Compare commits

...

2 commits

Author SHA1 Message Date
Yura Dupyn
8f1c35b982 line counts, line iterators, line spans 2026-04-06 17:56:04 +02:00
Yura Dupyn
a439f15c5f Introduce SourceRegion 2026-04-06 17:17:30 +02:00

View file

@ -61,6 +61,10 @@ export class SourceText {
return this.chars.length;
}
get lineCount(): number {
return this.lineStarts.length;
}
sliceByCp(start: number, end: number): string {
const startRef = this.chars[start];
// Handle out of bounds gracefully
@ -74,6 +78,31 @@ export class SourceText {
return this.source.slice(startOff, endOff);
}
// Returns a Span for the given line (1-based index).
// If stripNewlines is true, the span will exclude trailing \r\n.
getLineSpan(line: number, stripNewlines = true): Span {
const range = this.getLineRange(line);
let endIdx = range.end;
if (stripNewlines && endIdx > range.start) {
// Look at the character just before endIdx
const lastChar = this.chars[endIdx - 1].char;
if (lastChar === NEW_LINE) {
endIdx--;
if (endIdx > range.start && this.chars[endIdx - 1].char === CARRIAGE_RETURN) {
endIdx--;
}
} else if (lastChar === CARRIAGE_RETURN) {
endIdx--;
}
}
return {
start: this.getLocation(range.start),
end: this.getLocation(endIdx)
};
}
// Converts a linear Code Point Index into a SourceLocation (line, column, index).
getLocation(index: CodePointIndex): SourceLocation {
// Does binary search.
@ -97,6 +126,22 @@ export class SourceText {
return { index, line, column };
}
// Creates a SourceRegion from a Span.
makeRegion(span: Span): SourceRegion {
// Basic validation
if (span.start.index < 0 || span.end.index > this.length) {
throw new Error(`Span out of bounds: ${span.start.index}-${span.end.index} (length: ${this.length})`);
}
return new SourceRegion(this, span);
}
// Creates a SourceRegion covering the entire SourceText.
fullRegion(): SourceRegion {
const start = this.getLocation(0);
const end = this.getLocation(this.length);
return this.makeRegion({ start, end });
}
// Returns the full text of a specific line (1-based index)
getLineText(line: number): string {
const lineIndex = line - 1;
@ -131,18 +176,64 @@ export function sourceText(s: string): SourceText {
return new SourceText(s);
}
// Creates a Span from two SourceLocations.
export function span(start: SourceLocation, end: SourceLocation): Span {
return { start, end };
}
export class SourceRegion {
// TODO
readonly sourceText: SourceText;
readonly span: Span;
constructor(
public readonly source: SourceText,
public readonly span: Span
) {}
constructor(sourceText: SourceText, span: Span) {
this.sourceText = sourceText;
// TODO: What about span validation?
this.span = span;
get length(): number {
return this.span.end.index - this.span.start.index;
}
get lineCount(): number {
return this.span.end.line - this.span.start.line + 1;
}
toString(): string {
return this.source.sliceByCp(this.span.start.index, this.span.end.index);
}
// Returns a Span for the given line (1-based index).
getLineSpan(line: number, stripNewlines = true): Span {
if (line < this.span.start.line || line > this.span.end.line) {
throw new Error(`Line ${line} is outside of region lines ${this.span.start.line}-${this.span.end.line}`);
}
return this.source.getLineSpan(line, stripNewlines);
}
// Iterates over all lines that intersect this region.
// Yields a Span for each line.
*lines(stripNewlines = true): IterableIterator<Span> {
const startLine = this.span.start.line;
const endLine = this.span.end.line;
for (let currentLine = startLine; currentLine <= endLine; currentLine++) {
yield this.getLineSpan(currentLine, stripNewlines);
}
}
forEachLine(callback: (span: Span, lineNo: number) => void, stripNewlines = true): void {
let lineNo = this.span.start.line;
for (const lineSpan of this.lines(stripNewlines)) {
callback(lineSpan, lineNo++);
}
}
// Creates a sub-region within this region.
// Validates that the new span is contained within the current region.
subRegion(span: Span): SourceRegion {
if (span.start.index < this.span.start.index || span.end.index > this.span.end.index) {
throw new Error(`Sub-region span ${span.start.index}-${span.end.index} is not within parent region ${this.span.start.index}-${this.span.end.index}`);
}
return this.source.makeRegion(span);
}
}
export type Span = {
start: SourceLocation;
@ -190,7 +281,12 @@ export type LineView = {
underline: string; // The literal "^^^" string for CLI usage
};
export function renderSpan(text: SourceText, span: Span, contextLines = 1): LineView[] {
export function renderRegion(region: SourceRegion, contextLines = 1): LineView[] {
return renderSpan(region, region.span, contextLines);
}
export function renderSpan(region: SourceRegion, span: Span, contextLines = 1): LineView[] {
const text = region.source;
const views: LineView[] = [];
// Determine range of lines to show (including context)