refactor(language-service): initial reference and rename implementation for selectorless (#61240)

Adds an initial implementation for finding references and renaming to selectorless components/directives.

Finding references should work everywhere, whereas renaming only currently works when initiated from the template.

PR Close #61240
This commit is contained in:
Kristiyan Kostadinov 2025-05-09 14:50:02 +02:00 committed by Alex Rickabaugh
parent f074c30616
commit fa27b76339
3 changed files with 666 additions and 24 deletions

View file

@ -5,12 +5,17 @@
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/
import {AST, TmplAstNode} from '@angular/compiler';
import {AST, TmplAstComponent, TmplAstDirective, TmplAstNode} from '@angular/compiler';
import {NgCompiler} from '@angular/compiler-cli/src/ngtsc/core';
import {absoluteFrom} from '@angular/compiler-cli/src/ngtsc/file_system';
import {MetaKind, PipeMeta} from '@angular/compiler-cli/src/ngtsc/metadata';
import {PerfPhase} from '@angular/compiler-cli/src/ngtsc/perf';
import {SymbolKind, TemplateTypeChecker} from '@angular/compiler-cli/src/ngtsc/typecheck/api';
import {
SelectorlessComponentSymbol,
SelectorlessDirectiveSymbol,
SymbolKind,
TemplateTypeChecker,
} from '@angular/compiler-cli/src/ngtsc/typecheck/api';
import ts from 'typescript';
import {
@ -18,6 +23,7 @@ import {
FilePosition,
getParentClassMeta,
getRenameTextAndSpanAtPosition,
getSelectorlessTemplateSpanFromTcbLocations,
getTargetDetailsAtTemplatePosition,
TemplateLocationDetails,
} from './references_and_rename_utils';
@ -96,6 +102,7 @@ enum RequestKind {
DirectFromTypeScript,
PipeName,
Selector,
SelectorlessIdentifier,
}
/** The context needed to perform a rename of a pipe name. */
@ -127,6 +134,20 @@ interface SelectorRenameContext {
renamePosition: FilePosition;
}
/** The context needed to perform a rename of a selectorless component/directive. */
interface SelectorlessIdentifierRenameContext {
type: RequestKind.SelectorlessIdentifier;
/** Node defining the component/directive. */
templateNode: TmplAstComponent | TmplAstDirective;
/** Identifier of the class defining the class. */
identifier: ts.Identifier;
/** Location used for querying the TypeScript language service. */
renamePosition: FilePosition;
}
interface DirectFromTypescriptRenameContext {
type: RequestKind.DirectFromTypeScript;
@ -151,14 +172,19 @@ type IndirectRenameContext = PipeRenameContext | SelectorRenameContext;
type RenameRequest =
| IndirectRenameContext
| DirectFromTemplateRenameContext
| DirectFromTypescriptRenameContext;
| DirectFromTypescriptRenameContext
| SelectorlessIdentifierRenameContext;
function isDirectRenameContext(
context: RenameRequest,
): context is DirectFromTemplateRenameContext | DirectFromTypescriptRenameContext {
): context is
| DirectFromTemplateRenameContext
| DirectFromTypescriptRenameContext
| SelectorlessIdentifierRenameContext {
return (
context.type === RequestKind.DirectFromTemplate ||
context.type === RequestKind.DirectFromTypeScript
context.type === RequestKind.DirectFromTypeScript ||
context.type === RequestKind.SelectorlessIdentifier
);
}
@ -200,6 +226,16 @@ export class RenameBuilder {
start: renameRequest.pipeNameExpr.getStart() + 1,
},
};
} else if (renameRequest.type === RequestKind.SelectorlessIdentifier) {
return {
canRename: true,
displayName: renameRequest.identifier.text,
fullDisplayName: renameRequest.identifier.text,
triggerSpan: {
length: renameRequest.identifier.text.length,
start: renameRequest.identifier.getStart(),
},
};
} else {
// TODO(atscott): Add support for other special indirect renames from typescript files.
return this.tsLS.getRenameInfo(filePath, position);
@ -299,18 +335,30 @@ export class RenameBuilder {
for (const location of locations) {
if (this.ttc.isTrackedTypeCheckFile(absoluteFrom(location.fileName))) {
const entry = convertToTemplateDocumentSpan(
location,
this.ttc,
this.compiler.getCurrentProgram(),
expectedRenameText,
);
// There is no template node whose text matches the original rename request. Bail on
// renaming completely rather than providing incomplete results.
if (entry === null) {
return null;
if (renameRequest.type === RequestKind.SelectorlessIdentifier) {
const selectorlessEntries = getSelectorlessTemplateSpanFromTcbLocations(
location,
this.ttc,
this.compiler.getCurrentProgram(),
renameRequest.templateNode,
);
if (selectorlessEntries !== null) {
entries.push(...selectorlessEntries);
}
} else {
const entry = convertToTemplateDocumentSpan(
location,
this.ttc,
this.compiler.getCurrentProgram(),
expectedRenameText,
);
// There is no template node whose text matches the original rename request. Bail on
// renaming completely rather than providing incomplete results.
if (entry === null) {
return null;
}
entries.push(entry);
}
entries.push(entry);
} else {
if (!isDirectRenameContext(renameRequest)) {
// Discard any non-template results for non-direct renames. We should only rename
@ -358,6 +406,17 @@ export class RenameBuilder {
return null;
}
renameRequests.push(renameRequest);
} else if (
targetDetails.symbol.kind === SymbolKind.SelectorlessComponent ||
targetDetails.symbol.kind === SymbolKind.SelectorlessDirective
) {
const renameRequest = this.buildSelectorlessRenameRequestFromTemplate(
targetDetails.symbol,
);
if (renameRequest === null) {
return null;
}
renameRequests.push(renameRequest);
} else {
const renameRequest: RenameRequest = {
type: RequestKind.DirectFromTemplate,
@ -413,6 +472,40 @@ export class RenameBuilder {
},
};
}
private buildSelectorlessRenameRequestFromTemplate(
symbol: SelectorlessComponentSymbol | SelectorlessDirectiveSymbol,
): SelectorlessIdentifierRenameContext | null {
if (symbol.tsSymbol === null || symbol.tsSymbol.valueDeclaration === undefined) {
return null;
}
const meta = this.compiler.getMeta(symbol.tsSymbol.valueDeclaration);
if (meta === null || meta.kind !== MetaKind.Directive) {
return null;
}
const nameNode = meta.ref.node.name;
const templateName =
symbol.kind === SymbolKind.SelectorlessComponent
? symbol.templateNode.componentName
: symbol.templateNode.name;
// Do not rename aliased references.
if (templateName !== nameNode.text) {
return null;
}
return {
type: RequestKind.SelectorlessIdentifier,
templateNode: symbol.templateNode,
identifier: nameNode,
renamePosition: {
fileName: meta.ref.node.getSourceFile().fileName,
position: nameNode.getStart(),
},
};
}
}
/**
@ -444,6 +537,14 @@ function getExpectedRenameTextAndInitialRenameEntries(
textSpan: {start: pipeNameExpr.getStart() + 1, length: pipeNameExpr.getText().length - 2},
};
entries.push(entry);
} else if (renameRequest.type === RequestKind.SelectorlessIdentifier) {
const {identifier} = renameRequest;
expectedRenameText = identifier.text;
const entry: ts.RenameLocation = {
fileName: identifier.getSourceFile().fileName,
textSpan: {start: identifier.getStart(), length: identifier.getWidth()},
};
entries.push(entry);
} else {
// TODO(atscott): Implement other types of special renames
return null;

View file

@ -20,12 +20,16 @@ import {
TmplAstReference,
TmplAstTextAttribute,
TmplAstVariable,
TmplAstComponent,
TmplAstDirective,
} from '@angular/compiler';
import {NgCompiler} from '@angular/compiler-cli/src/ngtsc/core';
import {absoluteFrom} from '@angular/compiler-cli/src/ngtsc/file_system';
import {DirectiveMeta, PipeMeta} from '@angular/compiler-cli/src/ngtsc/metadata';
import {
DirectiveSymbol,
SelectorlessComponentSymbol,
SelectorlessDirectiveSymbol,
Symbol,
SymbolKind,
TcbLocation,
@ -216,6 +220,17 @@ export function getTargetDetailsAtTemplatePosition(
});
break;
}
case SymbolKind.SelectorlessDirective:
case SymbolKind.SelectorlessComponent:
const dirPosition = getPositionForDirective(symbol);
if (dirPosition !== null) {
details.push({
typescriptLocations: [dirPosition],
templateTarget,
symbol,
});
}
break;
}
}
@ -228,17 +243,31 @@ export function getTargetDetailsAtTemplatePosition(
function getPositionsForDirectives(directives: Set<DirectiveSymbol>): FilePosition[] {
const allDirectives: FilePosition[] = [];
for (const dir of directives.values()) {
const dirClass = dir.tsSymbol.valueDeclaration;
if (dirClass === undefined || !ts.isClassDeclaration(dirClass) || dirClass.name === undefined) {
continue;
const position = getPositionForDirective(dir);
if (position !== null) {
allDirectives.push(position);
}
}
return allDirectives;
}
const {fileName} = dirClass.getSourceFile();
const position = dirClass.name.getStart();
allDirectives.push({fileName, position});
/** Gets the `FilePosition` for a single directive symbol. */
function getPositionForDirective(
directive: DirectiveSymbol | SelectorlessComponentSymbol | SelectorlessDirectiveSymbol,
): FilePosition | null {
const declaration = directive.tsSymbol?.valueDeclaration;
if (
declaration !== undefined &&
ts.isClassDeclaration(declaration) &&
declaration.name !== undefined
) {
const {fileName} = declaration.getSourceFile();
const position = declaration.name.getStart();
return {fileName, position};
}
return allDirectives;
return null;
}
/**
@ -358,8 +387,10 @@ export function getRenameTextAndSpanAtPosition(
span.length -= 2;
}
return {text, span};
} else if (node instanceof TmplAstElement) {
} else if (node instanceof TmplAstElement || node instanceof TmplAstDirective) {
return {text: node.name, span: toTextSpan(node.startSourceSpan)};
} else if (node instanceof TmplAstComponent) {
return {text: node.componentName, span: toTextSpan(node.startSourceSpan)};
}
return null;
@ -380,3 +411,72 @@ export function getParentClassMeta(
}
return compiler.getMeta(parentClass);
}
/**
* Converts a given `ts.DocumentSpan` in a shim file into one or more spans in the template,
* representing a selectorless component or directive. There can be more than one return value
* when a component has a closing tag.
*/
export function getSelectorlessTemplateSpanFromTcbLocations(
shimDocumentSpan: ts.DocumentSpan,
templateTypeChecker: TemplateTypeChecker,
program: ts.Program,
node: TmplAstComponent | TmplAstDirective,
): ts.DocumentSpan[] | null {
const sf = program.getSourceFile(shimDocumentSpan.fileName);
if (sf === undefined) {
return null;
}
let tcbNode = findTightestNode(sf, shimDocumentSpan.textSpan.start);
if (tcbNode === undefined) {
return null;
}
// Variables in the typecheck block are generated with the type on the right hand
// side: `var _t1 = null! as i1.DirA`. Finding references of DirA will return the type
// assertion and we need to map it back to the variable identifier _t1.
if (hasExpressionIdentifier(sf, tcbNode, ExpressionIdentifier.VARIABLE_AS_EXPRESSION)) {
while (tcbNode && !ts.isVariableDeclaration(tcbNode)) {
tcbNode = tcbNode.parent;
}
}
const mapping = getTemplateLocationFromTcbLocation(
templateTypeChecker,
absoluteFrom(shimDocumentSpan.fileName),
/* tcbIsShim */ true,
tcbNode.getStart(),
);
if (mapping === null) {
return null;
}
const fileName = mapping.templateUrl;
const {length} = node instanceof TmplAstComponent ? node.componentName : node.name;
const spans: ts.DocumentSpan[] = [
{
fileName,
textSpan: {
// +1 because of the opening `<` or `@`.
start: node.startSourceSpan.start.offset + 1,
length,
},
},
];
// If it's not a self-closing template tag, we need to rename the end tag too.
if (node instanceof TmplAstComponent && node.endSourceSpan?.toString().startsWith('</')) {
spans.push({
fileName,
textSpan: {
// +2 because of the `</`.
start: node.endSourceSpan.start.offset + 2,
length,
},
});
}
return spans;
}

View file

@ -16,6 +16,7 @@ import {
humanizeDocumentSpanLike,
LanguageServiceTestEnv,
OpenBuffer,
Project,
} from '../testing';
describe('find references and rename locations', () => {
@ -2109,6 +2110,446 @@ describe('find references and rename locations', () => {
});
});
describe('selectorless components', () => {
let project: Project;
beforeEach(() => {
initMockFileSystem('Native');
env = LanguageServiceTestEnv.setup();
project = env.addProject(
'test',
{
'app.ts': `
import {Component} from '@angular/core';
import {TestComponent} from './test-component';
@Component({templateUrl: './app.html'})
export class AppCmp {
stringValue = 'hello';
handleEvent() {}
}
`,
'test-component.ts': `
import {Component, EventEmitter, Input, Output} from '@angular/core';
@Component({template: ''})
export class TestComponent {
@Input() name!: string;
@Output() testEvent = new EventEmitter<string>();
}
`,
'app.html': 'Will be overridden',
},
{_enableSelectorless: true},
);
});
describe('references', () => {
it('should find references to selectorless component from source file', () => {
const file = project.openFile('test-component.ts');
const template = project.openFile('app.html');
template.contents = '<TestComponent/>';
file.moveCursorToText('export class TestComp¦onent {');
const refs = getReferencesAtPosition(file)!;
expect(refs.length).toBe(3);
assertTextSpans(refs, ['TestComponent', '<TestComponent/>']);
assertFileNames(refs, ['test-component.ts', 'app.html', 'app.ts']);
});
it('should find references to selectorless component from template', () => {
const template = project.openFile('app.html');
template.contents = '<TestComponent/>';
template.moveCursorToText('<TestCom¦ponent/>');
const refs = getReferencesAtPosition(template)!;
expect(refs.length).toBe(3);
assertTextSpans(refs, ['TestComponent', '<TestComponent/>']);
assertFileNames(refs, ['test-component.ts', 'app.html', 'app.ts']);
});
it('should find references to selectorless component inputs from source file', () => {
const file = project.openFile('test-component.ts');
const template = project.openFile('app.html');
template.contents = '<TestComponent [name]="stringValue"/>';
file.moveCursorToText('@Input() na¦me!: string;');
const refs = getReferencesAtPosition(file)!;
expect(refs.length).toBe(2);
assertTextSpans(refs, ['name']);
assertFileNames(refs, ['test-component.ts', 'app.html']);
});
it('should find references to selectorless component inputs from template', () => {
const template = project.openFile('app.html');
template.contents = '<TestComponent [name]="stringValue"/>';
template.moveCursorToText('[na¦me]');
const refs = getReferencesAtPosition(template)!;
expect(refs.length).toBe(2);
assertTextSpans(refs, ['name']);
assertFileNames(refs, ['test-component.ts', 'app.html']);
});
it('should find references to selectorless component outputs from source file', () => {
const file = project.openFile('test-component.ts');
const template = project.openFile('app.html');
template.contents = '<TestComponent (testEvent)="handleEvent()"/>';
file.moveCursorToText('@Output() test¦Event = new EventEmitter<string>();');
const refs = getReferencesAtPosition(file)!;
expect(refs.length).toBe(2);
assertTextSpans(refs, ['testEvent']);
assertFileNames(refs, ['test-component.ts', 'app.html']);
});
it('should find references to selectorless component outputs from template', () => {
const template = project.openFile('app.html');
template.contents = '<TestComponent (testEvent)="handleEvent()"/>';
template.moveCursorToText('(tes¦tEvent)');
const refs = getReferencesAtPosition(template)!;
expect(refs.length).toBe(2);
assertTextSpans(refs, ['testEvent']);
assertFileNames(refs, ['test-component.ts', 'app.html']);
});
});
describe('rename locations', () => {
it('should find rename locations of selectorless component from template', () => {
const template = project.openFile('app.html');
template.contents = '<TestComponent/>';
template.moveCursorToText('<TestCom¦ponent/>');
const renameLocations = getRenameLocationsAtPosition(template)!;
// There are 3 locations that need to be renamed:
// - Source file where the component is defined.
// - Self-closing tag in the template.
// - Import in the app component.
expect(renameLocations.length).toBe(3);
assertTextSpans(renameLocations, ['TestComponent']);
assertFileNames(renameLocations, ['test-component.ts', 'app.html', 'app.ts']);
});
it('should find rename locations for complex selectorless component', () => {
const template = project.openFile('app.html');
template.contents = '<TestComponent:a hello="world">Hello</TestComponent:a>';
template.moveCursorToText('<TestCom¦ponent:a');
const renameLocations = getRenameLocationsAtPosition(template)!;
// There are 4 locations that need to be renamed:
// - Source file where the component is defined.
// - Opening tag in the template.
// - Closing tag in the template.
// - Import in the app component.
expect(renameLocations.length).toBe(4);
assertTextSpans(renameLocations, ['TestComponent']);
assertFileNames(renameLocations, ['test-component.ts', 'app.html', 'app.html', 'app.ts']);
});
it('should find rename locations to selectorless component inputs from source file', () => {
const file = project.openFile('test-component.ts');
const template = project.openFile('app.html');
template.contents = '<TestComponent [name]="stringValue"/>';
file.moveCursorToText('@Input() na¦me!: string;');
const refs = getRenameLocationsAtPosition(file)!;
expect(refs.length).toBe(2);
assertTextSpans(refs, ['name']);
assertFileNames(refs, ['test-component.ts', 'app.html']);
});
it('should find rename locations to selectorless component inputs from template', () => {
const template = project.openFile('app.html');
template.contents = '<TestComponent [name]="stringValue"/>';
template.moveCursorToText('[na¦me]');
const refs = getRenameLocationsAtPosition(template)!;
expect(refs.length).toBe(2);
assertTextSpans(refs, ['name']);
assertFileNames(refs, ['test-component.ts', 'app.html']);
});
it('should find rename locations to selectorless component outputs from source file', () => {
const file = project.openFile('test-component.ts');
const template = project.openFile('app.html');
template.contents = '<TestComponent (testEvent)="handleEvent()"/>';
file.moveCursorToText('@Output() test¦Event = new EventEmitter<string>();');
const refs = getRenameLocationsAtPosition(file)!;
expect(refs.length).toBe(2);
assertTextSpans(refs, ['testEvent']);
assertFileNames(refs, ['test-component.ts', 'app.html']);
});
it('should find rename locations to selectorless component outputs from template', () => {
const template = project.openFile('app.html');
template.contents = '<TestComponent (testEvent)="handleEvent()"/>';
template.moveCursorToText('(tes¦tEvent)');
const refs = getRenameLocationsAtPosition(template)!;
expect(refs.length).toBe(2);
assertTextSpans(refs, ['testEvent']);
assertFileNames(refs, ['test-component.ts', 'app.html']);
});
it('should handle rename request for selectorless component from source file', () => {
const file = project.openFile('test-component.ts');
const template = project.openFile('app.html');
template.contents = '<TestComponent/>';
file.moveCursorToText('export class TestCom¦ponent {');
const renameLocations = getRenameLocationsAtPosition(file)!;
expect(renameLocations).toBeUndefined();
// TODO(crisbeto): investigate if we can make this work.
// It would involve looking for all the component references and renaming them.
// expect(renameLocations.length).toBe(3);
// assertTextSpans(renameLocations, ['TestComponent']);
// assertFileNames(renameLocations, ['test-component.ts', 'app.html', 'app.ts']);
});
});
});
describe('selectorless directives', () => {
let project: Project;
beforeEach(() => {
initMockFileSystem('Native');
env = LanguageServiceTestEnv.setup();
project = env.addProject(
'test',
{
'app.ts': `
import {Component} from '@angular/core';
import {TestDirective} from './test-directive';
@Component({templateUrl: './app.html'})
export class AppCmp {
numberValue!: number;
handleEvent() {}
}
`,
'test-directive.ts': `
import {Directive, EventEmitter, Input, Output} from '@angular/core';
@Directive()
export class TestDirective {
@Input() value!: number;
@Output() testEvent = new EventEmitter<number>();
}
`,
'app.html': 'Will be overridden',
},
{_enableSelectorless: true},
);
});
describe('references', () => {
it('should find references to selectorless directive from source file', () => {
const file = project.openFile('test-directive.ts');
const template = project.openFile('app.html');
template.contents = '<div @TestDirective></div>';
file.moveCursorToText('export class TestDir¦ective {');
const refs = getReferencesAtPosition(file)!;
expect(refs.length).toBe(3);
assertTextSpans(refs, ['TestDirective', '@TestDirective']);
assertFileNames(refs, ['test-directive.ts', 'app.html', 'app.ts']);
});
it('should find references to selectorless directive from template', () => {
const template = project.openFile('app.html');
template.contents = '<div @TestDirective></div>';
template.moveCursorToText('<div @TestDir¦ective></div>');
const refs = getReferencesAtPosition(template)!;
expect(refs.length).toBe(3);
assertTextSpans(refs, ['TestDirective', '@TestDirective']);
assertFileNames(refs, ['test-directive.ts', 'app.html', 'app.ts']);
});
it('should find references to selectorless directive inputs from source file', () => {
const file = project.openFile('test-directive.ts');
const template = project.openFile('app.html');
template.contents = '<div @TestDirective([value]="numberValue")></div>';
file.moveCursorToText('@Input() val¦ue!: number;');
const refs = getReferencesAtPosition(file)!;
expect(refs.length).toBe(2);
assertTextSpans(refs, ['value']);
assertFileNames(refs, ['test-directive.ts', 'app.html']);
});
it('should find references to selectorless directive inputs from template', () => {
const template = project.openFile('app.html');
template.contents = '<div @TestDirective([value]="numberValue")></div>';
template.moveCursorToText('[val¦ue]');
const refs = getReferencesAtPosition(template)!;
expect(refs.length).toBe(2);
assertTextSpans(refs, ['value']);
assertFileNames(refs, ['test-directive.ts', 'app.html']);
});
it('should find references to selectorless directive outputs from source file', () => {
const file = project.openFile('test-directive.ts');
const template = project.openFile('app.html');
template.contents = '<div @TestDirective((testEvent)="handleEvent()")></div>';
file.moveCursorToText('@Output() test¦Event = new EventEmitter<number>();');
const refs = getReferencesAtPosition(file)!;
expect(refs.length).toBe(2);
assertTextSpans(refs, ['testEvent']);
assertFileNames(refs, ['test-directive.ts', 'app.html']);
});
it('should find references to selectorless directive outputs from template', () => {
const template = project.openFile('app.html');
template.contents = '<div @TestDirective((testEvent)="handleEvent()")></div>';
template.moveCursorToText('(tes¦tEvent)');
const refs = getReferencesAtPosition(template)!;
expect(refs.length).toBe(2);
assertTextSpans(refs, ['testEvent']);
assertFileNames(refs, ['test-directive.ts', 'app.html']);
});
});
describe('rename locations', () => {
it('should find rename locations of selectorless directive from template', () => {
const template = project.openFile('app.html');
template.contents = '<div @TestDirective></div>';
template.moveCursorToText('@TestDir¦ective');
const renameLocations = getRenameLocationsAtPosition(template)!;
// There are 3 locations that need to be renamed:
// - Source file where the directive is defined.
// - Reference on the `div` node.
// - Import in the app component.
expect(renameLocations.length).toBe(3);
assertTextSpans(renameLocations, ['TestDirective']);
assertFileNames(renameLocations, ['test-directive.ts', 'app.html', 'app.ts']);
});
it('should find rename locations for selectorless directive with bindings', () => {
const template = project.openFile('app.html');
template.contents = '<div @TestDirective([value]="123")>Hello</div>';
template.moveCursorToText('@TestDir¦ective(');
const renameLocations = getRenameLocationsAtPosition(template)!;
// There are 3 locations that need to be renamed:
// - Source file where the directive is defined.
// - Reference on the `div` node.
// - Import in the app component.
expect(renameLocations.length).toBe(3);
assertTextSpans(renameLocations, ['TestDirective']);
assertFileNames(renameLocations, ['test-directive.ts', 'app.html', 'app.ts']);
});
it('should find rename locations to selectorless directive inputs from source file', () => {
const file = project.openFile('test-directive.ts');
const template = project.openFile('app.html');
template.contents = '<div @TestDirective([value]="numberValue")></div>';
file.moveCursorToText('@Input() val¦ue!: number;');
const refs = getRenameLocationsAtPosition(file)!;
expect(refs.length).toBe(2);
assertTextSpans(refs, ['value']);
assertFileNames(refs, ['test-directive.ts', 'app.html']);
});
it('should find rename locations to selectorless directive inputs from template', () => {
const template = project.openFile('app.html');
template.contents = '<div @TestDirective([value]="numberValue")></div>';
template.moveCursorToText('[val¦ue]');
const refs = getRenameLocationsAtPosition(template)!;
expect(refs.length).toBe(2);
assertTextSpans(refs, ['value']);
assertFileNames(refs, ['test-directive.ts', 'app.html']);
});
it('should find rename locations to selectorless directive outputs from source file', () => {
const file = project.openFile('test-directive.ts');
const template = project.openFile('app.html');
template.contents = '<div @TestDirective((testEvent)="handleEvent()")></div>';
file.moveCursorToText('@Output() test¦Event = new EventEmitter<number>();');
const refs = getRenameLocationsAtPosition(file)!;
expect(refs.length).toBe(2);
assertTextSpans(refs, ['testEvent']);
assertFileNames(refs, ['test-directive.ts', 'app.html']);
});
it('should find rename locations to selectorless directive outputs from template', () => {
const template = project.openFile('app.html');
template.contents = '<div @TestDirective((testEvent)="handleEvent()")></div>';
template.moveCursorToText('(tes¦tEvent)');
const refs = getRenameLocationsAtPosition(template)!;
expect(refs.length).toBe(2);
assertTextSpans(refs, ['testEvent']);
assertFileNames(refs, ['test-directive.ts', 'app.html']);
});
it('should handle rename request for selectorless component from source file', () => {
const file = project.openFile('test-directive.ts');
const template = project.openFile('app.html');
template.contents = '<div @TestDirective></div>';
file.moveCursorToText('export class TestDir¦ective {');
const renameLocations = getRenameLocationsAtPosition(file)!;
expect(renameLocations).toBeUndefined();
// TODO(crisbeto): investigate if we can make this work.
// It would involve looking for all the component references and renaming them.
// expect(renameLocations.length).toBe(3);
// assertTextSpans(renameLocations, ['TestDirective']);
// assertFileNames(renameLocations, ['test-directive.ts', 'app.html', 'app.ts']);
});
});
});
describe('aliased selectorless', () => {
let project: Project;
beforeEach(() => {
initMockFileSystem('Native');
env = LanguageServiceTestEnv.setup();
project = env.addProject(
'test',
{
'app.ts': `
import {Component} from '@angular/core';
import {TestComponent as AliasedComponent} from './test-component';
import {TestDirective as AliasedDirective} from './test-directive';
@Component({templateUrl: './app.html'})
export class AppCmp {
numberValue!: number;
handleEvent() {}
}
`,
'test-component.ts': `
import {Component, EventEmitter, Input, Output} from '@angular/core';
@Component({template: ''})
export class TestComponent {
@Input() name!: string;
@Output() testEvent = new EventEmitter<string>();
}
`,
'test-directive.ts': `
import {Directive, EventEmitter, Input, Output} from '@angular/core';
@Directive()
export class TestDirective {
@Input() value!: number;
@Output() testEvent = new EventEmitter<number>();
}
`,
'app.html': 'Will be overridden',
},
{_enableSelectorless: true},
);
});
it('should not rename aliased selectorless component references', () => {
const template = project.openFile('app.html');
template.contents = '<AliasedComponent/>';
template.moveCursorToText('<AliasedCom¦ponent/>');
expect(getRenameLocationsAtPosition(template)).toBeUndefined();
});
it('should not rename aliased selectorless directive references', () => {
const template = project.openFile('app.html');
template.contents = '<div @AliasedDirective></div>';
template.moveCursorToText('@AliasedDir¦ective');
expect(getRenameLocationsAtPosition(template)).toBeUndefined();
});
});
function getReferencesAtPosition(file: OpenBuffer) {
env.expectNoSourceDiagnostics();
const result = file.getReferencesAtPosition();