Skip to content

Commit a5e183b

Browse files
authored
Merge pull request #2619 from asger-semmle/ts-monorepo-deps
Approved by erik-krogh, max-schaefer
2 parents 53763c7 + db2212e commit a5e183b

File tree

9 files changed

+636
-89
lines changed

9 files changed

+636
-89
lines changed

javascript/extractor/lib/typescript/src/common.ts

Lines changed: 106 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,53 @@
11
import * as ts from "./typescript";
22
import { TypeTable } from "./type_table";
3+
import * as pathlib from "path";
4+
import { VirtualSourceRoot } from "./virtual_source_root";
5+
6+
/**
7+
* Extracts the package name from the prefix of an import string.
8+
*/
9+
const packageNameRex = /^(?:@[\w.-]+[/\\]+)?\w[\w.-]*(?=[/\\]|$)/;
10+
const extensions = ['.ts', '.tsx', '.d.ts', '.js', '.jsx'];
11+
12+
function getPackageName(importString: string) {
13+
let packageNameMatch = packageNameRex.exec(importString);
14+
if (packageNameMatch == null) return null;
15+
let packageName = packageNameMatch[0];
16+
if (packageName.charAt(0) === '@') {
17+
packageName = packageName.replace(/[/\\]+/g, '/'); // Normalize slash after the scope.
18+
}
19+
return packageName;
20+
}
321

422
export class Project {
523
public program: ts.Program = null;
24+
private host: ts.CompilerHost;
25+
private resolutionCache: ts.ModuleResolutionCache;
626

7-
constructor(public tsConfig: string, public config: ts.ParsedCommandLine, public typeTable: TypeTable) {}
27+
constructor(
28+
public tsConfig: string,
29+
public config: ts.ParsedCommandLine,
30+
public typeTable: TypeTable,
31+
public packageEntryPoints: Map<string, string>,
32+
public virtualSourceRoot: VirtualSourceRoot) {
33+
34+
this.resolveModuleNames = this.resolveModuleNames.bind(this);
35+
36+
this.resolutionCache = ts.createModuleResolutionCache(pathlib.dirname(tsConfig), ts.sys.realpath, config.options);
37+
let host = ts.createCompilerHost(config.options, true);
38+
host.resolveModuleNames = this.resolveModuleNames;
39+
host.trace = undefined; // Disable tracing which would otherwise go to standard out
40+
this.host = host;
41+
}
842

943
public unload(): void {
1044
this.typeTable.releaseProgram();
1145
this.program = null;
1246
}
1347

1448
public load(): void {
15-
let host = ts.createCompilerHost(this.config.options, true);
16-
host.trace = undefined; // Disable tracing which would otherwise go to standard out
17-
this.program = ts.createProgram(this.config.fileNames, this.config.options, host);
49+
const { config, host } = this;
50+
this.program = ts.createProgram(config.fileNames, config.options, host);
1851
this.typeTable.setProgram(this.program);
1952
}
2053

@@ -27,4 +60,73 @@ export class Project {
2760
this.unload();
2861
this.load();
2962
}
63+
64+
/**
65+
* Override for module resolution in the TypeScript compiler host.
66+
*/
67+
private resolveModuleNames(
68+
moduleNames: string[],
69+
containingFile: string,
70+
reusedNames: string[],
71+
redirectedReference: ts.ResolvedProjectReference,
72+
options: ts.CompilerOptions) {
73+
74+
const { host, resolutionCache } = this;
75+
return moduleNames.map((moduleName) => {
76+
let redirected = this.redirectModuleName(moduleName, containingFile, options);
77+
if (redirected != null) return redirected;
78+
return ts.resolveModuleName(moduleName, containingFile, options, host, resolutionCache).resolvedModule;
79+
});
80+
}
81+
82+
/**
83+
* Returns the path that the given import string should be redirected to, or null if it should
84+
* fall back to standard module resolution.
85+
*/
86+
private redirectModuleName(moduleName: string, containingFile: string, options: ts.CompilerOptions): ts.ResolvedModule {
87+
// Get a package name from the leading part of the module name, e.g. '@scope/foo' from '@scope/foo/bar'.
88+
let packageName = getPackageName(moduleName);
89+
if (packageName == null) return null;
90+
91+
// Get the overridden location of this package, if one exists.
92+
let packageEntryPoint = this.packageEntryPoints.get(packageName);
93+
if (packageEntryPoint == null) {
94+
// The package is not overridden, but we have established that it begins with a valid package name.
95+
// Do a lookup in the virtual source root (where dependencies are installed) by changing the 'containing file'.
96+
let virtualContainingFile = this.virtualSourceRoot.toVirtualPath(containingFile);
97+
if (virtualContainingFile != null) {
98+
return ts.resolveModuleName(moduleName, virtualContainingFile, options, this.host, this.resolutionCache).resolvedModule;
99+
}
100+
return null;
101+
}
102+
103+
// If the requested module name is exactly the overridden package name,
104+
// return the entry point file (it is not necessarily called `index.ts`).
105+
if (moduleName === packageName) {
106+
return { resolvedFileName: packageEntryPoint, isExternalLibraryImport: true };
107+
}
108+
109+
// Get the suffix after the package name, e.g. the '/bar' in '@scope/foo/bar'.
110+
let suffix = moduleName.substring(packageName.length);
111+
112+
// Resolve the suffix relative to the package directory.
113+
let packageDir = pathlib.dirname(packageEntryPoint);
114+
let joinedPath = pathlib.join(packageDir, suffix);
115+
116+
// Add implicit '/index'
117+
if (ts.sys.directoryExists(joinedPath)) {
118+
joinedPath = pathlib.join(joinedPath, 'index');
119+
}
120+
121+
// Try each recognized extension. We must not return a file whose extension is not
122+
// recognized by TypeScript.
123+
for (let ext of extensions) {
124+
let candidate = joinedPath.endsWith(ext) ? joinedPath : (joinedPath + ext);
125+
if (ts.sys.fileExists(candidate)) {
126+
return { resolvedFileName: candidate, isExternalLibraryImport: true };
127+
}
128+
}
129+
130+
return null;
131+
}
30132
}

