405 lines
13 KiB
TypeScript
405 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 {CompileNgModuleMetadata, NgAnalyzedModules} from '@angular/compiler';
|
|
import {setup} from '@angular/compiler-cli/test/test_support';
|
|
import * as fs from 'fs';
|
|
import * as path from 'path';
|
|
import * as ts from 'typescript';
|
|
|
|
import {Span} from '../src/types';
|
|
|
|
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 to allow you
|
|
to use stopping on all exceptions. */
|
|
const missingCache = new Set<string>([
|
|
'/node_modules/@angular/core.d.ts',
|
|
'/node_modules/@angular/animations.d.ts',
|
|
'/node_modules/@angular/platform-browser/animations.d.ts',
|
|
'/node_modules/@angular/common.d.ts',
|
|
'/node_modules/@angular/forms.d.ts',
|
|
'/node_modules/@angular/core/src/di/provider.metadata.json',
|
|
'/node_modules/@angular/core/src/change_detection/pipe_transform.metadata.json',
|
|
'/node_modules/@angular/core/src/reflection/types.metadata.json',
|
|
'/node_modules/@angular/core/src/reflection/platform_reflection_capabilities.metadata.json',
|
|
'/node_modules/@angular/forms/src/directives/form_interface.metadata.json',
|
|
]);
|
|
|
|
function isFile(path: string) {
|
|
return fs.statSync(path).isFile();
|
|
}
|
|
|
|
/**
|
|
* Return a Map with key = directory / file path, value = file content.
|
|
* [
|
|
* /app => [[directory]]
|
|
* /app/main.ts => ...
|
|
* /app/app.component.ts => ...
|
|
* /app/expression-cases.ts => ...
|
|
* /app/ng-for-cases.ts => ...
|
|
* /app/ng-if-cases.ts => ...
|
|
* /app/parsing-cases.ts => ...
|
|
* /app/test.css => ...
|
|
* /app/test.ng => ...
|
|
* ]
|
|
*/
|
|
function loadTourOfHeroes(): ReadonlyMap<string, string> {
|
|
const {TEST_SRCDIR} = process.env;
|
|
const root =
|
|
path.join(TEST_SRCDIR !, 'angular', 'packages', 'language-service', 'test', 'project');
|
|
const dirs = [root];
|
|
const files = new Map<string, string>();
|
|
while (dirs.length) {
|
|
const dirPath = dirs.pop() !;
|
|
for (const filePath of fs.readdirSync(dirPath)) {
|
|
const absPath = path.join(dirPath, filePath);
|
|
if (isFile(absPath)) {
|
|
const key = path.join('/', path.relative(root, absPath));
|
|
const value = fs.readFileSync(absPath, 'utf8');
|
|
files.set(key, value);
|
|
} else {
|
|
const key = path.join('/', filePath);
|
|
files.set(key, '[[directory]]');
|
|
dirs.push(absPath);
|
|
}
|
|
}
|
|
}
|
|
return files;
|
|
}
|
|
|
|
const TOH = loadTourOfHeroes();
|
|
|
|
export class MockTypescriptHost implements ts.LanguageServiceHost {
|
|
private angularPath?: string;
|
|
private readonly nodeModulesPath: string;
|
|
private readonly scriptVersion = new Map<string, number>();
|
|
private readonly overrides = new Map<string, string>();
|
|
private projectVersion = 0;
|
|
private options: ts.CompilerOptions;
|
|
private readonly overrideDirectory = new Set<string>();
|
|
private readonly existsCache = new Map<string, boolean>();
|
|
private readonly fileCache = new Map<string, string|undefined>();
|
|
|
|
constructor(
|
|
private readonly scriptNames: string[],
|
|
private readonly node_modules: string = 'node_modules',
|
|
private readonly myPath: typeof path = path) {
|
|
const support = setup();
|
|
this.nodeModulesPath = path.posix.join(support.basePath, 'node_modules');
|
|
this.angularPath = path.posix.join(this.nodeModulesPath, '@angular');
|
|
this.options = {
|
|
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'],
|
|
};
|
|
}
|
|
|
|
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);
|
|
this.overrideDirectory.add(path.dirname(fileName));
|
|
} else {
|
|
this.overrides.delete(fileName);
|
|
}
|
|
return content;
|
|
}
|
|
|
|
addScript(fileName: string, content: string) {
|
|
this.projectVersion++;
|
|
this.overrides.set(fileName, content);
|
|
this.overrideDirectory.add(path.dirname(fileName));
|
|
this.scriptNames.push(fileName);
|
|
}
|
|
|
|
forgetAngular() { this.angularPath = undefined; }
|
|
|
|
overrideOptions(cb: (options: ts.CompilerOptions) => ts.CompilerOptions) {
|
|
this.options = cb((Object as any).assign({}, this.options));
|
|
this.projectVersion++;
|
|
}
|
|
|
|
getCompilationSettings(): ts.CompilerOptions { return this.options; }
|
|
|
|
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|undefined {
|
|
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 {
|
|
if (this.overrideDirectory.has(directoryName)) return true;
|
|
const effectiveName = this.getEffectiveName(directoryName);
|
|
if (effectiveName === directoryName) {
|
|
return TOH.has(directoryName);
|
|
}
|
|
if (effectiveName === '/' + this.node_modules) {
|
|
return true;
|
|
}
|
|
return this.pathExists(effectiveName);
|
|
}
|
|
|
|
fileExists(fileName: string): boolean { return this.getRawFileContent(fileName) != null; }
|
|
|
|
readFile(path: string): string|undefined { return this.getRawFileContent(path); }
|
|
|
|
getMarkerLocations(fileName: string): {[name: string]: number}|undefined {
|
|
let content = this.getRawFileContent(fileName);
|
|
if (content) {
|
|
return getLocationMarkers(content);
|
|
}
|
|
}
|
|
|
|
getReferenceMarkers(fileName: string): ReferenceResult|undefined {
|
|
let content = this.getRawFileContent(fileName);
|
|
if (content) {
|
|
return getReferenceMarkers(content);
|
|
}
|
|
}
|
|
|
|
getFileContent(fileName: string): string|undefined {
|
|
const content = this.getRawFileContent(fileName);
|
|
if (content) return removeReferenceMarkers(removeLocationMarkers(content));
|
|
}
|
|
|
|
/**
|
|
* Reset the project to its original state, effectively removing all overrides.
|
|
*/
|
|
reset() {
|
|
this.overrides.clear();
|
|
this.overrideDirectory.clear();
|
|
}
|
|
|
|
private getRawFileContent(fileName: string): string|undefined {
|
|
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(this.myPath.join(path.dirname(libPath), basename), 'utf8');
|
|
}
|
|
if (missingCache.has(fileName)) {
|
|
return undefined;
|
|
}
|
|
|
|
const effectiveName = this.getEffectiveName(fileName);
|
|
if (effectiveName === fileName) {
|
|
return TOH.get(fileName);
|
|
}
|
|
if (!fileName.match(angularts) && !fileName.match(rxjsts) && !fileName.match(rxjsmetadata) &&
|
|
!fileName.match(tsxfile)) {
|
|
if (this.fileCache.has(effectiveName)) {
|
|
return this.fileCache.get(effectiveName);
|
|
} else if (this.pathExists(effectiveName)) {
|
|
const content = fs.readFileSync(effectiveName, 'utf8');
|
|
this.fileCache.set(effectiveName, content);
|
|
return content;
|
|
} else {
|
|
missingCache.add(fileName);
|
|
}
|
|
}
|
|
}
|
|
|
|
private pathExists(path: string): boolean {
|
|
if (this.existsCache.has(path)) {
|
|
return this.existsCache.get(path) !;
|
|
}
|
|
|
|
const exists = fs.existsSync(path);
|
|
this.existsCache.set(path, exists);
|
|
return exists;
|
|
}
|
|
|
|
private getEffectiveName(name: string): string {
|
|
const node_modules = this.node_modules;
|
|
const at_angular = '/@angular';
|
|
if (name.startsWith('/' + node_modules)) {
|
|
if (this.nodeModulesPath && !name.startsWith('/' + node_modules + at_angular)) {
|
|
const result =
|
|
this.myPath.posix.join(this.nodeModulesPath, name.substr(node_modules.length + 1));
|
|
if (!name.match(rxjsts) && this.pathExists(result)) {
|
|
return result;
|
|
}
|
|
}
|
|
if (this.angularPath && name.startsWith('/' + node_modules + at_angular)) {
|
|
return this.myPath.posix.join(
|
|
this.angularPath, name.substr(node_modules.length + at_angular.length + 1));
|
|
}
|
|
}
|
|
return name;
|
|
}
|
|
|
|
|
|
/**
|
|
* Append a snippet of code to `app.component.ts` and return the file name.
|
|
* There must not be any name collision with existing code.
|
|
* @param code Snippet of code
|
|
*/
|
|
addCode(code: string) {
|
|
const fileName = '/app/app.component.ts';
|
|
const originalContent = this.getFileContent(fileName);
|
|
const newContent = originalContent + code;
|
|
this.override(fileName, newContent);
|
|
return fileName;
|
|
}
|
|
|
|
/**
|
|
* Returns the definition marker ᐱselectorᐱ for the specified 'selector'.
|
|
* Asserts that marker exists.
|
|
* @param fileName name of the file
|
|
* @param selector name of the marker
|
|
*/
|
|
getDefinitionMarkerFor(fileName: string, selector: string): ts.TextSpan {
|
|
const markers = this.getReferenceMarkers(fileName);
|
|
expect(markers).toBeDefined();
|
|
expect(Object.keys(markers !.definitions)).toContain(selector);
|
|
expect(markers !.definitions[selector].length).toBe(1);
|
|
const marker = markers !.definitions[selector][0];
|
|
expect(marker.start).toBeLessThanOrEqual(marker.end);
|
|
return {
|
|
start: marker.start,
|
|
length: marker.end - marker.start,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Returns the reference marker «selector» for the specified 'selector'.
|
|
* Asserts that marker exists.
|
|
* @param fileName name of the file
|
|
* @param selector name of the marker
|
|
*/
|
|
getReferenceMarkerFor(fileName: string, selector: string): ts.TextSpan {
|
|
const markers = this.getReferenceMarkers(fileName);
|
|
expect(markers).toBeDefined();
|
|
expect(Object.keys(markers !.references)).toContain(selector);
|
|
expect(markers !.references[selector].length).toBe(1);
|
|
const marker = markers !.references[selector][0];
|
|
expect(marker.start).toBeLessThanOrEqual(marker.end);
|
|
return {
|
|
start: marker.start,
|
|
length: marker.end - marker.start,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Returns the location marker ~{selector} for the specified 'selector'.
|
|
* Asserts that marker exists.
|
|
* @param fileName name of the file
|
|
* @param selector name of the marker
|
|
*/
|
|
getLocationMarkerFor(fileName: string, selector: string): ts.TextSpan {
|
|
const markers = this.getMarkerLocations(fileName);
|
|
expect(markers).toBeDefined();
|
|
const start = markers ![`start-${selector}`];
|
|
expect(start).toBeDefined();
|
|
const end = markers ![`end-${selector}`];
|
|
expect(end).toBeDefined();
|
|
expect(start).toBeLessThanOrEqual(end);
|
|
return {
|
|
start: start,
|
|
length: end - start,
|
|
};
|
|
}
|
|
}
|
|
|
|
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;
|
|
|
|
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, ''));
|
|
}
|
|
|
|
/**
|
|
* Find the StaticSymbol that has the specified `directiveName` and return its
|
|
* Angular metadata, if any.
|
|
* @param ngModules analyzed modules
|
|
* @param directiveName
|
|
*/
|
|
export function findDirectiveMetadataByName(
|
|
ngModules: NgAnalyzedModules, directiveName: string): CompileNgModuleMetadata|undefined {
|
|
for (const [key, value] of ngModules.ngModuleByPipeOrDirective) {
|
|
if (key.name === directiveName) {
|
|
return value;
|
|
}
|
|
}
|
|
}
|