Skip to content

Commit 2ad9453

Browse files
Copilotjakebaileyandrewbranch
authored
Fix panic in getTokenAtPosition for JSDoc type assertions (#1846)
Co-authored-by: copilot-swe-agent[bot] <[email protected]> Co-authored-by: jakebailey <[email protected]> Co-authored-by: andrewbranch <[email protected]> Co-authored-by: Andrew Branch <[email protected]>
1 parent 6183abf commit 2ad9453

File tree

2 files changed

+79
-12
lines changed

2 files changed

+79
-12
lines changed

internal/astnav/tokens.go

Lines changed: 33 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,10 @@ func getTokenAtPosition(
6565
// `left` tracks the lower boundary of the node/token that could be returned,
6666
// and is eventually the scanner's start position, if the scanner is used.
6767
left := 0
68+
// `allowReparsed` is set when we're navigating inside an AsExpression or
69+
// SatisfiesExpression, which allows visiting their reparsed children to reach
70+
// the actual identifier from JSDoc type assertions.
71+
allowReparsed := false
6872

6973
testNode := func(node *ast.Node) int {
7074
if node.Kind != ast.KindEndOfFile && node.End() == position && includePrecedingTokenAtEndPosition != nil {
@@ -74,7 +78,8 @@ func getTokenAtPosition(
7478
if node.End() < position || node.Kind != ast.KindEndOfFile && node.End() == position {
7579
return -1
7680
}
77-
if getPosition(node, sourceFile, allowPositionInLeadingTrivia) > position {
81+
nodePos := getPosition(node, sourceFile, allowPositionInLeadingTrivia)
82+
if nodePos > position {
7883
return 1
7984
}
8085
return 0
@@ -86,18 +91,29 @@ func getTokenAtPosition(
8691
visitNode := func(node *ast.Node, _ *ast.NodeVisitor) *ast.Node {
8792
// We can't abort visiting children, so once a match is found, we set `next`
8893
// and do nothing on subsequent visits.
89-
if node != nil && node.Flags&ast.NodeFlagsReparsed == 0 && next == nil {
90-
switch testNode(node) {
91-
case -1:
92-
if !ast.IsJSDocKind(node.Kind) {
93-
// We can't move the left boundary into or beyond JSDoc,
94-
// because we may end up returning the token after this JSDoc,
95-
// constructing it with the scanner, and we need to include
96-
// all its leading trivia in its position.
97-
left = node.End()
94+
if node != nil && next == nil {
95+
// Skip reparsed nodes unless:
96+
// 1. The node itself is AsExpression or SatisfiesExpression, OR
97+
// 2. We're already inside an AsExpression or SatisfiesExpression (allowReparsed=true)
98+
// These are special cases where reparsed nodes from JSDoc type assertions
99+
// should still be navigable to reach identifiers.
100+
isSpecialReparsed := node.Flags&ast.NodeFlagsReparsed != 0 &&
101+
(node.Kind == ast.KindAsExpression || node.Kind == ast.KindSatisfiesExpression)
102+
103+
if node.Flags&ast.NodeFlagsReparsed == 0 || isSpecialReparsed || allowReparsed {
104+
result := testNode(node)
105+
switch result {
106+
case -1:
107+
if !ast.IsJSDocKind(node.Kind) {
108+
// We can't move the left boundary into or beyond JSDoc,
109+
// because we may end up returning the token after this JSDoc,
110+
// constructing it with the scanner, and we need to include
111+
// all its leading trivia in its position.
112+
left = node.End()
113+
}
114+
case 0:
115+
next = node
98116
}
99-
case 0:
100-
next = node
101117
}
102118
}
103119
return node
@@ -194,6 +210,11 @@ func getTokenAtPosition(
194210
current = next
195211
left = current.Pos()
196212
next = nil
213+
// When navigating into AsExpression or SatisfiesExpression, allow visiting
214+
// their reparsed children to reach identifiers from JSDoc type assertions.
215+
if current.Kind == ast.KindAsExpression || current.Kind == ast.KindSatisfiesExpression {
216+
allowReparsed = true
217+
}
197218
}
198219
}
199220

internal/astnav/tokens_test.go

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,52 @@ func TestGetTokenAtPosition(t *testing.T) {
4343
)
4444
})
4545

46+
t.Run("JSDoc type assertion", func(t *testing.T) {
47+
t.Parallel()
48+
fileText := `function foo(x) {
49+
const s = /**@type {string}*/(x)
50+
}`
51+
file := parser.ParseSourceFile(ast.SourceFileParseOptions{
52+
FileName: "/test.js",
53+
Path: "/test.js",
54+
}, fileText, core.ScriptKindJS)
55+
56+
// Position of 'x' inside the parenthesized expression (position 52)
57+
position := 52
58+
59+
// This should not panic - it previously panicked with:
60+
// "did not expect KindParenthesizedExpression to have KindIdentifier in its trivia"
61+
token := astnav.GetTouchingPropertyName(file, position)
62+
if token == nil {
63+
t.Fatal("Expected to get a token, got nil")
64+
}
65+
66+
// The function may return either the identifier itself or the containing
67+
// parenthesized expression, depending on how the AST is structured
68+
if token.Kind != ast.KindIdentifier && token.Kind != ast.KindParenthesizedExpression {
69+
t.Errorf("Expected identifier or parenthesized expression, got %s", token.Kind)
70+
}
71+
})
72+
73+
t.Run("JSDoc type assertion with comment", func(t *testing.T) {
74+
t.Parallel()
75+
// Exact code from the issue report
76+
fileText := `function foo(x) {
77+
const s = /**@type {string}*/(x) // Go-to-definition on x causes panic
78+
}`
79+
file := parser.ParseSourceFile(ast.SourceFileParseOptions{
80+
FileName: "/test.js",
81+
Path: "/test.js",
82+
}, fileText, core.ScriptKindJS)
83+
84+
// Find position of 'x' in the type assertion
85+
xPos := 52 // Position of 'x' in (x)
86+
87+
// This should not panic
88+
token := astnav.GetTouchingPropertyName(file, xPos)
89+
assert.Assert(t, token != nil, "Expected to get a token")
90+
})
91+
4692
t.Run("pointer equality", func(t *testing.T) {
4793
t.Parallel()
4894
fileText := `

0 commit comments

Comments
 (0)