javascript/extractor/lib/typescript/src/main.ts

Lines changed: 78 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ import * as ast_extractor from "./ast_extractor";
3939

4040
import { Project } from "./common";
4141
import { TypeTable } from "./type_table";
42+
import { VirtualSourceRoot } from "./virtual_source_root";
4243

4344
interface ParseCommand {
4445
command: "parse";
@@ -47,6 +48,9 @@ interface ParseCommand {
4748
interface OpenProjectCommand {
4849
command: "open-project";
4950
tsConfig: string;
51+
virtualSourceRoot: string | null;
52+
packageEntryPoints: [string, string][];
53+
packageJsonFiles: [string, string][];
5054
}
5155
interface CloseProjectCommand {
5256
command: "close-project";
@@ -242,26 +246,93 @@ function parseSingleFile(filename: string): {ast: ts.SourceFile, code: string} {
242246
return {ast, code};
243247
}
244248

249+
/**
250+
* Matches a path segment referencing a package in a node_modules folder, and extracts
251+
* two capture groups: the package name, and the relative path in the package.
252+
*
253+
* For example `lib/node_modules/@foo/bar/src/index.js` extracts the capture groups [`@foo/bar`, `src/index.js`].
254+
*/
255+
const nodeModulesRex = /[/\\]node_modules[/\\]((?:@[\w.-]+[/\\])?\w[\w.-]*)[/\\](.*)/;
256+
245257
function handleOpenProjectCommand(command: OpenProjectCommand) {
246258
Error.stackTraceLimit = Infinity;
247259
let tsConfigFilename = String(command.tsConfig);
248260
let tsConfig = ts.readConfigFile(tsConfigFilename, ts.sys.readFile);
249261
let basePath = pathlib.dirname(tsConfigFilename);
250262

263+
let packageEntryPoints = new Map(command.packageEntryPoints);
264+
let packageJsonFiles = new Map(command.packageJsonFiles);
265+
let virtualSourceRoot = new VirtualSourceRoot(process.cwd(), command.virtualSourceRoot);
266+
267+
/**
268+
* Rewrites path segments of form `node_modules/PACK/suffix` to be relative to
269+
* the location of package PACK in the source tree, if it exists.
270+
*/
271+
function redirectNodeModulesPath(path: string) {
272+
let nodeModulesMatch = nodeModulesRex.exec(path);
273+
if (nodeModulesMatch == null) return null;
274+
let packageName = nodeModulesMatch[1];
275+
let packageJsonFile = packageJsonFiles.get(packageName);
276+
if (packageJsonFile == null) return null;
277+
let packageDir = pathlib.dirname(packageJsonFile);
278+
let suffix = nodeModulesMatch[2];
279+
let finalPath = pathlib.join(packageDir, suffix);
280+
if (!ts.sys.fileExists(finalPath)) return null;
281+
return finalPath;
282+
}
283+
284+
/**
285+
* Create the host passed to the tsconfig.json parser.
286+
*
287+
* We override its file system access in case there is an "extends"
288+
* clause pointing into "./node_modules", which must be redirected to
289+
* the location of an installed package or a checked-in package.
290+
*/
251291
let parseConfigHost: ts.ParseConfigHost = {
252292
useCaseSensitiveFileNames: true,
253-
readDirectory: ts.sys.readDirectory,
254-
fileExists: (path: string) => fs.existsSync(path),
255-
readFile: ts.sys.readFile,
293+
readDirectory: ts.sys.readDirectory, // No need to override traversal/glob matching
294+
fileExists: (path: string) => {
295+
return ts.sys.fileExists(path)
296+
|| virtualSourceRoot.toVirtualPathIfFileExists(path) != null
297+
|| redirectNodeModulesPath(path) != null;
298+
},
299+
readFile: (path: string) => {
300+
if (!ts.sys.fileExists(path)) {
301+
let virtualPath = virtualSourceRoot.toVirtualPathIfFileExists(path);
302+
if (virtualPath != null) return ts.sys.readFile(virtualPath);
303+
virtualPath = redirectNodeModulesPath(path);
304+
if (virtualPath != null) return ts.sys.readFile(virtualPath);
305+
}
306+
return ts.sys.readFile(path);
307+
}
256308
};
257309
let config = ts.parseJsonConfigFileContent(tsConfig.config, parseConfigHost, basePath);
258-
let project = new Project(tsConfigFilename, config, state.typeTable);
310+
let project = new Project(tsConfigFilename, config, state.typeTable, packageEntryPoints, virtualSourceRoot);
259311
project.load();
260312

261313
state.project = project;
262314
let program = project.program;
263315
let typeChecker = program.getTypeChecker();
264316

317+
let diagnostics = program.getSemanticDiagnostics()
318+
.filter(d => d.category === ts.DiagnosticCategory.Error);
319+
if (diagnostics.length > 0) {
320+
console.warn('TypeScript: reported ' + diagnostics.length + ' semantic errors.');
321+
}
322+
for (let diagnostic of diagnostics) {
323+
let text = diagnostic.messageText;
324+
if (text && typeof text !== 'string') {
325+
text = text.messageText;
326+
}
327+
let locationStr = '';
328+
let { file } = diagnostic;
329+
if (file != null) {
330+
let { line, character } = file.getLineAndCharacterOfPosition(diagnostic.start);
331+
locationStr = `${file.fileName}:${line}:${character}`;
332+
}
333+
console.warn(`TypeScript: ${locationStr} ${text}`);
334+
}
335+
265336
// Associate external module names with the corresponding file symbols.
266337
// We need these mappings to identify which module a given external type comes from.
267338
// The TypeScript API lets us resolve a module name to a source file, but there is no
@@ -512,6 +583,9 @@ if (process.argv.length > 2) {
512583
handleOpenProjectCommand({
513584
command: "open-project",
514585
tsConfig: argument,
586+
packageEntryPoints: [],
587+
packageJsonFiles: [],
588+
virtualSourceRoot: null,
515589
});
516590
for (let sf of state.project.program.getSourceFiles()) {
517591
if (pathlib.basename(sf.fileName) === "lib.d.ts") continue;
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import * as pathlib from "path";
2+
import * as ts from "./typescript";
3+
4+
/**
5+
* Mapping from the real source root to the virtual source root,
6+
* a directory whose folder structure mirrors the real source root, but with `node_modules` installed.
7+
*/
8+
export class VirtualSourceRoot {
9+
constructor(
10+
private sourceRoot: string,
11+
12+
/**
13+
* Directory whose folder structure mirrors the real source root, but with `node_modules` installed,
14+
* or undefined if no virtual source root exists.
15+
*/
16+
private virtualSourceRoot: string,
17+
) {}
18+
19+
/**
20+
* Maps a path under the real source root to the corresponding path in the virtual source root.
21+
*/
22+
public toVirtualPath(path: string) {
23+
if (!this.virtualSourceRoot) return null;
24+
let relative = pathlib.relative(this.sourceRoot, path);
25+
if (relative.startsWith('..') || pathlib.isAbsolute(relative)) return null;
26+
return pathlib.join(this.virtualSourceRoot, relative);
27+
}
28+
29+
/**
30+
* Maps a path under the real source root to the corresponding path in the virtual source root.
31+
*/
32+
public toVirtualPathIfFileExists(path: string) {
33+
let virtualPath = this.toVirtualPath(path);
34+
if (virtualPath != null && ts.sys.fileExists(virtualPath)) {
35+
return virtualPath;
36+
}
37+
return null;
38+
}
39+
}

0 commit comments

Comments
 (0)