diff --git a/index.d.ts b/index.d.ts index 426866c..425e324 100644 --- a/index.d.ts +++ b/index.d.ts @@ -7,6 +7,8 @@ declare namespace findUp { */ readonly cwd?: string; } + + type Match = string | symbol | undefined; } declare const findUp: { @@ -41,13 +43,34 @@ declare const findUp: { */ (name: string | string[], options?: findUp.Options): Promise; + /** + Find a file or directory by walking up parent directories. + + @param matcher - Called for each directory in the search. Return a path or `findUp.stop` to stop the search. + @returns The first path found or `undefined` if none could be found. + */ + (matcher: (directory: string) => (findUp.Match | Promise), options?: findUp.Options): Promise; + /** Synchronously find a file or directory by walking up parent directories. @param name - Name of the file or directory to find. Can be multiple. - @returns The first path found (by respecting the order of `names`s) or `undefined` if none could be found. + @returns The first path found (by respecting the order of `name`s) or `undefined` if none could be found. */ sync(name: string | string[], options?: findUp.Options): string | undefined; + + /** + Synchronously find a file or directory by walking up parent directories. + + @param matcher - Called for each directory in the search. Return a path or `findUp.stop` to stop the search. + @returns The first path found or `undefined` if none could be found. + */ + sync(matcher: (directory: string) => findUp.Match, options?: findUp.Options): string | undefined; + + /** + Return this in a `matcher` function to stop the search and force `findUp` to immediately return `undefined`. + */ + readonly stop: unique symbol; }; export = findUp; diff --git a/index.js b/index.js index 1345264..35c261d 100644 --- a/index.js +++ b/index.js @@ -2,18 +2,23 @@ const path = require('path'); const locatePath = require('locate-path'); +const stop = Symbol('findUp.stop'); + module.exports = async (name, options = {}) => { let directory = path.resolve(options.cwd || ''); const {root} = path.parse(directory); const paths = [].concat(name); - // eslint-disable-next-line no-constant-condition while (true) { // eslint-disable-next-line no-await-in-loop - const foundPath = await locatePath(paths, {cwd: directory}); + const foundPath = await (typeof name === 'function' ? name(directory) : locatePath(paths, {cwd: directory})); + + if (foundPath === stop) { + return; + } if (foundPath) { - return path.join(directory, foundPath); + return path.resolve(directory, foundPath); } if (directory === root) { @@ -31,10 +36,14 @@ module.exports.sync = (name, options = {}) => { // eslint-disable-next-line no-constant-condition while (true) { - const foundPath = locatePath.sync(paths, {cwd: directory}); + const foundPath = typeof name === 'function' ? name(directory) : locatePath.sync(paths, {cwd: directory}); + + if (foundPath === stop) { + return; + } if (foundPath) { - return path.join(directory, foundPath); + return path.resolve(directory, foundPath); } if (directory === root) { @@ -44,3 +53,5 @@ module.exports.sync = (name, options = {}) => { directory = path.dirname(directory); } }; + +module.exports.stop = stop; diff --git a/index.test-d.ts b/index.test-d.ts index 93e298f..42b1397 100644 --- a/index.test-d.ts +++ b/index.test-d.ts @@ -5,8 +5,28 @@ expectType>(findUp('unicorn.png')); expectType>(findUp('unicorn.png', {cwd: ''})); expectType>(findUp(['rainbow.png', 'unicorn.png'])); expectType>(findUp(['rainbow.png', 'unicorn.png'], {cwd: ''})); +expectType>(findUp(() => 'unicorn.png')); +expectType>(findUp(() => 'unicorn.png', {cwd: ''})); +expectType>(findUp(() => undefined)); +expectType>(findUp(() => undefined, {cwd: ''})); +expectType>(findUp(() => findUp.stop)); +expectType>(findUp(() => findUp.stop, {cwd: ''})); +expectType>(findUp(async () => 'unicorn.png')); +expectType>(findUp(async () => 'unicorn.png', {cwd: ''})); +expectType>(findUp(async () => undefined)); +expectType>(findUp(async () => undefined, {cwd: ''})); +expectType>(findUp(async () => findUp.stop)); +expectType>(findUp(async () => findUp.stop, {cwd: ''})); expectType(findUp.sync('unicorn.png')); expectType(findUp.sync('unicorn.png', {cwd: ''})); expectType(findUp.sync(['rainbow.png', 'unicorn.png'])); expectType(findUp.sync(['rainbow.png', 'unicorn.png'], {cwd: ''})); +expectType(findUp.sync(() => 'unicorn.png')); +expectType(findUp.sync(() => 'unicorn.png', {cwd: ''})); +expectType(findUp.sync(() => undefined)); +expectType(findUp.sync(() => undefined, {cwd: ''})); +expectType(findUp.sync(() => findUp.stop)); +expectType(findUp.sync(() => findUp.stop, {cwd: ''})); + +expectType(findUp.stop); diff --git a/package.json b/package.json index fbd34c3..cb92c5a 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ }, "devDependencies": { "ava": "^1.4.1", + "is-path-inside": "^2.1.0", "tempy": "^0.3.0", "tsd": "^0.7.2", "xo": "^0.24.0" diff --git a/readme.md b/readme.md index 949f2b4..c0fbbf5 100644 --- a/readme.md +++ b/readme.md @@ -39,6 +39,8 @@ $ npm install find-up `example.js` ```js +const fs = require('fs'); +const path = require('path'); const findUp = require('find-up'); (async () => { @@ -47,13 +49,22 @@ const findUp = require('find-up'); console.log(await findUp(['rainbow.png', 'unicorn.png'])); //=> '/Users/sindresorhus/unicorn.png' + + const pathExists = filepath => fs.promises.access(filepath).then(_ => true).catch(_ => false); + console.log(await findUp(async (directory) => { + const hasUnicorns = await pathExists(path.join(directory, 'unicorn.png')); + return hasUnicorns && directory; + }}); + //=> '/Users/sindresorhus' })(); ``` ## API + ### findUp(name, [options]) +### findUp(matcher, [options]) Returns a `Promise` for either the path or `undefined` if it couldn't be found. @@ -62,6 +73,7 @@ Returns a `Promise` for either the path or `undefined` if it couldn't be found. Returns a `Promise` for either the first path found (by respecting the order) or `undefined` if none could be found. ### findUp.sync(name, [options]) +### findUp.sync(matcher, [options]) Returns a path or `undefined` if it couldn't be found. @@ -75,6 +87,14 @@ Type: `string` Name of the file or directory to find. +#### matcher + +Type: `Function` + +A function that will be called with each directory until it returns a `string` with the path, which stops the search, or the root directory has been reached and nothing was found. Useful if you want to match files with certain patterns, set of permissions, or other advanced use cases. + +When using async mode, the `matcher` may optionally be an async or promise-returning function that returns the path. + #### options Type: `object` @@ -86,6 +106,20 @@ Default: `process.cwd()` Directory to start from. +### findUp.stop + +A [Symbol](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Symbol) that can be returned by a `matcher` function to stop the search and cause `findUp` to immediately return `undefined`. Useful as a performance optimization in case the current working directory is deeply nested in the filesystem. + +```js +const path = require('path'); +const findUp = require('find-up'); + +(async () => { + await findUp((directory) => { + return path.basename(directory) === 'work' ? findUp.stop : 'logo.png'; + }); +})(); +``` ## Security diff --git a/test.js b/test.js index 3a0623a..8fbb5d6 100644 --- a/test.js +++ b/test.js @@ -1,6 +1,8 @@ import fs from 'fs'; import path from 'path'; +import {promisify} from 'util'; import test from 'ava'; +import isPathInside from 'is-path-inside'; import tempy from 'tempy'; import findUp from '.'; @@ -8,13 +10,15 @@ const name = { packageDirectory: 'find-up', packageJson: 'package.json', fixtureDirectory: 'fixture', + modulesDirectory: 'node_modules', baz: 'baz.js', qux: 'qux.js' }; // These paths are relative to the project root const relative = { - fixtureDirectory: name.fixtureDirectory + fixtureDirectory: name.fixtureDirectory, + modulesDirectory: name.modulesDirectory }; relative.baz = path.join(relative.fixtureDirectory, name.baz); relative.qux = path.join(relative.fixtureDirectory, name.qux); @@ -54,13 +58,13 @@ test('sync (child file)', t => { t.is(foundPath, absolute.packageJson); }); -test('async (child dir)', async t => { +test('async (child directory)', async t => { const foundPath = await findUp(name.fixtureDirectory); t.is(foundPath, absolute.fixtureDirectory); }); -test('sync (child dir)', t => { +test('sync (child directory)', t => { const foundPath = findUp.sync(name.fixtureDirectory); t.is(foundPath, absolute.fixtureDirectory); @@ -174,19 +178,35 @@ test('sync (nested descendant file)', t => { t.is(foundPath, absolute.baz); }); -test('async (nested descendant dir)', async t => { +test('async (nested descendant directory)', async t => { const foundPath = await findUp(relative.barDir); t.is(foundPath, absolute.barDir); }); -test('sync (nested descendant dir)', t => { +test('sync (nested descendant directory)', t => { const foundPath = findUp.sync(relative.barDir); t.is(foundPath, absolute.barDir); }); -test('async (nested cousin dir, custom cwd)', async t => { +test('async (nested descendant directory, custom cwd)', async t => { + const filePath = await findUp(relative.barDir, { + cwd: relative.modulesDirectory + }); + + t.is(filePath, absolute.barDir); +}); + +test('sync (nested descendant directory, custom cwd)', t => { + const filePath = findUp.sync(relative.barDir, { + cwd: relative.modulesDirectory + }); + + t.is(filePath, absolute.barDir); +}); + +test('async (nested cousin directory, custom cwd)', async t => { const foundPath = await findUp(relative.barDir, { cwd: relative.fixtureDirectory }); @@ -194,7 +214,7 @@ test('async (nested cousin dir, custom cwd)', async t => { t.is(foundPath, absolute.barDir); }); -test('sync (nested cousin dir, custom cwd)', t => { +test('sync (nested cousin directory, custom cwd)', t => { const foundPath = findUp.sync(relative.barDir, { cwd: relative.fixtureDirectory }); @@ -202,7 +222,7 @@ test('sync (nested cousin dir, custom cwd)', t => { t.is(foundPath, absolute.barDir); }); -test('async (ancestor dir, custom cwd)', async t => { +test('async (ancestor directory, custom cwd)', async t => { const foundPath = await findUp(name.fixtureDirectory, { cwd: relative.barDir }); @@ -210,7 +230,7 @@ test('async (ancestor dir, custom cwd)', async t => { t.is(foundPath, absolute.fixtureDirectory); }); -test('sync (ancestor dir, custom cwd)', t => { +test('sync (ancestor directory, custom cwd)', t => { const foundPath = findUp.sync(name.fixtureDirectory, { cwd: relative.barDir }); @@ -218,6 +238,46 @@ test('sync (ancestor dir, custom cwd)', t => { t.is(foundPath, absolute.fixtureDirectory); }); +test('async (absolute directory)', async t => { + const filePath = await findUp(absolute.barDir); + + t.is(filePath, absolute.barDir); +}); + +test('sync (absolute directory)', t => { + const filePath = findUp.sync(absolute.barDir); + + t.is(filePath, absolute.barDir); +}); + +test('async (not found, absolute file)', async t => { + const filePath = await findUp(path.resolve('somenonexistentfile.js')); + + t.is(filePath, undefined); +}); + +test('sync (not found, absolute file)', t => { + const filePath = findUp.sync(path.resolve('somenonexistentfile.js')); + + t.is(filePath, undefined); +}); + +test('async (absolute directory, disjoint cwd)', async t => { + const filePath = await findUp(absolute.barDir, { + cwd: t.context.disjoint + }); + + t.is(filePath, absolute.barDir); +}); + +test('sync (absolute directory, disjoint cwd)', t => { + const filePath = findUp.sync(absolute.barDir, { + cwd: t.context.disjoint + }); + + t.is(filePath, absolute.barDir); +}); + test('async (not found)', async t => { const foundPath = await findUp('somenonexistentfile.js'); @@ -247,3 +307,156 @@ test('sync (not found, custom cwd)', t => { t.is(foundPath, undefined); }); + +test('async (matcher function)', async t => { + const cwd = process.cwd(); + + t.is(await findUp(directory => { + t.is(directory, cwd); + return directory; + }), cwd); + + t.is(await findUp(() => { + return '.'; + }), cwd); + + t.is(await findUp(async () => { + return 'foo.txt'; + }), path.join(cwd, 'foo.txt')); + + t.is(await findUp(() => { + return '..'; + }), path.join(cwd, '..')); + + t.is(await findUp(directory => { + return (directory !== cwd) && directory; + }), path.join(cwd, '..')); + + t.is(await findUp(directory => { + return (directory !== cwd) && 'foo.txt'; + }), path.join(cwd, '..', 'foo.txt')); +}); + +test('async (not found, matcher function)', async t => { + const cwd = process.cwd(); + const {root} = path.parse(cwd); + const visited = new Set(); + t.is(await findUp(async directory => { + t.is(typeof directory, 'string'); + const stat = await promisify(fs.stat)(directory); + t.true(stat.isDirectory()); + t.true((directory === cwd) || isPathInside(cwd, directory)); + t.false(visited.has(directory)); + visited.add(directory); + }), undefined); + t.true(visited.has(cwd)); + t.true(visited.has(root)); +}); + +test('async (matcher function throws)', async t => { + const cwd = process.cwd(); + const visited = new Set(); + await t.throwsAsync(findUp(directory => { + visited.add(directory); + throw new Error('Some sync throw'); + }), { + message: 'Some sync throw' + }); + t.true(visited.has(cwd)); + t.is(visited.size, 1); +}); + +test('async (matcher function rejects)', async t => { + const cwd = process.cwd(); + const visited = new Set(); + await t.throwsAsync(findUp(async directory => { + visited.add(directory); + throw new Error('Some async rejection'); + }), { + message: 'Some async rejection' + }); + t.true(visited.has(cwd)); + t.is(visited.size, 1); +}); + +test('async (matcher function stops early)', async t => { + const cwd = process.cwd(); + const visited = new Set(); + t.is(await findUp(async directory => { + visited.add(directory); + return findUp.stop; + }), undefined); + t.true(visited.has(cwd)); + t.is(visited.size, 1); +}); + +test('sync (matcher function)', t => { + const cwd = process.cwd(); + + t.is(findUp.sync(directory => { + t.is(directory, cwd); + return directory; + }), cwd); + + t.is(findUp.sync(() => { + return '.'; + }), cwd); + + t.is(findUp.sync(() => { + return 'foo.txt'; + }), path.join(cwd, 'foo.txt')); + + t.is(findUp.sync(() => { + return '..'; + }), path.join(cwd, '..')); + + t.is(findUp.sync(directory => { + return (directory !== cwd) && directory; + }), path.join(cwd, '..')); + + t.is(findUp.sync(directory => { + return (directory !== cwd) && 'foo.txt'; + }), path.join(cwd, '..', 'foo.txt')); +}); + +test('sync (not found, matcher function)', t => { + const cwd = process.cwd(); + const {root} = path.parse(cwd); + const visited = new Set(); + t.is(findUp.sync(directory => { + t.is(typeof directory, 'string'); + const stat = fs.statSync(directory); + t.true(stat.isDirectory()); + t.true((directory === cwd) || isPathInside(cwd, directory)); + t.false(visited.has(directory)); + visited.add(directory); + }), undefined); + t.true(visited.has(cwd)); + t.true(visited.has(root)); +}); + +test('sync (matcher function throws)', t => { + const cwd = process.cwd(); + const visited = new Set(); + t.throws(() => { + findUp.sync(directory => { + visited.add(directory); + throw new Error('Some problem'); + }); + }, { + message: 'Some problem' + }); + t.true(visited.has(cwd)); + t.is(visited.size, 1); +}); + +test('sync (matcher function stops early)', t => { + const cwd = process.cwd(); + const visited = new Set(); + t.is(findUp.sync(directory => { + visited.add(directory); + return findUp.stop; + }), undefined); + t.true(visited.has(cwd)); + t.is(visited.size, 1); +});