From 820dd8254d854b2258c9f079b8f3657d9df719ba Mon Sep 17 00:00:00 2001 From: Simo Kinnunen Date: Fri, 20 Dec 2024 06:12:13 +0900 Subject: [PATCH] feat: look up original TS sources when dealing with folders as modules --- .../package-files/package-json-file.ts | 11 +- .../check-parser/package-files/resolver.ts | 95 ++++++++++---- .../package-files/tsconfig-json-file.ts | 123 ++++++++++++++++++ 3 files changed, 198 insertions(+), 31 deletions(-) diff --git a/packages/cli/src/services/check-parser/package-files/package-json-file.ts b/packages/cli/src/services/check-parser/package-files/package-json-file.ts index d0c24880..24e694d7 100644 --- a/packages/cli/src/services/check-parser/package-files/package-json-file.ts +++ b/packages/cli/src/services/check-parser/package-files/package-json-file.ts @@ -20,10 +20,17 @@ export class PackageJsonFile { jsonFile: JsonSourceFile basePath: string + mainPaths: string[] private constructor (jsonFile: JsonSourceFile) { this.jsonFile = jsonFile this.basePath = jsonFile.meta.dirname + + const fallbackMainPath = path.resolve(this.basePath, 'index.js') + + this.mainPaths = jsonFile.data.main !== undefined + ? [path.resolve(this.basePath, jsonFile.data.main), fallbackMainPath] + : [fallbackMainPath] } public get meta () { @@ -55,8 +62,4 @@ export class PackageJsonFile { supportsPackageRelativePaths () { return this.jsonFile.data.exports === undefined } - - mainPath () { - return path.resolve(this.meta.dirname, this.jsonFile.data.main ?? 'index.js') - } } diff --git a/packages/cli/src/services/check-parser/package-files/resolver.ts b/packages/cli/src/services/check-parser/package-files/resolver.ts index aa9d4b29..59311668 100644 --- a/packages/cli/src/services/check-parser/package-files/resolver.ts +++ b/packages/cli/src/services/check-parser/package-files/resolver.ts @@ -34,7 +34,7 @@ export class PackageFilesResolver { packageJsonCache = new FileLoader(PackageJsonFile.loadFromFilePath) tsconfigJsonCache = new FileLoader(TSConfigFile.loadFromFilePath) - loadPackageFiles (filePath: string): PackageFiles { + loadPackageFiles (filePath: string, options?: { root?: string }): PackageFiles { const files: PackageFiles = {} let currentPath = filePath @@ -61,24 +61,53 @@ export class PackageFilesResolver { if (files.packageJson !== undefined && files.tsconfigJson !== undefined) { break } + + // Stop if we reach the user-specified root directory. + // TODO: I don't like a string comparison for this but it'll do for now. + if (currentPath === options?.root) { + break + } } return files } - private resolveSourceFile (sourceFile: SourceFile): SourceFile { + private resolveSourceFile (sourceFile: SourceFile): SourceFile | undefined { if (sourceFile.meta.basename === PackageJsonFile.FILENAME) { const packageJson = this.packageJsonCache.load(sourceFile.meta.filePath) if (packageJson === undefined) { return sourceFile } - const mainSourceFile = SourceFile.loadFromFilePath(packageJson.mainPath()) - if (mainSourceFile === undefined) { - return sourceFile + // Go through each main path. A fallback path is included. If we can + // find a tsconfig for the main file, look it up and attempt to find + // the original TypeScript sources roughly the same way tsc does it. + for (const mainPath of packageJson.mainPaths) { + const { tsconfigJson } = this.loadPackageFiles(mainPath, { + root: packageJson.basePath, + }) + + if (tsconfigJson === undefined) { + const mainSourceFile = SourceFile.loadFromFilePath(mainPath) + if (mainSourceFile === undefined) { + continue + } + + return mainSourceFile + } + + const candidatePaths = tsconfigJson.collectLookupPaths(mainPath) + for (const candidatePath of candidatePaths) { + const mainSourceFile = SourceFile.loadFromFilePath(candidatePath) + if (mainSourceFile === undefined) { + continue + } + + return mainSourceFile + } } - return mainSourceFile + return undefined } return sourceFile @@ -104,11 +133,14 @@ export class PackageFilesResolver { const relativeDepPath = path.resolve(dirname, dep) const sourceFile = SourceFile.loadFromFilePath(relativeDepPath, suffixes) if (sourceFile !== undefined) { - resolved.local.push({ - sourceFile: this.resolveSourceFile(sourceFile), - origin: 'relative-path', - }) - continue + const resolvedFile = this.resolveSourceFile(sourceFile) + if (resolvedFile !== undefined) { + resolved.local.push({ + sourceFile: resolvedFile, + origin: 'relative-path', + }) + continue + } } resolved.missing.push({ spec: dep, @@ -125,12 +157,15 @@ export class PackageFilesResolver { const relativePath = path.resolve(tsconfigJson.basePath, resolvedPath) const sourceFile = SourceFile.loadFromFilePath(relativePath, suffixes) if (sourceFile !== undefined) { - resolved.local.push({ - sourceFile: this.resolveSourceFile(sourceFile), - origin: 'tsconfig-resolved-path', - }) - found = true - break // We only need the first match that exists. + const resolvedFile = this.resolveSourceFile(sourceFile) + if (resolvedFile !== undefined) { + resolved.local.push({ + sourceFile: resolvedFile, + origin: 'tsconfig-resolved-path', + }) + found = true + break // We only need the first match that exists. + } } } if (found) { @@ -142,11 +177,14 @@ export class PackageFilesResolver { const relativePath = path.resolve(tsconfigJson.basePath, tsconfigJson.baseUrl, dep) const sourceFile = SourceFile.loadFromFilePath(relativePath, suffixes) if (sourceFile !== undefined) { - resolved.local.push({ - sourceFile: this.resolveSourceFile(sourceFile), - origin: 'tsconfig-baseurl-relative-path', - }) - continue + const resolvedFile = this.resolveSourceFile(sourceFile) + if (resolvedFile !== undefined) { + resolved.local.push({ + sourceFile: resolvedFile, + origin: 'tsconfig-baseurl-relative-path', + }) + continue + } } } } @@ -156,11 +194,14 @@ export class PackageFilesResolver { const relativePath = path.resolve(packageJson.basePath, dep) const sourceFile = SourceFile.loadFromFilePath(relativePath, suffixes) if (sourceFile !== undefined) { - resolved.local.push({ - sourceFile: this.resolveSourceFile(sourceFile), - origin: 'package-relative-path', - }) - continue + const resolvedFile = this.resolveSourceFile(sourceFile) + if (resolvedFile !== undefined) { + resolved.local.push({ + sourceFile: resolvedFile, + origin: 'package-relative-path', + }) + continue + } } } } diff --git a/packages/cli/src/services/check-parser/package-files/tsconfig-json-file.ts b/packages/cli/src/services/check-parser/package-files/tsconfig-json-file.ts index f3d21454..b2650ffd 100644 --- a/packages/cli/src/services/check-parser/package-files/tsconfig-json-file.ts +++ b/packages/cli/src/services/check-parser/package-files/tsconfig-json-file.ts @@ -18,6 +18,42 @@ interface CompilerOptions { moduleResolution?: ModuleResolution baseUrl?: string paths?: Paths + + /** + * If set, .js files will be emitted into this directory. + * + * If not set, .js files are placed right next to .ts files in the same + * folder. + * + * @see https://www.typescriptlang.org/tsconfig/#outDir + */ + outDir?: string + + /** + * If not set, rootDir is inferred to be the longest common path of all + * non-declaration input files, unless `composite: true`, in which case + * the inferred root is the directory containing the tsconfig.json file. + * + * @see https://www.typescriptlang.org/tsconfig/#rootDir + */ + rootDir?: string + + /** + * Allow multiple directions to act as a single root. Source files will be + * able to refer to files in other roots as if they were present in the same + * root. + * + * @see https://www.typescriptlang.org/tsconfig/#rootDirs + */ + rootDirs?: string[] + + /** + * If true, the default rootDir is the directory containing the + * tsconfig.json file. + * + * @see https://www.typescriptlang.org/tsconfig/#composite + */ + composite?: boolean } export interface Schema { @@ -28,6 +64,23 @@ export type Options = { jsonSourceFileLoader?: LoadFile>, } +type JSExtension = '.js' | '.mjs' | '.cjs' + +const JSExtensions: JSExtension[] = ['.js', '.mjs', '.cjs'] + +type JSExtensionMappings = { + [key in JSExtension]: string[] +} + +/** + * @see https://www.typescriptlang.org/docs/handbook/modules/reference.html#file-extension-substitution + */ +const extensionMappings: JSExtensionMappings = { + '.js': ['.ts', '.tsx', '.js', '.jsx'], + '.mjs': ['.mts', '.mjs'], + '.cjs': ['.cts', '.cjs'], +} + export class TSConfigFile { static FILENAME = 'tsconfig.json' @@ -92,4 +145,74 @@ export class TSConfigFile { resolvePath (importPath: string): string[] { return this.pathResolver.resolve(importPath) } + + private extifyLookupPaths (filePaths: string[]): string[] { + return filePaths.flatMap(filePath => { + let extensions = extensionMappings['.js'] + let extlessPath = filePath + + for (const ext of JSExtensions) { + if (filePath.endsWith(ext)) { + extensions = extensionMappings[ext] + extlessPath = filePath.substring(0, filePath.length - ext.length) + } + } + + return extensions.map(ext => path.resolve(this.basePath, extlessPath + ext)) + }) + } + + collectLookupPaths (filePath: string): string[] { + let { + outDir, + rootDir, + rootDirs, + composite, + } = this.jsonFile.data.compilerOptions ?? {} + + const candidates = [] + + if (outDir === undefined) { + candidates.push(filePath) + return this.extifyLookupPaths(candidates) // Nothing more we can do. + } + + if (composite === undefined) { + composite = false + } + + // Inferred rootDir is tsconfig directory if composite === true. + if (rootDir === undefined && composite) { + rootDir = '.' + } + + // If we still don't have a root, we should calculate the longest common + // path among input files, but that's a lot of effort. Assume tsconfig + // directory and hope for the best. + if (rootDir === undefined) { + rootDir = '.' + } + + const absoluteOutDir = path.resolve(this.basePath, outDir) + const relativePath = path.relative(absoluteOutDir, filePath) + + // If the file is outside outDir, then assume we're looking for + // something that wasn't compiled using this tsconfig (or at all), and + // stop looking. + if (relativePath.startsWith('..')) { + candidates.push(filePath) + return this.extifyLookupPaths(candidates) + } + + candidates.push(path.resolve(this.basePath, rootDir, relativePath)) + + // Assume that our inferred (or user specified) rootDir is enough to cover + // the same conditions we'd have to infer rootDirs, and only add rootDirs + // if they're actually set. + for (const multiRootDir of rootDirs ?? []) { + candidates.push(path.resolve(this.basePath, multiRootDir, relativePath)) + } + + return this.extifyLookupPaths(candidates) + } }