UNPKG

@augment-vir/node

Version:

A collection of augments, helpers types, functions, and classes only for Node.js (backend) JavaScript environments.

1,075 lines (972 loc) 32.7 kB
import {assert, check} from '@augment-vir/assert'; import { awaitedBlockingMap, getObjectTypedKeys, log, typedObjectFromEntries, type PartialWithUndefined, type SelectFrom, } from '@augment-vir/common'; import {spawn} from 'node:child_process'; import {lstat, readdir, stat} from 'node:fs/promises'; import {isAbsolute, join, resolve} from 'node:path'; import {type IsEqual, type RequireExactlyOne} from 'type-fest'; import {isOperatingSystem, OperatingSystem} from '../os/operating-system.js'; /** * Optional options for {@link grep}. * * @category Internal * @category Package : @augment-vir/node * @package [`@augment-vir/node`](https://www.npmjs.com/package/@augment-vir/node) */ export type GrepOptions<CountOnly extends boolean = false> = PartialWithUndefined<{ /* node:coverage ignore next */ patternSyntax: RequireExactlyOne<{ /** * -E, --extended-regexp: Interpret PATTERNS as extended regular expressions (EREs, see * below). */ extendedRegExp: true; /** -F, --fixed-strings: Interpret PATTERNS as fixed strings, not regular expressions. */ fixedStrings: true; /** * -G, --basic-regexp: Interpret PATTERNS as basic regular expressions (BREs, see below). * This is the default. */ basicRegExp: true; }>; /** * If set to true, sets: -i, --ignore-case: Ignore case distinctions in patterns and input data, * so that characters that differ only in case match each other. */ ignoreCase: boolean; /** -v, --invert-match: Invert the sense of matching, to select non-matching lines. */ invertMatch: boolean; matchType: RequireExactlyOne<{ /** * -w, --word-regexp: Select only those lines containing matches that form whole words. The * test is that the matching substring must either be at the beginning of the line, or * preceded by a non-word constituent character. Similarly, it must be either at the end of * the line or followed by a non-word constituent character. Word-constituent characters are * letters, digits, and the underscore. This option has no effect if -x is also specified. */ wordRegExp: true; /** * -x, --line-regexp: Select only those matches that exactly match the whole line. For a * regular expression pattern, this is like parenthesizing the pattern and then surrounding * it with ^ and $. */ lineRegExp: true; }>; output: RequireExactlyOne<{ /** * -c, --count: Suppress normal output; instead print a count of matching lines for each * input file. With the -v, --invert-match option (see above), count non-matching lines. */ countOnly: CountOnly; /** * -l, --files-with-matches: Suppress normal output; instead print the name of each input * file from which output would normally have been printed. Scanning each input file stops * upon first match. */ filesOnly: true; }>; /** * --exclude=GLOB: Skip any command-line file with a name suffix that matches the pattern GLOB, * using wildcard matching; a name suffix is either the whole name, or a trailing part that * starts with a non-slash character immediately after a slash (/) in the name. When searching * recursively, skip any subfile whose base name matches GLOB; the base name is the part after * the last slash. A pattern can use *, ?, and [...] as wildcards, and \ to quote a wildcard or * backslash character literally. */ excludePatterns: string[]; /** * -m NUM, --max-count=NUM: Stop reading a file after NUM matching lines. If NUM is zero, grep * stops right away without reading input. A NUM of -1 is treated as infinity and grep does not * stop; this is the default. If the input is standard input from a regular file, and NUM * matching lines are output, grep ensures that the standard input is positioned to just after * the last matching line before exiting, regardless of the presence of trailing context lines. * This enables a calling process to resume a search. When grep stops after NUM matching lines, * it outputs any trailing context lines. When the -c or --count option is also used, grep does * not output a count greater than NUM. When the -v or --invert-match option is also used, grep * stops after outputting NUM non-matching lines. */ maxCount: number; /** * -r, --recursive: Read all files under each directory, recursively, following symbolic links * only if they are on the command line. Note that if no file operand is given, grep searches * the working directory. This is equivalent to the -d recurse option. */ recursive: boolean; /** * If `true`, sets `--dereference-recursive` instead of `--recursive` when searching * recursively. * * -R, --dereference-recursive: Read all files under each directory, recursively. Follow all * symbolic links, unlike -r. */ followSymLinks: boolean; /** * -U, --binary: Treat the file(s) as binary. By default, under MS-DOS and MS-Windows, grep * guesses whether a file is text or binary as described for the --binary-files option. If grep * decides the file is a text file, it strips the CR characters from the original file contents * (to make regular expressions with ^ and $ work correctly). Specifying -U overrules this * guesswork, causing all files to be read and passed to the matching mechanism verbatim; if the * file is a text file with CR/LF pairs at the end of each line, this will cause some regular * expressions to fail. This option has no effect on platforms other than MS-DOS and MS- * Windows. */ binary: boolean; /** * --exclude-dir=GLOB: Skip any command-line directory with a name suffix that matches the * pattern GLOB. When searching recursively, skip any subdirectory whose base name matches GLOB. * Ignore any redundant trailing slashes in GLOB. */ excludeDirs: string[]; /** * --include=GLOB: Search only files whose base name matches GLOB (using wildcard matching as * described under --exclude). If contradictory --include and --exclude options are given, the * last matching one wins. If no --include or --exclude options match, a file is included unless * the first such option is --include. */ includeFiles: string[]; /** Debugging option: if set to `true`, the grep CLI command will be printed before execution. */ printCommand: boolean; /** The directory where the grep command where be run within. */ cwd: string; }>; /** * Search location options for {@link grep}. * * @category Internal * @category Package : @augment-vir/node * @package [`@augment-vir/node`](https://www.npmjs.com/package/@augment-vir/node) */ export type GrepSearchLocation = RequireExactlyOne<{ /** Search within multiple files. */ files: string[]; /** * Search within multiple directories. Set `recursive` to `true` in options to search the * directory recursively. */ dirs: string[]; /** * Search within a single directory. Set `recursive` to `true` in options to search the * directory recursively. */ dir: string; /** Search within a single file. */ file: string; }>; /** * Search pattern options for {@link grep}. * * @category Internal * @category Package : @augment-vir/node * @package [`@augment-vir/node`](https://www.npmjs.com/package/@augment-vir/node) */ export type GrepSearchPattern = RequireExactlyOne<{ pattern: string; patterns: string[]; }>; const grepBinPath = '/usr/bin/grep'; function shellQuote(input: string) { return [ "'", input.replaceAll("'", String.raw`'\''`), "'", ].join(''); } function recursiveFlag({ recursive, followSymLinks, }: Readonly<Pick<GrepOptions, 'recursive' | 'followSymLinks'>>): string { if (!recursive) { return ''; } else if (!followSymLinks) { return '--recursive'; } /** * BSD `grep` (macOS) requires `-S` to follow symlinks while recursing, but GNU `grep` (Linux) * has no `-S` flag and instead follows all symlinks with `-R`. Only one of these branches can * run on a given operating system. */ /* node:coverage ignore next */ return isOperatingSystem(OperatingSystem.Mac) ? '-RS' : '-R'; } function isValidMaxCount(maxCount: unknown) { return ( maxCount == undefined || (check.isNumber(maxCount) && Number.isInteger(maxCount) && maxCount >= -1) ); } function isOptionalBoolean(input: unknown) { return input == undefined || check.isBoolean(input); } function isValidTrueOnlyOptionGroup({ input, values, }: Readonly<{ input: unknown; values: ReadonlyArray<unknown>; }>) { return ( input == undefined || (check.isObject(input) && values.every((value) => value == undefined || value === true) && values.filter((value) => value === true).length === 1) ); } function isValidPatternSyntax(input: unknown) { return isValidTrueOnlyOptionGroup({ input, values: check.isObject(input) ? [ input.basicRegExp, input.extendedRegExp, input.fixedStrings, ] : [], }); } function isValidMatchType(input: unknown) { return isValidTrueOnlyOptionGroup({ input, values: check.isObject(input) ? [ input.lineRegExp, input.wordRegExp, ] : [], }); } function isValidOutput(input: unknown) { return isValidTrueOnlyOptionGroup({ input, values: check.isObject(input) ? [ input.countOnly, input.filesOnly, ] : [], }); } type GrepCommandOutput = { exitCode: number | undefined; stdout: string; }; function didGrepFail({exitCode}: Readonly<GrepCommandOutput>) { return exitCode == undefined || exitCode > 1; } function spawnGrepProcess({ args, cwd, }: Readonly<{ args: string[]; cwd: string | undefined; }>) { try { return spawn(grepBinPath, args, { cwd, stdio: [ 'ignore', 'pipe', 'pipe', ], }); /* node:coverage ignore next 3 */ } catch { return undefined; } } async function runGrepCommand({ args, cwd, }: Readonly<{ args: string[]; cwd: string | undefined; }>): Promise<GrepCommandOutput> { return new Promise<GrepCommandOutput>((resolveOutput) => { const stdoutChunks: Buffer[] = []; const grepProcess = spawnGrepProcess({ args, cwd, }); if (!grepProcess) { resolveOutput({ exitCode: undefined, stdout: '', }); return; } assert.isDefined(grepProcess.stdout, 'stdout emitter was not created for grep.'); assert.isDefined(grepProcess.stderr, 'stderr emitter was not created for grep.'); grepProcess.stdout.on('data', (chunk) => { stdoutChunks.push(Buffer.from(chunk)); }); grepProcess.stderr.on('data', () => {}); /* node:coverage ignore next 5 */ grepProcess.on('error', () => { resolveOutput({ exitCode: undefined, stdout: Buffer.concat(stdoutChunks).toString(), }); }); grepProcess.on('close', (rawExitCode) => { resolveOutput({ /* node:coverage ignore next */ exitCode: rawExitCode ?? undefined, stdout: Buffer.concat(stdoutChunks).toString(), }); }); }); } function redactGrepArgForLogging({ arg, index, operandDelimiterIndex, previousArg, }: Readonly<{ arg: string; index: number; operandDelimiterIndex: number; previousArg: string | undefined; }>) { if (previousArg === '-e') { return '<pattern>'; } else if (operandDelimiterIndex >= 0 && index > operandDelimiterIndex) { return '<path>'; } else if (arg.startsWith('--exclude-dir=')) { return '--exclude-dir=<glob>'; } else if (arg.startsWith('--exclude=')) { return '--exclude=<glob>'; } else if (arg.startsWith('--include=')) { return '--include=<glob>'; } else if (arg.startsWith('--max-count=')) { return '--max-count=<count>'; } else { return arg; } } function formatGrepCommand(args: ReadonlyArray<string>) { const operandDelimiterIndex = args.indexOf('--'); return [ 'grep', ...args.map((arg, index) => shellQuote( redactGrepArgForLogging({ arg, index, operandDelimiterIndex, previousArg: args[index - 1], }), ), ), ].join(' '); } function replaceGrepCountOutputArg({ args, replacement, }: Readonly<{ args: ReadonlyArray<string>; replacement: string; }>) { const countArgIndex = args.indexOf('--count'); /* node:coverage ignore next */ return countArgIndex < 0 ? [...args] : args.toSpliced(countArgIndex, 1, replacement); } function replaceGrepSearchOperands({ args, searchParts, }: Readonly<{ args: ReadonlyArray<string>; searchParts: ReadonlyArray<string>; }>) { const operandDelimiterIndex = args.indexOf('--'); return [ ...args.slice(0, operandDelimiterIndex + 1), ...searchParts, ]; } function extractStringArray(input: unknown) { return check.isArray(input) ? input.filter(check.isString).filter(check.isTruthy) : []; } function extractOptionalStringArray(input: unknown) { if (input == undefined) { return []; } else if (!check.isArray(input) || !input.every(check.isString)) { return undefined; } return input.filter(check.isTruthy); } function extractString(input: unknown) { return check.isString(input) && input ? input : undefined; } function extractGrepOptionArrays({ excludeDirs, excludePatterns, includeFiles, }: Readonly< PartialWithUndefined<{ excludeDirs: unknown; excludePatterns: unknown; includeFiles: unknown; }> >) { const extractedExcludeDirs = extractOptionalStringArray(excludeDirs); const extractedExcludePatterns = extractOptionalStringArray(excludePatterns); const extractedIncludeFiles = extractOptionalStringArray(includeFiles); return extractedExcludeDirs && extractedExcludePatterns && extractedIncludeFiles ? { excludeDirs: extractedExcludeDirs, excludePatterns: extractedExcludePatterns, includeFiles: extractedIncludeFiles, } : undefined; } function areGrepOptionsValid<const CountOnly extends boolean>({ grepOptions, }: Readonly<{ grepOptions: Readonly<Partial<GrepOptions<CountOnly>>>; }>) { return ( isValidMaxCount(grepOptions.maxCount) && (grepOptions.cwd == undefined || !!extractString(grepOptions.cwd)) && [ grepOptions.binary, grepOptions.followSymLinks, grepOptions.ignoreCase, grepOptions.invertMatch, grepOptions.printCommand, grepOptions.recursive, ].every(isOptionalBoolean) && isValidPatternSyntax(grepOptions.patternSyntax) && isValidMatchType(grepOptions.matchType) && isValidOutput(grepOptions.output) ); } function createSearchPatterns(grepSearchPattern: Readonly<GrepSearchPattern> | undefined) { if (!check.isObject(grepSearchPattern)) { return []; } const rawPatterns: unknown[] = check.isArray(grepSearchPattern.patterns) ? grepSearchPattern.patterns : [ grepSearchPattern.pattern, ]; return extractStringArray(rawPatterns); } function createSearchLocation( grepSearchLocation: Readonly<GrepSearchLocation> | undefined, ): SelectFrom<GrepSearchLocation, {files: true; dirs: true}> | undefined { if (!check.isObject(grepSearchLocation)) { return undefined; } const file = extractString(grepSearchLocation.file); const dir = extractString(grepSearchLocation.dir); if (grepSearchLocation.files != undefined) { return { files: extractStringArray(grepSearchLocation.files), }; } else if (file) { return { files: [ file, ], }; } else if (grepSearchLocation.dirs != undefined) { return { dirs: extractStringArray(grepSearchLocation.dirs), }; } return dir ? { dirs: [ dir, ], } : undefined; } function resolveSearchPart({ cwd, searchPart, }: Readonly<{ cwd: string | undefined; searchPart: string; }>) { return cwd && !isAbsolute(searchPart) ? resolve(cwd, searchPart) : searchPart; } async function shouldSearchPart({ cwd, followSymLinks, includeDirectories, searchPart, }: Readonly<{ cwd: string | undefined; followSymLinks: boolean | undefined; includeDirectories: boolean; searchPart: string; }>) { try { const fileStats = await (followSymLinks ? stat : lstat)( resolveSearchPart({ cwd, searchPart, }), ); return fileStats.isFile() || (includeDirectories && fileStats.isDirectory()); } catch { return false; } } async function filterSearchParts({ cwd, followSymLinks, includeDirectories, searchParts, }: Readonly<{ cwd: string | undefined; followSymLinks: boolean | undefined; includeDirectories: boolean; searchParts: ReadonlyArray<string>; }>) { return ( await awaitedBlockingMap(searchParts, async (searchPart) => { return (await shouldSearchPart({ cwd, followSymLinks, includeDirectories, searchPart, })) ? searchPart : undefined; }) ).filter(check.isTruthy); } async function readDirectDirSearchParts({ cwd, dir, followSymLinks, }: Readonly<{ cwd: string | undefined; dir: string; followSymLinks: boolean | undefined; }>) { try { const readDirPath = resolveSearchPart({ cwd, searchPart: dir, }); return ( await awaitedBlockingMap( (await readdir(readDirPath)).toSorted().filter((entry) => !entry.startsWith('.')), async (entry) => { const searchPart = join(dir, entry); return (await shouldSearchPart({ cwd: readDirPath, followSymLinks, includeDirectories: false, searchPart: entry, })) ? searchPart : undefined; }, ) ).filter(check.isTruthy); /* node:coverage ignore next 3 */ } catch { return []; } } async function createSearchParts({ cwd, followSymLinks, recursive, searchLocation, }: Readonly<{ cwd: string | undefined; followSymLinks: boolean | undefined; recursive: boolean | undefined; searchLocation: SelectFrom<GrepSearchLocation, {files: true; dirs: true}>; }>) { const searchParts = searchLocation.dirs || searchLocation.files; assert.isDefined(searchParts, 'Grep search location was not resolved.'); const filteredSearchParts = await filterSearchParts({ cwd, followSymLinks, includeDirectories: !!searchLocation.dirs, searchParts, }); return searchLocation.dirs ? recursive ? filteredSearchParts : ( await awaitedBlockingMap(filteredSearchParts, (dir) => readDirectDirSearchParts({ cwd, dir, followSymLinks, }), ) ).flat() : filteredSearchParts; } type GrepCountEntryParams = { countString: string | undefined; fileName: string | undefined; }; type NullDelimitedGrepRecord = { fileName: string; value: string; }; function createGrepCountEntry({countString, fileName}: Readonly<GrepCountEntryParams>) { assert.isDefined(fileName, 'Failed parse grep file name.'); const count = Number(countString); assert.isNumber(count, `Failed to parse grep number from: '${countString}'`); /* node:coverage ignore next 3 */ if (!count) { return undefined; } return { key: fileName, value: count, }; } function parseNullDelimitedGrepRecords({stdout}: Readonly<{stdout: string}>) { const records: NullDelimitedGrepRecord[] = []; let recordStartIndex = 0; while (recordStartIndex < stdout.length) { const delimiterIndex = stdout.indexOf('\0', recordStartIndex); /* node:coverage ignore next 3 */ if (delimiterIndex < 0) { break; } const valueStartIndex = delimiterIndex + 1; const newlineIndex = stdout.indexOf('\n', valueStartIndex); /* node:coverage ignore next */ const valueEndIndex = newlineIndex < 0 ? stdout.length : newlineIndex; records.push({ fileName: stdout.slice(recordStartIndex, delimiterIndex), value: stdout.slice(valueStartIndex, valueEndIndex), }); /* node:coverage ignore next */ recordStartIndex = newlineIndex < 0 ? stdout.length : newlineIndex + 1; } return records; } /* node:coverage ignore next 26 */ function parseColonDelimitedGrepCountOutput(stdout: string) { return typedObjectFromEntries( stdout .trimEnd() .split('\n') .map((entry) => { if (!entry) { return undefined; } const countDelimiterIndex = entry.lastIndexOf(':'); return createGrepCountEntry({ countString: countDelimiterIndex < 0 ? undefined : entry.slice(countDelimiterIndex + 1), fileName: countDelimiterIndex < 0 ? undefined : entry.slice(0, countDelimiterIndex), }); }) .filter(check.isTruthy) .map((entry) => [ entry.key, entry.value, ]), ); } /* node:coverage ignore next 20 */ function parseGrepCountOutput(stdout: string) { return stdout.includes('\0') ? typedObjectFromEntries( parseNullDelimitedGrepRecords({ stdout, }) .map((record) => { return createGrepCountEntry({ countString: record.value, fileName: record.fileName, }); }) .filter(check.isTruthy) .map((entry) => [ entry.key, entry.value, ]), ) : parseColonDelimitedGrepCountOutput(stdout); } /* node:coverage ignore next 7 */ function tryParseGrepCountOutput(stdout: string) { try { return parseGrepCountOutput(stdout); } catch { return undefined; } } function parseKnownFileCountOutput({ filePath, stdout, }: Readonly<{ filePath: string; stdout: string; }>) { /* node:coverage ignore next 3 */ if (stdout.includes('\0')) { return parseGrepCountOutput(stdout)[filePath]; } const countPrefix = `${filePath}:`; /* node:coverage ignore next */ const outputLine = stdout.endsWith('\n') ? stdout.slice(0, -1) : stdout; /* node:coverage ignore next 3 */ if (!outputLine.startsWith(countPrefix)) { return undefined; } /* node:coverage ignore next */ return createGrepCountEntry({ countString: outputLine.slice(countPrefix.length), fileName: filePath, })?.value; } async function runKnownFileGrepCount({ cwd, filePath, grepArgs, }: Readonly<{ cwd: string | undefined; filePath: string; grepArgs: ReadonlyArray<string>; }>) { const result = await runGrepCommand({ args: replaceGrepSearchOperands({ args: grepArgs, searchParts: [ filePath, ], }), cwd, }); /* node:coverage ignore next 3 */ if (didGrepFail(result)) { return undefined; } const count = parseKnownFileCountOutput({ filePath, stdout: result.stdout, }); /* node:coverage ignore next 3 */ if (!count) { return undefined; } return { key: filePath, value: count, }; } async function runGrepCountFallback({ cwd, grepArgs, }: Readonly<{ cwd: string | undefined; grepArgs: ReadonlyArray<string>; }>) { const filesOnlyResult = await runGrepCommand({ args: replaceGrepCountOutputArg({ args: grepArgs, replacement: '--files-with-matches', }), cwd, }); /* node:coverage ignore next 3 */ if (didGrepFail(filesOnlyResult) || filesOnlyResult.exitCode === 1 || !filesOnlyResult.stdout) { return {}; } return typedObjectFromEntries( ( await awaitedBlockingMap( getObjectTypedKeys(parseGrepFilesOnlyOutput(filesOnlyResult.stdout)), (filePath) => { return runKnownFileGrepCount({ cwd, filePath, grepArgs, }); }, ) ) .filter(check.isTruthy) .map((entry) => [ entry.key, entry.value, ]), ); } function parseGrepFilesOnlyOutput(stdout: string) { return typedObjectFromEntries( /* node:coverage ignore next */ (stdout.includes('\0') ? stdout.split('\0') : stdout.trimEnd().split('\n')) .filter(check.isTruthy) .map((entry) => [ entry, [], ]), ); } function parseGrepNormalOutput(stdout: string) { const fileMatches = new Map<string, string[]>(); parseNullDelimitedGrepRecords({ stdout, }).forEach((record) => { fileMatches.set(record.fileName, [ ...(fileMatches.get(record.fileName) || []), record.value, ]); }); return typedObjectFromEntries([...fileMatches.entries()]); } /** * Output of {@link grep}. Each key is a file path returned by `grep`. Values are arrays of matched * lines for that file. * * @category Internal * @category Package : @augment-vir/node * @package [`@augment-vir/node`](https://www.npmjs.com/package/@augment-vir/node) */ export type GrepMatches<CountOnly extends boolean = false> = IsEqual<CountOnly, true> extends true ? {[FileName in string]: number} : IsEqual<CountOnly, false> extends true ? {[FileName in string]: string[]} : {[FileName in string]: number} | {[FileName in string]: string[]}; /** * Run `grep`, matching patterns to specific lines in files or directories. * * @category Node : File * @category Package : @augment-vir/node * @package [`@augment-vir/node`](https://www.npmjs.com/package/@augment-vir/node) */ export async function grep<const CountOnly extends boolean = false>( grepSearchPattern: Readonly<GrepSearchPattern>, grepSearchLocation: Readonly<GrepSearchLocation>, options: Readonly<GrepOptions<CountOnly>> = {}, ): Promise<GrepMatches<CountOnly>> { const grepOptions: Readonly<Partial<GrepOptions<CountOnly>>> = check.isObject(options) ? options : {}; const searchPatterns = createSearchPatterns(grepSearchPattern); const grepOptionArrays = extractGrepOptionArrays(grepOptions); const cwd = extractString(grepOptions.cwd); if ( !searchPatterns.length || !grepOptionArrays || !areGrepOptionsValid({ grepOptions, }) ) { return {}; } const searchLocation = createSearchLocation(grepSearchLocation); if ( !searchLocation || (searchLocation.dirs && !searchLocation.dirs.length) || (searchLocation.files && !searchLocation.files.length) ) { return {}; } const searchParts = await createSearchParts({ cwd, followSymLinks: grepOptions.followSymLinks, recursive: grepOptions.recursive, searchLocation, }); if (!searchParts.length) { return {}; } const grepArgs = [ grepOptions.patternSyntax?.basicRegExp ? '--basic-regexp' : grepOptions.patternSyntax?.extendedRegExp ? '--extended-regexp' : grepOptions.patternSyntax?.fixedStrings ? '--fixed-strings' : '', grepOptions.ignoreCase ? '--ignore-case' : '', grepOptions.invertMatch && !grepOptions.output?.filesOnly ? '--invert-match' : '', grepOptions.matchType?.wordRegExp ? '--word-regexp' : grepOptions.matchType?.lineRegExp ? '--line-regexp' : '', grepOptions.output?.countOnly ? '--count' : grepOptions.output?.filesOnly ? grepOptions.invertMatch ? '--files-without-match' : '--files-with-matches' : '', '--color=never', grepOptions.maxCount == undefined ? '' : `--max-count=${grepOptions.maxCount}`, '--no-messages', '--devices=skip', '--with-filename', '--null', ...(grepOptionArrays.excludePatterns.length ? grepOptionArrays.excludePatterns.map( (excludePattern) => `--exclude=${excludePattern}`, ) : []), recursiveFlag(grepOptions), ...(grepOptionArrays.excludeDirs.length ? grepOptionArrays.excludeDirs.map((excludeDir) => `--exclude-dir=${excludeDir}`) : []), ...(grepOptionArrays.includeFiles.length ? grepOptionArrays.includeFiles.map((includeFile) => `--include=${includeFile}`) : []), grepOptions.binary ? '--binary' : '', ...searchPatterns.flatMap((searchPattern) => [ '-e', searchPattern, ]), '--', ...searchParts, ].filter(check.isTruthy); if (grepOptions.printCommand) { log.faint(`> ${formatGrepCommand(grepArgs)}`); } const result = await runGrepCommand({ args: grepArgs, cwd, }); if (didGrepFail(result) || result.exitCode === 1 || !result.stdout) { /** No matches. */ return {}; } else if (grepOptions.output?.countOnly) { /* node:coverage ignore next 4 */ const parsedCountOutput = isOperatingSystem(OperatingSystem.Mac) && !result.stdout.includes('\0') ? undefined : tryParseGrepCountOutput(result.stdout); /* node:coverage ignore next 3 */ if (parsedCountOutput) { return parsedCountOutput satisfies Record<string, number> as GrepMatches<CountOnly>; } return (await runGrepCountFallback({ cwd, grepArgs, })) satisfies Record<string, number> as GrepMatches<CountOnly>; } else if (grepOptions.output?.filesOnly) { return parseGrepFilesOnlyOutput(result.stdout) satisfies Record< string, string[] > as GrepMatches as GrepMatches<CountOnly>; } else { return parseGrepNormalOutput(result.stdout) satisfies Record< string, string[] > as GrepMatches<CountOnly>; } }