-
Notifications
You must be signed in to change notification settings - Fork 879
/
modules.ts
339 lines (325 loc) · 11.4 KB
/
modules.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
/**
* @license
* Copyright 2022 Google LLC
* SPDX-License-Identifier: BSD-3-Clause
*/
/**
* @fileoverview
*
* Utilities for analyzing ES modules
*/
import ts from 'typescript';
import {
Module,
AnalyzerInterface,
PackageInfo,
Declaration,
DeclarationInfo,
ExportMap,
DeclarationMap,
ModuleInfo,
LocalNameOrReference,
} from '../model.js';
import {getClassDeclarationInfo} from './classes.js';
import {
getExportAssignmentVariableDeclarationInfo,
getVariableDeclarationInfo,
getEnumDeclarationInfo,
} from './variables.js';
import {AbsolutePath, PackagePath, absoluteToPackage} from '../paths.js';
import {getPackageInfo} from './packages.js';
import {DiagnosticsError} from '../errors.js';
import {
getExportReferences,
getImportReferenceForSpecifierExpression,
getSpecifierString,
} from '../references.js';
import {parseModuleJSDocInfo} from './jsdoc.js';
import {getFunctionDeclarationInfo} from './functions.js';
/**
* Returns the sourcePath, jsPath, and package.json contents of the containing
* package for the given module path.
*
* This is a minimal subset of module information needed for constructing a
* Reference object for a module.
*/
export const getModuleInfo = (
modulePath: AbsolutePath,
analyzer: AnalyzerInterface,
packageInfo: PackageInfo = getPackageInfo(modulePath, analyzer)
): ModuleInfo => {
// The packageRoot for this module is needed for translating the source file
// path to a package relative path, and the packageName is needed for
// generating references to any symbols in this module.
const {rootDir, packageJson} = packageInfo;
const absJsPath = getJSPathFromSourcePath(
modulePath as AbsolutePath,
analyzer
);
const jsPath =
absJsPath !== undefined
? absoluteToPackage(absJsPath, rootDir)
: ('not/implemented' as PackagePath);
const sourcePath = absoluteToPackage(
analyzer.path.normalize(modulePath) as AbsolutePath,
rootDir
);
return {
jsPath,
sourcePath,
packageJson,
};
};
/**
* Returns an analyzer `Module` model for the given module path.
*/
export const getModule = (
modulePath: AbsolutePath,
analyzer: AnalyzerInterface,
packageInfo: PackageInfo = getPackageInfo(modulePath, analyzer)
) => {
// Return cached module if we've parsed this sourceFile already and its
// dependencies haven't changed
const cachedModule = getAndValidateModuleFromCache(modulePath, analyzer);
if (cachedModule !== undefined) {
return cachedModule;
}
const sourceFile = analyzer.program.getSourceFile(
analyzer.path.normalize(modulePath)
);
if (sourceFile === undefined) {
throw new Error(`Program did not contain a source file for ${modulePath}`);
}
const dependencies = new Set<AbsolutePath>();
const declarationMap: DeclarationMap = new Map<string, () => Declaration>();
const exportMap: ExportMap = new Map<string, LocalNameOrReference>();
const reexports: ts.Expression[] = [];
const addDeclaration = (info: DeclarationInfo) => {
const {name, factory, isExport} = info;
if (declarationMap.has(name)) {
throw new Error(
`Internal error: duplicate declaration '${name}' in ${sourceFile.fileName}`
);
}
declarationMap.set(name, factory);
if (isExport) {
exportMap.set(name, name);
}
};
// Find and add models for declarations in the module
// TODO(kschaaf): Add Function and MixinDeclarations
for (const statement of sourceFile.statements) {
if (ts.isClassDeclaration(statement)) {
addDeclaration(getClassDeclarationInfo(statement, analyzer));
} else if (ts.isFunctionDeclaration(statement)) {
addDeclaration(getFunctionDeclarationInfo(statement, analyzer));
} else if (ts.isVariableStatement(statement)) {
getVariableDeclarationInfo(statement, analyzer).forEach(addDeclaration);
} else if (ts.isEnumDeclaration(statement)) {
addDeclaration(getEnumDeclarationInfo(statement, analyzer));
} else if (ts.isExportDeclaration(statement) && !statement.isTypeOnly) {
const {exportClause, moduleSpecifier} = statement;
if (exportClause === undefined) {
// Case: `export * from 'foo';` The `exportClause` is undefined for
// wildcard exports. Add the re-exported module specifier to the
// `reexports` list, and we will add references to the exportMap lazily
// the first time exports are queried
if (moduleSpecifier === undefined) {
throw new DiagnosticsError(
statement,
`Expected a wildcard export to have a module specifier.`
);
}
reexports.push(moduleSpecifier);
} else {
// Case: `export {...}` and `export {...} from '...'`
// Add all of the exports in this export statement to the exportMap
getExportReferences(exportClause, moduleSpecifier, analyzer).forEach(
({exportName, decNameOrRef}) =>
exportMap.set(exportName, decNameOrRef)
);
}
} else if (ts.isExportAssignment(statement)) {
addDeclaration(
getExportAssignmentVariableDeclarationInfo(statement, analyzer)
);
} else if (ts.isImportDeclaration(statement)) {
dependencies.add(
getPathForModuleSpecifierExpression(statement.moduleSpecifier, analyzer)
);
}
}
// Construct module and save in cache
const module = new Module({
...getModuleInfo(modulePath, analyzer, packageInfo),
sourceFile,
declarationMap,
dependencies,
exportMap,
finalizeExports: () => finalizeExports(reexports, exportMap, analyzer),
...parseModuleJSDocInfo(sourceFile),
});
analyzer.moduleCache.set(
analyzer.path.normalize(sourceFile.fileName) as AbsolutePath,
module
);
return module;
};
/**
* For any re-exported modules (i.e. `export * from 'foo'`), add all of the
* exported names of the reexported module to the given exportMap, with
* References into that module.
*/
const finalizeExports = (
reexportSpecifiers: ts.Expression[],
exportMap: ExportMap,
analyzer: AnalyzerInterface
) => {
for (const moduleSpecifier of reexportSpecifiers) {
const module = getModule(
getPathForModuleSpecifierExpression(moduleSpecifier, analyzer),
analyzer
);
for (const name of module.exportNames) {
exportMap.set(
name,
getImportReferenceForSpecifierExpression(
moduleSpecifier,
name,
analyzer
)
);
}
}
};
/**
* Returns a cached Module model for the given module path if it and all of its
* dependencies' models are still valid since the model was cached. If the
* cached module is out-of-date and needs to be re-created, this method returns
* undefined.
*/
const getAndValidateModuleFromCache = (
modulePath: AbsolutePath,
analyzer: AnalyzerInterface
): Module | undefined => {
const module = analyzer.moduleCache.get(modulePath);
// A cached module is only valid if the source file that was used has not
// changed in the current program, and if all of its dependencies are still
// valid
if (module !== undefined) {
if (
module.sourceFile === analyzer.program.getSourceFile(modulePath) &&
depsAreValid(module, analyzer)
) {
return module;
}
analyzer.moduleCache.delete(modulePath);
}
return undefined;
};
/**
* Returns true if all dependencies of the module are still valid.
*/
const depsAreValid = (module: Module, analyzer: AnalyzerInterface) =>
Array.from(module.dependencies).every((path) => depIsValid(path, analyzer));
/**
* Returns true if the given dependency is valid, meaning that if it has a
* cached model, the model is still valid. Dependencies that don't yet have a
* cached model are considered valid.
*/
const depIsValid = (modulePath: AbsolutePath, analyzer: AnalyzerInterface) => {
if (analyzer.moduleCache.has(modulePath)) {
// If a dep has a model, it is valid only if its deps are valid
return Boolean(getAndValidateModuleFromCache(modulePath, analyzer));
} else {
// Deps that don't have a cached model are considered valid
return true;
}
};
/**
* For a given source file, return its associated JS file.
*
* For a JS source file, these will be the same thing. For a TS file, we use the
* TS API to determine where the associated JS will be output based on tsconfig
* settings.
*/
const getJSPathFromSourcePath = (
sourcePath: AbsolutePath,
analyzer: AnalyzerInterface
) => {
sourcePath = analyzer.path.normalize(sourcePath) as AbsolutePath;
// If the source file was already JS, just return that
if (sourcePath.endsWith('js')) {
return sourcePath;
}
// TODO(kschaaf): If the source file was a declaration file, this means we're
// likely getting information about an externally imported package that had
// types. In this case, we'll need to update our logic to resolve the import
// specifier to the JS path (in addition to the source file path that we do
// today). Unfortunately, TS's specifier resolver always prefers a declaration
// file, and due to type roots and other tsconfig fancies, it's not
// straightforward to go from a declaration file to a source file. In order to
// properly implement this we'll probably need to bring our own node module
// resolver ala https://www.npmjs.com/package/resolve. That change should be
// done along with the custom-elements.json manifest work.
if (sourcePath.endsWith('.d.ts')) {
return undefined;
}
// Use the TS API to determine where the associated JS will be output based
// on tsconfig settings.
const outputPath = ts
.getOutputFileNames(analyzer.commandLine, sourcePath, false)
.filter((f) => f.endsWith('.js'))[0];
// TODO(kschaaf): this could happen if someone imported only a .d.ts file;
// we might need to handle this differently
if (outputPath === undefined) {
throw new Error(`Could not determine output filename for '${sourcePath}'`);
}
// The filename appears to come out of the ts API with
// unix separators; since sourcePath uses OS separators, normalize this so
// that all our model paths are OS-native
return analyzer.path.normalize(outputPath) as AbsolutePath;
};
/**
* Resolves a module specifier expression node to an absolute path on disk.
*/
export const getPathForModuleSpecifierExpression = (
specifierExpression: ts.Expression,
analyzer: AnalyzerInterface
): AbsolutePath => {
const specifier = getSpecifierString(specifierExpression);
return getPathForModuleSpecifier(specifier, specifierExpression, analyzer);
};
/**
* Resolve a module specifier to an absolute path on disk.
*/
export const getPathForModuleSpecifier = (
specifier: string,
location: ts.Node,
analyzer: AnalyzerInterface
): AbsolutePath => {
const resolvedPath = ts.resolveModuleName(
specifier,
location.getSourceFile().fileName,
analyzer.commandLine.options,
analyzer.fs
).resolvedModule?.resolvedFileName;
if (resolvedPath === undefined) {
throw new DiagnosticsError(
location,
`Could not resolve specifier ${specifier} to filesystem path.`
);
}
return analyzer.path.normalize(resolvedPath) as AbsolutePath;
};
/**
* Returns the declaration for the named export of the given module path;
* note that if the given module re-exported a declaration from another
* module, references are followed to the concrete declaration, which is
* returned.
*/
export const getResolvedExportFromSourcePath = (
modulePath: AbsolutePath,
name: string,
analyzer: AnalyzerInterface
) => getModule(modulePath, analyzer)?.getResolvedExport(name);