From ed3aca1ad609e4517d8a9c34b38301e1a121af21 Mon Sep 17 00:00:00 2001 From: upstream Date: Thu, 3 Sep 2026 14:37:27 +0200 Subject: [PATCH] Upstream mrmlnc/fast-glob state of the touched files before 0754373f47 --- src/index.spec.ts | 357 +++++++++++++++++++++++++++++++++++++ src/readers/reader.spec.ts | 115 ++++++++++++ src/readers/reader.ts | 56 ++++++ 3 files changed, 528 insertions(+) create mode 100644 src/index.spec.ts create mode 100644 src/readers/reader.spec.ts create mode 100644 src/readers/reader.ts diff --git a/src/index.spec.ts b/src/index.spec.ts new file mode 100644 index 0000000..917e292 --- /dev/null +++ b/src/index.spec.ts @@ -0,0 +1,357 @@ +import * as assert from 'node:assert'; +import { describe, it } from 'mocha'; +import * as tests from './tests/index.js'; +import type { EntryItem, ErrnoException } from './types/index.js'; +import * as fg from './index.js'; + +// Only for validating the input data. +const invalidInputData = null as unknown as string; + +describe('Package', () => { + describe('.globSync', () => { + it('should throw an error when input values can not pass validation', () => { + const message = 'Patterns must be a string (non empty) or an array of strings'; + + assert.throws(() => fg.globSync(invalidInputData), { message }); + assert.throws(() => fg.globSync(''), { message }); + }); + + it('should returns entries', () => { + const expected: EntryItem[] = [ + 'fixtures/file.md', + 'fixtures/first/file.md', + 'fixtures/first/nested/directory/file.md', + 'fixtures/first/nested/file.md', + 'fixtures/second/file.md', + 'fixtures/second/nested/directory/file.md', + 'fixtures/second/nested/file.md', + 'fixtures/third/library/a/book.md', + 'fixtures/third/library/b/book.md', + ]; + + const actual = fg.globSync(['fixtures/**/*.md']); + + actual.sort((a, b) => a.localeCompare(b)); + + assert.deepStrictEqual(actual, expected); + }); + + it('should returns entries (two sources)', () => { + const expected: EntryItem[] = [ + 'fixtures/first/file.md', + 'fixtures/first/nested/directory/file.md', + 'fixtures/first/nested/file.md', + 'fixtures/second/file.md', + 'fixtures/second/nested/directory/file.md', + 'fixtures/second/nested/file.md', + ]; + + const actual = fg.globSync(['fixtures/first/**/*.md', 'fixtures/second/**/*.md']); + + actual.sort((a, b) => a.localeCompare(b)); + + assert.deepStrictEqual(actual, expected); + }); + }); + + describe('.sync', () => { + it('should be an alias for the .globSync method', () => { + // eslint-disable-next-line @typescript-eslint/no-deprecated + assert.strictEqual(fg.sync, fg.globSync); + }); + }); + + describe('.glob', () => { + it('should throw an error when input values can not pass validation', async () => { + const message = 'Patterns must be a string (non empty) or an array of strings'; + + await assert.rejects(async () => fg.glob(invalidInputData), { message }); + await assert.rejects(async () => fg.glob(''), { message }); + }); + + it('should returns entries', async () => { + const expected: EntryItem[] = [ + 'fixtures/file.md', + 'fixtures/first/file.md', + 'fixtures/first/nested/directory/file.md', + 'fixtures/first/nested/file.md', + 'fixtures/second/file.md', + 'fixtures/second/nested/directory/file.md', + 'fixtures/second/nested/file.md', + 'fixtures/third/library/a/book.md', + 'fixtures/third/library/b/book.md', + ]; + + const actual = await fg.glob(['fixtures/**/*.md']); + + actual.sort((a, b) => a.localeCompare(b)); + + assert.deepStrictEqual(actual, expected); + }); + + it('should returns entries (two sources)', async () => { + const expected: EntryItem[] = [ + 'fixtures/first/file.md', + 'fixtures/first/nested/directory/file.md', + 'fixtures/first/nested/file.md', + 'fixtures/second/file.md', + 'fixtures/second/nested/directory/file.md', + 'fixtures/second/nested/file.md', + ]; + + const actual = await fg.glob(['fixtures/first/**/*.md', 'fixtures/second/**/*.md']); + + actual.sort((a, b) => a.localeCompare(b)); + + assert.deepStrictEqual(actual, expected); + }); + + it('should abort processing dynamic pattern with abort signal', async () => { + const signal = AbortSignal.timeout(5); + + // The globstar pattern is used here to make the call run longer than the settimeout. + const action = fg.glob(['**'], { signal }); + + await assert.rejects(async () => action, { message: 'The operation was aborted due to timeout' }); + }); + + it('should abort processing static pattern with abort signal', async () => { + const ac = new AbortController(); + + ac.abort(); + + const action = fg.glob(['./package.json'], { signal: ac.signal }); + + await assert.rejects(async () => action, { message: 'The operation was aborted' }); + }); + }); + + describe('.async', () => { + it('should be an alias for the .glob method', () => { + // eslint-disable-next-line @typescript-eslint/no-deprecated + assert.strictEqual(fg.async, fg.glob); + }); + }); + + describe('.globStream', () => { + it('should throw an error when input values can not pass validation', () => { + const message = 'Patterns must be a string (non empty) or an array of strings'; + + assert.throws(() => fg.globStream(invalidInputData), { message }); + assert.throws(() => fg.globStream(''), { message }); + }); + + it('should returns entries', (done) => { + const expected: string[] = [ + 'fixtures/file.md', + 'fixtures/first/file.md', + 'fixtures/first/nested/directory/file.md', + 'fixtures/first/nested/file.md', + 'fixtures/second/file.md', + 'fixtures/second/nested/directory/file.md', + 'fixtures/second/nested/file.md', + 'fixtures/third/library/a/book.md', + 'fixtures/third/library/b/book.md', + ]; + + const actual: string[] = []; + + const stream = fg.globStream(['fixtures/**/*.md']); + + stream.on('data', (entry: string) => { + actual.push(entry); + }); + stream.once('error', (error: ErrnoException) => assert.fail(error)); + stream.once('end', () => { + actual.sort(); + + assert.deepStrictEqual(actual, expected); + done(); + }); + }); + + it('should returns entries (two sources)', (done) => { + const expected: EntryItem[] = [ + 'fixtures/first/file.md', + 'fixtures/first/nested/directory/file.md', + 'fixtures/first/nested/file.md', + 'fixtures/second/file.md', + 'fixtures/second/nested/directory/file.md', + 'fixtures/second/nested/file.md', + ]; + + const actual: string[] = []; + + const stream = fg.globStream(['fixtures/first/**/*.md', 'fixtures/second/**/*.md']); + + stream.on('data', (entry: string) => { + actual.push(entry); + }); + stream.once('error', (error: ErrnoException) => assert.fail(error)); + stream.once('end', () => { + actual.sort(); + + assert.deepStrictEqual(actual, expected); + done(); + }); + }); + + it('should abort processing dynamic pattern with abort signal', (done) => { + const signal = AbortSignal.timeout(5); + + // The globstar pattern is used here to make the call run longer than the settimeout. + const steam = fg.globStream(['**'], { signal }); + + steam.once('error', (error: ErrnoException) => { + assert.strictEqual(error.message, 'The operation was aborted due to timeout'); + done(); + }); + + steam.once('end', () => { + assert.fail('The stream should be aborted'); + }); + }); + + it('should abort processing static pattern with abort signal', (done) => { + const ac = new AbortController(); + + ac.abort(); + + // The globstar pattern is used here to make the call run longer than the settimeout. + const steam = fg.globStream(['./package.json'], { signal: ac.signal }); + + steam.once('error', (error: ErrnoException) => { + assert.strictEqual(error.message, 'The operation was aborted'); + done(); + }); + + steam.once('end', () => { + assert.fail('The stream should be aborted'); + }); + }); + }); + + describe('.stream', () => { + it('should be an alias for the .globStream method', () => { + // eslint-disable-next-line @typescript-eslint/no-deprecated + assert.strictEqual(fg.stream, fg.globStream); + }); + }); + + describe('.generateTasks', () => { + it('should throw an error when input values can not pass validation', () => { + const message = 'Patterns must be a string (non empty) or an array of strings'; + + assert.throws(() => fg.generateTasks(invalidInputData), { message }); + assert.throws(() => fg.generateTasks(''), { message }); + }); + + it('should return tasks', () => { + const expected = [ + tests.task.builder().base('.').positive('*').build(), + ]; + + const actual = fg.generateTasks(['*']); + + assert.deepStrictEqual(actual, expected); + }); + + it('should return tasks with negative patterns', () => { + const expected = [ + tests.task.builder().base('.').positive('*').negative('*.txt').negative('*.md').build(), + ]; + + const actual = fg.generateTasks(['*', '!*.txt'], { ignore: ['*.md'] }); + + assert.deepStrictEqual(actual, expected); + }); + + it('should clean up patterns', () => { + const expected = [ + // Clean up duplicate slashes + tests.task.builder().base('fixtures').positive('fixtures/*').build(), + ]; + + const actual = fg.generateTasks(['fixtures//*']); + + assert.deepStrictEqual(actual, expected); + }); + }); + + describe('.isDynamicPattern', () => { + it('should return true for dynamic pattern', () => { + assert.ok(fg.isDynamicPattern('*')); + }); + + it('should return false for static pattern', () => { + assert.ok(!fg.isDynamicPattern('abc')); + }); + }); + + describe('.escapePath', () => { + it('should return escaped path', () => { + const expected = String.raw`C:/Program Files \(x86\)`; + + const actual = fg.escapePath('C:/Program Files (x86)'); + + assert.strictEqual(actual, expected); + }); + }); + + describe('.convertPathToPattern', () => { + it('should return a pattern', () => { + // In posix system \\ is a escaping character and it will be escaped before non-special characters. + const posix = String.raw`C:\\Program Files \(x86\)\*\*\*`; + const windows = String.raw`C:/Program Files \(x86\)/**/*`; + const expected = tests.platform.isWindows() ? windows : posix; + + const actual = fg.convertPathToPattern(String.raw`C:\Program Files (x86)\**\*`); + + assert.strictEqual(actual, expected); + }); + }); + + describe('posix', () => { + describe('.escapePath', () => { + it('should return escaped path', () => { + const expected = String.raw`/directory/\*\*/\*`; + + const actual = fg.posix.escapePath(String.raw`/directory/*\*/*`); + + assert.strictEqual(actual, expected); + }); + }); + + describe('.convertPathToPattern', () => { + it('should return a pattern', () => { + const expected = String.raw`a\*.txt`; + + const actual = fg.posix.convertPathToPattern(String.raw`a\*.txt`); + + assert.strictEqual(actual, expected); + }); + }); + }); + + describe('win32', () => { + describe('.escapePath', () => { + it('should return escaped path', () => { + const expected = String.raw`C:\Program Files \(x86\)\**\*`; + + const actual = fg.win32.escapePath(String.raw`C:\Program Files (x86)\**\*`); + + assert.strictEqual(actual, expected); + }); + }); + + describe('.convertPathToPattern', () => { + it('should return a pattern', () => { + const expected = String.raw`C:/Program Files \(x86\)/**/*`; + + const actual = fg.win32.convertPathToPattern(String.raw`C:\Program Files (x86)\**\*`); + + assert.strictEqual(actual, expected); + }); + }); + }); +}); diff --git a/src/readers/reader.spec.ts b/src/readers/reader.spec.ts new file mode 100644 index 0000000..e0494a8 --- /dev/null +++ b/src/readers/reader.spec.ts @@ -0,0 +1,115 @@ +import * as assert from 'node:assert'; +import * as path from 'node:path'; +import * as process from 'node:process'; +import { Stats, StatsMode } from '@nodelib/fs.macchiato'; +import { describe, it } from 'mocha'; +import Settings, { type Options } from '../settings.js'; +import * as tests from '../tests/index.js'; +import type { + Entry, + ErrnoException, + FsStats, + Pattern, +} from '../types/index.js'; +import { Reader } from './reader.js'; + +class TestReader extends Reader { + constructor(options?: Options) { + super(new Settings(options)); + } + + public dynamic(): never[] { + return []; + } + + public static(): never[] { + return []; + } + + public getFullEntryPath(filepath: string): string { + return this._getFullEntryPath(filepath); + } + + public makeEntry(stats: FsStats, pattern: Pattern): Entry { + return this._makeEntry(stats, pattern); + } + + public isFatalError(error: ErrnoException): boolean { + return this._isFatalError(error); + } +} + +function getReader(options?: Options): TestReader { + return new TestReader(options); +} + +describe('Readers → Reader', () => { + describe('Constructor', () => { + it('should create instance of class', () => { + const reader = getReader(); + + assert.ok(reader instanceof TestReader); + }); + }); + + describe('.getFullEntryPath', () => { + it('should return path to entry', () => { + const reader = getReader(); + + const expected = path.join(process.cwd(), 'config.json'); + + const actual = reader.getFullEntryPath('config.json'); + + assert.strictEqual(actual, expected); + }); + }); + + describe('.makeEntry', () => { + it('should return created entry', () => { + const reader = getReader(); + const pattern = 'config.json'; + const stats = new Stats({ mode: StatsMode.File }); + + const actual = reader.makeEntry(stats, pattern); + + assert.strictEqual(actual.name, pattern); + assert.strictEqual(actual.path, pattern); + assert.ok(actual.dirent.isFile()); + }); + + it('should return created entry with fs.Stats', () => { + const reader = getReader({ stats: true }); + const pattern = 'config.json'; + + const actual = reader.makeEntry(new Stats(), pattern); + + assert.ok(actual.stats); + }); + }); + + describe('.isFatalError', () => { + it('should return false for ENOENT error', () => { + const reader = getReader(); + + assert.ok(!reader.isFatalError(tests.errno.getEnoent())); + }); + + it('should return true for EPERM error', () => { + const reader = getReader(); + + assert.ok(reader.isFatalError(tests.errno.getEperm())); + }); + + it('should return true for ENOENT error when the `errorFilter` option returns false', () => { + const reader = getReader({ errorFilter: () => false }); + + assert.ok(reader.isFatalError(tests.errno.getEnoent())); + }); + + it('should return false for EPERM error when the `errorFilter` option returns true', () => { + const reader = getReader({ errorFilter: () => true }); + + assert.ok(!reader.isFatalError(tests.errno.getEperm())); + }); + }); +}); diff --git a/src/readers/reader.ts b/src/readers/reader.ts new file mode 100644 index 0000000..427ca72 --- /dev/null +++ b/src/readers/reader.ts @@ -0,0 +1,56 @@ +import * as path from 'node:path'; +import * as fsStat from '@nodelib/fs.stat'; +import * as utils from '../utils/index.js'; +import type Settings from '../settings.js'; +import type { + Entry, + ErrnoException, + FsStats, + Pattern, + ReaderOptions, +} from '../types/index.js'; + +export abstract class Reader { + readonly #settings: Settings; + + protected readonly _fsStatSettings: fsStat.Settings; + + constructor(settings: Settings) { + this.#settings = settings; + + this._fsStatSettings = new fsStat.Settings({ + followSymbolicLink: settings.followSymbolicLinks, + fs: settings.fs, + throwErrorOnBrokenSymbolicLink: settings.throwErrorOnBrokenSymbolicLink, + }); + } + + public abstract dynamic(root: string, options: ReaderOptions): T; + public abstract static(patterns: Pattern[], options: ReaderOptions): T; + + protected _getFullEntryPath(filepath: string): string { + return path.resolve(this.#settings.cwd, filepath); + } + + protected _makeEntry(stats: FsStats, pattern: Pattern): Entry { + const entry: Entry = { + name: pattern, + path: pattern, + dirent: utils.fs.createDirentFromStats(pattern, stats), + }; + + if (this.#settings.stats) { + entry.stats = stats; + } + + return entry; + } + + protected _isFatalError(error: ErrnoException): boolean { + if (this.#settings.errorFilter !== undefined) { + return !this.#settings.errorFilter(error); + } + + return !utils.errno.isEnoentCodeError(error) && !utils.errno.isEnotdirCodeError(error); + } +}