feat(language-service): add selection range for smart expand selection in templates#66739
feat(language-service): add selection range for smart expand selection in templates#66739kbrilla wants to merge 2 commits intoangular:mainfrom
Conversation
🤖 AI ReviewSummaryThe pull request introduces a new feature to the Angular language service, specifically adding functionality for smart selection range expansion in templates. The implementation appears well-structured and follows modern coding practices. The code is generally clean, with clear separation of concerns and appropriate use of TypeScript features. However, there are some areas for improvement, particularly in documentation, error handling, and testing coverage. FindingsFILE 1:
|
…selection This adds support for the VS Code 'Expand Selection' and 'Shrink Selection' commands in Angular templates. The feature uses the Angular template AST to provide semantic selection ranges based on the template structure. The implementation: - Adds a new selection_range.ts module that traverses the template AST - Supports selection ranges for elements, attributes, bindings, and text - Works in both inline templates and external HTML template files - Uses the Angular compiler's template parser for accurate ranges PR Close angular#66739
98ad184 to
47a8170
Compare
|
In the example, |
I checked how it works for html tags and You are right it should expand in this order: Added this behaviour and tests for it. |
|
@atscott I will add more tests for complicated template patterns , should I convert to draft? |
| // If Angular LS could answer the query for templates, use that; otherwise fall back to TS | ||
| return ngLS.getSelectionRangeAtPosition(fileName, position); |
There was a problem hiding this comment.
This comment implies a ?? tsLs.getSelectionRange... fallback
There was a problem hiding this comment.
now works correctly
return (
ngLS.getSelectionRangeAtPosition(fileName, position) ??
tsLS.getSmartSelectionRange(fileName, position)
);c5985ee to
511261d
Compare
543e5e7 to
e6a813a
Compare
atscott
left a comment
There was a problem hiding this comment.
There's a significant amount of unrelated changes in here. (adev, forms). Can you please clean this up?
f76cfc2 to
2b6566b
Compare
cleaned up PR, sorry for that |
fac4233 to
4e25d25
Compare
d49a79c to
2f4a326
Compare
atscott
left a comment
There was a problem hiding this comment.
Code Review: PR 66739 (Selection Range)
I have critically reviewed the changes proposed in PR 66739. While the feature is a great addition, the current implementation of SelectionRangeVisitor constructs fundamentally invalid parent chains in several cases, violating the Language Server Protocol (LSP) specification for SelectionRange, which explicitly states:
The parent selection range containing this range. Therefore
parent.rangemust containthis.range.
The current implementation builds the chain from path elements from outermost (first pushed) to innermost (last pushed). However, it adds siblings or disjoint nodes to the same ancestry chain, causing non-containing ranges to become parents. This results in unpredictable selection expansion behavior in VSCode.
1. visitPropertyChainReceiver violates range containment
In SelectionRangeVisitor.visitExpression(), when visiting a PropertyRead, the code explicitly bypasses the cursor position check:
} else if (innerAst instanceof PropertyRead || innerAst instanceof SafePropertyRead) {
if (innerAst.receiver) {
// Temporarily bypass the position check for receivers in property chains
// We want to add all ancestor receivers to the path
this.visitPropertyChainReceiver(innerAst.receiver);
}
}The Bug:
If we have the expression user.address.city and the cursor is over city (e.g., indexes 13-17).
visitExpressionpushesuser.address.city(0-17) onto the path.visitPropertyChainReceiverunconditionally pushesuser.address(0-12) onto the path.- It recursively calls itself and pushes
user(0-4) onto the path.
When buildSelectionRangeChain runs, it creates the parent chain linking the latest pushed node to its predecessor:
user (0-4) → user.address (0-12) → user.address.city (0-17)
If the cursor is on city, the innermost returned range is user (which doesn't even contain the cursor!). If the user requested selection at index 15, returning a range [0, 4] is incorrect. Even worse, if we consider containment, user.address DOES NOT contain user.address.city, yet user.address is forced tightly into the hierarchy as a parent.
Fix:
Only visit the receiver if the cursor is actually inside the receiver (isWithin(this.position, innerAst.receiver.sourceSpan)). If the cursor is on the property name, push only the nameSpan (which PropertyRead has) and let the parent implicitly be the full PropertyRead.
2. Invalid ancestry in KeyedRead
A similar bug exists for keyed reads:
} else if (innerAst instanceof KeyedRead || innerAst instanceof SafeKeyedRead) {
if (innerAst.receiver) {
this.visitPropertyChainReceiver(innerAst.receiver);
}
if (innerAst.key && isWithin(this.position, innerAst.key.sourceSpan)) {
this.visitExpression(innerAst.key);
}
}If the expression is arr[0] and the cursor is on 0:
arr[0]is pushed.arris pushed (unconditionally).0is pushed.
Parent chain created: 0 → arr → arr[0].
Here, the parent of 0 becomes arr. But arr (0-3) obviously does not contain 0 (4-5)! This structurally violates the LSP and causes standard selection expansion logic to break.
3. Disjoint Attributes & Events (visitBoundAttribute, visitBoundEvent)
When visiting a bound attribute, keySpan is pushed regardless of where exactly the cursor is:
visitBoundAttribute(attribute: TmplAstBoundAttribute): void {
if (attribute.keySpan) {
const keySpan = { ... };
this.path.push({node: null, span: keySpan});
}
this.visitExpression(attribute.value);
}If the user's cursor is inside the attribute's value (e.g., [foo]="bar" and cursor is in bar):
TmplAstElementis pushedTmplAstBoundAttributeis pushedkeySpan([foo]) is unconditionally pushedASTforbaris pushed.
Parent chain created: bar → [foo] → [foo]="bar" → Element.
The parent of the value expression is marked as the keySpan, which does not contain the value. The selection would weirdly jump sideways from the value into the key, instead of wrapping the value into the full attribute.
Fix:
Check if (positionShouldSnapToSpan(this.position, keySpan)) before adding it to the path, exactly like how it is properly handled inside visitTextAttribute.
Summary
The SelectionRangeVisitor assumes that pushing nodes sequentially creates a logical path, but buildSelectionRangeChain turns that sequential array into a hierarchy where arr[i+1] is strictly contained by arr[i]. The visitor must only push spans that strictly contain the cursor position, and nodes must be pushed strictly in outer-to-inner order. Any deviation creates broken overlapping or disjoint tree structures in the LSP response that editors won't be able to expand correctly.
0b96e06 to
71926b8
Compare
|
@kbrilla is this ready for review again? |
atscott
left a comment
There was a problem hiding this comment.
The "feat(language-service): delegate css selection ranges in style bindings" commit took things too far. Please drop that from this PR. Please also clean up the commit history.
You are right, it should have been moved to #67266 |
5764ac5 to
8964b02
Compare
Summary
This PR adds and hardens Selection Range behavior for Angular templates, with CSS-aware delegation for style-related binding subregions.
What changed
Server-side delegation and merge
vscode-ng-language-service/server/src/handlers/selection_range.tsDelegated CSS-aware coverage
style="..."attribute content,[style.<prop>]string-literal content,[style]object-literal key/value string subregions,[ngStyle]object-literal key/value string subregions.Test updates
vscode-ng-language-service/server/src/tests/selection_range_spec.tspackages/language-service/test/selection_range_spec.tsValidation
Executed and passing:
pnpm bazel test //vscode-ng-language-service/server/src/tests:test --test_output=errorspnpm bazel test //packages/language-service/test:test --test_output=errorsNotes