|
| 1 | +# Investigation: JSDoc Type Assertion Panic in getTokenAtPosition |
| 2 | + |
| 3 | +## Problem Statement |
| 4 | + |
| 5 | +The `getTokenAtPosition` function panics with the message: |
| 6 | +``` |
| 7 | +panic: did not expect KindParenthesizedExpression to have KindIdentifier in its trivia |
| 8 | +``` |
| 9 | + |
| 10 | +When processing code like: |
| 11 | +```javascript |
| 12 | +function foo(x) { |
| 13 | + const s = /**@type {string}*/(x) // Go-to-definition on x causes panic |
| 14 | +} |
| 15 | +``` |
| 16 | + |
| 17 | +## Initial (Incorrect) Fix |
| 18 | + |
| 19 | +My initial fix simply removed the panic and returned `current` when an identifier was found during trivia scanning. However, @andrewbranch correctly pointed out that: |
| 20 | +- Identifiers are NEVER allowed in the trivia of nodes |
| 21 | +- JSDoc nodes should be fully-formed subtrees with their identifiers accounted for in the AST |
| 22 | +- The panic is there for a reason and should not be removed |
| 23 | + |
| 24 | +## Investigation Plan |
| 25 | + |
| 26 | +1. Understand how JSDoc nodes are structured in the AST |
| 27 | +2. Compare working JSDoc cases (that don't panic) with the failing JSDoc type assertion case |
| 28 | +3. Identify why the identifier is appearing in trivia for type assertions but not other JSDoc constructs |
| 29 | +4. Find the root cause and proper fix |
| 30 | + |
| 31 | +## Step 1: Understanding the AST Structure |
| 32 | + |
| 33 | +From the test output, here's what I observed: |
| 34 | + |
| 35 | +### Case 1: JSDoc Type Assertion (The Failing Case) |
| 36 | +``` |
| 37 | +KindParenthesizedExpression [31..54) Flags=0x210000 |
| 38 | + JSDoc: KindJSDoc [31..51) |
| 39 | + KindAsExpression [52..53) |
| 40 | + KindIdentifier [52..53) |
| 41 | + KindStringKeyword [42..48) |
| 42 | +``` |
| 43 | + |
| 44 | +Key observations: |
| 45 | +- The `ParenthesizedExpression` has the `HasJSDoc` flag (0x200000 in Flags=0x210000) |
| 46 | +- The JSDoc is at [31..51), which includes the `/**@type {string}*/` part |
| 47 | +- The JSDoc is attached as metadata to the `ParenthesizedExpression` |
| 48 | +- The `AsExpression` starts at position 52 (the identifier `x`) |
| 49 | +- The `StringKeyword` at [42..48) is from inside the JSDoc comment |
| 50 | + |
| 51 | +### Case 2: Regular JSDoc Comment (Works Fine) |
| 52 | +``` |
| 53 | +KindFunctionDeclaration [0..62) Flags=0x210000 |
| 54 | + JSDoc: KindJSDoc [0..28) |
| 55 | + KindIdentifier [37..41) |
| 56 | + KindParameter [42..43) |
| 57 | + KindIdentifier [42..43) |
| 58 | + KindStringKeyword [15..21) |
| 59 | +``` |
| 60 | + |
| 61 | +Key observations: |
| 62 | +- The JSDoc is properly attached to the `FunctionDeclaration` |
| 63 | +- The `StringKeyword` at [15..21) is also from the JSDoc |
| 64 | +- The parameter identifier at [42..43) is a separate node |
| 65 | + |
| 66 | +## Step 2: Identifying the Problem |
| 67 | + |
| 68 | +**ROOT CAUSE FOUND!** |
| 69 | + |
| 70 | +When `getTokenAtPosition` is called with position 52 (the identifier `x`): |
| 71 | + |
| 72 | +1. It starts at `ParenthesizedExpression` [31..54) |
| 73 | +2. Calls `VisitEachChildAndJSDoc`, which: |
| 74 | + - First visits JSDoc children if `HasJSDoc` flag is set (the JSDoc is at [31..51), doesn't contain position 52) |
| 75 | + - Then visits regular children via `VisitEachChild` (the `AsExpression` at [52..53)) |
| 76 | +3. The `AsExpression` IS visited, and `testNode` would return 0 (match) |
| 77 | +4. **BUT** - the `visitNode` function has this check: |
| 78 | + ```go |
| 79 | + if node != nil && node.Flags&ast.NodeFlagsReparsed == 0 && next == nil { |
| 80 | + ``` |
| 81 | +5. The `AsExpression` has Flags=0x10008, which includes `NodeFlagsReparsed` (0x8) |
| 82 | +6. Because the Reparsed flag is set, `visitNode` **skips the AsExpression entirely**! |
| 83 | +7. Since no child is found, the function falls back to using the scanner |
| 84 | +8. The scanner scans from `left` (which is after the JSDoc) and encounters the identifier `x` |
| 85 | +9. The code panics because it doesn't expect an identifier in trivia |
| 86 | +
|
| 87 | +## Step 3: Understanding "Reparsed" Nodes |
| 88 | +
|
| 89 | +The `NodeFlagsReparsed` flag is used to mark nodes that are created from reparsing JSDoc comments. When a JSDoc type annotation like `/**@type {string}*/` is encountered, it gets "reparsed" into proper AST nodes (in this case, an `AsExpression`). |
| 90 | +
|
| 91 | +The problem is that `getTokenAtPosition` explicitly skips reparsed nodes during traversal: |
| 92 | +```go |
| 93 | +if node != nil && node.Flags&ast.NodeFlagsReparsed == 0 && next == nil { |
| 94 | +``` |
| 95 | +
|
| 96 | +This is intentional - reparsed nodes represent synthetic AST nodes created from JSDoc, and their positions overlap with the JSDoc comment text. The code is designed to skip them to avoid confusion. |
| 97 | +
|
| 98 | +However, in the case of JSDoc type assertions, the `AsExpression` is the ONLY child of the `ParenthesizedExpression` (besides the JSDoc itself). When it's skipped, there's no other child to navigate to, so the scanner kicks in and finds the identifier. |
| 99 | +
|
| 100 | +## Step 4: Comparing with Working JSDoc Cases |
| 101 | +
|
| 102 | +Let me check how other JSDoc cases work: |
| 103 | +
|
| 104 | +**Case 2: Regular @param tag** |
| 105 | +``` |
| 106 | +KindFunctionDeclaration [0..62) |
| 107 | + JSDoc: KindJSDoc [0..28) |
| 108 | + KindParameter [42..43) |
| 109 | + KindIdentifier [42..43) |
| 110 | + KindStringKeyword [15..21) <-- Reparsed node, but it's inside the Parameter |
| 111 | +``` |
| 112 | +
|
| 113 | +In this case, the `StringKeyword` (the type from JSDoc) is a child of the `Parameter` node. When we navigate to the Parameter node, we have a real, non-reparsed identifier at [42..43) that we can find. The reparsed `StringKeyword` is a sibling, not the only path to the identifier. |
| 114 | +
|
| 115 | +**Case 1: JSDoc type assertion (failing)** |
| 116 | +``` |
| 117 | +KindParenthesizedExpression [31..54) |
| 118 | + JSDoc: KindJSDoc [31..51) |
| 119 | + KindAsExpression [52..53) <-- Reparsed node, ONLY child |
| 120 | + KindIdentifier [52..53) |
| 121 | +``` |
| 122 | +
|
| 123 | +In this case, the `AsExpression` IS the only child (besides the JSDoc). When it's skipped, there's NO other path to the identifier. |
| 124 | +
|
| 125 | +## Step 5: The Solution |
| 126 | +
|
| 127 | +The issue is that when navigating into a JSDoc type assertion, we need to be able to navigate through reparsed nodes to reach the actual identifiers within them. The current code correctly skips reparsed nodes to avoid returning positions within JSDoc comments, but it doesn't handle the case where the reparsed node is the ONLY way to reach a real token. |
| 128 | + |
| 129 | +Looking at `visitNodeList`, there's already special handling for reparsed nodes: |
| 130 | +```go |
| 131 | +if match && nodes[index].Flags&ast.NodeFlagsReparsed != 0 { |
| 132 | + // filter and search again |
| 133 | + nodes = core.Filter(nodes, func(node *ast.Node) bool { |
| 134 | + return node.Flags&ast.NodeFlagsReparsed == 0 |
| 135 | + }) |
| 136 | + // ... search again without reparsed nodes |
| 137 | +} |
| 138 | +``` |
| 139 | +
|
| 140 | +The problem is that `visitNode` (used for single children) doesn't have this fallback logic. It just skips reparsed nodes unconditionally. |
| 141 | + |
| 142 | +**The Fix:** Modify `visitNode` to match the behavior of `visitNodeList`. When a reparsed node is encountered that would match the position, we should still process it if there are no non-reparsed alternatives. This allows navigation through JSDoc type assertions while maintaining the invariant that identifiers never appear in trivia. |
| 143 | + |
| 144 | +Specifically, the fix is to: |
| 145 | +1. When `visitNode` encounters a reparsed node that matches, don't immediately skip it - instead, save it in a `reparsedNext` variable |
| 146 | +2. After visiting all children, if no non-reparsed match was found (`next == nil`), use the reparsed match |
| 147 | +3. Reset `reparsedNext` at the end of each iteration |
| 148 | +
|
| 149 | +This way: |
| 150 | +- Normal cases continue to prefer non-reparsed nodes (e.g., when a parameter has both a JSDoc type and a real identifier) |
| 151 | +- JSDoc type assertion cases can navigate through the reparsed AsExpression to reach the identifier |
| 152 | +- The invariant is maintained: we never return an identifier found in trivia |
| 153 | +
|
| 154 | +## Implementation |
| 155 | +
|
| 156 | +The changes made to `internal/astnav/tokens.go`: |
| 157 | +
|
| 158 | +1. Added `reparsedNext` variable to track reparsed node matches as a fallback |
| 159 | +2. Modified `visitNode` to: |
| 160 | + - Still test reparsed nodes |
| 161 | + - Save matching reparsed nodes in `reparsedNext` instead of skipping them entirely |
| 162 | + - Prefer non-reparsed nodes by only setting `next` for non-reparsed matches |
| 163 | +3. Added logic after `VisitEachChildAndJSDoc` to use `reparsedNext` if `next` is nil |
| 164 | +4. Reset both `next` and `reparsedNext` at the end of each loop iteration |
| 165 | +
|
| 166 | +This maintains backward compatibility while fixing the JSDoc type assertion panic. The panic condition is kept intact - identifiers should never appear in trivia. |
| 167 | +
|
| 168 | +## Testing |
| 169 | +
|
| 170 | +All existing tests pass, including: |
| 171 | +- The new test cases for JSDoc type assertions |
| 172 | +- All baseline tests for `GetTokenAtPosition` and `GetTouchingPropertyName` |
| 173 | +- All other astnav tests |
| 174 | +
|
| 175 | +The fix correctly handles: |
| 176 | +- JSDoc type assertions like `/**@type {string}*/(x)` |
| 177 | +- Regular JSDoc comments (unchanged behavior) |
| 178 | +- All other token position lookups (unchanged behavior) |
0 commit comments