feat(language-service): add services to support editors (#12987)
This commit is contained in:
236
modules/@angular/language-service/test/completions_spec.ts
Normal file
236
modules/@angular/language-service/test/completions_spec.ts
Normal file
@ -0,0 +1,236 @@
|
||||
/**
|
||||
* @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 {createLanguageService} from '../src/language_service';
|
||||
import {Completions, Diagnostic, Diagnostics} from '../src/types';
|
||||
import {TypeScriptServiceHost} from '../src/typescript_host';
|
||||
|
||||
import {toh} from './test_data';
|
||||
import {MockTypescriptHost, includeDiagnostic, noDiagnostics} from './test_utils';
|
||||
|
||||
describe('completions', () => {
|
||||
let documentRegistry = ts.createDocumentRegistry();
|
||||
let mockHost = new MockTypescriptHost(['/app/main.ts', '/app/parsing-cases.ts'], toh);
|
||||
let service = ts.createLanguageService(mockHost, documentRegistry);
|
||||
let program = service.getProgram();
|
||||
let ngHost = new TypeScriptServiceHost(ts, mockHost, service);
|
||||
let ngService = createLanguageService(ngHost);
|
||||
ngHost.setSite(ngService);
|
||||
|
||||
it('should be able to get entity completions',
|
||||
() => { contains('/app/test.ng', '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/test.ng', location, ...htmlTags);
|
||||
}
|
||||
});
|
||||
|
||||
it('should be able to return element diretives',
|
||||
() => { contains('/app/test.ng', 'empty', 'my-app'); });
|
||||
|
||||
it('should be able to return h1 attributes',
|
||||
() => { contains('/app/test.ng', 'h1-after-space', 'id', 'dir', 'lang', 'onclick'); });
|
||||
|
||||
it('should be able to find common angular attributes',
|
||||
() => { contains('/app/test.ng', 'div-attributes', '(click)', '[ngClass]'); });
|
||||
|
||||
it('should be able to get completions in some random garbage', () => {
|
||||
const fileName = '/app/test.ng';
|
||||
mockHost.override(fileName, ' > {{tle<\n {{retl ><bel/beled}}di>\n la</b </d &a ');
|
||||
expect(() => ngService.getCompletionsAt(fileName, 31)).not.toThrow();
|
||||
mockHost.override(fileName, undefined);
|
||||
});
|
||||
|
||||
it('should be able to infer the type of a ngForOf', () => {
|
||||
addCode(
|
||||
`
|
||||
interface Person {
|
||||
name: string,
|
||||
street: string
|
||||
}
|
||||
|
||||
@Component({template: '<div *ngFor="let person of people">{{person.~{name}name}}</div'})
|
||||
export class MyComponent {
|
||||
people: Person[]
|
||||
}`,
|
||||
() => { contains('/app/app.component.ts', 'name', 'name', 'street'); });
|
||||
});
|
||||
|
||||
it('should be able to infer the type of a ngForOf with an async pipe', () => {
|
||||
addCode(
|
||||
`
|
||||
interface Person {
|
||||
name: string,
|
||||
street: string
|
||||
}
|
||||
|
||||
@Component({template: '<div *ngFor="let person of people | async">{{person.~{name}name}}</div'})
|
||||
export class MyComponent {
|
||||
people: Promise<Person[]>;
|
||||
}`,
|
||||
() => { contains('/app/app.component.ts', 'name', 'name', 'street'); });
|
||||
});
|
||||
|
||||
it('should be able to complete every character in the file', () => {
|
||||
const fileName = '/app/test.ng';
|
||||
|
||||
expect(() => {
|
||||
let chance = 0.05;
|
||||
let requests = 0;
|
||||
function tryCompletionsAt(position: number) {
|
||||
try {
|
||||
if (Math.random() < chance) {
|
||||
ngService.getCompletionsAt(fileName, position);
|
||||
requests++;
|
||||
}
|
||||
} catch (e) {
|
||||
// Emit enough diagnostic information to reproduce the error.
|
||||
console.log(
|
||||
`Position: ${position}\nContent: "${mockHost.getFileContent(fileName)}"\nStack:\n${e.stack}`);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
try {
|
||||
const originalContent = mockHost.getFileContent(fileName);
|
||||
|
||||
// For each character in the file, add it to the file and request a completion after it.
|
||||
for (let index = 0, len = originalContent.length; index < len; index++) {
|
||||
const content = originalContent.substr(0, index);
|
||||
mockHost.override(fileName, content);
|
||||
tryCompletionsAt(index);
|
||||
}
|
||||
|
||||
// For the complete file, try to get a completion at every character.
|
||||
mockHost.override(fileName, originalContent);
|
||||
for (let index = 0, len = originalContent.length; index < len; index++) {
|
||||
tryCompletionsAt(index);
|
||||
}
|
||||
|
||||
// Delete random characters in the file until we get an empty file.
|
||||
let content = originalContent;
|
||||
while (content.length > 0) {
|
||||
const deleteIndex = Math.floor(Math.random() * content.length);
|
||||
content = content.slice(0, deleteIndex - 1) + content.slice(deleteIndex + 1);
|
||||
mockHost.override(fileName, content);
|
||||
|
||||
const requestIndex = Math.floor(Math.random() * content.length);
|
||||
tryCompletionsAt(requestIndex);
|
||||
}
|
||||
|
||||
// Build up the string from zero asking for a completion after every char
|
||||
buildUp(originalContent, (text, position) => {
|
||||
mockHost.override(fileName, text);
|
||||
tryCompletionsAt(position);
|
||||
});
|
||||
} finally {
|
||||
mockHost.override(fileName, undefined);
|
||||
}
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
describe('with regression tests', () => {
|
||||
it('should not crash with an incomplete component', () => {
|
||||
expect(() => {
|
||||
const code = `
|
||||
@Component({
|
||||
template: '~{inside-template}'
|
||||
})
|
||||
export class MyComponent {
|
||||
|
||||
}`;
|
||||
addCode(code, fileName => { contains(fileName, 'inside-template', 'h1'); });
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('should hot crash with an incomplete class', () => {
|
||||
expect(() => {
|
||||
addCode('\nexport class', fileName => { ngHost.updateAnalyzedModules(); });
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
function addCode(code: string, cb: (fileName: string, content?: string) => void) {
|
||||
const fileName = '/app/app.component.ts';
|
||||
const originalContent = mockHost.getFileContent(fileName);
|
||||
const newContent = originalContent + code;
|
||||
mockHost.override(fileName, originalContent + code);
|
||||
try {
|
||||
cb(fileName, newContent);
|
||||
} finally {
|
||||
mockHost.override(fileName, undefined);
|
||||
}
|
||||
}
|
||||
|
||||
function contains(fileName: string, locationMarker: string, ...names: string[]) {
|
||||
let location = mockHost.getMarkerLocations(fileName)[locationMarker];
|
||||
if (location == null) {
|
||||
throw new Error(`No marker ${locationMarker} found.`);
|
||||
}
|
||||
expectEntries(locationMarker, ngService.getCompletionsAt(fileName, location), ...names);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
function expectEntries(locationMarker: string, completions: Completions, ...names: string[]) {
|
||||
let entries: {[name: string]: boolean} = {};
|
||||
if (!completions) {
|
||||
throw new Error(
|
||||
`Expected result from ${locationMarker} to include ${names.join(', ')} but no result provided`);
|
||||
}
|
||||
if (!completions.length) {
|
||||
throw new Error(
|
||||
`Expected result from ${locationMarker} to include ${names.join(', ')} an empty result provided`);
|
||||
} else {
|
||||
for (let entry of completions) {
|
||||
entries[entry.name] = true;
|
||||
}
|
||||
let missing = names.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 ${completions.map(entry => entry.name).join(', ')}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function buildUp(originalText: string, cb: (text: string, position: number) => void) {
|
||||
let count = originalText.length;
|
||||
|
||||
let inString: boolean[] = (new Array(count)).fill(false);
|
||||
let unused: number[] = (new Array(count)).fill(1).map((v, i) => i);
|
||||
|
||||
function getText() {
|
||||
return new Array(count)
|
||||
.fill(1)
|
||||
.map((v, i) => i)
|
||||
.filter(i => inString[i])
|
||||
.map(i => originalText[i])
|
||||
.join('');
|
||||
}
|
||||
|
||||
function randomUnusedIndex() { return Math.floor(Math.random() * unused.length); }
|
||||
|
||||
while (unused.length > 0) {
|
||||
let unusedIndex = randomUnusedIndex();
|
||||
let index = unused[unusedIndex];
|
||||
if (index == null) throw new Error('Internal test buildup error');
|
||||
if (inString[index]) throw new Error('Internal test buildup error');
|
||||
inString[index] = true;
|
||||
unused.splice(unusedIndex, 1);
|
||||
let text = getText();
|
||||
let position =
|
||||
inString.filter((_, i) => i <= index).map(v => v ? 1 : 0).reduce((p, v) => p + v, 0);
|
||||
cb(text, position);
|
||||
}
|
||||
}
|
169
modules/@angular/language-service/test/definitions_spec.ts
Normal file
169
modules/@angular/language-service/test/definitions_spec.ts
Normal file
@ -0,0 +1,169 @@
|
||||
/**
|
||||
* @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 * as ts from 'typescript';
|
||||
|
||||
import {createLanguageService} from '../src/language_service';
|
||||
import {Completions, Diagnostic, Diagnostics, Span} from '../src/types';
|
||||
import {TypeScriptServiceHost} from '../src/typescript_host';
|
||||
|
||||
import {toh} from './test_data';
|
||||
|
||||
import {MockTypescriptHost,} from './test_utils';
|
||||
|
||||
describe('definitions', () => {
|
||||
let documentRegistry = ts.createDocumentRegistry();
|
||||
let mockHost = new MockTypescriptHost(['/app/main.ts', '/app/parsing-cases.ts'], toh);
|
||||
let service = ts.createLanguageService(mockHost, documentRegistry);
|
||||
let program = service.getProgram();
|
||||
let ngHost = new TypeScriptServiceHost(ts, mockHost, service);
|
||||
let ngService = createLanguageService(ngHost);
|
||||
ngHost.setSite(ngService);
|
||||
|
||||
it('should be able to find field in an interpolation', () => {
|
||||
localReference(
|
||||
` @Component({template: '{{«name»}}'}) export class MyComponent { «∆name∆: string;» }`);
|
||||
});
|
||||
|
||||
it('should be able to find a field in a attribute reference', () => {
|
||||
localReference(
|
||||
` @Component({template: '<input [(ngModel)]="«name»">'}) export class MyComponent { «∆name∆: string;» }`);
|
||||
});
|
||||
|
||||
it('should be able to find a method from a call', () => {
|
||||
localReference(
|
||||
` @Component({template: '<div (click)="«myClick»();"></div>'}) export class MyComponent { «∆myClick∆() { }»}`);
|
||||
});
|
||||
|
||||
it('should be able to find a field reference in an *ngIf', () => {
|
||||
localReference(
|
||||
` @Component({template: '<div *ngIf="«include»"></div>'}) export class MyComponent { «∆include∆ = true;»}`);
|
||||
});
|
||||
|
||||
it('should be able to find a reference to a component', () => {
|
||||
reference(
|
||||
'parsing-cases.ts',
|
||||
` @Component({template: '<«test-comp»></test-comp>'}) export class MyComponent { }`);
|
||||
});
|
||||
|
||||
it('should be able to find an event provider', () => {
|
||||
reference(
|
||||
'/app/parsing-cases.ts', 'test',
|
||||
` @Component({template: '<test-comp («test»)="myHandler()"></div>'}) export class MyComponent { myHandler() {} }`);
|
||||
});
|
||||
|
||||
it('should be able to find an input provider', () => {
|
||||
reference(
|
||||
'/app/parsing-cases.ts', 'tcName',
|
||||
` @Component({template: '<test-comp [«tcName»]="name"></div>'}) export class MyComponent { name = 'my name'; }`);
|
||||
});
|
||||
|
||||
it('should be able to find a pipe', () => {
|
||||
reference(
|
||||
'async_pipe.d.ts',
|
||||
` @Component({template: '<div *ngIf="input | «async»"></div>'}) export class MyComponent { input: EventEmitter; }`);
|
||||
});
|
||||
|
||||
function localReference(code: string) {
|
||||
addCode(code, fileName => {
|
||||
const refResult = mockHost.getReferenceMarkers(fileName);
|
||||
for (const name in refResult.references) {
|
||||
const references = refResult.references[name];
|
||||
const definitions = refResult.definitions[name];
|
||||
expect(definitions).toBeDefined(); // If this fails the test data is wrong.
|
||||
for (const reference of references) {
|
||||
const definition = ngService.getDefinitionAt(fileName, reference.start);
|
||||
if (definition) {
|
||||
definition.forEach(d => expect(d.fileName).toEqual(fileName));
|
||||
const match = matchingSpan(definition.map(d => d.span), definitions);
|
||||
if (!match) {
|
||||
throw new Error(
|
||||
`Expected one of ${stringifySpans(definition.map(d => d.span))} to match one of ${stringifySpans(definitions)}`);
|
||||
}
|
||||
} else {
|
||||
throw new Error('Expected a definition');
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function reference(referencedFile: string, code: string): void;
|
||||
function reference(referencedFile: string, span: Span, code: string): void;
|
||||
function reference(referencedFile: string, definition: string, code: string): void;
|
||||
function reference(referencedFile: string, p1?: any, p2?: any): void {
|
||||
const code: string = p2 ? p2 : p1;
|
||||
const definition: string = p2 ? p1 : undefined;
|
||||
let span: Span = p2 && p1.start != null ? p1 : undefined;
|
||||
if (definition && !span) {
|
||||
const referencedFileMarkers = mockHost.getReferenceMarkers(referencedFile);
|
||||
expect(referencedFileMarkers).toBeDefined(); // If this fails the test data is wrong.
|
||||
const spans = referencedFileMarkers.definitions[definition];
|
||||
expect(spans).toBeDefined(); // If this fails the test data is wrong.
|
||||
span = spans[0];
|
||||
}
|
||||
addCode(code, fileName => {
|
||||
const refResult = mockHost.getReferenceMarkers(fileName);
|
||||
let tests = 0;
|
||||
for (const name in refResult.references) {
|
||||
const references = refResult.references[name];
|
||||
expect(reference).toBeDefined(); // If this fails the test data is wrong.
|
||||
for (const reference of references) {
|
||||
tests++;
|
||||
const definition = ngService.getDefinitionAt(fileName, reference.start);
|
||||
if (definition) {
|
||||
definition.forEach(d => {
|
||||
if (d.fileName.indexOf(referencedFile) < 0) {
|
||||
throw new Error(
|
||||
`Expected reference to file ${referencedFile}, received ${d.fileName}`);
|
||||
}
|
||||
if (span) {
|
||||
expect(d.span).toEqual(span);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
throw new Error('Expected a definition');
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!tests) {
|
||||
throw new Error('Expected at least one reference (test data error)');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function addCode(code: string, cb: (fileName: string, content?: string) => void) {
|
||||
const fileName = '/app/app.component.ts';
|
||||
const originalContent = mockHost.getFileContent(fileName);
|
||||
const newContent = originalContent + code;
|
||||
mockHost.override(fileName, originalContent + code);
|
||||
try {
|
||||
cb(fileName, newContent);
|
||||
} finally {
|
||||
mockHost.override(fileName, undefined);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function matchingSpan(aSpans: Span[], bSpans: Span[]): Span {
|
||||
for (const a of aSpans) {
|
||||
for (const b of bSpans) {
|
||||
if (a.start == b.start && a.end == b.end) {
|
||||
return a;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function stringifySpan(span: Span) {
|
||||
return span ? `(${span.start}-${span.end})` : '<undefined>';
|
||||
}
|
||||
|
||||
function stringifySpans(spans: Span[]) {
|
||||
return spans ? `[${spans.map(stringifySpan).join(', ')}]` : '<empty>';
|
||||
}
|
136
modules/@angular/language-service/test/diagnostics_spec.ts
Normal file
136
modules/@angular/language-service/test/diagnostics_spec.ts
Normal file
@ -0,0 +1,136 @@
|
||||
/**
|
||||
* @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 * as ts from 'typescript';
|
||||
|
||||
import {createLanguageService} from '../src/language_service';
|
||||
import {Completions, Diagnostic, Diagnostics} from '../src/types';
|
||||
import {TypeScriptServiceHost} from '../src/typescript_host';
|
||||
|
||||
import {toh} from './test_data';
|
||||
import {MockTypescriptHost, includeDiagnostic, noDiagnostics} from './test_utils';
|
||||
|
||||
describe('diagnostics', () => {
|
||||
let documentRegistry = ts.createDocumentRegistry();
|
||||
let mockHost = new MockTypescriptHost(['/app/main.ts', '/app/parsing-cases.ts'], toh);
|
||||
let service = ts.createLanguageService(mockHost, documentRegistry);
|
||||
let program = service.getProgram();
|
||||
let ngHost = new TypeScriptServiceHost(ts, mockHost, service);
|
||||
let ngService = createLanguageService(ngHost);
|
||||
ngHost.setSite(ngService);
|
||||
|
||||
it('should be no diagnostics for test.ng',
|
||||
() => { expect(ngService.getDiagnostics('/app/test.ng')).toEqual([]); });
|
||||
|
||||
describe('for semantic errors', () => {
|
||||
const fileName = '/app/test.ng';
|
||||
|
||||
function diagnostics(template: string): Diagnostics {
|
||||
try {
|
||||
mockHost.override(fileName, template);
|
||||
return ngService.getDiagnostics(fileName);
|
||||
} finally {
|
||||
mockHost.override(fileName, undefined);
|
||||
}
|
||||
}
|
||||
|
||||
function accept(template: string) { noDiagnostics(diagnostics(template)); }
|
||||
|
||||
function reject(template: string, message: string): void;
|
||||
function reject(template: string, message: string, at: string): void;
|
||||
function reject(template: string, message: string, location: string): void;
|
||||
function reject(template: string, message: string, location: string, len: number): void;
|
||||
function reject(template: string, message: string, at?: number | string, len?: number): void {
|
||||
if (typeof at == 'string') {
|
||||
len = at.length;
|
||||
at = template.indexOf(at);
|
||||
}
|
||||
includeDiagnostic(diagnostics(template), message, at, len);
|
||||
}
|
||||
|
||||
describe('with $event', () => {
|
||||
it('should accept an event',
|
||||
() => { accept('<div (click)="myClick($event)">Click me!</div>'); });
|
||||
it('should reject it when not in an event binding', () => {
|
||||
reject('<div [tabIndex]="$event"></div>', '\'$event\' is not defined', '$event');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('with regression tests', () => {
|
||||
|
||||
it('should not crash with a incomplete *ngFor', () => {
|
||||
expect(() => {
|
||||
const code =
|
||||
'\n@Component({template: \'<div *ngFor></div> ~{after-div}\'}) export class MyComponent {}';
|
||||
addCode(code, fileName => { ngService.getDiagnostics(fileName); });
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('should report a component not in a module', () => {
|
||||
const code = '\n@Component({template: \'<div></div>\'}) export class MyComponent {}';
|
||||
addCode(code, (fileName, content) => {
|
||||
const diagnostics = ngService.getDiagnostics(fileName);
|
||||
const offset = content.lastIndexOf('@Component') + 1;
|
||||
const len = 'Component'.length;
|
||||
includeDiagnostic(
|
||||
diagnostics, 'Component \'MyComponent\' is not included in a module', offset, len);
|
||||
});
|
||||
});
|
||||
|
||||
it('should not report an error for a form\'s host directives', () => {
|
||||
const code = '\n@Component({template: \'<form></form>\'}) export class MyComponent {}';
|
||||
addCode(code, (fileName, content) => {
|
||||
const diagnostics = ngService.getDiagnostics(fileName);
|
||||
onlyModuleDiagnostics(diagnostics);
|
||||
});
|
||||
});
|
||||
|
||||
it('should not throw getting diagnostics for an index expression', () => {
|
||||
const code =
|
||||
` @Component({template: '<a *ngIf="(auth.isAdmin | async) || (event.leads && event.leads[(auth.uid | async)])"></a>'}) export class MyComponent {}`;
|
||||
addCode(
|
||||
code, fileName => { expect(() => ngService.getDiagnostics(fileName)).not.toThrow(); });
|
||||
});
|
||||
|
||||
it('should not throw using a directive with no value', () => {
|
||||
const code =
|
||||
` @Component({template: '<form><input [(ngModel)]="name" required /></form>'}) export class MyComponent { name = 'some name'; }`;
|
||||
addCode(
|
||||
code, fileName => { expect(() => ngService.getDiagnostics(fileName)).not.toThrow(); });
|
||||
});
|
||||
|
||||
it('should report an error for invalid metadata', () => {
|
||||
const code =
|
||||
` @Component({template: '', provider: [{provide: 'foo', useFactor: () => 'foo' }]}) export class MyComponent { name = 'some name'; }`;
|
||||
addCode(code, (fileName, content) => {
|
||||
const diagnostics = ngService.getDiagnostics(fileName);
|
||||
includeDiagnostic(
|
||||
diagnostics, 'Function calls are not supported.', '() => \'foo\'', content);
|
||||
});
|
||||
});
|
||||
|
||||
function addCode(code: string, cb: (fileName: string, content?: string) => void) {
|
||||
const fileName = '/app/app.component.ts';
|
||||
const originalContent = mockHost.getFileContent(fileName);
|
||||
const newContent = originalContent + code;
|
||||
mockHost.override(fileName, originalContent + code);
|
||||
try {
|
||||
cb(fileName, newContent);
|
||||
} finally {
|
||||
mockHost.override(fileName, undefined);
|
||||
}
|
||||
}
|
||||
|
||||
function onlyModuleDiagnostics(diagnostics: Diagnostics) {
|
||||
// Expect only the 'MyComponent' diagnostic
|
||||
expect(diagnostics.length).toBe(1);
|
||||
expect(diagnostics[0].message.indexOf('MyComponent') >= 0).toBeTruthy();
|
||||
}
|
||||
});
|
||||
});
|
105
modules/@angular/language-service/test/hover_spec.ts
Normal file
105
modules/@angular/language-service/test/hover_spec.ts
Normal file
@ -0,0 +1,105 @@
|
||||
/**
|
||||
* @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 {createLanguageService} from '../src/language_service';
|
||||
import {Hover, HoverTextSection} from '../src/types';
|
||||
import {TypeScriptServiceHost} from '../src/typescript_host';
|
||||
|
||||
import {toh} from './test_data';
|
||||
import {MockTypescriptHost, includeDiagnostic, noDiagnostics} from './test_utils';
|
||||
|
||||
describe('hover', () => {
|
||||
let documentRegistry = ts.createDocumentRegistry();
|
||||
let mockHost = new MockTypescriptHost(['/app/main.ts', '/app/parsing-cases.ts'], toh);
|
||||
let service = ts.createLanguageService(mockHost, documentRegistry);
|
||||
let program = service.getProgram();
|
||||
let ngHost = new TypeScriptServiceHost(ts, mockHost, service);
|
||||
let ngService = createLanguageService(ngHost);
|
||||
ngHost.setSite(ngService);
|
||||
|
||||
|
||||
it('should be able to find field in an interpolation', () => {
|
||||
hover(
|
||||
` @Component({template: '{{«name»}}'}) export class MyComponent { name: string; }`,
|
||||
'property name of MyComponent');
|
||||
});
|
||||
|
||||
it('should be able to find a field in a attribute reference', () => {
|
||||
hover(
|
||||
` @Component({template: '<input [(ngModel)]="«name»">'}) export class MyComponent { name: string; }`,
|
||||
'property name of MyComponent');
|
||||
});
|
||||
|
||||
it('should be able to find a method from a call', () => {
|
||||
hover(
|
||||
` @Component({template: '<div (click)="«∆myClick∆()»;"></div>'}) export class MyComponent { myClick() { }}`,
|
||||
'method myClick of MyComponent');
|
||||
});
|
||||
|
||||
it('should be able to find a field reference in an *ngIf', () => {
|
||||
hover(
|
||||
` @Component({template: '<div *ngIf="«include»"></div>'}) export class MyComponent { include = true;}`,
|
||||
'property include of MyComponent');
|
||||
});
|
||||
|
||||
it('should be able to find a reference to a component', () => {
|
||||
hover(
|
||||
` @Component({template: '«<∆test∆-comp></test-comp>»'}) export class MyComponent { }`,
|
||||
'component TestComponent');
|
||||
});
|
||||
|
||||
it('should be able to find an event provider', () => {
|
||||
hover(
|
||||
` @Component({template: '<test-comp «(∆test∆)="myHandler()"»></div>'}) export class MyComponent { myHandler() {} }`,
|
||||
'event testEvent of TestComponent');
|
||||
});
|
||||
|
||||
it('should be able to find an input provider', () => {
|
||||
hover(
|
||||
` @Component({template: '<test-comp «[∆tcName∆]="name"»></div>'}) export class MyComponent { name = 'my name'; }`,
|
||||
'property name of TestComponent');
|
||||
});
|
||||
|
||||
function hover(code: string, hoverText: string) {
|
||||
addCode(code, fileName => {
|
||||
let tests = 0;
|
||||
const markers = mockHost.getReferenceMarkers(fileName);
|
||||
const keys = Object.keys(markers.references).concat(Object.keys(markers.definitions));
|
||||
for (const referenceName of keys) {
|
||||
const references = (markers.references[referenceName] ||
|
||||
[]).concat(markers.definitions[referenceName] || []);
|
||||
for (const reference of references) {
|
||||
tests++;
|
||||
const hover = ngService.getHoverAt(fileName, reference.start);
|
||||
if (!hover) throw new Error(`Expected a hover at location ${reference.start}`);
|
||||
expect(hover.span).toEqual(reference);
|
||||
expect(toText(hover)).toEqual(hoverText);
|
||||
}
|
||||
}
|
||||
expect(tests).toBeGreaterThan(0); // If this fails the test is wrong.
|
||||
});
|
||||
}
|
||||
|
||||
function addCode(code: string, cb: (fileName: string, content?: string) => void) {
|
||||
const fileName = '/app/app.component.ts';
|
||||
const originalContent = mockHost.getFileContent(fileName);
|
||||
const newContent = originalContent + code;
|
||||
mockHost.override(fileName, originalContent + code);
|
||||
try {
|
||||
cb(fileName, newContent);
|
||||
} finally {
|
||||
mockHost.override(fileName, undefined);
|
||||
}
|
||||
}
|
||||
|
||||
function toText(hover: Hover): string { return hover.text.map(h => h.text).join(''); }
|
||||
});
|
52
modules/@angular/language-service/test/html_info_spec.ts
Normal file
52
modules/@angular/language-service/test/html_info_spec.ts
Normal file
@ -0,0 +1,52 @@
|
||||
/**
|
||||
* @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 {DomElementSchemaRegistry} from '@angular/compiler';
|
||||
import {SchemaInformation} from '../src/html_info';
|
||||
|
||||
describe('html_info', () => {
|
||||
const domRegistry = new DomElementSchemaRegistry();
|
||||
|
||||
it('should have the same elements as the dom registry', () => {
|
||||
// If this test fails, replace the SCHEMA constant in html_info with the one
|
||||
// from dom_element_schema_registry and also verify the code to interpret
|
||||
// the schema is the same.
|
||||
const domElements = domRegistry.allKnownElementNames();
|
||||
const infoElements = SchemaInformation.instance.allKnownElements();
|
||||
const uniqueToDom = uniqueElements(infoElements, domElements);
|
||||
const uniqueToInfo = uniqueElements(domElements, infoElements);
|
||||
expect(uniqueToDom).toEqual([]);
|
||||
expect(uniqueToInfo).toEqual([]);
|
||||
});
|
||||
|
||||
it('should have at least a sub-set of properties', () => {
|
||||
const elements = SchemaInformation.instance.allKnownElements();
|
||||
for (const element of elements) {
|
||||
for (const prop of SchemaInformation.instance.propertiesOf(element)) {
|
||||
expect(domRegistry.hasProperty(element, prop, []));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
function uniqueElements<T>(a: T[], b: T[]): T[] {
|
||||
const s = new Set<T>();
|
||||
for (const aItem of a) {
|
||||
s.add(aItem);
|
||||
}
|
||||
const result: T[] = [];
|
||||
const reported = new Set<T>();
|
||||
for (const bItem of b) {
|
||||
if (!s.has(bItem) && !reported.has(bItem)) {
|
||||
reported.add(bItem);
|
||||
result.push(bItem);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
@ -0,0 +1,32 @@
|
||||
/**
|
||||
* @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 * as ts from 'typescript';
|
||||
|
||||
import {createLanguageService} from '../src/language_service';
|
||||
import {Completions, Diagnostic, Diagnostics} from '../src/types';
|
||||
import {TypeScriptServiceHost} from '../src/typescript_host';
|
||||
|
||||
import {toh} from './test_data';
|
||||
import {MockTypescriptHost} from './test_utils';
|
||||
|
||||
describe('references', () => {
|
||||
let documentRegistry = ts.createDocumentRegistry();
|
||||
let mockHost = new MockTypescriptHost(['/app/main.ts', '/app/parsing-cases.ts'], toh);
|
||||
let service = ts.createLanguageService(mockHost, documentRegistry);
|
||||
let program = service.getProgram();
|
||||
let ngHost = new TypeScriptServiceHost(ts, mockHost, service);
|
||||
let ngService = createLanguageService(ngHost);
|
||||
ngHost.setSite(ngService);
|
||||
|
||||
it('should be able to get template references',
|
||||
() => { expect(() => ngService.getTemplateReferences()).not.toThrow(); });
|
||||
|
||||
it('should be able to determine that test.ng is a template reference',
|
||||
() => { expect(ngService.getTemplateReferences()).toContain('/app/test.ng'); });
|
||||
});
|
231
modules/@angular/language-service/test/test_data.ts
Normal file
231
modules/@angular/language-service/test/test_data.ts
Normal file
@ -0,0 +1,231 @@
|
||||
/**
|
||||
* @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 {MockData} from './test_utils';
|
||||
|
||||
export const toh = {
|
||||
app: {
|
||||
'app.component.ts': `import { Component } from '@angular/core';
|
||||
|
||||
export class Hero {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'my-app',
|
||||
template: \`~{empty}
|
||||
<~{start-tag}h~{start-tag-after-h}1~{start-tag-h1} ~{h1-after-space}>~{h1-content} {{~{sub-start}title~{sub-end}}}</h1>
|
||||
~{after-h1}<h2>{{~{h2-hero}hero.~{h2-name}name}} details!</h2>
|
||||
<div><label>id: </label>{{~{label-hero}hero.~{label-id}id}}</div>
|
||||
<div ~{div-attributes}>
|
||||
<label>name: </label>
|
||||
</div>
|
||||
&~{entity-amp}amp;
|
||||
\`
|
||||
})
|
||||
export class AppComponent {
|
||||
title = 'Tour of Heroes';
|
||||
hero: Hero = {
|
||||
id: 1,
|
||||
name: 'Windstorm'
|
||||
};
|
||||
private internal: string;
|
||||
}`,
|
||||
'main.ts': `
|
||||
import { NgModule } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { AppComponent } from './app.component';
|
||||
import { CaseIncompleteOpen, CaseMissingClosing, CaseUnknown, Pipes, TemplateReference, NoValueAttribute,
|
||||
AttributeBinding, StringModel,PropertyBinding, EventBinding, TwoWayBinding, EmptyInterpolation,
|
||||
ForOfEmpty, ForLetIEqual, ForOfLetEmpty, ForUsingComponent, References, TestComponent} from './parsing-cases';
|
||||
import { WrongFieldReference, WrongSubFieldReference, PrivateReference, ExpectNumericType, LowercasePipe } from './expression-cases';
|
||||
import { UnknownPeople, UnknownEven, UnknownTrackBy } from './ng-for-cases';
|
||||
import { ShowIf } from './ng-if-cases';
|
||||
|
||||
@NgModule({
|
||||
imports: [CommonModule, FormsModule],
|
||||
declarations: [AppComponent, CaseIncompleteOpen, CaseMissingClosing, CaseUnknown, Pipes, TemplateReference, NoValueAttribute,
|
||||
AttributeBinding, StringModel, PropertyBinding, EventBinding, TwoWayBinding, EmptyInterpolation, ForOfEmpty, ForOfLetEmpty,
|
||||
ForLetIEqual, ForUsingComponent, References, TestComponent, WrongFieldReference, WrongSubFieldReference, PrivateReference,
|
||||
ExpectNumericType, UnknownPeople, UnknownEven, UnknownTrackBy, ShowIf, LowercasePipe]
|
||||
})
|
||||
export class AppModule {}
|
||||
|
||||
declare function bootstrap(v: any): void;
|
||||
|
||||
bootstrap(AppComponent);
|
||||
`,
|
||||
'parsing-cases.ts': `
|
||||
import {Component, Directive, Input, Output, EventEmitter} from '@angular/core';
|
||||
import {Hero} from './app.component';
|
||||
|
||||
@Component({template: '<h1>Some <~{incomplete-open-lt}a~{incomplete-open-a} ~{incomplete-open-attr} text</h1>'})
|
||||
export class CaseIncompleteOpen {}
|
||||
|
||||
@Component({template: '<h1>Some <a> ~{missing-closing} text</h1>'})
|
||||
export class CaseMissingClosing {}
|
||||
|
||||
@Component({template: '<h1>Some <unknown ~{unknown-element}> text</h1>'})
|
||||
export class CaseUnknown {}
|
||||
|
||||
@Component({template: '<h1>{{data | ~{before-pipe}lowe~{in-pipe}rcase~{after-pipe} }}'})
|
||||
export class Pipes {
|
||||
data = 'Some string';
|
||||
}
|
||||
|
||||
@Component({template: '<h1 h~{no-value-attribute}></h1>'})
|
||||
export class NoValueAttribute {}
|
||||
|
||||
|
||||
@Component({template: '<h1 model="~{attribute-binding-model}test"></h1>'})
|
||||
export class AttributeBinding {
|
||||
test: string;
|
||||
}
|
||||
|
||||
@Component({template: '<h1 [model]="~{property-binding-model}test"></h1>'})
|
||||
export class PropertyBinding {
|
||||
test: string;
|
||||
}
|
||||
|
||||
@Component({template: '<h1 (model)="~{event-binding-model}modelChanged()"></h1>'})
|
||||
export class EventBinding {
|
||||
test: string;
|
||||
|
||||
modelChanged() {}
|
||||
}
|
||||
|
||||
@Component({template: '<h1 [(model)]="~{two-way-binding-model}test"></h1>'})
|
||||
export class TwoWayBinding {
|
||||
test: string;
|
||||
}
|
||||
|
||||
@Directive({selector: '[string-model]'})
|
||||
export class StringModel {
|
||||
@Input() model: string;
|
||||
@Output() modelChanged: EventEmitter<string>;
|
||||
}
|
||||
|
||||
interface Person {
|
||||
name: string;
|
||||
age: number
|
||||
}
|
||||
|
||||
@Component({template: '<div *ngFor="~{for-empty}"></div>'})
|
||||
export class ForOfEmpty {}
|
||||
|
||||
@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="~{for-let}let ~{for-person}person ~{for-of}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({template: '<div #div> <test-comp #test1> {{~{test-comp-content}}} {{test1.~{test-comp-after-test}name}} {{div.~{test-comp-after-div}.innerText}} </test-comp> </div> <test-comp #test2></test-comp>'})
|
||||
export class References {}
|
||||
|
||||
@Component({selector: 'test-comp', template: '<div>Testing: {{name}}</div>'})
|
||||
export class TestComponent {
|
||||
«@Input('∆tcName∆') name = 'test';»
|
||||
«@Output('∆test∆') testEvent = new EventEmitter();»
|
||||
}
|
||||
|
||||
@Component({templateUrl: 'test.ng'})
|
||||
export class TemplateReference {
|
||||
title = 'Some title';
|
||||
hero: Hero = {
|
||||
id: 1,
|
||||
name: 'Windstorm'
|
||||
};
|
||||
myClick(event: any) {
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@Component({template: '{{~{empty-interpolation}}}'})
|
||||
export class EmptyInterpolation {
|
||||
title = 'Some title';
|
||||
subTitle = 'Some sub title';
|
||||
}
|
||||
`,
|
||||
'expression-cases.ts': `
|
||||
import {Component} from '@angular/core';
|
||||
|
||||
export interface Person {
|
||||
name: string;
|
||||
age: number;
|
||||
}
|
||||
|
||||
@Component({template: '{{~{foo}foo~{foo-end}}}'})
|
||||
export class WrongFieldReference {
|
||||
bar = 'bar';
|
||||
}
|
||||
|
||||
@Component({template: '{{~{nam}person.nam~{nam-end}}}'})
|
||||
export class WrongSubFieldReference {
|
||||
person: Person = { name: 'Bob', age: 23 };
|
||||
}
|
||||
|
||||
@Component({template: '{{~{myField}myField~{myField-end}}}'})
|
||||
export class PrivateReference {
|
||||
private myField = 'My Field';
|
||||
}
|
||||
|
||||
@Component({template: '{{~{mod}"a" ~{mod-end}% 2}}'})
|
||||
export class ExpectNumericType {}
|
||||
|
||||
@Component({template: '{{ (name | lowercase).~{string-pipe}substring }}'})
|
||||
export class LowercasePipe {
|
||||
name: string;
|
||||
}
|
||||
`,
|
||||
'ng-for-cases.ts': `
|
||||
import {Component} from '@angular/core';
|
||||
|
||||
export interface Person {
|
||||
name: string;
|
||||
age: number;
|
||||
}
|
||||
|
||||
@Component({template: '<div *ngFor="let person of ~{people_1}people_1~{people_1-end}"> <span>{{person.name}}</span> </div>'})
|
||||
export class UnknownPeople {}
|
||||
|
||||
@Component({template: '<div ~{even_1}*ngFor="let person of people; let e = even_1"~{even_1-end}><span>{{person.name}}</span> </div>'})
|
||||
export class UnknownEven {
|
||||
people: Person[];
|
||||
}
|
||||
|
||||
@Component({template: '<div *ngFor="let person of people; trackBy ~{trackBy_1}trackBy_1~{trackBy_1-end}"><span>{{person.name}}</span> </div>'})
|
||||
export class UnknownTrackBy {
|
||||
people: Person[];
|
||||
}
|
||||
`,
|
||||
'ng-if-cases.ts': `
|
||||
import {Component} from '@angular/core';
|
||||
|
||||
@Component({template: '<div ~{implicit}*ngIf="show; let l"~{implicit-end}>Showing now!</div>'})
|
||||
export class ShowIf {
|
||||
show = false;
|
||||
}
|
||||
`,
|
||||
'test.ng': `~{empty}
|
||||
<~{start-tag}h~{start-tag-after-h}1~{start-tag-h1} ~{h1-after-space}>~{h1-content} {{~{sub-start}title~{sub-end}}}</h1>
|
||||
~{after-h1}<h2>{{~{h2-hero}hero.~{h2-name}name}} details!</h2>
|
||||
<div><label>id: </label>{{~{label-hero}hero.~{label-id}id}}</div>
|
||||
<div ~{div-attributes}>
|
||||
<label>name: </label>
|
||||
</div>
|
||||
&~{entity-amp}amp;
|
||||
`
|
||||
}
|
||||
};
|
320
modules/@angular/language-service/test/test_utils.ts
Normal file
320
modules/@angular/language-service/test/test_utils.ts
Normal file
@ -0,0 +1,320 @@
|
||||
/**
|
||||
* @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
|
||||
*/
|
||||
|
||||
/// <reference path="../../../../node_modules/@types/node/index.d.ts" />
|
||||
/// <reference path="../../../../node_modules/@types/jasmine/index.d.ts" />
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as ts from 'typescript';
|
||||
|
||||
import {Diagnostic, Diagnostics, Span} from '../src/types';
|
||||
|
||||
export type MockData = string | MockDirectory;
|
||||
|
||||
export type MockDirectory = {
|
||||
[name: string]: MockData | undefined;
|
||||
}
|
||||
|
||||
const angularts = /@angular\/(\w|\/|-)+\.tsx?$/;
|
||||
const rxjsts = /rxjs\/(\w|\/)+\.tsx?$/;
|
||||
const rxjsmetadata = /rxjs\/(\w|\/)+\.metadata\.json?$/;
|
||||
const tsxfile = /\.tsx$/;
|
||||
|
||||
/* The missing cache does two things. First it improves performance of the
|
||||
tests as it reduces the number of OS calls made during testing. Also it
|
||||
improves debugging experience as fewer exceptions are raised allow you
|
||||
to use stopping on all exceptions. */
|
||||
const missingCache = new Map<string, boolean>();
|
||||
const cacheUsed = new Set<string>();
|
||||
const reportedMissing = new Set<string>();
|
||||
|
||||
/**
|
||||
* The cache is valid if all the returned entries are empty.
|
||||
*/
|
||||
export function validateCache(): {exists: string[], unused: string[], reported: string[]} {
|
||||
const exists: string[] = [];
|
||||
const unused: string[] = [];
|
||||
for (const fileName of iterableToArray(missingCache.keys())) {
|
||||
if (fs.existsSync(fileName)) {
|
||||
exists.push(fileName);
|
||||
}
|
||||
if (!cacheUsed.has(fileName)) {
|
||||
unused.push(fileName);
|
||||
}
|
||||
}
|
||||
return {exists, unused, reported: iterableToArray(reportedMissing.keys())};
|
||||
}
|
||||
|
||||
missingCache.set('/node_modules/@angular/core.d.ts', true);
|
||||
missingCache.set('/node_modules/@angular/common.d.ts', true);
|
||||
missingCache.set('/node_modules/@angular/forms.d.ts', true);
|
||||
missingCache.set('/node_modules/@angular/core/src/di/provider.metadata.json', true);
|
||||
missingCache.set(
|
||||
'/node_modules/@angular/core/src/change_detection/pipe_transform.metadata.json', true);
|
||||
missingCache.set('/node_modules/@angular/core/src/reflection/types.metadata.json', true);
|
||||
missingCache.set(
|
||||
'/node_modules/@angular/core/src/reflection/platform_reflection_capabilities.metadata.json',
|
||||
true);
|
||||
missingCache.set('/node_modules/@angular/forms/src/directives/form_interface.metadata.json', true);
|
||||
|
||||
export class MockTypescriptHost implements ts.LanguageServiceHost {
|
||||
private angularPath: string;
|
||||
private nodeModulesPath: string;
|
||||
private scriptVersion = new Map<string, number>();
|
||||
private overrides = new Map<string, string>();
|
||||
private projectVersion = 0;
|
||||
|
||||
constructor(private scriptNames: string[], private data: MockData) {
|
||||
let angularIndex = module.filename.indexOf('@angular');
|
||||
if (angularIndex >= 0)
|
||||
this.angularPath =
|
||||
module.filename.substr(0, angularIndex).replace('/all/', '/packages-dist/');
|
||||
let distIndex = module.filename.indexOf('/dist/all');
|
||||
if (distIndex >= 0)
|
||||
this.nodeModulesPath = path.join(module.filename.substr(0, distIndex), 'node_modules');
|
||||
}
|
||||
|
||||
override(fileName: string, content: string) {
|
||||
this.scriptVersion.set(fileName, (this.scriptVersion.get(fileName) || 0) + 1);
|
||||
if (fileName.endsWith('.ts')) {
|
||||
this.projectVersion++;
|
||||
}
|
||||
if (content) {
|
||||
this.overrides.set(fileName, content);
|
||||
} else {
|
||||
this.overrides.delete(fileName);
|
||||
}
|
||||
}
|
||||
|
||||
getCompilationSettings(): ts.CompilerOptions {
|
||||
return {
|
||||
target: ts.ScriptTarget.ES5,
|
||||
module: ts.ModuleKind.CommonJS,
|
||||
moduleResolution: ts.ModuleResolutionKind.NodeJs,
|
||||
emitDecoratorMetadata: true,
|
||||
experimentalDecorators: true,
|
||||
removeComments: false,
|
||||
noImplicitAny: false,
|
||||
lib: ['lib.es2015.d.ts', 'lib.dom.d.ts'],
|
||||
};
|
||||
}
|
||||
|
||||
getProjectVersion(): string { return this.projectVersion.toString(); }
|
||||
|
||||
getScriptFileNames(): string[] { return this.scriptNames; }
|
||||
|
||||
getScriptVersion(fileName: string): string {
|
||||
return (this.scriptVersion.get(fileName) || 0).toString();
|
||||
}
|
||||
|
||||
getScriptSnapshot(fileName: string): ts.IScriptSnapshot {
|
||||
const content = this.getFileContent(fileName);
|
||||
if (content) return ts.ScriptSnapshot.fromString(content);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
getCurrentDirectory(): string { return '/'; }
|
||||
|
||||
getDefaultLibFileName(options: ts.CompilerOptions): string { return 'lib.d.ts'; }
|
||||
|
||||
directoryExists(directoryName: string): boolean {
|
||||
let effectiveName = this.getEffectiveName(directoryName);
|
||||
if (effectiveName === directoryName)
|
||||
return directoryExists(directoryName, this.data);
|
||||
else
|
||||
return fs.existsSync(effectiveName);
|
||||
}
|
||||
|
||||
getMarkerLocations(fileName: string): {[name: string]: number}|undefined {
|
||||
let content = this.getRawFileContent(fileName);
|
||||
if (content) {
|
||||
return getLocationMarkers(content);
|
||||
}
|
||||
}
|
||||
|
||||
getReferenceMarkers(fileName: string): ReferenceResult {
|
||||
let content = this.getRawFileContent(fileName);
|
||||
if (content) {
|
||||
return getReferenceMarkers(content);
|
||||
}
|
||||
}
|
||||
|
||||
getFileContent(fileName: string): string {
|
||||
const content = this.getRawFileContent(fileName);
|
||||
if (content) return removeReferenceMarkers(removeLocationMarkers(content));
|
||||
}
|
||||
|
||||
private getRawFileContent(fileName: string): string {
|
||||
if (this.overrides.has(fileName)) {
|
||||
return this.overrides.get(fileName);
|
||||
}
|
||||
let basename = path.basename(fileName);
|
||||
if (/^lib.*\.d\.ts$/.test(basename)) {
|
||||
let libPath = ts.getDefaultLibFilePath(this.getCompilationSettings());
|
||||
return fs.readFileSync(path.join(path.dirname(libPath), basename), 'utf8');
|
||||
} else {
|
||||
if (missingCache.has(fileName)) {
|
||||
cacheUsed.add(fileName);
|
||||
return undefined;
|
||||
}
|
||||
let effectiveName = this.getEffectiveName(fileName);
|
||||
if (effectiveName === fileName)
|
||||
return open(fileName, this.data);
|
||||
else if (
|
||||
!fileName.match(angularts) && !fileName.match(rxjsts) && !fileName.match(rxjsmetadata) &&
|
||||
!fileName.match(tsxfile)) {
|
||||
if (fs.existsSync(effectiveName)) {
|
||||
return fs.readFileSync(effectiveName, 'utf8');
|
||||
} else {
|
||||
missingCache.set(fileName, true);
|
||||
reportedMissing.add(fileName);
|
||||
cacheUsed.add(fileName);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private getEffectiveName(name: string): string {
|
||||
const node_modules = 'node_modules';
|
||||
const at_angular = '/@angular';
|
||||
if (name.startsWith('/' + node_modules)) {
|
||||
if (this.nodeModulesPath && !name.startsWith('/' + node_modules + at_angular)) {
|
||||
let result = path.join(this.nodeModulesPath, name.substr(node_modules.length + 1));
|
||||
if (!name.match(rxjsts))
|
||||
if (fs.existsSync(result)) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
if (this.angularPath && name.startsWith('/' + node_modules + at_angular)) {
|
||||
return path.join(
|
||||
this.angularPath, name.substr(node_modules.length + at_angular.length + 1));
|
||||
}
|
||||
}
|
||||
return name;
|
||||
}
|
||||
}
|
||||
|
||||
function iterableToArray<T>(iterator: IterableIterator<T>) {
|
||||
const result: T[] = [];
|
||||
while (true) {
|
||||
const next = iterator.next();
|
||||
if (next.done) break;
|
||||
result.push(next.value);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function find(fileName: string, data: MockData): MockData|undefined {
|
||||
let names = fileName.split('/');
|
||||
if (names.length && !names[0].length) names.shift();
|
||||
let current = data;
|
||||
for (let name of names) {
|
||||
if (typeof current === 'string')
|
||||
return undefined;
|
||||
else
|
||||
current = (<MockDirectory>current)[name];
|
||||
if (!current) return undefined;
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
function open(fileName: string, data: MockData): string|undefined {
|
||||
let result = find(fileName, data);
|
||||
if (typeof result === 'string') {
|
||||
return result;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function directoryExists(dirname: string, data: MockData): boolean {
|
||||
let result = find(dirname, data);
|
||||
return result && typeof result !== 'string';
|
||||
}
|
||||
|
||||
const locationMarker = /\~\{(\w+(-\w+)*)\}/g;
|
||||
|
||||
function removeLocationMarkers(value: string): string {
|
||||
return value.replace(locationMarker, '');
|
||||
}
|
||||
|
||||
function getLocationMarkers(value: string): {[name: string]: number} {
|
||||
value = removeReferenceMarkers(value);
|
||||
let result: {[name: string]: number} = {};
|
||||
let adjustment = 0;
|
||||
value.replace(locationMarker, (match: string, name: string, _: any, index: number): string => {
|
||||
result[name] = index - adjustment;
|
||||
adjustment += match.length;
|
||||
return '';
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
const referenceMarker = /«(((\w|\-)+)|([^∆]*∆(\w+)∆.[^»]*))»/g;
|
||||
const definitionMarkerGroup = 1;
|
||||
const nameMarkerGroup = 2;
|
||||
|
||||
export type ReferenceMarkers = {
|
||||
[name: string]: Span[]
|
||||
};
|
||||
export interface ReferenceResult {
|
||||
text: string;
|
||||
definitions: ReferenceMarkers;
|
||||
references: ReferenceMarkers;
|
||||
}
|
||||
|
||||
function getReferenceMarkers(value: string): ReferenceResult {
|
||||
const references: ReferenceMarkers = {};
|
||||
const definitions: ReferenceMarkers = {};
|
||||
value = removeLocationMarkers(value);
|
||||
|
||||
let adjustment = 0;
|
||||
const text = value.replace(
|
||||
referenceMarker, (match: string, text: string, reference: string, _: string,
|
||||
definition: string, definitionName: string, index: number): string => {
|
||||
const result = reference ? text : text.replace(/∆/g, '');
|
||||
const span: Span = {start: index - adjustment, end: index - adjustment + result.length};
|
||||
const markers = reference ? references : definitions;
|
||||
const name = reference || definitionName;
|
||||
(markers[name] = (markers[name] || [])).push(span);
|
||||
adjustment += match.length - result.length;
|
||||
return result;
|
||||
});
|
||||
|
||||
return {text, definitions, references};
|
||||
}
|
||||
|
||||
function removeReferenceMarkers(value: string): string {
|
||||
return value.replace(referenceMarker, (match, text) => text.replace(/∆/g, ''));
|
||||
}
|
||||
|
||||
export function noDiagnostics(diagnostics: Diagnostics) {
|
||||
if (diagnostics && diagnostics.length) {
|
||||
throw new Error(`Unexpected diagnostics: \n ${diagnostics.map(d => d.message).join('\n ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function includeDiagnostic(
|
||||
diagnostics: Diagnostics, message: string, text?: string, len?: string): void;
|
||||
export function includeDiagnostic(
|
||||
diagnostics: Diagnostics, message: string, at?: number, len?: number): void;
|
||||
export function includeDiagnostic(diagnostics: Diagnostics, message: string, p1?: any, p2?: any) {
|
||||
expect(diagnostics).toBeDefined();
|
||||
if (diagnostics) {
|
||||
const diagnostic = diagnostics.find(d => d.message.indexOf(message) >= 0) as Diagnostic;
|
||||
expect(diagnostic).toBeDefined();
|
||||
if (diagnostic && p1 != null) {
|
||||
const at = typeof p1 === 'number' ? p1 : p2.indexOf(p1);
|
||||
const len = typeof p2 === 'number' ? p2 : p1.length;
|
||||
expect(diagnostic.span.start).toEqual(at);
|
||||
if (len != null) {
|
||||
expect(diagnostic.span.end - diagnostic.span.start).toEqual(len);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
264
modules/@angular/language-service/test/ts_plugin_spec.ts
Normal file
264
modules/@angular/language-service/test/ts_plugin_spec.ts
Normal file
@ -0,0 +1,264 @@
|
||||
/**
|
||||
* @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 {LanguageServicePlugin} from '../src/ts_plugin';
|
||||
|
||||
import {toh} from './test_data';
|
||||
import {MockTypescriptHost} from './test_utils';
|
||||
|
||||
describe('plugin', () => {
|
||||
let documentRegistry = ts.createDocumentRegistry();
|
||||
let mockHost = new MockTypescriptHost(['/app/main.ts', '/app/parsing-cases.ts'], toh);
|
||||
let service = ts.createLanguageService(mockHost, documentRegistry);
|
||||
let program = service.getProgram();
|
||||
|
||||
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));
|
||||
}
|
||||
});
|
||||
|
||||
let plugin =
|
||||
new LanguageServicePlugin({ts: ts, host: mockHost, service, registry: documentRegistry});
|
||||
|
||||
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.getSemanticDiagnosticsFilter(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 diretives',
|
||||
() => { 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 returned 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 in 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 the 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 read',
|
||||
() => { 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 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 not suggest any entries if in the name part of a let',
|
||||
() => { expectEmpty('app/parsing-cases.ts', 'for-let-empty'); });
|
||||
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 refernce 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 erros',
|
||||
() => { 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 have an implicit value');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
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), ...names);
|
||||
}
|
||||
|
||||
function expectEmpty(fileName: string, locationMarker: string) {
|
||||
const location = getMarkerLocation(fileName, locationMarker);
|
||||
expect(plugin.getCompletionsAtPosition(fileName, location).entries).toEqual([]);
|
||||
}
|
||||
|
||||
function expectSemanticError(fileName: string, locationMarker: string, message: string) {
|
||||
const start = getMarkerLocation(fileName, locationMarker);
|
||||
const end = getMarkerLocation(fileName, locationMarker + '-end');
|
||||
const errors = plugin.getSemanticDiagnosticsFilter(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.start) {
|
||||
let {line, character} = diagnostic.file.getLineAndCharacterOfPosition(diagnostic.start);
|
||||
console.log(`${diagnostic.file.fileName} (${line + 1},${character + 1}): ${message}`);
|
||||
} else {
|
||||
console.log(`${message}`);
|
||||
}
|
||||
}
|
||||
expect(diagnostics.length).toBe(0);
|
||||
}
|
Reference in New Issue
Block a user