refactor(ivy): implement a virtual file-system layer in ngtsc + ngcc (#30921)

To improve cross platform support, all file access (and path manipulation)
is now done through a well known interface (`FileSystem`).

For testing a number of `MockFileSystem` implementations are provided.
These provide an in-memory file-system which emulates operating systems
like OS/X, Unix and Windows.

The current file system is always available via the static method,
`FileSystem.getFileSystem()`. This is also used by a number of static
methods on `AbsoluteFsPath` and `PathSegment`, to avoid having to pass
`FileSystem` objects around all the time. The result of this is that one
must be careful to ensure that the file-system has been initialized before
using any of these static methods. To prevent this happening accidentally
the current file system always starts out as an instance of `InvalidFileSystem`,
which will throw an error if any of its methods are called.

You can set the current file-system by calling `FileSystem.setFileSystem()`.
During testing you can call the helper function `initMockFileSystem(os)`
which takes a string name of the OS to emulate, and will also monkey-patch
aspects of the TypeScript library to ensure that TS is also using the
current file-system.

Finally there is the `NgtscCompilerHost` to be used for any TypeScript
compilation, which uses a given file-system.

All tests that interact with the file-system should be tested against each
of the mock file-systems. A series of helpers have been provided to support
such tests:

* `runInEachFileSystem()` - wrap your tests in this helper to run all the
wrapped tests in each of the mock file-systems.
* `addTestFilesToFileSystem()` - use this to add files and their contents
to the mock file system for testing.
* `loadTestFilesFromDisk()` - use this to load a mirror image of files on
disk into the in-memory mock file-system.
* `loadFakeCore()` - use this to load a fake version of `@angular/core`
into the mock file-system.

All ngcc and ngtsc source and tests now use this virtual file-system setup.

PR Close #30921
This commit is contained in:
Pete Bacon Darwin
2019-06-06 20:22:32 +01:00
committed by Kara Erickson
parent 1e7e065423
commit 7186f9c016
177 changed files with 16598 additions and 14829 deletions

View File

@ -10,6 +10,7 @@ ts_library(
]),
deps = [
"//packages:types",
"//packages/compiler-cli/src/ngtsc/file_system",
"@npm//typescript",
],
)

View File

@ -1,157 +0,0 @@
/**
* @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 types="jasmine"/>
import * as path from 'path';
import * as ts from 'typescript';
export function makeProgram(
files: {name: string, contents: string, isRoot?: boolean}[], options?: ts.CompilerOptions,
host: ts.CompilerHost = new InMemoryHost(), checkForErrors: boolean = true):
{program: ts.Program, host: ts.CompilerHost, options: ts.CompilerOptions} {
files.forEach(file => host.writeFile(file.name, file.contents, false, undefined, []));
const rootNames =
files.filter(file => file.isRoot !== false).map(file => host.getCanonicalFileName(file.name));
const compilerOptions = {
noLib: true,
experimentalDecorators: true,
moduleResolution: ts.ModuleResolutionKind.NodeJs, ...options
};
const program = ts.createProgram(rootNames, compilerOptions, host);
if (checkForErrors) {
const diags = [...program.getSyntacticDiagnostics(), ...program.getSemanticDiagnostics()];
if (diags.length > 0) {
const errors = diags.map(diagnostic => {
let message = ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n');
if (diagnostic.file) {
const {line, character} =
diagnostic.file.getLineAndCharacterOfPosition(diagnostic.start !);
message = `${diagnostic.file.fileName} (${line + 1},${character + 1}): ${message}`;
}
return `Error: ${message}`;
});
throw new Error(`Typescript diagnostics failed! ${errors.join(', ')}`);
}
}
return {program, host, options: compilerOptions};
}
export class InMemoryHost implements ts.CompilerHost {
private fileSystem = new Map<string, string>();
getSourceFile(
fileName: string, languageVersion: ts.ScriptTarget,
onError?: ((message: string) => void)|undefined,
shouldCreateNewSourceFile?: boolean|undefined): ts.SourceFile|undefined {
const contents = this.fileSystem.get(this.getCanonicalFileName(fileName));
if (contents === undefined) {
onError && onError(`File does not exist: ${this.getCanonicalFileName(fileName)})`);
return undefined;
}
return ts.createSourceFile(fileName, contents, languageVersion);
}
getDefaultLibFileName(options: ts.CompilerOptions): string { return '/lib.d.ts'; }
writeFile(
fileName: string, data: string, writeByteOrderMark?: boolean,
onError?: ((message: string) => void)|undefined,
sourceFiles?: ReadonlyArray<ts.SourceFile>): void {
this.fileSystem.set(this.getCanonicalFileName(fileName), data);
}
getCurrentDirectory(): string { return '/'; }
getDirectories(dir: string): string[] {
const fullDir = this.getCanonicalFileName(dir) + '/';
const dirSet = new Set(Array
// Look at all paths known to the host.
.from(this.fileSystem.keys())
// Filter out those that aren't under the requested directory.
.filter(candidate => candidate.startsWith(fullDir))
// Relativize the rest by the requested directory.
.map(candidate => candidate.substr(fullDir.length))
// What's left are dir/.../file.txt entries, and file.txt entries.
// Get the dirname, which
// yields '.' for the latter and dir/... for the former.
.map(candidate => path.dirname(candidate))
// Filter out the '.' entries, which were files.
.filter(candidate => candidate !== '.')
// Finally, split on / and grab the first entry.
.map(candidate => candidate.split('/', 1)[0]));
// Get the resulting values out of the Set.
return Array.from(dirSet);
}
getCanonicalFileName(fileName: string): string {
return path.posix.normalize(`${this.getCurrentDirectory()}/${fileName}`);
}
useCaseSensitiveFileNames(): boolean { return true; }
getNewLine(): string { return '\n'; }
fileExists(fileName: string): boolean { return this.fileSystem.has(fileName); }
readFile(fileName: string): string|undefined { return this.fileSystem.get(fileName); }
}
function bindingNameEquals(node: ts.BindingName, name: string): boolean {
if (ts.isIdentifier(node)) {
return node.text === name;
}
return false;
}
export function getDeclaration<T extends ts.Declaration>(
program: ts.Program, fileName: string, name: string, assert: (value: any) => value is T): T {
const sf = program.getSourceFile(fileName);
if (!sf) {
throw new Error(`No such file: ${fileName}`);
}
const chosenDecl = walkForDeclaration(sf);
if (chosenDecl === null) {
throw new Error(`No such symbol: ${name} in ${fileName}`);
}
if (!assert(chosenDecl)) {
throw new Error(`Symbol ${name} from ${fileName} is a ${ts.SyntaxKind[chosenDecl.kind]}`);
}
return chosenDecl;
// We walk the AST tree looking for a declaration that matches
function walkForDeclaration(rootNode: ts.Node): ts.Declaration|null {
let chosenDecl: ts.Declaration|null = null;
rootNode.forEachChild(node => {
if (chosenDecl !== null) {
return;
}
if (ts.isVariableStatement(node)) {
node.declarationList.declarations.forEach(decl => {
if (bindingNameEquals(decl.name, name)) {
chosenDecl = decl;
}
});
} else if (ts.isClassDeclaration(node) || ts.isFunctionDeclaration(node)) {
if (node.name !== undefined && node.name.text === name) {
chosenDecl = node;
}
} else if (
ts.isImportDeclaration(node) && node.importClause !== undefined &&
node.importClause.name !== undefined && node.importClause.name.text === name) {
chosenDecl = node.importClause;
} else {
chosenDecl = walkForDeclaration(node);
}
});
return chosenDecl;
}
}

View File

@ -0,0 +1,8 @@
/**
* @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
*/
export {getDeclaration, makeProgram} from './src/utils';

View File

@ -0,0 +1,100 @@
/**
* @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 types="jasmine"/>
import * as ts from 'typescript';
import {AbsoluteFsPath, NgtscCompilerHost, dirname, getFileSystem, getSourceFileOrError} from '../../file_system';
export function makeProgram(
files: {name: AbsoluteFsPath, contents: string, isRoot?: boolean}[],
options?: ts.CompilerOptions, host?: ts.CompilerHost, checkForErrors: boolean = true):
{program: ts.Program, host: ts.CompilerHost, options: ts.CompilerOptions} {
const fs = getFileSystem();
files.forEach(file => {
fs.ensureDir(dirname(file.name));
fs.writeFile(file.name, file.contents);
});
const compilerOptions = {
noLib: true,
experimentalDecorators: true,
moduleResolution: ts.ModuleResolutionKind.NodeJs, ...options
};
const compilerHost = new NgtscCompilerHost(fs, compilerOptions);
const rootNames = files.filter(file => file.isRoot !== false)
.map(file => compilerHost.getCanonicalFileName(file.name));
const program = ts.createProgram(rootNames, compilerOptions, compilerHost);
if (checkForErrors) {
const diags = [...program.getSyntacticDiagnostics(), ...program.getSemanticDiagnostics()];
if (diags.length > 0) {
const errors = diags.map(diagnostic => {
let message = ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n');
if (diagnostic.file) {
const {line, character} =
diagnostic.file.getLineAndCharacterOfPosition(diagnostic.start !);
message = `${diagnostic.file.fileName} (${line + 1},${character + 1}): ${message}`;
}
return `Error: ${message}`;
});
throw new Error(`Typescript diagnostics failed! ${errors.join(', ')}`);
}
}
return {program, host: compilerHost, options: compilerOptions};
}
export function getDeclaration<T extends ts.Declaration>(
program: ts.Program, fileName: AbsoluteFsPath, name: string,
assert: (value: any) => value is T): T {
const sf = getSourceFileOrError(program, fileName);
const chosenDecl = walkForDeclaration(sf);
if (chosenDecl === null) {
throw new Error(`No such symbol: ${name} in ${fileName}`);
}
if (!assert(chosenDecl)) {
throw new Error(`Symbol ${name} from ${fileName} is a ${ts.SyntaxKind[chosenDecl.kind]}`);
}
return chosenDecl;
// We walk the AST tree looking for a declaration that matches
function walkForDeclaration(rootNode: ts.Node): ts.Declaration|null {
let chosenDecl: ts.Declaration|null = null;
rootNode.forEachChild(node => {
if (chosenDecl !== null) {
return;
}
if (ts.isVariableStatement(node)) {
node.declarationList.declarations.forEach(decl => {
if (bindingNameEquals(decl.name, name)) {
chosenDecl = decl;
}
});
} else if (ts.isClassDeclaration(node) || ts.isFunctionDeclaration(node)) {
if (node.name !== undefined && node.name.text === name) {
chosenDecl = node;
}
} else if (
ts.isImportDeclaration(node) && node.importClause !== undefined &&
node.importClause.name !== undefined && node.importClause.name.text === name) {
chosenDecl = node.importClause;
} else {
chosenDecl = walkForDeclaration(node);
}
});
return chosenDecl;
}
}
function bindingNameEquals(node: ts.BindingName, name: string): boolean {
if (ts.isIdentifier(node)) {
return node.text === name;
}
return false;
}