Skip to content

Commit

Permalink
fix(language-service): completions after "let x of |" in ngFor (#34473)
Browse files Browse the repository at this point in the history
This commit fixes a bug in which we do testing for completions.
Subsequently, this exposes another bug in our implementation whereby
suggestions are not provided in "ngFor" where there should have been.

Currently, multiple test cases are grouped together in a single
template. This requires the template to be somewhat complete so that
test cases that depend on variables declared earlier would pass.

Consider the following example:

```
  template: `
    <div *ngFor="let ~{for-person}person of ~{for-people}people">
      <span>Name: {{~{for-interp-person}person.~{for-interp-name}name}}</span>
      <span>Age: {{person.~{for-interp-age}age}}</span>
    </div>`,
```

In order to test `~{for-interp-person}`, `people` has to be included after
`~{for-people}`. This means the test case for `~{for-people}` is not
reflective of the actual use case because the variable is already there!
In real case, the expression would be incomplete, and our implementation
failed to take that into account.

This commit breaks such test into individual tests, and fix the bugs in
the underlying implementation.

PR Close #34473
  • Loading branch information
Keen Yee Liau authored and alxhub committed Dec 19, 2019
1 parent fde5067 commit ca8b584
Show file tree
Hide file tree
Showing 5 changed files with 51 additions and 43 deletions.
15 changes: 14 additions & 1 deletion packages/language-service/src/completions.ts
Original file line number Original file line Diff line number Diff line change
Expand Up @@ -511,7 +511,7 @@ class ExpressionVisitor extends NullTemplateVisitor {


if (binding.keyIsVar) { if (binding.keyIsVar) {
const equalLocation = attr.value.indexOf('='); const equalLocation = attr.value.indexOf('=');
if (equalLocation >= 0 && valueRelativePosition >= equalLocation) { if (equalLocation > 0 && valueRelativePosition > equalLocation) {
// We are after the '=' in a let clause. The valid values here are the members of the // We are after the '=' in a let clause. The valid values here are the members of the
// template reference's type parameter. // template reference's type parameter.
const directiveMetadata = selectorInfo.map.get(selector); const directiveMetadata = selectorInfo.map.get(selector);
Expand All @@ -531,6 +531,19 @@ class ExpressionVisitor extends NullTemplateVisitor {
this.addAttributeValuesToCompletions(binding.expression.ast); this.addAttributeValuesToCompletions(binding.expression.ast);
return; return;
} }

// If the expression is incomplete, for example *ngFor="let x of |"
// binding.expression is null. We could still try to provide suggestions
// by looking for symbols that are in scope.
const KW_OF = ' of ';
const ofLocation = attr.value.indexOf(KW_OF);
if (ofLocation > 0 && valueRelativePosition >= ofLocation + KW_OF.length) {
const span = new ParseSpan(0, attr.value.length);
const offset = attr.sourceSpan.start.offset;
const receiver = new ImplicitReceiver(span, span.toAbsolute(offset));
const expressionAst = new PropertyRead(span, span.toAbsolute(offset), receiver, '');
this.addAttributeValuesToCompletions(expressionAst);
}
} }
} }


Expand Down
51 changes: 36 additions & 15 deletions packages/language-service/test/completions_spec.ts
Original file line number Original file line Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ describe('completions', () => {
expectContain(completions, CompletionKind.PROPERTY, ['id', 'name']); expectContain(completions, CompletionKind.PROPERTY, ['id', 'name']);
}); });


