Skip to content

Commit 39ccaf4

Browse files
eneajahoalxhub
authored andcommitted
fix(compiler-cli): correctly get the type of nested function call expressions (angular#57010)
This PR fixes a bug where the type of a nested function call expression was incorrectly being returned as null. PR Close angular#57010
1 parent c3115b8 commit 39ccaf4

File tree

8 files changed

+205
-15
lines changed

8 files changed

+205
-15
lines changed

packages/compiler-cli/src/ngtsc/typecheck/extended/test/checks/nullish_coalescing_not_nullable/nullish_coalescing_not_nullable_spec.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,29 @@ runInEachFileSystem(() => {
125125
expect(diags.length).toBe(0);
126126
});
127127

128+
it('should not produce diagnostics for nullish coalescing in a chain', () => {
129+
const fileName = absoluteFrom('/main.ts');
130+
const {program, templateTypeChecker} = setup([
131+
{
132+
fileName,
133+
templates: {
134+
'TestCmp': `{{ var1 !== '' && (items?.length ?? 0) > 0 }}`,
135+
},
136+
source: 'export class TestCmp { var1 = "text"; items: string[] | undefined = [] }',
137+
},
138+
]);
139+
const sf = getSourceFileOrError(program, fileName);
140+
const component = getClass(sf, 'TestCmp');
141+
const extendedTemplateChecker = new ExtendedTemplateCheckerImpl(
142+
templateTypeChecker,
143+
program.getTypeChecker(),
144+
[nullishCoalescingNotNullableFactory],
145+
{strictNullChecks: true} /* options */,
146+
);
147+
const diags = extendedTemplateChecker.getDiagnosticsForComponent(component);
148+
expect(diags.length).toBe(0);
149+
});
150+
128151
it('should not produce nullish coalescing warning for the any type', () => {
129152
const fileName = absoluteFrom('/main.ts');
130153
const {program, templateTypeChecker} = setup([

packages/compiler-cli/src/ngtsc/typecheck/src/template_symbol_builder.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
import {
1010
AST,
11+
ASTWithName,
1112
ASTWithSource,
1213
BindingPipe,
1314
ParseSourceSpan,
@@ -708,9 +709,13 @@ export class SymbolBuilder {
708709

709710
let withSpan = expression.sourceSpan;
710711

711-
// The `name` part of a `PropertyWrite` and a non-safe `Call` does not have its own
712+
// The `name` part of a `PropertyWrite` and `ASTWithName` do not have their own
712713
// AST so there is no way to retrieve a `Symbol` for just the `name` via a specific node.
713-
if (expression instanceof PropertyWrite) {
714+
// Also skipping SafePropertyReads as it breaks nullish coalescing not nullable extended diagnostic
715+
if (
716+
expression instanceof PropertyWrite ||
717+
(expression instanceof ASTWithName && !(expression instanceof SafePropertyRead))
718+
) {
714719
withSpan = expression.nameSpan;
715720
}
716721

@@ -770,6 +775,8 @@ export class SymbolBuilder {
770775
let tsSymbol: ts.Symbol | undefined;
771776
if (ts.isPropertyAccessExpression(node)) {
772777
tsSymbol = this.getTypeChecker().getSymbolAtLocation(node.name);
778+
} else if (ts.isCallExpression(node)) {
779+
tsSymbol = this.getTypeChecker().getSymbolAtLocation(node.expression);
773780
} else {
774781
tsSymbol = this.getTypeChecker().getSymbolAtLocation(node);
775782
}

packages/compiler-cli/src/ngtsc/typecheck/test/type_checker__get_symbol_of_template_node_spec.ts

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -758,14 +758,15 @@ runInEachFileSystem(() => {
758758

759759
it('should get a symbol for function on a component used in an input binding', () => {
760760
const fileName = absoluteFrom('/main.ts');
761-
const templateString = `<div [inputA]="helloWorld"></div>`;
761+
const templateString = `<div [inputA]="helloWorld" [nestedFunction]="nested.helloWorld1()"></div>`;
762762
const {templateTypeChecker, program} = setup([
763763
{
764764
fileName,
765765
templates: {'Cmp': templateString},
766766
source: `
767767
export class Cmp {
768768
helloWorld() { return ''; }
769+
nested = { helloWorld1() { return ''; } };
769770
}`,
770771
},
771772
]);
@@ -777,6 +778,13 @@ runInEachFileSystem(() => {
777778
assertExpressionSymbol(symbol);
778779
expect(program.getTypeChecker().symbolToString(symbol.tsSymbol!)).toEqual('helloWorld');
779780
expect(program.getTypeChecker().typeToString(symbol.tsType)).toEqual('() => string');
781+
782+
const nestedSymbol = templateTypeChecker.getSymbolOfNode(nodes[0].inputs[1].value, cmp)!;
783+
assertExpressionSymbol(nestedSymbol);
784+
expect(program.getTypeChecker().symbolToString(nestedSymbol.tsSymbol!)).toEqual(
785+
'helloWorld1',
786+
);
787+
expect(program.getTypeChecker().typeToString(nestedSymbol.tsType)).toEqual('string');
780788
});
781789

782790
it('should get a symbol for binary expressions', () => {
@@ -1118,8 +1126,15 @@ runInEachFileSystem(() => {
11181126
const {templateTypeChecker, program} = setup([
11191127
{
11201128
fileName,
1121-
templates: {'Cmp': '<div [input]="toString(123)"></div>'},
1122-
source: `export class Cmp { toString(v: any): string { return String(v); } }`,
1129+
templates: {
1130+
'Cmp': '<div [input]="toString(123)" [nestedFunction]="nested.toString(123)"></div>',
1131+
},
1132+
source: `
1133+
export class Cmp {
1134+
toString(v: any): string { return String(v); }
1135+
nested = { toString(v: any): string { return String(v); } };
1136+
}
1137+
`,
11231138
},
11241139
]);
11251140
const sf = getSourceFileOrError(program, fileName);
@@ -1128,8 +1143,16 @@ runInEachFileSystem(() => {
11281143
const callSymbol = templateTypeChecker.getSymbolOfNode(node.inputs[0].value, cmp)!;
11291144
assertExpressionSymbol(callSymbol);
11301145
// Note that the symbol returned is for the return value of the Call.
1131-
expect(callSymbol.tsSymbol).toBeNull();
1146+
expect(callSymbol.tsSymbol).toBeTruthy();
1147+
expect(callSymbol.tsSymbol?.getName()).toEqual('toString');
11321148
expect(program.getTypeChecker().typeToString(callSymbol.tsType)).toBe('string');
1149+
1150+
const nestedCallSymbol = templateTypeChecker.getSymbolOfNode(node.inputs[1].value, cmp)!;
1151+
assertExpressionSymbol(nestedCallSymbol);
1152+
// Note that the symbol returned is for the return value of the Call.
1153+
expect(nestedCallSymbol.tsSymbol).toBeTruthy();
1154+
expect(nestedCallSymbol.tsSymbol?.getName()).toEqual('toString');
1155+
expect(program.getTypeChecker().typeToString(nestedCallSymbol.tsType)).toBe('string');
11331156
});
11341157

11351158
it('should get a symbol for SafeCall expressions', () => {

packages/language-service/src/template_target.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,6 +229,7 @@ export function getTargetAtPosition(
229229
}
230230

231231
const candidate = path[path.length - 1];
232+
232233
// Walk up the result nodes to find the nearest `TmplAstTemplate` which contains the targeted
233234
// node.
234235
let context: TmplAstTemplate | null = null;

packages/language-service/test/legacy/template_target_spec.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,15 @@ describe('getTargetAtPosition for template AST', () => {
155155
expect(node).toBeInstanceOf(e.PropertyRead);
156156
});
157157

158+
it('should locate bound event nested value', () => {
159+
const {errors, nodes, position} = parse(`<test-cmp (foo)="nested.b¦ar()"></test-cmp>`);
160+
expect(errors).toBe(null);
161+
const {context} = getTargetAtPosition(nodes, position)!;
162+
const {node} = context as SingleNodeTarget;
163+
expect(isExpressionNode(node!)).toBe(true);
164+
expect(node).toBeInstanceOf(e.PropertyRead);
165+
});
166+
158167
it('should locate element children', () => {
159168
const {errors, nodes, position} = parse(`<div><sp¦an></span></div>`);
160169
expect(errors).toBe(null);

packages/language-service/test/quick_info_spec.ts

Lines changed: 76 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import {createModuleAndProjectWithDeclarations, LanguageServiceTestEnv, Project}
1414
function quickInfoSkeleton(): {[fileName: string]: string} {
1515
return {
1616
'app.ts': `
17-
import {Component, Directive, EventEmitter, Input, NgModule, Output, Pipe, PipeTransform, model} from '@angular/core';
17+
import {Component, Directive, EventEmitter, Input, NgModule, Output, Pipe, PipeTransform, model, signal} from '@angular/core';
1818
import {CommonModule} from '@angular/common';
1919
2020
export interface Address {
@@ -60,6 +60,18 @@ function quickInfoSkeleton(): {[fileName: string]: string} {
6060
setTitle(newTitle: string) {}
6161
trackByFn!: any;
6262
name!: any;
63+
someObject = {
64+
someProp: 'prop',
65+
someSignal: signal<number>(0),
66+
someMethod: (): number => 1,
67+
nested: {
68+
helloWorld: () => {
69+
return {
70+
nestedMethod: () => 1
71+
}
72+
}
73+
}
74+
};
6375
}
6476
6577
@Directive({
@@ -331,14 +343,6 @@ describe('quick info', () => {
331343
});
332344
});
333345

334-
it('should work for $event from native element', () => {
335-
expectQuickInfo({
336-
templateOverride: `<div (click)="myClick($e¦vent)"></div>`,
337-
expectedSpanText: '$event',
338-
expectedDisplayString: '(parameter) $event: MouseEvent',
339-
});
340-
});
341-
342346
it('should work for click output from native element', () => {
343347
expectQuickInfo({
344348
templateOverride: `<div (cl¦ick)="myClick($event)"></div>`,
@@ -422,6 +426,23 @@ describe('quick info', () => {
422426
});
423427
});
424428

429+
it('should work for accessed function calls', () => {
430+
expectQuickInfo({
431+
templateOverride: `<div (click)="someObject.some¦Method()"></div>`,
432+
expectedSpanText: 'someMethod',
433+
expectedDisplayString: '(property) someMethod: () => number',
434+
});
435+
});
436+
437+
it('should work for accessed very nested function calls', () => {
438+
expectQuickInfo({
439+
templateOverride: `<div (click)="someObject.nested.helloWor¦ld().nestedMethod()"></div>`,
440+
expectedSpanText: 'helloWorld',
441+
expectedDisplayString:
442+
'(property) helloWorld: () => {\n nestedMethod: () => number;\n}',
443+
});
444+
});
445+
425446
it('should find members in an attribute interpolation', () => {
426447
expectQuickInfo({
427448
templateOverride: `<div string-model model="{{tit¦le}}"></div>`,
@@ -481,6 +502,44 @@ describe('quick info', () => {
481502
expect(toText(info.documentation)).toEqual('Documentation for myFunc.');
482503
});
483504

505+
it('should work for safe signal calls', () => {
506+
const files = {
507+
'app.ts': `import {Component, Signal} from '@angular/core';
508+
@Component({template: '<div [id]="something?.value()"></div>'})
509+
export class AppCmp {
510+
something!: {
511+
/** Documentation for value. */
512+
value: Signal<number>;
513+
};
514+
}`,
515+
};
516+
const project = createModuleAndProjectWithDeclarations(env, 'test_project', files);
517+
const appFile = project.openFile('app.ts');
518+
appFile.moveCursorToText('something?.va¦lue()');
519+
const info = appFile.getQuickInfoAtPosition()!;
520+
expect(toText(info.displayParts)).toEqual('(property) value: Signal<number>');
521+
expect(toText(info.documentation)).toEqual('Documentation for value.');
522+
});
523+
524+
it('should work for signal calls', () => {
525+
const files = {
526+
'app.ts': `import {Component, signal} from '@angular/core';
527+
@Component({template: '<div [id]="something.value()"></div>'})
528+
export class AppCmp {
529+
something = {
530+
/** Documentation for value. */
531+
value: signal(0)
532+
};
533+
}`,
534+
};
535+
const project = createModuleAndProjectWithDeclarations(env, 'test_project', files);
536+
const appFile = project.openFile('app.ts');
537+
appFile.moveCursorToText('something.va¦lue()');
538+
const info = appFile.getQuickInfoAtPosition()!;
539+
expect(toText(info.displayParts)).toEqual('(property) value: WritableSignal\n() => number');
540+
expect(toText(info.documentation)).toEqual('Documentation for value.');
541+
});
542+
484543
it('should work for accessed properties in writes', () => {
485544
expectQuickInfo({
486545
templateOverride: `<div (click)="hero.i¦d = 2"></div>`,
@@ -719,6 +778,14 @@ describe('quick info', () => {
719778
expectedDisplayString: '(variable) aliasName: [{ readonly name: "name"; }]',
720779
});
721780
});
781+
782+
it('if block alias variable', () => {
783+
expectQuickInfo({
784+
templateOverride: `@if (someObject.some¦Signal(); as aliasName) {}`,
785+
expectedSpanText: 'someSignal',
786+
expectedDisplayString: '(property) someSignal: WritableSignal\n() => number',
787+
});
788+
});
722789
});
723790

724791
describe('let declarations', () => {

packages/language-service/test/references_and_rename_spec.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,42 @@ describe('find references and rename locations', () => {
170170
});
171171
});
172172

173+
describe('when cursor in on argument to a nested function call in an external template', () => {
174+
let appFile: OpenBuffer;
175+
176+
beforeEach(() => {
177+
const files = {
178+
'app.ts': `
179+
import {Component} from '@angular/core';
180+
@Component({template: '<div (click)="nested.setTitle(title)"></div>'})
181+
export class AppCmp {
182+
title = '';
183+
nested = {
184+
setTitle(s: string) {}
185+
}
186+
}`,
187+
};
188+
env = LanguageServiceTestEnv.setup();
189+
const project = createModuleAndProjectWithDeclarations(env, 'test', files);
190+
appFile = project.openFile('app.ts');
191+
appFile.moveCursorToText('(ti¦tle)');
192+
});
193+
194+
it('gets member reference in ts file', () => {
195+
const refs = getReferencesAtPosition(appFile)!;
196+
expect(refs.length).toBe(2);
197+
198+
assertTextSpans(refs, ['title']);
199+
});
200+
201+
it('finds rename location in ts file', () => {
202+
const refs = getRenameLocationsAtPosition(appFile)!;
203+
expect(refs.length).toBe(2);
204+
205+
assertTextSpans(refs, ['title']);
206+
});
207+
});
208+
173209
describe('when cursor is on $event in method call arguments', () => {
174210
let file: OpenBuffer;
175211

packages/language-service/test/signature_help_spec.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,30 @@ describe('signature help', () => {
131131
expect(items.argumentIndex).toEqual(1);
132132
expect(items.items.length).toEqual(1);
133133
});
134+
135+
it('should handle a single argument if the function is nested', () => {
136+
const main = setup(`
137+
import {Component} from '@angular/core';
138+
@Component({
139+
template: '{{ someObj.foo("test") }}',
140+
})
141+
export class MainCmp {
142+
someObj = {
143+
foo(alpha: string, beta: number): string {
144+
return 'blah';
145+
}
146+
}
147+
}
148+
`);
149+
main.moveCursorToText('foo("test"¦)');
150+
151+
const items = main.getSignatureHelpItems()!;
152+
expect(items).toBeDefined();
153+
expect(getText(main.contents, items.applicableSpan)).toEqual('"test"');
154+
expect(items.argumentCount).toEqual(1);
155+
expect(items.argumentIndex).toEqual(0);
156+
expect(items.items.length).toEqual(1);
157+
});
134158
});
135159

136160
function setup(mainTs: string): OpenBuffer {

0 commit comments

Comments
 (0)