Skip to content

feat(language-service): add selection range for smart expand selection in templates#66739

Open
kbrilla wants to merge 2 commits intoangular:mainfrom
kbrilla:feat/selection-range
Open

feat(language-service): add selection range for smart expand selection in templates#66739
kbrilla wants to merge 2 commits intoangular:mainfrom
kbrilla:feat/selection-range

Conversation

@kbrilla
Copy link
Copy Markdown
Contributor

@kbrilla kbrilla commented Jan 24, 2026

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.ts
  • Added conservative delegated-range preference logic:
    • keep Angular range chain as default,
    • prefer delegated range only when it is strictly more specific at cursor position,
    • preserve Angular outer semantic chain when possible.

Delegated CSS-aware coverage

  • Delegation now applies to:
    • inline HTML style="..." attribute content,
    • [style.<prop>] string-literal content,
    • [style] object-literal key/value string subregions,
    • [ngStyle] object-literal key/value string subregions.
  • Negative control added to ensure no CSS delegation for non-style bindings.

Test updates

  • vscode-ng-language-service/server/src/tests/selection_range_spec.ts
    • Added focused tests for all delegated style scenarios above.
    • Added multiline object-literal hardening case.
  • packages/language-service/test/selection_range_spec.ts
    • Removed oversized fixture.
    • Adjusted expectation to avoid asserting CSS token granularity in core LS-only path.

Validation

Executed and passing:

  • pnpm bazel test //vscode-ng-language-service/server/src/tests:test --test_output=errors
  • pnpm bazel test //packages/language-service/test:test --test_output=errors

Notes

  • No unrelated code paths were intentionally modified.
  • The implementation is intentionally conservative: delegation is applied only in detected style contexts and only when delegated ranges are more specific than Angular's direct chain.

@pullapprove pullapprove Bot requested a review from crisbeto January 24, 2026 10:56
@angular-robot angular-robot Bot added detected: feature PR contains a feature commit area: language-service Issues related to Angular's VS Code language service labels Jan 24, 2026
@ngbot ngbot Bot added this to the Backlog milestone Jan 24, 2026
@Coder-Himanshu
Copy link
Copy Markdown

🤖 AI Review

Summary

The 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.

Findings

FILE 1: packages/language-service/api.ts

  • Finding: No issues found.

FILE 2: packages/language-service/src/language_service.ts

  • Finding: No issues found.

FILE 3: packages/language-service/src/selection_range.ts

  • Finding: Lack of error handling for getTypeCheckInfoAtPosition.

    • Impact: If getTypeCheckInfoAtPosition fails or returns unexpected results, it could lead to runtime errors or undefined behavior.
    • Suggested Fix: Add error handling to manage unexpected results gracefully.
      const typeCheckInfo = getTypeCheckInfoAtPosition(fileName, position, compiler);
      if (!typeCheckInfo) {
          console.error(`Failed to get type check info for ${fileName} at position ${position}`);
          return undefined; // or throw an error based on your error handling strategy
      }
  • Finding: The buildSelectionRangeChain function could benefit from a more explicit return type.

    • Impact: While TypeScript infers the return type, being explicit improves code readability and maintainability.
    • Suggested Fix: Specify the return type explicitly.
      function buildSelectionRangeChain(path: Array<TmplAstNode | AST>): ts.SelectionRange | undefined {
          // existing code...
      }

FILE 4: packages/language-service/src/ts_plugin.ts

  • Finding: No issues found.

FILE 5: vscode-ng-language-service/server/src/handlers/initialization.ts

  • Finding: No issues found.

FILE 6: vscode-ng-language-service/server/src/handlers/selection_range.ts

  • Finding: Lack of input validation for params.positions.

    • Impact: If params.positions is malformed or contains invalid data, it could lead to runtime errors.
    • Suggested Fix: Validate params.positions before processing.
      if (!Array.isArray(params.positions) || params.positions.length === 0) {
          console.warn('Invalid positions array');
          return null;
      }
  • Finding: No error handling for the conversion process in convertSelectionRange.

    • Impact: If the conversion fails, it could lead to silent failures where no selection range is returned.
    • Suggested Fix: Add error handling to log or manage conversion failures.
      const range = tsTextSpanToLspRange(scriptInfo, tsRange.textSpan);
      if (!range) {
          console.error('Failed to convert selection range', tsRange);
          return undefined;
      }

FILE 7: vscode-ng-language-service/server/src/session.ts

  • Finding: No issues found.

Conclusion

Overall, the implementation of the selection range feature is solid, with a clear structure and adherence to TypeScript best practices. The suggestions provided focus on enhancing error handling, improving documentation, and ensuring input validation, which will contribute to the robustness and maintainability of the code.

— Posted via PR Review Agent

kbrilla added a commit to kbrilla/angular that referenced this pull request Jan 26, 2026
…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
@kbrilla kbrilla force-pushed the feat/selection-range branch from 98ad184 to 47a8170 Compare January 26, 2026 18:36
@atscott
Copy link
Copy Markdown
Contributor

atscott commented Jan 27, 2026

In the example, <span>{{user.name}}</span>, the second expand goes to {{user.name}} because that's the entirety of the element. If it was instead <span>{{user.name}} - {{user.phone}}</span>, the expand would go straight to {{user.name}} - {{user.phone}} rather than having one at {{user.name}}. Should the expansions include the binding expression boundaries?

