import path from 'node:path'; import { promises as fs } from 'node:fs'; import { createRequire } from 'module'; import { find } from './find.js'; import { toJson } from './to-json.js'; import { makePromise, native2posix, resolve2posix, resolveReferencedTSConfigFiles, resolveSolutionTSConfig, resolveTSConfigJson } from './util.js'; const not_found_result = { tsconfigFile: null, tsconfig: {} }; /** * parse the closest tsconfig.json file * * @param {string} filename - path to a tsconfig .json or a source file or directory (absolute or relative to cwd) * @param {import('./public.d.ts').TSConfckParseOptions} [options] - options * @returns {Promise} * @throws {TSConfckParseError} */ export async function parse(filename, options) { /** @type {import('./cache.js').TSConfckCache} */ const cache = options?.cache; if (cache?.hasParseResult(filename)) { return getParsedDeep(filename, cache, options); } const { resolve, reject, /** @type {Promise}*/ promise } = makePromise(); cache?.setParseResult(filename, promise); try { let tsconfigFile = (await resolveTSConfigJson(filename, cache)) || (await find(filename, options)); if (!tsconfigFile) { resolve(not_found_result); return promise; } let result; if (filename !== tsconfigFile && cache?.hasParseResult(tsconfigFile)) { result = await getParsedDeep(tsconfigFile, cache, options); } else { result = await parseFile(tsconfigFile, cache, filename === tsconfigFile); await Promise.all([parseExtends(result, cache), parseReferences(result, options)]); } resolve(resolveSolutionTSConfig(filename, result)); } catch (e) { reject(e); } return promise; } /** * ensure extends and references are parsed * * @param {string} filename - cached file * @param {import('./cache.js').TSConfckCache} cache - cache * @param {import('./public.d.ts').TSConfckParseOptions} options - options */ async function getParsedDeep(filename, cache, options) { const result = await cache.getParseResult(filename); if ( (result.tsconfig.extends && !result.extended) || (result.tsconfig.references && !result.referenced) ) { const promise = Promise.all([ parseExtends(result, cache), parseReferences(result, options) ]).then(() => result); cache.setParseResult(filename, promise); return promise; } return result; } /** * * @param {string} tsconfigFile - path to tsconfig file * @param {import('./cache.js').TSConfckCache} [cache] - cache * @param {boolean} [skipCache] - skip cache * @returns {Promise} */ async function parseFile(tsconfigFile, cache, skipCache) { if (!skipCache && cache?.hasParseResult(tsconfigFile)) { return cache.getParseResult(tsconfigFile); } const promise = fs .readFile(tsconfigFile, 'utf-8') .then(toJson) .then((json) => { const parsed = JSON.parse(json); applyDefaults(parsed, tsconfigFile); return { tsconfigFile, tsconfig: normalizeTSConfig(parsed, path.dirname(tsconfigFile)) }; }) .catch((e) => { throw new TSConfckParseError( `parsing ${tsconfigFile} failed: ${e}`, 'PARSE_FILE', tsconfigFile, e ); }); if (!skipCache) { cache?.setParseResult(tsconfigFile, promise); } return promise; } /** * normalize to match the output of ts.parseJsonConfigFileContent * * @param {any} tsconfig - typescript tsconfig output * @param {string} dir - directory */ function normalizeTSConfig(tsconfig, dir) { // set baseUrl to absolute path if (tsconfig.compilerOptions?.baseUrl && !path.isAbsolute(tsconfig.compilerOptions.baseUrl)) { tsconfig.compilerOptions.baseUrl = resolve2posix(dir, tsconfig.compilerOptions.baseUrl); } return tsconfig; } /** * * @param {import('./public.d.ts').TSConfckParseResult} result * @param {import('./public.d.ts').TSConfckParseOptions} [options] * @returns {Promise} */ async function parseReferences(result, options) { if (!result.tsconfig.references) { return; } const referencedFiles = resolveReferencedTSConfigFiles(result, options); const referenced = await Promise.all( referencedFiles.map((file) => parseFile(file, options?.cache)) ); await Promise.all(referenced.map((ref) => parseExtends(ref, options?.cache))); referenced.forEach((ref) => { ref.solution = result; }); result.referenced = referenced; } /** * @param {import('./public.d.ts').TSConfckParseResult} result * @param {import('./cache.js').TSConfckCache}[cache] * @returns {Promise} */ async function parseExtends(result, cache) { if (!result.tsconfig.extends) { return; } // use result as first element in extended // but dereference tsconfig so that mergeExtended can modify the original without affecting extended[0] /** @type {import('./public.d.ts').TSConfckParseResult[]} */ const extended = [ { tsconfigFile: result.tsconfigFile, tsconfig: JSON.parse(JSON.stringify(result.tsconfig)) } ]; // flatten extends graph into extended let pos = 0; /** @type {string[]} */ const extendsPath = []; let currentBranchDepth = 0; while (pos < extended.length) { const extending = extended[pos]; extendsPath.push(extending.tsconfigFile); if (extending.tsconfig.extends) { // keep following this branch currentBranchDepth += 1; /** @type {string[]} */ let resolvedExtends; if (!Array.isArray(extending.tsconfig.extends)) { resolvedExtends = [resolveExtends(extending.tsconfig.extends, extending.tsconfigFile)]; } else { // reverse because typescript 5.0 treats ['a','b','c'] as c extends b extends a resolvedExtends = extending.tsconfig.extends .reverse() .map((ex) => resolveExtends(ex, extending.tsconfigFile)); } const circularExtends = resolvedExtends.find((tsconfigFile) => extendsPath.includes(tsconfigFile) ); if (circularExtends) { const circle = extendsPath.concat([circularExtends]).join(' -> '); throw new TSConfckParseError( `Circular dependency in "extends": ${circle}`, 'EXTENDS_CIRCULAR', result.tsconfigFile ); } // add new extends to the list directly after current extended.splice( pos + 1, 0, ...(await Promise.all(resolvedExtends.map((file) => parseFile(file, cache)))) ); } else { // reached a leaf, backtrack to the last branching point and continue extendsPath.splice(-currentBranchDepth); currentBranchDepth = 0; } pos = pos + 1; } result.extended = extended; // skip first as it is the original config for (const ext of result.extended.slice(1)) { extendTSConfig(result, ext); } } /** * * @param {string} extended * @param {string} from * @returns {string} */ function resolveExtends(extended, from) { if (extended === '..') { // see #149 extended = '../tsconfig.json'; } const req = createRequire(from); let error; try { return req.resolve(extended); } catch (e) { error = e; } if (extended[0] !== '.' && !path.isAbsolute(extended)) { try { return req.resolve(`${extended}/tsconfig.json`); } catch (e) { error = e; } } throw new TSConfckParseError( `failed to resolve "extends":"${extended}" in ${from}`, 'EXTENDS_RESOLVE', from, error ); } // references, extends and custom keys are not carried over const EXTENDABLE_KEYS = [ 'compilerOptions', 'files', 'include', 'exclude', 'watchOptions', 'compileOnSave', 'typeAcquisition', 'buildOptions' ]; /** * * @param {import('./public.d.ts').TSConfckParseResult} extending * @param {import('./public.d.ts').TSConfckParseResult} extended * @returns void */ function extendTSConfig(extending, extended) { const extendingConfig = extending.tsconfig; const extendedConfig = extended.tsconfig; const relativePath = native2posix( path.relative(path.dirname(extending.tsconfigFile), path.dirname(extended.tsconfigFile)) ); for (const key of Object.keys(extendedConfig).filter((key) => EXTENDABLE_KEYS.includes(key))) { if (key === 'compilerOptions') { if (!extendingConfig.compilerOptions) { extendingConfig.compilerOptions = {}; } for (const option of Object.keys(extendedConfig.compilerOptions)) { if (Object.prototype.hasOwnProperty.call(extendingConfig.compilerOptions, option)) { continue; // already set } extendingConfig.compilerOptions[option] = rebaseRelative( option, extendedConfig.compilerOptions[option], relativePath ); } } else if (extendingConfig[key] === undefined) { if (key === 'watchOptions') { extendingConfig.watchOptions = {}; for (const option of Object.keys(extendedConfig.watchOptions)) { extendingConfig.watchOptions[option] = rebaseRelative( option, extendedConfig.watchOptions[option], relativePath ); } } else { extendingConfig[key] = rebaseRelative(key, extendedConfig[key], relativePath); } } } } const REBASE_KEYS = [ // root 'files', 'include', 'exclude', // compilerOptions 'baseUrl', 'rootDir', 'rootDirs', 'typeRoots', 'outDir', 'outFile', 'declarationDir', // watchOptions 'excludeDirectories', 'excludeFiles' ]; /** @typedef {string | string[]} PathValue */ /** * * @param {string} key * @param {PathValue} value * @param {string} prependPath * @returns {PathValue} */ function rebaseRelative(key, value, prependPath) { if (!REBASE_KEYS.includes(key)) { return value; } if (Array.isArray(value)) { return value.map((x) => rebasePath(x, prependPath)); } else { return rebasePath(value, prependPath); } } /** * * @param {string} value * @param {string} prependPath * @returns {string} */ function rebasePath(value, prependPath) { if (path.isAbsolute(value)) { return value; } else { // relative paths use posix syntax in tsconfig return path.posix.normalize(path.posix.join(prependPath, value)); } } export class TSConfckParseError extends Error { /** * error code * @type {string} */ code; /** * error cause * @type { Error | undefined} */ cause; /** * absolute path of tsconfig file where the error happened * @type {string} */ tsconfigFile; /** * * @param {string} message - error message * @param {string} code - error code * @param {string} tsconfigFile - path to tsconfig file * @param {Error?} cause - cause of this error */ constructor(message, code, tsconfigFile, cause) { super(message); // Set the prototype explicitly. Object.setPrototypeOf(this, TSConfckParseError.prototype); this.name = TSConfckParseError.name; this.code = code; this.cause = cause; this.tsconfigFile = tsconfigFile; } } /** * * @param {any} tsconfig * @param {string} tsconfigFile */ function applyDefaults(tsconfig, tsconfigFile) { if (isJSConfig(tsconfigFile)) { tsconfig.compilerOptions = { ...DEFAULT_JSCONFIG_COMPILER_OPTIONS, ...tsconfig.compilerOptions }; } } const DEFAULT_JSCONFIG_COMPILER_OPTIONS = { allowJs: true, maxNodeModuleJsDepth: 2, allowSyntheticDefaultImports: true, skipLibCheck: true, noEmit: true }; /** * @param {string} configFileName */ function isJSConfig(configFileName) { return path.basename(configFileName) === 'jsconfig.json'; }