@augment-vir/node 31.73.2 → 31.73.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,16 +1,17 @@
1
1
  import {assert, check} from '@augment-vir/assert';
2
2
  import {
3
- arrayToObject,
4
- getOrSet,
3
+ awaitedBlockingMap,
4
+ getObjectTypedKeys,
5
5
  log,
6
- safeMatch,
6
+ typedObjectFromEntries,
7
7
  type PartialWithUndefined,
8
8
  type SelectFrom,
9
9
  } from '@augment-vir/common';
10
- import {join} from 'node:path';
10
+ import {spawn} from 'node:child_process';
11
+ import {lstat, readdir, stat} from 'node:fs/promises';
12
+ import {isAbsolute, join, resolve} from 'node:path';
11
13
  import {type IsEqual, type RequireExactlyOne} from 'type-fest';
12
14
  import {isOperatingSystem, OperatingSystem} from '../os/operating-system.js';
13
- import {runShellCommand} from '../terminal/shell.js';
14
15
 
15
16
  /**
16
17
  * Optional options for {@link grep}.
@@ -20,6 +21,7 @@ import {runShellCommand} from '../terminal/shell.js';
20
21
  * @package [`@augment-vir/node`](https://www.npmjs.com/package/@augment-vir/node)
21
22
  */
22
23
  export type GrepOptions<CountOnly extends boolean = false> = PartialWithUndefined<{
24
+ /* node:coverage ignore next */
23
25
  patternSyntax: RequireExactlyOne<{
24
26
  /**
25
27
  * -E, --extended-regexp: Interpret PATTERNS as extended regular expressions (EREs, see
@@ -175,8 +177,14 @@ export type GrepSearchPattern = RequireExactlyOne<{
175
177
  patterns: string[];
176
178
  }>;
177
179
 
178
- function escape(input: string) {
179
- return input.replaceAll('"', String.raw`\"`).replaceAll('\n', '');
180
+ const grepBinPath = '/usr/bin/grep';
181
+
182
+ function shellQuote(input: string) {
183
+ return [
184
+ "'",
185
+ input.replaceAll("'", String.raw`'\''`),
186
+ "'",
187
+ ].join('');
180
188
  }
181
189
 
182
190
  function recursiveFlag({
@@ -198,9 +206,721 @@ function recursiveFlag({
198
206
  return isOperatingSystem(OperatingSystem.Mac) ? '-RS' : '-R';
199
207
  }
200
208
 
209
+ function isValidMaxCount(maxCount: unknown) {
210
+ return (
211
+ maxCount == undefined ||
212
+ (check.isNumber(maxCount) && Number.isInteger(maxCount) && maxCount >= -1)
213
+ );
214
+ }
215
+
216
+ function isOptionalBoolean(input: unknown) {
217
+ return input == undefined || check.isBoolean(input);
218
+ }
219
+
220
+ function isValidTrueOnlyOptionGroup({
221
+ input,
222
+ values,
223
+ }: Readonly<{
224
+ input: unknown;
225
+ values: ReadonlyArray<unknown>;
226
+ }>) {
227
+ return (
228
+ input == undefined ||
229
+ (check.isObject(input) &&
230
+ values.every((value) => value == undefined || value === true) &&
231
+ values.filter((value) => value === true).length === 1)
232
+ );
233
+ }
234
+
235
+ function isValidPatternSyntax(input: unknown) {
236
+ return isValidTrueOnlyOptionGroup({
237
+ input,
238
+ values: check.isObject(input)
239
+ ? [
240
+ input.basicRegExp,
241
+ input.extendedRegExp,
242
+ input.fixedStrings,
243
+ ]
244
+ : [],
245
+ });
246
+ }
247
+
248
+ function isValidMatchType(input: unknown) {
249
+ return isValidTrueOnlyOptionGroup({
250
+ input,
251
+ values: check.isObject(input)
252
+ ? [
253
+ input.lineRegExp,
254
+ input.wordRegExp,
255
+ ]
256
+ : [],
257
+ });
258
+ }
259
+
260
+ function isValidOutput(input: unknown) {
261
+ return isValidTrueOnlyOptionGroup({
262
+ input,
263
+ values: check.isObject(input)
264
+ ? [
265
+ input.countOnly,
266
+ input.filesOnly,
267
+ ]
268
+ : [],
269
+ });
270
+ }
271
+
272
+ type GrepCommandOutput = {
273
+ exitCode: number | undefined;
274
+ stdout: string;
275
+ };
276
+
277
+ function didGrepFail({exitCode}: Readonly<GrepCommandOutput>) {
278
+ return exitCode == undefined || exitCode > 1;
279
+ }
280
+
281
+ function spawnGrepProcess({
282
+ args,
283
+ cwd,
284
+ }: Readonly<{
285
+ args: string[];
286
+ cwd: string | undefined;
287
+ }>) {
288
+ try {
289
+ return spawn(grepBinPath, args, {
290
+ cwd,
291
+ stdio: [
292
+ 'ignore',
293
+ 'pipe',
294
+ 'pipe',
295
+ ],
296
+ });
297
+ /* node:coverage ignore next 3 */
298
+ } catch {
299
+ return undefined;
300
+ }
301
+ }
302
+
303
+ async function runGrepCommand({
304
+ args,
305
+ cwd,
306
+ }: Readonly<{
307
+ args: string[];
308
+ cwd: string | undefined;
309
+ }>): Promise<GrepCommandOutput> {
310
+ return new Promise<GrepCommandOutput>((resolveOutput) => {
311
+ const stdoutChunks: Buffer[] = [];
312
+ const grepProcess = spawnGrepProcess({
313
+ args,
314
+ cwd,
315
+ });
316
+
317
+ if (!grepProcess) {
318
+ resolveOutput({
319
+ exitCode: undefined,
320
+ stdout: '',
321
+ });
322
+ return;
323
+ }
324
+
325
+ assert.isDefined(grepProcess.stdout, 'stdout emitter was not created for grep.');
326
+ assert.isDefined(grepProcess.stderr, 'stderr emitter was not created for grep.');
327
+
328
+ grepProcess.stdout.on('data', (chunk) => {
329
+ stdoutChunks.push(Buffer.from(chunk));
330
+ });
331
+ grepProcess.stderr.on('data', () => {});
332
+ /* node:coverage ignore next 5 */
333
+ grepProcess.on('error', () => {
334
+ resolveOutput({
335
+ exitCode: undefined,
336
+ stdout: Buffer.concat(stdoutChunks).toString(),
337
+ });
338
+ });
339
+ grepProcess.on('close', (rawExitCode) => {
340
+ resolveOutput({
341
+ /* node:coverage ignore next */
342
+ exitCode: rawExitCode ?? undefined,
343
+ stdout: Buffer.concat(stdoutChunks).toString(),
344
+ });
345
+ });
346
+ });
347
+ }
348
+
349
+ function redactGrepArgForLogging({
350
+ arg,
351
+ index,
352
+ operandDelimiterIndex,
353
+ previousArg,
354
+ }: Readonly<{
355
+ arg: string;
356
+ index: number;
357
+ operandDelimiterIndex: number;
358
+ previousArg: string | undefined;
359
+ }>) {
360
+ if (previousArg === '-e') {
361
+ return '<pattern>';
362
+ } else if (operandDelimiterIndex >= 0 && index > operandDelimiterIndex) {
363
+ return '<path>';
364
+ } else if (arg.startsWith('--exclude-dir=')) {
365
+ return '--exclude-dir=<glob>';
366
+ } else if (arg.startsWith('--exclude=')) {
367
+ return '--exclude=<glob>';
368
+ } else if (arg.startsWith('--include=')) {
369
+ return '--include=<glob>';
370
+ } else if (arg.startsWith('--max-count=')) {
371
+ return '--max-count=<count>';
372
+ } else {
373
+ return arg;
374
+ }
375
+ }
376
+
377
+ function formatGrepCommand(args: ReadonlyArray<string>) {
378
+ const operandDelimiterIndex = args.indexOf('--');
379
+
380
+ return [
381
+ 'grep',
382
+ ...args.map((arg, index) =>
383
+ shellQuote(
384
+ redactGrepArgForLogging({
385
+ arg,
386
+ index,
387
+ operandDelimiterIndex,
388
+ previousArg: args[index - 1],
389
+ }),
390
+ ),
391
+ ),
392
+ ].join(' ');
393
+ }
394
+
395
+ function replaceGrepCountOutputArg({
396
+ args,
397
+ replacement,
398
+ }: Readonly<{
399
+ args: ReadonlyArray<string>;
400
+ replacement: string;
401
+ }>) {
402
+ const countArgIndex = args.indexOf('--count');
403
+
404
+ /* node:coverage ignore next */
405
+ return countArgIndex < 0 ? [...args] : args.toSpliced(countArgIndex, 1, replacement);
406
+ }
407
+
408
+ function replaceGrepSearchOperands({
409
+ args,
410
+ searchParts,
411
+ }: Readonly<{
412
+ args: ReadonlyArray<string>;
413
+ searchParts: ReadonlyArray<string>;
414
+ }>) {
415
+ const operandDelimiterIndex = args.indexOf('--');
416
+
417
+ return [
418
+ ...args.slice(0, operandDelimiterIndex + 1),
419
+ ...searchParts,
420
+ ];
421
+ }
422
+
423
+ function extractStringArray(input: unknown) {
424
+ return check.isArray(input) ? input.filter(check.isString).filter(check.isTruthy) : [];
425
+ }
426
+
427
+ function extractOptionalStringArray(input: unknown) {
428
+ if (input == undefined) {
429
+ return [];
430
+ } else if (!check.isArray(input) || !input.every(check.isString)) {
431
+ return undefined;
432
+ }
433
+
434
+ return input.filter(check.isTruthy);
435
+ }
436
+
437
+ function extractString(input: unknown) {
438
+ return check.isString(input) && input ? input : undefined;
439
+ }
440
+
441
+ function extractGrepOptionArrays({
442
+ excludeDirs,
443
+ excludePatterns,
444
+ includeFiles,
445
+ }: Readonly<
446
+ PartialWithUndefined<{
447
+ excludeDirs: unknown;
448
+ excludePatterns: unknown;
449
+ includeFiles: unknown;
450
+ }>
451
+ >) {
452
+ const extractedExcludeDirs = extractOptionalStringArray(excludeDirs);
453
+ const extractedExcludePatterns = extractOptionalStringArray(excludePatterns);
454
+ const extractedIncludeFiles = extractOptionalStringArray(includeFiles);
455
+
456
+ return extractedExcludeDirs && extractedExcludePatterns && extractedIncludeFiles
457
+ ? {
458
+ excludeDirs: extractedExcludeDirs,
459
+ excludePatterns: extractedExcludePatterns,
460
+ includeFiles: extractedIncludeFiles,
461
+ }
462
+ : undefined;
463
+ }
464
+
465
+ function areGrepOptionsValid<const CountOnly extends boolean>({
466
+ grepOptions,
467
+ }: Readonly<{
468
+ grepOptions: Readonly<Partial<GrepOptions<CountOnly>>>;
469
+ }>) {
470
+ return (
471
+ isValidMaxCount(grepOptions.maxCount) &&
472
+ (grepOptions.cwd == undefined || !!extractString(grepOptions.cwd)) &&
473
+ [
474
+ grepOptions.binary,
475
+ grepOptions.followSymLinks,
476
+ grepOptions.ignoreCase,
477
+ grepOptions.invertMatch,
478
+ grepOptions.printCommand,
479
+ grepOptions.recursive,
480
+ ].every(isOptionalBoolean) &&
481
+ isValidPatternSyntax(grepOptions.patternSyntax) &&
482
+ isValidMatchType(grepOptions.matchType) &&
483
+ isValidOutput(grepOptions.output)
484
+ );
485
+ }
486
+
487
+ function createSearchPatterns(grepSearchPattern: Readonly<GrepSearchPattern> | undefined) {
488
+ if (!check.isObject(grepSearchPattern)) {
489
+ return [];
490
+ }
491
+
492
+ const rawPatterns: unknown[] = check.isArray(grepSearchPattern.patterns)
493
+ ? grepSearchPattern.patterns
494
+ : [
495
+ grepSearchPattern.pattern,
496
+ ];
497
+
498
+ return extractStringArray(rawPatterns);
499
+ }
500
+
501
+ function createSearchLocation(
502
+ grepSearchLocation: Readonly<GrepSearchLocation> | undefined,
503
+ ): SelectFrom<GrepSearchLocation, {files: true; dirs: true}> | undefined {
504
+ if (!check.isObject(grepSearchLocation)) {
505
+ return undefined;
506
+ }
507
+
508
+ const file = extractString(grepSearchLocation.file);
509
+ const dir = extractString(grepSearchLocation.dir);
510
+
511
+ if (grepSearchLocation.files != undefined) {
512
+ return {
513
+ files: extractStringArray(grepSearchLocation.files),
514
+ };
515
+ } else if (file) {
516
+ return {
517
+ files: [
518
+ file,
519
+ ],
520
+ };
521
+ } else if (grepSearchLocation.dirs != undefined) {
522
+ return {
523
+ dirs: extractStringArray(grepSearchLocation.dirs),
524
+ };
525
+ }
526
+
527
+ return dir
528
+ ? {
529
+ dirs: [
530
+ dir,
531
+ ],
532
+ }
533
+ : undefined;
534
+ }
535
+
536
+ function resolveSearchPart({
537
+ cwd,
538
+ searchPart,
539
+ }: Readonly<{
540
+ cwd: string | undefined;
541
+ searchPart: string;
542
+ }>) {
543
+ return cwd && !isAbsolute(searchPart) ? resolve(cwd, searchPart) : searchPart;
544
+ }
545
+
546
+ async function shouldSearchPart({
547
+ cwd,
548
+ followSymLinks,
549
+ includeDirectories,
550
+ searchPart,
551
+ }: Readonly<{
552
+ cwd: string | undefined;
553
+ followSymLinks: boolean | undefined;
554
+ includeDirectories: boolean;
555
+ searchPart: string;
556
+ }>) {
557
+ try {
558
+ const fileStats = await (followSymLinks ? stat : lstat)(
559
+ resolveSearchPart({
560
+ cwd,
561
+ searchPart,
562
+ }),
563
+ );
564
+
565
+ return fileStats.isFile() || (includeDirectories && fileStats.isDirectory());
566
+ } catch {
567
+ return false;
568
+ }
569
+ }
570
+
571
+ async function filterSearchParts({
572
+ cwd,
573
+ followSymLinks,
574
+ includeDirectories,
575
+ searchParts,
576
+ }: Readonly<{
577
+ cwd: string | undefined;
578
+ followSymLinks: boolean | undefined;
579
+ includeDirectories: boolean;
580
+ searchParts: ReadonlyArray<string>;
581
+ }>) {
582
+ return (
583
+ await awaitedBlockingMap(searchParts, async (searchPart) => {
584
+ return (await shouldSearchPart({
585
+ cwd,
586
+ followSymLinks,
587
+ includeDirectories,
588
+ searchPart,
589
+ }))
590
+ ? searchPart
591
+ : undefined;
592
+ })
593
+ ).filter(check.isTruthy);
594
+ }
595
+
596
+ async function readDirectDirSearchParts({
597
+ cwd,
598
+ dir,
599
+ followSymLinks,
600
+ }: Readonly<{
601
+ cwd: string | undefined;
602
+ dir: string;
603
+ followSymLinks: boolean | undefined;
604
+ }>) {
605
+ try {
606
+ const readDirPath = resolveSearchPart({
607
+ cwd,
608
+ searchPart: dir,
609
+ });
610
+
611
+ return (
612
+ await awaitedBlockingMap(
613
+ (await readdir(readDirPath)).toSorted().filter((entry) => !entry.startsWith('.')),
614
+ async (entry) => {
615
+ const searchPart = join(dir, entry);
616
+
617
+ return (await shouldSearchPart({
618
+ cwd: readDirPath,
619
+ followSymLinks,
620
+ includeDirectories: false,
621
+ searchPart: entry,
622
+ }))
623
+ ? searchPart
624
+ : undefined;
625
+ },
626
+ )
627
+ ).filter(check.isTruthy);
628
+ /* node:coverage ignore next 3 */
629
+ } catch {
630
+ return [];
631
+ }
632
+ }
633
+
634
+ async function createSearchParts({
635
+ cwd,
636
+ followSymLinks,
637
+ recursive,
638
+ searchLocation,
639
+ }: Readonly<{
640
+ cwd: string | undefined;
641
+ followSymLinks: boolean | undefined;
642
+ recursive: boolean | undefined;
643
+ searchLocation: SelectFrom<GrepSearchLocation, {files: true; dirs: true}>;
644
+ }>) {
645
+ const searchParts = searchLocation.dirs || searchLocation.files;
646
+ assert.isDefined(searchParts, 'Grep search location was not resolved.');
647
+
648
+ const filteredSearchParts = await filterSearchParts({
649
+ cwd,
650
+ followSymLinks,
651
+ includeDirectories: !!searchLocation.dirs,
652
+ searchParts,
653
+ });
654
+
655
+ return searchLocation.dirs
656
+ ? recursive
657
+ ? filteredSearchParts
658
+ : (
659
+ await awaitedBlockingMap(filteredSearchParts, (dir) =>
660
+ readDirectDirSearchParts({
661
+ cwd,
662
+ dir,
663
+ followSymLinks,
664
+ }),
665
+ )
666
+ ).flat()
667
+ : filteredSearchParts;
668
+ }
669
+
670
+ type GrepCountEntryParams = {
671
+ countString: string | undefined;
672
+ fileName: string | undefined;
673
+ };
674
+
675
+ type NullDelimitedGrepRecord = {
676
+ fileName: string;
677
+ value: string;
678
+ };
679
+
680
+ function createGrepCountEntry({countString, fileName}: Readonly<GrepCountEntryParams>) {
681
+ assert.isDefined(fileName, 'Failed parse grep file name.');
682
+
683
+ const count = Number(countString);
684
+
685
+ assert.isNumber(count, `Failed to parse grep number from: '${countString}'`);
686
+ /* node:coverage ignore next 3 */
687
+ if (!count) {
688
+ return undefined;
689
+ }
690
+
691
+ return {
692
+ key: fileName,
693
+ value: count,
694
+ };
695
+ }
696
+
697
+ function parseNullDelimitedGrepRecords({stdout}: Readonly<{stdout: string}>) {
698
+ const records: NullDelimitedGrepRecord[] = [];
699
+ let recordStartIndex = 0;
700
+
701
+ while (recordStartIndex < stdout.length) {
702
+ const delimiterIndex = stdout.indexOf('\0', recordStartIndex);
703
+
704
+ /* node:coverage ignore next 3 */
705
+ if (delimiterIndex < 0) {
706
+ break;
707
+ }
708
+
709
+ const valueStartIndex = delimiterIndex + 1;
710
+ const newlineIndex = stdout.indexOf('\n', valueStartIndex);
711
+ /* node:coverage ignore next */
712
+ const valueEndIndex = newlineIndex < 0 ? stdout.length : newlineIndex;
713
+
714
+ records.push({
715
+ fileName: stdout.slice(recordStartIndex, delimiterIndex),
716
+ value: stdout.slice(valueStartIndex, valueEndIndex),
717
+ });
718
+
719
+ /* node:coverage ignore next */
720
+ recordStartIndex = newlineIndex < 0 ? stdout.length : newlineIndex + 1;
721
+ }
722
+
723
+ return records;
724
+ }
725
+
726
+ /* node:coverage ignore next 26 */
727
+ function parseColonDelimitedGrepCountOutput(stdout: string) {
728
+ return typedObjectFromEntries(
729
+ stdout
730
+ .trimEnd()
731
+ .split('\n')
732
+ .map((entry) => {
733
+ if (!entry) {
734
+ return undefined;
735
+ }
736
+
737
+ const countDelimiterIndex = entry.lastIndexOf(':');
738
+
739
+ return createGrepCountEntry({
740
+ countString:
741
+ countDelimiterIndex < 0 ? undefined : entry.slice(countDelimiterIndex + 1),
742
+ fileName:
743
+ countDelimiterIndex < 0 ? undefined : entry.slice(0, countDelimiterIndex),
744
+ });
745
+ })
746
+ .filter(check.isTruthy)
747
+ .map((entry) => [
748
+ entry.key,
749
+ entry.value,
750
+ ]),
751
+ );
752
+ }
753
+
754
+ /* node:coverage ignore next 20 */
755
+ function parseGrepCountOutput(stdout: string) {
756
+ return stdout.includes('\0')
757
+ ? typedObjectFromEntries(
758
+ parseNullDelimitedGrepRecords({
759
+ stdout,
760
+ })
761
+ .map((record) => {
762
+ return createGrepCountEntry({
763
+ countString: record.value,
764
+ fileName: record.fileName,
765
+ });
766
+ })
767
+ .filter(check.isTruthy)
768
+ .map((entry) => [
769
+ entry.key,
770
+ entry.value,
771
+ ]),
772
+ )
773
+ : parseColonDelimitedGrepCountOutput(stdout);
774
+ }
775
+
776
+ /* node:coverage ignore next 7 */
777
+ function tryParseGrepCountOutput(stdout: string) {
778
+ try {
779
+ return parseGrepCountOutput(stdout);
780
+ } catch {
781
+ return undefined;
782
+ }
783
+ }
784
+
785
+ function parseKnownFileCountOutput({
786
+ filePath,
787
+ stdout,
788
+ }: Readonly<{
789
+ filePath: string;
790
+ stdout: string;
791
+ }>) {
792
+ /* node:coverage ignore next 3 */
793
+ if (stdout.includes('\0')) {
794
+ return parseGrepCountOutput(stdout)[filePath];
795
+ }
796
+
797
+ const countPrefix = `${filePath}:`;
798
+ /* node:coverage ignore next */
799
+ const outputLine = stdout.endsWith('\n') ? stdout.slice(0, -1) : stdout;
800
+
801
+ /* node:coverage ignore next 3 */
802
+ if (!outputLine.startsWith(countPrefix)) {
803
+ return undefined;
804
+ }
805
+
806
+ /* node:coverage ignore next */
807
+ return createGrepCountEntry({
808
+ countString: outputLine.slice(countPrefix.length),
809
+ fileName: filePath,
810
+ })?.value;
811
+ }
812
+
813
+ async function runKnownFileGrepCount({
814
+ cwd,
815
+ filePath,
816
+ grepArgs,
817
+ }: Readonly<{
818
+ cwd: string | undefined;
819
+ filePath: string;
820
+ grepArgs: ReadonlyArray<string>;
821
+ }>) {
822
+ const result = await runGrepCommand({
823
+ args: replaceGrepSearchOperands({
824
+ args: grepArgs,
825
+ searchParts: [
826
+ filePath,
827
+ ],
828
+ }),
829
+ cwd,
830
+ });
831
+
832
+ /* node:coverage ignore next 3 */
833
+ if (didGrepFail(result)) {
834
+ return undefined;
835
+ }
836
+
837
+ const count = parseKnownFileCountOutput({
838
+ filePath,
839
+ stdout: result.stdout,
840
+ });
841
+
842
+ /* node:coverage ignore next 3 */
843
+ if (!count) {
844
+ return undefined;
845
+ }
846
+
847
+ return {
848
+ key: filePath,
849
+ value: count,
850
+ };
851
+ }
852
+
853
+ async function runGrepCountFallback({
854
+ cwd,
855
+ grepArgs,
856
+ }: Readonly<{
857
+ cwd: string | undefined;
858
+ grepArgs: ReadonlyArray<string>;
859
+ }>) {
860
+ const filesOnlyResult = await runGrepCommand({
861
+ args: replaceGrepCountOutputArg({
862
+ args: grepArgs,
863
+ replacement: '--files-with-matches',
864
+ }),
865
+ cwd,
866
+ });
867
+
868
+ /* node:coverage ignore next 3 */
869
+ if (didGrepFail(filesOnlyResult) || filesOnlyResult.exitCode === 1 || !filesOnlyResult.stdout) {
870
+ return {};
871
+ }
872
+
873
+ return typedObjectFromEntries(
874
+ (
875
+ await awaitedBlockingMap(
876
+ getObjectTypedKeys(parseGrepFilesOnlyOutput(filesOnlyResult.stdout)),
877
+ (filePath) => {
878
+ return runKnownFileGrepCount({
879
+ cwd,
880
+ filePath,
881
+ grepArgs,
882
+ });
883
+ },
884
+ )
885
+ )
886
+ .filter(check.isTruthy)
887
+ .map((entry) => [
888
+ entry.key,
889
+ entry.value,
890
+ ]),
891
+ );
892
+ }
893
+
894
+ function parseGrepFilesOnlyOutput(stdout: string) {
895
+ return typedObjectFromEntries(
896
+ /* node:coverage ignore next */
897
+ (stdout.includes('\0') ? stdout.split('\0') : stdout.trimEnd().split('\n'))
898
+ .filter(check.isTruthy)
899
+ .map((entry) => [
900
+ entry,
901
+ [],
902
+ ]),
903
+ );
904
+ }
905
+
906
+ function parseGrepNormalOutput(stdout: string) {
907
+ const fileMatches = new Map<string, string[]>();
908
+
909
+ parseNullDelimitedGrepRecords({
910
+ stdout,
911
+ }).forEach((record) => {
912
+ fileMatches.set(record.fileName, [
913
+ ...(fileMatches.get(record.fileName) || []),
914
+ record.value,
915
+ ]);
916
+ });
917
+
918
+ return typedObjectFromEntries([...fileMatches.entries()]);
919
+ }
920
+
201
921
  /**
202
- * Output of {@link grep}. Each key is an absolute file path. Values are array of matches lines for
203
- * that file.
922
+ * Output of {@link grep}. Each key is a file path returned by `grep`. Values are arrays of matched
923
+ * lines for that file.
204
924
  *
205
925
  * @category Internal
206
926
  * @category Package : @augment-vir/node
@@ -225,32 +945,24 @@ export async function grep<const CountOnly extends boolean = false>(
225
945
  grepSearchLocation: Readonly<GrepSearchLocation>,
226
946
  options: Readonly<GrepOptions<CountOnly>> = {},
227
947
  ): Promise<GrepMatches<CountOnly>> {
228
- const searchPatterns: string[] = (
229
- grepSearchPattern.patterns || [grepSearchPattern.pattern]
230
- ).filter(check.isTruthy);
948
+ const grepOptions: Readonly<Partial<GrepOptions<CountOnly>>> = check.isObject(options)
949
+ ? options
950
+ : {};
951
+ const searchPatterns = createSearchPatterns(grepSearchPattern);
952
+ const grepOptionArrays = extractGrepOptionArrays(grepOptions);
953
+ const cwd = extractString(grepOptions.cwd);
231
954
 
232
- if (!searchPatterns.length) {
955
+ if (
956
+ !searchPatterns.length ||
957
+ !grepOptionArrays ||
958
+ !areGrepOptionsValid({
959
+ grepOptions,
960
+ })
961
+ ) {
233
962
  return {};
234
963
  }
235
964
 
236
- const searchLocation: SelectFrom<GrepSearchLocation, {files: true; dirs: true}> | undefined =
237
- grepSearchLocation.files
238
- ? {
239
- files: grepSearchLocation.files,
240
- }
241
- : grepSearchLocation.file
242
- ? {
243
- files: [grepSearchLocation.file],
244
- }
245
- : grepSearchLocation.dirs
246
- ? {
247
- dirs: grepSearchLocation.dirs,
248
- }
249
- : grepSearchLocation.dir
250
- ? {
251
- dirs: [grepSearchLocation.dir],
252
- }
253
- : undefined;
965
+ const searchLocation = createSearchLocation(grepSearchLocation);
254
966
 
255
967
  if (
256
968
  !searchLocation ||
@@ -260,140 +972,103 @@ export async function grep<const CountOnly extends boolean = false>(
260
972
  return {};
261
973
  }
262
974
 
263
- const searchParts = searchLocation.dirs
264
- ? options.recursive
265
- ? searchLocation.dirs
266
- : searchLocation.dirs.map((dir) => join(dir, '*'))
267
- : searchLocation.files;
975
+ const searchParts = await createSearchParts({
976
+ cwd,
977
+ followSymLinks: grepOptions.followSymLinks,
978
+ recursive: grepOptions.recursive,
979
+ searchLocation,
980
+ });
268
981
 
269
- const fullCommand = [
270
- 'grep',
271
- options.patternSyntax?.basicRegExp
982
+ if (!searchParts.length) {
983
+ return {};
984
+ }
985
+
986
+ const grepArgs = [
987
+ grepOptions.patternSyntax?.basicRegExp
272
988
  ? '--basic-regexp'
273
- : options.patternSyntax?.extendedRegExp
989
+ : grepOptions.patternSyntax?.extendedRegExp
274
990
  ? '--extended-regexp'
275
- : options.patternSyntax?.fixedStrings
991
+ : grepOptions.patternSyntax?.fixedStrings
276
992
  ? '--fixed-strings'
277
993
  : '',
278
- options.ignoreCase ? '--ignore-case' : '',
279
- options.invertMatch && !options.output?.filesOnly ? '--invert-match' : '',
280
- options.matchType?.wordRegExp
994
+ grepOptions.ignoreCase ? '--ignore-case' : '',
995
+ grepOptions.invertMatch && !grepOptions.output?.filesOnly ? '--invert-match' : '',
996
+ grepOptions.matchType?.wordRegExp
281
997
  ? '--word-regexp'
282
- : options.matchType?.lineRegExp
998
+ : grepOptions.matchType?.lineRegExp
283
999
  ? '--line-regexp'
284
1000
  : '',
285
- options.output?.countOnly
1001
+ grepOptions.output?.countOnly
286
1002
  ? '--count'
287
- : options.output?.filesOnly
288
- ? options.invertMatch
1003
+ : grepOptions.output?.filesOnly
1004
+ ? grepOptions.invertMatch
289
1005
  ? '--files-without-match'
290
1006
  : '--files-with-matches'
291
1007
  : '',
292
1008
  '--color=never',
293
- options.maxCount ? `--max-count=${options.maxCount}` : '',
1009
+ grepOptions.maxCount == undefined ? '' : `--max-count=${grepOptions.maxCount}`,
294
1010
  '--no-messages',
1011
+ '--devices=skip',
295
1012
  '--with-filename',
296
1013
  '--null',
297
- ...(options.excludePatterns?.length
298
- ? options.excludePatterns.map(
299
- (excludePattern) => `--exclude="${escape(excludePattern)}"`,
1014
+ ...(grepOptionArrays.excludePatterns.length
1015
+ ? grepOptionArrays.excludePatterns.map(
1016
+ (excludePattern) => `--exclude=${excludePattern}`,
300
1017
  )
301
1018
  : []),
302
- recursiveFlag(options),
303
- ...(options.excludeDirs?.length
304
- ? options.excludeDirs.map((excludeDir) => `--exclude-dir="${escape(excludeDir)}"`)
1019
+ recursiveFlag(grepOptions),
1020
+ ...(grepOptionArrays.excludeDirs.length
1021
+ ? grepOptionArrays.excludeDirs.map((excludeDir) => `--exclude-dir=${excludeDir}`)
305
1022
  : []),
306
- ...(options.includeFiles?.length
307
- ? options.includeFiles.map((includeFile) => `--include="${escape(includeFile)}"`)
1023
+ ...(grepOptionArrays.includeFiles.length
1024
+ ? grepOptionArrays.includeFiles.map((includeFile) => `--include=${includeFile}`)
308
1025
  : []),
309
- options.binary ? '--binary' : '',
310
- ...searchPatterns.map((searchPattern) => `-e "${searchPattern}"`),
1026
+ grepOptions.binary ? '--binary' : '',
1027
+ ...searchPatterns.flatMap((searchPattern) => [
1028
+ '-e',
1029
+ searchPattern,
1030
+ ]),
1031
+ '--',
311
1032
  ...searchParts,
312
- ]
313
- .filter(check.isTruthy)
314
- .join(' ');
1033
+ ].filter(check.isTruthy);
315
1034
 
316
- if (options.printCommand) {
317
- log.faint(`> ${fullCommand}`);
1035
+ if (grepOptions.printCommand) {
1036
+ log.faint(`> ${formatGrepCommand(grepArgs)}`);
318
1037
  }
319
1038
 
320
- const result = await runShellCommand(fullCommand, {
321
- cwd: options.cwd,
1039
+ const result = await runGrepCommand({
1040
+ args: grepArgs,
1041
+ cwd,
322
1042
  });
323
1043
 
324
- const trimmedOutput = result.stdout.trim();
325
-
326
- if (result.exitCode === 1 || !trimmedOutput) {
1044
+ if (didGrepFail(result) || result.exitCode === 1 || !result.stdout) {
327
1045
  /** No matches. */
