diff --git a/src/crawler.ts b/src/crawler.ts index e1d0587..89769b7 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, patterns }; +} +// #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 7bc2e7f..0273c74 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,9 +1,8 @@ -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 } from './options.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) { if (mapper) { @@ -14,44 +13,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[], CrawlerInfo] { if (globInput && inputOptions?.patterns) { throw new Error('Cannot pass patterns as both an argument and an option'); } @@ -59,9 +24,23 @@ function getCrawler(globInput: GlobInput, inputOptions: GlobOptions = {}): [] | const isModern = isReadonlyArray(globInput) || typeof globInput === 'string'; // defaulting to ['**/*'] is tinyglobby exclusive behavior, deprecated const patterns = ensureStringArray((isModern ? globInput : globInput.patterns) ?? '**/*'); + + if (patterns.length === 0) { + return []; + } + const options = getOptions(isModern ? inputOptions : globInput); + const [crawler, relative, crawlerInfo] = buildCrawler(options, patterns); + return [crawler, relative, options, patterns, crawlerInfo]; +} - return patterns.length > 0 ? buildCrawler(options, patterns) : []; +function processResults( + paths: string[], + relative: false | RelativeMapper, + options: GlobOptions, + crawlerInfo: CrawlerInfo +): string[] { + return internalSortFiles(formatPaths(paths, relative), crawlerInfo, options.sort); } /** @@ -74,8 +53,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, _, crawlerInfo] = result; + return processResults(await crawler.withPromise(), relative, opts, crawlerInfo); } /** @@ -88,9 +71,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; +} diff --git a/src/sort.ts b/src/sort.ts new file mode 100644 index 0000000..8697afa --- /dev/null +++ b/src/sort.ts @@ -0,0 +1,273 @@ +import picomatch from 'picomatch'; +import { buildCrawlerInfo } from './crawler.ts'; +import { getOptions } from './options.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`. + * + * 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. + * @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?: Omit +): Generator boolean], undefined, void> { + 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); + +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); + 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. + * @param patterns The glob pattern(s). + * @param options The options. + * @returns The files from a glob scan sorted. + */ +export function sortFiles( + files: string[], + 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 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 [...sortFilesByPatternPrecedence(files, patternArray, options)]; + default: + return files; + } +} + +export function* internalSortFilesByPatternPrecedence( + files: string[], + crawlerInfo: CrawlerInfo, + sort?: Sort +): Generator { + sort ??= 'pattern'; + if (sort !== 'pattern' && sort !== 'pattern-asc' && sort !== 'pattern-desc') { + for (const file of files) { + yield file; + } + return; + } + + if (crawlerInfo.patterns.length === 1) { + yield* internalYieldFiles(files, sort); + return; + } + + const matcher = internalCompileMatchers(crawlerInfo); + 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; + } + } +} + +/** + * 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 { + // 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), patternArray), + options?.sort + ); +} diff --git a/src/types.ts b/src/types.ts index 68f18b1..85e1d01 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,19 @@ export interface ProcessedPatterns { ignore: string[]; } +export interface CrawlerInfo { + processed: ProcessedPatterns; + matchOptions: PicomatchOptions; + cwd: string; + root: string; + absolute?: boolean; + props: InternalProps; + patterns: readonly string[]; + 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. @@ -130,6 +144,17 @@ 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. + * - `(a: string, b: string) => number`: A custom sort function. + * @default undefined + */ + sort?: Sort; } export type InternalOptions = Pick< diff --git a/test/index.test.ts b/test/index.test.ts index df32a99..56feb52 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -498,3 +498,52 @@ 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']); +}); + +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 new file mode 100644 index 0000000..6804886 --- /dev/null +++ b/test/sort.test.ts @@ -0,0 +1,238 @@ +import assert from 'node:assert/strict'; +import { after, test } from 'node:test'; +import { createFixture } from 'fs-fixture'; +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({ + 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('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), [ + `${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); +}); + +// 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 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', () => { + // 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); + } +}); + +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 + ); +});