diff --git a/_packages/native-preview/src/api/node/node.ts b/_packages/native-preview/src/api/node/node.ts index 9e176b5a601..03c505b618e 100644 --- a/_packages/native-preview/src/api/node/node.ts +++ b/_packages/native-preview/src/api/node/node.ts @@ -1,5 +1,7 @@ import { + computeLineStarts, type FileReference, + type LineAndCharacter, type Node, NodeFlags, type Path, @@ -47,6 +49,8 @@ export class RemoteSourceFile extends RemoteNode implements SourceFileInfo { readonly _decoder: TextDecoder; private _cachedText: string | undefined; + private _lineStarts: readonly number[] | undefined; + constructor(data: Uint8Array, decoder: TextDecoder) { const view = new DataView(data.buffer, data.byteOffset, data.byteLength); const offsetNodes = view.getUint32(HEADER_OFFSET_NODES, true); @@ -206,6 +210,49 @@ export class RemoteSourceFile extends RemoteNode implements SourceFileInfo { this._cachedText = text; return text; } + + // ═══ Line/character position mapping ═══ + + getLineStarts(): readonly number[] { + return this._lineStarts ??= computeLineStarts(this.text ?? ""); + } + + getLineAndCharacterOfPosition(position: number): LineAndCharacter { + const lineStarts = this.getLineStarts(); + const line = computeLineOfPosition(lineStarts, position); + return { line, character: position - lineStarts[line] }; + } + + getPositionOfLineAndCharacter(line: number, character: number): number { + const lineStarts = this.getLineStarts(); + if (line < 0 || line >= lineStarts.length) { + throw new Error(`Bad line number. Line: ${line}, lineStarts.length: ${lineStarts.length}`); + } + return lineStarts[line] + character; + } +} + +/** + * Find the 0-based line number containing the given position via binary search. + * Assumes the first line starts at position 0 and `position` is non-negative. + */ +function computeLineOfPosition(lineStarts: readonly number[], position: number): number { + let low = 0; + let high = lineStarts.length - 1; + while (low <= high) { + const middle = low + ((high - low) >> 1); + const value = lineStarts[middle]; + if (value < position) { + low = middle + 1; + } + else if (value > position) { + high = middle - 1; + } + else { + return middle; + } + } + return low - 1; } /** diff --git a/_packages/native-preview/src/ast/ast.ts b/_packages/native-preview/src/ast/ast.ts index c12a7953e4e..19839e7906e 100644 --- a/_packages/native-preview/src/ast/ast.ts +++ b/_packages/native-preview/src/ast/ast.ts @@ -64,6 +64,13 @@ export interface FileReference extends TextRange { readonly preserve: boolean; } +export interface LineAndCharacter { + /** 0-based line number. */ + readonly line: number; + /** 0-based character offset, in UTF-16 code units, from the start of the line. */ + readonly character: number; +} + export interface SourceFile extends Node { readonly kind: SyntaxKind.SourceFile; readonly statements: NodeArray; @@ -81,6 +88,12 @@ export interface SourceFile extends Node { readonly moduleAugmentations: readonly Node[]; readonly ambientModuleNames: readonly string[]; readonly externalModuleIndicator: Node | true | undefined; + /** Returns the UTF-16 code unit offset of the start of each line. */ + getLineStarts(): readonly number[]; + /** Converts a UTF-16 code unit position into a 0-based line and character. */ + getLineAndCharacterOfPosition(position: number): LineAndCharacter; + /** Converts a 0-based line and character into a UTF-16 code unit position. */ + getPositionOfLineAndCharacter(line: number, character: number): number; /** @internal */ tokenCache?: Map; } diff --git a/_packages/native-preview/test/encoder.test.ts b/_packages/native-preview/test/encoder.test.ts index d3c81ae6046..23fdd653f79 100644 --- a/_packages/native-preview/test/encoder.test.ts +++ b/_packages/native-preview/test/encoder.test.ts @@ -330,3 +330,63 @@ describe("UTF-8 vs UTF-16 position encoding", () => { assert.strictEqual(end - pos, 4); // UTF-16 length, not UTF-8 byte length (5) }); }); + +describe("Line and character mapping", () => { + function makeSourceFile(text: string): RemoteSourceFile { + return decode(encodeSourceFile(makeSF(text, "/test.ts", []))); + } + + test("getLineStarts for empty file", () => { + const sf = makeSourceFile(""); + assert.deepStrictEqual(sf.getLineStarts(), [0]); + }); + + test("getLineStarts for LF-separated lines", () => { + const sf = makeSourceFile("a\nb\nc"); + assert.deepStrictEqual(sf.getLineStarts(), [0, 2, 4]); + }); + + test("getLineStarts for CRLF-separated lines", () => { + const sf = makeSourceFile("a\r\nbb\r\nc"); + assert.deepStrictEqual(sf.getLineStarts(), [0, 3, 7]); + }); + + test("getLineStarts is cached", () => { + const sf = makeSourceFile("a\nb"); + assert.strictEqual(sf.getLineStarts(), sf.getLineStarts()); + }); + + test("getLineAndCharacterOfPosition", () => { + const sf = makeSourceFile("ab\ncde\nf"); + assert.deepStrictEqual(sf.getLineAndCharacterOfPosition(0), { line: 0, character: 0 }); + assert.deepStrictEqual(sf.getLineAndCharacterOfPosition(2), { line: 0, character: 2 }); + assert.deepStrictEqual(sf.getLineAndCharacterOfPosition(3), { line: 1, character: 0 }); + assert.deepStrictEqual(sf.getLineAndCharacterOfPosition(6), { line: 1, character: 3 }); + assert.deepStrictEqual(sf.getLineAndCharacterOfPosition(7), { line: 2, character: 0 }); + }); + + test("getLineAndCharacterOfPosition uses UTF-16 code units", () => { + // 🎉 (U+1F389) is a surrogate pair: 2 UTF-16 code units. + const source = "🎉a\nb"; + const sf = makeSourceFile(source); + // "a" is at UTF-16 offset 2, on line 0 + assert.deepStrictEqual(sf.getLineAndCharacterOfPosition(2), { line: 0, character: 2 }); + // "b" is at UTF-16 offset 4 (after "🎉a\n"), on line 1 + assert.strictEqual(source.indexOf("b"), 4); + assert.deepStrictEqual(sf.getLineAndCharacterOfPosition(4), { line: 1, character: 0 }); + }); + + test("getPositionOfLineAndCharacter round-trips", () => { + const sf = makeSourceFile("ab\ncde\nf"); + for (const pos of [0, 1, 2, 3, 5, 6, 7]) { + const lc = sf.getLineAndCharacterOfPosition(pos); + assert.strictEqual(sf.getPositionOfLineAndCharacter(lc.line, lc.character), pos); + } + }); + + test("getPositionOfLineAndCharacter throws for out-of-range line", () => { + const sf = makeSourceFile("a\nb"); + assert.throws(() => sf.getPositionOfLineAndCharacter(5, 0), /Bad line number/); + assert.throws(() => sf.getPositionOfLineAndCharacter(-1, 0), /Bad line number/); + }); +});