Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for path matcher function #28

Merged
merged 19 commits into from Apr 24, 2019
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
21 changes: 16 additions & 5 deletions index.js
Expand Up @@ -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) {
Expand All @@ -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) {
Expand All @@ -44,3 +53,5 @@ module.exports.sync = (name, options = {}) => {
directory = path.dirname(directory);
}
};

module.exports.stop = stop;
sholladay marked this conversation as resolved.
Show resolved Hide resolved
2 changes: 2 additions & 0 deletions package.json
Expand Up @@ -44,6 +44,8 @@
},
"devDependencies": {
"ava": "^1.4.1",
"is-path-inside": "^2.1.0",
"pify": "^4.0.1",
"tempy": "^0.3.0",
"tsd": "^0.7.2",
"xo": "^0.24.0"
Expand Down
26 changes: 26 additions & 0 deletions readme.md
Expand Up @@ -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 () => {
Expand All @@ -47,13 +49,28 @@ const findUp = require('find-up');

console.log(await findUp(['rainbow.png', 'unicorn.png']));
//=> '/Users/sindresorhus/unicorn.png'

console.log(await findUp(dir => {
return fs.existsSync(path.join(dir, 'unicorn.png')) && 'foo';
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You shouldn't mix async and sync. Here, it should have used an async method to check whether it exists. The problem is that fs.exists is deprecated, and it's also not async/await friendly. This is why I want to expose some utility methods. To make common operation simple.

I'm thinking:

  • findUp.exists()
  • findUp.isFile()
  • findUp.isDirectory()
  • findUp.sync.exists()
  • findUp.sync.isFile()
  • findUp.sync.isDirectory()

I know this seems like a lot, but I want find-up to just work for people with minimal mental overhead or boilerplate.

Maybe we don't need the exists methods as I can't really think of a scenario where you wouldn't care about the type. Can you?

The methods could alternatively be passed into the callback as a sort of context or something. I think I prefer them to be on the findUp object though.

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Or maybe we should have a type option that accepts either file or directory, but defaults to file? Hmm, we should probably have that regardless, as currently find-up could match both directories and files, while most would only want to match file. Usually it's not a problem as people use extensions, but I can imagine a scenario where a user wants to find an extension-less file and instead gets a directory of the same name somewhere else in the hierarchy.

Copy link
Collaborator Author

@sholladay sholladay Apr 18, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I should've used findUp.sync(). I think I was trying to avoid that and then I realized:

  1. As you mentioned, async fs.exists() is deprecated.
  2. At the time, converting from callbacks to promises would have been some extra noisy code to understand for any async fs method.

But now fs.promises is stable and it makes the examples simpler. How about something like this?

const pathExists = filepath => fs.promises.access(filepath).then(_ => true).catch(_ => false);
console.log(await findUp(async (directory) => {
	const isInstalled = await pathExists(path.join(directory, 'node_modules'));
	return isInstalled && 'package.json';
}});

That could replace the example below as well.

As for the utility methods, I agree they are useful and I'd also prefer for them to be properties on the findUp function. Let's do that. I think we should merge this PR first and then build the utilities on top of this functionality in a separate PR(s), since this is still useful with or without them.

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How about something like this?

👍

I think we should merge this PR first and then build the utilities on top of this functionality in a separate PR(s), since this is still useful with or without them.

👍

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I realized that if we implement #33, we don't need the file/directory checks, so we can simply implement two utility methods => #37

}));
//=> '/Users/sindresorhus/foo'

console.log(await findUp(async dir => {
const children = await fs.promises.readdir(dir);
if (children.some(fileName => fileName.endsWith('.png'))) {
return dir;
}
}));
//=> '/Users/sindresorhus'
sholladay marked this conversation as resolved.
Show resolved Hide resolved
})();
```


## API


### findUp(name, [options])
### findUp(matcher, [options])

Returns a `Promise` for either the path or `undefined` if it couldn't be found.

Expand All @@ -62,6 +79,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.

Expand All @@ -75,6 +93,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 filepath to stop the search or the root directory has been reached and nothing was found. Useful if you want to match files with a certain pattern, set of permissions, or other advanced use cases.
sholladay marked this conversation as resolved.
Show resolved Hide resolved

When using async mode, `matcher` may optionally be an `async` function or return a `Promise` for the filepath.
sholladay marked this conversation as resolved.
Show resolved Hide resolved

#### options

Type: `object`
Expand Down
171 changes: 163 additions & 8 deletions test.js
@@ -1,6 +1,8 @@
import fs from 'fs';
import path from 'path';
import test from 'ava';
import isPathInside from 'is-path-inside';
import pify from 'pify';
import tempy from 'tempy';
import findUp from '.';

Expand Down Expand Up @@ -54,13 +56,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);
Expand Down Expand Up @@ -174,43 +176,43 @@ 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 cousin directory, custom cwd)', async t => {
const foundPath = await findUp(relative.barDir, {
cwd: relative.fixtureDirectory
});

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
});

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
});

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
});
Expand Down Expand Up @@ -247,3 +249,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'));
sholladay marked this conversation as resolved.
Show resolved Hide resolved

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 pify(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));
sindresorhus marked this conversation as resolved.
Show resolved Hide resolved
});

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);
});