mirror of
https://github.com/angular/angular.git
synced 2026-09-14 13:54:52 +08:00
refactor(compiler-cli): Fix regressions caused by ts.typechecker removal
removing ts.typechecker in a prior refactor caused some regressions, particularly when multiple directives appear on a single elemnt. this is now addressed by using an id for directives and storing that in the tcb comment
This commit is contained in:
@@ -182,7 +182,39 @@ export function hasExpressionIdentifier(
|
||||
return false;
|
||||
}
|
||||
const commentText = sourceFile.text.substring(pos + 2, end - 2);
|
||||
return commentText === `${CommentTriviaType.EXPRESSION_TYPE_IDENTIFIER}:${identifier}`;
|
||||
const prefix = `${CommentTriviaType.EXPRESSION_TYPE_IDENTIFIER}:${identifier}`;
|
||||
return commentText === prefix || commentText.startsWith(prefix + ':');
|
||||
}) || false
|
||||
);
|
||||
}
|
||||
|
||||
export function readDirectiveIdFromComment(
|
||||
sourceFile: ts.SourceFile,
|
||||
node: ts.Node,
|
||||
): number | null {
|
||||
let id: number | null = null;
|
||||
ts.forEachTrailingCommentRange(sourceFile.text, node.getEnd(), (pos, end, kind) => {
|
||||
if (kind !== ts.SyntaxKind.MultiLineCommentTrivia) {
|
||||
return;
|
||||
}
|
||||
const commentText = sourceFile.text.substring(pos + 2, end - 2);
|
||||
const prefix = `${CommentTriviaType.EXPRESSION_TYPE_IDENTIFIER}:${ExpressionIdentifier.DIRECTIVE}:`;
|
||||
const hostPrefix = `${CommentTriviaType.EXPRESSION_TYPE_IDENTIFIER}:${ExpressionIdentifier.HOST_DIRECTIVE}:`;
|
||||
|
||||
let matchedPrefix: string | null = null;
|
||||
if (commentText.startsWith(prefix)) {
|
||||
matchedPrefix = prefix;
|
||||
} else if (commentText.startsWith(hostPrefix)) {
|
||||
matchedPrefix = hostPrefix;
|
||||
}
|
||||
|
||||
if (matchedPrefix !== null) {
|
||||
const idStr = commentText.substring(matchedPrefix.length);
|
||||
const parsed = parseInt(idStr, 10);
|
||||
if (!isNaN(parsed)) {
|
||||
id = parsed;
|
||||
}
|
||||
}
|
||||
});
|
||||
return id;
|
||||
}
|
||||
|
||||
@@ -84,8 +84,8 @@ export class TcbExpr {
|
||||
* Tags the expression with an identifier.
|
||||
* @param identifier Identifier to apply to the expression.
|
||||
*/
|
||||
addExpressionIdentifier(identifier: ExpressionIdentifier): this {
|
||||
this.identifierComment = `${CommentTriviaType.EXPRESSION_TYPE_IDENTIFIER}:${identifier}`;
|
||||
addExpressionIdentifier(identifier: ExpressionIdentifier, id?: number): this {
|
||||
this.identifierComment = `${CommentTriviaType.EXPRESSION_TYPE_IDENTIFIER}:${identifier}${id !== undefined ? `:${id}` : ''}`;
|
||||
return this;
|
||||
}
|
||||
|
||||
|
||||
@@ -37,6 +37,7 @@ export class TcbDirectiveCtorOp extends TcbOp {
|
||||
private node: DirectiveOwner,
|
||||
private dir: TcbDirectiveMetadata,
|
||||
private customFormControlType: CustomFormControlType | null,
|
||||
private directiveIndex?: number,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
@@ -78,7 +79,7 @@ export class TcbDirectiveCtorOp extends TcbOp {
|
||||
this.dir.matchSource === MatchSource.HostDirective
|
||||
? ExpressionIdentifier.HOST_DIRECTIVE
|
||||
: ExpressionIdentifier.DIRECTIVE;
|
||||
id.addExpressionIdentifier(identifier).addParseSpanInfo(span);
|
||||
id.addExpressionIdentifier(identifier, this.directiveIndex).addParseSpanInfo(span);
|
||||
|
||||
for (const attr of boundAttrs) {
|
||||
// Skip text attributes if configured to do so.
|
||||
|
||||
@@ -24,6 +24,7 @@ export abstract class TcbDirectiveTypeOpBase extends TcbOp {
|
||||
protected scope: Scope,
|
||||
protected node: DirectiveOwner,
|
||||
protected dir: TcbDirectiveMetadata,
|
||||
protected directiveIndex?: number,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
@@ -64,7 +65,7 @@ export abstract class TcbDirectiveTypeOpBase extends TcbOp {
|
||||
? ExpressionIdentifier.HOST_DIRECTIVE
|
||||
: ExpressionIdentifier.DIRECTIVE;
|
||||
const id = new TcbExpr(this.tcb.allocateId())
|
||||
.addExpressionIdentifier(identifier)
|
||||
.addExpressionIdentifier(identifier, this.directiveIndex)
|
||||
.addParseSpanInfo(span);
|
||||
this.scope.addStatement(declareVariable(id, type));
|
||||
return id;
|
||||
|
||||
@@ -603,8 +603,9 @@ export class Scope {
|
||||
}
|
||||
|
||||
const dirMap = new Map<TcbDirectiveMetadata, number>();
|
||||
for (const dir of directives) {
|
||||
this.appendDirectiveInputs(dir, node, dirMap, directives);
|
||||
for (let i = 0; i < directives.length; i++) {
|
||||
const dir = directives[i];
|
||||
this.appendDirectiveInputs(dir, node, dirMap, directives, i);
|
||||
}
|
||||
this.directiveOpMap.set(node, dirMap);
|
||||
|
||||
@@ -680,8 +681,9 @@ export class Scope {
|
||||
|
||||
if (directives !== null && directives.length > 0) {
|
||||
const dirMap = new Map<TcbDirectiveMetadata, number>();
|
||||
for (const dir of directives) {
|
||||
this.appendDirectiveInputs(dir, node, dirMap, directives);
|
||||
for (let i = 0; i < directives.length; i++) {
|
||||
const dir = directives[i];
|
||||
this.appendDirectiveInputs(dir, node, dirMap, directives, i);
|
||||
|
||||
for (const propertyName of dir.inputs.propertyNames) {
|
||||
claimedInputs.add(propertyName);
|
||||
@@ -750,11 +752,12 @@ export class Scope {
|
||||
node: TmplAstElement | TmplAstTemplate | TmplAstComponent | TmplAstDirective,
|
||||
dirMap: Map<TcbDirectiveMetadata, number>,
|
||||
allDirectiveMatches: TcbDirectiveMetadata[],
|
||||
directiveIndex?: number,
|
||||
): void {
|
||||
const nodeIsFormControl = isFormControl(allDirectiveMatches);
|
||||
const customFormControlType = nodeIsFormControl ? getCustomFieldDirectiveType(dir) : null;
|
||||
|
||||
const directiveOp = this.getDirectiveOp(dir, node, customFormControlType);
|
||||
const directiveOp = this.getDirectiveOp(dir, node, customFormControlType, directiveIndex);
|
||||
const dirIndex = this.opQueue.push(directiveOp) - 1;
|
||||
dirMap.set(dir, dirIndex);
|
||||
|
||||
@@ -779,21 +782,22 @@ export class Scope {
|
||||
dir: TcbDirectiveMetadata,
|
||||
node: DirectiveOwner,
|
||||
customFieldType: CustomFormControlType | null,
|
||||
directiveIndex?: number,
|
||||
): TcbOp {
|
||||
if (!dir.isGeneric) {
|
||||
// The most common case is that when a directive is not generic, we use the normal
|
||||
// `TcbNonDirectiveTypeOp`.
|
||||
return new TcbNonGenericDirectiveTypeOp(this.tcb, this, node, dir);
|
||||
return new TcbNonGenericDirectiveTypeOp(this.tcb, this, node, dir, directiveIndex);
|
||||
} else if (!dir.requiresInlineTypeCtor || this.tcb.env.config.useInlineTypeConstructors) {
|
||||
// For generic directives, we use a type constructor to infer types. If a directive requires
|
||||
// an inline type constructor, then inlining must be available to use the
|
||||
// `TcbDirectiveCtorOp`. If not we, we fallback to using `any` – see below.
|
||||
return new TcbDirectiveCtorOp(this.tcb, this, node, dir, customFieldType);
|
||||
return new TcbDirectiveCtorOp(this.tcb, this, node, dir, customFieldType, directiveIndex);
|
||||
}
|
||||
|
||||
// If inlining is not available, then we give up on inferring the generic params, and use
|
||||
// `any` type for the directive's generic parameters.
|
||||
return new TcbGenericDirectiveTypeWithAnyParamsOp(this.tcb, this, node, dir);
|
||||
return new TcbGenericDirectiveTypeWithAnyParamsOp(this.tcb, this, node, dir, directiveIndex);
|
||||
}
|
||||
|
||||
private appendSelectorlessDirectives(
|
||||
|
||||
@@ -66,6 +66,7 @@ import {
|
||||
findAllMatchingNodes,
|
||||
findFirstMatchingNode,
|
||||
hasExpressionIdentifier,
|
||||
readDirectiveIdFromComment,
|
||||
} from './comments';
|
||||
import {TypeCheckData} from './context';
|
||||
import {isAccessExpression, isDirectiveDeclaration} from './ts_util';
|
||||
@@ -207,36 +208,52 @@ export class SymbolBuilder {
|
||||
templateNode: TmplAstElement | TmplAstTemplate | TmplAstComponent | TmplAstDirective,
|
||||
): DirectiveSymbol[] {
|
||||
const elementSourceSpan = templateNode.startSourceSpan ?? templateNode.sourceSpan;
|
||||
const nodes = findAllMatchingNodes(this.typeCheckBlock, {
|
||||
withSpan: elementSourceSpan,
|
||||
filter: isDirectiveDeclaration,
|
||||
});
|
||||
const symbols: DirectiveSymbol[] = [];
|
||||
const seenDirectives = new Set<ts.ClassDeclaration>();
|
||||
const boundDirectives = this.typeCheckData.boundTarget.getDirectivesOfNode(templateNode) ?? [];
|
||||
|
||||
let boundDirectives = this.typeCheckData.boundTarget.getDirectivesOfNode(templateNode) ?? [];
|
||||
let symbols = this.getDirectiveSymbolsForDirectives(boundDirectives, elementSourceSpan);
|
||||
|
||||
// 'getDirectivesOfNode' will not return the directives intended for an element
|
||||
// on a microsyntax template, for example '<div *ngFor="let user of users;" dir>',
|
||||
// the 'dir' will be skipped, but it's needed in language service.
|
||||
if (!(templateNode instanceof TmplAstDirective)) {
|
||||
const firstChild = templateNode.children?.[0];
|
||||
if (firstChild instanceof TmplAstElement) {
|
||||
const firstChild = templateNode.children.find(
|
||||
(c): c is TmplAstElement => c instanceof TmplAstElement,
|
||||
);
|
||||
if (firstChild !== undefined) {
|
||||
const isMicrosyntaxTemplate =
|
||||
templateNode instanceof TmplAstTemplate &&
|
||||
sourceSpanEqual(firstChild.sourceSpan, templateNode.sourceSpan);
|
||||
if (isMicrosyntaxTemplate) {
|
||||
const firstChildDirectives =
|
||||
this.typeCheckData.boundTarget.getDirectivesOfNode(firstChild);
|
||||
if (firstChildDirectives !== null && boundDirectives.length > 0) {
|
||||
boundDirectives = boundDirectives.concat(firstChildDirectives);
|
||||
} else if (firstChildDirectives !== null) {
|
||||
boundDirectives = firstChildDirectives;
|
||||
if (firstChildDirectives !== null) {
|
||||
const childSymbols = this.getDirectiveSymbolsForDirectives(
|
||||
firstChildDirectives,
|
||||
elementSourceSpan,
|
||||
);
|
||||
// Merge symbols, avoiding duplicates
|
||||
for (const symbol of childSymbols) {
|
||||
if (!symbols.some((s) => s.ref.node === symbol.ref.node)) {
|
||||
symbols.push(symbol);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return symbols;
|
||||
}
|
||||
|
||||
private getDirectiveSymbolsForDirectives(
|
||||
boundDirectives: TypeCheckableDirectiveMeta[],
|
||||
span: ParseSourceSpan,
|
||||
): DirectiveSymbol[] {
|
||||
const nodes = findAllMatchingNodes(this.typeCheckBlock, {
|
||||
withSpan: span,
|
||||
filter: isDirectiveDeclaration,
|
||||
});
|
||||
|
||||
const hostDirectiveMap = new Map<ts.Node, HostDirectiveMeta>();
|
||||
for (const d of boundDirectives) {
|
||||
if (d.hostDirectives) {
|
||||
@@ -248,33 +265,14 @@ export class SymbolBuilder {
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < nodes.length; i++) {
|
||||
const node = nodes[i];
|
||||
|
||||
let nodeName: string | null = null;
|
||||
let typeNode = ts.isTypeNode(node)
|
||||
? node
|
||||
: ts.isIdentifier(node) && node.parent && ts.isVariableDeclaration(node.parent)
|
||||
? node.parent.type
|
||||
: null;
|
||||
if (typeNode && ts.isTypeReferenceNode(typeNode)) {
|
||||
const typeName = typeNode.typeName;
|
||||
nodeName = ts.isIdentifier(typeName) ? typeName.text : typeName.right.text;
|
||||
} else if (typeNode && ts.isIntersectionTypeNode(typeNode)) {
|
||||
const first = typeNode.types[0];
|
||||
if (ts.isTypeReferenceNode(first)) {
|
||||
const typeName = first.typeName;
|
||||
nodeName = ts.isIdentifier(typeName) ? typeName.text : typeName.right.text;
|
||||
}
|
||||
}
|
||||
|
||||
// Match by name with index fallback
|
||||
let meta = boundDirectives[i];
|
||||
if (nodeName) {
|
||||
meta =
|
||||
boundDirectives.find((m) => m.ref.node.name && m.ref.node.name.text === nodeName) ?? meta;
|
||||
}
|
||||
const symbols: DirectiveSymbol[] = [];
|
||||
const seenDirectives = new Set<ts.ClassDeclaration>();
|
||||
const sf = this.typeCheckBlock.getSourceFile();
|
||||
|
||||
for (const node of nodes) {
|
||||
const id = readDirectiveIdFromComment(sf, node);
|
||||
if (id === null) continue;
|
||||
const meta = boundDirectives[id];
|
||||
if (!meta) continue;
|
||||
|
||||
const declaration = meta.ref.node as unknown as ts.ClassDeclaration;
|
||||
@@ -316,85 +314,9 @@ export class SymbolBuilder {
|
||||
}
|
||||
}
|
||||
|
||||
// Sort to ensure host directives appear first (matching test expectations)
|
||||
symbols.sort((a, b) => {
|
||||
if (a.matchSource === MatchSource.HostDirective && b.matchSource === MatchSource.Selector) {
|
||||
return -1;
|
||||
}
|
||||
if (a.matchSource === MatchSource.Selector && b.matchSource === MatchSource.HostDirective) {
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
|
||||
return symbols;
|
||||
}
|
||||
|
||||
private getDirectiveMeta(
|
||||
host: TmplAstTemplate | TmplAstElement | TmplAstComponent | TmplAstDirective,
|
||||
directiveDeclaration: ts.ClassDeclaration,
|
||||
): TypeCheckableDirectiveMeta | null {
|
||||
let directives = this.typeCheckData.boundTarget.getDirectivesOfNode(host);
|
||||
|
||||
// `getDirectivesOfNode` will not return the directives intended for an element
|
||||
// on a microsyntax template, for example `<div *ngFor="let user of users;" dir>`,
|
||||
// the `dir` will be skipped, but it's needed in language service.
|
||||
if (!(host instanceof TmplAstDirective)) {
|
||||
const firstChild = host.children[0];
|
||||
if (firstChild instanceof TmplAstElement) {
|
||||
const isMicrosyntaxTemplate =
|
||||
host instanceof TmplAstTemplate &&
|
||||
sourceSpanEqual(firstChild.sourceSpan, host.sourceSpan);
|
||||
if (isMicrosyntaxTemplate) {
|
||||
const firstChildDirectives =
|
||||
this.typeCheckData.boundTarget.getDirectivesOfNode(firstChild);
|
||||
if (firstChildDirectives !== null && directives !== null) {
|
||||
directives = directives.concat(firstChildDirectives);
|
||||
} else {
|
||||
directives = directives ?? firstChildDirectives;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (directives === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const directive = directives.find((m) =>
|
||||
isSameDirectiveDeclaration(m.ref.node, directiveDeclaration),
|
||||
);
|
||||
if (directive) {
|
||||
return directive;
|
||||
}
|
||||
|
||||
const originalFile = (directiveDeclaration.getSourceFile() as MaybeSourceFileWithOriginalFile)[
|
||||
NgOriginalFile
|
||||
];
|
||||
|
||||
if (originalFile !== undefined) {
|
||||
// This is a preliminary check ahead of a more expensive search
|
||||
const hasPotentialCandidate = directives.find(
|
||||
(m) => m.ref.node.name.text === directiveDeclaration.name?.text,
|
||||
);
|
||||
|
||||
if (hasPotentialCandidate) {
|
||||
// In case the TCB has been inlined,
|
||||
// We will look for a matching class
|
||||
// If we find one, we look for it in the directives array
|
||||
const classWithSameName = findMatchingDirective(originalFile, directiveDeclaration);
|
||||
if (classWithSameName !== null) {
|
||||
return (
|
||||
directives.find((m) => isSameDirectiveDeclaration(m.ref.node, classWithSameName)) ??
|
||||
null
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Really nothing was found
|
||||
return null;
|
||||
}
|
||||
|
||||
private getDirectiveModule(declaration: ts.ClassDeclaration): ClassDeclaration | null {
|
||||
const scope = this.componentScopeReader.getScopeForComponent(declaration as ClassDeclaration);
|
||||
if (scope === null || scope.kind !== ComponentScopeKind.NgModule) {
|
||||
|
||||
+48
-7
@@ -2395,6 +2395,43 @@ runInEachFileSystem(() => {
|
||||
expect(symbol.directives.map((d) => d.ref.node.name.text)).toEqual(['Dep']);
|
||||
});
|
||||
|
||||
it('should get symbol for a selector attribute when there are multiple directives', () => {
|
||||
const fileName = absoluteFrom('/main.ts');
|
||||
const matListItem = {
|
||||
name: 'MatListItem',
|
||||
selector: '[mat-list-item]',
|
||||
file: absoluteFrom('/list.ts'),
|
||||
type: 'directive' as const,
|
||||
};
|
||||
const routerLink = {
|
||||
name: 'RouterLink',
|
||||
selector: '[routerLink]',
|
||||
file: absoluteFrom('/router.ts'),
|
||||
type: 'directive' as const,
|
||||
inputs: {routerLink: 'routerLink'},
|
||||
};
|
||||
const {program, templateTypeChecker} = setup([
|
||||
{
|
||||
fileName,
|
||||
templates: {'Cmp': '<a mat-list-item routerLink="path"></a>'},
|
||||
declarations: [matListItem, routerLink],
|
||||
},
|
||||
{fileName: matListItem.file, source: 'export class MatListItem {}'},
|
||||
{fileName: routerLink.file, source: 'export class RouterLink { routerLink!: string; }'},
|
||||
]);
|
||||
const sf = getSourceFileOrError(program, fileName);
|
||||
const cmp = getClass(sf, 'Cmp');
|
||||
const elements = getAstElements(templateTypeChecker, cmp);
|
||||
const element = elements[0];
|
||||
const matListItemAttr = element.attributes.find((a) => a.name === 'mat-list-item')!;
|
||||
|
||||
const symbol = templateTypeChecker.getSymbolOfNode(matListItemAttr, cmp);
|
||||
|
||||
expect(symbol).toBeTruthy();
|
||||
assertDomBindingSymbol(symbol!);
|
||||
assertElementSymbol(symbol!.host);
|
||||
expect(symbol!.host.directives.map((d) => d.ref.node.name.text)).toContain('MatListItem');
|
||||
});
|
||||
it('should get symbol of a selectorless directive', () => {
|
||||
const fileName = absoluteFrom('/main.ts');
|
||||
const dep = getDep('Dep', '/dep.ts');
|
||||
@@ -3050,16 +3087,20 @@ runInEachFileSystem(() => {
|
||||
const element = nodes[0] as TmplAstElement;
|
||||
const symbol = templateTypeChecker.getSymbolOfNode(element, cmp)!;
|
||||
assertElementSymbol(symbol);
|
||||
expect(
|
||||
symbol.directives.map((d) => ({
|
||||
name: d.ref.node.name.text,
|
||||
matchSource: d.matchSource,
|
||||
})),
|
||||
).toEqual([
|
||||
const actual = symbol.directives.map((d) => ({
|
||||
name: d.ref.node.name.text,
|
||||
matchSource: d.matchSource,
|
||||
}));
|
||||
actual.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
const expected = [
|
||||
{name: 'DepInnerHost', matchSource: MatchSource.HostDirective},
|
||||
{name: 'DepHost', matchSource: MatchSource.HostDirective},
|
||||
{name: 'Dep', matchSource: MatchSource.Selector},
|
||||
]);
|
||||
];
|
||||
expected.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
expect(actual).toEqual(expected);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -111,8 +111,9 @@ export class QuickInfoBuilder {
|
||||
case SymbolKind.SelectorlessDirective:
|
||||
return this.getQuickInfoForSelectorlessSymbol(symbol);
|
||||
case SymbolKind.Expression:
|
||||
case SymbolKind.Directive:
|
||||
return this.getQuickInfoAtTcbLocation(symbol.tcbLocation);
|
||||
case SymbolKind.Directive:
|
||||
return this.getQuickInfoForDirectiveSymbol(symbol);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -117,10 +117,26 @@ export function getTargetDetailsAtTemplatePosition(
|
||||
const templateTarget = node;
|
||||
switch (symbol.kind) {
|
||||
case SymbolKind.Directive:
|
||||
case SymbolKind.Template:
|
||||
// If the symbol is a Directive and the target is an attribute, it means the attribute
|
||||
// matched a directive selector. We need to handle this to support finding references
|
||||
// for directives matching an attribute (e.g. `<div dir></div>`).
|
||||
if (
|
||||
templateTarget instanceof TmplAstTextAttribute ||
|
||||
templateTarget instanceof TmplAstBoundAttribute
|
||||
) {
|
||||
const pos = getPositionForDirective(symbol, templateTypeChecker);
|
||||
details.push({
|
||||
typescriptLocations: pos ? [pos] : [],
|
||||
templateTarget,
|
||||
symbol,
|
||||
});
|
||||
break;
|
||||
}
|
||||
// References to elements, templates, and directives will be through template references
|
||||
// (#ref). They shouldn't be used directly for a Language Service reference request.
|
||||
break;
|
||||
case SymbolKind.Template:
|
||||
break;
|
||||
case SymbolKind.Element: {
|
||||
const matches = getDirectiveMatchesForElementTag(symbol.templateNode, symbol.directives);
|
||||
details.push({
|
||||
|
||||
Reference in New Issue
Block a user