filehound 2.0.0-beta.3 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -61,6 +61,8 @@ for await (const file of query.iterate()) {
61
61
  ## Documentation
62
62
  For more examples and API details, see [API documentation](https://nspragg.github.io/filehound/)
63
63
 
64
+ Runnable examples are in the [examples](examples/) directory. Each one searches the current directory, for example `node by-extension.mts`.
65
+
64
66
  ## Test
65
67
 
66
68
  ```
@@ -118,6 +118,40 @@ export declare class FileHound<T = string> extends EventEmitter {
118
118
  * ```
119
119
  */
120
120
  match(query: FilePredicate): this;
121
+ /**
122
+ * Skips directories by name: a directory whose name matches any of the
123
+ * given names is neither searched nor returned. Use it to keep the search
124
+ * out of directories such as `node_modules`, which a {@link FileHound.match}
125
+ * predicate can only exclude from the results after searching them.
126
+ * Calling it again replaces the names.
127
+ *
128
+ * @remarks
129
+ * Each name is matched against a directory's name only, not its path,
130
+ * following Node's [`path.matchesGlob`](https://nodejs.org/api/path.html#pathmatchesglobpath-pattern).
131
+ * A plain name, such as `'node_modules'`, matches exactly, and a glob, such
132
+ * as `'build-*'`, matches any name it describes. Every directory below the
133
+ * search paths is tested, including linked directories, but not the search
134
+ * paths themselves.
135
+ *
136
+ * @param names - one or more directory names or globs
137
+ * @returns the FileHound instance
138
+ * @throws `Error`
139
+ * Thrown if the query is locked because a search has started.
140
+ * @throws `RangeError`
141
+ * Thrown if no names are given.
142
+ * @throws `TypeError`
143
+ * Thrown if a name is not a non-empty string, or contains a path separator.
144
+ * @example
145
+ * ```ts
146
+ * import { FileHound, ext } from 'filehound';
147
+ *
148
+ * const files = await FileHound.create()
149
+ * .skipDirectories('node_modules', '.git', 'build-*')
150
+ * .match(ext('js'))
151
+ * .find();
152
+ * ```
153
+ */
154
+ skipDirectories(...names: string[]): this;
121
155
  /**
122
156
  * Specify the directory search depth. If set to zero, recursive searching
123
157
  * will be disabled. Directories at the limit are not searched, but are
package/dist/filehound.js CHANGED
@@ -34,6 +34,7 @@ export class FileHound extends EventEmitter {
34
34
  #maxDepth;
35
35
  #types;
36
36
  #matchExpression;
37
+ #skipDirectories = [];
37
38
  #ignoreHiddenFiles = false;
38
39
  #ignoreHiddenDirectories = false;
39
40
  #ignoreSymlinks = false;
@@ -108,6 +109,45 @@ export class FileHound extends EventEmitter {
108
109
  this.#matchExpression = query;
109
110
  return this;
110
111
  }
112
+ /**
113
+ * Skips directories by name: a directory whose name matches any of the
114
+ * given names is neither searched nor returned. Use it to keep the search
115
+ * out of directories such as `node_modules`, which a {@link FileHound.match}
116
+ * predicate can only exclude from the results after searching them.
117
+ * Calling it again replaces the names.
118
+ *
119
+ * @remarks
120
+ * Each name is matched against a directory's name only, not its path,
121
+ * following Node's [`path.matchesGlob`](https://nodejs.org/api/path.html#pathmatchesglobpath-pattern).
122
+ * A plain name, such as `'node_modules'`, matches exactly, and a glob, such
123
+ * as `'build-*'`, matches any name it describes. Every directory below the
124
+ * search paths is tested, including linked directories, but not the search
125
+ * paths themselves.
126
+ *
127
+ * @param names - one or more directory names or globs
128
+ * @returns the FileHound instance
129
+ * @throws `Error`
130
+ * Thrown if the query is locked because a search has started.
131
+ * @throws `RangeError`
132
+ * Thrown if no names are given.
133
+ * @throws `TypeError`
134
+ * Thrown if a name is not a non-empty string, or contains a path separator.
135
+ * @example
136
+ * ```ts
137
+ * import { FileHound, ext } from 'filehound';
138
+ *
139
+ * const files = await FileHound.create()
140
+ * .skipDirectories('node_modules', '.git', 'build-*')
141
+ * .match(ext('js'))
142
+ * .find();
143
+ * ```
144
+ */
145
+ skipDirectories(...names) {
146
+ this.#throwIfQueryLocked();
147
+ this.#validateDirectoryNames(names);
148
+ this.#skipDirectories = names;
149
+ return this;
150
+ }
111
151
  /**
112
152
  * Specify the directory search depth. If set to zero, recursive searching
113
153
  * will be disabled. Directories at the limit are not searched, but are
@@ -375,6 +415,20 @@ export class FileHound extends EventEmitter {
375
415
  throw new Error('Cannot modify query after search has started');
376
416
  }
377
417
  }
418
+ #validateDirectoryNames(names) {
419
+ if (names.length === 0) {
420
+ throw new RangeError('At least one directory name must be specified');
421
+ }
422
+ names.forEach((name) => {
423
+ if (typeof name !== 'string' || name.length === 0) {
424
+ throw new TypeError(`Invalid directory name: ${String(name)}.`);
425
+ }
426
+ // Only a directory's name is matched, so a path could never match
427
+ if (name.includes('/') || name.includes(path.sep)) {
428
+ throw new TypeError(`Directory name must not contain a path separator: ${name}.`);
429
+ }
430
+ });
431
+ }
378
432
  #validateTypeArguments(types) {
379
433
  if (types.length === 0) {
380
434
  throw new RangeError('At least one file type must be specified');
@@ -437,6 +491,7 @@ export class FileHound extends EventEmitter {
437
491
  ignoreSymlinks: this.#ignoreSymlinks,
438
492
  maxDepth: this.#maxDepth,
439
493
  fileTypes: this.#types,
494
+ skipDirectories: this.#skipDirectories,
440
495
  signal
441
496
  };
442
497
  }
package/dist/index.d.ts CHANGED
@@ -2,3 +2,4 @@ export { FileHound, type FileWithStats, type SearchOptions } from './filehound.j
2
2
  export { FileType } from './filetype.js';
3
3
  export type { FileView } from './file.js';
4
4
  export { type FilePredicate, accessed, applyFilter, applyFilterSync, changed, discard, ext, glob, isEmpty, like, matchAll, modified, not, size, sizeEquals } from './matchers.js';
5
+ export { ExpressionError } from 'unit-compare';
package/dist/index.js CHANGED
@@ -1,3 +1,6 @@
1
1
  export { FileHound } from './filehound.js';
2
2
  export { FileType } from './filetype.js';
3
3
  export { accessed, applyFilter, applyFilterSync, changed, discard, ext, glob, isEmpty, like, matchAll, modified, not, size, sizeEquals } from './matchers.js';
4
+ // Thrown by size() and the date matchers for an invalid expression, so it can
5
+ // be caught without depending on unit-compare
6
+ export { ExpressionError } from 'unit-compare';
@@ -158,15 +158,21 @@ export declare function ext(extensions: string[]): FilePredicate;
158
158
  */
159
159
  export declare function matchAll(): FilePredicate;
160
160
  /**
161
- * Matches files by size, using a comparison with optional units, such as
162
- * `'>10k'` or `'<=1mb'`. The operators are `<`, `<=`, `>`, `>=` and `==`,
163
- * which is the default, and the units are `b`, `k`, `m`, `g` and `t`, in
164
- * multiples of 1024. Use {@link sizeEquals} for an exact number of bytes.
161
+ * Matches files by size, using an expression of an optional operator, a
162
+ * whole number and an optional unit, such as `'>10kb'` or `'<= 1 mb'`.
163
+ *
164
+ * The operators are `<`, `<=`, `>`, `>=` and `==` (or `=`), which is the
165
+ * default. The units, in multiples of 1024, are bytes (`b`, the default),
166
+ * kilobytes (`k`, `kb`), megabytes (`m`, `mb`), gigabytes (`g`, `gb`) and
167
+ * terabytes (`t`, `tb`), in any case. Use {@link sizeEquals} for an exact
168
+ * number of bytes.
165
169
  *
166
170
  * @param expression - the size expression
167
171
  * @returns the predicate
168
172
  * @throws `TypeError`
169
173
  * Thrown if the expression is not a string.
174
+ * @throws `ExpressionError`
175
+ * Thrown if the expression can't be parsed or has an unknown unit.
170
176
  * @example
171
177
  * ```ts
172
178
  * FileHound.create().match(size('>1mb'));
@@ -242,6 +248,11 @@ export declare function like(pattern: string): FilePredicate;
242
248
  /**
243
249
  * Excludes files whose path satisfies any of the given regular expression patterns
244
250
  *
251
+ * @remarks
252
+ * Used with {@link FileHound.match}, a discarded directory is left out of the
253
+ * results but still searched. To keep the search out of it, pass its name
254
+ * to {@link FileHound.skipDirectories} instead.
255
+ *
245
256
  * @param patterns - one or more regular expression patterns
246
257
  * @returns the predicate
247
258
  * @example
@@ -251,16 +262,28 @@ export declare function like(pattern: string): FilePredicate;
251
262
  */
252
263
  export declare function discard(...patterns: string[]): FilePredicate;
253
264
  /**
254
- * Matches files by when they were last modified, using a comparison of their
255
- * age with optional units, such as `'<2 days'` or `'>8h'`. The operators are
256
- * `<`, `<=`, `>` and `=`, which is the default and compares to the second.
257
- * The units are minutes (`m`, `min`), hours (`h`) and days (`d`), which is
258
- * the default.
265
+ * Matches files by when they were last modified, using an expression of their
266
+ * age: an optional operator, a whole number and an optional unit, such as
267
+ * `'<2 days'`, `'>8h'` or `'>=1 week'`.
268
+ *
269
+ * The operators are `<`, `<=`, `>`, `>=` and `==` (or `=`), which is the
270
+ * default. The units are minutes (`m`, `min`, `mins`, `minute`, `minutes`),
271
+ * hours (`h`, `hour`, `hours`), days (`d`, `day`, `days`, the default) and
272
+ * weeks (`w`, `week`, `weeks`), in any case.
273
+ *
274
+ * @remarks
275
+ * Ages are compared in whole units, like `find -mtime`: a file modified two
276
+ * and a half days ago is 2 days old, so it matches `'=2 days'`, `'>=2 days'`
277
+ * and `'<3 days'`, but not `'>2 days'`. Days and weeks are calendar days, so
278
+ * stay correct across daylight saving changes. Ages are measured when each
279
+ * file is tested.
259
280
  *
260
281
  * @param expression - the age expression
261
282
  * @returns the predicate
262
283
  * @throws `TypeError`
263
284
  * Thrown if the expression is not a string.
285
+ * @throws `ExpressionError`
286
+ * Thrown if the expression can't be parsed or has an unknown unit.
264
287
  * @example
265
288
  * ```ts
266
289
  * FileHound.create().match(modified('<2 days'));
@@ -275,6 +298,8 @@ export declare function modified(expression: string): FilePredicate;
275
298
  * @returns the predicate
276
299
  * @throws `TypeError`
277
300
  * Thrown if the expression is not a string.
301
+ * @throws `ExpressionError`
302
+ * Thrown if the expression can't be parsed or has an unknown unit.
278
303
  */
279
304
  export declare function accessed(expression: string): FilePredicate;
280
305
  /**
@@ -285,6 +310,8 @@ export declare function accessed(expression: string): FilePredicate;
285
310
  * @returns the predicate
286
311
  * @throws `TypeError`
287
312
  * Thrown if the expression is not a string.
313
+ * @throws `ExpressionError`
314
+ * Thrown if the expression can't be parsed or has an unknown unit.
288
315
  */
289
316
  export declare function changed(expression: string): FilePredicate;
290
317
  /**
package/dist/matchers.js CHANGED
@@ -146,11 +146,14 @@ export class Size extends AbstractFilePredicate {
146
146
  return this.#isMatch(file.sizeSync());
147
147
  }
148
148
  }
149
+ // Ages are measured when a file is tested, not when the predicate is made, so
150
+ // a predicate kept and reused later still compares against the current time
151
+ const MEASURED_WHEN_TESTED = Object.freeze({ now: () => Date.now() });
149
152
  export class Modified extends AbstractFilePredicate {
150
153
  #isMatch;
151
154
  constructor(expression) {
152
155
  super();
153
- this.#isMatch = parseAge(expression);
156
+ this.#isMatch = parseAge(expression, MEASURED_WHEN_TESTED);
154
157
  }
155
158
  async test(file) {
156
159
  return this.#isMatch(await file.lastModified());
@@ -163,7 +166,7 @@ export class Accessed extends AbstractFilePredicate {
163
166
  #isMatch;
164
167
  constructor(expression) {
165
168
  super();
166
- this.#isMatch = parseAge(expression);
169
+ this.#isMatch = parseAge(expression, MEASURED_WHEN_TESTED);
167
170
  }
168
171
  async test(file) {
169
172
  return this.#isMatch(await file.lastAccessed());
@@ -176,7 +179,7 @@ export class Changed extends AbstractFilePredicate {
176
179
  #isMatch;
177
180
  constructor(expression) {
178
181
  super();
179
- this.#isMatch = parseAge(expression);
182
+ this.#isMatch = parseAge(expression, MEASURED_WHEN_TESTED);
180
183
  }
181
184
  async test(file) {
182
185
  return this.#isMatch(await file.lastChanged());
@@ -212,15 +215,21 @@ function expressionFor(matcher, expression, example) {
212
215
  return expression;
213
216
  }
214
217
  /**
215
- * Matches files by size, using a comparison with optional units, such as
216
- * `'>10k'` or `'<=1mb'`. The operators are `<`, `<=`, `>`, `>=` and `==`,
217
- * which is the default, and the units are `b`, `k`, `m`, `g` and `t`, in
218
- * multiples of 1024. Use {@link sizeEquals} for an exact number of bytes.
218
+ * Matches files by size, using an expression of an optional operator, a
219
+ * whole number and an optional unit, such as `'>10kb'` or `'<= 1 mb'`.
220
+ *
221
+ * The operators are `<`, `<=`, `>`, `>=` and `==` (or `=`), which is the
222
+ * default. The units, in multiples of 1024, are bytes (`b`, the default),
223
+ * kilobytes (`k`, `kb`), megabytes (`m`, `mb`), gigabytes (`g`, `gb`) and
224
+ * terabytes (`t`, `tb`), in any case. Use {@link sizeEquals} for an exact
225
+ * number of bytes.
219
226
  *
220
227
  * @param expression - the size expression
221
228
  * @returns the predicate
222
229
  * @throws `TypeError`
223
230
  * Thrown if the expression is not a string.
231
+ * @throws `ExpressionError`
232
+ * Thrown if the expression can't be parsed or has an unknown unit.
224
233
  * @example
225
234
  * ```ts
226
235
  * FileHound.create().match(size('>1mb'));
@@ -313,6 +322,11 @@ export function like(pattern) {
313
322
  /**
314
323
  * Excludes files whose path satisfies any of the given regular expression patterns
315
324
  *
325
+ * @remarks
326
+ * Used with {@link FileHound.match}, a discarded directory is left out of the
327
+ * results but still searched. To keep the search out of it, pass its name
328
+ * to {@link FileHound.skipDirectories} instead.
329
+ *
316
330
  * @param patterns - one or more regular expression patterns
317
331
  * @returns the predicate
318
332
  * @example
@@ -336,16 +350,28 @@ function cleanExtension(fileExtension) {
336
350
  return fileExtension;
337
351
  }
338
352
  /**
339
- * Matches files by when they were last modified, using a comparison of their
340
- * age with optional units, such as `'<2 days'` or `'>8h'`. The operators are
341
- * `<`, `<=`, `>` and `=`, which is the default and compares to the second.
342
- * The units are minutes (`m`, `min`), hours (`h`) and days (`d`), which is
343
- * the default.
353
+ * Matches files by when they were last modified, using an expression of their
354
+ * age: an optional operator, a whole number and an optional unit, such as
355
+ * `'<2 days'`, `'>8h'` or `'>=1 week'`.
356
+ *
357
+ * The operators are `<`, `<=`, `>`, `>=` and `==` (or `=`), which is the
358
+ * default. The units are minutes (`m`, `min`, `mins`, `minute`, `minutes`),
359
+ * hours (`h`, `hour`, `hours`), days (`d`, `day`, `days`, the default) and
360
+ * weeks (`w`, `week`, `weeks`), in any case.
361
+ *
362
+ * @remarks
363
+ * Ages are compared in whole units, like `find -mtime`: a file modified two
364
+ * and a half days ago is 2 days old, so it matches `'=2 days'`, `'>=2 days'`
365
+ * and `'<3 days'`, but not `'>2 days'`. Days and weeks are calendar days, so
366
+ * stay correct across daylight saving changes. Ages are measured when each
367
+ * file is tested.
344
368
  *
345
369
  * @param expression - the age expression
346
370
  * @returns the predicate
347
371
  * @throws `TypeError`
348
372
  * Thrown if the expression is not a string.
373
+ * @throws `ExpressionError`
374
+ * Thrown if the expression can't be parsed or has an unknown unit.
349
375
  * @example
350
376
  * ```ts
351
377
  * FileHound.create().match(modified('<2 days'));
@@ -362,6 +388,8 @@ export function modified(expression) {
362
388
  * @returns the predicate
363
389
  * @throws `TypeError`
364
390
  * Thrown if the expression is not a string.
391
+ * @throws `ExpressionError`
392
+ * Thrown if the expression can't be parsed or has an unknown unit.
365
393
  */
366
394
  export function accessed(expression) {
367
395
  return new Accessed(expressionFor('accessed', expression, '<2 days'));
@@ -374,6 +402,8 @@ export function accessed(expression) {
374
402
  * @returns the predicate
375
403
  * @throws `TypeError`
376
404
  * Thrown if the expression is not a string.
405
+ * @throws `ExpressionError`
406
+ * Thrown if the expression can't be parsed or has an unknown unit.
377
407
  */
378
408
  export function changed(expression) {
379
409
  return new Changed(expressionFor('changed', expression, '<2 days'));
@@ -7,6 +7,7 @@ export type WalkOptions = {
7
7
  ignoreHiddenDirectories: boolean;
8
8
  ignoreSymlinks: boolean;
9
9
  fileTypes: FileType[];
10
+ skipDirectories: readonly string[];
10
11
  signal?: AbortSignal;
11
12
  };
12
13
  export declare const EntryType: {
@@ -1,4 +1,4 @@
1
- import { dirname, resolve } from 'node:path';
1
+ import { basename, dirname, matchesGlob, resolve } from 'node:path';
2
2
  import { FileType } from '../filetype.js';
3
3
  import { isSyncPredicate } from '../matchers.js';
4
4
  // Every entry has one. FileType is the public subset; anything else, such as a
@@ -39,15 +39,25 @@ export class Walk {
39
39
  return false;
40
40
  }
41
41
  }
42
- // Hidden directories are neither returned nor walked
42
+ // Hidden directories, when ignored, and those named by skipDirectories are
43
+ // neither returned nor walked. Only names are tested, so the async walk
44
+ // can skip a directory before reading it ahead.
43
45
  shouldSkipDirectory(dir) {
44
- return this.#opts.ignoreHiddenDirectories && dir.isHiddenSync();
46
+ return (this.#opts.ignoreHiddenDirectories && dir.isHiddenSync()) || this.#isSkippedName(dir);
45
47
  }
46
48
  // depth counts levels below the root, which is 0. A directory beyond
47
49
  // maxDepth is still returned, like any other entry at its level, but not walked.
48
50
  canDescend(depth) {
49
51
  return this.#opts.maxDepth === undefined || depth <= this.#opts.maxDepth;
50
52
  }
53
+ #isSkippedName(dir) {
54
+ const skip = this.#opts.skipDirectories;
55
+ if (skip.length === 0) {
56
+ return false;
57
+ }
58
+ const name = basename(dir.getName());
59
+ return skip.some(pattern => matchesGlob(name, pattern));
60
+ }
51
61
  #typeEnabled(type) {
52
62
  return this.#opts.fileTypes.includes(type);
53
63
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "filehound",
3
- "version": "2.0.0-beta.3",
3
+ "version": "2.0.0",
4
4
  "description": "Find files the easy way",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -26,9 +26,10 @@
26
26
  "prepublishOnly": "npm run clean && npm run build",
27
27
  "test": "mocha",
28
28
  "posttest": "npm run lint",
29
- "lint": "eslint src test bench",
29
+ "lint": "eslint src test bench examples scripts",
30
30
  "coverage": "c8 mocha",
31
- "bench": "npm run build && node bench/bench.mjs"
31
+ "bench": "npm run build && node bench/bench.mjs",
32
+ "examples": "npm run build && node scripts/run-examples.mjs"
32
33
  },
33
34
  "repository": {
34
35
  "type": "git",