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] 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 {