From 81f9e7788b01c26ee1be3feace59bffd81b74ab4 Mon Sep 17 00:00:00 2001 From: Hiroki Osame Date: Wed, 9 Aug 2023 09:46:19 +0900 Subject: [PATCH] feat: accept cache (#54) --- README.md | 18 +++++- src/get-tsconfig.ts | 9 ++- src/parse-tsconfig/index.ts | 27 +++++++-- src/parse-tsconfig/resolve-extends-path.ts | 67 +++++++++++++++------- src/utils/find-up.ts | 5 +- src/utils/fs-cached.ts | 36 ++++++++++++ src/utils/read-jsonc.ts | 5 +- tests/specs/get-tsconfig.ts | 23 ++++++++ tests/specs/parse-tsconfig/parses.spec.ts | 35 +++++++++++ 9 files changed, 190 insertions(+), 35 deletions(-) create mode 100644 src/utils/fs-cached.ts diff --git a/README.md b/README.md index 3d01bf9..8430a7e 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ For TypeScript related tooling to correctly parse `tsconfig.json` file without d ## API -### getTsconfig(searchPath?, configName?) +### getTsconfig(searchPath?, configName?, cache?) Searches for a `tsconfig.json` file and parses it. Returns `null` if a config file cannot be found, or an object containing the path and parsed TSConfig object if found. Returns: @@ -57,6 +57,13 @@ Default: `tsconfig.json` The file name of the TypeScript config file. +#### cache +Type: `Map` + +Default: `new Map()` + +Optional cache for fs operations. + #### Example ```ts @@ -80,7 +87,7 @@ console.log(getTsconfig('.', 'jsconfig.json')) --- -### parseTsconfig(tsconfigPath) +### parseTsconfig(tsconfigPath, cache?) The `tsconfig.json` parser used internally by `getTsconfig`. Returns the parsed tsconfig as `TsConfigJsonResolved`. #### tsconfigPath @@ -88,6 +95,13 @@ Type: `string` Required path to the tsconfig file. +#### cache +Type: `Map` + +Default: `new Map()` + +Optional cache for fs operations. + #### Example ```ts diff --git a/src/get-tsconfig.ts b/src/get-tsconfig.ts index 0496d4a..d5ad442 100644 --- a/src/get-tsconfig.ts +++ b/src/get-tsconfig.ts @@ -6,14 +6,19 @@ import type { TsConfigResult } from './types.js'; export const getTsconfig = ( searchPath = process.cwd(), configName = 'tsconfig.json', + cache: Map = new Map(), ): TsConfigResult | null => { - const configFile = findUp(slash(searchPath), configName); + const configFile = findUp( + slash(searchPath), + configName, + cache, + ); if (!configFile) { return null; } - const config = parseTsconfig(configFile); + const config = parseTsconfig(configFile, cache); return { path: configFile, diff --git a/src/parse-tsconfig/index.ts b/src/parse-tsconfig/index.ts index 3500a89..bcb5e2d 100644 --- a/src/parse-tsconfig/index.ts +++ b/src/parse-tsconfig/index.ts @@ -1,25 +1,27 @@ -import fs from 'fs'; import path from 'path'; import slash from 'slash'; import type { TsConfigJson, TsConfigJsonResolved } from '../types.js'; import { normalizePath } from '../utils/normalize-path.js'; import { readJsonc } from '../utils/read-jsonc.js'; +import { realpath } from '../utils/fs-cached.js'; import { resolveExtendsPath } from './resolve-extends-path.js'; const resolveExtends = ( extendsPath: string, directoryPath: string, + cache?: Map, ) => { const resolvedExtendsPath = resolveExtendsPath( extendsPath, directoryPath, + cache, ); if (!resolvedExtendsPath) { throw new Error(`File '${extendsPath}' not found.`); } - const extendsConfig = parseTsconfig(resolvedExtendsPath); + const extendsConfig = parseTsconfig(resolvedExtendsPath, cache); delete extendsConfig.references; if (extendsConfig.compilerOptions?.baseUrl) { @@ -48,25 +50,38 @@ const resolveExtends = ( ), ); } + return extendsConfig; }; export const parseTsconfig = ( tsconfigPath: string, + cache: Map = new Map(), ): TsConfigJsonResolved => { let realTsconfigPath: string; try { - realTsconfigPath = fs.realpathSync(tsconfigPath); + realTsconfigPath = realpath(cache, tsconfigPath) as string; } catch { throw new Error(`Cannot resolve tsconfig at path: ${tsconfigPath}`); } - const directoryPath = path.dirname(realTsconfigPath); - let config: TsConfigJson = readJsonc(realTsconfigPath) || {}; + + /** + * Decided not to cache the TsConfigJsonResolved object because it's + * mutable. + * + * Note how `resolveExtends` can call `parseTsconfig` rescursively + * and actually mutates the object. It can also be mutated in + * user-land. + * + * By only caching fs results, we can avoid serving mutated objects + */ + let config: TsConfigJson = readJsonc(realTsconfigPath, cache) || {}; if (typeof config !== 'object') { throw new SyntaxError(`Failed to parse tsconfig at: ${tsconfigPath}`); } + const directoryPath = path.dirname(realTsconfigPath); if (config.extends) { const extendsPathList = ( Array.isArray(config.extends) @@ -77,7 +92,7 @@ export const parseTsconfig = ( delete config.extends; for (const extendsPath of extendsPathList.reverse()) { - const extendsConfig = resolveExtends(extendsPath, directoryPath); + const extendsConfig = resolveExtends(extendsPath, directoryPath, cache); const merged = { ...extendsConfig, ...config, diff --git a/src/parse-tsconfig/resolve-extends-path.ts b/src/parse-tsconfig/resolve-extends-path.ts index 720409c..391d70c 100644 --- a/src/parse-tsconfig/resolve-extends-path.ts +++ b/src/parse-tsconfig/resolve-extends-path.ts @@ -1,12 +1,10 @@ import path from 'path'; -import fs from 'fs'; import Module from 'module'; import { resolveExports } from 'resolve-pkg-maps'; import type { PackageJson } from 'type-fest'; import { findUp } from '../utils/find-up.js'; import { readJsonc } from '../utils/read-jsonc.js'; - -const { existsSync } = fs; +import { stat, exists } from '../utils/fs-cached.js'; const getPnpApi = () => { const { findPnpApi } = Module; @@ -19,10 +17,16 @@ const resolveFromPackageJsonPath = ( packageJsonPath: string, subpath: string, ignoreExports?: boolean, + cache?: Map, ) => { + const cacheKey = `resolveFromPackageJsonPath:${packageJsonPath}:${subpath}:${ignoreExports}`; + if (cache?.has(cacheKey)) { + return cache.get(cacheKey); + } + let resolvedPath = 'tsconfig.json'; - const packageJson = readJsonc(packageJsonPath) as PackageJson; + const packageJson = readJsonc(packageJsonPath, cache) as PackageJson; if (packageJson) { if ( !ignoreExports @@ -42,11 +46,15 @@ const resolveFromPackageJsonPath = ( } } - return path.join( + resolvedPath = path.join( packageJsonPath, '..', resolvedPath, ); + + cache?.set(cacheKey, resolvedPath); + + return resolvedPath; }; const PACKAGE_JSON = 'package.json'; @@ -55,6 +63,7 @@ const TS_CONFIG_JSON = 'tsconfig.json'; export const resolveExtendsPath = ( requestedPath: string, directoryPath: string, + cache?: Map, ) => { let filePath = requestedPath; @@ -67,14 +76,14 @@ export const resolveExtendsPath = ( } if (path.isAbsolute(filePath)) { - if (existsSync(filePath)) { - if (fs.statSync(filePath).isFile()) { + if (exists(cache, filePath)) { + if (stat(cache, filePath)!.isFile()) { return filePath; } } else if (!filePath.endsWith('.json')) { const jsonPath = `${filePath}.json`; - if (existsSync(jsonPath)) { + if (exists(cache, jsonPath)) { return jsonPath; } } @@ -97,9 +106,14 @@ export const resolveExtendsPath = ( ); if (packageJsonPath) { - const resolvedPath = resolveFromPackageJsonPath(packageJsonPath, subpath); + const resolvedPath = resolveFromPackageJsonPath( + packageJsonPath, + subpath, + false, + cache, + ); - if (resolvedPath && existsSync(resolvedPath)) { + if (resolvedPath && exists(cache, resolvedPath)) { return resolvedPath; } } @@ -128,21 +142,27 @@ export const resolveExtendsPath = ( const packagePath = findUp( directoryPath, path.join('node_modules', packageName), + cache, ); - if (!packagePath || !fs.statSync(packagePath).isDirectory()) { + if (!packagePath || !stat(cache, packagePath)!.isDirectory()) { return; } const packageJsonPath = path.join(packagePath, PACKAGE_JSON); - if (existsSync(packageJsonPath)) { - const resolvedPath = resolveFromPackageJsonPath(packageJsonPath, subpath); + if (exists(cache, packageJsonPath)) { + const resolvedPath = resolveFromPackageJsonPath( + packageJsonPath, + subpath, + false, + cache, + ); if (!resolvedPath) { return; } - if (existsSync(resolvedPath)) { + if (exists(cache, resolvedPath)) { return resolvedPath; } } @@ -153,26 +173,31 @@ export const resolveExtendsPath = ( if (!jsonExtension) { const fullPackagePathWithJson = `${fullPackagePath}.json`; - if (existsSync(fullPackagePathWithJson)) { + if (exists(cache, fullPackagePathWithJson)) { return fullPackagePathWithJson; } } - if (!existsSync(fullPackagePath)) { + if (!exists(cache, fullPackagePath)) { return; } - if (fs.statSync(fullPackagePath).isDirectory()) { + if (stat(cache, fullPackagePath)!.isDirectory()) { const fullPackageJsonPath = path.join(fullPackagePath, PACKAGE_JSON); - if (existsSync(fullPackageJsonPath)) { - const resolvedPath = resolveFromPackageJsonPath(fullPackageJsonPath, '', true); - if (resolvedPath && existsSync(resolvedPath)) { + if (exists(cache, fullPackageJsonPath)) { + const resolvedPath = resolveFromPackageJsonPath( + fullPackageJsonPath, + '', + true, + cache, + ); + if (resolvedPath && exists(cache, resolvedPath)) { return resolvedPath; } } const tsconfigPath = path.join(fullPackagePath, TS_CONFIG_JSON); - if (existsSync(tsconfigPath)) { + if (exists(cache, tsconfigPath)) { return tsconfigPath; } } else if (jsonExtension) { diff --git a/src/utils/find-up.ts b/src/utils/find-up.ts index e59d804..daacb72 100644 --- a/src/utils/find-up.ts +++ b/src/utils/find-up.ts @@ -1,13 +1,14 @@ import path from 'path'; -import fs from 'fs'; +import { exists } from './fs-cached.js'; export const findUp = ( searchPath: string, fileName: string, + cache?: Map, ) => { while (true) { const configPath = path.posix.join(searchPath, fileName); - if (fs.existsSync(configPath)) { + if (exists(cache, configPath)) { return configPath; } diff --git a/src/utils/fs-cached.ts b/src/utils/fs-cached.ts new file mode 100644 index 0000000..0dd92c4 --- /dev/null +++ b/src/utils/fs-cached.ts @@ -0,0 +1,36 @@ +import fs from 'fs'; + +type Fs = typeof fs; + +type AnyFunction = (...args: any[]) => any; + +type FunctionProperties = { + [Key in keyof Type as Type[Key] extends AnyFunction ? Key : never]: Type[Key]; +}; + +type FsMethods = FunctionProperties; + +const cacheFs = ( + name: MethodName, +) => { + const method = fs[name]; + return function ( + cache?: Map, + ...args: any[] + ): ReturnType { + const cacheKey = `${name}:${args.join(':')}`; + let result = cache?.get(cacheKey); + + if (result === undefined) { + result = Reflect.apply(method, fs, args); + cache?.set(cacheKey, result); + } + + return result; + }; +}; + +export const exists = cacheFs('existsSync'); +export const realpath = cacheFs('realpathSync'); +export const readFile = cacheFs('readFileSync'); +export const stat = cacheFs('statSync'); diff --git a/src/utils/read-jsonc.ts b/src/utils/read-jsonc.ts index 57e53ac..607d86e 100644 --- a/src/utils/read-jsonc.ts +++ b/src/utils/read-jsonc.ts @@ -1,6 +1,7 @@ -import fs from 'fs'; import { parse } from 'jsonc-parser'; +import { readFile } from './fs-cached.js'; export const readJsonc = ( jsonPath: string, -) => parse(fs.readFileSync(jsonPath, 'utf8')) as unknown; + cache?: Map, +) => parse(readFile(cache, jsonPath, 'utf8') as string) as unknown; diff --git a/tests/specs/get-tsconfig.ts b/tests/specs/get-tsconfig.ts index 11049b3..1488029 100644 --- a/tests/specs/get-tsconfig.ts +++ b/tests/specs/get-tsconfig.ts @@ -72,5 +72,28 @@ export default testSuite(({ describe }) => { await fixture.rm(); }); + + test('cache', async () => { + const fixture = await createFixture({ + 'tsconfig.json': tsconfigJson, + }); + + const expectedResult = { + path: slash(path.join(fixture.path, 'tsconfig.json')), + config: { compilerOptions }, + }; + + const cache = new Map(); + const tsconfig = getTsconfig(fixture.path, 'tsconfig.json', cache); + expect(tsconfig).toStrictEqual(expectedResult); + expect(cache.size).toBe(3); + + await fixture.rm('tsconfig.json'); + + const tsconfigCacheHit = getTsconfig(fixture.path, 'tsconfig.json', cache); + expect(tsconfigCacheHit).toStrictEqual(expectedResult); + + await fixture.rm(); + }); }); }); diff --git a/tests/specs/parse-tsconfig/parses.spec.ts b/tests/specs/parse-tsconfig/parses.spec.ts index 1d7fca1..a116bd9 100644 --- a/tests/specs/parse-tsconfig/parses.spec.ts +++ b/tests/specs/parse-tsconfig/parses.spec.ts @@ -139,5 +139,40 @@ export default testSuite(({ describe }) => { await fixture.rm(); }); }); + + test('cache', async () => { + const fixture = await createFixture({ + 'file.ts': '', + 'tsconfig.json': createTsconfigJson({ + compilerOptions: { + baseUrl: '.', + moduleResolution: 'node10', + isolatedModules: true, + module: 'esnext', + esModuleInterop: true, + declaration: true, + outDir: 'dist', + strict: true, + target: 'esnext', + }, + }), + }); + + const cache = new Map(); + const parsedTsconfig = parseTsconfig(path.join(fixture.path, 'tsconfig.json'), cache); + expect(cache.size).toBe(2); + + const expectedTsconfig = await getTscTsconfig(fixture.path); + delete expectedTsconfig.files; + + expect(parsedTsconfig).toStrictEqual(expectedTsconfig); + + const parsedTsconfigCached = parseTsconfig(path.join(fixture.path, 'tsconfig.json'), cache); + expect(cache.size).toBe(2); + + expect(parsedTsconfigCached).toStrictEqual(expectedTsconfig); + + await fixture.rm(); + }); }); });