|
11 | 11 | */ |
12 | 12 |
|
13 | 13 | import ts from 'typescript'; |
14 | | -import {Module, AnalyzerInterface, PackageInfo} from '../model.js'; |
| 14 | +import {Module, AnalyzerInterface, PackageInfo, Declaration} from '../model.js'; |
15 | 15 | import { |
16 | 16 | isLitElement, |
17 | 17 | getLitElementDeclaration, |
18 | 18 | } from '../lit-element/lit-element.js'; |
19 | | -import * as path from 'path'; |
20 | 19 | import {getClassDeclaration} from './classes.js'; |
21 | 20 | import {getVariableDeclarations} from './variables.js'; |
22 | 21 | import {AbsolutePath, absoluteToPackage} from '../paths.js'; |
23 | 22 | import {getPackageInfo} from './packages.js'; |
| 23 | +import {DiagnosticsError} from '../errors.js'; |
24 | 24 |
|
25 | 25 | /** |
26 | | - * Returns an analyzer `Module` model for the given ts.SourceFile. |
| 26 | + * Returns an analyzer `Module` model for the given module path. |
27 | 27 | */ |
28 | 28 | export const getModule = ( |
29 | | - sourceFile: ts.SourceFile, |
| 29 | + modulePath: AbsolutePath, |
30 | 30 | analyzer: AnalyzerInterface, |
31 | | - packageInfo: PackageInfo = getPackageInfo( |
32 | | - sourceFile.fileName as AbsolutePath, |
33 | | - analyzer |
34 | | - ) |
| 31 | + packageInfo: PackageInfo = getPackageInfo(modulePath, analyzer) |
35 | 32 | ) => { |
36 | | - // Find and load the package.json associated with this module; this both gives |
37 | | - // us the packageRoot for this module (needed for translating the source file |
38 | | - // path to a package relative path), as well as the packageName (needed for |
39 | | - // generating references to any symbols in this module). This will need |
40 | | - // caching/invalidation. |
| 33 | + // Return cached module if we've parsed this sourceFile already and its |
| 34 | + // dependencies haven't changed |
| 35 | + const cachedModule = getAndValidateModuleFromCache(modulePath, analyzer); |
| 36 | + if (cachedModule !== undefined) { |
| 37 | + return cachedModule; |
| 38 | + } |
| 39 | + const sourceFile = analyzer.program.getSourceFile( |
| 40 | + analyzer.path.normalize(modulePath) |
| 41 | + ); |
| 42 | + if (sourceFile === undefined) { |
| 43 | + throw new Error(`Program did not contain a source file for ${modulePath}`); |
| 44 | + } |
| 45 | + // The packageRoot for this module is needed for translating the source file |
| 46 | + // path to a package relative path, and the packageName is needed for |
| 47 | + // generating references to any symbols in this module. |
41 | 48 | const {rootDir, packageJson} = packageInfo; |
42 | 49 | const sourcePath = absoluteToPackage( |
43 | | - analyzer.path.normalize(sourceFile.fileName) as AbsolutePath, |
| 50 | + analyzer.path.normalize(modulePath) as AbsolutePath, |
44 | 51 | rootDir |
45 | 52 | ); |
46 | | - const fullSourcePath = path.join(rootDir, sourcePath); |
47 | | - const jsPath = fullSourcePath.endsWith('.js') |
48 | | - ? fullSourcePath |
49 | | - : ts |
50 | | - .getOutputFileNames(analyzer.commandLine, fullSourcePath, false) |
51 | | - .filter((f) => f.endsWith('.js'))[0]; |
52 | | - // TODO(kschaaf): this could happen if someone imported only a .d.ts file; |
53 | | - // we might need to handle this differently |
54 | | - if (jsPath === undefined) { |
55 | | - throw new Error(`Could not determine output filename for '${sourcePath}'`); |
56 | | - } |
57 | | - |
58 | | - const module = new Module({ |
59 | | - sourcePath, |
60 | | - // The jsPath appears to come out of the ts API with unix |
61 | | - // separators; since sourcePath uses OS separators, normalize |
62 | | - // this so that all our model paths are OS-native |
63 | | - jsPath: absoluteToPackage( |
64 | | - analyzer.path.normalize(jsPath) as AbsolutePath, |
65 | | - rootDir |
66 | | - ), |
67 | | - sourceFile, |
68 | | - packageJson, |
69 | | - }); |
| 53 | + const jsPath = absoluteToPackage( |
| 54 | + getJSPathFromSourcePath(modulePath as AbsolutePath, analyzer), |
| 55 | + rootDir |
| 56 | + ); |
| 57 | + const dependencies = new Set<AbsolutePath>(); |
| 58 | + const declarations: Declaration[] = []; |
70 | 59 |
|
| 60 | + // Find and add models for declarations in the module |
| 61 | + // TODO(kschaaf): Add Variable, Function, and MixinDeclarations |
71 | 62 | for (const statement of sourceFile.statements) { |
72 | 63 | if (ts.isClassDeclaration(statement)) { |
73 | | - module.declarations.push( |
| 64 | + declarations.push( |
74 | 65 | isLitElement(statement, analyzer) |
75 | 66 | ? getLitElementDeclaration(statement, analyzer) |
76 | 67 | : getClassDeclaration(statement, analyzer) |
77 | 68 | ); |
78 | 69 | } else if (ts.isVariableStatement(statement)) { |
79 | | - module.declarations.push( |
| 70 | + declarations.push( |
80 | 71 | ...statement.declarationList.declarations |
81 | 72 | .map((dec) => getVariableDeclarations(dec, dec.name, analyzer)) |
82 | 73 | .flat() |
83 | 74 | ); |
| 75 | + } else if (ts.isImportDeclaration(statement)) { |
| 76 | + dependencies.add( |
| 77 | + getPathForModuleSpecifier(statement.moduleSpecifier, analyzer) |
| 78 | + ); |
84 | 79 | } |
85 | 80 | } |
| 81 | + // Construct module and save in cache |
| 82 | + const module = new Module({ |
| 83 | + sourcePath, |
| 84 | + jsPath, |
| 85 | + sourceFile, |
| 86 | + packageJson, |
| 87 | + declarations, |
| 88 | + dependencies, |
| 89 | + }); |
| 90 | + analyzer.moduleCache.set( |
| 91 | + analyzer.path.normalize(sourceFile.fileName) as AbsolutePath, |
| 92 | + module |
| 93 | + ); |
86 | 94 | return module; |
87 | 95 | }; |
| 96 | + |
| 97 | +/** |
| 98 | + * Returns a cached Module model for the given module path if it and all of its |
| 99 | + * dependencies' models are still valid since the model was cached. If the |
| 100 | + * cached module is out-of-date and needs to be re-created, this method returns |
| 101 | + * undefined. |
| 102 | + */ |
| 103 | +const getAndValidateModuleFromCache = ( |
| 104 | + modulePath: AbsolutePath, |
| 105 | + analyzer: AnalyzerInterface |
| 106 | +): Module | undefined => { |
| 107 | + const module = analyzer.moduleCache.get(modulePath); |
| 108 | + // A cached module is only valid if the source file that was used has not |
| 109 | + // changed in the current program, and if all of its dependencies are still |
| 110 | + // valid |
| 111 | + if (module !== undefined) { |
| 112 | + if ( |
| 113 | + module.sourceFile === analyzer.program.getSourceFile(modulePath) && |
| 114 | + depsAreValid(module, analyzer) |
| 115 | + ) { |
| 116 | + return module; |
| 117 | + } |
| 118 | + analyzer.moduleCache.delete(modulePath); |
| 119 | + } |
| 120 | + return undefined; |
| 121 | +}; |
| 122 | + |
| 123 | +/** |
| 124 | + * Returns true if all dependencies of the module are still valid. |
| 125 | + */ |
| 126 | +const depsAreValid = (module: Module, analyzer: AnalyzerInterface) => |
| 127 | + Array.from(module.dependencies).every((path) => depIsValid(path, analyzer)); |
| 128 | + |
| 129 | +/** |
| 130 | + * Returns true if the given dependency is valid, meaning that if it has a |
| 131 | + * cached model, the model is still valid. Dependencies that don't yet have a |
| 132 | + * cached model are considered valid. |
| 133 | + */ |
| 134 | +const depIsValid = (modulePath: AbsolutePath, analyzer: AnalyzerInterface) => { |
| 135 | + if (analyzer.moduleCache.has(modulePath)) { |
| 136 | + // If a dep has a model, it is valid only if its deps are valid |
| 137 | + return Boolean(getAndValidateModuleFromCache(modulePath, analyzer)); |
| 138 | + } else { |
| 139 | + // Deps that don't have a cached model are considered valid |
| 140 | + return true; |
| 141 | + } |
| 142 | +}; |
| 143 | + |
| 144 | +/** |
| 145 | + * For a given source file, return its associated JS file. |
| 146 | + * |
| 147 | + * For a JS source file, these will be the same thing. For a TS file, we use the |
| 148 | + * TS API to determine where the associated JS will be output based on tsconfig |
| 149 | + * settings. |
| 150 | + */ |
| 151 | +const getJSPathFromSourcePath = ( |
| 152 | + sourcePath: AbsolutePath, |
| 153 | + analyzer: AnalyzerInterface |
| 154 | +) => { |
| 155 | + sourcePath = analyzer.path.normalize(sourcePath) as AbsolutePath; |
| 156 | + // If the source file was already JS, just return that |
| 157 | + if (sourcePath.endsWith('js')) { |
| 158 | + return sourcePath; |
| 159 | + } |
| 160 | + // Use the TS API to determine where the associated JS will be output based |
| 161 | + // on tsconfig settings. |
| 162 | + const outputPath = ts |
| 163 | + .getOutputFileNames(analyzer.commandLine, sourcePath, false) |
| 164 | + .filter((f) => f.endsWith('.js'))[0]; |
| 165 | + // TODO(kschaaf): this could happen if someone imported only a .d.ts file; |
| 166 | + // we might need to handle this differently |
| 167 | + if (outputPath === undefined) { |
| 168 | + throw new Error(`Could not determine output filename for '${sourcePath}'`); |
| 169 | + } |
| 170 | + // The filename appears to come out of the ts API with |
| 171 | + // unix separators; since sourcePath uses OS separators, normalize this so |
| 172 | + // that all our model paths are OS-native |
| 173 | + return analyzer.path.normalize(outputPath) as AbsolutePath; |
| 174 | +}; |
| 175 | + |
| 176 | +/** |
| 177 | + * Resolve a module specifier to an absolute path on disk. |
| 178 | + */ |
| 179 | +const getPathForModuleSpecifier = ( |
| 180 | + moduleSpecifier: ts.Expression, |
| 181 | + analyzer: AnalyzerInterface |
| 182 | +): AbsolutePath => { |
| 183 | + const specifier = moduleSpecifier.getText().slice(1, -1); |
| 184 | + let resolvedPath = ts.resolveModuleName( |
| 185 | + specifier, |
| 186 | + moduleSpecifier.getSourceFile().fileName, |
| 187 | + analyzer.commandLine.options, |
| 188 | + analyzer.fs |
| 189 | + ).resolvedModule?.resolvedFileName; |
| 190 | + if (resolvedPath === undefined) { |
| 191 | + throw new DiagnosticsError( |
| 192 | + moduleSpecifier, |
| 193 | + `Could not resolve specifier to filesystem path.` |
| 194 | + ); |
| 195 | + } |
| 196 | + if (!analyzer.fs.useCaseSensitiveFileNames) { |
| 197 | + resolvedPath = resolvedPath.toLowerCase(); |
| 198 | + } |
| 199 | + return analyzer.path.normalize(resolvedPath) as AbsolutePath; |
| 200 | +}; |
0 commit comments