diff --git a/README.md b/README.md index 150c1673..43317f99 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,7 @@ This package provides methods for traversing the file system and returning pathn * [onlyFiles](#onlyfiles) * [stats](#stats) * [unique](#unique) + * [includePatternBaseDirectory](#includepatternbasedirectory) * [Matching control](#matching-control) * [braceExpansion](#braceexpansion) * [caseSensitiveMatch](#casesensitivematch) @@ -556,6 +557,22 @@ fg.sync(['*.json', 'package.json'], { unique: true }); // ['package.json'] If `true` and similar entries are found, the result is the first found. +#### includePatternBaseDirectory + +* Type: `boolean` +* Default: `false` + +Include the base directory of the pattern in the results. + +> :book: If the base directory of the pattern is `.`, it will not be included in the results. +> +> :book: If the [`onlyFiles`](#onlyfiles) is enabled, then this option is automatically `false`. + +```js +fg.sync(['fixtures/**'], { includePatternBaseDirectory: false }); // Entries from directory +fg.sync(['fixtures/**'], { includePatternBaseDirectory: true }); // `fixtures` + entries from directory +``` + ### Matching control #### braceExpansion diff --git a/src/providers/async.spec.ts b/src/providers/async.spec.ts index b994ab65..d0e6442c 100644 --- a/src/providers/async.spec.ts +++ b/src/providers/async.spec.ts @@ -83,5 +83,58 @@ describe('Providers → ProviderAsync', () => { assert.strictEqual((error as ErrnoException).code, 'ENOENT'); } }); + + describe('includePatternBaseDirectory', () => { + it('should return base pattern directory', async () => { + const provider = getProvider({ + onlyFiles: false, + includePatternBaseDirectory: true + }); + const task = tests.task.builder().base('root').positive('*').build(); + const baseEntry = tests.entry.builder().path('root').directory().build(); + const fileEntry = tests.entry.builder().path('root/file.txt').file().build(); + + provider.reader.static.resolves([baseEntry]); + provider.reader.dynamic.resolves([fileEntry]); + + const expected = ['root', 'root/file.txt']; + + const actual = await provider.read(task); + + assert.strictEqual(provider.reader.static.callCount, 1); + assert.strictEqual(provider.reader.dynamic.callCount, 1); + assert.deepStrictEqual(actual, expected); + }); + + it('should do not read base directory for static task', async () => { + const provider = getProvider({ + onlyFiles: false, + includePatternBaseDirectory: true + }); + + const task = tests.task.builder().base('root').positive('file.txt').static().build(); + + provider.reader.static.resolves([]); + + await provider.read(task); + + assert.strictEqual(provider.reader.static.callCount, 1); + }); + + it('should do not read base directory when it is a dot', async () => { + const provider = getProvider({ + onlyFiles: false, + includePatternBaseDirectory: true + }); + const task = tests.task.builder().base('.').positive('*').build(); + + provider.reader.static.resolves([]); + provider.reader.dynamic.resolves([]); + + await provider.read(task); + + assert.strictEqual(provider.reader.static.callCount, 0); + }); + }); }); }); diff --git a/src/providers/async.ts b/src/providers/async.ts index fa8b38ec..c4414ce6 100644 --- a/src/providers/async.ts +++ b/src/providers/async.ts @@ -10,12 +10,28 @@ export default class ProviderAsync extends Provider> { const root = this._getRootDirectory(task); const options = this._getReaderOptions(task); - const entries = await this.api(root, task, options); + return ([] as Entry[]) + .concat(await this._readBasePatternDirectory(task, options)) + .concat(await this._readTask(root, task, options)) + .map((entry) => options.transform(entry)); + } + + private async _readBasePatternDirectory(task: Task, options: ReaderOptions): Promise { + /** + * Currently, the micromatch package cannot match the input string `.` when the '**' pattern is used. + */ + if (task.base === '.') { + return []; + } + + if (task.dynamic && this._settings.includePatternBaseDirectory) { + return this._reader.static([task.base], options); + } - return entries.map((entry) => options.transform(entry)); + return []; } - public api(root: string, task: Task, options: ReaderOptions): Promise { + private _readTask(root: string, task: Task, options: ReaderOptions): Promise { if (task.dynamic) { return this._reader.dynamic(root, options); } diff --git a/src/providers/stream.spec.ts b/src/providers/stream.spec.ts index 99c6979b..592b14e9 100644 --- a/src/providers/stream.spec.ts +++ b/src/providers/stream.spec.ts @@ -1,5 +1,5 @@ import * as assert from 'assert'; -import { PassThrough } from 'stream'; +import { PassThrough, Readable } from 'stream'; import * as sinon from 'sinon'; @@ -27,6 +27,7 @@ function getProvider(options?: Options): TestProvider { } function getEntries(provider: TestProvider, task: Task, entry: Entry): Promise { + // Replace by PassThrough.from after when targeting Node.js 12+. const reader = new PassThrough({ objectMode: true }); provider.reader.dynamic.returns(reader); @@ -35,14 +36,18 @@ function getEntries(provider: TestProvider, task: Task, entry: Entry): Promise { return new Promise((resolve, reject) => { const items: EntryItem[] = []; - const api = provider.read(task); - - api.on('data', (item: EntryItem) => items.push(item)); - api.once('error', reject); - api.once('end', () => resolve(items)); + stream.on('data', (item: EntryItem) => items.push(item)); + stream.once('error', reject); + stream.once('end', () => resolve(items)); }); } @@ -119,4 +124,84 @@ describe('Providers → ProviderStream', () => { actual.emit('close'); }); }); + + describe('includePatternBaseDirectory', () => { + it('should return base pattern directory', async () => { + const provider = getProvider({ + onlyFiles: false, + includePatternBaseDirectory: true + }); + const task = tests.task.builder().base('root').positive('*').build(); + const baseEntry = tests.entry.builder().path('root').directory().build(); + const fileEntry = tests.entry.builder().path('root/file.txt').file().build(); + + // Replace by PassThrough.from after when targeting Node.js 12+. + const staticReaderStream = new PassThrough({ objectMode: true }); + const dynamicReaderStream = new PassThrough({ objectMode: true }); + + provider.reader.static.returns(staticReaderStream); + provider.reader.dynamic.returns(dynamicReaderStream); + + staticReaderStream.push(baseEntry); + staticReaderStream.push(null); + dynamicReaderStream.push(fileEntry); + dynamicReaderStream.push(null); + + const expected = ['root', 'root/file.txt']; + + const actual = await waitStreamEnd(provider.read(task)); + + assert.strictEqual(provider.reader.static.callCount, 1); + assert.strictEqual(provider.reader.dynamic.callCount, 1); + assert.deepStrictEqual(actual, expected); + }); + + it('should do not read base directory for static task', async () => { + const provider = getProvider({ + onlyFiles: false, + includePatternBaseDirectory: true + }); + const task = tests.task.builder().base('root').positive('file.txt').static().build(); + const baseEntry = tests.entry.builder().path('root/file.txt').directory().build(); + + // Replace by PassThrough.from after when targeting Node.js 12+. + const staticReaderStream = new PassThrough({ objectMode: true }); + const dynamicReaderStream = new PassThrough({ objectMode: true }); + + provider.reader.static.returns(staticReaderStream); + provider.reader.dynamic.returns(dynamicReaderStream); + + staticReaderStream.push(baseEntry); + staticReaderStream.push(null); + dynamicReaderStream.push(null); + + await waitStreamEnd(provider.read(task)); + + assert.strictEqual(provider.reader.static.callCount, 1); + }); + + it('should do not read base directory when it is a dot', async () => { + const provider = getProvider({ + onlyFiles: false, + includePatternBaseDirectory: true + }); + const task = tests.task.builder().base('.').positive('*').build(); + const baseEntry = tests.entry.builder().path('.').directory().build(); + + // Replace by PassThrough.from after when targeting Node.js 12+. + const staticReaderStream = new PassThrough({ objectMode: true }); + const dynamicReaderStream = new PassThrough({ objectMode: true }); + + provider.reader.static.returns(staticReaderStream); + provider.reader.dynamic.returns(dynamicReaderStream); + + staticReaderStream.push(baseEntry); + staticReaderStream.push(null); + dynamicReaderStream.push(null); + + await waitStreamEnd(provider.read(task)); + + assert.strictEqual(provider.reader.static.callCount, 0); + }); + }); }); diff --git a/src/providers/stream.ts b/src/providers/stream.ts index 790efcb8..ecf124db 100644 --- a/src/providers/stream.ts +++ b/src/providers/stream.ts @@ -12,21 +12,49 @@ export default class ProviderStream extends Provider { const root = this._getRootDirectory(task); const options = this._getReaderOptions(task); - const source = this.api(root, task, options); + const baseDirectoryStream = this._getBasePatternDirectoryStream(task, options); + const taskStream = this._getTaskStream(root, task, options); const destination = new Readable({ objectMode: true, read: () => { /* noop */ } }); - source + if (baseDirectoryStream !== null) { + // Do not terminate the destination stream because stream with tasks will emit entries. + baseDirectoryStream + .once('error', (error: ErrnoException) => destination.emit('error', error)) + .on('data', (entry: Entry) => destination.emit('data', options.transform(entry))); + } + + taskStream .once('error', (error: ErrnoException) => destination.emit('error', error)) .on('data', (entry: Entry) => destination.emit('data', options.transform(entry))) .once('end', () => destination.emit('end')); - destination - .once('close', () => source.destroy()); + destination.once('close', () => { + if (baseDirectoryStream !== null) { + baseDirectoryStream.destroy(); + } + + taskStream.destroy(); + }); return destination; } - public api(root: string, task: Task, options: ReaderOptions): Readable { + private _getBasePatternDirectoryStream(task: Task, options: ReaderOptions): Readable | null { + /** + * Currently, the micromatch package cannot match the input string `.` when the '**' pattern is used. + */ + if (task.base === '.') { + return null; + } + + if (task.dynamic && this._settings.includePatternBaseDirectory) { + return this._reader.static([task.base], options); + } + + return null; + } + + private _getTaskStream(root: string, task: Task, options: ReaderOptions): Readable { if (task.dynamic) { return this._reader.dynamic(root, options); } diff --git a/src/providers/sync.spec.ts b/src/providers/sync.spec.ts index 80a26f1a..0c654c69 100644 --- a/src/providers/sync.spec.ts +++ b/src/providers/sync.spec.ts @@ -62,5 +62,58 @@ describe('Providers → ProviderSync', () => { assert.strictEqual(provider.reader.static.callCount, 1); assert.deepStrictEqual(actual, expected); }); + + describe('includePatternBaseDirectory', () => { + it('should return base pattern directory', () => { + const provider = getProvider({ + onlyFiles: false, + includePatternBaseDirectory: true + }); + const task = tests.task.builder().base('root').positive('*').build(); + const baseEntry = tests.entry.builder().path('root').directory().build(); + const fileEntry = tests.entry.builder().path('root/file.txt').file().build(); + + provider.reader.static.returns([baseEntry]); + provider.reader.dynamic.returns([fileEntry]); + + const expected = ['root', 'root/file.txt']; + + const actual = provider.read(task); + + assert.strictEqual(provider.reader.static.callCount, 1); + assert.strictEqual(provider.reader.dynamic.callCount, 1); + assert.deepStrictEqual(actual, expected); + }); + + it('should do not read base directory for static task', () => { + const provider = getProvider({ + onlyFiles: false, + includePatternBaseDirectory: true + }); + + const task = tests.task.builder().base('root').positive('file.txt').static().build(); + + provider.reader.static.returns([]); + + provider.read(task); + + assert.strictEqual(provider.reader.static.callCount, 1); + }); + + it('should do not read base directory when it is a dot', () => { + const provider = getProvider({ + onlyFiles: false, + includePatternBaseDirectory: true + }); + const task = tests.task.builder().base('.').positive('*').build(); + + provider.reader.static.returns([]); + provider.reader.dynamic.returns([]); + + provider.read(task); + + assert.strictEqual(provider.reader.static.callCount, 0); + }); + }); }); }); diff --git a/src/providers/sync.ts b/src/providers/sync.ts index 079819d9..a988cc9e 100644 --- a/src/providers/sync.ts +++ b/src/providers/sync.ts @@ -10,12 +10,28 @@ export default class ProviderSync extends Provider { const root = this._getRootDirectory(task); const options = this._getReaderOptions(task); - const entries = this.api(root, task, options); + return ([] as Entry[]) + .concat(this._readBasePatternDirectory(task, options)) + .concat(this._readTask(root, task, options)) + .map(options.transform); + } + + private _readBasePatternDirectory(task: Task, options: ReaderOptions): Entry[] { + /** + * Currently, the micromatch package cannot match the input string `.` when the '**' pattern is used. + */ + if (task.base === '.') { + return []; + } + + if (task.dynamic && this._settings.includePatternBaseDirectory) { + return this._reader.static([task.base], options); + } - return entries.map(options.transform); + return []; } - public api(root: string, task: Task, options: ReaderOptions): Entry[] { + private _readTask(root: string, task: Task, options: ReaderOptions): Entry[] { if (task.dynamic) { return this._reader.dynamic(root, options); } diff --git a/src/settings.spec.ts b/src/settings.spec.ts index c9dcc838..eee2cdb7 100644 --- a/src/settings.spec.ts +++ b/src/settings.spec.ts @@ -26,6 +26,7 @@ describe('Settings', () => { assert.ok(settings.globstar); assert.ok(settings.onlyFiles); assert.ok(settings.unique); + assert.ok(!settings.includePatternBaseDirectory); assert.strictEqual(settings.concurrency, os.cpus().length); assert.strictEqual(settings.cwd, process.cwd()); }); @@ -38,16 +39,27 @@ describe('Settings', () => { assert.ok(!settings.onlyFiles); }); - it('should set the "onlyFiles" option when the "onlyDirectories" is enabled', () => { + it('should set the "onlyFiles" option when the "onlyDirectories" option is enabled', () => { const settings = new Settings({ - onlyDirectories: true + onlyDirectories: true, + onlyFiles: true }); assert.ok(!settings.onlyFiles); assert.ok(settings.onlyDirectories); }); - it('should set the "objectMode" option when the "stats" is enabled', () => { + it('should disable the "includePatternBaseDirectory" option when the "onlyFiles" option is enabled', () => { + const settings = new Settings({ + onlyFiles: true, + includePatternBaseDirectory: true + }); + + assert.ok(settings.onlyFiles); + assert.ok(!settings.includePatternBaseDirectory); + }); + + it('should set the "objectMode" option when the "stats" option is enabled', () => { const settings = new Settings({ stats: true }); diff --git a/src/settings.ts b/src/settings.ts index 3000541a..3acd48bc 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -152,6 +152,12 @@ export type Options = { * @default true */ unique?: boolean; + /** + * Include the base directory of the pattern in the results. + * + * @default false + */ + includePatternBaseDirectory?: boolean; }; export default class Settings { @@ -176,12 +182,17 @@ export default class Settings { public readonly suppressErrors: boolean = this._getValue(this._options.suppressErrors, false); public readonly throwErrorOnBrokenSymbolicLink: boolean = this._getValue(this._options.throwErrorOnBrokenSymbolicLink, false); public readonly unique: boolean = this._getValue(this._options.unique, true); + public readonly includePatternBaseDirectory: boolean = this._getValue(this._options.includePatternBaseDirectory, false); constructor(private readonly _options: Options = {}) { if (this.onlyDirectories) { this.onlyFiles = false; } + if (this.onlyFiles) { + this.includePatternBaseDirectory = false; + } + if (this.stats) { this.objectMode = true; }