Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions _packages/native-preview/src/api/node/node.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import {
computeLineStarts,
type FileReference,
type LineAndCharacter,
type Node,
NodeFlags,
type Path,
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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;
}

/**
Expand Down
13 changes: 13 additions & 0 deletions _packages/native-preview/src/ast/ast.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Statement>;
Expand All @@ -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<string, Node>;
}
Expand Down
60 changes: 60 additions & 0 deletions _packages/native-preview/test/encoder.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/);
});
});
Loading