From db9e646004220d313d608db7e0ff7a3b76e7d2da Mon Sep 17 00:00:00 2001 From: userquin Date: Tue, 27 Jan 2026 20:28:38 +0100 Subject: [PATCH 1/6] feat: add sort support --- src/index.ts | 83 +++++++--------- src/sort.ts | 235 +++++++++++++++++++++++++++++++++++++++++++++ src/types.ts | 10 ++ test/index.test.ts | 29 ++++++ test/sort.test.ts | 109 +++++++++++++++++++++ 5 files changed, 418 insertions(+), 48 deletions(-) create mode 100644 src/sort.ts create mode 100644 test/sort.test.ts diff --git a/src/index.ts b/src/index.ts index 7bc2e7f..5777dd3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,9 +1,7 @@ -import nativeFs from 'node:fs'; -import { resolve } from 'node:path'; -import { fileURLToPath } from 'node:url'; import { buildCrawler } from './crawler.ts'; -import type { Crawler, FileSystemAdapter, GlobInput, GlobOptions, InternalOptions, RelativeMapper } from './types.ts'; -import { BACKSLASHES, ensureStringArray, isReadonlyArray, log } from './utils.ts'; +import { getOptions, sortFiles } from './sort.ts'; +import type { Crawler, GlobInput, GlobOptions, RelativeMapper } from './types.ts'; +import { ensureStringArray, isReadonlyArray } from './utils.ts'; function formatPaths(paths: string[], mapper?: false | RelativeMapper) { if (mapper) { @@ -14,44 +12,10 @@ function formatPaths(paths: string[], mapper?: false | RelativeMapper) { return paths; } -const fsKeys = ['readdir', 'readdirSync', 'realpath', 'realpathSync', 'stat', 'statSync']; - -function normalizeFs(fs?: Record): FileSystemAdapter | undefined { - if (fs && fs !== nativeFs) { - for (const key of fsKeys) { - fs[key] = (fs[key] ? fs : (nativeFs as Record))[key]; - } - } - return fs; -} - -// Object containing all default options to ensure there is no hidden state difference -// between false and undefined. -const defaultOptions: GlobOptions = { - caseSensitiveMatch: true, - cwd: process.cwd(), - debug: !!process.env.TINYGLOBBY_DEBUG, - expandDirectories: true, - followSymbolicLinks: true, - onlyFiles: true -}; - -function getOptions(options?: GlobOptions): InternalOptions { - const opts = { ...defaultOptions, ...options } as InternalOptions; - - opts.cwd = (opts.cwd instanceof URL ? fileURLToPath(opts.cwd) : resolve(opts.cwd)).replace(BACKSLASHES, '/'); - // Default value of [] will be inserted here if ignore is undefined - opts.ignore = ensureStringArray(opts.ignore); - opts.fs = normalizeFs(opts.fs); - - if (opts.debug) { - log('globbing with options:', opts); - } - - return opts; -} - -function getCrawler(globInput: GlobInput, inputOptions: GlobOptions = {}): [] | [Crawler, false | RelativeMapper] { +function getCrawler( + globInput: GlobInput, + inputOptions: GlobOptions = {} +): [] | [Crawler, false | RelativeMapper, GlobOptions, readonly string[]] { if (globInput && inputOptions?.patterns) { throw new Error('Cannot pass patterns as both an argument and an option'); } @@ -61,7 +25,21 @@ function getCrawler(globInput: GlobInput, inputOptions: GlobOptions = {}): [] | const patterns = ensureStringArray((isModern ? globInput : globInput.patterns) ?? '**/*'); const options = getOptions(isModern ? inputOptions : globInput); - return patterns.length > 0 ? buildCrawler(options, patterns) : []; + if (patterns.length === 0) { + return []; + } + + const [crawler, relative] = buildCrawler(options, patterns); + return [crawler, relative, options, patterns]; +} + +function processResults( + paths: string[], + relative: false | RelativeMapper, + options: GlobOptions, + patterns: readonly string[] +): string[] { + return sortFiles(formatPaths(paths, relative), patterns, options); } /** @@ -74,8 +52,12 @@ export function glob(patterns: string | readonly string[], options?: Omit; export async function glob(globInput: GlobInput, options?: GlobOptions): Promise { - const [crawler, relative] = getCrawler(globInput, options); - return crawler ? formatPaths(await crawler.withPromise(), relative) : []; + const result = getCrawler(globInput, options); + if (result.length === 0) { + return []; + } + const [crawler, relative, opts, patterns] = result; + return processResults(await crawler.withPromise(), relative, opts, patterns); } /** @@ -88,9 +70,14 @@ export function globSync(patterns: string | readonly string[], options?: Omit): FileSystemAdapter | undefined { + if (fs && fs !== nativeFs) { + for (const key of fsKeys) { + fs[key] = (fs[key] ? fs : (nativeFs as Record))[key]; + } + } + return fs; +} + +// Object containing all default options to ensure there is no hidden state difference +// between false and undefined. +const defaultOptions: GlobOptions = { + caseSensitiveMatch: true, + cwd: process.cwd(), + debug: !!process.env.TINYGLOBBY_DEBUG, + expandDirectories: true, + followSymbolicLinks: true, + onlyFiles: true +}; + +export function getOptions(options?: GlobOptions): InternalOptions { + const opts = { ...defaultOptions, ...options } as InternalOptions; + + opts.cwd = (opts.cwd instanceof URL ? fileURLToPath(opts.cwd) : resolve(opts.cwd)).replace(BACKSLASHES, '/'); + // Default value of [] will be inserted here if ignore is undefined + opts.ignore = ensureStringArray(opts.ignore); + opts.fs = normalizeFs(opts.fs); + + if (opts.debug) { + log('globbing with options:', opts); + } + + return opts; +} + +/** + * Compiles glob patterns into matcher functions using the exact same logic as `glob` and `globSync`. + * + * This is an advanced utility function designed to be a **companion** to `glob` and `globSync`. + * Its primary use case is to enable advanced post-processing of the files returned by a scan. + * + * For example, since the order of files from a glob scan is not guaranteed, this function + * provides the necessary tools to implement **deterministic sorting**. By yielding a matcher for + * each original pattern, you can iterate through them in their intended order of precedence + * and sort the results of a `globSync` call accordingly. + * + * This function is key because it uses the **exact same internal pattern normalization and + * option processing as `glob` and `globSync`**. This guarantees that your post-processing + * logic (like sorting) will be perfectly consistent with the file scan that produced the results. + * + * A key benefit of this approach is **decoupling**. Your code only depends on + * the returned matcher's signature `(path: string) => boolean`, not on the + * underlying matching library (currently `picomatch`). If `tinyglobby` were to + * switch to a different matching engine in the future, your code using this + * function would continue to work without any changes. + * + * @param patterns The glob pattern(s). + * @param options The options object if the first argument is the pattern(s). + * @yields A readonly tuple `[glob, matcher]` containing: + * - `glob`: The normalized and processed glob pattern. + * - `matcher`: The pre-compiled matcher function for that specific pattern. + * @returns A generator that yields the `[glob, matcher]` tuples. + * + * @example Implementing deterministic sorting of `globSync` results + * ```javascript + * // Assume the following file structure: + * // /project + * // ├── common + * // │ ├── Button.js + * // │ └── Card.js + * // └── overrides + * // └── Button.js + * + * import { globSync, compileMatchers } from 'tinyglobby'; + * + * // 1. Define your globs and options ONCE. + * // The order of this array defines the desired sorting precedence. + * const globs = [ + * 'overrides/**' + '/*.js', // Highest priority + * 'common/**' + '/*.js', // Normal priority + * ]; + * const options = { cwd: '/project', absolute: true }; + * + * // 2. Scan the filesystem using the defined globs. + * // `globSync` uses the patterns to find files but does not guarantee order. + * const files = globSync(globs, options); + * // Let's assume `files` is now (in a non-deterministic order): + * // [ + * // '/project/common/Button.js', + * // '/project/common/Card.js', + * // '/project/overrides/Button.js' + * // ] + * + * // 3. Compile the exact same globs to get matchers in their intended order. + * const matchersGenerator = compileMatchers(globs, options); + * + * // 4. Use the generated matchers to sort the file list. + * const sortedFiles = []; + * const processedFiles = new Set(); + * + * for (const [glob, match] of matchersGenerator) { + * for (const file of files) { + * if (!processedFiles.has(file) && match(file)) { + * processedFiles.add(file); + * sortedFiles.push(file); + * } + * } + * } + * + * console.log(sortedFiles); + * // The correctly sorted output, respecting the original glob order: + * // [ + * // '/project/overrides/Button.js', + * // '/project/common/Button.js', + * // '/project/common/Card.js' + * // ] + * ``` + */ +export function* compileMatchers( + patterns: string | readonly string[], + options?: GlobOptions +): Generator boolean], undefined, void> { + // defaulting to ['**/*'] is tinyglobby exclusive behavior, deprecated + const usePatterns = ensureStringArray(patterns); + const useOptions = getOptions(options); + + const cwd = useOptions.cwd as string; + const props: InternalProps = { root: cwd, depthOffset: 0 }; + const processed = processPatterns(useOptions, usePatterns, props); + + if (useOptions.debug) { + log('internal processing patterns:', processed); + } + + const { absolute, caseSensitiveMatch, dot } = useOptions; + const root = props.root.replace(BACKSLASHES, ''); + // For some of these options, false and undefined are two different states! + const matchOptions = { + dot, + nobrace: useOptions.braceExpansion === false, + nocase: !caseSensitiveMatch, + noextglob: useOptions.extglob === false, + noglobstar: useOptions.globstar === false, + posix: true + } satisfies PicomatchOptions; + + const format = buildFormat(cwd, root, absolute); + + for (const match of processed.match) { + const isMatch = picomatch(match, { ...matchOptions, ignore: processed.ignore }); + yield [match, (filePath: string): boolean => isMatch(format(filePath, false))] as const; + } +} + +const sortAsc = (a: string, b: string) => a.localeCompare(b); +const sortDesc = (a: string, b: string) => b.localeCompare(a); + +/** + * Sort files from a glob scan. + * @param files The files from a glob scan. + * @param patterns The glob pattern(s). + * @param options The options object if the first argument is the pattern(s). + * @returns The files from a glob scan sorted. + */ +export function sortFiles(files: string[], patterns: string | readonly string[], options?: GlobOptions): string[] { + switch (true) { + case options?.sort === 'asc': + return files.sort(sortAsc); + case options?.sort === 'desc': + return files.sort(sortDesc); + case options?.sort === 'pattern': + case options?.sort === 'pattern-asc': + case options?.sort === 'pattern-desc': + return [...sortFilesByPatternPrecedence(files, patterns, options)]; + default: + return files; + } +} + +/** + * Sort files from a glob scan. + * @param files The files from a glob scan. + * @param patterns The glob pattern(s). + * @param options The options object if the first argument is the pattern(s). + * @yields The files from a glob scan sorted. + */ +export function* sortFilesByPatternPrecedence( + files: string[], + patterns: string | readonly string[], + options?: GlobOptions +): Generator { + const sort = options?.sort ?? 'pattern'; + if (sort !== 'pattern' && sort !== 'pattern-asc' && sort !== 'pattern-desc') { + for (const file of files) { + yield file; + } + return; + } + + const matcher = compileMatchers(patterns, options); + const processedFiles = new Set(); + if (sort === 'pattern') { + for (const [_, match] of matcher) { + for (const file of files) { + if (!processedFiles.has(file) && match(file)) { + processedFiles.add(file); + yield file; + } + } + } + } else { + const matches: string[] = []; + const sortFn = sort === 'pattern-asc' ? sortAsc : sortDesc; + for (const [_, match] of matcher) { + for (const file of files) { + if (!processedFiles.has(file) && match(file)) { + processedFiles.add(file); + matches.push(file); + } + } + yield* matches.sort(sortFn); + matches.length = 0; + } + } +} diff --git a/src/types.ts b/src/types.ts index 68f18b1..4d769d0 100644 --- a/src/types.ts +++ b/src/types.ts @@ -130,6 +130,16 @@ export interface GlobOptions { * @default undefined */ signal?: AbortSignal; + /** + * Sort the results. + * - `asc`: Sorts the results in ascending order. + * - `desc`: Sorts the results in descending order. + * - `pattern`: Sorts the results by pattern precedence. + * - `pattern-asc`: Sorts the results by pattern precedence and then ascending. + * - `pattern-desc`: Sorts the results by pattern precedence and then descending. + * @default undefined + */ + sort?: 'asc' | 'desc' | 'pattern' | 'pattern-asc' | 'pattern-desc'; } export type InternalOptions = Pick< diff --git a/test/index.test.ts b/test/index.test.ts index df32a99..329f6b6 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -498,3 +498,32 @@ test('relative self + normal pattern', () => { }); assert.deepEqual(files.sort(), ['.', 'a/a.txt']); }); + +test('sort asc', async () => { + const files = await glob('**/*', { cwd, sort: 'asc' }); + assert.deepEqual(files, ['a/a.txt', 'a/b.txt', 'b/a.txt', 'b/b.txt']); +}); + +test('sort desc', async () => { + const files = await glob('**/*', { cwd, sort: 'desc' }); + assert.deepEqual(files, ['b/b.txt', 'b/a.txt', 'a/b.txt', 'a/a.txt']); +}); + +test('sort pattern', async () => { + const files = await glob(['b/*', 'a/*'], { cwd, sort: 'pattern' }); + assert.equal(files.length, 4); + assert.ok(files[0].startsWith('b/')); + assert.ok(files[1].startsWith('b/')); + assert.ok(files[2].startsWith('a/')); + assert.ok(files[3].startsWith('a/')); +}); + +test('sort pattern-asc', async () => { + const files = await glob(['b/*', 'a/*'], { cwd, sort: 'pattern-asc' }); + assert.deepEqual(files, ['b/a.txt', 'b/b.txt', 'a/a.txt', 'a/b.txt']); +}); + +test('sort pattern-desc', async () => { + const files = await glob(['b/*', 'a/*'], { cwd, sort: 'pattern-desc' }); + assert.deepEqual(files, ['b/b.txt', 'b/a.txt', 'a/b.txt', 'a/a.txt']); +}); diff --git a/test/sort.test.ts b/test/sort.test.ts new file mode 100644 index 0000000..6a48daa --- /dev/null +++ b/test/sort.test.ts @@ -0,0 +1,109 @@ +import assert from 'node:assert/strict'; +import { after, test } from 'node:test'; +import { createFixture } from 'fs-fixture'; +import { sortFiles, sortFilesByPatternPrecedence } from '../src/sort.ts'; +import type { GlobInput } from '../src/types.ts'; + +const fixture = await createFixture({ + common: { + 'Button.js': 'a', + 'Card.js': 'a' + }, + overrides: { + 'Button.js': 'b' + } +}); + +const cwd = fixture.path; +const escapedCwd = cwd.replaceAll('\\', '/'); +const options = { + cwd, + absolute: true, + onlyFiles: true, + expandDirectories: false +} satisfies GlobInput; +const patterns = [ + `overrides/**/*.js`, // Highest priority + 'common/**/*.js' // Normal priority +]; + +after(() => fixture.rm()); + +test('sort files without sort', () => { + const files = [`${escapedCwd}/overrides/Button.js`, `${escapedCwd}/common/Card.js`, `${escapedCwd}/common/Button.js`]; + assert.deepEqual(sortFiles(files, patterns, options), [ + `${escapedCwd}/overrides/Button.js`, + `${escapedCwd}/common/Card.js`, + `${escapedCwd}/common/Button.js` + ]); +}); +test('sort files ascending', () => { + const files = [`${escapedCwd}/overrides/Button.js`, `${escapedCwd}/common/Card.js`, `${escapedCwd}/common/Button.js`]; + const useOptions = { ...options, sort: 'asc' } satisfies GlobInput; + assert.deepEqual(sortFiles(files, patterns, useOptions), [ + `${escapedCwd}/common/Button.js`, + `${escapedCwd}/common/Card.js`, + `${escapedCwd}/overrides/Button.js` + ]); +}); +test('sort files descending', () => { + const files = [`${escapedCwd}/common/Button.js`, `${escapedCwd}/common/Card.js`, `${escapedCwd}/overrides/Button.js`]; + const useOptions = { ...options, sort: 'desc' } satisfies GlobInput; + assert.deepEqual(sortFiles(files, patterns, useOptions), [ + `${escapedCwd}/overrides/Button.js`, + `${escapedCwd}/common/Card.js`, + `${escapedCwd}/common/Button.js` + ]); +}); +test('sort files by precedence without sort', () => { + const files = [`${escapedCwd}/common/Button.js`, `${escapedCwd}/common/Card.js`, `${escapedCwd}/overrides/Button.js`]; + assert.deepEqual( + [...sortFilesByPatternPrecedence(files, patterns, options)], + [`${escapedCwd}/overrides/Button.js`, `${escapedCwd}/common/Button.js`, `${escapedCwd}/common/Card.js`] + ); +}); +test('sort files by precedence with sort ascending (no sort)', () => { + const files = [`${escapedCwd}/common/Button.js`, `${escapedCwd}/common/Card.js`, `${escapedCwd}/overrides/Button.js`]; + const useOptions = { ...options, sort: 'asc' } satisfies GlobInput; + assert.deepEqual( + [...sortFilesByPatternPrecedence(files, patterns, useOptions)], + [`${escapedCwd}/common/Button.js`, `${escapedCwd}/common/Card.js`, `${escapedCwd}/overrides/Button.js`] + ); +}); +test('sort files by precedence', () => { + const files = [`${escapedCwd}/common/Button.js`, `${escapedCwd}/common/Card.js`, `${escapedCwd}/overrides/Button.js`]; + const useOptions = { ...options, sort: 'pattern' } satisfies GlobInput; + assert.deepEqual( + [...sortFilesByPatternPrecedence(files, patterns, useOptions)], + [`${escapedCwd}/overrides/Button.js`, `${escapedCwd}/common/Button.js`, `${escapedCwd}/common/Card.js`] + ); +}); +test('sort files by precedence with no sort', () => { + const files = [`${escapedCwd}/common/Button.js`, `${escapedCwd}/common/Card.js`, `${escapedCwd}/overrides/Button.js`]; + assert.deepEqual( + [...sortFilesByPatternPrecedence(files, patterns, options)], + [`${escapedCwd}/overrides/Button.js`, `${escapedCwd}/common/Button.js`, `${escapedCwd}/common/Card.js`] + ); +}); +test('sort files by precedence ascending', () => { + const files = [`${escapedCwd}/common/Card.js`, `${escapedCwd}/common/Button.js`, `${escapedCwd}/overrides/Button.js`]; + const result = [ + `${escapedCwd}/overrides/Button.js`, + `${escapedCwd}/common/Button.js`, + `${escapedCwd}/common/Card.js` + ]; + const useOptions = { ...options, sort: 'pattern-asc' } satisfies GlobInput; + assert.deepEqual([...sortFilesByPatternPrecedence(files, patterns, useOptions)], result); + assert.deepEqual(sortFiles(files, patterns, useOptions), result); +}); +test('sort files by precedence descending', () => { + const files = [`${escapedCwd}/common/Button.js`, `${escapedCwd}/common/Card.js`, `${escapedCwd}/overrides/Button.js`]; + const result = [ + `${escapedCwd}/overrides/Button.js`, + `${escapedCwd}/common/Card.js`, + `${escapedCwd}/common/Button.js` + ]; + const useOptions = { ...options, sort: 'pattern-desc', debug: true } satisfies GlobInput; + assert.deepEqual([...sortFilesByPatternPrecedence(files, patterns, useOptions)], result); + assert.deepEqual(sortFiles(files, patterns, useOptions), result); +}); From 9dcc846a7c1927b934bb3397ea5ff4692ec8cc30 Mon Sep 17 00:00:00 2001 From: userquin Date: Tue, 27 Jan 2026 20:43:41 +0100 Subject: [PATCH 2/6] chore: extract getOptions to options.ts module --- src/index.ts | 3 ++- src/options.ts | 42 ++++++++++++++++++++++++++++++++++++++++++ src/sort.ts | 43 ++----------------------------------------- 3 files changed, 46 insertions(+), 42 deletions(-) create mode 100644 src/options.ts diff --git a/src/index.ts b/src/index.ts index 5777dd3..9d7171b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,6 @@ import { buildCrawler } from './crawler.ts'; -import { getOptions, sortFiles } from './sort.ts'; +import { getOptions } from './options.ts'; +import { sortFiles } from './sort.ts'; import type { Crawler, GlobInput, GlobOptions, RelativeMapper } from './types.ts'; import { ensureStringArray, isReadonlyArray } from './utils.ts'; diff --git a/src/options.ts b/src/options.ts new file mode 100644 index 0000000..507b263 --- /dev/null +++ b/src/options.ts @@ -0,0 +1,42 @@ +import nativeFs from 'node:fs'; +import { resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import type { FileSystemAdapter, GlobOptions, InternalOptions } from './types.ts'; +import { BACKSLASHES, ensureStringArray, log } from './utils.ts'; + +const fsKeys = ['readdir', 'readdirSync', 'realpath', 'realpathSync', 'stat', 'statSync']; + +function normalizeFs(fs?: Record): FileSystemAdapter | undefined { + if (fs && fs !== nativeFs) { + for (const key of fsKeys) { + fs[key] = (fs[key] ? fs : (nativeFs as Record))[key]; + } + } + return fs; +} + +// Object containing all default options to ensure there is no hidden state difference +// between false and undefined. +const defaultOptions: GlobOptions = { + caseSensitiveMatch: true, + cwd: process.cwd(), + debug: !!process.env.TINYGLOBBY_DEBUG, + expandDirectories: true, + followSymbolicLinks: true, + onlyFiles: true +}; + +export function getOptions(options?: GlobOptions): InternalOptions { + const opts = { ...defaultOptions, ...options } as InternalOptions; + + opts.cwd = (opts.cwd instanceof URL ? fileURLToPath(opts.cwd) : resolve(opts.cwd)).replace(BACKSLASHES, '/'); + // Default value of [] will be inserted here if ignore is undefined + opts.ignore = ensureStringArray(opts.ignore); + opts.fs = normalizeFs(opts.fs); + + if (opts.debug) { + log('globbing with options:', opts); + } + + return opts; +} diff --git a/src/sort.ts b/src/sort.ts index 517fac9..780d761 100644 --- a/src/sort.ts +++ b/src/sort.ts @@ -1,48 +1,9 @@ -import nativeFs from 'node:fs'; -import { resolve } from 'node:path'; -import { fileURLToPath } from 'node:url'; import picomatch, { type PicomatchOptions } from 'picomatch'; +import { getOptions } from './options.ts'; import processPatterns from './patterns.ts'; -import type { FileSystemAdapter, GlobOptions, InternalOptions, InternalProps } from './types.ts'; +import type { GlobOptions, InternalProps } from './types.ts'; import { BACKSLASHES, buildFormat, ensureStringArray, log } from './utils.ts'; -const fsKeys = ['readdir', 'readdirSync', 'realpath', 'realpathSync', 'stat', 'statSync']; - -function normalizeFs(fs?: Record): FileSystemAdapter | undefined { - if (fs && fs !== nativeFs) { - for (const key of fsKeys) { - fs[key] = (fs[key] ? fs : (nativeFs as Record))[key]; - } - } - return fs; -} - -// Object containing all default options to ensure there is no hidden state difference -// between false and undefined. -const defaultOptions: GlobOptions = { - caseSensitiveMatch: true, - cwd: process.cwd(), - debug: !!process.env.TINYGLOBBY_DEBUG, - expandDirectories: true, - followSymbolicLinks: true, - onlyFiles: true -}; - -export function getOptions(options?: GlobOptions): InternalOptions { - const opts = { ...defaultOptions, ...options } as InternalOptions; - - opts.cwd = (opts.cwd instanceof URL ? fileURLToPath(opts.cwd) : resolve(opts.cwd)).replace(BACKSLASHES, '/'); - // Default value of [] will be inserted here if ignore is undefined - opts.ignore = ensureStringArray(opts.ignore); - opts.fs = normalizeFs(opts.fs); - - if (opts.debug) { - log('globbing with options:', opts); - } - - return opts; -} - /** * Compiles glob patterns into matcher functions using the exact same logic as `glob` and `globSync`. * From 125124f6341a7ab8e6d0daa6fde4569999211d69 Mon Sep 17 00:00:00 2001 From: userquin Date: Tue, 27 Jan 2026 21:08:17 +0100 Subject: [PATCH 3/6] chore: add custom sort --- src/sort.ts | 2 ++ src/types.ts | 3 ++- test/index.test.ts | 5 +++++ 3 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/sort.ts b/src/sort.ts index 780d761..a9cdd89 100644 --- a/src/sort.ts +++ b/src/sort.ts @@ -135,6 +135,8 @@ const sortDesc = (a: string, b: string) => b.localeCompare(a); */ export function sortFiles(files: string[], patterns: string | readonly string[], options?: GlobOptions): string[] { switch (true) { + case typeof options?.sort === 'function': + return files.sort(options.sort); case options?.sort === 'asc': return files.sort(sortAsc); case options?.sort === 'desc': diff --git a/src/types.ts b/src/types.ts index 4d769d0..bbe4f7d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -137,9 +137,10 @@ export interface GlobOptions { * - `pattern`: Sorts the results by pattern precedence. * - `pattern-asc`: Sorts the results by pattern precedence and then ascending. * - `pattern-desc`: Sorts the results by pattern precedence and then descending. + * - `(a: string, b: string) => number`: A custom sort function. * @default undefined */ - sort?: 'asc' | 'desc' | 'pattern' | 'pattern-asc' | 'pattern-desc'; + sort?: 'asc' | 'desc' | 'pattern' | 'pattern-asc' | 'pattern-desc' | ((a: string, b: string) => number); } export type InternalOptions = Pick< diff --git a/test/index.test.ts b/test/index.test.ts index 329f6b6..7d23289 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -527,3 +527,8 @@ test('sort pattern-desc', async () => { const files = await glob(['b/*', 'a/*'], { cwd, sort: 'pattern-desc' }); assert.deepEqual(files, ['b/b.txt', 'b/a.txt', 'a/b.txt', 'a/a.txt']); }); + +test('sort custom', async () => { + const files = await glob('**/*', { cwd, sort: (a, b) => a.localeCompare(b) }); + assert.deepEqual(files, ['a/a.txt', 'a/b.txt', 'b/a.txt', 'b/b.txt']); +}); From bbb93eea0f7fdf3888cc9181f4b493e6db77b1ce Mon Sep 17 00:00:00 2001 From: userquin Date: Tue, 27 Jan 2026 21:30:58 +0100 Subject: [PATCH 4/6] chore: change signatures --- src/sort.ts | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/src/sort.ts b/src/sort.ts index a9cdd89..9f1c90c 100644 --- a/src/sort.ts +++ b/src/sort.ts @@ -26,7 +26,7 @@ import { BACKSLASHES, buildFormat, ensureStringArray, log } from './utils.ts'; * function would continue to work without any changes. * * @param patterns The glob pattern(s). - * @param options The options object if the first argument is the pattern(s). + * @param options The options. * @yields A readonly tuple `[glob, matcher]` containing: * - `glob`: The normalized and processed glob pattern. * - `matcher`: The pre-compiled matcher function for that specific pattern. @@ -89,7 +89,7 @@ import { BACKSLASHES, buildFormat, ensureStringArray, log } from './utils.ts'; */ export function* compileMatchers( patterns: string | readonly string[], - options?: GlobOptions + options?: Omit ): Generator boolean], undefined, void> { // defaulting to ['**/*'] is tinyglobby exclusive behavior, deprecated const usePatterns = ensureStringArray(patterns); @@ -130,10 +130,14 @@ const sortDesc = (a: string, b: string) => b.localeCompare(a); * Sort files from a glob scan. * @param files The files from a glob scan. * @param patterns The glob pattern(s). - * @param options The options object if the first argument is the pattern(s). + * @param options The options. * @returns The files from a glob scan sorted. */ -export function sortFiles(files: string[], patterns: string | readonly string[], options?: GlobOptions): string[] { +export function sortFiles( + files: string[], + patterns: string | readonly string[], + options?: Omit +): string[] { switch (true) { case typeof options?.sort === 'function': return files.sort(options.sort); @@ -154,13 +158,13 @@ export function sortFiles(files: string[], patterns: string | readonly string[], * Sort files from a glob scan. * @param files The files from a glob scan. * @param patterns The glob pattern(s). - * @param options The options object if the first argument is the pattern(s). + * @param options The options. * @yields The files from a glob scan sorted. */ export function* sortFilesByPatternPrecedence( files: string[], patterns: string | readonly string[], - options?: GlobOptions + options?: Omit ): Generator { const sort = options?.sort ?? 'pattern'; if (sort !== 'pattern' && sort !== 'pattern-asc' && sort !== 'pattern-desc') { From 46902a8ff6bbd37a094bb74d54acfffbb5fa46a7 Mon Sep 17 00:00:00 2001 From: userquin Date: Wed, 28 Jan 2026 00:03:03 +0100 Subject: [PATCH 5/6] chore: add `buildCrawlerInfo` updating logic and tests --- src/crawler.ts | 26 +++++++++--- src/index.ts | 26 ++++++------ src/sort.ts | 101 +++++++++++++++++++++++++--------------------- src/types.ts | 15 ++++++- test/sort.test.ts | 49 +++++++++++++++++++++- 5 files changed, 150 insertions(+), 67 deletions(-) diff --git a/src/crawler.ts b/src/crawler.ts index e1d0587..99f16b3 100644 --- a/src/crawler.ts +++ b/src/crawler.ts @@ -1,11 +1,11 @@ import { type ExcludePredicate, type FSLike, fdir } from 'fdir'; import picomatch, { type PicomatchOptions } from 'picomatch'; import processPatterns from './patterns.ts'; -import type { Crawler, InternalOptions, InternalProps, RelativeMapper } from './types.ts'; +import type { Crawler, CrawlerInfo, InternalOptions, InternalProps, RelativeMapper } from './types.ts'; import { BACKSLASHES, buildFormat, buildRelative, getPartialMatcher, log } from './utils.ts'; -// #region buildCrawler -export function buildCrawler(options: InternalOptions, patterns: readonly string[]): [Crawler, false | RelativeMapper] { +// #region buildCrawlerInfo +export function buildCrawlerInfo(options: InternalOptions, patterns: readonly string[]): CrawlerInfo { const cwd = options.cwd as string; const props: InternalProps = { root: cwd, depthOffset: 0 }; const processed = processPatterns(options, patterns, props); @@ -14,7 +14,7 @@ export function buildCrawler(options: InternalOptions, patterns: readonly string log('internal processing patterns:', processed); } - const { absolute, caseSensitiveMatch, debug, dot, followSymbolicLinks, onlyDirectories } = options; + const { absolute, caseSensitiveMatch, dot } = options; const root = props.root.replace(BACKSLASHES, ''); // For some of these options, false and undefined are two different states! const matchOptions = { @@ -26,11 +26,25 @@ export function buildCrawler(options: InternalOptions, patterns: readonly string posix: true } satisfies PicomatchOptions; + const format = buildFormat(cwd, root, absolute); + + return { processed, matchOptions, cwd, root, absolute, props, format }; +} +// #endregion buildCrawlerInfo + +// #region buildCrawler +export function buildCrawler( + options: InternalOptions, + patterns: readonly string[] +): [Crawler, false | RelativeMapper, CrawlerInfo] { + const info = buildCrawlerInfo(options, patterns); + const { processed, matchOptions, cwd, root, absolute, props, format } = info; + const { debug, followSymbolicLinks, onlyDirectories } = options; + const matcher = picomatch(processed.match, { ...matchOptions, ignore: processed.ignore }); const ignore = picomatch(processed.ignore, matchOptions); const partialMatcher = getPartialMatcher(processed.match, matchOptions); - const format = buildFormat(cwd, root, absolute); const excludeFormatter = absolute ? format : buildFormat(cwd, root, true); const excludePredicate: ExcludePredicate = (_, p): boolean => { @@ -83,6 +97,6 @@ export function buildCrawler(options: InternalOptions, patterns: readonly string log('internal properties:', { ...props, root }); } - return [crawler, cwd !== root && !absolute && buildRelative(cwd, root)]; + return [crawler, cwd !== root && !absolute && buildRelative(cwd, root), info]; } // #endregion buildCrawler diff --git a/src/index.ts b/src/index.ts index 9d7171b..0273c74 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,7 +1,7 @@ import { buildCrawler } from './crawler.ts'; import { getOptions } from './options.ts'; -import { sortFiles } from './sort.ts'; -import type { Crawler, GlobInput, GlobOptions, RelativeMapper } from './types.ts'; +import { internalSortFiles } from './sort.ts'; +import type { Crawler, CrawlerInfo, GlobInput, GlobOptions, RelativeMapper } from './types.ts'; import { ensureStringArray, isReadonlyArray } from './utils.ts'; function formatPaths(paths: string[], mapper?: false | RelativeMapper) { @@ -16,7 +16,7 @@ function formatPaths(paths: string[], mapper?: false | RelativeMapper) { function getCrawler( globInput: GlobInput, inputOptions: GlobOptions = {} -): [] | [Crawler, false | RelativeMapper, GlobOptions, readonly string[]] { +): [] | [Crawler, false | RelativeMapper, GlobOptions, readonly string[], CrawlerInfo] { if (globInput && inputOptions?.patterns) { throw new Error('Cannot pass patterns as both an argument and an option'); } @@ -24,23 +24,23 @@ function getCrawler( const isModern = isReadonlyArray(globInput) || typeof globInput === 'string'; // defaulting to ['**/*'] is tinyglobby exclusive behavior, deprecated const patterns = ensureStringArray((isModern ? globInput : globInput.patterns) ?? '**/*'); - const options = getOptions(isModern ? inputOptions : globInput); if (patterns.length === 0) { return []; } - const [crawler, relative] = buildCrawler(options, patterns); - return [crawler, relative, options, patterns]; + const options = getOptions(isModern ? inputOptions : globInput); + const [crawler, relative, crawlerInfo] = buildCrawler(options, patterns); + return [crawler, relative, options, patterns, crawlerInfo]; } function processResults( paths: string[], relative: false | RelativeMapper, options: GlobOptions, - patterns: readonly string[] + crawlerInfo: CrawlerInfo ): string[] { - return sortFiles(formatPaths(paths, relative), patterns, options); + return internalSortFiles(formatPaths(paths, relative), crawlerInfo, options.sort); } /** @@ -57,8 +57,8 @@ export async function glob(globInput: GlobInput, options?: GlobOptions): Promise if (result.length === 0) { return []; } - const [crawler, relative, opts, patterns] = result; - return processResults(await crawler.withPromise(), relative, opts, patterns); + const [crawler, relative, opts, _, crawlerInfo] = result; + return processResults(await crawler.withPromise(), relative, opts, crawlerInfo); } /** @@ -75,10 +75,10 @@ export function globSync(globInput: GlobInput, options?: GlobOptions): string[] if (result.length === 0) { return []; } - const [crawler, relative, opts, patterns] = result; - return processResults(crawler.sync(), relative, opts, patterns); + const [crawler, relative, opts, _, crawlerInfo] = result; + return processResults(crawler.sync(), relative, opts, crawlerInfo); } export { compileMatchers, sortFiles, sortFilesByPatternPrecedence } from './sort.ts'; -export type { GlobOptions } from './types.ts'; +export type { GlobOptions, Sort } from './types.ts'; export { convertPathToPattern, escapePath, isDynamicPattern } from './utils.ts'; diff --git a/src/sort.ts b/src/sort.ts index 9f1c90c..d349480 100644 --- a/src/sort.ts +++ b/src/sort.ts @@ -1,9 +1,18 @@ -import picomatch, { type PicomatchOptions } from 'picomatch'; +import picomatch from 'picomatch'; +import { buildCrawlerInfo } from './crawler.ts'; import { getOptions } from './options.ts'; -import processPatterns from './patterns.ts'; -import type { GlobOptions, InternalProps } from './types.ts'; -import { BACKSLASHES, buildFormat, ensureStringArray, log } from './utils.ts'; +import type { CrawlerInfo, GlobOptions, Sort } from './types.ts'; +import { ensureStringArray } from './utils.ts'; +export function* internalCompileMatchers( + crawlerInfo: CrawlerInfo +): Generator boolean], undefined, void> { + const { processed, matchOptions, format } = crawlerInfo; + for (const match of processed.match) { + const isMatch = picomatch(match, { ...matchOptions, ignore: processed.ignore }); + yield [match, (filePath: string): boolean => isMatch(format(filePath, false))] as const; + } +} /** * Compiles glob patterns into matcher functions using the exact same logic as `glob` and `globSync`. * @@ -91,41 +100,29 @@ export function* compileMatchers( patterns: string | readonly string[], options?: Omit ): Generator boolean], undefined, void> { - // defaulting to ['**/*'] is tinyglobby exclusive behavior, deprecated - const usePatterns = ensureStringArray(patterns); - const useOptions = getOptions(options); - - const cwd = useOptions.cwd as string; - const props: InternalProps = { root: cwd, depthOffset: 0 }; - const processed = processPatterns(useOptions, usePatterns, props); - - if (useOptions.debug) { - log('internal processing patterns:', processed); - } - - const { absolute, caseSensitiveMatch, dot } = useOptions; - const root = props.root.replace(BACKSLASHES, ''); - // For some of these options, false and undefined are two different states! - const matchOptions = { - dot, - nobrace: useOptions.braceExpansion === false, - nocase: !caseSensitiveMatch, - noextglob: useOptions.extglob === false, - noglobstar: useOptions.globstar === false, - posix: true - } satisfies PicomatchOptions; - - const format = buildFormat(cwd, root, absolute); - - for (const match of processed.match) { - const isMatch = picomatch(match, { ...matchOptions, ignore: processed.ignore }); - yield [match, (filePath: string): boolean => isMatch(format(filePath, false))] as const; - } + yield* internalCompileMatchers(buildCrawlerInfo(getOptions(options), ensureStringArray(patterns))); } const sortAsc = (a: string, b: string) => a.localeCompare(b); const sortDesc = (a: string, b: string) => b.localeCompare(a); +export function internalSortFiles(files: string[], crawlerInfo: CrawlerInfo, sort?: Sort): string[] { + switch (true) { + case typeof sort === 'function': + return files.sort(sort); + case sort === 'asc': + return files.sort(sortAsc); + case sort === 'desc': + return files.sort(sortDesc); + case sort === 'pattern': + case sort === 'pattern-asc': + case sort === 'pattern-desc': + return [...internalSortFilesByPatternPrecedence(files, crawlerInfo, sort)]; + default: + return files; + } +} + /** * Sort files from a glob scan. * @param files The files from a glob scan. @@ -154,19 +151,12 @@ export function sortFiles( } } -/** - * Sort files from a glob scan. - * @param files The files from a glob scan. - * @param patterns The glob pattern(s). - * @param options The options. - * @yields The files from a glob scan sorted. - */ -export function* sortFilesByPatternPrecedence( +export function* internalSortFilesByPatternPrecedence( files: string[], - patterns: string | readonly string[], - options?: Omit + crawlerInfo: CrawlerInfo, + sort?: Sort ): Generator { - const sort = options?.sort ?? 'pattern'; + sort ??= 'pattern'; if (sort !== 'pattern' && sort !== 'pattern-asc' && sort !== 'pattern-desc') { for (const file of files) { yield file; @@ -174,7 +164,7 @@ export function* sortFilesByPatternPrecedence( return; } - const matcher = compileMatchers(patterns, options); + const matcher = internalCompileMatchers(crawlerInfo); const processedFiles = new Set(); if (sort === 'pattern') { for (const [_, match] of matcher) { @@ -200,3 +190,22 @@ export function* sortFilesByPatternPrecedence( } } } + +/** + * Sort files from a glob scan. + * @param files The files from a glob scan. + * @param patterns The glob pattern(s). + * @param options The options. + * @yields The files from a glob scan sorted. + */ +export function* sortFilesByPatternPrecedence( + files: string[], + patterns: string | readonly string[], + options?: Omit +): Generator { + yield* internalSortFilesByPatternPrecedence( + files, + buildCrawlerInfo(getOptions(options), ensureStringArray(patterns)), + options?.sort + ); +} diff --git a/src/types.ts b/src/types.ts index bbe4f7d..ebe2d4e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,4 +1,5 @@ import type { FSLike, PathsOutput, ResultCallback } from 'fdir'; +import type { PicomatchOptions } from 'picomatch'; export type FileSystemAdapter = Partial; // can't use `Matcher` from picomatch as it requires a second argument since @types/picomatch v4 @@ -37,6 +38,18 @@ export interface ProcessedPatterns { ignore: string[]; } +export interface CrawlerInfo { + processed: ProcessedPatterns; + matchOptions: PicomatchOptions; + cwd: string; + root: string; + absolute?: boolean; + props: InternalProps; + format: (p: string, isDir: boolean) => string; +} + +export type Sort = 'asc' | 'desc' | 'pattern' | 'pattern-asc' | 'pattern-desc' | ((a: string, b: string) => number); + export interface GlobOptions { /** * Whether to return absolute paths. Disable to have relative paths. @@ -140,7 +153,7 @@ export interface GlobOptions { * - `(a: string, b: string) => number`: A custom sort function. * @default undefined */ - sort?: 'asc' | 'desc' | 'pattern' | 'pattern-asc' | 'pattern-desc' | ((a: string, b: string) => number); + sort?: Sort; } export type InternalOptions = Pick< diff --git a/test/sort.test.ts b/test/sort.test.ts index 6a48daa..2b70e0a 100644 --- a/test/sort.test.ts +++ b/test/sort.test.ts @@ -1,7 +1,7 @@ import assert from 'node:assert/strict'; import { after, test } from 'node:test'; import { createFixture } from 'fs-fixture'; -import { sortFiles, sortFilesByPatternPrecedence } from '../src/sort.ts'; +import { compileMatchers, sortFiles, sortFilesByPatternPrecedence } from '../src/sort.ts'; import type { GlobInput } from '../src/types.ts'; const fixture = await createFixture({ @@ -29,6 +29,18 @@ const patterns = [ after(() => fixture.rm()); +test('compileMatchers yields matchers', () => { + const generator = compileMatchers(patterns, options); + let count = 0; + + for (const [glob, match] of generator) { + count++; + assert.equal(typeof glob, 'string'); + assert.equal(typeof match, 'function'); + } + assert.equal(count, 2); +}); + test('sort files without sort', () => { const files = [`${escapedCwd}/overrides/Button.js`, `${escapedCwd}/common/Card.js`, `${escapedCwd}/common/Button.js`]; assert.deepEqual(sortFiles(files, patterns, options), [ @@ -107,3 +119,38 @@ test('sort files by precedence descending', () => { assert.deepEqual([...sortFilesByPatternPrecedence(files, patterns, useOptions)], result); assert.deepEqual(sortFiles(files, patterns, useOptions), result); }); + +// internal tests +test('sort files with custom function', () => { + const files = [`${escapedCwd}/overrides/Button.js`, `${escapedCwd}/common/Card.js`, `${escapedCwd}/common/Button.js`]; + const useOptions = { ...options, sort: (a, b) => a.localeCompare(b) } satisfies GlobInput; + assert.deepEqual(sortFiles(files, patterns, useOptions), [ + `${escapedCwd}/common/Button.js`, + `${escapedCwd}/common/Card.js`, + `${escapedCwd}/overrides/Button.js` + ]); +}); + +test('sort files by precedence returns unsorted if sort option is not pattern-related', () => { + const files = [`${escapedCwd}/common/Button.js`, `${escapedCwd}/common/Card.js`, `${escapedCwd}/overrides/Button.js`]; + // sort: 'asc' is not a pattern sort, so it should return files as is from this generator + const useOptions = { ...options, sort: 'asc' } satisfies GlobInput; + assert.deepEqual([...sortFilesByPatternPrecedence(files, patterns, useOptions)], files); +}); + +test('sort files by precedence with no options', () => { + // we need to change the cwd to the fixture path to make sure the patterns match + // since we are not passing options to sortFilesByPatternPrecedence + const originalCwd = process.cwd(); + try { + process.chdir(cwd); + const files = ['common/Button.js', 'common/Card.js', 'overrides/Button.js']; + // sort without options + assert.deepEqual( + [...sortFilesByPatternPrecedence(files, patterns)], + ['overrides/Button.js', 'common/Button.js', 'common/Card.js'] + ); + } finally { + process.chdir(originalCwd); + } +}); From 491f7b0a1e49cc35ed740658e0ed6c2958734a4f Mon Sep 17 00:00:00 2001 From: userquin Date: Wed, 28 Jan 2026 15:09:08 +0100 Subject: [PATCH 6/6] chore: optimize single patterns --- src/crawler.ts | 2 +- src/sort.ts | 80 ++++++++++++++++++++++++++++++++++----- src/types.ts | 1 + test/index.test.ts | 15 ++++++++ test/sort.test.ts | 94 +++++++++++++++++++++++++++++++++++++++++++--- 5 files changed, 176 insertions(+), 16 deletions(-) diff --git a/src/crawler.ts b/src/crawler.ts index 99f16b3..89769b7 100644 --- a/src/crawler.ts +++ b/src/crawler.ts @@ -28,7 +28,7 @@ export function buildCrawlerInfo(options: InternalOptions, patterns: readonly st const format = buildFormat(cwd, root, absolute); - return { processed, matchOptions, cwd, root, absolute, props, format }; + return { processed, matchOptions, cwd, root, absolute, props, format, patterns }; } // #endregion buildCrawlerInfo diff --git a/src/sort.ts b/src/sort.ts index d349480..8697afa 100644 --- a/src/sort.ts +++ b/src/sort.ts @@ -106,7 +106,50 @@ export function* compileMatchers( const sortAsc = (a: string, b: string) => a.localeCompare(b); const sortDesc = (a: string, b: string) => b.localeCompare(a); +function* internalYieldFiles(files: string[], sort?: Sort): Generator { + switch (true) { + case sort === 'asc': + case sort === 'pattern-asc': + yield* files.sort(sortAsc); + return; + case sort === 'desc': + case sort === 'pattern-desc': + yield* files.sort(sortDesc); + return; + case typeof sort === 'function': + yield* files.sort(sort); + return; + } + + for (const file of files) { + yield file; + } +} + +function internalReturnSortedFiles(files: string[], sort?: Sort): string[] { + switch (true) { + case sort === 'asc': + case sort === 'pattern-asc': + return files.sort(sortAsc); + case sort === 'desc': + case sort === 'pattern-desc': + return files.sort(sortDesc); + case typeof sort === 'function': + return files.sort(sort); + } + + return files; +} + export function internalSortFiles(files: string[], crawlerInfo: CrawlerInfo, sort?: Sort): string[] { + if (sort === undefined) { + return files; + } + + if (crawlerInfo.patterns.length === 1) { + return internalReturnSortedFiles(files, sort); + } + switch (true) { case typeof sort === 'function': return files.sort(sort); @@ -135,17 +178,23 @@ export function sortFiles( patterns: string | readonly string[], options?: Omit ): string[] { + const sort = options?.sort; + const patternArray = ensureStringArray(patterns); + if (patternArray.length === 1) { + return internalReturnSortedFiles(files, sort); + } + switch (true) { - case typeof options?.sort === 'function': - return files.sort(options.sort); - case options?.sort === 'asc': + case typeof sort === 'function': + return files.sort(sort); + case sort === 'asc': return files.sort(sortAsc); - case options?.sort === 'desc': + case sort === 'desc': return files.sort(sortDesc); - case options?.sort === 'pattern': - case options?.sort === 'pattern-asc': - case options?.sort === 'pattern-desc': - return [...sortFilesByPatternPrecedence(files, patterns, options)]; + case sort === 'pattern': + case sort === 'pattern-asc': + case sort === 'pattern-desc': + return [...sortFilesByPatternPrecedence(files, patternArray, options)]; default: return files; } @@ -164,6 +213,11 @@ export function* internalSortFilesByPatternPrecedence( return; } + if (crawlerInfo.patterns.length === 1) { + yield* internalYieldFiles(files, sort); + return; + } + const matcher = internalCompileMatchers(crawlerInfo); const processedFiles = new Set(); if (sort === 'pattern') { @@ -203,9 +257,17 @@ export function* sortFilesByPatternPrecedence( patterns: string | readonly string[], options?: Omit ): Generator { + // avoid creating crawler info + const sort = options?.sort; + const patternArray = ensureStringArray(patterns); + if (patternArray.length === 1) { + yield* internalYieldFiles(files, sort); + return; + } + yield* internalSortFilesByPatternPrecedence( files, - buildCrawlerInfo(getOptions(options), ensureStringArray(patterns)), + buildCrawlerInfo(getOptions(options), patternArray), options?.sort ); } diff --git a/src/types.ts b/src/types.ts index ebe2d4e..85e1d01 100644 --- a/src/types.ts +++ b/src/types.ts @@ -45,6 +45,7 @@ export interface CrawlerInfo { root: string; absolute?: boolean; props: InternalProps; + patterns: readonly string[]; format: (p: string, isDir: boolean) => string; } diff --git a/test/index.test.ts b/test/index.test.ts index 7d23289..56feb52 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -532,3 +532,18 @@ test('sort custom', async () => { const files = await glob('**/*', { cwd, sort: (a, b) => a.localeCompare(b) }); assert.deepEqual(files, ['a/a.txt', 'a/b.txt', 'b/a.txt', 'b/b.txt']); }); + +test('sort asc with multiple patterns', async () => { + const files = await glob(['a/*', 'b/*'], { cwd, sort: 'asc' }); + assert.deepEqual(files, ['a/a.txt', 'a/b.txt', 'b/a.txt', 'b/b.txt']); +}); + +test('sort desc with multiple patterns', async () => { + const files = await glob(['a/*', 'b/*'], { cwd, sort: 'desc' }); + assert.deepEqual(files, ['b/b.txt', 'b/a.txt', 'a/b.txt', 'a/a.txt']); +}); + +test('sort custom with multiple patterns', async () => { + const files = await glob(['a/*', 'b/*'], { cwd, sort: (a, b) => a.localeCompare(b) }); + assert.deepEqual(files, ['a/a.txt', 'a/b.txt', 'b/a.txt', 'b/b.txt']); +}); diff --git a/test/sort.test.ts b/test/sort.test.ts index 2b70e0a..6804886 100644 --- a/test/sort.test.ts +++ b/test/sort.test.ts @@ -1,7 +1,15 @@ import assert from 'node:assert/strict'; import { after, test } from 'node:test'; import { createFixture } from 'fs-fixture'; -import { compileMatchers, sortFiles, sortFilesByPatternPrecedence } from '../src/sort.ts'; +import { buildCrawlerInfo } from '../src/crawler.ts'; +import { getOptions } from '../src/options.ts'; +import { + compileMatchers, + internalSortFiles, + internalSortFilesByPatternPrecedence, + sortFiles, + sortFilesByPatternPrecedence +} from '../src/sort.ts'; import type { GlobInput } from '../src/types.ts'; const fixture = await createFixture({ @@ -131,11 +139,14 @@ test('sort files with custom function', () => { ]); }); -test('sort files by precedence returns unsorted if sort option is not pattern-related', () => { - const files = [`${escapedCwd}/common/Button.js`, `${escapedCwd}/common/Card.js`, `${escapedCwd}/overrides/Button.js`]; - // sort: 'asc' is not a pattern sort, so it should return files as is from this generator - const useOptions = { ...options, sort: 'asc' } satisfies GlobInput; - assert.deepEqual([...sortFilesByPatternPrecedence(files, patterns, useOptions)], files); +test('sort files with no options', () => { + const files = [`${escapedCwd}/overrides/Button.js`, `${escapedCwd}/common/Card.js`, `${escapedCwd}/common/Button.js`]; + // sort without options + assert.deepEqual(sortFiles(files, patterns), [ + `${escapedCwd}/overrides/Button.js`, + `${escapedCwd}/common/Card.js`, + `${escapedCwd}/common/Button.js` + ]); }); test('sort files by precedence with no options', () => { @@ -154,3 +165,74 @@ test('sort files by precedence with no options', () => { process.chdir(originalCwd); } }); + +test('sort files with single pattern', () => { + const files = [`${escapedCwd}/common/Button.js`, `${escapedCwd}/common/Card.js`]; + const singlePattern = ['common/**/*.js']; + + // pattern (no sort) + assert.deepEqual(sortFiles(files, singlePattern, { ...options, sort: 'pattern' }), files); + + // pattern-asc (sort asc) + assert.deepEqual(sortFiles(files, singlePattern, { ...options, sort: 'pattern-asc' }), [ + `${escapedCwd}/common/Button.js`, + `${escapedCwd}/common/Card.js` + ]); + + // pattern-desc (sort desc) + assert.deepEqual(sortFiles(files, singlePattern, { ...options, sort: 'pattern-desc' }), [ + `${escapedCwd}/common/Card.js`, + `${escapedCwd}/common/Button.js` + ]); +}); + +test('sort files by precedence with single pattern', () => { + const files = [`${escapedCwd}/common/Button.js`, `${escapedCwd}/common/Card.js`]; + const singlePattern = ['common/**/*.js']; + + // pattern (no sort) + assert.deepEqual([...sortFilesByPatternPrecedence(files, singlePattern, { ...options, sort: 'pattern' })], files); + + // pattern-asc (sort asc) + assert.deepEqual( + [...sortFilesByPatternPrecedence(files, singlePattern, { ...options, sort: 'pattern-asc' })], + [`${escapedCwd}/common/Button.js`, `${escapedCwd}/common/Card.js`] + ); + + // pattern-desc (sort desc) + assert.deepEqual( + [...sortFilesByPatternPrecedence(files, singlePattern, { ...options, sort: 'pattern-desc' })], + [`${escapedCwd}/common/Card.js`, `${escapedCwd}/common/Button.js`] + ); +}); + +test('sort files by precedence with single pattern and custom sort', () => { + const files = [`${escapedCwd}/common/Button.js`, `${escapedCwd}/common/Card.js`]; + const singlePattern = ['common/**/*.js']; + const useOptions = { ...options, sort: (a, b) => a.localeCompare(b) } satisfies GlobInput; + + assert.deepEqual( + [...sortFilesByPatternPrecedence(files, singlePattern, useOptions)], + [`${escapedCwd}/common/Button.js`, `${escapedCwd}/common/Card.js`] + ); +}); + +test('sort files with invalid sort option', () => { + const files = [`${escapedCwd}/override/Button.js`, `${escapedCwd}/common/Button.js`, `${escapedCwd}/common/Card.js`]; + assert.deepEqual( + internalSortFiles( + files, + buildCrawlerInfo(getOptions(options), patterns), + // @ts-expect-error force add coverage for L165 + 'invalid' + ), + files + ); +}); +test('sort files with single pattern and pattern sort option', () => { + const files = [`${escapedCwd}/common/Button.js`, `${escapedCwd}/common/Card.js`]; + assert.deepEqual( + [...internalSortFilesByPatternPrecedence(files, buildCrawlerInfo(getOptions(options), [patterns[1]]), 'pattern')], + files + ); +});