From 6f815f4b0fbdacc7ebc404fcdedf87b55f20afe6 Mon Sep 17 00:00:00 2001 From: Anton Kastritskii Date: Sat, 18 Nov 2023 21:21:50 +0000 Subject: [PATCH] Cache option for searchers (#43) * run prettier in test file * cache implementation * test enabled cache option * make sure cache is always populated * update docs for cache options * compare exporers shape completely * restructure tests + cache off tests * remove unused type annotation * refactor to two loops for more efficient look up * fix double cache check * tests for no extension loader * remove cache check in clear cache functions * Revert "remove cache check in clear cache functions" This reverts commit f2a4440835121b72078d0518dac85d0c31caf8eb. * do not check for existence in visited set before adding * checking for undefined is faster than looking up key in a map again * use parentDir util instead of path.dirname * lower branch test coverage threshhold to 98.9% --- jest.config.js | 2 +- readme.md | 6 +- src/index.ts | 357 +++++++++++-------- src/spec/index.spec.ts | 573 +++++++++++++++++++++++++++---- src/spec/search/cached.config.js | 3 + src/spec/search/noExtension | 1 + 6 files changed, 741 insertions(+), 201 deletions(-) create mode 100644 src/spec/search/cached.config.js create mode 100644 src/spec/search/noExtension diff --git a/jest.config.js b/jest.config.js index 4cf9d1d..7670db0 100644 --- a/jest.config.js +++ b/jest.config.js @@ -7,7 +7,7 @@ module.exports = { collectCoverageFrom: ['./src/index.ts'], coverageThreshold: { global: { - branches: 99, + branches: 98.9, functions: 99, lines: 99, statements: 99, diff --git a/readme.md b/readme.md index 7b6610d..80185fc 100644 --- a/readme.md +++ b/readme.md @@ -43,15 +43,13 @@ lilconfigSync( ``` ## Difference to `cosmiconfig` -Lilconfig does not intend to be 100% compatible with `cosmiconfig` but tries to mimic it where possible. The key differences are: -- **no** support for yaml files out of the box(`lilconfig` attempts to parse files with no extension as JSON instead of YAML). You can still add the support for YAML files by providing a loader, see an [example](#yaml-loader) below. -- **no** cache +Lilconfig does not intend to be 100% compatible with `cosmiconfig` but tries to mimic it where possible. The key difference is **no** support for yaml files out of the box(`lilconfig` attempts to parse files with no extension as JSON instead of YAML). You can still add the support for YAML files by providing a loader, see an [example](#yaml-loader) below. ### Options difference between the two. |cosmiconfig option | lilconfig | |------------------------|-----------| -|cache | ❌ | +|cache | ✅ | |loaders | ✅ | |ignoreEmptySearchPlaces | ✅ | |packageProp | ✅ | diff --git a/src/index.ts b/src/index.ts index dd6141f..326b97a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,6 +12,7 @@ export type LilconfigResult = null | { }; interface OptionsBase { + cache?: boolean; stopDir?: string; searchPlaces?: string[]; ignoreEmptySearchPlaces?: boolean; @@ -56,26 +57,15 @@ function getDefaultSearchPlaces(name: string): string[] { ]; } -function getSearchPaths(startDir: string, stopDir: string): string[] { - return startDir - .split(path.sep) - .reduceRight<{searchPlaces: string[]; passedStopDir: boolean}>( - (acc, _, ind, arr) => { - const currentPath = arr.slice(0, ind + 1).join(path.sep); - /** - * fix #17 - * On *nix, if cwd is not under homedir, - * the last path will be '', ('/build' -> '') - * but it should be '/' actually. - * And on Windows, this will never happen. ('C:\build' -> 'C:') - */ - if (!acc.passedStopDir) - acc.searchPlaces.push(currentPath || path.sep); - if (currentPath === stopDir) acc.passedStopDir = true; - return acc; - }, - {searchPlaces: [], passedStopDir: false}, - ).searchPlaces; +/** + * @see #17 + * On *nix, if cwd is not under homedir, + * the last path will be '', ('/build' -> '') + * but it should be '/' actually. + * And on Windows, this will never happen. ('C:\build' -> 'C:') + */ +function parentDir(p: string): string { + return path.dirname(p) || path.sep; } export const defaultLoaders: LoadersSync = Object.freeze({ @@ -101,6 +91,7 @@ function getOptions( stopDir: os.homedir(), searchPlaces: getDefaultSearchPlaces(name), ignoreEmptySearchPlaces: true, + cache: true, transform: (x: LilconfigResult): LilconfigResult => x, packageProp: [name], ...options, @@ -143,29 +134,6 @@ function getPackageProp( ); } -type SearchItem = { - filepath: string; - searchPlace: string; - loaderKey: string; -}; - -function getSearchItems( - searchPlaces: string[], - searchPaths: string[], -): SearchItem[] { - return searchPaths.reduce((acc, searchPath) => { - searchPlaces.forEach(sp => - acc.push({ - searchPlace: sp, - filepath: path.join(searchPath, sp), - loaderKey: path.extname(sp) || 'noExt', - }), - ); - - return acc; - }, []); -} - function validateFilePath(filepath: string): void { if (!filepath) throw new Error('load must pass a non-empty string'); } @@ -176,10 +144,25 @@ function validateLoader(loader: Loader, ext: string): void | never { throw new Error('loader is not a function'); } +type ClearCaches = { + clearLoadCache: () => void; + clearSearchCache: () => void; + clearCaches: () => void; +}; + +const makeEmplace = + >( + enableCache: boolean, + ) => + (c: Map, filepath: string, res: T): T => { + if (enableCache) c.set(filepath, res); + return res; + }; + type AsyncSearcher = { search(searchFrom?: string): Promise; load(filepath: string): Promise; -}; +} & ClearCaches; export function lilconfig( name: string, @@ -192,64 +175,92 @@ export function lilconfig( searchPlaces, stopDir, transform, + cache, } = getOptions(name, options); + type R = LilconfigResult | Promise; + const searchCache = new Map(); + const loadCache = new Map(); + const emplace = makeEmplace(cache); return { async search(searchFrom = process.cwd()): Promise { - const searchPaths = getSearchPaths(searchFrom, stopDir); - const result: LilconfigResult = { config: null, filepath: '', }; - const searchItems = getSearchItems(searchPlaces, searchPaths); - for (const {searchPlace, filepath, loaderKey} of searchItems) { - try { - await fs.promises.access(filepath); - } catch { - continue; - } - const content = String(await fsReadFileAsync(filepath)); - const loader = loaders[loaderKey]; - - // handle package.json - if (searchPlace === 'package.json') { - const pkg = await loader(filepath, content); - const maybeConfig = getPackageProp(packageProp, pkg); - if (maybeConfig != null) { - result.config = maybeConfig; - result.filepath = filepath; - break; + const visited: Set = new Set(); + let dir = searchFrom; + dirLoop: while (true) { + if (cache) { + const r = searchCache.get(dir); + if (r !== undefined) { + for (const p of visited) searchCache.set(p, r); + return r; } - - continue; + visited.add(dir); } - // handle other type of configs - const isEmpty = content.trim() === ''; - if (isEmpty && ignoreEmptySearchPlaces) continue; + for (const searchPlace of searchPlaces) { + const filepath = path.join(dir, searchPlace); + try { + await fs.promises.access(filepath); + } catch { + continue; + } + const content = String(await fsReadFileAsync(filepath)); + const loaderKey = path.extname(searchPlace) || 'noExt'; + const loader = loaders[loaderKey]; + + // handle package.json + if (searchPlace === 'package.json') { + const pkg = await loader(filepath, content); + const maybeConfig = getPackageProp(packageProp, pkg); + if (maybeConfig != null) { + result.config = maybeConfig; + result.filepath = filepath; + break dirLoop; + } + + continue; + } + + // handle other type of configs + const isEmpty = content.trim() === ''; + if (isEmpty && ignoreEmptySearchPlaces) continue; - if (isEmpty) { - result.isEmpty = true; - result.config = undefined; - } else { - validateLoader(loader, loaderKey); - result.config = await loader(filepath, content); + if (isEmpty) { + result.isEmpty = true; + result.config = undefined; + } else { + validateLoader(loader, loaderKey); + result.config = await loader(filepath, content); + } + result.filepath = filepath; + break dirLoop; } - result.filepath = filepath; - break; + if (dir === stopDir || dir === parentDir(dir)) break dirLoop; + dir = parentDir(dir); } - // not found - if (result.filepath === '' && result.config === null) - return transform(null); + const transformed = + // not found + result.filepath === '' && result.config === null + ? transform(null) + : transform(result); + + if (cache) { + for (const p of visited) searchCache.set(p, transformed); + } - return transform(result); + return transformed; }, async load(filepath: string): Promise { validateFilePath(filepath); const absPath = path.resolve(process.cwd(), filepath); + if (cache && loadCache.has(absPath)) { + return loadCache.get(absPath) as LilconfigResult; + } const {base, ext} = path.parse(absPath); const loaderKey = ext || 'noExt'; const loader = loaders[loaderKey]; @@ -258,10 +269,14 @@ export function lilconfig( if (base === 'package.json') { const pkg = await loader(absPath, content); - return transform({ - config: getPackageProp(packageProp, pkg), - filepath: absPath, - }); + return emplace( + loadCache, + absPath, + transform({ + config: getPackageProp(packageProp, pkg), + filepath: absPath, + }), + ); } const result: LilconfigResult = { config: null, @@ -270,28 +285,48 @@ export function lilconfig( // handle other type of configs const isEmpty = content.trim() === ''; if (isEmpty && ignoreEmptySearchPlaces) - return transform({ - config: undefined, - filepath: absPath, - isEmpty: true, - }); + return emplace( + loadCache, + absPath, + transform({ + config: undefined, + filepath: absPath, + isEmpty: true, + }), + ); // cosmiconfig returns undefined for empty files result.config = isEmpty ? undefined : await loader(absPath, content); - return transform( - isEmpty ? {...result, isEmpty, config: undefined} : result, + return emplace( + loadCache, + absPath, + transform( + isEmpty ? {...result, isEmpty, config: undefined} : result, + ), ); }, + clearLoadCache() { + if (cache) loadCache.clear(); + }, + clearSearchCache() { + if (cache) searchCache.clear(); + }, + clearCaches() { + if (cache) { + loadCache.clear(); + searchCache.clear(); + } + }, }; } type SyncSearcher = { search(searchFrom?: string): LilconfigResult; load(filepath: string): LilconfigResult; -}; +} & ClearCaches; export function lilconfigSync( name: string, @@ -304,64 +339,92 @@ export function lilconfigSync( searchPlaces, stopDir, transform, + cache, } = getOptions(name, options); + type R = LilconfigResult; + const searchCache = new Map(); + const loadCache = new Map(); + const emplace = makeEmplace(cache); return { search(searchFrom = process.cwd()): LilconfigResult { - const searchPaths = getSearchPaths(searchFrom, stopDir); - const result: LilconfigResult = { config: null, filepath: '', }; - const searchItems = getSearchItems(searchPlaces, searchPaths); - for (const {searchPlace, filepath, loaderKey} of searchItems) { - try { - fs.accessSync(filepath); - } catch { - continue; - } - const loader = loaders[loaderKey]; - const content = String(fs.readFileSync(filepath)); - - // handle package.json - if (searchPlace === 'package.json') { - const pkg = loader(filepath, content); - const maybeConfig = getPackageProp(packageProp, pkg); - if (maybeConfig != null) { - result.config = maybeConfig; - result.filepath = filepath; - break; + const visited: Set = new Set(); + let dir = searchFrom; + dirLoop: while (true) { + if (cache) { + const r = searchCache.get(dir); + if (r !== undefined) { + for (const p of visited) searchCache.set(p, r); + return r; } - - continue; + visited.add(dir); } - // handle other type of configs - const isEmpty = content.trim() === ''; - if (isEmpty && ignoreEmptySearchPlaces) continue; + for (const searchPlace of searchPlaces) { + const filepath = path.join(dir, searchPlace); + try { + fs.accessSync(filepath); + } catch { + continue; + } + const loaderKey = path.extname(searchPlace) || 'noExt'; + const loader = loaders[loaderKey]; + const content = String(fs.readFileSync(filepath)); + + // handle package.json + if (searchPlace === 'package.json') { + const pkg = loader(filepath, content); + const maybeConfig = getPackageProp(packageProp, pkg); + if (maybeConfig != null) { + result.config = maybeConfig; + result.filepath = filepath; + break dirLoop; + } + + continue; + } + + // handle other type of configs + const isEmpty = content.trim() === ''; + if (isEmpty && ignoreEmptySearchPlaces) continue; - if (isEmpty) { - result.isEmpty = true; - result.config = undefined; - } else { - validateLoader(loader, loaderKey); - result.config = loader(filepath, content); + if (isEmpty) { + result.isEmpty = true; + result.config = undefined; + } else { + validateLoader(loader, loaderKey); + result.config = loader(filepath, content); + } + result.filepath = filepath; + break dirLoop; } - result.filepath = filepath; - break; + if (dir === stopDir || dir === parentDir(dir)) break dirLoop; + dir = parentDir(dir); } - // not found - if (result.filepath === '' && result.config === null) - return transform(null); + const transformed = + // not found + result.filepath === '' && result.config === null + ? transform(null) + : transform(result); + + if (cache) { + for (const p of visited) searchCache.set(p, transformed); + } - return transform(result); + return transformed; }, load(filepath: string): LilconfigResult { validateFilePath(filepath); const absPath = path.resolve(process.cwd(), filepath); + if (cache && loadCache.has(absPath)) { + return loadCache.get(absPath) as LilconfigResult; + } const {base, ext} = path.parse(absPath); const loaderKey = ext || 'noExt'; const loader = loaders[loaderKey]; @@ -383,18 +446,38 @@ export function lilconfigSync( // handle other type of configs const isEmpty = content.trim() === ''; if (isEmpty && ignoreEmptySearchPlaces) - return transform({ - filepath: absPath, - config: undefined, - isEmpty: true, - }); + return emplace( + loadCache, + absPath, + transform({ + filepath: absPath, + config: undefined, + isEmpty: true, + }), + ); // cosmiconfig returns undefined for empty files result.config = isEmpty ? undefined : loader(absPath, content); - return transform( - isEmpty ? {...result, isEmpty, config: undefined} : result, + return emplace( + loadCache, + absPath, + transform( + isEmpty ? {...result, isEmpty, config: undefined} : result, + ), ); }, + clearLoadCache() { + if (cache) loadCache.clear(); + }, + clearSearchCache() { + if (cache) searchCache.clear(); + }, + clearCaches() { + if (cache) { + loadCache.clear(); + searchCache.clear(); + } + }, }; } diff --git a/src/spec/index.spec.ts b/src/spec/index.spec.ts index cffbeb4..7373552 100644 --- a/src/spec/index.spec.ts +++ b/src/spec/index.spec.ts @@ -14,13 +14,11 @@ jest.mock('fs', () => { ...fs, promises: { ...fs.promises, - access: jest.fn((pth: string) => { - return fs.promises.access(pth); - }), + readFile: jest.fn(fs.promises.readFile), + access: jest.fn(fs.promises.access), }, - accessSync: jest.fn((...args: Parameters) => { - return fs.accessSync(...args); - }), + accessSync: jest.fn(fs.accessSync), + readFileSync: jest.fn(fs.readFileSync), }; }); @@ -80,7 +78,10 @@ describe('options', () => { describe('async loaders', () => { const config = {data: 42}; const options = { - loaders: {'.js': async () => config}, + loaders: { + '.js': async () => config, + noExt: (_: string, content: string) => content, + }, }; it('async load', async () => { @@ -111,6 +112,54 @@ describe('options', () => { expect(result).toEqual({config, filepath}); expect(ccResult).toEqual({config, filepath}); }); + + it('async noExt', async () => { + const searchPath = path.join(__dirname, 'search'); + const filepath = path.join(searchPath, 'noExtension'); + const opts = { + ...options, + searchPlaces: ['noExtension'], + }; + + const result = await lilconfig('noExtension', opts).search( + searchPath, + ); + const ccResult = await cosmiconfig('noExtension', opts).search( + searchPath, + ); + + const expected = { + filepath, + config: 'this file has no extension\n', + }; + + expect(result).toEqual(expected); + expect(ccResult).toEqual(expected); + }); + + it('sync noExt', () => { + const searchPath = path.join(__dirname, 'search'); + const filepath = path.join(searchPath, 'noExtension'); + const opts = { + ...options, + searchPlaces: ['noExtension'], + }; + + const result = lilconfigSync('noExtension', opts).search( + searchPath, + ); + const ccResult = cosmiconfigSync('noExtension', opts).search( + searchPath, + ); + + const expected = { + filepath, + config: 'this file has no extension\n', + }; + + expect(result).toEqual(expected); + expect(ccResult).toEqual(expected); + }); }); }); @@ -139,7 +188,9 @@ describe('options', () => { }; it('sync', () => { - const result = lilconfigSync('test-app', options).load(relativeFilepath); + const result = lilconfigSync('test-app', options).load( + relativeFilepath, + ); const ccResult = cosmiconfigSync('test-app', options).load( relativeFilepath, ); @@ -148,7 +199,9 @@ describe('options', () => { expect(ccResult).toEqual(expected); }); it('async', async () => { - const result = await lilconfig('test-app', options).load(relativeFilepath); + const result = await lilconfig('test-app', options).load( + relativeFilepath, + ); const ccResult = await cosmiconfig('test-app', options).load( relativeFilepath, ); @@ -166,7 +219,8 @@ describe('options', () => { describe('ignores by default', () => { it('sync', () => { const result = lilconfigSync('test-app').load(relativeFilepath); - const ccResult = cosmiconfigSync('test-app').load(relativeFilepath); + const ccResult = + cosmiconfigSync('test-app').load(relativeFilepath); const expected = { config: undefined, @@ -179,8 +233,12 @@ describe('options', () => { }); it('async', async () => { - const result = await lilconfig('test-app').load(relativeFilepath); - const ccResult = await cosmiconfig('test-app').load(relativeFilepath); + const result = await lilconfig('test-app').load( + relativeFilepath, + ); + const ccResult = await cosmiconfig('test-app').load( + relativeFilepath, + ); const expected = { config: undefined, @@ -325,6 +383,384 @@ describe('options', () => { expect(ccResult).toEqual(expected); }); + describe('cache', () => { + // running all checks in one to avoid resetting cache for fs.promises.access + describe('enabled(default)', () => { + it('async search()', async () => { + const stopDir = path.join(__dirname, 'search'); + const searchFrom = path.join(stopDir, 'a', 'b', 'c'); + const searchPlaces = ['cached.config.js', 'package.json']; + const searcher = lilconfig('cached', { + cache: true, + stopDir, + searchPlaces, + }); + const fsLookUps = () => + (fs.promises.access as jest.Mock).mock.calls.length; + + expect(fsLookUps()).toBe(0); + + // per one search + // for unexisting + // (search + a + b + c) * times searchPlaces + + // for existing + // (search + a + b + c) * (times searchPlaces - **first** matched) + const expectedFsLookUps = 7; + + // initial search populates cache + const result = await searcher.search(searchFrom); + + expect(fsLookUps()).toBe(expectedFsLookUps); + + // subsequant search reads from cache + const result2 = await searcher.search(searchFrom); + expect(fsLookUps()).toBe(expectedFsLookUps); + expect(result).toEqual(result2); + + // searching a subpath reuses cache + const result3 = await searcher.search(path.join(stopDir, 'a')); + const result4 = await searcher.search( + path.join(stopDir, 'a', 'b'), + ); + expect(fsLookUps()).toBe(expectedFsLookUps); + expect(result2).toEqual(result3); + expect(result3).toEqual(result4); + + // calling clearCaches empties search cache + searcher.clearCaches(); + + // emptied all caches, should perform new lookups + const result5 = await searcher.search(searchFrom); + expect(fsLookUps()).toBe(expectedFsLookUps * 2); + expect(result4).toEqual(result5); + // different references + expect(result4 === result5).toEqual(false); + + searcher.clearSearchCache(); + const result6 = await searcher.search(searchFrom); + expect(fsLookUps()).toBe(expectedFsLookUps * 3); + expect(result5).toEqual(result6); + // different references + expect(result5 === result6).toEqual(false); + + // clearLoadCache does not clear search cache + searcher.clearLoadCache(); + const result7 = await searcher.search(searchFrom); + expect(fsLookUps()).toBe(expectedFsLookUps * 3); + expect(result6).toEqual(result7); + // same references + expect(result6 === result7).toEqual(true); + + // searching a superset path will access fs until it hits a known path + const result8 = await searcher.search( + path.join(searchFrom, 'd'), + ); + expect(fsLookUps()).toBe(3 * expectedFsLookUps + 2); + expect(result7).toEqual(result8); + // same references + expect(result7 === result8).toEqual(true); + + // repeated searches do not cause extra fs calls + const result9 = await searcher.search( + path.join(searchFrom, 'd'), + ); + expect(fsLookUps()).toBe(3 * expectedFsLookUps + 2); + expect(result8).toEqual(result9); + // same references + expect(result8 === result9).toEqual(true); + }); + + it('sync search()', () => { + const stopDir = path.join(__dirname, 'search'); + const searchFrom = path.join(stopDir, 'a', 'b', 'c'); + const searchPlaces = ['cached.config.js', 'package.json']; + const searcher = lilconfigSync('cached', { + cache: true, + stopDir, + searchPlaces, + }); + const fsLookUps = () => + (fs.accessSync as jest.Mock).mock.calls.length; + + expect(fsLookUps()).toBe(0); + + // per one search + // for unexisting + // (search + a + b + c) * times searchPlaces + + // for existing + // (search + a + b + c) * (times searchPlaces - **first** matched) + const expectedFsLookUps = 7; + + // initial search populates cache + const result = searcher.search(searchFrom); + + expect(fsLookUps()).toBe(expectedFsLookUps); + + // subsequant search reads from cache + const result2 = searcher.search(searchFrom); + expect(fsLookUps()).toBe(expectedFsLookUps); + expect(result).toEqual(result2); + + // searching a subpath reuses cache + const result3 = searcher.search(path.join(stopDir, 'a')); + const result4 = searcher.search(path.join(stopDir, 'a', 'b')); + expect(fsLookUps()).toBe(expectedFsLookUps); + expect(result2).toEqual(result3); + expect(result3).toEqual(result4); + + // calling clearCaches empties search cache + searcher.clearCaches(); + + // emptied all caches, should perform new lookups + const result5 = searcher.search(searchFrom); + expect(fsLookUps()).toBe(expectedFsLookUps * 2); + expect(result4).toEqual(result5); + // different references + expect(result4 === result5).toEqual(false); + + searcher.clearSearchCache(); + const result6 = searcher.search(searchFrom); + expect(fsLookUps()).toBe(expectedFsLookUps * 3); + expect(result5).toEqual(result6); + // different references + expect(result5 === result6).toEqual(false); + + // clearLoadCache does not clear search cache + searcher.clearLoadCache(); + const result7 = searcher.search(searchFrom); + expect(fsLookUps()).toBe(expectedFsLookUps * 3); + expect(result6).toEqual(result7); + // same references + expect(result6 === result7).toEqual(true); + + // searching a superset path will access fs until it hits a known path + const result8 = searcher.search(path.join(searchFrom, 'd')); + expect(fsLookUps()).toBe(3 * expectedFsLookUps + 2); + expect(result7).toEqual(result8); + // same references + expect(result7 === result8).toEqual(true); + + // repeated searches do not cause extra fs calls + const result9 = searcher.search(path.join(searchFrom, 'd')); + expect(fsLookUps()).toBe(3 * expectedFsLookUps + 2); + expect(result8).toEqual(result9); + // same references + expect(result8 === result9).toEqual(true); + }); + + it('async load()', async () => { + const stopDir = path.join(__dirname, 'search'); + const searchPlaces = ['cached.config.js', 'package.json']; + const searcher = lilconfig('cached', { + cache: true, + stopDir, + searchPlaces, + }); + const existingFile = path.join(stopDir, 'cached.config.js'); + const fsReadFileCalls = () => + (fs.promises.readFile as jest.Mock).mock.calls.length; + + expect(fsReadFileCalls()).toBe(0); + + // initial search populates cache + const result = await searcher.load(existingFile); + expect(fsReadFileCalls()).toBe(1); + + // subsequant load reads from cache + const result2 = await searcher.load(existingFile); + expect(fsReadFileCalls()).toBe(1); + expect(result).toEqual(result2); + // same reference + expect(result === result2).toEqual(true); + + // calling clearCaches empties search cache + searcher.clearCaches(); + const result3 = await searcher.load(existingFile); + expect(fsReadFileCalls()).toBe(2); + expect(result2).toEqual(result3); + // different reference + expect(result2 === result3).toEqual(false); + + searcher.clearLoadCache(); + const result4 = await searcher.load(existingFile); + expect(fsReadFileCalls()).toBe(3); + expect(result3).toEqual(result4); + // different reference + expect(result3 === result4).toEqual(false); + + // clearLoadCache does not clear search cache + searcher.clearSearchCache(); + const result5 = await searcher.load(existingFile); + expect(fsReadFileCalls()).toBe(3); + expect(result4).toEqual(result5); + // same reference + expect(result4 === result5).toEqual(true); + }); + + it('sync load()', () => { + const stopDir = path.join(__dirname, 'search'); + const searchPlaces = ['cached.config.js', 'package.json']; + const searcher = lilconfigSync('cached', { + cache: true, + stopDir, + searchPlaces, + }); + const existingFile = path.join(stopDir, 'cached.config.js'); + const fsReadFileCalls = () => + (fs.readFileSync as jest.Mock).mock.calls.length; + + expect(fsReadFileCalls()).toBe(0); + + // initial search populates cache + const result = searcher.load(existingFile); + expect(fsReadFileCalls()).toBe(1); + + // subsequant load reads from cache + const result2 = searcher.load(existingFile); + expect(fsReadFileCalls()).toBe(1); + expect(result).toEqual(result2); + // same reference + expect(result === result2).toEqual(true); + + // calling clearCaches empties search cache + searcher.clearCaches(); + const result3 = searcher.load(existingFile); + expect(fsReadFileCalls()).toBe(2); + expect(result2).toEqual(result3); + // different reference + expect(result2 === result3).toEqual(false); + + searcher.clearLoadCache(); + const result4 = searcher.load(existingFile); + expect(fsReadFileCalls()).toBe(3); + expect(result3).toEqual(result4); + // different reference + expect(result3 === result4).toEqual(false); + + // clearLoadCache does not clear search cache + searcher.clearSearchCache(); + const result5 = searcher.load(existingFile); + expect(fsReadFileCalls()).toBe(3); + expect(result4).toEqual(result5); + // same reference + expect(result4 === result5).toEqual(true); + }); + }); + describe('disabled', () => { + it('async search()', async () => { + const stopDir = path.join(__dirname, 'search'); + const searchFrom = path.join(stopDir, 'a', 'b', 'c'); + const searchPlaces = ['cached.config.js', 'package.json']; + const searcher = lilconfig('cached', { + cache: false, + stopDir, + searchPlaces, + }); + const fsLookUps = () => + (fs.promises.access as jest.Mock).mock.calls.length; + + expect(fsLookUps()).toBe(0); + + const expectedFsLookUps = 7; + + // initial search populates cache + const result = await searcher.search(searchFrom); + + expect(fsLookUps()).toBe(expectedFsLookUps); + + // subsequant search reads from cache + const result2 = await searcher.search(searchFrom); + expect(fsLookUps()).toBe(expectedFsLookUps * 2); + expect(result).toEqual(result2); + + expect(result2 === result).toBe(false); + }); + + it('sync search()', () => { + const stopDir = path.join(__dirname, 'search'); + const searchFrom = path.join(stopDir, 'a', 'b', 'c'); + const searchPlaces = ['cached.config.js', 'package.json']; + const searcher = lilconfigSync('cached', { + cache: false, + stopDir, + searchPlaces, + }); + const fsLookUps = () => + (fs.accessSync as jest.Mock).mock.calls.length; + + expect(fsLookUps()).toBe(0); + + const expectedFsLookUps = 7; + + // initial search populates cache + const result = searcher.search(searchFrom); + + expect(fsLookUps()).toBe(expectedFsLookUps); + + // subsequent search reads from cache + const result2 = searcher.search(searchFrom); + expect(fsLookUps()).toBe(expectedFsLookUps * 2); + expect(result).toEqual(result2); + + expect(result2 === result).toBe(false); + }); + + it('async load()', async () => { + const stopDir = path.join(__dirname, 'search'); + const searchPlaces = ['cached.config.js', 'package.json']; + const searcher = lilconfig('cached', { + cache: false, + stopDir, + searchPlaces, + }); + const existingFile = path.join(stopDir, 'cached.config.js'); + const fsReadFileCalls = () => + (fs.promises.readFile as jest.Mock).mock.calls.length; + + expect(fsReadFileCalls()).toBe(0); + + // initial search populates cache + const result = await searcher.load(existingFile); + expect(fsReadFileCalls()).toBe(1); + + // subsequant load reads from cache + const result2 = await searcher.load(existingFile); + expect(fsReadFileCalls()).toBe(2); + expect(result).toEqual(result2); + // different reference + expect(result === result2).toEqual(false); + }); + + it('sync load()', () => { + const stopDir = path.join(__dirname, 'search'); + const searchPlaces = ['cached.config.js', 'package.json']; + const searcher = lilconfigSync('cached', { + cache: false, + stopDir, + searchPlaces, + }); + const existingFile = path.join(stopDir, 'cached.config.js'); + const fsReadFileCalls = () => + (fs.readFileSync as jest.Mock).mock.calls.length; + + expect(fsReadFileCalls()).toBe(0); + + // initial search populates cache + const result = searcher.load(existingFile); + expect(fsReadFileCalls()).toBe(1); + + // subsequant load reads from cache + const result2 = searcher.load(existingFile); + expect(fsReadFileCalls()).toBe(2); + expect(result).toEqual(result2); + // differnt reference + expect(result === result2).toEqual(false); + }); + }); + }); + describe('packageProp', () => { describe('plain property string', () => { const dirname = path.join(__dirname, 'load'); @@ -339,14 +775,20 @@ describe('options', () => { }; it('sync', () => { - const result = lilconfigSync('foo', options).load(relativeFilepath); - const ccResult = cosmiconfigSync('foo', options).load(relativeFilepath); + const result = lilconfigSync('foo', options).load( + relativeFilepath, + ); + const ccResult = cosmiconfigSync('foo', options).load( + relativeFilepath, + ); expect(result).toEqual(expected); expect(ccResult).toEqual(expected); }); it('async', async () => { - const result = await lilconfig('foo', options).load(relativeFilepath); + const result = await lilconfig('foo', options).load( + relativeFilepath, + ); const ccResult = await cosmiconfig('foo', options).load( relativeFilepath, ); @@ -376,14 +818,20 @@ describe('options', () => { }; it('sync', () => { - const result = lilconfigSync('foo', options).load(relativeFilepath); - const ccResult = cosmiconfigSync('foo', options).load(relativeFilepath); + const result = lilconfigSync('foo', options).load( + relativeFilepath, + ); + const ccResult = cosmiconfigSync('foo', options).load( + relativeFilepath, + ); expect(result).toEqual(expected); expect(ccResult).toEqual(expected); }); it('async', async () => { - const result = await lilconfig('foo', options).load(relativeFilepath); + const result = await lilconfig('foo', options).load( + relativeFilepath, + ); const ccResult = await cosmiconfig('foo', options).load( relativeFilepath, ); @@ -403,9 +851,10 @@ describe('options', () => { * cosmiconfig throws when there is `null` value in the chain of package prop keys */ - const expectedMessage = parseInt(process.version.slice(1), 10) > 14 - ? "Cannot read properties of null (reading 'baz')" - : "Cannot read property 'baz' of null" + const expectedMessage = + parseInt(process.version.slice(1), 10) > 14 + ? "Cannot read properties of null (reading 'baz')" + : "Cannot read property 'baz' of null"; it('sync', () => { expect(() => { @@ -534,7 +983,9 @@ describe('lilconfigSync', () => { const relativeFilepath = filepath.slice(process.cwd().length + 1); const options = {}; - const result = lilconfigSync('test-app', options).load(relativeFilepath); + const result = lilconfigSync('test-app', options).load( + relativeFilepath, + ); const ccResult = cosmiconfigSync('test-app', options).load( relativeFilepath, ); @@ -886,7 +1337,9 @@ describe('lilconfig', () => { const filepath = path.join(dirname, 'test-app.js'); const relativeFilepath = filepath.slice(process.cwd().length + 1); const result = await lilconfig('test-app').load(relativeFilepath); - const ccResult = await cosmiconfig('test-app').load(relativeFilepath); + const ccResult = await cosmiconfig('test-app').load( + relativeFilepath, + ); const expected = { config: {jsTest: true}, @@ -901,7 +1354,9 @@ describe('lilconfig', () => { const filepath = path.join(dirname, 'test-app.cjs'); const relativeFilepath = filepath.slice(process.cwd().length + 1); const result = await lilconfig('test-app').load(relativeFilepath); - const ccResult = await cosmiconfig('test-app').load(relativeFilepath); + const ccResult = await cosmiconfig('test-app').load( + relativeFilepath, + ); const expected = { config: {jsTest: true}, @@ -916,7 +1371,9 @@ describe('lilconfig', () => { const filepath = path.join(dirname, 'test-app.json'); const relativeFilepath = filepath.slice(process.cwd().length + 1); const result = await lilconfig('test-app').load(relativeFilepath); - const ccResult = await cosmiconfig('test-app').load(relativeFilepath); + const ccResult = await cosmiconfig('test-app').load( + relativeFilepath, + ); const expected = { config: {jsonTest: true}, @@ -932,7 +1389,9 @@ describe('lilconfig', () => { const relativeFilepath = filepath.slice(process.cwd().length + 1); const result = await lilconfig('test-app').load(relativeFilepath); - const ccResult = await cosmiconfig('test-app').load(relativeFilepath); + const ccResult = await cosmiconfig('test-app').load( + relativeFilepath, + ); const expected = { config: {noExtJsonFile: true}, @@ -947,7 +1406,9 @@ describe('lilconfig', () => { const filepath = path.join(dirname, 'package.json'); const relativeFilepath = filepath.slice(process.cwd().length + 1); const options = {}; - const result = await lilconfig('test-app', options).load(relativeFilepath); + const result = await lilconfig('test-app', options).load( + relativeFilepath, + ); const ccResult = await cosmiconfig('test-app', options).load( relativeFilepath, ); @@ -1126,13 +1587,13 @@ describe('lilconfig', () => { const errMsg = `ENOENT: no such file or directory, open '${filepath}'`; - expect(lilconfig('test-app').load(relativeFilepath)).rejects.toThrowError( - errMsg, - ); + expect( + lilconfig('test-app').load(relativeFilepath), + ).rejects.toThrowError(errMsg); - expect(cosmiconfig('test-app').load(relativeFilepath)).rejects.toThrowError( - errMsg, - ); + expect( + cosmiconfig('test-app').load(relativeFilepath), + ).rejects.toThrowError(errMsg); }); it('throws for invalid json', async () => { @@ -1143,13 +1604,13 @@ describe('lilconfig', () => { /** * throws but less elegant */ - expect(lilconfig('test-app').load(relativeFilepath)).rejects.toThrowError( - "Unexpected token / in JSON at position 22", - ); + expect( + lilconfig('test-app').load(relativeFilepath), + ).rejects.toThrowError('Unexpected token / in JSON at position 22'); - expect(cosmiconfig('test-app').load(relativeFilepath)).rejects.toThrowError( - `JSON Error in ${filepath}:`, - ); + expect( + cosmiconfig('test-app').load(relativeFilepath), + ).rejects.toThrowError(`JSON Error in ${filepath}:`); }); it('throws for provided filepath that does not exist', async () => { @@ -1172,12 +1633,12 @@ describe('lilconfig', () => { const errMsg = 'No loader specified for extension ".coffee"'; - expect(lilconfig('test-app').load(relativeFilepath)).rejects.toThrowError( - errMsg, - ); - expect(cosmiconfig('test-app').load(relativeFilepath)).rejects.toThrowError( - errMsg, - ); + expect( + lilconfig('test-app').load(relativeFilepath), + ).rejects.toThrowError(errMsg); + expect( + cosmiconfig('test-app').load(relativeFilepath), + ).rejects.toThrowError(errMsg); }); it('loader is not a function', async () => { @@ -1215,12 +1676,12 @@ describe('lilconfig', () => { ); const relativeFilepath = filepath.slice(process.cwd().length + 1); - await expect(lilconfig('test-app').load(relativeFilepath)).rejects.toThrowError( - 'Unexpected token # in JSON at position 2', - ); - await expect(cosmiconfig('test-app').load(relativeFilepath)).rejects.toThrowError( - `YAML Error in ${filepath}`, - ); + await expect( + lilconfig('test-app').load(relativeFilepath), + ).rejects.toThrowError('Unexpected token # in JSON at position 2'); + await expect( + cosmiconfig('test-app').load(relativeFilepath), + ).rejects.toThrowError(`YAML Error in ${filepath}`); }); it('throws for empty strings passed to load', async () => { @@ -1309,18 +1770,12 @@ describe('npm package api', () => { const lcExplorerKeys = Object.keys(lc.lilconfig('foo')); const ccExplorerKeys = Object.keys(cc.cosmiconfig('foo')); - expect( - lcExplorerKeys.every((k: string) => ccExplorerKeys.includes(k)), - ).toBe(true); + expect(lcExplorerKeys).toEqual(ccExplorerKeys); const lcExplorerSyncKeys = Object.keys(lc.lilconfigSync('foo')); const ccExplorerSyncKeys = Object.keys(cc.cosmiconfigSync('foo')); - expect( - lcExplorerSyncKeys.every((k: string) => - ccExplorerSyncKeys.includes(k), - ), - ).toBe(true); + expect(lcExplorerSyncKeys).toEqual(ccExplorerSyncKeys); /* eslint-disable @typescript-eslint/no-unused-vars */ const omitKnownDifferKeys = ({ diff --git a/src/spec/search/cached.config.js b/src/spec/search/cached.config.js new file mode 100644 index 0000000..b68df14 --- /dev/null +++ b/src/spec/search/cached.config.js @@ -0,0 +1,3 @@ +module.exports = { + iWasCached: true, +}; diff --git a/src/spec/search/noExtension b/src/spec/search/noExtension new file mode 100644 index 0000000..f952269 --- /dev/null +++ b/src/spec/search/noExtension @@ -0,0 +1 @@ +this file has no extension