@kbrilla
Copy link
Copy Markdown
Contributor Author

kbrilla commented Jan 27, 2026

In the example, <span>{{user.name}}</span>, the second expand goes to {{user.name}} because that's the entirety of the element. If it was instead <span>{{user.name}} - {{user.phone}}</span>, the expand would go straight to {{user.name}} - {{user.phone}} rather than having one at {{user.name}}. Should the expansions include the binding expression boundaries?

I checked how it works for html tags and You are right it should expand in this order:

user.name -> {{user.name}} -> {{user.name}} - {{user.phone}} -> <span>...</span> 

Added this behaviour and tests for it.

@kbrilla
Copy link
Copy Markdown
Contributor Author

kbrilla commented Jan 27, 2026

@atscott I will add more tests for complicated template patterns , should I convert to draft?

@atscott atscott requested review from atscott and removed request for crisbeto January 27, 2026 17:21
@atscott atscott marked this pull request as draft January 27, 2026 17:21
Comment on lines +141 to +142
// If Angular LS could answer the query for templates, use that; otherwise fall back to TS
return ngLS.getSelectionRangeAtPosition(fileName, position);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This comment implies a ?? tsLs.getSelectionRange... fallback

Copy link
Copy Markdown
Contributor Author

@kbrilla kbrilla Feb 5, 2026

Choose a reason for hiding this comment

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

now works correctly

return (
        ngLS.getSelectionRangeAtPosition(fileName, position) ??
        tsLS.getSmartSelectionRange(fileName, position)
      );

@angular-robot angular-robot Bot added the area: docs Related to the documentation label Feb 5, 2026
@kbrilla kbrilla force-pushed the feat/selection-range branch 2 times, most recently from c5985ee to 511261d Compare February 5, 2026 12:24
kbrilla

This comment was marked as resolved.

@kbrilla kbrilla force-pushed the feat/selection-range branch 12 times, most recently from 543e5e7 to e6a813a Compare February 5, 2026 14:36
@kbrilla kbrilla marked this pull request as ready for review February 6, 2026 22:26
@pullapprove pullapprove Bot requested review from hawkgs and josephperrott February 6, 2026 22:26
@kbrilla kbrilla requested a review from atscott February 6, 2026 22:27
Copy link
Copy Markdown
Contributor

@atscott atscott left a comment

Choose a reason for hiding this comment

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

There's a significant amount of unrelated changes in here. (adev, forms). Can you please clean this up?

@kbrilla kbrilla force-pushed the feat/selection-range branch 7 times, most recently from f76cfc2 to 2b6566b Compare February 6, 2026 23:18
@kbrilla kbrilla requested a review from atscott February 6, 2026 23:20
@kbrilla
Copy link
Copy Markdown
Contributor Author

kbrilla commented Feb 6, 2026

There's a significant amount of unrelated changes in here. (adev, forms). Can you please clean this up?

cleaned up PR, sorry for that

@kbrilla kbrilla force-pushed the feat/selection-range branch from fac4233 to 4e25d25 Compare February 9, 2026 06:38
@angular-robot angular-robot Bot added the area: compiler Issues related to `ngc`, Angular's template compiler label Feb 9, 2026
@kbrilla kbrilla force-pushed the feat/selection-range branch from d49a79c to 2f4a326 Compare February 9, 2026 17:31
Copy link
Copy Markdown
Contributor

@atscott atscott left a comment

Choose a reason for hiding this comment

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

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.range must contain this.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).

  1. visitExpression pushes user.address.city (0-17) onto the path.
  2. visitPropertyChainReceiver unconditionally pushes user.address (0-12) onto the path.
  3. 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:

  1. arr[0] is pushed.
  2. arr is pushed (unconditionally).
  3. 0 is pushed.

Parent chain created: 0arrarr[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):

  1. TmplAstElement is pushed
  2. TmplAstBoundAttribute is pushed
  3. keySpan ([foo]) is unconditionally pushed
  4. AST for bar is 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.

@josephperrott josephperrott removed their request for review February 9, 2026 18:32
@kbrilla kbrilla force-pushed the feat/selection-range branch from 0b96e06 to 71926b8 Compare February 9, 2026 19:55
@hawkgs hawkgs removed their request for review February 10, 2026 08:55
@atscott
Copy link
Copy Markdown
Contributor

atscott commented Feb 18, 2026

@kbrilla is this ready for review again?

@kbrilla
Copy link
Copy Markdown
Contributor Author

kbrilla commented Feb 20, 2026

@kbrilla is this ready for review again?

@atscott yes, it is now ready for review

@kbrilla kbrilla requested a review from atscott February 20, 2026 13:32
Copy link
Copy Markdown
Contributor

@atscott atscott left a comment

Choose a reason for hiding this comment

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

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.

@kbrilla
Copy link
Copy Markdown
Contributor Author

kbrilla commented Mar 3, 2026

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

@kbrilla kbrilla force-pushed the feat/selection-range branch from 5764ac5 to 8964b02 Compare March 3, 2026 13:34
@kbrilla kbrilla requested a review from atscott March 4, 2026 01:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area: compiler Issues related to `ngc`, Angular's template compiler area: docs Related to the documentation area: language-service Issues related to Angular's VS Code language service area: vscode-extension Issues related to the Angular Language Service VsCode extension detected: feature PR contains a feature commit

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants