feat(ivy): introduce concrete types for paths in ngtsc (#28523)

This commit introduces a new ngtsc sub-library, 'path', which contains
branded string types for the different kind of paths that ngtsc manipulates.
Having static types for these paths will reduce the number of path-related
bugs (especially on Windows) and will eliminate unnecessary defensive
normalizing.

See the README.md file for more detail.

PR Close #28523
This commit is contained in:
Alex Rickabaugh 2019-02-01 10:07:34 -08:00 committed by Misko Hevery
parent 99d8582882
commit a529f53031
9 changed files with 375 additions and 0 deletions

View File

@ -0,0 +1,16 @@
package(default_visibility = ["//visibility:public"])
load("//tools:defaults.bzl", "ts_library")
ts_library(
name = "path",
srcs = glob([
"index.ts",
"src/*.ts",
]),
deps = [
"//packages:types",
"@ngdeps//@types/node",
"@ngdeps//typescript",
],
)

View File

@ -0,0 +1,45 @@
# About paths in ngtsc
Within the compiler, there are a number of different types of file system or URL "paths" which are manipulated as strings. While it's possible to declare the variables and fields which store these different kinds of paths using the 'string' type, this has significant drawbacks:
* When calling a function which accepts a path as an argument, it's not clear what kind of path should be passed.
* It can be expensive to check whether a path is properly formatted, and without types it's easy to fall into the habit of normalizing different kinds of paths repeatedly.
* There is no static check to detect if paths are improperly used in the wrong context (e.g. a relative path passed where an absolute path was required). This can cause subtle bugs.
* When running on Windows, some paths can use different conventions (e.g. forward vs back slashes). It's not always clear when a path needs to be checked for the correct convention.
To address these issues, ngtsc has specific static types for each kind of path in the system. These types are not mutually assignable, nor can they be directly assigned from `string`s (though they can be assigned _to_ `string`s). Conversion between `string`s and these specific path types happens through a narrow API which validates that all typed paths are valid.
# The different path kinds
All paths in the type system use POSIX format (`/` separators).
## `AbsoluteFsPath`
This path type represents an absolute path to a physical directory or file. For example, `/foo/bar.txt`.
## `PathSegment`
This path type represents a relative path to a directory or file. It only makes sense in the context of some directory (e.g. the working directory) or set of directories to search, and does not need to necessarily represent a relative path between two physical files.
## `LogicalProjectPath`
This path type represents a path to a file in TypeScript's logical file system.
TypeScript supports multiple root directories for a given project, which are effectively overlayed to obtain a file layout. For example, if a project has two root directories `foo` and `bar` with the layout:
```text
/foo
/foo/foo.ts
/bar
/bar/bar.ts
```
Then `foo.ts` could theoretically contain:
```typescript
import {Bar} from './bar';
```
This import of `./bar` is not a valid relative path from `foo.ts` to `bar.ts` on the physical filesystem, but is valid in the context of the project because the contents of the `foo` and `bar` directories are overlayed as far as TypeScript is concerned.
In this example, `/foo/foo.ts` has a `LogicalProjectPath` of `/foo.ts` and `/bar/bar.ts` has a `LogicalProjectPath` of `/bar.ts`, allowing the module specifier in the import (`./bar`) to be resolved via standard path operations.

View File

@ -0,0 +1,10 @@
/**
* @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 {LogicalFileSystem, LogicalProjectPath} from './src/logical';
export {AbsoluteFsPath, PathSegment} from './src/types';

View File

@ -0,0 +1,95 @@
/**
* @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="node" />
import * as path from 'path';
import * as ts from 'typescript';
import {AbsoluteFsPath, BrandedPath, PathSegment} from './types';
import {stripExtension} from './util';
/**
* A path that's relative to the logical root of a TypeScript project (one of the project's
* rootDirs).
*/
export type LogicalProjectPath = BrandedPath<'LogicalProjectPath'>;
export const LogicalProjectPath = {
/**
* Get the relative path between two `LogicalProjectPath`s.
*
* This will return a `PathSegment` which would be a valid module specifier to use in `from` when
* importing from `to`.
*/
relativePathBetween: function(from: LogicalProjectPath, to: LogicalProjectPath): PathSegment {
let relativePath = path.posix.relative(path.posix.dirname(from), to);
if (!relativePath.startsWith('../')) {
relativePath = ('./' + relativePath);
}
return relativePath as PathSegment;
},
};
/**
* A utility class which can translate absolute paths to source files into logical paths in
* TypeScript's logical file system, based on the root directories of the project.
*/
export class LogicalFileSystem {
/**
* The root directories of the project, sorted with the longest path first.
*/
private rootDirs: AbsoluteFsPath[];
/**
* A cache of file paths to project paths, because computation of these paths is slightly
* expensive.
*/
private cache: Map<AbsoluteFsPath, LogicalProjectPath|null> = new Map();
constructor(rootDirs: AbsoluteFsPath[]) {
// Make a copy and sort it by length in reverse order (longest first). This speeds up lookups,
// since there's no need to keep going through the array once a match is found.
this.rootDirs = rootDirs.concat([]).sort((a, b) => b.length - a.length);
}
/**
* Get the logical path in the project of a `ts.SourceFile`.
*
* This method is provided as a convenient alternative to calling
* `logicalPathOfFile(AbsoluteFsPath.fromSourceFile(sf))`.
*/
logicalPathOfSf(sf: ts.SourceFile): LogicalProjectPath|null {
return this.logicalPathOfFile(sf.fileName as AbsoluteFsPath);
}
/**
* Get the logical path in the project of a source file.
*
* @returns A `LogicalProjectPath` to the source file, or `null` if the source file is not in any
* of the TS project's root directories.
*/
logicalPathOfFile(physicalFile: AbsoluteFsPath): LogicalProjectPath|null {
if (!this.cache.has(physicalFile)) {
let logicalFile: LogicalProjectPath|null = null;
for (const rootDir of this.rootDirs) {
if (physicalFile.startsWith(rootDir)) {
logicalFile = stripExtension(physicalFile.substr(rootDir.length)) as LogicalProjectPath;
// The logical project does not include any special "node_modules" nested directories.
if (logicalFile.indexOf('/node_modules/') !== -1) {
logicalFile = null;
} else {
break;
}
}
}
this.cache.set(physicalFile, logicalFile);
}
return this.cache.get(physicalFile) !;
}
}

View File

@ -0,0 +1,86 @@
/**
* @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 {normalizeSeparators} from './util';
/**
* A `string` representing a specific type of path, with a particular brand `B`.
*
* A `string` is not assignable to a `BrandedPath`, but a `BrandedPath` is assignable to a `string`.
* Two `BrandedPath`s with different brands are not mutually assignable.
*/
export type BrandedPath<B extends string> = string & {
_brand: B;
};
/**
* A fully qualified path in the file system, in POSIX form.
*/
export type AbsoluteFsPath = BrandedPath<'AbsoluteFsPath'>;
/**
* A path that's relative to another (unspecified) root.
*
* This does not necessarily have to refer to a physical file.
*/
export type PathSegment = BrandedPath<'PathSegment'>;
/**
* Contains utility functions for creating and manipulating `AbsoluteFsPath`s.
*/
export const AbsoluteFsPath = {
/**
* Convert the path `str` to an `AbsoluteFsPath`, throwing an error if it's not an absolute path.
*/
from: function(str: string): AbsoluteFsPath {
const normalized = normalizeSeparators(str);
if (!normalized.startsWith('/')) {
throw new Error(`Internal Error: AbsoluteFsPath.from(${str}): path is not absolute`);
}
return normalized as AbsoluteFsPath;
},
/**
* Assume that the path `str` is an `AbsoluteFsPath` in the correct format already.
*/
fromUnchecked: function(str: string): AbsoluteFsPath { return str as AbsoluteFsPath;},
/**
* Extract an `AbsoluteFsPath` from a `ts.SourceFile`.
*
* This is cheaper than calling `AbsoluteFsPath.from(sf.fileName)`, as source files already have
* their file path in absolute POSIX format.
*/
fromSourceFile: function(sf: ts.SourceFile): AbsoluteFsPath {
// ts.SourceFile paths are always absolute.
return sf.fileName as AbsoluteFsPath;
},
};
/**
* Contains utility functions for creating and manipulating `PathSegment`s.
*/
export const PathSegment = {
/**
* Convert the path `str` to a `PathSegment`, throwing an error if it's not a relative path.
*/
fromFsPath: function(str: string): PathSegment {
const normalized = normalizeSeparators(str);
if (normalized.startsWith('/')) {
throw new Error(`Internal Error: PathSegment.from(${str}): path is not relative`);
}
return normalized as PathSegment;
},
/**
* Convert the path `str` to a `PathSegment`, while assuming that `str` is already normalized.
*/
fromUnchecked: function(str: string): PathSegment { return str as PathSegment;},
};

View File

@ -0,0 +1,26 @@
/**
* @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
*/
// TODO(alxhub): Unify this file with `util/src/path`.
const TS_DTS_JS_EXTENSION = /(?:\.d)?\.ts$|\.js$/;
/**
* Convert Windows-style paths to POSIX paths.
*/
export function normalizeSeparators(path: string): string {
// TODO: normalize path only for OS that need it.
return path.replace(/\\/g, '/');
}
/**
* Remove a .ts, .d.ts, or .js extension from a file name.
*/
export function stripExtension(path: string): string {
return path.replace(TS_DTS_JS_EXTENSION, '');
}

View File

@ -0,0 +1,24 @@
package(default_visibility = ["//visibility:public"])
load("//tools:defaults.bzl", "jasmine_node_test", "ts_library")
ts_library(
name = "test_lib",
testonly = True,
srcs = glob([
"**/*.ts",
]),
deps = [
"//packages:types",
"//packages/compiler-cli/src/ngtsc/path",
],
)
jasmine_node_test(
name = "test",
bootstrap = ["angular/tools/testing/init_node_no_angular_spec.js"],
deps = [
":test_lib",
"//tools/testing:node_no_angular",
],
)

View File

@ -0,0 +1,53 @@
/**
* @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 {LogicalFileSystem, LogicalProjectPath} from '../src/logical';
import {AbsoluteFsPath} from '../src/types';
describe('logical paths', () => {
describe('LogicalFileSystem', () => {
it('should determine logical paths in a single root file system', () => {
const fs = new LogicalFileSystem([abs('/test')]);
expect(fs.logicalPathOfFile(abs('/test/foo/foo.ts')))
.toEqual('/foo/foo' as LogicalProjectPath);
expect(fs.logicalPathOfFile(abs('/test/bar/bar.ts')))
.toEqual('/bar/bar' as LogicalProjectPath);
expect(fs.logicalPathOfFile(abs('/not-test/bar.ts'))).toBeNull();
});
it('should determine logical paths in a multi-root file system', () => {
const fs = new LogicalFileSystem([abs('/test/foo'), abs('/test/bar')]);
expect(fs.logicalPathOfFile(abs('/test/foo/foo.ts'))).toEqual('/foo' as LogicalProjectPath);
expect(fs.logicalPathOfFile(abs('/test/bar/bar.ts'))).toEqual('/bar' as LogicalProjectPath);
});
it('should continue to work when one root is a child of another', () => {
const fs = new LogicalFileSystem([abs('/test'), abs('/test/dist')]);
expect(fs.logicalPathOfFile(abs('/test/foo.ts'))).toEqual('/foo' as LogicalProjectPath);
expect(fs.logicalPathOfFile(abs('/test/dist/foo.ts'))).toEqual('/foo' as LogicalProjectPath);
});
});
describe('utilities', () => {
it('should give a relative path between two adjacent logical files', () => {
const res = LogicalProjectPath.relativePathBetween(
'/foo' as LogicalProjectPath, '/bar' as LogicalProjectPath);
expect(res).toEqual('./bar');
});
it('should give a relative path between two non-adjacent logical files', () => {
const res = LogicalProjectPath.relativePathBetween(
'/foo/index' as LogicalProjectPath, '/bar/index' as LogicalProjectPath);
expect(res).toEqual('../bar/index');
});
});
});
function abs(file: string): AbsoluteFsPath {
return AbsoluteFsPath.from(file);
}

View File

@ -0,0 +1,20 @@
/**
* @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 {AbsoluteFsPath} from '../src/types';
describe('path types', () => {
describe('AbsoluteFsPath', () => {
it('should not throw when creating one from a non-absolute path',
() => { expect(AbsoluteFsPath.from('/test.txt')).toEqual('/test.txt'); });
it('should throw when creating one from a non-absolute path',
() => { expect(() => AbsoluteFsPath.from('test.txt')).toThrow(); });
it('should convert Windows path separators to POSIX separators',
() => { expect(AbsoluteFsPath.from('\\foo\\test.txt')).toEqual('/foo/test.txt'); });
});
});