This PR changes the language service to work in two different modes: 1. TS + Angular Plugin augments TS language service to provide additonal Angular information. This only works with inline template and is meant to be used as a local plugin (configured via tsconfig.json). 2. Angular only Plugin only provides information on Angular templates, no TS info at all. This effectively disables native TS features and is meant for internal use only. Default mode is `angularOnly = false` so that we don't break any users already using Angular LS as local plugin. As part of the refactoring, `undefined` is removed from type aliases because it is considered bad practice. go/tsstyle#nullableundefined-type-aliases ``` Type aliases must not include |null or |undefined in a union type. Nullable aliases typically indicate that null values are being passed around through too many layers of an application, and this clouds the source of the original issue that resulted in null. They also make it unclear when specific values on a class or interface might be absent. ``` PR Close #31935
304 lines
13 KiB
TypeScript
304 lines
13 KiB
TypeScript
/**
|
|
* @license
|
|
* Copyright Google Inc. All Rights Reserved.
|
|
*
|
|
* Use of this source code is governed by an MIT-style license that can be
|
|
* found in the LICENSE file at https://angular.io/license
|
|
*/
|
|
|
|
import 'reflect-metadata';
|
|
import * as ts from 'typescript';
|
|
import {create} from '../src/ts_plugin';
|
|
import {toh} from './test_data';
|
|
import {MockTypescriptHost} from './test_utils';
|
|
|
|
describe('plugin', () => {
|
|
const mockHost = new MockTypescriptHost(['/app/main.ts', '/app/parsing-cases.ts'], toh);
|
|
const service = ts.createLanguageService(mockHost);
|
|
const program = service.getProgram();
|
|
const plugin = createPlugin(service, mockHost);
|
|
|
|
it('should not report errors on tour of heroes', () => {
|
|
expectNoDiagnostics(service.getCompilerOptionsDiagnostics());
|
|
for (let source of program !.getSourceFiles()) {
|
|
expectNoDiagnostics(service.getSyntacticDiagnostics(source.fileName));
|
|
expectNoDiagnostics(service.getSemanticDiagnostics(source.fileName));
|
|
}
|
|
});
|
|
|
|
it('should not report template errors on tour of heroes', () => {
|
|
for (let source of program !.getSourceFiles()) {
|
|
// Ignore all 'cases.ts' files as they intentionally contain errors.
|
|
if (!source.fileName.endsWith('cases.ts')) {
|
|
expectNoDiagnostics(plugin.getSemanticDiagnostics(source.fileName));
|
|
}
|
|
}
|
|
});
|
|
|
|
it('should be able to get entity completions',
|
|
() => { contains('app/app.component.ts', 'entity-amp', '&', '>', '<', 'ι'); });
|
|
|
|
it('should be able to return html elements', () => {
|
|
let htmlTags = ['h1', 'h2', 'div', 'span'];
|
|
let locations = ['empty', 'start-tag-h1', 'h1-content', 'start-tag', 'start-tag-after-h'];
|
|
for (let location of locations) {
|
|
contains('app/app.component.ts', location, ...htmlTags);
|
|
}
|
|
});
|
|
|
|
it('should be able to return element directives',
|
|
() => { contains('app/app.component.ts', 'empty', 'my-app'); });
|
|
|
|
it('should be able to return h1 attributes',
|
|
() => { contains('app/app.component.ts', 'h1-after-space', 'id', 'dir', 'lang', 'onclick'); });
|
|
|
|
it('should be able to find common angular attributes', () => {
|
|
contains('app/app.component.ts', 'div-attributes', '(click)', '[ngClass]', '*ngIf', '*ngFor');
|
|
});
|
|
|
|
it('should be able to return attribute names with an incompete attribute',
|
|
() => { contains('app/parsing-cases.ts', 'no-value-attribute', 'id', 'dir', 'lang'); });
|
|
|
|
it('should be able to return attributes of an incomplete element', () => {
|
|
contains('app/parsing-cases.ts', 'incomplete-open-lt', 'a');
|
|
contains('app/parsing-cases.ts', 'incomplete-open-a', 'a');
|
|
contains('app/parsing-cases.ts', 'incomplete-open-attr', 'id', 'dir', 'lang');
|
|
});
|
|
|
|
it('should be able to return completions with a missing closing tag',
|
|
() => { contains('app/parsing-cases.ts', 'missing-closing', 'h1', 'h2'); });
|
|
|
|
it('should be able to return common attributes of an unknown tag',
|
|
() => { contains('app/parsing-cases.ts', 'unknown-element', 'id', 'dir', 'lang'); });
|
|
|
|
it('should be able to get the completions at the beginning of an interpolation',
|
|
() => { contains('app/app.component.ts', 'h2-hero', 'hero', 'title'); });
|
|
|
|
it('should not include private members of a class',
|
|
() => { contains('app/app.component.ts', 'h2-hero', '-internal'); });
|
|
|
|
it('should be able to get the completions at the end of an interpolation',
|
|
() => { contains('app/app.component.ts', 'sub-end', 'hero', 'title'); });
|
|
|
|
it('should be able to get the completions in a property',
|
|
() => { contains('app/app.component.ts', 'h2-name', 'name', 'id'); });
|
|
|
|
it('should be able to get a list of pipe values', () => {
|
|
contains('app/parsing-cases.ts', 'before-pipe', 'lowercase', 'uppercase');
|
|
contains('app/parsing-cases.ts', 'in-pipe', 'lowercase', 'uppercase');
|
|
contains('app/parsing-cases.ts', 'after-pipe', 'lowercase', 'uppercase');
|
|
});
|
|
|
|
it('should be able to get completions in an empty interpolation',
|
|
() => { contains('app/parsing-cases.ts', 'empty-interpolation', 'title', 'subTitle'); });
|
|
|
|
describe('with attributes', () => {
|
|
it('should be able to complete property value',
|
|
() => { contains('app/parsing-cases.ts', 'property-binding-model', 'test'); });
|
|
it('should be able to complete an event',
|
|
() => { contains('app/parsing-cases.ts', 'event-binding-model', 'modelChanged'); });
|
|
it('should be able to complete a two-way binding',
|
|
() => { contains('app/parsing-cases.ts', 'two-way-binding-model', 'test'); });
|
|
});
|
|
|
|
describe('with a *ngFor', () => {
|
|
it('should include a let for empty attribute',
|
|
() => { contains('app/parsing-cases.ts', 'for-empty', 'let'); });
|
|
it('should suggest NgForRow members for let initialization expression', () => {
|
|
contains(
|
|
'app/parsing-cases.ts', 'for-let-i-equal', 'index', 'count', 'first', 'last', 'even',
|
|
'odd');
|
|
});
|
|
it('should include a let', () => { contains('app/parsing-cases.ts', 'for-let', 'let'); });
|
|
it('should include an "of"', () => { contains('app/parsing-cases.ts', 'for-of', 'of'); });
|
|
it('should include field reference',
|
|
() => { contains('app/parsing-cases.ts', 'for-people', 'people'); });
|
|
it('should include person in the let scope',
|
|
() => { contains('app/parsing-cases.ts', 'for-interp-person', 'person'); });
|
|
// TODO: Enable when we can infer the element type of the ngFor
|
|
// it('should include determine person\'s type as Person', () => {
|
|
// contains('app/parsing-cases.ts', 'for-interp-name', 'name', 'age');
|
|
// contains('app/parsing-cases.ts', 'for-interp-age', 'name', 'age');
|
|
// });
|
|
});
|
|
|
|
describe('for pipes', () => {
|
|
it('should be able to resolve lowercase',
|
|
() => { contains('app/expression-cases.ts', 'string-pipe', 'substring'); });
|
|
});
|
|
|
|
describe('with references', () => {
|
|
it('should list references',
|
|
() => { contains('app/parsing-cases.ts', 'test-comp-content', 'test1', 'test2', 'div'); });
|
|
it('should reference the component',
|
|
() => { contains('app/parsing-cases.ts', 'test-comp-after-test', 'name'); });
|
|
// TODO: Enable when we have a flag that indicates the project targets the DOM
|
|
// it('should reference the element if no component', () => {
|
|
// contains('app/parsing-cases.ts', 'test-comp-after-div', 'innerText');
|
|
// });
|
|
});
|
|
|
|
describe('for semantic errors', () => {
|
|
it('should report access to an unknown field', () => {
|
|
expectSemanticError(
|
|
'app/expression-cases.ts', 'foo',
|
|
'Identifier \'foo\' is not defined. The component declaration, template variable declarations, and element references do not contain such a member');
|
|
});
|
|
it('should report access to an unknown sub-field', () => {
|
|
expectSemanticError(
|
|
'app/expression-cases.ts', 'nam',
|
|
'Identifier \'nam\' is not defined. \'Person\' does not contain such a member');
|
|
});
|
|
it('should report access to a private member', () => {
|
|
expectSemanticError(
|
|
'app/expression-cases.ts', 'myField',
|
|
'Identifier \'myField\' refers to a private member of the component');
|
|
});
|
|
it('should report numeric operator errors',
|
|
() => { expectSemanticError('app/expression-cases.ts', 'mod', 'Expected a numeric type'); });
|
|
describe('in ngFor', () => {
|
|
function expectError(locationMarker: string, message: string) {
|
|
expectSemanticError('app/ng-for-cases.ts', locationMarker, message);
|
|
}
|
|
it('should report an unknown field', () => {
|
|
expectError(
|
|
'people_1',
|
|
'Identifier \'people_1\' is not defined. The component declaration, template variable declarations, and element references do not contain such a member');
|
|
});
|
|
it('should report an unknown context reference', () => {
|
|
expectError('even_1', 'The template context does not defined a member called \'even_1\'');
|
|
});
|
|
it('should report an unknown value in a key expression', () => {
|
|
expectError(
|
|
'trackBy_1',
|
|
'Identifier \'trackBy_1\' is not defined. The component declaration, template variable declarations, and element references do not contain such a member');
|
|
});
|
|
});
|
|
describe('in ngIf', () => {
|
|
function expectError(locationMarker: string, message: string) {
|
|
expectSemanticError('app/ng-if-cases.ts', locationMarker, message);
|
|
}
|
|
it('should report an implicit context reference', () => {
|
|
expectError(
|
|
'implicit', 'The template context does not defined a member called \'unknown\'');
|
|
});
|
|
});
|
|
|
|
describe(`with config 'angularOnly = true`, () => {
|
|
const ngLS = createPlugin(service, mockHost, {angularOnly: true});
|
|
it('should not report template errors on TOH', () => {
|
|
const sourceFiles = ngLS.getProgram() !.getSourceFiles();
|
|
expect(sourceFiles.length).toBeGreaterThan(0);
|
|
for (const {fileName} of sourceFiles) {
|
|
// Ignore all 'cases.ts' files as they intentionally contain errors.
|
|
if (!fileName.endsWith('cases.ts')) {
|
|
expectNoDiagnostics(ngLS.getSemanticDiagnostics(fileName));
|
|
}
|
|
}
|
|
});
|
|
|
|
it('should be able to get entity completions', () => {
|
|
const fileName = 'app/app.component.ts';
|
|
const marker = 'entity-amp';
|
|
const position = getMarkerLocation(fileName, marker);
|
|
const results = ngLS.getCompletionsAtPosition(fileName, position, {} /* options */);
|
|
expect(results).toBeTruthy();
|
|
expectEntries(marker, results !, ...['&', '>', '<', 'ι']);
|
|
});
|
|
|
|
it('should report template diagnostics', () => {
|
|
// TODO(kyliau): Rename these to end with '-error.ts'
|
|
const fileName = 'app/expression-cases.ts';
|
|
const diagnostics = ngLS.getSemanticDiagnostics(fileName);
|
|
expect(diagnostics.map(d => d.messageText)).toEqual([
|
|
`Identifier 'foo' is not defined. The component declaration, template variable declarations, and element references do not contain such a member`,
|
|
`Identifier 'nam' is not defined. 'Person' does not contain such a member`,
|
|
`Identifier 'myField' refers to a private member of the component`,
|
|
`Expected a numeric type`,
|
|
]);
|
|
});
|
|
});
|
|
});
|
|
|
|
function createPlugin(tsLS: ts.LanguageService, tsLSHost: ts.LanguageServiceHost, config = {}) {
|
|
const project = {projectService: {logger: {info() {}}}};
|
|
return create({
|
|
languageService: tsLS,
|
|
languageServiceHost: tsLSHost,
|
|
project: project as any,
|
|
serverHost: {} as any,
|
|
config: {...config},
|
|
});
|
|
}
|
|
|
|
function getMarkerLocation(fileName: string, locationMarker: string): number {
|
|
const location = mockHost.getMarkerLocations(fileName) ![locationMarker];
|
|
if (location == null) {
|
|
throw new Error(`No marker ${locationMarker} found.`);
|
|
}
|
|
return location;
|
|
}
|
|
function contains(fileName: string, locationMarker: string, ...names: string[]) {
|
|
const location = getMarkerLocation(fileName, locationMarker);
|
|
expectEntries(
|
|
locationMarker, plugin.getCompletionsAtPosition(fileName, location, undefined) !, ...names);
|
|
}
|
|
|
|
function expectSemanticError(fileName: string, locationMarker: string, message: string) {
|
|
const start = getMarkerLocation(fileName, locationMarker);
|
|
const end = getMarkerLocation(fileName, locationMarker + '-end');
|
|
const errors = plugin.getSemanticDiagnostics(fileName);
|
|
for (const error of errors) {
|
|
if (error.messageText.toString().indexOf(message) >= 0) {
|
|
expect(error.start).toEqual(start);
|
|
expect(error.length).toEqual(end - start);
|
|
return;
|
|
}
|
|
}
|
|
throw new Error(`Expected error messages to contain ${message}, in messages:\n ${errors
|
|
.map(e => e.messageText.toString())
|
|
.join(',\n ')}`);
|
|
}
|
|
});
|
|
|
|
|
|
function expectEntries(locationMarker: string, info: ts.CompletionInfo, ...names: string[]) {
|
|
let entries: {[name: string]: boolean} = {};
|
|
if (!info) {
|
|
throw new Error(`Expected result from ${locationMarker} to include ${names.join(
|
|
', ')} but no result provided`);
|
|
} else {
|
|
for (let entry of info.entries) {
|
|
entries[entry.name] = true;
|
|
}
|
|
let shouldContains = names.filter(name => !name.startsWith('-'));
|
|
let shouldNotContain = names.filter(name => name.startsWith('-'));
|
|
let missing = shouldContains.filter(name => !entries[name]);
|
|
let present = shouldNotContain.map(name => name.substr(1)).filter(name => entries[name]);
|
|
if (missing.length) {
|
|
throw new Error(`Expected result from ${locationMarker
|
|
} to include at least one of the following, ${missing
|
|
.join(', ')}, in the list of entries ${info.entries.map(entry => entry.name)
|
|
.join(', ')}`);
|
|
}
|
|
if (present.length) {
|
|
throw new Error(`Unexpected member${present.length > 1 ? 's' :
|
|
''
|
|
} included in result: ${present.join(', ')}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
function expectNoDiagnostics(diagnostics: ts.Diagnostic[]) {
|
|
for (const diagnostic of diagnostics) {
|
|
let message = ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n');
|
|
if (diagnostic.file && diagnostic.start) {
|
|
let {line, character} = diagnostic.file.getLineAndCharacterOfPosition(diagnostic.start);
|
|
console.error(`${diagnostic.file.fileName} (${line + 1},${character + 1}): ${message}`);
|
|
} else {
|
|
console.error(`${message}`);
|
|
}
|
|
}
|
|
expect(diagnostics.length).toBe(0);
|
|
}
|