it('should suggest template refereces', () => { it('should suggest template references', () => {
mockHost.override(TEST_TEMPLATE, `<div *~{cursor}></div>`); mockHost.override(TEST_TEMPLATE, `<div *~{cursor}></div>`);
const marker = mockHost.getLocationMarkerFor(TEST_TEMPLATE, 'cursor'); const marker = mockHost.getLocationMarkerFor(TEST_TEMPLATE, 'cursor');
const completions = ngLS.getCompletionsAt(TEST_TEMPLATE, marker.start); const completions = ngLS.getCompletionsAt(TEST_TEMPLATE, marker.start);
Expand Down Expand Up @@ -275,8 +275,9 @@ describe('completions', () => {


describe('with a *ngFor', () => { describe('with a *ngFor', () => {
it('should suggest NgForRow members for let initialization expression', () => { it('should suggest NgForRow members for let initialization expression', () => {
const marker = mockHost.getLocationMarkerFor(PARSING_CASES, 'for-let-i-equal'); mockHost.override(TEST_TEMPLATE, `<div *ngFor="let i=~{cursor}"></div>`);
const completions = ngLS.getCompletionsAt(PARSING_CASES, marker.start); const marker = mockHost.getLocationMarkerFor(TEST_TEMPLATE, 'cursor');
const completions = ngLS.getCompletionsAt(TEST_TEMPLATE, marker.start);
expectContain(completions, CompletionKind.PROPERTY, [ expectContain(completions, CompletionKind.PROPERTY, [
'$implicit', '$implicit',
'ngForOf', 'ngForOf',
Expand All @@ -289,24 +290,44 @@ describe('completions', () => {
]); ]);
}); });


it('should not provide suggestion before the = sign', () => {
mockHost.override(TEST_TEMPLATE, `<div *ngFor="let i~{cursor}="></div>`);
const marker = mockHost.getLocationMarkerFor(TEST_TEMPLATE, 'cursor');
const completions = ngLS.getCompletionsAt(TEST_TEMPLATE, marker.start);
expect(completions).toBeUndefined();
});

it('should include field reference', () => { it('should include field reference', () => {
const marker = mockHost.getLocationMarkerFor(PARSING_CASES, 'for-people'); mockHost.override(TEST_TEMPLATE, `<div *ngFor="let x of ~{cursor}"></div>`);
const completions = ngLS.getCompletionsAt(PARSING_CASES, marker.start); const marker = mockHost.getLocationMarkerFor(TEST_TEMPLATE, 'cursor');
expectContain(completions, CompletionKind.PROPERTY, ['people']); const completions = ngLS.getCompletionsAt(TEST_TEMPLATE, marker.start);
expectContain(completions, CompletionKind.PROPERTY, ['title', 'heroes', 'league']);
// the symbol 'x' declared in *ngFor is also in scope. This asserts that
// we are actually taking the AST into account and not just referring to
// the symbol table of the Component.
expectContain(completions, CompletionKind.VARIABLE, ['x']);
}); });


it('should include person in the let scope', () => { it('should include variable in the let scope in interpolation', () => {
const marker = mockHost.getLocationMarkerFor(PARSING_CASES, 'for-interp-person'); mockHost.override(TEST_TEMPLATE, `
const completions = ngLS.getCompletionsAt(PARSING_CASES, marker.start); <div *ngFor="let h of heroes">
expectContain(completions, CompletionKind.VARIABLE, ['person']); {{~{cursor}}}
</div>
`);
const marker = mockHost.getLocationMarkerFor(TEST_TEMPLATE, 'cursor');
const completions = ngLS.getCompletionsAt(TEST_TEMPLATE, marker.start);
expectContain(completions, CompletionKind.VARIABLE, ['h']);
}); });


it('should be able to infer the type of a ngForOf', () => { it('should be able to infer the type of a ngForOf', () => {
for (const location of ['for-interp-name', 'for-interp-age']) { mockHost.override(TEST_TEMPLATE, `
const marker = mockHost.getLocationMarkerFor(PARSING_CASES, location); <div *ngFor="let h of heroes">
const completions = ngLS.getCompletionsAt(PARSING_CASES, marker.start); {{ h.~{cursor} }}
expectContain(completions, CompletionKind.PROPERTY, ['name', 'age', 'street']); </div>
} `);
const marker = mockHost.getLocationMarkerFor(TEST_TEMPLATE, 'cursor');
const completions = ngLS.getCompletionsAt(TEST_TEMPLATE, marker.start);
expectContain(completions, CompletionKind.PROPERTY, ['id', 'name']);
}); });


it('should be able to infer the type of a ngForOf with an async pipe', () => { it('should be able to infer the type of a ngForOf with an async pipe', () => {
Expand Down
3 changes: 0 additions & 3 deletions packages/language-service/test/project/app/main.ts
Original file line number Original file line Diff line number Diff line change
Expand Up @@ -36,9 +36,6 @@ import * as ParsingCases from './parsing-cases';
ParsingCases.CaseUnknown, ParsingCases.CaseUnknown,
ParsingCases.EmptyInterpolation, ParsingCases.EmptyInterpolation,
ParsingCases.EventBinding, ParsingCases.EventBinding,
ParsingCases.ForLetIEqual,
ParsingCases.ForOfLetEmpty,
ParsingCases.ForUsingComponent,
ParsingCases.NoValueAttribute, ParsingCases.NoValueAttribute,
ParsingCases.NumberModel, ParsingCases.NumberModel,
ParsingCases.Pipes, ParsingCases.Pipes,
Expand Down
23 changes: 0 additions & 23 deletions packages/language-service/test/project/app/parsing-cases.ts
Original file line number Original file line Diff line number Diff line change
Expand Up @@ -99,29 +99,6 @@ interface Person {
street: string; street: string;
} }


@Component({
template: '<div *ngFor="let ~{for-let-empty}"></div>',
})
export class ForOfLetEmpty {
}

@Component({
template: '<div *ngFor="let i = ~{for-let-i-equal}"></div>',
})
export class ForLetIEqual {
}

@Component({
template: `
<div *ngFor="let ~{for-person}person of ~{for-people}people">
<span>Name: {{~{for-interp-person}person.~{for-interp-name}name}}</span>
<span>Age: {{person.~{for-interp-age}age}}</span>
</div>`,
})
export class ForUsingComponent {
people: Person[] = [];
}

@Component({ @Component({
template: ` template: `
<div *ngFor="let person of people | async"> <div *ngFor="let person of people | async">
Expand Down
2 changes: 1 addition & 1 deletion packages/language-service/test/typescript_host_spec.ts
Original file line number Original file line Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ describe('TypeScriptServiceHost', () => {
const tsLS = ts.createLanguageService(tsLSHost); const tsLS = ts.createLanguageService(tsLSHost);
const ngLSHost = new TypeScriptServiceHost(tsLSHost, tsLS); const ngLSHost = new TypeScriptServiceHost(tsLSHost, tsLS);
const templates = ngLSHost.getTemplates('/app/parsing-cases.ts'); const templates = ngLSHost.getTemplates('/app/parsing-cases.ts');
expect(templates.length).toBe(16); expect(templates.length).toBe(13);
}); });


it('should be able to find external template', () => { it('should be able to find external template', () => {
Expand Down

0 comments on commit ca8b584

Please sign in to comment.