Skip to content

Commit 437f1cf

Browse files
Copilotandrewbranch
andcommitted
Refine fix to special-case AsExpression and SatisfiesExpression
Changed from a general reparsed node fallback to specifically handling AsExpression and SatisfiesExpression. These are the only reparsed node types where their .Expression child should be visited. The fix allows these special nodes to be visited even when reparsed, and sets an allowReparsed flag when navigating into them, which enables visiting their reparsed children to reach identifiers from JSDoc type assertions. This targeted approach is more precise and maintains strict filtering for all other reparsed nodes. Co-authored-by: andrewbranch <[email protected]>
1 parent b59ddfc commit 437f1cf

File tree

2 files changed

+50
-52
lines changed

2 files changed

+50
-52
lines changed

copilot-notes.md

Lines changed: 30 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -122,48 +122,48 @@ KindParenthesizedExpression [31..54)
122122
123123
In this case, the `AsExpression` IS the only child (besides the JSDoc). When it's skipped, there's NO other path to the identifier.
124124
125-
## Step 5: The Solution
125+
## Step 5: The Solution (Revised)
126126
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.
127+
After feedback from @andrewbranch, the solution was refined to be more targeted. Instead of a general fallback mechanism for all reparsed nodes, the fix specifically handles `AsExpression` and `SatisfiesExpression`.
128128
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-
```
129+
**Key Insight:** `AsExpression` and `SatisfiesExpression` are special cases among reparsed nodes. While most reparsed nodes are synthetic and exist outside the "real" tree with no real position in the file, these two node kinds can have the `Reparsed` flag when they come from JSDoc type assertions, but their `.Expression` child should still be visited.
139130
140-
The problem is that `visitNode` (used for single children) doesn't have this fallback logic. It just skips reparsed nodes unconditionally.
131+
**The Fix:** Modify `visitNode` to special-case `AsExpression` and `SatisfiesExpression`:
141132
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.
133+
1. Check if a node is `AsExpression` or `SatisfiesExpression` when deciding whether to skip reparsed nodes
134+
2. When we navigate into one of these special nodes, set an `allowReparsed` flag that allows visiting all their reparsed children
135+
3. This allows recursive navigation through the reparsed tree structure to reach the actual identifier
143136
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
137+
The logic:
138+
```go
139+
// Skip reparsed nodes unless:
140+
// 1. The node itself is AsExpression or SatisfiesExpression, OR
141+
// 2. We're already inside an AsExpression or SatisfiesExpression (allowReparsed=true)
142+
isSpecialReparsed := node.Flags&ast.NodeFlagsReparsed != 0 &&
143+
(node.Kind == ast.KindAsExpression || node.Kind == ast.KindSatisfiesExpression)
144+
145+
if node.Flags&ast.NodeFlagsReparsed == 0 || isSpecialReparsed || allowReparsed {
146+
// Process the node
147+
}
148+
```
148149
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
150+
When we navigate into an `AsExpression` or `SatisfiesExpression`, we set `allowReparsed = true` for the next iteration, which allows their reparsed children (like the identifier) to be visited.
153151
154152
## Implementation
155153
156154
The changes made to `internal/astnav/tokens.go`:
157155
158-
1. Added `reparsedNext` variable to track reparsed node matches as a fallback
156+
1. Added `allowReparsed` flag to track when we're inside an AsExpression or SatisfiesExpression
159157
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
158+
- Allow AsExpression and SatisfiesExpression nodes even if they're reparsed
159+
- Allow any reparsed node if `allowReparsed` is true (we're inside a special node)
160+
3. Set `allowReparsed = true` when navigating into AsExpression or SatisfiesExpression
165161
166-
This maintains backward compatibility while fixing the JSDoc type assertion panic. The panic condition is kept intact - identifiers should never appear in trivia.
162+
This targeted approach:
163+
- Only affects the specific node types that need special handling
164+
- Maintains the strict reparsed node filtering for all other cases
165+
- Keeps the panic intact - identifiers should never appear in actual trivia
166+
- Maintains backward compatibility with all existing code
167167
168168
## Testing
169169
@@ -174,5 +174,6 @@ All existing tests pass, including:
174174
175175
The fix correctly handles:
176176
- JSDoc type assertions like `/**@type {string}*/(x)`
177+
- JSDoc satisfies expressions
177178
- Regular JSDoc comments (unchanged behavior)
178179
- All other token position lookups (unchanged behavior)

internal/astnav/tokens.go

Lines changed: 20 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -60,14 +60,15 @@ func getTokenAtPosition(
6060
// only if `includePrecedingTokenAtEndPosition` is provided. Once set, the next
6161
// iteration of the loop will test the rightmost token of `prevSubtree` to see
6262
// if it should be returned.
63-
// `reparsedNext` tracks a reparsed node that matches, used as a fallback if no
64-
// non-reparsed node is found. This handles JSDoc type assertions where the
65-
// reparsed AsExpression is the only path to the identifier.
66-
var next, prevSubtree, reparsedNext *ast.Node
63+
var next, prevSubtree *ast.Node
6764
current := sourceFile.AsNode()
6865
// `left` tracks the lower boundary of the node/token that could be returned,
6966
// and is eventually the scanner's start position, if the scanner is used.
7067
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
7172

7273
testNode := func(node *ast.Node) int {
7374
if node.Kind != ast.KindEndOfFile && node.End() == position && includePrecedingTokenAtEndPosition != nil {
@@ -91,16 +92,16 @@ func getTokenAtPosition(
9192
// We can't abort visiting children, so once a match is found, we set `next`
9293
// and do nothing on subsequent visits.
9394
if node != nil && next == nil {
94-
result := testNode(node)
95-
if node.Flags&ast.NodeFlagsReparsed != 0 {
96-
// This is a reparsed node (from JSDoc). We prefer non-reparsed nodes,
97-
// but track this as a fallback in case there are no non-reparsed matches.
98-
// This handles JSDoc type assertions like /**@type {string}*/(x) where
99-
// the AsExpression child is reparsed but is the only path to the identifier.
100-
if result == 0 && reparsedNext == nil {
101-
reparsedNext = node
102-
}
103-
} else {
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)
104105
switch result {
105106
case -1:
106107
if !ast.IsJSDocKind(node.Kind) {
@@ -173,14 +174,6 @@ func getTokenAtPosition(
173174
prevSubtree = nil
174175
}
175176

176-
// If no non-reparsed node was found but we have a reparsed match, use it.
177-
// This handles JSDoc type assertions where the only path to an identifier
178-
// is through a reparsed AsExpression node.
179-
if next == nil && reparsedNext != nil {
180-
next = reparsedNext
181-
reparsedNext = nil
182-
}
183-
184177
// No node was found that contains the target position, so we've gone as deep as
185178
// we can in the AST. We've either found a token, or we need to run the scanner
186179
// to construct one that isn't stored in the AST.
@@ -217,7 +210,11 @@ func getTokenAtPosition(
217210
current = next
218211
left = current.Pos()
219212
next = nil
220-
reparsedNext = 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+
}
221218
}
222219
}
223220

0 commit comments

Comments
 (0)