328
1046
  return {};
329
- } else if (options.output?.countOnly) {
330
- return arrayToObject(
331
- trimmedOutput.split('\n'),
332
- (entry) => {
333
- /** Ignore empty strings. */
334
- /* node:coverage ignore next 3 */
335
- if (!entry) {
336
- return undefined;
337
- }
338
-
339
- /**
340
- * GNU `grep` (Linux) separates the file name from its count with a null byte when
341
- * `--null` is set, while BSD `grep` (macOS) uses a colon. Accept either.
342
- */
343
- const [
344
- ,
345
- fileName,
346
- countString,
347
- ] = safeMatch(entry, /^(.+)[\0:](\d+)$/);
348
-
349
- assert.isDefined(fileName, `Failed parse grep file name from: '${entry}'`);
1047
+ } else if (grepOptions.output?.countOnly) {
1048
+ /* node:coverage ignore next 4 */
1049
+ const parsedCountOutput =
1050
+ isOperatingSystem(OperatingSystem.Mac) && !result.stdout.includes('\0')
1051
+ ? undefined
1052
+ : tryParseGrepCountOutput(result.stdout);
350
1053
 
351
- const count = Number(countString);
1054
+ /* node:coverage ignore next 3 */
1055
+ if (parsedCountOutput) {
1056
+ return parsedCountOutput satisfies Record<string, number> as GrepMatches<CountOnly>;
1057
+ }
352
1058
 
353
- assert.isNumber(count, `Failed to parse grep number from: '${entry}'`);
354
- if (!count) {
355
- return undefined;
356
- }
357
-
358
- return {
359
- key: fileName,
360
- value: count,
361
- };
362
- },
363
- {
364
- useRequired: true,
365
- },
366
- ) satisfies Record<string, number> as GrepMatches<CountOnly>;
367
- } else if (options.output?.filesOnly) {
368
- return arrayToObject(
369
- trimmedOutput.split(/[\0\n]/),
370
- (entry) => {
371
- /** Ignore empty strings. */
372
- if (!entry) {
373
- return undefined;
374
- }
375
-
376
- return {
377
- key: entry,
378
- value: [],
379
- };
380
- },
381
- {
382
- useRequired: true,
383
- },
384
- ) satisfies Record<string, string[]> as GrepMatches as GrepMatches<CountOnly>;
1059
+ return (await runGrepCountFallback({
1060
+ cwd,
1061
+ grepArgs,
1062
+ })) satisfies Record<string, number> as GrepMatches<CountOnly>;
1063
+ } else if (grepOptions.output?.filesOnly) {
1064
+ return parseGrepFilesOnlyOutput(result.stdout) satisfies Record<
1065
+ string,
1066
+ string[]
1067
+ > as GrepMatches as GrepMatches<CountOnly>;
385
1068
  } else {
386
- const outputLines = trimmedOutput.split(/[\0\n]/);
387
-
388
- const fileMatches: Record<string, string[]> = {};
389
-
390
- outputLines.forEach((line, index) => {
391
- if (!(index % 2)) {
392
- // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
393
- getOrSet(fileMatches, line, () => []).push(outputLines[index + 1]!);
394
- }
395
- });
396
-
397
- return fileMatches as GrepMatches<CountOnly>;
1069
+ return parseGrepNormalOutput(result.stdout) satisfies Record<
1070
+ string,
1071
+ string[]
1072
+ > as GrepMatches<CountOnly>;
398
1073
  }
399
1074
  }