Skip to content

Commit 3aef3e6

Browse files
committed
refactor(language-service): rename all references to selectorless directives (#61307)
Follow-up to #61240 that adds renaming support for selectorless components/directives both from the template and from the TypeScript source. PR Close #61307
1 parent f6bb6cc commit 3aef3e6

File tree

3 files changed

+145
-132
lines changed

3 files changed

+145
-132
lines changed

packages/language-service/src/references_and_rename.ts

Lines changed: 111 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -5,26 +5,21 @@
55
* Use of this source code is governed by an MIT-style license that can be
66
* found in the LICENSE file at https://angular.dev/license
77
*/
8-
import {AST, TmplAstComponent, TmplAstDirective, TmplAstNode} from '@angular/compiler';
8+
import {AST, TmplAstComponent, TmplAstNode} from '@angular/compiler';
99
import {NgCompiler} from '@angular/compiler-cli/src/ngtsc/core';
1010
import {absoluteFrom} from '@angular/compiler-cli/src/ngtsc/file_system';
11-
import {MetaKind, PipeMeta} from '@angular/compiler-cli/src/ngtsc/metadata';
11+
import {MetaKind, PipeMeta, DirectiveMeta} from '@angular/compiler-cli/src/ngtsc/metadata';
1212
import {PerfPhase} from '@angular/compiler-cli/src/ngtsc/perf';
13-
import {
14-
SelectorlessComponentSymbol,
15-
SelectorlessDirectiveSymbol,
16-
SymbolKind,
17-
TemplateTypeChecker,
18-
} from '@angular/compiler-cli/src/ngtsc/typecheck/api';
13+
import {SymbolKind, TemplateTypeChecker} from '@angular/compiler-cli/src/ngtsc/typecheck/api';
1914
import ts from 'typescript';
2015

2116
import {
2217
convertToTemplateDocumentSpan,
2318
FilePosition,
2419
getParentClassMeta,
2520
getRenameTextAndSpanAtPosition,
26-
getSelectorlessTemplateSpanFromTcbLocations,
2721
getTargetDetailsAtTemplatePosition,
22+
SelectorlessCollector,
2823
TemplateLocationDetails,
2924
} from './references_and_rename_utils';
3025
import {collectMemberMethods, findTightestNode} from './utils/ts_utils';
@@ -138,9 +133,6 @@ interface SelectorRenameContext {
138133
interface SelectorlessIdentifierRenameContext {
139134
type: RequestKind.SelectorlessIdentifier;
140135

141-
/** Node defining the component/directive. */
142-
templateNode: TmplAstComponent | TmplAstDirective;
143-
144136
/** Identifier of the class defining the class. */
145137
identifier: ts.Identifier;
146138

@@ -311,7 +303,7 @@ export class RenameBuilder {
311303
return allRenameLocations.length > 0 ? allRenameLocations : null;
312304
}
313305

314-
findRenameLocationsAtTypescriptPosition(
306+
private findRenameLocationsAtTypescriptPosition(
315307
renameRequest: RenameRequest,
316308
): readonly ts.RenameLocation[] | null {
317309
return this.compiler.perfRecorder.inPhase(PerfPhase.LsReferencesAndRenames, () => {
@@ -336,15 +328,11 @@ export class RenameBuilder {
336328
for (const location of locations) {
337329
if (this.ttc.isTrackedTypeCheckFile(absoluteFrom(location.fileName))) {
338330
if (renameRequest.type === RequestKind.SelectorlessIdentifier) {
339-
const selectorlessEntries = getSelectorlessTemplateSpanFromTcbLocations(
340-
location,
341-
this.ttc,
342-
this.compiler.getCurrentProgram(),
343-
renameRequest.templateNode,
344-
);
345-
if (selectorlessEntries !== null) {
346-
entries.push(...selectorlessEntries);
331+
const selectorlessEntries = this.getSelectorlessRenameLocations(renameRequest);
332+
if (selectorlessEntries === null) {
333+
return null;
347334
}
335+
entries.push(...selectorlessEntries);
348336
} else {
349337
const entry = convertToTemplateDocumentSpan(
350338
location,
@@ -410,13 +398,15 @@ export class RenameBuilder {
410398
targetDetails.symbol.kind === SymbolKind.SelectorlessComponent ||
411399
targetDetails.symbol.kind === SymbolKind.SelectorlessDirective
412400
) {
413-
const renameRequest = this.buildSelectorlessRenameRequestFromTemplate(
414-
targetDetails.symbol,
415-
);
416-
if (renameRequest === null) {
401+
const tsSymbol = targetDetails.symbol.tsSymbol;
402+
const meta =
403+
tsSymbol === null || tsSymbol.valueDeclaration === undefined
404+
? null
405+
: this.compiler.getMeta(tsSymbol.valueDeclaration);
406+
if (meta === null || meta.kind !== MetaKind.Directive) {
417407
return null;
418408
}
419-
renameRequests.push(renameRequest);
409+
renameRequests.push(this.buildSelectorlessRenameRequest(meta));
420410
} else {
421411
const renameRequest: RenameRequest = {
422412
type: RequestKind.DirectFromTemplate,
@@ -440,11 +430,16 @@ export class RenameBuilder {
440430
return null;
441431
}
442432
const meta = getParentClassMeta(requestNode, this.compiler);
443-
if (meta !== null && meta.kind === MetaKind.Pipe && meta.nameExpr === requestNode) {
433+
434+
if (meta?.kind === MetaKind.Pipe && meta.nameExpr === requestNode) {
444435
return this.buildPipeRenameRequest(meta);
445-
} else {
446-
return {type: RequestKind.DirectFromTypeScript, requestNode};
447436
}
437+
438+
if (meta?.kind === MetaKind.Directive && meta.ref.node.name === requestNode) {
439+
return this.buildSelectorlessRenameRequest(meta);
440+
}
441+
442+
return {type: RequestKind.DirectFromTypeScript, requestNode};
448443
}
449444

450445
private buildPipeRenameRequest(meta: PipeMeta): PipeRenameContext | null {
@@ -473,38 +468,100 @@ export class RenameBuilder {
473468
};
474469
}
475470

476-
private buildSelectorlessRenameRequestFromTemplate(
477-
symbol: SelectorlessComponentSymbol | SelectorlessDirectiveSymbol,
478-
): SelectorlessIdentifierRenameContext | null {
479-
if (symbol.tsSymbol === null || symbol.tsSymbol.valueDeclaration === undefined) {
480-
return null;
481-
}
471+
private buildSelectorlessRenameRequest(meta: DirectiveMeta): SelectorlessIdentifierRenameContext {
472+
const identifier = meta.ref.node.name;
473+
474+
return {
475+
type: RequestKind.SelectorlessIdentifier,
476+
identifier,
477+
renamePosition: {
478+
fileName: identifier.getSourceFile().fileName,
479+
position: identifier.getStart(),
480+
},
481+
};
482+
}
482483

483-
const meta = this.compiler.getMeta(symbol.tsSymbol.valueDeclaration);
484-
if (meta === null || meta.kind !== MetaKind.Directive) {
484+
/** Gets the rename locations for a selectorless request. */
485+
private getSelectorlessRenameLocations(
486+
request: SelectorlessIdentifierRenameContext,
487+
): ts.RenameLocation[] | null {
488+
// Find all the references to the class.
489+
const refs = this.tsLS.getReferencesAtPosition(
490+
request.renamePosition.fileName,
491+
request.renamePosition.position,
492+
);
493+
494+
if (refs === undefined) {
485495
return null;
486496
}
487497

488-
const nameNode = meta.ref.node.name;
489-
const templateName =
490-
symbol.kind === SymbolKind.SelectorlessComponent
491-
? symbol.templateNode.componentName
492-
: symbol.templateNode.name;
498+
const entries: ts.RenameLocation[] = [];
499+
let hasSelectorlessReferences = false;
493500

494-
// Do not rename aliased references.
495-
if (templateName !== nameNode.text) {
496-
return null;
501+
for (const ref of refs) {
502+
// Preserve the TS-based references.
503+
if (!this.ttc.isTrackedTypeCheckFile(absoluteFrom(ref.fileName))) {
504+
entries.push(ref);
505+
continue;
506+
}
507+
508+
// Resolve the TCB references to their real locations.
509+
const entry = convertToTemplateDocumentSpan(ref, this.ttc, this.compiler.getCurrentProgram());
510+
const typeCheckInfo =
511+
entry === null
512+
? undefined
513+
: getTypeCheckInfoAtPosition(entry.fileName, entry.textSpan.start, this.compiler);
514+
515+
if (entry === null || typeCheckInfo === undefined) {
516+
continue;
517+
}
518+
519+
const nodes = SelectorlessCollector.getSelectorlessNodes(typeCheckInfo.nodes);
520+
521+
// Go through all the selectorless template nodes and look for matches.
522+
for (const node of nodes) {
523+
const startSpan = node.startSourceSpan;
524+
const isComponent = node instanceof TmplAstComponent;
525+
const name = isComponent ? node.componentName : node.name;
526+
527+
if (
528+
// The span of the template node should match the span of the reference.
529+
startSpan.start.offset !== entry.textSpan.start ||
530+
startSpan.end.offset !== entry.textSpan.start + entry.textSpan.length ||
531+
// Skip aliased directives.
532+
name !== request.identifier.text
533+
) {
534+
continue;
535+
}
536+
537+
hasSelectorlessReferences = true;
538+
539+
entries.push({
540+
fileName: entry.fileName,
541+
textSpan: {
542+
// +1 to skip over the `<` for components and `@` for directives.
543+
start: entry.textSpan.start + 1,
544+
length: name.length,
545+
},
546+
});
547+
548+
// Components also need to rename the closing tag.
549+
if (isComponent && !node.isSelfClosing && node.endSourceSpan !== null) {
550+
entries.push({
551+
fileName: entry.fileName,
552+
textSpan: {
553+
// +2 to skip over the `</` of the closing tag.
554+
start: node.endSourceSpan.start.offset + 2,
555+
length: name.length,
556+
},
557+
});
558+
}
559+
}
497560
}
498561

499-
return {
500-
type: RequestKind.SelectorlessIdentifier,
501-
templateNode: symbol.templateNode,
502-
identifier: nameNode,
503-
renamePosition: {
504-
fileName: meta.ref.node.getSourceFile().fileName,
505-
position: nameNode.getStart(),
506-
},
507-
};
562+
// Do not produce any rename locations if there weren't any references in the template.
563+
// This is for backwards compatibility since we should fall back to the TS language service.
564+
return hasSelectorlessReferences ? entries : null;
508565
}
509566
}
510567

packages/language-service/src/references_and_rename_utils.ts

Lines changed: 13 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ import {
2222
TmplAstVariable,
2323
TmplAstComponent,
2424
TmplAstDirective,
25+
TmplAstRecursiveVisitor,
26+
tmplAstVisitAll,
2527
} from '@angular/compiler';
2628
import {NgCompiler} from '@angular/compiler-cli/src/ngtsc/core';
2729
import {absoluteFrom} from '@angular/compiler-cli/src/ngtsc/file_system';
@@ -412,71 +414,20 @@ export function getParentClassMeta(
412414
return compiler.getMeta(parentClass);
413415
}
414416

415-
/**
416-
* Converts a given `ts.DocumentSpan` in a shim file into one or more spans in the template,
417-
* representing a selectorless component or directive. There can be more than one return value
418-
* when a component has a closing tag.
419-
*/
420-
export function getSelectorlessTemplateSpanFromTcbLocations(
421-
shimDocumentSpan: ts.DocumentSpan,
422-
templateTypeChecker: TemplateTypeChecker,
423-
program: ts.Program,
424-
node: TmplAstComponent | TmplAstDirective,
425-
): ts.DocumentSpan[] | null {
426-
const sf = program.getSourceFile(shimDocumentSpan.fileName);
427-
if (sf === undefined) {
428-
return null;
429-
}
417+
/** Visitor that collects all selectorless AST nodes from a template. */
418+
export class SelectorlessCollector extends TmplAstRecursiveVisitor {
419+
private nodes: (TmplAstComponent | TmplAstDirective)[] = [];
430420

431-
let tcbNode = findTightestNode(sf, shimDocumentSpan.textSpan.start);
432-
if (tcbNode === undefined) {
433-
return null;
421+
static getSelectorlessNodes(nodes: TmplAstNode[]): (TmplAstComponent | TmplAstDirective)[] {
422+
const visitor = new SelectorlessCollector();
423+
tmplAstVisitAll(visitor, nodes);
424+
return visitor.nodes;
434425
}
435426

436-
// Variables in the typecheck block are generated with the type on the right hand
437-
// side: `var _t1 = null! as i1.DirA`. Finding references of DirA will return the type
438-
// assertion and we need to map it back to the variable identifier _t1.
439-
if (hasExpressionIdentifier(sf, tcbNode, ExpressionIdentifier.VARIABLE_AS_EXPRESSION)) {
440-
while (tcbNode && !ts.isVariableDeclaration(tcbNode)) {
441-
tcbNode = tcbNode.parent;
427+
visit(node: TmplAstNode) {
428+
if (node instanceof TmplAstComponent || node instanceof TmplAstDirective) {
429+
this.nodes.push(node);
442430
}
431+
node.visit(this);
443432
}
444-
445-
const mapping = getTemplateLocationFromTcbLocation(
446-
templateTypeChecker,
447-
absoluteFrom(shimDocumentSpan.fileName),
448-
/* tcbIsShim */ true,
449-
tcbNode.getStart(),
450-
);
451-
452-
if (mapping === null) {
453-
return null;
454-
}
455-
456-
const fileName = mapping.templateUrl;
457-
const {length} = node instanceof TmplAstComponent ? node.componentName : node.name;
458-
const spans: ts.DocumentSpan[] = [
459-
{
460-
fileName,
461-
textSpan: {
462-
// +1 because of the opening `<` or `@`.
463-
start: node.startSourceSpan.start.offset + 1,
464-
length,
465-
},
466-
},
467-
];
468-
469-
// If it's not a self-closing template tag, we need to rename the end tag too.
470-
if (node instanceof TmplAstComponent && !node.isSelfClosing && node.endSourceSpan !== null) {
471-
spans.push({
472-
fileName,
473-
textSpan: {
474-
// +2 because of the `</`.
475-
start: node.endSourceSpan.start.offset + 2,
476-
length,
477-
},
478-
});
479-
}
480-
481-
return spans;
482433
}

packages/language-service/test/references_and_rename_spec.ts

Lines changed: 21 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -2286,17 +2286,20 @@ describe('find references and rename locations', () => {
22862286
it('should handle rename request for selectorless component from source file', () => {
22872287
const file = project.openFile('test-component.ts');
22882288
const template = project.openFile('app.html');
2289-
template.contents = '<TestComponent/>';
2289+
template.contents =
2290+
'<TestComponent/> {{123 + 456}} <div><TestComponent>Hello</TestComponent></div>';
22902291
file.moveCursorToText('export class TestCom¦ponent {');
22912292
const renameLocations = getRenameLocationsAtPosition(file)!;
22922293

2293-
expect(renameLocations).toBeUndefined();
2294-
2295-
// TODO(crisbeto): investigate if we can make this work.
2296-
// It would involve looking for all the component references and renaming them.
2297-
// expect(renameLocations.length).toBe(3);
2298-
// assertTextSpans(renameLocations, ['TestComponent']);
2299-
// assertFileNames(renameLocations, ['test-component.ts', 'app.html', 'app.ts']);
2294+
// There are 5 locations that need to be renamed:
2295+
// - Source file where the component is defined.
2296+
// - Self-closing tag in the template.
2297+
// - Opening tag in the template.
2298+
// - Closing tag in the template.
2299+
// - Import in the app component.
2300+
expect(renameLocations.length).toBe(5);
2301+
assertTextSpans(renameLocations, ['TestComponent']);
2302+
assertFileNames(renameLocations, ['test-component.ts', 'app.html', 'app.ts']);
23002303
});
23012304
});
23022305
});
@@ -2476,17 +2479,19 @@ describe('find references and rename locations', () => {
24762479
it('should handle rename request for selectorless component from source file', () => {
24772480
const file = project.openFile('test-directive.ts');
24782481
const template = project.openFile('app.html');
2479-
template.contents = '<div @TestDirective></div>';
2482+
template.contents =
2483+
'<div @TestDirective><span><input @TestDirective([value]="numberValue")></span></div>';
24802484
file.moveCursorToText('export class TestDir¦ective {');
24812485
const renameLocations = getRenameLocationsAtPosition(file)!;
24822486

2483-
expect(renameLocations).toBeUndefined();
2484-
2485-
// TODO(crisbeto): investigate if we can make this work.
2486-
// It would involve looking for all the component references and renaming them.
2487-
// expect(renameLocations.length).toBe(3);
2488-
// assertTextSpans(renameLocations, ['TestDirective']);
2489-
// assertFileNames(renameLocations, ['test-directive.ts', 'app.html', 'app.ts']);
2487+
// There are 4 locations that need to be renamed:
2488+
// - Source file where the directive is defined.
2489+
// - Reference on the `div` node.
2490+
// - Refernce on the `input` node.
2491+
// - Import in the app component.
2492+
expect(renameLocations.length).toBe(4);
2493+
assertTextSpans(renameLocations, ['TestDirective']);
2494+
assertFileNames(renameLocations, ['test-directive.ts', 'app.html', 'app.ts']);
24902495
});
24912496
});
24922497
});

0 commit comments

Comments
 (0)