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
30 changes: 27 additions & 3 deletions _packages/api/test/async/astnav.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import { API } from "@typescript/api/async"; // @sync-skip
// @sync-only-end
import { createVirtualFileSystem } from "@typescript/api/fs";
import {
findNextToken,
findPrecedingToken,
formatSyntaxKind,
getTokenAtPosition,
getTouchingPropertyName,
Expand Down Expand Up @@ -117,12 +119,22 @@ describe("astnav", () => {
{
name: "getTokenAtPosition",
baselineFile: "GetTokenAtPosition.mapCode.ts.baseline.json",
fn: getTokenAtPosition,
fn: (sf: SourceFile, pos: number) => getTokenAtPosition(sf, pos) as Node | undefined,
},
{
name: "getTouchingPropertyName",
baselineFile: "GetTouchingPropertyName.mapCode.ts.baseline.json",
fn: getTouchingPropertyName,
fn: (sf: SourceFile, pos: number) => getTouchingPropertyName(sf, pos) as Node | undefined,
},
{
name: "findPrecedingToken",
baselineFile: "FindPrecedingToken.mapCode.ts.baseline.json",
fn: (sf: SourceFile, pos: number) => findPrecedingToken(sf, pos),
},
{
name: "findNextToken",
baselineFile: "FindNextToken.mapCode.ts.baseline.json",
fn: (sf: SourceFile, pos: number) => findNextToken(getTokenAtPosition(sf, pos), sf, sf),
},
];

Expand All @@ -135,11 +147,23 @@ describe("astnav", () => {
const failures: string[] = [];

for (let pos = 0; pos < fileText.length; pos++) {
const result = toTokenInfo(tc.fn(sourceFile, pos));
const node = tc.fn(sourceFile, pos);
const goExpected = expected.get(pos);

if (!goExpected) continue;

if (node === undefined) {
failures.push(
` pos ${pos}: expected ${goExpected.kind} [${goExpected.pos}, ${goExpected.end}), got undefined`,
);
if (failures.length >= 50) {
failures.push(" ... (truncated, too many failures)");
break;
}
continue;
}

const result = toTokenInfo(node);
if (result.kind !== goExpected.kind || result.pos !== goExpected.pos || result.end !== goExpected.end) {
failures.push(
` pos ${pos}: expected ${goExpected.kind} [${goExpected.pos}, ${goExpected.end}), ` +
Expand Down
30 changes: 27 additions & 3 deletions _packages/api/test/sync/astnav.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
import { createVirtualFileSystem } from "@typescript/api/fs";
import { API } from "@typescript/api/sync";
import {
findNextToken,
findPrecedingToken,
formatSyntaxKind,
getTokenAtPosition,
getTouchingPropertyName,
Expand Down Expand Up @@ -122,12 +124,22 @@ describe("astnav", () => {
{
name: "getTokenAtPosition",
baselineFile: "GetTokenAtPosition.mapCode.ts.baseline.json",
fn: getTokenAtPosition,
fn: (sf: SourceFile, pos: number) => getTokenAtPosition(sf, pos) as Node | undefined,
},
{
name: "getTouchingPropertyName",
baselineFile: "GetTouchingPropertyName.mapCode.ts.baseline.json",
fn: getTouchingPropertyName,
fn: (sf: SourceFile, pos: number) => getTouchingPropertyName(sf, pos) as Node | undefined,
},
{
name: "findPrecedingToken",
baselineFile: "FindPrecedingToken.mapCode.ts.baseline.json",
fn: (sf: SourceFile, pos: number) => findPrecedingToken(sf, pos),
},
{
name: "findNextToken",
baselineFile: "FindNextToken.mapCode.ts.baseline.json",
fn: (sf: SourceFile, pos: number) => findNextToken(getTokenAtPosition(sf, pos), sf, sf),
},
];

Expand All @@ -140,11 +152,23 @@ describe("astnav", () => {
const failures: string[] = [];

for (let pos = 0; pos < fileText.length; pos++) {
const result = toTokenInfo(tc.fn(sourceFile, pos));
const node = tc.fn(sourceFile, pos);
const goExpected = expected.get(pos);

if (!goExpected) continue;

if (node === undefined) {
failures.push(
` pos ${pos}: expected ${goExpected.kind} [${goExpected.pos}, ${goExpected.end}), got undefined`,
);
if (failures.length >= 50) {
failures.push(" ... (truncated, too many failures)");
break;
}
continue;
}

const result = toTokenInfo(node);
if (result.kind !== goExpected.kind || result.pos !== goExpected.pos || result.end !== goExpected.end) {
failures.push(
` pos ${pos}: expected ${goExpected.kind} [${goExpected.pos}, ${goExpected.end}), ` +
Expand Down
173 changes: 169 additions & 4 deletions _packages/ast/src/astnav.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,87 @@ export function getTouchingToken(sourceFile: SourceFile, position: number): Node
return getTokenAtPositionImpl(sourceFile, position, /*allowPositionInLeadingTrivia*/ false, /*includePrecedingTokenAtEndPosition*/ undefined);
}

/**
* Finds the token that starts immediately after `previousToken` ends, searching
* within `parent`. Returns `undefined` if no such token exists.
*/
export function findNextToken(previousToken: Node, parent: Node, sourceFile: SourceFile): Node | undefined {
return find(parent);

function find(n: Node): Node | undefined {
if (isTokenKind(n.kind) && n.pos === previousToken.end) {
// This is the token that starts at the end of previousToken – return it.
return n;
}

// Find the child node that contains `previousToken` or starts immediately after it.
let foundNode: Node | undefined;

const visitChild = (node: Node) => {
if (node.flags & NodeFlags.Reparsed) {
return undefined;
}
if (node.pos <= previousToken.end && node.end > previousToken.end) {
foundNode = node;
}
return undefined;
};

// Visit JSDoc children first (mirrors Go's VisitEachChildAndJSDoc).
if (n.jsDoc) {
for (const jsdoc of n.jsDoc) {
visitChild(jsdoc);
}
}

n.forEachChild(
visitChild,
nodes => {
if (nodes.length > 0 && foundNode === undefined) {
for (const node of nodes) {
if (node.flags & NodeFlags.Reparsed) continue;
if (node.pos > previousToken.end) break;
if (node.end > previousToken.end) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks a little different from Corsa. I believe this should be if (node.end > previousToken.pos) instead.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After careful analysis, the linear scan predicate node.end > previousToken.end is actually correct for the scanner-based approach. Changing it to node.end > previousToken.pos would cause nodes with the same span as previousToken to be selected, preventing the scanner fallback from firing and breaking comma-finding (e.g. findNextToken(Block_identifier, ...) would return nil instead of ,). The real fix was in the scanner fallback itself — using tokenFullStart instead of tokenStart — committed in a4777eb.

foundNode = node;
break;
}
}
Comment on lines +68 to +77
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the NodeArray path, the selection predicate differs from the Go port: it only considers nodes with node.end > previousToken.end. The Go implementation’s binary search matches any node overlapping the previousToken span (node.pos <= previousToken.End && node.end > previousToken.Pos), which matters when callers pass a non-token node (the Go API allows this) or when the relevant child ends exactly at previousToken.end. Consider mirroring the Go overlap predicate (and ideally the binary-search approach) to avoid missing the correct subtree.

Copilot uses AI. Check for mistakes.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the only difference I see as well. Rest all looks good.

}
return undefined;
},
);
Comment on lines +59 to +81
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

findNextToken doesn’t apply the JSDoc single-comment NodeList skipping that other navigation code in this file uses (see the isJSDocSingleCommentNodeList/isJSDocCommentChildKind helpers used by getTokenAtPositionImpl). The Go VisitEachChildAndJSDoc path also explicitly skips these. Without the same skip here, the descent target can differ (especially inside JSDoc), which can change results vs the intended port.

Copilot uses AI. Check for mistakes.

// Recurse into the found child.
if (foundNode !== undefined) {
return find(foundNode);
}

// No AST child covers the position; use the scanner to find the syntactic token.
// The scanner is initialized at `previousToken.end`, so tokenFullStart === previousToken.end.
const startPos = previousToken.end;
if (startPos >= n.pos && startPos < n.end) {
const scanner = getScannerForSourceFile(sourceFile, startPos);
const token = scanner.getToken();
const tokenFullStart = scanner.getTokenFullStart();
const tokenEnd = scanner.getTokenEnd();
const flags = scanner.getTokenFlags();
return getOrCreateToken(sourceFile, token, tokenFullStart, tokenEnd, n, flags);
}

return undefined;
}
}

/**
* Finds the leftmost token satisfying `position < token.end`.
* If the position is in the trivia of that leftmost token, or the token is invalid,
* returns the rightmost valid token with `token.end <= position`.
* Excludes `JsxText` tokens containing only whitespace.
*/
export function findPrecedingToken(sourceFile: SourceFile, position: number): Node | undefined {
return findPrecedingTokenImpl(sourceFile, position, sourceFile);
}

function getTokenAtPositionImpl(
sourceFile: SourceFile,
position: number,
Expand Down Expand Up @@ -244,11 +325,29 @@ function findPrecedingTokenImpl(sourceFile: SourceFile, position: number, startN
let foundChild: Node | undefined;
let prevChild: Node | undefined;

// Visit JSDoc nodes first (mirrors Go's VisitEachChildAndJSDoc).
if (n.jsDoc) {
for (const jsdoc of n.jsDoc) {
if (jsdoc.flags & NodeFlags.Reparsed) continue;
if (foundChild !== undefined) break;
if (position < jsdoc.end && (prevChild === undefined || prevChild.end <= position)) {
foundChild = jsdoc;
}
else {
prevChild = jsdoc;
}
}
}

let skipSingleCommentChildrenImpl = false;
n.forEachChild(
node => {
if (node.flags & NodeFlags.Reparsed) {
return undefined;
}
if (skipSingleCommentChildrenImpl && isJSDocCommentChildKind(node.kind)) {
return undefined;
}
if (foundChild !== undefined) {
return undefined;
}
Expand All @@ -261,10 +360,11 @@ function findPrecedingTokenImpl(sourceFile: SourceFile, position: number, startN
return undefined;
},
nodes => {
skipSingleCommentChildrenImpl = isJSDocSingleCommentNodeList(nodes);
if (foundChild !== undefined) {
return undefined;
}
if (nodes.length > 0) {
if (nodes.length > 0 && !skipSingleCommentChildrenImpl) {
const index = binarySearchForPrecedingToken(nodes, position);
if (index >= 0 && !(nodes[index].flags & NodeFlags.Reparsed)) {
foundChild = nodes[index];
Expand All @@ -284,9 +384,29 @@ function findPrecedingTokenImpl(sourceFile: SourceFile, position: number, startN
);

if (foundChild !== undefined) {
const start = getTokenPosOfNode(foundChild, sourceFile);
const start = getTokenPosOfNode(foundChild, sourceFile, /*includeJSDoc*/ true);
if (start >= position) {
// cursor in leading trivia; find rightmost valid token in prevChild
if (position >= foundChild.pos) {
// We are in the leading trivia of foundChild. Check for JSDoc nodes of n
// preceding foundChild, mirroring Go's findPrecedingToken logic.
let jsDoc: Node | undefined;
if (n.jsDoc) {
for (let i = n.jsDoc.length - 1; i >= 0; i--) {
if (n.jsDoc[i].pos >= foundChild.pos) {
jsDoc = n.jsDoc[i];
break;
}
}
}
if (jsDoc !== undefined) {
if (position < jsDoc.end) {
return find(jsDoc);
}
return findRightmostValidToken(sourceFile, jsDoc.end, n, position);
}
return findRightmostValidToken(sourceFile, foundChild.pos, n, -1);
}
// Answer is in tokens between two visited children.
return findRightmostValidToken(sourceFile, foundChild.pos, n, position);
}
return find(foundChild);
Expand Down Expand Up @@ -314,11 +434,27 @@ function findRightmostValidToken(sourceFile: SourceFile, endPos: number, contain
let rightmostValidNode: Node | undefined;
let hasChildren = false;

// Visit JSDoc nodes first (mirrors Go's VisitEachChildAndJSDoc).
if (n.jsDoc) {
hasChildren = true;
for (const jsdoc of n.jsDoc) {
if (jsdoc.flags & NodeFlags.Reparsed) continue;
if (jsdoc.end > endPos || getTokenPosOfNode(jsdoc, sourceFile) >= position) continue;
if (isValidPrecedingNode(jsdoc, sourceFile)) {
rightmostValidNode = jsdoc;
}
}
}

let skipSingleCommentChildren = false;
n.forEachChild(
node => {
if (node.flags & NodeFlags.Reparsed) {
return undefined;
}
if (skipSingleCommentChildren && isJSDocCommentChildKind(node.kind)) {
return undefined;
}
hasChildren = true;
if (node.end > endPos || getTokenPosOfNode(node, sourceFile) >= position) {
return undefined;
Expand All @@ -329,7 +465,10 @@ function findRightmostValidToken(sourceFile: SourceFile, endPos: number, contain
return undefined;
},
nodes => {
if (nodes.length > 0) {
// Skip single-comment JSDoc NodeLists (e.g. JSDocText children of a JSDoc node):
// In Go, these are stored as string properties and are never visited as children.
skipSingleCommentChildren = isJSDocSingleCommentNodeList(nodes);
if (nodes.length > 0 && !skipSingleCommentChildren) {
hasChildren = true;
for (let i = nodes.length - 1; i >= 0; i--) {
const node = nodes[i];
Expand All @@ -345,6 +484,32 @@ function findRightmostValidToken(sourceFile: SourceFile, endPos: number, contain
},
);

// Scan for syntactic tokens (e.g. `{`, `,`) between AST nodes, matching Go's
// findRightmostValidToken scanner step.
if (!shouldSkipChild(n)) {
const startPos = rightmostValidNode !== undefined ? rightmostValidNode.end : n.pos;
const targetEnd = Math.min(endPos, position);
if (startPos < targetEnd) {
const scanner = getScannerForSourceFile(sourceFile, startPos);
let pos = startPos;
let lastScannedToken: Node | undefined;
while (pos < targetEnd) {
const tokenStart = scanner.getTokenStart();
if (tokenStart >= position) break;
const tokenFullStart = scanner.getTokenFullStart();
const tokenEnd = scanner.getTokenEnd();
const token = scanner.getToken();
const flags = scanner.getTokenFlags();
lastScannedToken = getOrCreateToken(sourceFile, token, tokenFullStart, tokenEnd, n, flags);
pos = tokenEnd;
scanner.scan();
}
if (lastScannedToken !== undefined) {
return lastScannedToken;
}
}
}

if (!hasChildren) {
if (n !== containingNode) {
return n;
Expand Down
29 changes: 29 additions & 0 deletions internal/astnav/tokens_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -476,6 +476,35 @@ func TestFindPrecedingToken(t *testing.T) {
},
)
})

t.Run("go baseline json", func(t *testing.T) {
t.Parallel()
baselineGoTokensJSON(t, "FindPrecedingToken", func(file *ast.SourceFile, pos int) *tokenInfo {
return toTokenInfo(astnav.FindPrecedingToken(file, pos))
})
})
}

func TestFindNextToken(t *testing.T) {
t.Parallel()
repo.SkipIfNoTypeScriptSubmodule(t)

t.Run("go baseline json", func(t *testing.T) {
t.Parallel()
baselineGoTokensJSON(t, "FindNextToken", func(file *ast.SourceFile, pos int) (result *tokenInfo) {
// FindNextToken panics (like Go's assert) when the scanner finds trivia between
// previousToken.End() and the next syntactic token. Catch those to avoid crashing
// the baseline generator; those positions will be absent from the baseline.
defer func() {
if r := recover(); r != nil {
result = nil
}
}()
token := astnav.GetTokenAtPosition(file, pos)
next := astnav.FindNextToken(token, file.AsNode(), file)
return toTokenInfo(next)
})
})
}

func TestUnitFindPrecedingToken(t *testing.T) {
Expand Down
Loading