UNPKG

ttsc

Version:

General-purpose TypeScript-Go compiler, runtime, plugin host, and LSP host.

192 lines (181 loc) 6.19 kB
import fs from "node:fs"; import path from "node:path"; import { isOutsideRelativePath } from "./paths"; /** * Locate the JavaScript file emitted for a TypeScript source file. * * Resolution strategy: * * 1. Try to derive the exact output path by mirroring the source's relative * position inside `projectRoot` into `outDir`, applying the correct JS * extension (`.js` / `.jsx` / `.mjs` / `.cjs`). Use this path if it exists * on disk. * 2. Fall back to scoring each candidate in `emittedFiles` (or a recursive * directory scan of `outDir`) by the number of trailing path-stem segments * shared with the source file name, and pick the highest-scoring existing * file. * * Returns `null` when no matching output file is found on disk. */ export function resolveEmittedJavaScript(options: { /** Pre-computed list of emitted paths; when absent `outDir` is scanned. */ emittedFiles?: readonly string[]; outDir: string; projectRoot: string; sourceFile: string; }): string | null { const exact = resolveExactEmittedFiles( options.outDir, options.projectRoot, options.sourceFile, ); const emitted = new Set( options.emittedFiles?.map((file) => emittedPathKey(file)) ?? [], ); for (const candidate of exact) { if (emitted.has(emittedPathKey(candidate)) && fs.existsSync(candidate)) { return candidate; } } for (const candidate of exact) { if (fs.existsSync(candidate)) { return candidate; } } // Score the pre-computed emit list first (cheap). When it yields nothing — // because the list is incomplete (a native transform host such as typia emits // without printing the `--listEmittedFiles` lines) or because the emit landed // at a path the exact mirror did not predict (tsgo shifts every output path // when the program pulls a raw-`.ts` dependency that sits outside `rootDir`, // so it strips the common source root rather than `rootDir`) — fall back to a // full recursive scan of `outDir`. Trailing-stem scoring still pins the right // file regardless of how deep the shifted prefix is. const primary = bestStemMatch( options.emittedFiles ?? listJavaScriptFiles(options.outDir), options.sourceFile, ); if (primary !== null && fs.existsSync(primary)) { return primary; } if (options.emittedFiles !== undefined) { const fromDir = bestStemMatch( listJavaScriptFiles(options.outDir), options.sourceFile, ); if (fromDir !== null && fs.existsSync(fromDir)) { return fromDir; } } return null; } /** Highest trailing-stem-scoring JavaScript output among `files`, or `null`. */ function bestStemMatch( files: readonly string[], sourceFile: string, ): string | null { let best: string | null = null; let bestScore = 0; for (const file of files) { if (!isJavaScriptOutput(file)) continue; const score = sharedSourceStemSegments(file, sourceFile); if (score > bestScore) { best = file; bestScore = score; } } return best; } /** * Derive the exact output path for `sourceFile` by mirroring its position * relative to `projectRoot` into `outDir`. Returns no candidates when the * source is not inside the project root or when the path cannot be determined. */ function resolveExactEmittedFiles( outDir: string, projectRoot: string, sourceFile: string, ): string[] { const relative = path.relative(projectRoot, sourceFile); if (relative === "" || isOutsideRelativePath(relative)) { return []; } const stem = relative.slice( 0, relative.length - path.extname(relative).length, ); return emittedJavaScriptExtensions(sourceFile).map((extension) => path.resolve(outDir, stem + extension), ); } /** * Recursively enumerate every JavaScript output file under `root`. Uses an * explicit stack instead of recursion to avoid call-stack overflow on deep * directory trees. Non-existent roots are silently skipped. */ function listJavaScriptFiles(root: string): string[] { const out: string[] = []; const stack = [root]; while (stack.length !== 0) { const current = stack.pop()!; if (!fs.existsSync(current)) continue; for (const entry of fs.readdirSync(current, { withFileTypes: true })) { const next = path.join(current, entry.name); if (entry.isDirectory()) { stack.push(next); } else if (entry.isFile() && isJavaScriptOutput(next)) { out.push(path.resolve(next)); } } } return out; } /** * Count the number of consecutive trailing path-stem segments that `outPath` * and `srcPath` share when both are stripped of their extensions and normalised * to forward slashes. * * Example: `dist/lib/foo.js` vs `src/lib/foo.ts` → 2 (`lib`, `foo`). */ function sharedSourceStemSegments(outPath: string, srcPath: string): number { const stripExtAndSplit = (location: string): string[] => { const normalized = location.replace(/\\/g, "/"); return normalized .slice(0, normalized.length - path.extname(normalized).length) .split("/"); }; const a = stripExtAndSplit(outPath); const b = stripExtAndSplit(srcPath); const count = Math.min(a.length, b.length); let shared = 0; for (let i = 1; i <= count; i += 1) { if (a[a.length - i] !== b[b.length - i]) break; shared += 1; } return shared; } /** * Map a source extension to every JavaScript output counterpart tsgo can use. * JSX preserve mode writes `.tsx`/`.jsx` inputs as `.jsx`; all other JSX modes * write `.js`. */ function emittedJavaScriptExtensions(filename: string): readonly string[] { switch (path.extname(filename).toLowerCase()) { case ".mts": return [".mjs"]; case ".cts": return [".cjs"]; case ".tsx": case ".jsx": return [".js", ".jsx"]; default: return [".js"]; } } /** Return true when `filename` has a JavaScript output extension. */ function isJavaScriptOutput(filename: string): boolean { return /\.(?:[cm]?js|jsx)$/i.test(filename); } function emittedPathKey(filename: string): string { const resolved = path.resolve(filename); return process.platform === "win32" ? resolved.toLowerCase() : resolved; }