├── src ├── spec │ ├── load │ │ ├── test-empty.js │ │ ├── test-noExt-yaml │ │ ├── test-app.json │ │ ├── test-noExt-nonParsable │ │ ├── test-app.mjs │ │ ├── test-noExt-json │ │ ├── test-app.cjs │ │ ├── test-app.js │ │ ├── test-app.ts │ │ ├── config.coffee │ │ ├── test-invalid.json │ │ └── package.json │ ├── search │ │ ├── a │ │ │ ├── b │ │ │ │ ├── maybeEmpty.config.js │ │ │ │ ├── package.json │ │ │ │ └── searchPlaces.conf.js │ │ │ ├── maybeEmpty.config.js │ │ │ └── package.json │ │ ├── noExtension │ │ ├── .config │ │ │ └── hiddenrc.json │ │ ├── cached.config.js │ │ └── test-app.config.js │ ├── esm-project │ │ ├── esm.config.js │ │ ├── esm.config.cjs │ │ ├── esm.config.mjs │ │ ├── cjs.config.mjs │ │ └── package.json │ ├── cjs-project │ │ ├── cjs.config.cjs │ │ ├── cjs.config.js │ │ ├── cjs.config.mjs │ │ └── package.json │ ├── old-node-tests.uvu.js │ └── index.spec.js ├── index.d.ts └── index.js ├── .gitignore ├── jest.config.js ├── .github └── workflows │ ├── coverage.yaml │ └── nodejs.yaml ├── tsconfig.json ├── biome.json ├── LICENSE ├── package.json └── readme.md /src/spec/load/test-empty.js: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/spec/search/a/b/maybeEmpty.config.js: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/spec/load/test-noExt-yaml: -------------------------------------------------------------------------------- 1 | noExtYamlFile: true 2 | -------------------------------------------------------------------------------- /src/spec/search/noExtension: -------------------------------------------------------------------------------- 1 | this file has no extension 2 | -------------------------------------------------------------------------------- /src/spec/load/test-app.json: -------------------------------------------------------------------------------- 1 | { 2 | "jsonTest": true 3 | } 4 | -------------------------------------------------------------------------------- /src/spec/search/a/b/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "bar": null 3 | } 4 | -------------------------------------------------------------------------------- /src/spec/load/test-noExt-nonParsable: -------------------------------------------------------------------------------- 1 | hobbies: 2 | - "Reading 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | coverage 4 | .DS_Store 5 | .vscode -------------------------------------------------------------------------------- /src/spec/load/test-app.mjs: -------------------------------------------------------------------------------- 1 | export default { 2 | jsTest: true, 3 | }; 4 | -------------------------------------------------------------------------------- /src/spec/load/test-noExt-json: -------------------------------------------------------------------------------- 1 | { 2 | "noExtJsonFile": true 3 | } 4 | -------------------------------------------------------------------------------- /src/spec/search/.config/hiddenrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "hidden": true 3 | } 4 | -------------------------------------------------------------------------------- /src/spec/esm-project/esm.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | esm: true, 3 | }; 4 | -------------------------------------------------------------------------------- /src/spec/load/test-app.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | jsTest: true, 3 | }; 4 | -------------------------------------------------------------------------------- /src/spec/load/test-app.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | jsTest: true, 3 | }; 4 | -------------------------------------------------------------------------------- /src/spec/load/test-app.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | typescript: true, 3 | }; 4 | -------------------------------------------------------------------------------- /src/spec/cjs-project/cjs.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | cjs: true, 3 | }; 4 | -------------------------------------------------------------------------------- /src/spec/cjs-project/cjs.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | cjs: true, 3 | }; 4 | -------------------------------------------------------------------------------- /src/spec/cjs-project/cjs.config.mjs: -------------------------------------------------------------------------------- 1 | export default { 2 | esm: true, 3 | }; 4 | -------------------------------------------------------------------------------- /src/spec/esm-project/esm.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | cjs: true, 3 | }; 4 | -------------------------------------------------------------------------------- /src/spec/esm-project/esm.config.mjs: -------------------------------------------------------------------------------- 1 | export default { 2 | esm: true, 3 | }; 4 | -------------------------------------------------------------------------------- /src/spec/load/config.coffee: -------------------------------------------------------------------------------- 1 | module.exports = 2 | foo: true 3 | bar: false 4 | -------------------------------------------------------------------------------- /src/spec/search/cached.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | iWasCached: true, 3 | }; 4 | -------------------------------------------------------------------------------- /src/spec/search/test-app.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | stopped: true, 3 | }; 4 | -------------------------------------------------------------------------------- /src/spec/load/test-invalid.json: -------------------------------------------------------------------------------- 1 | { 2 | "invalid": true // cannot comment in json 3 | } 4 | -------------------------------------------------------------------------------- /src/spec/search/a/maybeEmpty.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | notSoEmpty: true, 3 | }; 4 | -------------------------------------------------------------------------------- /src/spec/esm-project/cjs.config.mjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | iShouldBeESMbutIAmCJS: true, 3 | }; 4 | -------------------------------------------------------------------------------- /src/spec/search/a/b/searchPlaces.conf.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | searchPlacesWorks: true, 3 | }; 4 | -------------------------------------------------------------------------------- /src/spec/load/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "testing-purposes-only", 3 | "test-app": { 4 | "customThingHere": "is-configured" 5 | }, 6 | "foo": { 7 | "insideFoo": true 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/spec/cjs-project/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cjs-project", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "keywords": [], 10 | "author": "", 11 | "license": "ISC" 12 | } 13 | -------------------------------------------------------------------------------- /src/spec/esm-project/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "esm-package", 3 | "type": "module", 4 | "version": "1.0.0", 5 | "description": "", 6 | "main": "index.js", 7 | "scripts": { 8 | "test": "echo \"Error: no test specified\" && exit 1" 9 | }, 10 | "author": "", 11 | "license": "ISC" 12 | } 13 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | roots: ['/src'], 3 | moduleNameMapper: { 4 | '^@/(.*)$': '/src/$1', 5 | }, 6 | collectCoverageFrom: ['./src/index.js'], 7 | coverageThreshold: { 8 | global: { 9 | branches: 92, 10 | functions: 99, 11 | lines: 99, 12 | statements: 99, 13 | }, 14 | }, 15 | }; 16 | -------------------------------------------------------------------------------- /src/spec/search/a/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "testing-purposes-only", 3 | "test-app": { 4 | "customThingHere": "is-configured" 5 | }, 6 | "foo": { 7 | "insideFoo": true 8 | }, 9 | "bar": { 10 | "baz": { 11 | "insideBarBaz": true 12 | } 13 | }, 14 | "zoo": { 15 | "foo": { 16 | "insideZooFoo": true 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /.github/workflows/coverage.yaml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | coveralls: 10 | runs-on: ubuntu-latest 11 | strategy: 12 | matrix: 13 | node-version: [22.x] 14 | steps: 15 | - uses: actions/checkout@v4 16 | - name: Use Node.js ${{ matrix.node-version }} 17 | uses: actions/setup-node@v4 18 | with: 19 | node-version: ${{ matrix.node-version }} 20 | - name: install dependencies 21 | run: npm ci 22 | - name: tests 23 | run: npm run test 24 | - name: Coveralls GitHub Action 25 | uses: coverallsapp/github-action@v2 26 | with: 27 | github-token: ${{ secrets.GITHUB_TOKEN }} 28 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowJs": true, 4 | "checkJs": true, 5 | "noEmit": true, 6 | "alwaysStrict": true, 7 | "esModuleInterop": false, 8 | "lib": ["es2018"], 9 | "noFallthroughCasesInSwitch": true, 10 | "noUnusedParameters": true, 11 | "strict": true, 12 | "skipLibCheck": true, 13 | "forceConsistentCasingInFileNames": true, 14 | "declaration": false, 15 | "removeComments": true, 16 | 17 | "target": "es2018", 18 | "module": "commonjs", 19 | "moduleResolution": "node", 20 | "rootDir": "./src", 21 | "outDir": "./dist", 22 | "baseUrl": "./src" 23 | }, 24 | "include": ["src/**/*.ts", "src/**/*.js"], 25 | "exclude": ["src/**/spec", "./package.json"] 26 | } 27 | -------------------------------------------------------------------------------- /biome.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://biomejs.dev/schemas/1.6.0/schema.json", 3 | "organizeImports": { 4 | "enabled": false 5 | }, 6 | "linter": { 7 | "enabled": true, 8 | "ignore": [ 9 | "src/spec/load/test-invalid.json" 10 | ], 11 | "rules": { 12 | "recommended": true, 13 | "complexity": { 14 | "noUselessLabel": "off", 15 | "noForEach": "off" 16 | }, 17 | "style": { 18 | "useNodejsImportProtocol": "off", 19 | "useNumberNamespace": "off" 20 | }, 21 | "suspicious": { 22 | "noExplicitAny": "off" 23 | } 24 | } 25 | }, 26 | "formatter": { 27 | "ignore": [ 28 | "src/spec/load/test-invalid.json" 29 | ] 30 | }, 31 | "javascript": { 32 | "formatter": { 33 | "indentStyle": "tab", 34 | "quoteStyle": "single", 35 | "arrowParentheses": "asNeeded", 36 | "bracketSpacing": false 37 | } 38 | }, 39 | "json": { 40 | "formatter": { 41 | "indentStyle": "space", 42 | "indentWidth": 2 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Anton Kastritskiy 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "lilconfig", 3 | "version": "3.1.3", 4 | "description": "A zero-dependency alternative to cosmiconfig", 5 | "main": "src/index.js", 6 | "types": "src/index.d.ts", 7 | "scripts": { 8 | "test": "NODE_OPTIONS=--experimental-vm-modules ./node_modules/.bin/jest --coverage", 9 | "lint": "biome ci ./src", 10 | "types": "tsc" 11 | }, 12 | "keywords": [ 13 | "cosmiconfig", 14 | "config", 15 | "configuration", 16 | "search" 17 | ], 18 | "files": [ 19 | "src/index.*" 20 | ], 21 | "repository": { 22 | "type": "git", 23 | "url": "https://github.com/antonk52/lilconfig" 24 | }, 25 | "bugs": "https://github.com/antonk52/lilconfig/issues", 26 | "author": "antonk52", 27 | "license": "MIT", 28 | "devDependencies": { 29 | "@biomejs/biome": "^1.6.0", 30 | "@types/jest": "^29.5.12", 31 | "@types/node": "^14.18.63", 32 | "@types/webpack-env": "^1.18.5", 33 | "cosmiconfig": "^8.3.6", 34 | "jest": "^29.7.0", 35 | "typescript": "^5.3.3", 36 | "uvu": "^0.5.6" 37 | }, 38 | "funding": "https://github.com/sponsors/antonk52", 39 | "engines": { 40 | "node": ">=14" 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/index.d.ts: -------------------------------------------------------------------------------- 1 | export type LilconfigResult = null | { 2 | filepath: string; 3 | config: any; 4 | isEmpty?: boolean; 5 | }; 6 | interface OptionsBase { 7 | cache?: boolean; 8 | stopDir?: string; 9 | searchPlaces?: string[]; 10 | ignoreEmptySearchPlaces?: boolean; 11 | packageProp?: string | string[]; 12 | } 13 | export type Transform = 14 | | TransformSync 15 | | ((result: LilconfigResult) => Promise); 16 | export type TransformSync = (result: LilconfigResult) => LilconfigResult; 17 | type LoaderResult = any; 18 | export type LoaderSync = (filepath: string, content: string) => LoaderResult; 19 | export type Loader = 20 | | LoaderSync 21 | | ((filepath: string, content: string) => Promise); 22 | export type Loaders = Record; 23 | export type LoadersSync = Record; 24 | export interface Options extends OptionsBase { 25 | loaders?: Loaders; 26 | transform?: Transform; 27 | } 28 | export interface OptionsSync extends OptionsBase { 29 | loaders?: LoadersSync; 30 | transform?: TransformSync; 31 | } 32 | export declare const defaultLoadersSync: LoadersSync; 33 | export declare const defaultLoaders: Loaders; 34 | type ClearCaches = { 35 | clearLoadCache: () => void; 36 | clearSearchCache: () => void; 37 | clearCaches: () => void; 38 | }; 39 | type AsyncSearcher = { 40 | search(searchFrom?: string): Promise; 41 | load(filepath: string): Promise; 42 | } & ClearCaches; 43 | export declare function lilconfig( 44 | name: string, 45 | options?: Partial, 46 | ): AsyncSearcher; 47 | type SyncSearcher = { 48 | search(searchFrom?: string): LilconfigResult; 49 | load(filepath: string): LilconfigResult; 50 | } & ClearCaches; 51 | export declare function lilconfigSync( 52 | name: string, 53 | options?: OptionsSync, 54 | ): SyncSearcher; 55 | -------------------------------------------------------------------------------- /.github/workflows/nodejs.yaml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | branches: 9 | - '**' 10 | 11 | jobs: 12 | 13 | types: 14 | runs-on: ubuntu-latest 15 | strategy: 16 | matrix: 17 | node-version: [22.x] 18 | steps: 19 | - uses: actions/checkout@v4 20 | - name: Use Node.js ${{ matrix.node-version }} 21 | uses: actions/setup-node@v4 22 | with: 23 | node-version: ${{ matrix.node-version }} 24 | - name: install dependencies 25 | run: npm ci --ignore-scripts 26 | - name: types 27 | run: npm run types 28 | 29 | lint: 30 | runs-on: ubuntu-latest 31 | strategy: 32 | matrix: 33 | node-version: [22.x] 34 | steps: 35 | - uses: actions/checkout@v4 36 | - name: Use Node.js ${{ matrix.node-version }} 37 | uses: actions/setup-node@v4 38 | with: 39 | node-version: ${{ matrix.node-version }} 40 | - name: install dependencies 41 | run: npm ci 42 | - name: check codestyle 43 | run: npm run lint 44 | 45 | tests: 46 | runs-on: ubuntu-latest 47 | strategy: 48 | matrix: 49 | node-version: [22.x] 50 | steps: 51 | - uses: actions/checkout@v4 52 | - name: Use Node.js ${{ matrix.node-version }} 53 | uses: actions/setup-node@v4 54 | with: 55 | node-version: ${{ matrix.node-version }} 56 | - name: install dependencies 57 | run: npm ci 58 | - name: tests 59 | run: npm run test 60 | 61 | old-node-tests: 62 | runs-on: ubuntu-latest 63 | strategy: 64 | matrix: 65 | node-version: [14.x, 16.x, 18.x, 20.x] 66 | steps: 67 | - uses: actions/checkout@v4 68 | - name: Use Node.js ${{ matrix.node-version }} 69 | uses: actions/setup-node@v4 70 | with: 71 | node-version: ${{ matrix.node-version }} 72 | - name: install dependencies --ignore-scripts 73 | run: npm ci 74 | - name: old node tests 75 | run: node src/spec/old-node-tests.uvu.js 76 | 77 | windows-tests: 78 | runs-on: windows-latest 79 | strategy: 80 | matrix: 81 | node-version: [14.x, 16.x, 18.x, 20.x, 22.x] 82 | steps: 83 | - uses: actions/checkout@v4 84 | - name: Use Node.js ${{ matrix.node-version }} 85 | uses: actions/setup-node@v4 86 | with: 87 | node-version: ${{ matrix.node-version }} 88 | - name: install dependencies 89 | run: npm ci --ignore-scripts 90 | - name: windows node tests 91 | run: node src/spec/old-node-tests.uvu.js 92 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # Lilconfig ⚙️ 2 | [![npm version](https://badge.fury.io/js/lilconfig.svg)](https://badge.fury.io/js/lilconfig) 3 | [![install size](https://packagephobia.now.sh/badge?p=lilconfig)](https://packagephobia.now.sh/result?p=lilconfig) 4 | [![Coverage Status](https://coveralls.io/repos/github/antonk52/lilconfig/badge.svg)](https://coveralls.io/github/antonk52/lilconfig) 5 | 6 | A zero-dependency alternative to [cosmiconfig](https://www.npmjs.com/package/cosmiconfig) with the same API. 7 | 8 | ## Installation 9 | 10 | ```sh 11 | npm install lilconfig 12 | ``` 13 | 14 | ## Usage 15 | 16 | ```js 17 | import {lilconfig, lilconfigSync} from 'lilconfig'; 18 | 19 | // all keys are optional 20 | const options = { 21 | stopDir: '/Users/you/some/dir', 22 | searchPlaces: ['package.json', 'myapp.conf.js'], 23 | ignoreEmptySearchPlaces: false 24 | } 25 | 26 | lilconfig( 27 | 'myapp', 28 | options // optional 29 | ).search() // Promise 30 | 31 | lilconfigSync( 32 | 'myapp', 33 | options // optional 34 | ).load(pathToConfig) // LilconfigResult 35 | 36 | /** 37 | * LilconfigResult 38 | * { 39 | * config: any; // your config 40 | * filepath: string; 41 | * } 42 | */ 43 | ``` 44 | 45 | ## ESM 46 | 47 | ESM configs can be loaded with **async API only**. Specifically `js` files in projects with `"type": "module"` in `package.json` or `mjs` files. 48 | 49 | ## Difference to `cosmiconfig` 50 | 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. 51 | 52 | ### Option difference between the two. 53 | 54 | |cosmiconfig option | lilconfig | 55 | |------------------------|-----------| 56 | |cache | ✅ | 57 | |loaders | ✅ | 58 | |ignoreEmptySearchPlaces | ✅ | 59 | |packageProp | ✅ | 60 | |searchPlaces | ✅ | 61 | |stopDir | ✅ | 62 | |transform | ✅ | 63 | 64 | ## Loaders examples 65 | 66 | ### Yaml loader 67 | 68 | If you need the YAML support, you can provide your own loader 69 | 70 | ```js 71 | import {lilconfig} from 'lilconfig'; 72 | import yaml from 'yaml'; 73 | 74 | function loadYaml(filepath, content) { 75 | return yaml.parse(content); 76 | } 77 | 78 | const options = { 79 | loaders: { 80 | '.yaml': loadYaml, 81 | '.yml': loadYaml, 82 | // loader for files with no extension 83 | noExt: loadYaml 84 | } 85 | }; 86 | 87 | lilconfig('myapp', options) 88 | .search() 89 | .then(result => { 90 | result // {config, filepath} 91 | }); 92 | ``` 93 | 94 | ## Version correlation 95 | 96 | - lilconfig v1 → cosmiconfig v6 97 | - lilconfig v2 → cosmiconfig v7 98 | - lilconfig v3 → cosmiconfig v8 99 | -------------------------------------------------------------------------------- /src/spec/old-node-tests.uvu.js: -------------------------------------------------------------------------------- 1 | /** 2 | * This file tests the package 3 | * * Using older node versions that are not supported by Jest 4 | * * No javascript transformation is done by the test runner 5 | * ESM is tested as is 6 | */ 7 | const uvu = require('uvu'); 8 | const assert = require('assert'); 9 | const path = require('path'); 10 | const {lilconfig, lilconfigSync, defaultLoaders} = require('..'); 11 | const {cosmiconfig, cosmiconfigSync} = require('cosmiconfig'); 12 | const {transpileModule} = require('typescript'); 13 | 14 | const dirname = path.join(__dirname, 'load'); 15 | /** @type {import('../index').LoaderSync} */ 16 | const tsLoader = (_, content) => { 17 | const res = transpileModule(content, {}).outputText; 18 | // biome-ignore lint: it is a test 19 | return eval(res); 20 | }; 21 | 22 | const tsLoaderSuit = uvu.suite('ts-loader'); 23 | 24 | tsLoaderSuit('sync', () => { 25 | const filepath = path.join(dirname, 'test-app.ts'); 26 | const relativeFilepath = filepath.slice(process.cwd().length + 1); 27 | const options = { 28 | loaders: { 29 | '.ts': tsLoader, 30 | }, 31 | }; 32 | const expected = { 33 | config: { 34 | typescript: true, 35 | }, 36 | filepath, 37 | }; 38 | const result = lilconfigSync('test-app', options).load(relativeFilepath); 39 | const ccResult = cosmiconfigSync('test-app', options).load(relativeFilepath); 40 | 41 | assert.deepStrictEqual(result, expected); 42 | assert.deepStrictEqual(ccResult, expected); 43 | }); 44 | 45 | tsLoaderSuit('async', async () => { 46 | const filepath = path.join(dirname, 'test-app.ts'); 47 | const relativeFilepath = filepath.slice(process.cwd().length + 1); 48 | const options = { 49 | loaders: { 50 | '.ts': tsLoader, 51 | }, 52 | }; 53 | const expected = { 54 | config: { 55 | typescript: true, 56 | }, 57 | filepath, 58 | }; 59 | const result = await lilconfig('test-app', options).load(relativeFilepath); 60 | const ccResult = await cosmiconfig('test-app', options).load( 61 | relativeFilepath, 62 | ); 63 | 64 | assert.deepStrictEqual(result, expected); 65 | assert.deepStrictEqual(ccResult, expected); 66 | }); 67 | 68 | tsLoaderSuit.run(); 69 | 70 | const esmProjectSuit = uvu.suite('esm-project'); 71 | esmProjectSuit('async search js', async () => { 72 | const stopDir = __dirname; 73 | const filepath = path.join(stopDir, 'esm-project', 'esm.config.js'); 74 | const searchFrom = path.join(stopDir, 'esm-project', 'a', 'b', 'c'); 75 | 76 | const options = { 77 | searchPlaces: ['esm.config.js'], 78 | stopDir, 79 | }; 80 | 81 | const config = {esm: true}; 82 | 83 | const result = await lilconfig('test-app', options).search(searchFrom); 84 | const ccResult = await cosmiconfig('test-app', options).search(searchFrom); 85 | 86 | assert.deepStrictEqual(result, {config, filepath}); 87 | assert.deepStrictEqual(ccResult, {config, filepath}); 88 | }); 89 | 90 | esmProjectSuit('async search mjs', async () => { 91 | const stopDir = __dirname; 92 | const filepath = path.join(stopDir, 'esm-project', 'esm.config.mjs'); 93 | const searchFrom = path.join(stopDir, 'esm-project', 'a', 'b', 'c'); 94 | 95 | const options = { 96 | searchPlaces: ['esm.config.mjs'], 97 | stopDir, 98 | }; 99 | 100 | const config = {esm: true}; 101 | 102 | const result = await lilconfig('test-app', options).search(searchFrom); 103 | const ccResult = await cosmiconfig('test-app', options).search(searchFrom); 104 | 105 | assert.deepStrictEqual(result, {config, filepath}); 106 | assert.deepStrictEqual(ccResult, {config, filepath}); 107 | }); 108 | 109 | esmProjectSuit('async search cjs', async () => { 110 | const stopDir = __dirname; 111 | const filepath = path.join(stopDir, 'esm-project', 'esm.config.cjs'); 112 | const searchFrom = path.join(stopDir, 'esm-project', 'a', 'b', 'c'); 113 | 114 | const options = { 115 | searchPlaces: ['esm.config.cjs'], 116 | stopDir, 117 | }; 118 | 119 | const config = {cjs: true}; 120 | 121 | const result = await lilconfig('test-app', options).search(searchFrom); 122 | const ccResult = await cosmiconfig('test-app', options).search(searchFrom); 123 | 124 | assert.deepStrictEqual(result, {config, filepath}); 125 | assert.deepStrictEqual(ccResult, {config, filepath}); 126 | }); 127 | 128 | esmProjectSuit.run(); 129 | 130 | const cjsProjectSuit = uvu.suite('cjs-project'); 131 | cjsProjectSuit('async search js', async () => { 132 | const stopDir = __dirname; 133 | const filepath = path.join(stopDir, 'cjs-project', 'cjs.config.js'); 134 | const searchFrom = path.join(stopDir, 'cjs-project', 'a', 'b', 'c'); 135 | 136 | const options = { 137 | searchPlaces: ['cjs.config.js'], 138 | stopDir, 139 | }; 140 | 141 | const config = {cjs: true}; 142 | 143 | const result = await lilconfig('test-app', options).search(searchFrom); 144 | const ccResult = await cosmiconfig('test-app', options).search(searchFrom); 145 | 146 | assert.deepStrictEqual(result, {config, filepath}); 147 | assert.deepStrictEqual(ccResult, {config, filepath}); 148 | }); 149 | 150 | cjsProjectSuit('async search mjs', async () => { 151 | const stopDir = __dirname; 152 | const filepath = path.join(stopDir, 'cjs-project', 'cjs.config.mjs'); 153 | const searchFrom = path.join(stopDir, 'cjs-project', 'a', 'b', 'c'); 154 | 155 | const options = { 156 | searchPlaces: ['cjs.config.mjs'], 157 | stopDir, 158 | }; 159 | 160 | const config = {esm: true}; 161 | 162 | const result = await lilconfig('test-app', options).search(searchFrom); 163 | const ccResult = await cosmiconfig('test-app', options).search(searchFrom); 164 | 165 | assert.deepStrictEqual(result, {config, filepath}); 166 | assert.deepStrictEqual(ccResult, {config, filepath}); 167 | }); 168 | 169 | cjsProjectSuit('async search cjs', async () => { 170 | const stopDir = __dirname; 171 | const filepath = path.join(stopDir, 'cjs-project', 'cjs.config.cjs'); 172 | const searchFrom = path.join(stopDir, 'cjs-project', 'a', 'b', 'c'); 173 | 174 | const options = { 175 | searchPlaces: ['cjs.config.cjs'], 176 | stopDir, 177 | }; 178 | 179 | const config = {cjs: true}; 180 | 181 | const result = await lilconfig('test-app', options).search(searchFrom); 182 | const ccResult = await cosmiconfig('test-app', options).search(searchFrom); 183 | 184 | assert.deepStrictEqual(result, {config, filepath}); 185 | assert.deepStrictEqual(ccResult, {config, filepath}); 186 | }); 187 | 188 | cjsProjectSuit.run(); 189 | 190 | const defaultLoaderSuit = uvu.suite('js-default-loader'); 191 | 192 | defaultLoaderSuit('loads js file', async () => { 193 | const filepath = path.join(__dirname, 'load', 'test-app.js'); 194 | const result = await defaultLoaders['.js'](filepath, ''); 195 | 196 | assert.deepStrictEqual(result, {jsTest: true}); 197 | }); 198 | 199 | defaultLoaderSuit('loads cjs file', async () => { 200 | const filepath = path.join(__dirname, 'load', 'test-app.cjs'); 201 | const result = await defaultLoaders['.cjs'](filepath, ''); 202 | 203 | assert.deepStrictEqual(result, {jsTest: true}); 204 | }); 205 | 206 | defaultLoaderSuit('loads mjs file', async () => { 207 | const filepath = path.join(__dirname, 'load', 'test-app.mjs'); 208 | const result = await defaultLoaders['.mjs'](filepath, ''); 209 | 210 | assert.deepStrictEqual(result, {jsTest: true}); 211 | }); 212 | 213 | defaultLoaderSuit.run(); 214 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | const path = require('path'); 3 | const fs = require('fs'); 4 | const os = require('os'); 5 | const url = require('url'); 6 | 7 | const fsReadFileAsync = fs.promises.readFile; 8 | 9 | /** @type {(name: string, sync: boolean) => string[]} */ 10 | function getDefaultSearchPlaces(name, sync) { 11 | return [ 12 | 'package.json', 13 | `.${name}rc.json`, 14 | `.${name}rc.js`, 15 | `.${name}rc.cjs`, 16 | ...(sync ? [] : [`.${name}rc.mjs`]), 17 | `.config/${name}rc`, 18 | `.config/${name}rc.json`, 19 | `.config/${name}rc.js`, 20 | `.config/${name}rc.cjs`, 21 | ...(sync ? [] : [`.config/${name}rc.mjs`]), 22 | `${name}.config.js`, 23 | `${name}.config.cjs`, 24 | ...(sync ? [] : [`${name}.config.mjs`]), 25 | ]; 26 | } 27 | 28 | /** 29 | * @type {(p: string) => string} 30 | * 31 | * see #17 32 | * On *nix, if cwd is not under homedir, 33 | * the last path will be '', ('/build' -> '') 34 | * but it should be '/' actually. 35 | * And on Windows, this will never happen. ('C:\build' -> 'C:') 36 | */ 37 | function parentDir(p) { 38 | return path.dirname(p) || path.sep; 39 | } 40 | 41 | /** @type {import('./index').LoaderSync} */ 42 | const jsonLoader = (_, content) => JSON.parse(content); 43 | // Use plain require in webpack context for dynamic import 44 | const requireFunc = 45 | typeof __webpack_require__ === 'function' ? __non_webpack_require__ : require; 46 | /** @type {import('./index').LoadersSync} */ 47 | const defaultLoadersSync = Object.freeze({ 48 | '.js': requireFunc, 49 | '.json': requireFunc, 50 | '.cjs': requireFunc, 51 | noExt: jsonLoader, 52 | }); 53 | module.exports.defaultLoadersSync = defaultLoadersSync; 54 | 55 | /** @type {import('./index').Loader} */ 56 | const dynamicImport = async id => { 57 | try { 58 | const fileUrl = url.pathToFileURL(id).href; 59 | const mod = await import(/* webpackIgnore: true */ fileUrl); 60 | 61 | return mod.default; 62 | } catch (e) { 63 | try { 64 | return requireFunc(id); 65 | } catch (/** @type {any} */ requireE) { 66 | if ( 67 | requireE.code === 'ERR_REQUIRE_ESM' || 68 | (requireE instanceof SyntaxError && 69 | requireE 70 | .toString() 71 | .includes('Cannot use import statement outside a module')) 72 | ) { 73 | throw e; 74 | } 75 | throw requireE; 76 | } 77 | } 78 | }; 79 | 80 | /** @type {import('./index').Loaders} */ 81 | const defaultLoaders = Object.freeze({ 82 | '.js': dynamicImport, 83 | '.mjs': dynamicImport, 84 | '.cjs': dynamicImport, 85 | '.json': jsonLoader, 86 | noExt: jsonLoader, 87 | }); 88 | module.exports.defaultLoaders = defaultLoaders; 89 | 90 | /** 91 | * @param {string} name 92 | * @param {import('./index').Options | import('./index').OptionsSync} options 93 | * @param {boolean} sync 94 | * @returns {Required} 95 | */ 96 | function getOptions(name, options, sync) { 97 | /** @type {Required} */ 98 | const conf = { 99 | stopDir: os.homedir(), 100 | searchPlaces: getDefaultSearchPlaces(name, sync), 101 | ignoreEmptySearchPlaces: true, 102 | cache: true, 103 | transform: x => x, 104 | packageProp: [name], 105 | ...options, 106 | loaders: { 107 | ...(sync ? defaultLoadersSync : defaultLoaders), 108 | ...options.loaders, 109 | }, 110 | }; 111 | conf.searchPlaces.forEach(place => { 112 | const key = path.extname(place) || 'noExt'; 113 | const loader = conf.loaders[key]; 114 | if (!loader) { 115 | throw new Error(`Missing loader for extension "${place}"`); 116 | } 117 | 118 | if (typeof loader !== 'function') { 119 | throw new Error( 120 | `Loader for extension "${place}" is not a function: Received ${typeof loader}.`, 121 | ); 122 | } 123 | }); 124 | 125 | return conf; 126 | } 127 | 128 | /** @type {(props: string | string[], obj: Record) => unknown} */ 129 | function getPackageProp(props, obj) { 130 | if (typeof props === 'string' && props in obj) return obj[props]; 131 | return ( 132 | (Array.isArray(props) ? props : props.split('.')).reduce( 133 | (acc, prop) => (acc === undefined ? acc : acc[prop]), 134 | obj, 135 | ) || null 136 | ); 137 | } 138 | 139 | /** @param {string} filepath */ 140 | function validateFilePath(filepath) { 141 | if (!filepath) throw new Error('load must pass a non-empty string'); 142 | } 143 | 144 | /** @type {(loader: import('./index').Loader, ext: string) => void} */ 145 | function validateLoader(loader, ext) { 146 | if (!loader) throw new Error(`No loader specified for extension "${ext}"`); 147 | if (typeof loader !== 'function') throw new Error('loader is not a function'); 148 | } 149 | 150 | /** @type {(enableCache: boolean) => (c: Map, filepath: string, res: T) => T} */ 151 | const makeEmplace = enableCache => (c, filepath, res) => { 152 | if (enableCache) c.set(filepath, res); 153 | return res; 154 | }; 155 | 156 | /** @type {import('./index').lilconfig} */ 157 | module.exports.lilconfig = function lilconfig(name, options) { 158 | const { 159 | ignoreEmptySearchPlaces, 160 | loaders, 161 | packageProp, 162 | searchPlaces, 163 | stopDir, 164 | transform, 165 | cache, 166 | } = getOptions(name, options ?? {}, false); 167 | const searchCache = new Map(); 168 | const loadCache = new Map(); 169 | const emplace = makeEmplace(cache); 170 | 171 | return { 172 | async search(searchFrom = process.cwd()) { 173 | /** @type {import('./index').LilconfigResult} */ 174 | const result = { 175 | config: null, 176 | filepath: '', 177 | }; 178 | 179 | /** @type {Set} */ 180 | const visited = new Set(); 181 | let dir = searchFrom; 182 | dirLoop: while (true) { 183 | if (cache) { 184 | const r = searchCache.get(dir); 185 | if (r !== undefined) { 186 | for (const p of visited) searchCache.set(p, r); 187 | return r; 188 | } 189 | visited.add(dir); 190 | } 191 | 192 | for (const searchPlace of searchPlaces) { 193 | const filepath = path.join(dir, searchPlace); 194 | try { 195 | await fs.promises.access(filepath); 196 | } catch { 197 | continue; 198 | } 199 | const content = String(await fsReadFileAsync(filepath)); 200 | const loaderKey = path.extname(searchPlace) || 'noExt'; 201 | const loader = loaders[loaderKey]; 202 | 203 | // handle package.json 204 | if (searchPlace === 'package.json') { 205 | const pkg = await loader(filepath, content); 206 | const maybeConfig = getPackageProp(packageProp, pkg); 207 | if (maybeConfig != null) { 208 | result.config = maybeConfig; 209 | result.filepath = filepath; 210 | break dirLoop; 211 | } 212 | 213 | continue; 214 | } 215 | 216 | // handle other type of configs 217 | const isEmpty = content.trim() === ''; 218 | if (isEmpty && ignoreEmptySearchPlaces) continue; 219 | 220 | if (isEmpty) { 221 | result.isEmpty = true; 222 | result.config = undefined; 223 | } else { 224 | validateLoader(loader, loaderKey); 225 | result.config = await loader(filepath, content); 226 | } 227 | result.filepath = filepath; 228 | break dirLoop; 229 | } 230 | if (dir === stopDir || dir === parentDir(dir)) break dirLoop; 231 | dir = parentDir(dir); 232 | } 233 | 234 | const transformed = 235 | // not found 236 | result.filepath === '' && result.config === null 237 | ? transform(null) 238 | : transform(result); 239 | 240 | if (cache) { 241 | for (const p of visited) searchCache.set(p, transformed); 242 | } 243 | 244 | return transformed; 245 | }, 246 | async load(filepath) { 247 | validateFilePath(filepath); 248 | const absPath = path.resolve(process.cwd(), filepath); 249 | if (cache && loadCache.has(absPath)) { 250 | return loadCache.get(absPath); 251 | } 252 | const {base, ext} = path.parse(absPath); 253 | const loaderKey = ext || 'noExt'; 254 | const loader = loaders[loaderKey]; 255 | validateLoader(loader, loaderKey); 256 | const content = String(await fsReadFileAsync(absPath)); 257 | 258 | if (base === 'package.json') { 259 | const pkg = await loader(absPath, content); 260 | return emplace( 261 | loadCache, 262 | absPath, 263 | transform({ 264 | config: getPackageProp(packageProp, pkg), 265 | filepath: absPath, 266 | }), 267 | ); 268 | } 269 | /** @type {import('./index').LilconfigResult} */ 270 | const result = { 271 | config: null, 272 | filepath: absPath, 273 | }; 274 | // handle other type of configs 275 | const isEmpty = content.trim() === ''; 276 | if (isEmpty && ignoreEmptySearchPlaces) 277 | return emplace( 278 | loadCache, 279 | absPath, 280 | transform({ 281 | config: undefined, 282 | filepath: absPath, 283 | isEmpty: true, 284 | }), 285 | ); 286 | 287 | // cosmiconfig returns undefined for empty files 288 | result.config = isEmpty ? undefined : await loader(absPath, content); 289 | 290 | return emplace( 291 | loadCache, 292 | absPath, 293 | transform(isEmpty ? {...result, isEmpty, config: undefined} : result), 294 | ); 295 | }, 296 | clearLoadCache() { 297 | if (cache) loadCache.clear(); 298 | }, 299 | clearSearchCache() { 300 | if (cache) searchCache.clear(); 301 | }, 302 | clearCaches() { 303 | if (cache) { 304 | loadCache.clear(); 305 | searchCache.clear(); 306 | } 307 | }, 308 | }; 309 | }; 310 | 311 | /** @type {import('./index').lilconfigSync} */ 312 | module.exports.lilconfigSync = function lilconfigSync(name, options) { 313 | const { 314 | ignoreEmptySearchPlaces, 315 | loaders, 316 | packageProp, 317 | searchPlaces, 318 | stopDir, 319 | transform, 320 | cache, 321 | } = getOptions(name, options ?? {}, true); 322 | const searchCache = new Map(); 323 | const loadCache = new Map(); 324 | const emplace = makeEmplace(cache); 325 | 326 | return { 327 | search(searchFrom = process.cwd()) { 328 | /** @type {import('./index').LilconfigResult} */ 329 | const result = { 330 | config: null, 331 | filepath: '', 332 | }; 333 | 334 | /** @type {Set} */ 335 | const visited = new Set(); 336 | let dir = searchFrom; 337 | dirLoop: while (true) { 338 | if (cache) { 339 | const r = searchCache.get(dir); 340 | if (r !== undefined) { 341 | for (const p of visited) searchCache.set(p, r); 342 | return r; 343 | } 344 | visited.add(dir); 345 | } 346 | 347 | for (const searchPlace of searchPlaces) { 348 | const filepath = path.join(dir, searchPlace); 349 | try { 350 | fs.accessSync(filepath); 351 | } catch { 352 | continue; 353 | } 354 | const loaderKey = path.extname(searchPlace) || 'noExt'; 355 | const loader = loaders[loaderKey]; 356 | const content = String(fs.readFileSync(filepath)); 357 | 358 | // handle package.json 359 | if (searchPlace === 'package.json') { 360 | const pkg = loader(filepath, content); 361 | const maybeConfig = getPackageProp(packageProp, pkg); 362 | if (maybeConfig != null) { 363 | result.config = maybeConfig; 364 | result.filepath = filepath; 365 | break dirLoop; 366 | } 367 | 368 | continue; 369 | } 370 | 371 | // handle other type of configs 372 | const isEmpty = content.trim() === ''; 373 | if (isEmpty && ignoreEmptySearchPlaces) continue; 374 | 375 | if (isEmpty) { 376 | result.isEmpty = true; 377 | result.config = undefined; 378 | } else { 379 | validateLoader(loader, loaderKey); 380 | result.config = loader(filepath, content); 381 | } 382 | result.filepath = filepath; 383 | break dirLoop; 384 | } 385 | if (dir === stopDir || dir === parentDir(dir)) break dirLoop; 386 | dir = parentDir(dir); 387 | } 388 | 389 | const transformed = 390 | // not found 391 | result.filepath === '' && result.config === null 392 | ? transform(null) 393 | : transform(result); 394 | 395 | if (cache) { 396 | for (const p of visited) searchCache.set(p, transformed); 397 | } 398 | 399 | return transformed; 400 | }, 401 | load(filepath) { 402 | validateFilePath(filepath); 403 | const absPath = path.resolve(process.cwd(), filepath); 404 | if (cache && loadCache.has(absPath)) { 405 | return loadCache.get(absPath); 406 | } 407 | const {base, ext} = path.parse(absPath); 408 | const loaderKey = ext || 'noExt'; 409 | const loader = loaders[loaderKey]; 410 | validateLoader(loader, loaderKey); 411 | 412 | const content = String(fs.readFileSync(absPath)); 413 | 414 | if (base === 'package.json') { 415 | const pkg = loader(absPath, content); 416 | return transform({ 417 | config: getPackageProp(packageProp, pkg), 418 | filepath: absPath, 419 | }); 420 | } 421 | const result = { 422 | config: null, 423 | filepath: absPath, 424 | }; 425 | // handle other type of configs 426 | const isEmpty = content.trim() === ''; 427 | if (isEmpty && ignoreEmptySearchPlaces) 428 | return emplace( 429 | loadCache, 430 | absPath, 431 | transform({ 432 | filepath: absPath, 433 | config: undefined, 434 | isEmpty: true, 435 | }), 436 | ); 437 | 438 | // cosmiconfig returns undefined for empty files 439 | result.config = isEmpty ? undefined : loader(absPath, content); 440 | 441 | return emplace( 442 | loadCache, 443 | absPath, 444 | transform(isEmpty ? {...result, isEmpty, config: undefined} : result), 445 | ); 446 | }, 447 | clearLoadCache() { 448 | if (cache) loadCache.clear(); 449 | }, 450 | clearSearchCache() { 451 | if (cache) searchCache.clear(); 452 | }, 453 | clearCaches() { 454 | if (cache) { 455 | loadCache.clear(); 456 | searchCache.clear(); 457 | } 458 | }, 459 | }; 460 | }; 461 | -------------------------------------------------------------------------------- /src/spec/index.spec.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | const path = require('path'); 3 | const fs = require('fs'); 4 | const {lilconfig, lilconfigSync} = require('..'); 5 | const {cosmiconfig, cosmiconfigSync} = require('cosmiconfig'); 6 | const {transpileModule} = require('typescript'); 7 | 8 | /** 9 | * Mocking fs solely to test the root directory filepath 10 | */ 11 | jest.mock('fs', () => { 12 | const fs = jest.requireActual('fs'); 13 | 14 | return { 15 | ...fs, 16 | promises: { 17 | ...fs.promises, 18 | readFile: jest.fn(fs.promises.readFile), 19 | access: jest.fn(fs.promises.access), 20 | }, 21 | accessSync: jest.fn(fs.accessSync), 22 | readFileSync: jest.fn(fs.readFileSync), 23 | }; 24 | }); 25 | 26 | beforeEach(() => { 27 | jest.clearAllMocks(); 28 | }); 29 | 30 | const isNodeV20orNewer = parseInt(process.versions.node, 10) >= 20; 31 | 32 | describe('options', () => { 33 | const dirname = path.join(__dirname, 'load'); 34 | 35 | describe('loaders', () => { 36 | /** @type {import('../index').LoaderSync} */ 37 | const tsLoader = (_, content) => { 38 | const res = transpileModule(content, {}).outputText; 39 | // biome-ignore lint: it is a test 40 | return eval(res); 41 | }; 42 | 43 | describe('ts-loader', () => { 44 | const filepath = path.join(dirname, 'test-app.ts'); 45 | const relativeFilepath = filepath.slice(process.cwd().length + 1); 46 | const options = { 47 | loaders: { 48 | '.ts': tsLoader, 49 | }, 50 | }; 51 | const expected = { 52 | config: { 53 | typescript: true, 54 | }, 55 | filepath, 56 | }; 57 | 58 | it('sync', () => { 59 | const result = lilconfigSync('test-app', options).load( 60 | relativeFilepath, 61 | ); 62 | const ccResult = cosmiconfigSync('test-app', options).load( 63 | relativeFilepath, 64 | ); 65 | 66 | expect(result).toEqual(expected); 67 | expect(ccResult).toEqual(expected); 68 | }); 69 | 70 | it('async', async () => { 71 | const result = await lilconfig('test-app', options).load( 72 | relativeFilepath, 73 | ); 74 | const ccResult = await cosmiconfig('test-app', options).load( 75 | relativeFilepath, 76 | ); 77 | 78 | expect(result).toEqual(expected); 79 | expect(ccResult).toEqual(expected); 80 | }); 81 | }); 82 | 83 | describe('async loaders', () => { 84 | const config = {data: 42}; 85 | const options = { 86 | loaders: { 87 | '.js': async () => config, 88 | 89 | /** @type {import('../index').LoaderSync} */ 90 | noExt: (_, content) => content, 91 | }, 92 | }; 93 | 94 | it('async load', async () => { 95 | const filepath = path.join(__dirname, 'load', 'test-app.js'); 96 | 97 | const result = await lilconfig('test-app', options).load(filepath); 98 | const ccResult = await cosmiconfig('test-app', options).load(filepath); 99 | 100 | expect(result).toEqual({config, filepath}); 101 | expect(ccResult).toEqual({config, filepath}); 102 | }); 103 | 104 | it('async search', async () => { 105 | const searchPath = path.join(__dirname, 'search'); 106 | const filepath = path.join(searchPath, 'test-app.config.js'); 107 | 108 | const result = await lilconfig('test-app', options).search(searchPath); 109 | const ccResult = await cosmiconfig('test-app', options).search( 110 | searchPath, 111 | ); 112 | 113 | expect(result).toEqual({config, filepath}); 114 | expect(ccResult).toEqual({config, filepath}); 115 | }); 116 | 117 | describe('esm-project', () => { 118 | it('async search js', async () => { 119 | const stopDir = __dirname; 120 | const filepath = path.join(stopDir, 'esm-project', 'esm.config.js'); 121 | const searchFrom = path.join(stopDir, 'esm-project', 'a', 'b', 'c'); 122 | 123 | const options = { 124 | searchPlaces: ['esm.config.js'], 125 | stopDir, 126 | }; 127 | 128 | const config = {esm: true}; 129 | 130 | const result = await lilconfig('test-app', options).search( 131 | searchFrom, 132 | ); 133 | const ccResult = await cosmiconfig('test-app', options).search( 134 | searchFrom, 135 | ); 136 | 137 | expect(result).toEqual({config, filepath}); 138 | expect(ccResult).toEqual({config, filepath}); 139 | }); 140 | 141 | it('async search mjs', async () => { 142 | const stopDir = __dirname; 143 | const filepath = path.join(stopDir, 'esm-project', 'esm.config.mjs'); 144 | const searchFrom = path.join(stopDir, 'esm-project', 'a', 'b', 'c'); 145 | 146 | const options = { 147 | searchPlaces: ['esm.config.mjs'], 148 | stopDir, 149 | }; 150 | 151 | const config = {esm: true}; 152 | 153 | const result = await lilconfig('test-app', options).search( 154 | searchFrom, 155 | ); 156 | const ccResult = await cosmiconfig('test-app', options).search( 157 | searchFrom, 158 | ); 159 | 160 | expect(result).toEqual({config, filepath}); 161 | expect(ccResult).toEqual({config, filepath}); 162 | }); 163 | 164 | it('async search cjs', async () => { 165 | const stopDir = __dirname; 166 | const filepath = path.join(stopDir, 'esm-project', 'esm.config.cjs'); 167 | const searchFrom = path.join(stopDir, 'esm-project', 'a', 'b', 'c'); 168 | 169 | const options = { 170 | searchPlaces: ['esm.config.cjs'], 171 | stopDir, 172 | }; 173 | 174 | const config = {cjs: true}; 175 | 176 | const result = await lilconfig('test-app', options).search( 177 | searchFrom, 178 | ); 179 | const ccResult = await cosmiconfig('test-app', options).search( 180 | searchFrom, 181 | ); 182 | 183 | expect(result).toEqual({config, filepath}); 184 | expect(ccResult).toEqual({config, filepath}); 185 | }); 186 | it('throws for using cjs instead of esm in esm project', async () => { 187 | const stopDir = __dirname; 188 | const filepath = path.join(stopDir, 'esm-project', 'cjs.config.mjs'); 189 | 190 | const searcher = lilconfig('test-app', {}); 191 | 192 | const err = await searcher.load(filepath).catch(e => e); 193 | expect(err.toString()).toMatch('module is not defined'); 194 | // TODO test for cosmiconfig 195 | // cosmiconfig added this in v9.0.0 196 | // but also some breaking changes 197 | }); 198 | }); 199 | 200 | describe('cjs-project', () => { 201 | it('async search js', async () => { 202 | const stopDir = __dirname; 203 | const filepath = path.join(stopDir, 'cjs-project', 'cjs.config.js'); 204 | const searchFrom = path.join(stopDir, 'cjs-project', 'a', 'b', 'c'); 205 | 206 | const options = { 207 | searchPlaces: ['cjs.config.js'], 208 | stopDir, 209 | }; 210 | 211 | const config = {cjs: true}; 212 | 213 | const result = await lilconfig('test-app', options).search( 214 | searchFrom, 215 | ); 216 | const ccResult = await cosmiconfig('test-app', options).search( 217 | searchFrom, 218 | ); 219 | 220 | expect(result).toEqual({config, filepath}); 221 | expect(ccResult).toEqual({config, filepath}); 222 | }); 223 | 224 | it('async search mjs', async () => { 225 | const stopDir = __dirname; 226 | const filepath = path.join(stopDir, 'cjs-project', 'cjs.config.mjs'); 227 | const searchFrom = path.join(stopDir, 'cjs-project', 'a', 'b', 'c'); 228 | 229 | const options = { 230 | searchPlaces: ['cjs.config.mjs'], 231 | stopDir, 232 | }; 233 | 234 | const config = {esm: true}; 235 | 236 | const result = await lilconfig('test-app', options).search( 237 | searchFrom, 238 | ); 239 | const ccResult = await cosmiconfig('test-app', options).search( 240 | searchFrom, 241 | ); 242 | 243 | expect(result).toEqual({config, filepath}); 244 | expect(ccResult).toEqual({config, filepath}); 245 | }); 246 | 247 | it('async search cjs', async () => { 248 | const stopDir = __dirname; 249 | const filepath = path.join(stopDir, 'cjs-project', 'cjs.config.cjs'); 250 | const searchFrom = path.join(stopDir, 'cjs-project', 'a', 'b', 'c'); 251 | 252 | const options = { 253 | searchPlaces: ['cjs.config.cjs'], 254 | stopDir, 255 | }; 256 | 257 | const config = {cjs: true}; 258 | 259 | const result = await lilconfig('test-app', options).search( 260 | searchFrom, 261 | ); 262 | const ccResult = await cosmiconfig('test-app', options).search( 263 | searchFrom, 264 | ); 265 | 266 | expect(result).toEqual({config, filepath}); 267 | expect(ccResult).toEqual({config, filepath}); 268 | }); 269 | }); 270 | 271 | it('async noExt', async () => { 272 | const searchPath = path.join(__dirname, 'search'); 273 | const filepath = path.join(searchPath, 'noExtension'); 274 | const opts = { 275 | ...options, 276 | searchPlaces: ['noExtension'], 277 | }; 278 | 279 | const result = await lilconfig('noExtension', opts).search(searchPath); 280 | const ccResult = await cosmiconfig('noExtension', opts).search( 281 | searchPath, 282 | ); 283 | 284 | const expected = { 285 | filepath, 286 | config: 'this file has no extension\n', 287 | }; 288 | 289 | expect(result).toEqual(expected); 290 | expect(ccResult).toEqual(expected); 291 | }); 292 | 293 | it('sync noExt', () => { 294 | const searchPath = path.join(__dirname, 'search'); 295 | const filepath = path.join(searchPath, 'noExtension'); 296 | const opts = { 297 | ...options, 298 | searchPlaces: ['noExtension'], 299 | }; 300 | 301 | const result = lilconfigSync('noExtension', opts).search(searchPath); 302 | const ccResult = cosmiconfigSync('noExtension', opts).search( 303 | searchPath, 304 | ); 305 | 306 | const expected = { 307 | filepath, 308 | config: 'this file has no extension\n', 309 | }; 310 | 311 | expect(result).toEqual(expected); 312 | expect(ccResult).toEqual(expected); 313 | }); 314 | }); 315 | }); 316 | 317 | describe('transform', () => { 318 | /** @type {import('../index').TransformSync} */ 319 | const transform = result => { 320 | if (result == null) return null; 321 | return { 322 | ...result, 323 | config: { 324 | ...result.config, 325 | transformed: true, 326 | }, 327 | }; 328 | }; 329 | const filepath = path.join(dirname, 'test-app.js'); 330 | const relativeFilepath = filepath.slice(process.cwd().length + 1); 331 | const options = { 332 | transform, 333 | }; 334 | const expected = { 335 | config: { 336 | jsTest: true, 337 | transformed: true, 338 | }, 339 | filepath, 340 | }; 341 | 342 | it('sync', () => { 343 | const result = lilconfigSync('test-app', options).load(relativeFilepath); 344 | const ccResult = cosmiconfigSync('test-app', options).load( 345 | relativeFilepath, 346 | ); 347 | 348 | expect(result).toEqual(expected); 349 | expect(ccResult).toEqual(expected); 350 | }); 351 | it('async', async () => { 352 | const result = await lilconfig('test-app', options).load( 353 | relativeFilepath, 354 | ); 355 | const ccResult = await cosmiconfig('test-app', options).load( 356 | relativeFilepath, 357 | ); 358 | 359 | expect(result).toEqual(expected); 360 | expect(ccResult).toEqual(expected); 361 | }); 362 | }); 363 | 364 | describe('ignoreEmptySearchPlaces', () => { 365 | const dirname = path.join(__dirname, 'load'); 366 | const filepath = path.join(dirname, 'test-empty.js'); 367 | const relativeFilepath = filepath.slice(process.cwd().length + 1); 368 | 369 | describe('ignores by default', () => { 370 | it('sync', () => { 371 | const result = lilconfigSync('test-app').load(relativeFilepath); 372 | const ccResult = cosmiconfigSync('test-app').load(relativeFilepath); 373 | 374 | const expected = { 375 | config: undefined, 376 | filepath, 377 | isEmpty: true, 378 | }; 379 | 380 | expect(result).toEqual(expected); 381 | expect(ccResult).toEqual(expected); 382 | }); 383 | 384 | it('async', async () => { 385 | const result = await lilconfig('test-app').load(relativeFilepath); 386 | const ccResult = await cosmiconfig('test-app').load(relativeFilepath); 387 | 388 | const expected = { 389 | config: undefined, 390 | filepath, 391 | isEmpty: true, 392 | }; 393 | 394 | expect(result).toEqual(expected); 395 | expect(ccResult).toEqual(expected); 396 | }); 397 | }); 398 | 399 | describe('ignores when true', () => { 400 | it('sync', () => { 401 | const options = { 402 | ignoreEmptySearchPlaces: true, 403 | }; 404 | const result = lilconfigSync('test-app', options).load(filepath); 405 | const ccResult = cosmiconfigSync('test-app', options).load(filepath); 406 | 407 | const expected = { 408 | config: undefined, 409 | filepath, 410 | isEmpty: true, 411 | }; 412 | 413 | expect(result).toEqual(expected); 414 | expect(ccResult).toEqual(expected); 415 | }); 416 | 417 | it('async', async () => { 418 | const options = { 419 | ignoreEmptySearchPlaces: true, 420 | }; 421 | const result = await lilconfig('test-app', options).load(filepath); 422 | const ccResult = await cosmiconfig('test-app', options).load(filepath); 423 | 424 | const expected = { 425 | config: undefined, 426 | filepath, 427 | isEmpty: true, 428 | }; 429 | 430 | expect(result).toEqual(expected); 431 | expect(ccResult).toEqual(expected); 432 | }); 433 | }); 434 | 435 | describe('doesnt ignore when false', () => { 436 | it('sync', () => { 437 | const options = { 438 | ignoreEmptySearchPlaces: false, 439 | }; 440 | const result = lilconfigSync('test-app', options).load(filepath); 441 | const ccResult = cosmiconfigSync('test-app', options).load(filepath); 442 | 443 | const expected = {config: undefined, filepath, isEmpty: true}; 444 | 445 | expect(result).toEqual(expected); 446 | expect(ccResult).toEqual(expected); 447 | }); 448 | 449 | it('async', async () => { 450 | const options = { 451 | ignoreEmptySearchPlaces: false, 452 | }; 453 | const result = await lilconfig('test-app', options).load(filepath); 454 | const ccResult = await cosmiconfig('test-app', options).load(filepath); 455 | 456 | const expected = {config: undefined, filepath, isEmpty: true}; 457 | 458 | expect(result).toEqual(expected); 459 | expect(ccResult).toEqual(expected); 460 | }); 461 | }); 462 | }); 463 | 464 | it('stopDir', () => { 465 | const stopDir = path.join(__dirname, 'search'); 466 | const searchFrom = path.join(__dirname, 'search', 'a', 'b', 'c'); 467 | 468 | const result = lilconfigSync('non-existent', {stopDir}).search(searchFrom); 469 | const ccResult = cosmiconfigSync('non-existent', {stopDir}).search( 470 | searchFrom, 471 | ); 472 | 473 | const expected = null; 474 | 475 | expect(result).toEqual(expected); 476 | expect(ccResult).toEqual(expected); 477 | }); 478 | 479 | it('searchPlaces', () => { 480 | const stopDir = path.join(__dirname, 'search'); 481 | const searchFrom = path.join(__dirname, 'search', 'a', 'b', 'c'); 482 | const searchPlaces = ['searchPlaces.conf.js', 'searchPlaces-noExt']; 483 | 484 | const options = { 485 | stopDir, 486 | searchPlaces, 487 | }; 488 | 489 | const result = lilconfigSync('doesnt-matter', options).search(searchFrom); 490 | const ccResult = cosmiconfigSync('doesnt-matter', options).search( 491 | searchFrom, 492 | ); 493 | 494 | const expected = { 495 | config: { 496 | searchPlacesWorks: true, 497 | }, 498 | filepath: path.join( 499 | __dirname, 500 | 'search', 501 | 'a', 502 | 'b', 503 | 'searchPlaces.conf.js', 504 | ), 505 | }; 506 | 507 | expect(result).toEqual(expected); 508 | expect(ccResult).toEqual(expected); 509 | }); 510 | 511 | describe('cache', () => { 512 | // running all checks in one to avoid resetting cache for fs.promises.access 513 | describe('enabled(default)', () => { 514 | it('async search()', async () => { 515 | const stopDir = path.join(__dirname, 'search'); 516 | const searchFrom = path.join(stopDir, 'a', 'b', 'c'); 517 | const searchPlaces = ['cached.config.js', 'package.json']; 518 | const searcher = lilconfig('cached', { 519 | cache: true, 520 | stopDir, 521 | searchPlaces, 522 | }); 523 | const fsLookUps = () => 524 | // @ts-expect-error 525 | fs.promises.access.mock.calls.length; 526 | 527 | expect(fsLookUps()).toBe(0); 528 | 529 | // per one search 530 | // for unexisting 531 | // (search + a + b + c) * times searchPlaces 532 | 533 | // for existing 534 | // (search + a + b + c) * (times searchPlaces - **first** matched) 535 | const expectedFsLookUps = 7; 536 | 537 | // initial search populates cache 538 | const result = await searcher.search(searchFrom); 539 | 540 | expect(fsLookUps()).toBe(expectedFsLookUps); 541 | 542 | // subsequant search reads from cache 543 | const result2 = await searcher.search(searchFrom); 544 | expect(fsLookUps()).toBe(expectedFsLookUps); 545 | expect(result).toEqual(result2); 546 | 547 | // searching a subpath reuses cache 548 | const result3 = await searcher.search(path.join(stopDir, 'a')); 549 | const result4 = await searcher.search(path.join(stopDir, 'a', 'b')); 550 | expect(fsLookUps()).toBe(expectedFsLookUps); 551 | expect(result2).toEqual(result3); 552 | expect(result3).toEqual(result4); 553 | 554 | // calling clearCaches empties search cache 555 | searcher.clearCaches(); 556 | 557 | // emptied all caches, should perform new lookups 558 | const result5 = await searcher.search(searchFrom); 559 | expect(fsLookUps()).toBe(expectedFsLookUps * 2); 560 | expect(result4).toEqual(result5); 561 | // different references 562 | expect(result4 === result5).toEqual(false); 563 | 564 | searcher.clearSearchCache(); 565 | const result6 = await searcher.search(searchFrom); 566 | expect(fsLookUps()).toBe(expectedFsLookUps * 3); 567 | expect(result5).toEqual(result6); 568 | // different references 569 | expect(result5 === result6).toEqual(false); 570 | 571 | // clearLoadCache does not clear search cache 572 | searcher.clearLoadCache(); 573 | const result7 = await searcher.search(searchFrom); 574 | expect(fsLookUps()).toBe(expectedFsLookUps * 3); 575 | expect(result6).toEqual(result7); 576 | // same references 577 | expect(result6 === result7).toEqual(true); 578 | 579 | // searching a superset path will access fs until it hits a known path 580 | const result8 = await searcher.search(path.join(searchFrom, 'd')); 581 | expect(fsLookUps()).toBe(3 * expectedFsLookUps + 2); 582 | expect(result7).toEqual(result8); 583 | // same references 584 | expect(result7 === result8).toEqual(true); 585 | 586 | // repeated searches do not cause extra fs calls 587 | const result9 = await searcher.search(path.join(searchFrom, 'd')); 588 | expect(fsLookUps()).toBe(3 * expectedFsLookUps + 2); 589 | expect(result8).toEqual(result9); 590 | // same references 591 | expect(result8 === result9).toEqual(true); 592 | }); 593 | 594 | it('sync search()', () => { 595 | const stopDir = path.join(__dirname, 'search'); 596 | const searchFrom = path.join(stopDir, 'a', 'b', 'c'); 597 | const searchPlaces = ['cached.config.js', 'package.json']; 598 | const searcher = lilconfigSync('cached', { 599 | cache: true, 600 | stopDir, 601 | searchPlaces, 602 | }); 603 | const fsLookUps = () => 604 | // @ts-expect-error 605 | fs.accessSync.mock.calls.length; 606 | 607 | expect(fsLookUps()).toBe(0); 608 | 609 | // per one search 610 | // for unexisting 611 | // (search + a + b + c) * times searchPlaces 612 | 613 | // for existing 614 | // (search + a + b + c) * (times searchPlaces - **first** matched) 615 | const expectedFsLookUps = 7; 616 | 617 | // initial search populates cache 618 | const result = searcher.search(searchFrom); 619 | 620 | expect(fsLookUps()).toBe(expectedFsLookUps); 621 | 622 | // subsequant search reads from cache 623 | const result2 = searcher.search(searchFrom); 624 | expect(fsLookUps()).toBe(expectedFsLookUps); 625 | expect(result).toEqual(result2); 626 | 627 | // searching a subpath reuses cache 628 | const result3 = searcher.search(path.join(stopDir, 'a')); 629 | const result4 = searcher.search(path.join(stopDir, 'a', 'b')); 630 | expect(fsLookUps()).toBe(expectedFsLookUps); 631 | expect(result2).toEqual(result3); 632 | expect(result3).toEqual(result4); 633 | 634 | // calling clearCaches empties search cache 635 | searcher.clearCaches(); 636 | 637 | // emptied all caches, should perform new lookups 638 | const result5 = searcher.search(searchFrom); 639 | expect(fsLookUps()).toBe(expectedFsLookUps * 2); 640 | expect(result4).toEqual(result5); 641 | // different references 642 | expect(result4 === result5).toEqual(false); 643 | 644 | searcher.clearSearchCache(); 645 | const result6 = searcher.search(searchFrom); 646 | expect(fsLookUps()).toBe(expectedFsLookUps * 3); 647 | expect(result5).toEqual(result6); 648 | // different references 649 | expect(result5 === result6).toEqual(false); 650 | 651 | // clearLoadCache does not clear search cache 652 | searcher.clearLoadCache(); 653 | const result7 = searcher.search(searchFrom); 654 | expect(fsLookUps()).toBe(expectedFsLookUps * 3); 655 | expect(result6).toEqual(result7); 656 | // same references 657 | expect(result6 === result7).toEqual(true); 658 | 659 | // searching a superset path will access fs until it hits a known path 660 | const result8 = searcher.search(path.join(searchFrom, 'd')); 661 | expect(fsLookUps()).toBe(3 * expectedFsLookUps + 2); 662 | expect(result7).toEqual(result8); 663 | // same references 664 | expect(result7 === result8).toEqual(true); 665 | 666 | // repeated searches do not cause extra fs calls 667 | const result9 = searcher.search(path.join(searchFrom, 'd')); 668 | expect(fsLookUps()).toBe(3 * expectedFsLookUps + 2); 669 | expect(result8).toEqual(result9); 670 | // same references 671 | expect(result8 === result9).toEqual(true); 672 | }); 673 | 674 | it('async load()', async () => { 675 | const stopDir = path.join(__dirname, 'search'); 676 | const searchPlaces = ['cached.config.js', 'package.json']; 677 | const searcher = lilconfig('cached', { 678 | cache: true, 679 | stopDir, 680 | searchPlaces, 681 | }); 682 | const existingFile = path.join(stopDir, 'cached.config.js'); 683 | const fsReadFileCalls = () => 684 | // @ts-expect-error 685 | fs.promises.readFile.mock.calls.length; 686 | 687 | expect(fsReadFileCalls()).toBe(0); 688 | 689 | // initial search populates cache 690 | const result = await searcher.load(existingFile); 691 | expect(fsReadFileCalls()).toBe(1); 692 | 693 | // subsequant load reads from cache 694 | const result2 = await searcher.load(existingFile); 695 | expect(fsReadFileCalls()).toBe(1); 696 | expect(result).toEqual(result2); 697 | // same reference 698 | expect(result === result2).toEqual(true); 699 | 700 | // calling clearCaches empties search cache 701 | searcher.clearCaches(); 702 | const result3 = await searcher.load(existingFile); 703 | expect(fsReadFileCalls()).toBe(2); 704 | expect(result2).toEqual(result3); 705 | // different reference 706 | expect(result2 === result3).toEqual(false); 707 | 708 | searcher.clearLoadCache(); 709 | const result4 = await searcher.load(existingFile); 710 | expect(fsReadFileCalls()).toBe(3); 711 | expect(result3).toEqual(result4); 712 | // different reference 713 | expect(result3 === result4).toEqual(false); 714 | 715 | // clearLoadCache does not clear search cache 716 | searcher.clearSearchCache(); 717 | const result5 = await searcher.load(existingFile); 718 | expect(fsReadFileCalls()).toBe(3); 719 | expect(result4).toEqual(result5); 720 | // same reference 721 | expect(result4 === result5).toEqual(true); 722 | }); 723 | 724 | it('sync load()', () => { 725 | const stopDir = path.join(__dirname, 'search'); 726 | const searchPlaces = ['cached.config.js', 'package.json']; 727 | const searcher = lilconfigSync('cached', { 728 | cache: true, 729 | stopDir, 730 | searchPlaces, 731 | }); 732 | const existingFile = path.join(stopDir, 'cached.config.js'); 733 | const fsReadFileCalls = () => 734 | // @ts-expect-error 735 | fs.readFileSync.mock.calls.length; 736 | 737 | expect(fsReadFileCalls()).toBe(0); 738 | 739 | // initial search populates cache 740 | const result = searcher.load(existingFile); 741 | expect(fsReadFileCalls()).toBe(1); 742 | 743 | // subsequant load reads from cache 744 | const result2 = searcher.load(existingFile); 745 | expect(fsReadFileCalls()).toBe(1); 746 | expect(result).toEqual(result2); 747 | // same reference 748 | expect(result === result2).toEqual(true); 749 | 750 | // calling clearCaches empties search cache 751 | searcher.clearCaches(); 752 | const result3 = searcher.load(existingFile); 753 | expect(fsReadFileCalls()).toBe(2); 754 | expect(result2).toEqual(result3); 755 | // different reference 756 | expect(result2 === result3).toEqual(false); 757 | 758 | searcher.clearLoadCache(); 759 | const result4 = searcher.load(existingFile); 760 | expect(fsReadFileCalls()).toBe(3); 761 | expect(result3).toEqual(result4); 762 | // different reference 763 | expect(result3 === result4).toEqual(false); 764 | 765 | // clearLoadCache does not clear search cache 766 | searcher.clearSearchCache(); 767 | const result5 = searcher.load(existingFile); 768 | expect(fsReadFileCalls()).toBe(3); 769 | expect(result4).toEqual(result5); 770 | // same reference 771 | expect(result4 === result5).toEqual(true); 772 | }); 773 | }); 774 | describe('disabled', () => { 775 | it('async search()', async () => { 776 | const stopDir = path.join(__dirname, 'search'); 777 | const searchFrom = path.join(stopDir, 'a', 'b', 'c'); 778 | const searchPlaces = ['cached.config.js', 'package.json']; 779 | const searcher = lilconfig('cached', { 780 | cache: false, 781 | stopDir, 782 | searchPlaces, 783 | }); 784 | const fsLookUps = () => 785 | // @ts-expect-error 786 | fs.promises.access.mock.calls.length; 787 | 788 | expect(fsLookUps()).toBe(0); 789 | 790 | const expectedFsLookUps = 7; 791 | 792 | // initial search populates cache 793 | const result = await searcher.search(searchFrom); 794 | 795 | expect(fsLookUps()).toBe(expectedFsLookUps); 796 | 797 | // subsequant search reads from cache 798 | const result2 = await searcher.search(searchFrom); 799 | expect(fsLookUps()).toBe(expectedFsLookUps * 2); 800 | expect(result).toEqual(result2); 801 | 802 | expect(result2 === result).toBe(false); 803 | }); 804 | 805 | it('sync search()', () => { 806 | const stopDir = path.join(__dirname, 'search'); 807 | const searchFrom = path.join(stopDir, 'a', 'b', 'c'); 808 | const searchPlaces = ['cached.config.js', 'package.json']; 809 | const searcher = lilconfigSync('cached', { 810 | cache: false, 811 | stopDir, 812 | searchPlaces, 813 | }); 814 | const fsLookUps = () => 815 | // @ts-expect-error 816 | fs.accessSync.mock.calls.length; 817 | 818 | expect(fsLookUps()).toBe(0); 819 | 820 | const expectedFsLookUps = 7; 821 | 822 | // initial search populates cache 823 | const result = searcher.search(searchFrom); 824 | 825 | expect(fsLookUps()).toBe(expectedFsLookUps); 826 | 827 | // subsequent search reads from cache 828 | const result2 = searcher.search(searchFrom); 829 | expect(fsLookUps()).toBe(expectedFsLookUps * 2); 830 | expect(result).toEqual(result2); 831 | 832 | expect(result2 === result).toBe(false); 833 | }); 834 | 835 | it('async load()', async () => { 836 | const stopDir = path.join(__dirname, 'search'); 837 | const searchPlaces = ['cached.config.js', 'package.json']; 838 | const searcher = lilconfig('cached', { 839 | cache: false, 840 | stopDir, 841 | searchPlaces, 842 | }); 843 | const existingFile = path.join(stopDir, 'cached.config.js'); 844 | const fsReadFileCalls = () => 845 | // @ts-expect-error 846 | fs.promises.readFile.mock.calls.length; 847 | 848 | expect(fsReadFileCalls()).toBe(0); 849 | 850 | // initial search populates cache 851 | const result = await searcher.load(existingFile); 852 | expect(fsReadFileCalls()).toBe(1); 853 | 854 | // subsequant load reads from cache 855 | const result2 = await searcher.load(existingFile); 856 | expect(fsReadFileCalls()).toBe(2); 857 | expect(result).toEqual(result2); 858 | // different reference 859 | expect(result === result2).toEqual(false); 860 | }); 861 | 862 | it('sync load()', () => { 863 | const stopDir = path.join(__dirname, 'search'); 864 | const searchPlaces = ['cached.config.js', 'package.json']; 865 | const searcher = lilconfigSync('cached', { 866 | cache: false, 867 | stopDir, 868 | searchPlaces, 869 | }); 870 | const existingFile = path.join(stopDir, 'cached.config.js'); 871 | const fsReadFileCalls = () => 872 | // @ts-expect-error 873 | fs.readFileSync.mock.calls.length; 874 | 875 | expect(fsReadFileCalls()).toBe(0); 876 | 877 | // initial search populates cache 878 | const result = searcher.load(existingFile); 879 | expect(fsReadFileCalls()).toBe(1); 880 | 881 | // subsequant load reads from cache 882 | const result2 = searcher.load(existingFile); 883 | expect(fsReadFileCalls()).toBe(2); 884 | expect(result).toEqual(result2); 885 | // differnt reference 886 | expect(result === result2).toEqual(false); 887 | }); 888 | }); 889 | }); 890 | 891 | describe('packageProp', () => { 892 | describe('plain property string', () => { 893 | const dirname = path.join(__dirname, 'load'); 894 | const options = {packageProp: 'foo'}; 895 | const filepath = path.join(dirname, 'package.json'); 896 | const relativeFilepath = filepath.slice(process.cwd().length + 1); 897 | const expected = { 898 | config: { 899 | insideFoo: true, 900 | }, 901 | filepath, 902 | }; 903 | 904 | it('sync', () => { 905 | const result = lilconfigSync('foo', options).load(relativeFilepath); 906 | const ccResult = cosmiconfigSync('foo', options).load(relativeFilepath); 907 | 908 | expect(result).toEqual(expected); 909 | expect(ccResult).toEqual(expected); 910 | }); 911 | it('async', async () => { 912 | const result = await lilconfig('foo', options).load(relativeFilepath); 913 | const ccResult = await cosmiconfig('foo', options).load( 914 | relativeFilepath, 915 | ); 916 | 917 | expect(result).toEqual(expected); 918 | expect(ccResult).toEqual(expected); 919 | }); 920 | }); 921 | 922 | describe('array of strings', () => { 923 | const filepath = path.join(__dirname, 'search', 'a', 'package.json'); 924 | const relativeFilepath = filepath.slice(process.cwd().length + 1); 925 | const options = { 926 | packageProp: 'bar.baz', 927 | stopDir: path.join(__dirname, 'search'), 928 | }; 929 | const expected = { 930 | config: { 931 | insideBarBaz: true, 932 | }, 933 | filepath, 934 | }; 935 | 936 | it('sync', () => { 937 | const result = lilconfigSync('foo', options).load(relativeFilepath); 938 | const ccResult = cosmiconfigSync('foo', options).load(relativeFilepath); 939 | 940 | expect(result).toEqual(expected); 941 | expect(ccResult).toEqual(expected); 942 | }); 943 | it('async', async () => { 944 | const result = await lilconfig('foo', options).load(relativeFilepath); 945 | const ccResult = await cosmiconfig('foo', options).load( 946 | relativeFilepath, 947 | ); 948 | 949 | expect(result).toEqual(expected); 950 | expect(ccResult).toEqual(expected); 951 | }); 952 | }); 953 | 954 | describe('string[] with null in the middle', () => { 955 | const searchFrom = path.join(__dirname, 'search', 'a', 'b', 'c'); 956 | const options = { 957 | packageProp: 'bar.baz', 958 | stopDir: path.join(__dirname, 'search'), 959 | }; 960 | /** 961 | * cosmiconfig throws when there is `null` value in the chain of package prop keys 962 | */ 963 | 964 | const expectedMessage = 965 | parseInt(process.version.slice(1), 10) > 14 966 | ? "Cannot read properties of null (reading 'baz')" 967 | : "Cannot read property 'baz' of null"; 968 | 969 | it('sync', () => { 970 | expect(() => { 971 | lilconfigSync('foo', options).search(searchFrom); 972 | }).toThrowError(expectedMessage); 973 | expect(() => { 974 | cosmiconfigSync('foo', options).search(searchFrom); 975 | }).toThrowError(expectedMessage); 976 | }); 977 | it('async', async () => { 978 | expect( 979 | lilconfig('foo', options).search(searchFrom), 980 | ).rejects.toThrowError(expectedMessage); 981 | expect( 982 | cosmiconfig('foo', options).search(searchFrom), 983 | ).rejects.toThrowError(expectedMessage); 984 | }); 985 | }); 986 | 987 | describe('string[] with result', () => { 988 | const searchFrom = path.join(__dirname, 'search', 'a', 'b', 'c'); 989 | const options = { 990 | packageProp: 'zoo.foo', 991 | stopDir: path.join(__dirname, 'search'), 992 | }; 993 | const expected = { 994 | config: { 995 | insideZooFoo: true, 996 | }, 997 | filepath: path.join(__dirname, 'search', 'a', 'package.json'), 998 | }; 999 | 1000 | it('sync', () => { 1001 | const result = lilconfigSync('foo', options).search(searchFrom); 1002 | const ccResult = cosmiconfigSync('foo', options).search(searchFrom); 1003 | 1004 | expect(result).toEqual(expected); 1005 | expect(ccResult).toEqual(expected); 1006 | }); 1007 | it('async', async () => { 1008 | const result = await lilconfig('foo', options).search(searchFrom); 1009 | const ccResult = await cosmiconfig('foo', options).search(searchFrom); 1010 | 1011 | expect(result).toEqual(expected); 1012 | expect(ccResult).toEqual(expected); 1013 | }); 1014 | }); 1015 | }); 1016 | }); 1017 | describe('lilconfigSync', () => { 1018 | describe('load', () => { 1019 | const dirname = path.join(__dirname, 'load'); 1020 | 1021 | it('existing js file', () => { 1022 | const filepath = path.join(dirname, 'test-app.js'); 1023 | const relativeFilepath = filepath.slice(process.cwd().length + 1); 1024 | 1025 | const result = lilconfigSync('test-app').load(relativeFilepath); 1026 | const ccResult = cosmiconfigSync('test-app').load(relativeFilepath); 1027 | 1028 | const expected = { 1029 | config: {jsTest: true}, 1030 | filepath, 1031 | }; 1032 | 1033 | expect(result).toEqual(expected); 1034 | expect(result).toEqual(ccResult); 1035 | }); 1036 | 1037 | it('existing cjs file', () => { 1038 | const filepath = path.join(dirname, 'test-app.cjs'); 1039 | const relativeFilepath = filepath.slice(process.cwd().length + 1); 1040 | 1041 | const result = lilconfigSync('test-app').load(relativeFilepath); 1042 | const ccResult = cosmiconfigSync('test-app').load(relativeFilepath); 1043 | 1044 | const expected = { 1045 | config: {jsTest: true}, 1046 | filepath, 1047 | }; 1048 | 1049 | expect(result).toEqual(expected); 1050 | expect(result).toEqual(ccResult); 1051 | }); 1052 | 1053 | it('existing json file', () => { 1054 | const filepath = path.join(dirname, 'test-app.json'); 1055 | const relativeFilepath = filepath.slice(process.cwd().length + 1); 1056 | 1057 | const result = lilconfigSync('test-app').load(relativeFilepath); 1058 | const ccResult = cosmiconfigSync('test-app').load(relativeFilepath); 1059 | 1060 | const expected = { 1061 | config: {jsonTest: true}, 1062 | filepath, 1063 | }; 1064 | 1065 | expect(result).toEqual(expected); 1066 | expect(result).toEqual(ccResult); 1067 | }); 1068 | 1069 | it('no extension json file', () => { 1070 | const filepath = path.join(dirname, 'test-noExt-json'); 1071 | const relativeFilepath = filepath.slice(process.cwd().length + 1); 1072 | 1073 | const result = lilconfigSync('test-app').load(relativeFilepath); 1074 | const ccResult = cosmiconfigSync('test-app').load(relativeFilepath); 1075 | 1076 | const expected = { 1077 | config: {noExtJsonFile: true}, 1078 | filepath, 1079 | }; 1080 | 1081 | expect(result).toEqual(expected); 1082 | expect(result).toEqual(ccResult); 1083 | }); 1084 | 1085 | it('package.json', () => { 1086 | const filepath = path.join(dirname, 'package.json'); 1087 | const relativeFilepath = filepath.slice(process.cwd().length + 1); 1088 | 1089 | const options = {}; 1090 | const result = lilconfigSync('test-app', options).load(relativeFilepath); 1091 | const ccResult = cosmiconfigSync('test-app', options).load( 1092 | relativeFilepath, 1093 | ); 1094 | 1095 | const expected = { 1096 | config: { 1097 | customThingHere: 'is-configured', 1098 | }, 1099 | filepath, 1100 | }; 1101 | 1102 | expect(result).toEqual(expected); 1103 | expect(ccResult).toEqual(expected); 1104 | }); 1105 | }); 1106 | 1107 | describe('search', () => { 1108 | const dirname = path.join(__dirname, 'search'); 1109 | 1110 | it('default for searchFrom', () => { 1111 | const result = lilconfigSync('non-existent').search(); 1112 | const ccResult = cosmiconfigSync('non-existent').search(); 1113 | 1114 | const expected = null; 1115 | 1116 | expect(result).toEqual(expected); 1117 | expect(ccResult).toEqual(expected); 1118 | }); 1119 | 1120 | it('checks in hidden .config dir', () => { 1121 | const searchFrom = path.join(dirname, 'a', 'b', 'c'); 1122 | 1123 | const result = lilconfigSync('hidden').search(searchFrom); 1124 | const ccResult = cosmiconfigSync('hidden').search(searchFrom); 1125 | 1126 | const expected = {hidden: true}; 1127 | 1128 | expect(result?.config).toEqual(expected); 1129 | expect(ccResult?.config).toEqual(expected); 1130 | }); 1131 | 1132 | if (process.platform !== 'win32') { 1133 | it('default for searchFrom till root directory', () => { 1134 | const options = {stopDir: '/'}; 1135 | const result = lilconfigSync('non-existent', options).search(); 1136 | expect( 1137 | // @ts-expect-error 1138 | fs.accessSync.mock.calls.slice(-10), 1139 | ).toEqual([ 1140 | ['/package.json'], 1141 | ['/.non-existentrc.json'], 1142 | ['/.non-existentrc.js'], 1143 | ['/.non-existentrc.cjs'], 1144 | ['/.config/non-existentrc'], 1145 | ['/.config/non-existentrc.json'], 1146 | ['/.config/non-existentrc.js'], 1147 | ['/.config/non-existentrc.cjs'], 1148 | ['/non-existent.config.js'], 1149 | ['/non-existent.config.cjs'], 1150 | ]); 1151 | const ccResult = cosmiconfigSync('non-existent', options).search(); 1152 | 1153 | const expected = null; 1154 | 1155 | expect(result).toEqual(expected); 1156 | expect(ccResult).toEqual(expected); 1157 | }); 1158 | } 1159 | 1160 | it('provided searchFrom', () => { 1161 | const searchFrom = path.join(dirname, 'a', 'b', 'c'); 1162 | 1163 | const options = { 1164 | stopDir: dirname, 1165 | }; 1166 | 1167 | const result = lilconfigSync('non-existent', options).search(searchFrom); 1168 | const ccResult = cosmiconfigSync('non-existent', options).search( 1169 | searchFrom, 1170 | ); 1171 | 1172 | const expected = null; 1173 | 1174 | expect(result).toEqual(expected); 1175 | expect(ccResult).toEqual(expected); 1176 | }); 1177 | 1178 | it('treating empty configs', () => { 1179 | const searchFrom = path.join(dirname, 'a', 'b', 'c'); 1180 | 1181 | const options = { 1182 | stopDir: dirname, 1183 | }; 1184 | 1185 | const result = lilconfigSync('maybeEmpty', options).search(searchFrom); 1186 | const ccResult = cosmiconfigSync('maybeEmpty', options).search( 1187 | searchFrom, 1188 | ); 1189 | 1190 | const expected = { 1191 | config: { 1192 | notSoEmpty: true, 1193 | }, 1194 | filepath: path.join(dirname, 'a', 'maybeEmpty.config.js'), 1195 | }; 1196 | 1197 | expect(result).toEqual(expected); 1198 | expect(ccResult).toEqual(expected); 1199 | }); 1200 | 1201 | it('treating empty configs with ignoreEmptySearchPlaces off', () => { 1202 | const searchFrom = path.join(dirname, 'a', 'b', 'c'); 1203 | 1204 | const options = { 1205 | stopDir: dirname, 1206 | ignoreEmptySearchPlaces: false, 1207 | }; 1208 | 1209 | const result = lilconfigSync('maybeEmpty', options).search(searchFrom); 1210 | const ccResult = cosmiconfigSync('maybeEmpty', options).search( 1211 | searchFrom, 1212 | ); 1213 | 1214 | const expected = { 1215 | config: undefined, 1216 | filepath: path.join(dirname, 'a', 'b', 'maybeEmpty.config.js'), 1217 | isEmpty: true, 1218 | }; 1219 | 1220 | expect(result).toEqual(expected); 1221 | expect(ccResult).toEqual(expected); 1222 | }); 1223 | }); 1224 | 1225 | describe('when to throw', () => { 1226 | it('loader throws', () => { 1227 | const dirname = path.join(__dirname, 'search'); 1228 | const searchFrom = path.join(dirname, 'a', 'b', 'c'); 1229 | 1230 | class LoaderError extends Error {} 1231 | 1232 | const options = { 1233 | loaders: { 1234 | '.js'() { 1235 | throw new LoaderError(); 1236 | }, 1237 | }, 1238 | }; 1239 | 1240 | expect(() => { 1241 | lilconfigSync('maybeEmpty', options).search(searchFrom); 1242 | }).toThrowError(LoaderError); 1243 | expect(() => { 1244 | cosmiconfigSync('maybeEmpty', options).search(searchFrom); 1245 | }).toThrowError(LoaderError); 1246 | }); 1247 | 1248 | it('non existing file', () => { 1249 | const dirname = path.join(__dirname, 'load'); 1250 | const filepath = path.join(dirname, 'nope.json'); 1251 | const relativeFilepath = filepath.slice(process.cwd().length + 1); 1252 | 1253 | expect(() => { 1254 | lilconfigSync('test-app').load(relativeFilepath); 1255 | }).toThrowError(`ENOENT: no such file or directory, open '${filepath}'`); 1256 | 1257 | expect(() => { 1258 | cosmiconfigSync('test-app').load(relativeFilepath); 1259 | }).toThrowError(`ENOENT: no such file or directory, open '${filepath}'`); 1260 | }); 1261 | 1262 | it('throws for invalid json', () => { 1263 | const dirname = path.join(__dirname, 'load'); 1264 | const filepath = path.join(dirname, 'test-invalid.json'); 1265 | const relativeFilepath = filepath.slice(process.cwd().length + 1); 1266 | 1267 | /** 1268 | * throws but less elegant 1269 | */ 1270 | expect(() => { 1271 | lilconfigSync('test-app').load(relativeFilepath); 1272 | }).toThrowError( 1273 | isNodeV20orNewer 1274 | ? `Expected ',' or '}' after property value in JSON at position 22` 1275 | : 'Unexpected token / in JSON at position 22', 1276 | ); 1277 | 1278 | expect(() => { 1279 | cosmiconfigSync('test-app').load(relativeFilepath); 1280 | }).toThrowError(`JSON Error in ${filepath}:`); 1281 | }); 1282 | 1283 | it('throws for provided filepath that does not exist', () => { 1284 | const dirname = path.join(__dirname, 'load'); 1285 | const filepath = path.join(dirname, 'i-do-no-exist.js'); 1286 | const relativeFilepath = filepath.slice(process.cwd().length + 1); 1287 | const errMsg = `ENOENT: no such file or directory, open '${filepath}'`; 1288 | 1289 | expect(() => { 1290 | lilconfigSync('test-app', {}).load(relativeFilepath); 1291 | }).toThrowError(errMsg); 1292 | expect(() => { 1293 | cosmiconfigSync('test-app', {}).load(relativeFilepath); 1294 | }).toThrowError(errMsg); 1295 | }); 1296 | 1297 | it('no loader specified for the search place', () => { 1298 | const filepath = path.join(__dirname, 'load', 'config.coffee'); 1299 | const relativeFilepath = filepath.slice(process.cwd().length + 1); 1300 | 1301 | const errMsg = 'No loader specified for extension ".coffee"'; 1302 | 1303 | expect(() => { 1304 | lilconfigSync('test-app').load(relativeFilepath); 1305 | }).toThrowError(errMsg); 1306 | expect(() => { 1307 | cosmiconfigSync('test-app').load(relativeFilepath); 1308 | }).toThrowError(errMsg); 1309 | }); 1310 | 1311 | it('loader is not a function', () => { 1312 | const filepath = path.join(__dirname, 'load', 'config.coffee'); 1313 | const relativeFilepath = filepath.slice(process.cwd().length + 1); 1314 | const options = { 1315 | loaders: { 1316 | '.coffee': true, 1317 | }, 1318 | }; 1319 | 1320 | const errMsg = 'loader is not a function'; 1321 | 1322 | expect(() => { 1323 | // @ts-expect-error: unit test is literally for this purpose 1324 | lilconfigSync('test-app', options).load(relativeFilepath); 1325 | }).toThrowError(errMsg); 1326 | expect(() => { 1327 | // @ts-ignore: unit test is literally for this purpose 1328 | cosmiconfigSync('test-app', options).load(relativeFilepath); 1329 | }).toThrowError(errMsg); 1330 | }); 1331 | 1332 | it('no extension loader throws for unparsable file', () => { 1333 | const filepath = path.join(__dirname, 'load', 'test-noExt-nonParsable'); 1334 | const relativeFilepath = filepath.slice(process.cwd().length + 1); 1335 | 1336 | expect(() => { 1337 | lilconfigSync('test-app').load(relativeFilepath); 1338 | }).toThrowError( 1339 | isNodeV20orNewer 1340 | ? `Unexpected token 'h', "hobbies:\n- "Reading\n` 1341 | : 'Unexpected token # in JSON at position 2', 1342 | ); 1343 | expect(() => { 1344 | cosmiconfigSync('test-app').load(relativeFilepath); 1345 | }).toThrowError(`YAML Error in ${filepath}`); 1346 | }); 1347 | 1348 | it('throws for empty strings passed to load', () => { 1349 | const errMsg = 'load must pass a non-empty string'; 1350 | 1351 | expect(() => { 1352 | lilconfigSync('test-app').load(''); 1353 | }).toThrowError(errMsg); 1354 | expect(() => { 1355 | cosmiconfigSync('test-app').load(''); 1356 | }).toThrowError('EISDIR: illegal operation on a directory, read'); 1357 | }); 1358 | 1359 | it('throws when provided searchPlace has no loader', () => { 1360 | const errMsg = 'Missing loader for extension "file.coffee"'; 1361 | expect(() => { 1362 | lilconfigSync('foo', { 1363 | searchPlaces: ['file.coffee'], 1364 | }); 1365 | }).toThrowError(errMsg); 1366 | expect(() => { 1367 | cosmiconfigSync('foo', { 1368 | searchPlaces: ['file.coffee'], 1369 | }); 1370 | }).toThrowError(errMsg); 1371 | }); 1372 | 1373 | it('throws when a loader for a searchPlace is not a function', () => { 1374 | const errMsg = 1375 | 'Loader for extension "file.js" is not a function: Received object.'; 1376 | const options = { 1377 | searchPlaces: ['file.js'], 1378 | loaders: { 1379 | '.js': {}, 1380 | }, 1381 | }; 1382 | expect(() => { 1383 | lilconfigSync( 1384 | 'foo', 1385 | // @ts-expect-error: unit test is literally for this purpose 1386 | options, 1387 | ); 1388 | }).toThrowError(errMsg); 1389 | expect(() => { 1390 | cosmiconfigSync( 1391 | 'foo', 1392 | // @ts-ignore: needed for jest 1393 | options, 1394 | ); 1395 | }).toThrowError(errMsg); 1396 | }); 1397 | 1398 | it('throws for searchPlaces with no extension', () => { 1399 | const errMsg = 1400 | 'Loader for extension "file" is not a function: Received object.'; 1401 | const options = { 1402 | searchPlaces: ['file'], 1403 | loaders: { 1404 | noExt: {}, 1405 | }, 1406 | }; 1407 | expect(() => { 1408 | lilconfigSync( 1409 | 'foo', 1410 | // @ts-expect-error: unit test is literally for this purpose 1411 | options, 1412 | ); 1413 | }).toThrowError(errMsg); 1414 | expect(() => { 1415 | cosmiconfigSync( 1416 | 'foo', 1417 | // @ts-ignore: needed for jest 1418 | options, 1419 | ); 1420 | }).toThrowError(errMsg); 1421 | }); 1422 | }); 1423 | }); 1424 | 1425 | describe('lilconfig', () => { 1426 | describe('load', () => { 1427 | const dirname = path.join(__dirname, 'load'); 1428 | 1429 | it('existing js file', async () => { 1430 | const filepath = path.join(dirname, 'test-app.js'); 1431 | const relativeFilepath = filepath.slice(process.cwd().length + 1); 1432 | const result = await lilconfig('test-app').load(relativeFilepath); 1433 | const ccResult = await cosmiconfig('test-app').load(relativeFilepath); 1434 | 1435 | const expected = { 1436 | config: {jsTest: true}, 1437 | filepath, 1438 | }; 1439 | 1440 | expect(result).toEqual(expected); 1441 | expect(result).toEqual(ccResult); 1442 | }); 1443 | 1444 | it('existing cjs file', async () => { 1445 | const filepath = path.join(dirname, 'test-app.cjs'); 1446 | const relativeFilepath = filepath.slice(process.cwd().length + 1); 1447 | const result = await lilconfig('test-app').load(relativeFilepath); 1448 | const ccResult = await cosmiconfig('test-app').load(relativeFilepath); 1449 | 1450 | const expected = { 1451 | config: {jsTest: true}, 1452 | filepath, 1453 | }; 1454 | 1455 | expect(result).toEqual(expected); 1456 | expect(result).toEqual(ccResult); 1457 | }); 1458 | 1459 | it('existing json file', async () => { 1460 | const filepath = path.join(dirname, 'test-app.json'); 1461 | const relativeFilepath = filepath.slice(process.cwd().length + 1); 1462 | const result = await lilconfig('test-app').load(relativeFilepath); 1463 | const ccResult = await cosmiconfig('test-app').load(relativeFilepath); 1464 | 1465 | const expected = { 1466 | config: {jsonTest: true}, 1467 | filepath, 1468 | }; 1469 | 1470 | expect(result).toEqual(expected); 1471 | expect(result).toEqual(ccResult); 1472 | }); 1473 | 1474 | it('no extension json file', async () => { 1475 | const filepath = path.join(dirname, 'test-noExt-json'); 1476 | const relativeFilepath = filepath.slice(process.cwd().length + 1); 1477 | 1478 | const result = await lilconfig('test-app').load(relativeFilepath); 1479 | const ccResult = await cosmiconfig('test-app').load(relativeFilepath); 1480 | 1481 | const expected = { 1482 | config: {noExtJsonFile: true}, 1483 | filepath, 1484 | }; 1485 | 1486 | expect(result).toEqual(expected); 1487 | expect(result).toEqual(ccResult); 1488 | }); 1489 | 1490 | it('package.json', async () => { 1491 | const filepath = path.join(dirname, 'package.json'); 1492 | const relativeFilepath = filepath.slice(process.cwd().length + 1); 1493 | const options = {}; 1494 | const result = await lilconfig('test-app', options).load( 1495 | relativeFilepath, 1496 | ); 1497 | const ccResult = await cosmiconfig('test-app', options).load( 1498 | relativeFilepath, 1499 | ); 1500 | 1501 | const expected = { 1502 | config: { 1503 | customThingHere: 'is-configured', 1504 | }, 1505 | filepath, 1506 | }; 1507 | 1508 | expect(result).toEqual(expected); 1509 | expect(ccResult).toEqual(expected); 1510 | }); 1511 | }); 1512 | 1513 | describe('search', () => { 1514 | const dirname = path.join(__dirname, 'search'); 1515 | 1516 | it('returns null when no config found', async () => { 1517 | const result = await lilconfig('non-existent').search(); 1518 | const ccResult = await cosmiconfig('non-existent').search(); 1519 | 1520 | const expected = null; 1521 | 1522 | expect(result).toEqual(expected); 1523 | expect(ccResult).toEqual(expected); 1524 | }); 1525 | 1526 | it('checks in hidden .config dir', async () => { 1527 | const searchFrom = path.join(dirname, 'a', 'b', 'c'); 1528 | 1529 | const result = await lilconfig('hidden').search(searchFrom); 1530 | const ccResult = await cosmiconfig('hidden').search(searchFrom); 1531 | 1532 | const expected = {hidden: true}; 1533 | 1534 | expect(result?.config).toEqual(expected); 1535 | expect(ccResult?.config).toEqual(expected); 1536 | }); 1537 | 1538 | if (process.platform !== 'win32') { 1539 | it('searches root directory correctly', async () => { 1540 | const options = {stopDir: '/'}; 1541 | const result = await lilconfig('non-existent', options).search(); 1542 | expect( 1543 | // @ts-expect-error 1544 | fs.promises.access.mock.calls.slice(-13), 1545 | ).toEqual([ 1546 | ['/package.json'], 1547 | ['/.non-existentrc.json'], 1548 | ['/.non-existentrc.js'], 1549 | ['/.non-existentrc.cjs'], 1550 | ['/.non-existentrc.mjs'], 1551 | ['/.config/non-existentrc'], 1552 | ['/.config/non-existentrc.json'], 1553 | ['/.config/non-existentrc.js'], 1554 | ['/.config/non-existentrc.cjs'], 1555 | ['/.config/non-existentrc.mjs'], 1556 | ['/non-existent.config.js'], 1557 | ['/non-existent.config.cjs'], 1558 | ['/non-existent.config.mjs'], 1559 | ]); 1560 | const ccResult = await cosmiconfig('non-existent', options).search(); 1561 | 1562 | const expected = null; 1563 | 1564 | expect(result).toEqual(expected); 1565 | expect(ccResult).toEqual(expected); 1566 | }); 1567 | } 1568 | 1569 | it('provided searchFrom', async () => { 1570 | const searchFrom = path.join(dirname, 'a', 'b', 'c'); 1571 | 1572 | const options = { 1573 | stopDir: dirname, 1574 | }; 1575 | 1576 | const result = await lilconfig('non-existent', options).search( 1577 | searchFrom, 1578 | ); 1579 | const ccResult = await cosmiconfig('non-existent', options).search( 1580 | searchFrom, 1581 | ); 1582 | 1583 | const expected = null; 1584 | 1585 | expect(result).toEqual(expected); 1586 | expect(ccResult).toEqual(expected); 1587 | }); 1588 | 1589 | it('treating empty configs', async () => { 1590 | const searchFrom = path.join(dirname, 'a', 'b', 'c'); 1591 | 1592 | const options = { 1593 | stopDir: dirname, 1594 | }; 1595 | 1596 | const result = await lilconfig('maybeEmpty', options).search(searchFrom); 1597 | const ccResult = await cosmiconfig('maybeEmpty', options).search( 1598 | searchFrom, 1599 | ); 1600 | 1601 | const expected = { 1602 | config: { 1603 | notSoEmpty: true, 1604 | }, 1605 | filepath: path.join(dirname, 'a', 'maybeEmpty.config.js'), 1606 | }; 1607 | 1608 | expect(result).toEqual(expected); 1609 | expect(ccResult).toEqual(expected); 1610 | }); 1611 | 1612 | it('treating empty configs with ignoreEmptySearchPlaces off', async () => { 1613 | const searchFrom = path.join(dirname, 'a', 'b', 'c'); 1614 | 1615 | const options = { 1616 | stopDir: dirname, 1617 | ignoreEmptySearchPlaces: false, 1618 | }; 1619 | 1620 | const result = await lilconfig('maybeEmpty', options).search(searchFrom); 1621 | const ccResult = await cosmiconfig('maybeEmpty', options).search( 1622 | searchFrom, 1623 | ); 1624 | 1625 | const expected = { 1626 | config: undefined, 1627 | filepath: path.join(dirname, 'a', 'b', 'maybeEmpty.config.js'), 1628 | isEmpty: true, 1629 | }; 1630 | 1631 | expect(result).toEqual(expected); 1632 | expect(ccResult).toEqual(expected); 1633 | }); 1634 | }); 1635 | 1636 | describe('when to throw', () => { 1637 | it('loader throws', async () => { 1638 | const dirname = path.join(__dirname, 'search'); 1639 | const searchFrom = path.join(dirname, 'a', 'b', 'c'); 1640 | 1641 | class LoaderError extends Error {} 1642 | 1643 | const options = { 1644 | loaders: { 1645 | '.js': () => { 1646 | throw new LoaderError(); 1647 | }, 1648 | }, 1649 | }; 1650 | 1651 | const result = await lilconfig('maybeEmpty', options) 1652 | .search(searchFrom) 1653 | .catch(x => x); 1654 | const ccResult = await cosmiconfig('maybeEmpty', options) 1655 | .search(searchFrom) 1656 | .catch(x => x); 1657 | 1658 | expect(result instanceof LoaderError).toBeTruthy(); 1659 | expect(ccResult instanceof LoaderError).toBeTruthy(); 1660 | }); 1661 | 1662 | it('non existing file', async () => { 1663 | const dirname = path.join(__dirname, 'load'); 1664 | const filepath = path.join(dirname, 'nope.json'); 1665 | const relativeFilepath = filepath.slice(process.cwd().length + 1); 1666 | 1667 | const errMsg = `ENOENT: no such file or directory, open '${filepath}'`; 1668 | 1669 | expect(lilconfig('test-app').load(relativeFilepath)).rejects.toThrowError( 1670 | errMsg, 1671 | ); 1672 | 1673 | expect( 1674 | cosmiconfig('test-app').load(relativeFilepath), 1675 | ).rejects.toThrowError(errMsg); 1676 | }); 1677 | 1678 | it('throws for invalid json', async () => { 1679 | const dirname = path.join(__dirname, 'load'); 1680 | const filepath = path.join(dirname, 'test-invalid.json'); 1681 | const relativeFilepath = filepath.slice(process.cwd().length + 1); 1682 | 1683 | /** 1684 | * throws but less elegant 1685 | */ 1686 | expect(lilconfig('test-app').load(relativeFilepath)).rejects.toThrowError( 1687 | isNodeV20orNewer 1688 | ? `Expected ',' or '}' after property value in JSON at position 22` 1689 | : 'Unexpected token / in JSON at position 22', 1690 | ); 1691 | 1692 | expect( 1693 | cosmiconfig('test-app').load(relativeFilepath), 1694 | ).rejects.toThrowError(`JSON Error in ${filepath}:`); 1695 | }); 1696 | 1697 | it('throws for provided filepath that does not exist', async () => { 1698 | const dirname = path.join(__dirname, 'load'); 1699 | const filepath = path.join(dirname, 'i-do-no-exist.js'); 1700 | const relativeFilepath = filepath.slice(process.cwd().length + 1); 1701 | const errMsg = `ENOENT: no such file or directory, open '${filepath}'`; 1702 | 1703 | expect( 1704 | lilconfig('test-app', {}).load(relativeFilepath), 1705 | ).rejects.toThrowError(errMsg); 1706 | expect( 1707 | cosmiconfig('test-app', {}).load(relativeFilepath), 1708 | ).rejects.toThrowError(errMsg); 1709 | }); 1710 | 1711 | it('no loader specified for the search place', async () => { 1712 | const filepath = path.join(__dirname, 'load', 'config.coffee'); 1713 | const relativeFilepath = filepath.slice(process.cwd().length + 1); 1714 | 1715 | const errMsg = 'No loader specified for extension ".coffee"'; 1716 | 1717 | expect(lilconfig('test-app').load(relativeFilepath)).rejects.toThrowError( 1718 | errMsg, 1719 | ); 1720 | expect( 1721 | cosmiconfig('test-app').load(relativeFilepath), 1722 | ).rejects.toThrowError(errMsg); 1723 | }); 1724 | 1725 | it('loader is not a function', async () => { 1726 | const filepath = path.join(__dirname, 'load', 'config.coffee'); 1727 | const relativeFilepath = filepath.slice(process.cwd().length + 1); 1728 | const options = { 1729 | loaders: { 1730 | '.coffee': true, 1731 | }, 1732 | }; 1733 | 1734 | const errMsg = 'loader is not a function'; 1735 | 1736 | expect( 1737 | lilconfig( 1738 | 'test-app', 1739 | // @ts-expect-error: for unit test purpose 1740 | options, 1741 | ).load(relativeFilepath), 1742 | ).rejects.toThrowError(errMsg); 1743 | expect( 1744 | // @ts-ignore: required for jest, but not ts used in editor 1745 | cosmiconfig('test-app', options).load(relativeFilepath), 1746 | ).rejects.toThrowError(errMsg); 1747 | }); 1748 | 1749 | it('no extension loader throws for unparsable file', async () => { 1750 | const filepath = path.join(__dirname, 'load', 'test-noExt-nonParsable'); 1751 | const relativeFilepath = filepath.slice(process.cwd().length + 1); 1752 | 1753 | await expect( 1754 | lilconfig('test-app').load(relativeFilepath), 1755 | ).rejects.toThrowError( 1756 | isNodeV20orNewer 1757 | ? `Unexpected token 'h', "hobbies:\n- "Reading\n" is not valid JSON` 1758 | : 'Unexpected token h in JSON at position 0', 1759 | ); 1760 | await expect( 1761 | cosmiconfig('test-app').load(relativeFilepath), 1762 | ).rejects.toThrowError(`YAML Error in ${filepath}`); 1763 | }); 1764 | 1765 | it('throws for empty strings passed to load', async () => { 1766 | const errMsg = 'load must pass a non-empty string'; 1767 | 1768 | expect(lilconfig('test-app').load('')).rejects.toThrowError(errMsg); 1769 | expect(cosmiconfig('test-app').load('')).rejects.toThrowError( 1770 | 'EISDIR: illegal operation on a directory, read', 1771 | ); 1772 | }); 1773 | 1774 | it('throws when provided searchPlace has no loader', () => { 1775 | const errMsg = 'Missing loader for extension "file.coffee"'; 1776 | expect(() => 1777 | lilconfig('foo', { 1778 | searchPlaces: ['file.coffee'], 1779 | }), 1780 | ).toThrowError(errMsg); 1781 | expect(() => 1782 | cosmiconfig('foo', { 1783 | searchPlaces: ['file.coffee'], 1784 | }), 1785 | ).toThrowError(errMsg); 1786 | }); 1787 | 1788 | it('throws when a loader for a searchPlace is not a function', () => { 1789 | const errMsg = 1790 | 'Loader for extension "file.js" is not a function: Received object'; 1791 | const options = { 1792 | searchPlaces: ['file.js'], 1793 | loaders: { 1794 | '.js': {}, 1795 | }, 1796 | }; 1797 | expect(() => 1798 | lilconfig( 1799 | 'foo', 1800 | // @ts-expect-error: for unit test purpose 1801 | options, 1802 | ), 1803 | ).toThrowError(errMsg); 1804 | expect(() => 1805 | cosmiconfig( 1806 | 'foo', 1807 | // @ts-ignore: needed for jest 1808 | options, 1809 | ), 1810 | ).toThrowError(errMsg); 1811 | }); 1812 | 1813 | it('throws for searchPlaces with no extension', () => { 1814 | const errMsg = 1815 | 'Loader for extension "file" is not a function: Received object.'; 1816 | const options = { 1817 | searchPlaces: ['file'], 1818 | loaders: { 1819 | noExt: {}, 1820 | }, 1821 | }; 1822 | expect(() => { 1823 | lilconfig( 1824 | 'foo', 1825 | // @ts-expect-error: for unit test purpose 1826 | options, 1827 | ); 1828 | }).toThrowError(errMsg); 1829 | expect(() => { 1830 | cosmiconfig( 1831 | 'foo', 1832 | // @ts-ignore: needed for jest, but not editor 1833 | options, 1834 | ); 1835 | }).toThrowError(errMsg); 1836 | }); 1837 | }); 1838 | }); 1839 | 1840 | describe('npm package api', () => { 1841 | it('exports the same things as cosmiconfig', () => { 1842 | const lc = require('../index'); 1843 | const cc = require('cosmiconfig'); 1844 | 1845 | expect(typeof lc.defaultLoaders).toEqual(typeof cc.defaultLoaders); 1846 | expect(typeof lc.defaultLoadersSync).toEqual(typeof cc.defaultLoadersSync); 1847 | // @ts-expect-error: not in types 1848 | expect(typeof lc.defaultLoadersAsync).toEqual( 1849 | // @ts-expect-error: not in types 1850 | typeof cc.defaultLoadersAsync, 1851 | ); 1852 | 1853 | const lcExplorerSyncKeys = Object.keys(lc.lilconfigSync('foo')); 1854 | const ccExplorerSyncKeys = Object.keys(cc.cosmiconfigSync('foo')); 1855 | 1856 | expect(lcExplorerSyncKeys).toEqual(ccExplorerSyncKeys); 1857 | 1858 | /* eslint-disable no-unused-vars */ 1859 | const omitKnownDifferKeys = ({ 1860 | lilconfig, 1861 | lilconfigSync, 1862 | cosmiconfig, 1863 | cosmiconfigSync, 1864 | metaSearchPlaces, 1865 | ...rest 1866 | }) => rest; 1867 | /* eslint-enable no-unused-vars */ 1868 | 1869 | // @ts-expect-error: not in types 1870 | expect(Object.keys(omitKnownDifferKeys(lc)).sort()).toEqual( 1871 | // @ts-expect-error: not in types 1872 | Object.keys(omitKnownDifferKeys(cc)).sort(), 1873 | ); 1874 | }); 1875 | }); 1876 | --------------------------------------------------------------------------------