From a439f15c5f240c85d4dc5a29a08dbdf88ac79d4c Mon Sep 17 00:00:00 2001 From: Yura Dupyn <2153100+omedusyo@users.noreply.github.com> Date: Mon, 6 Apr 2026 17:17:30 +0200 Subject: [PATCH 1/2] Introduce SourceRegion --- src/index.ts | 50 +++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 41 insertions(+), 9 deletions(-) diff --git a/src/index.ts b/src/index.ts index 21fc37e..ded5edc 100644 --- a/src/index.ts +++ b/src/index.ts @@ -97,6 +97,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; @@ -132,18 +148,29 @@ export function sourceText(s: string): SourceText { } 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; + } + + toString(): string { + return this.source.sliceByCp(this.span.start.index, this.span.end.index); + } + + // 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; end: SourceLocation; @@ -190,7 +217,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) From 8f1c35b9826d34b8fcf6f8484044d3d0d59d7a17 Mon Sep 17 00:00:00 2001 From: Yura Dupyn <2153100+omedusyo@users.noreply.github.com> Date: Mon, 6 Apr 2026 17:56:04 +0200 Subject: [PATCH 2/2] line counts, line iterators, line spans --- src/index.ts | 64 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/src/index.ts b/src/index.ts index ded5edc..25dc4a9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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. @@ -147,6 +176,11 @@ 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 { constructor( public readonly source: SourceText, @@ -157,10 +191,40 @@ export class SourceRegion { 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 { + 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 {