@foundation0/api 1.1.1 → 1.1.2

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.
package/projects.ts CHANGED
@@ -160,6 +160,73 @@ export type ProjectWriteGitTaskResult = {
160
160
  issue: ProjectGitTaskRecord
161
161
  }
162
162
 
163
+ type ProjectSearchTargetSection = 'docs' | 'spec'
164
+
165
+ type ProjectSearchOptions = {
166
+ projectName?: string
167
+ owner?: string
168
+ repo?: string
169
+ ref?: string
170
+ branch?: string
171
+ sha?: string
172
+ source?: 'local' | 'gitea' | 'auto'
173
+ remote?: boolean
174
+ processRoot?: string
175
+ cacheDir?: string
176
+ refresh?: boolean
177
+ args?: unknown[]
178
+ }
179
+
180
+ type RgLikeSearchQuery = {
181
+ patterns: string[]
182
+ paths: string[]
183
+ lineNumber: boolean
184
+ withFilename: boolean
185
+ noFilename: boolean
186
+ ignoreCase: boolean
187
+ caseSensitive: boolean
188
+ smartCase: boolean
189
+ fixedStrings: boolean
190
+ wordRegexp: boolean
191
+ maxCount: number | null
192
+ countOnly: boolean
193
+ filesWithMatches: boolean
194
+ filesWithoutMatch: boolean
195
+ onlyMatching: boolean
196
+ includeHidden: boolean
197
+ globs: string[]
198
+ }
199
+
200
+ type RgLikeCompiledPattern = {
201
+ test: RegExp
202
+ global: RegExp
203
+ }
204
+
205
+ type RgLikeSearchExecutionResult = {
206
+ filesSearched: number
207
+ filesMatched: number
208
+ matchedLines: number
209
+ matchedSegments: number
210
+ outputLines: string[]
211
+ }
212
+
213
+ export type ProjectSearchResult = {
214
+ projectName: string
215
+ owner: string | null
216
+ repo: string | null
217
+ section: ProjectSearchTargetSection
218
+ ref: string | null
219
+ source: 'gitea' | 'local'
220
+ cacheDir: string
221
+ cacheRefreshed: boolean
222
+ args: string[]
223
+ filesSearched: number
224
+ filesMatched: number
225
+ matchedLines: number
226
+ matchedSegments: number
227
+ output: string
228
+ }
229
+
163
230
  const CLI_NAME = 'example'
164
231
  const VERSION_RE = 'v?\\d+(?:\\.\\d+){2,}'
165
232
  const VERSION_RE_CORE = `(?:${VERSION_RE})`
@@ -185,6 +252,8 @@ const DEFAULT_TRACKS_CANDIDATES = [
185
252
  path.join('spec', '07_roadmap', 'tracks.md'),
186
253
  ] as const
187
254
  const PROJECTS_DIRECTORY = 'projects'
255
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
256
+ typeof value === 'object' && value !== null && !Array.isArray(value)
188
257
 
189
258
  type ProjectDocsCandidateKind = 'active' | 'plain' | 'versioned'
190
259
 
@@ -271,6 +340,1126 @@ export async function readProjectDoc(
271
340
  return content
272
341
  }
273
342
 
343
+ export async function searchDocs(projectName: string, ...rawArgs: unknown[]): Promise<ProjectSearchResult> {
344
+ return searchProjectSection(projectName, 'docs', rawArgs)
345
+ }
346
+
347
+ export async function searchSpecs(projectName: string, ...rawArgs: unknown[]): Promise<ProjectSearchResult> {
348
+ return searchProjectSection(projectName, 'spec', rawArgs)
349
+ }
350
+
351
+ async function searchProjectSection(
352
+ projectNameInput: unknown,
353
+ section: ProjectSearchTargetSection,
354
+ rawArgs: unknown[]
355
+ ): Promise<ProjectSearchResult> {
356
+ const seedOptions = isRecord(projectNameInput) ? projectNameInput : {}
357
+ const { rgArgs, options } = parseProjectSearchInvocationWithSeed(rawArgs, seedOptions)
358
+ const processRoot = parseStringOption(options.processRoot) ?? process.cwd()
359
+ const positionalProjectName = typeof projectNameInput === 'string' ? projectNameInput.trim() : ''
360
+ const optionProjectName = parseStringOption(options.projectName) ?? ''
361
+ const sourcePreference = parseSearchSourceOption(options.source, options.remote)
362
+
363
+ if (!positionalProjectName && !optionProjectName) {
364
+ throw new Error('Missing project name. Pass it as first arg or options.projectName.')
365
+ }
366
+
367
+ let projectName = positionalProjectName || optionProjectName
368
+ let projectRoot: string | null = null
369
+
370
+ if (positionalProjectName && optionProjectName && positionalProjectName !== optionProjectName) {
371
+ const resolvedPositional = tryResolveProjectRoot(positionalProjectName, processRoot)
372
+ const resolvedOption = tryResolveProjectRoot(optionProjectName, processRoot)
373
+
374
+ if (!resolvedPositional && resolvedOption) {
375
+ projectName = optionProjectName
376
+ projectRoot = resolvedOption
377
+ } else if (resolvedPositional && !resolvedOption) {
378
+ projectName = positionalProjectName
379
+ projectRoot = resolvedPositional
380
+ } else if (!resolvedPositional && !resolvedOption) {
381
+ throw new Error(
382
+ `Could not resolve project-name from positional "${positionalProjectName}" or options.projectName "${optionProjectName}".`,
383
+ )
384
+ } else {
385
+ throw new Error(
386
+ `Conflicting project names: positional "${positionalProjectName}" and options.projectName "${optionProjectName}". Use one.`,
387
+ )
388
+ }
389
+ }
390
+
391
+ if (!projectRoot) {
392
+ projectRoot = resolveProjectRoot(projectName, processRoot)
393
+ }
394
+
395
+ const ownerFromOptions = parseStringOption(options.owner)
396
+ const repoFromOptions = parseStringOption(options.repo)
397
+ const resolvedIdentity = (!ownerFromOptions || !repoFromOptions)
398
+ ? tryResolveGitIdentityFromProject(projectRoot)
399
+ : null
400
+ const owner = ownerFromOptions ?? resolvedIdentity?.owner ?? null
401
+ const repo = repoFromOptions ?? resolvedIdentity?.repo ?? null
402
+ const ref = parseStringOption(options.ref) ?? parseStringOption(options.branch) ?? parseStringOption(options.sha) ?? null
403
+
404
+ const cacheRoot = resolveProjectSearchCacheRoot({
405
+ processRoot,
406
+ projectName,
407
+ owner: owner ?? 'unknown-owner',
408
+ repo: repo ?? 'unknown-repo',
409
+ section,
410
+ ref,
411
+ cacheDir: parseStringOption(options.cacheDir),
412
+ })
413
+
414
+ const cacheSync = await syncProjectSearchCache({
415
+ owner,
416
+ repo,
417
+ projectRoot,
418
+ section,
419
+ ref,
420
+ cacheRoot,
421
+ sourcePreference,
422
+ refresh: parseBooleanOption(options.refresh),
423
+ })
424
+
425
+ const query = parseRgLikeQueryArgs(rgArgs)
426
+ const searchResult = await executeRgLikeSearch(cacheRoot, query)
427
+
428
+ return {
429
+ projectName,
430
+ owner,
431
+ repo,
432
+ section,
433
+ ref,
434
+ source: cacheSync.source,
435
+ cacheDir: cacheRoot,
436
+ cacheRefreshed: cacheSync.refreshed,
437
+ args: rgArgs,
438
+ filesSearched: searchResult.filesSearched,
439
+ filesMatched: searchResult.filesMatched,
440
+ matchedLines: searchResult.matchedLines,
441
+ matchedSegments: searchResult.matchedSegments,
442
+ output: searchResult.outputLines.join('\n'),
443
+ }
444
+ }
445
+
446
+ function parseProjectSearchInvocation(rawArgs: unknown[]): { rgArgs: string[]; options: ProjectSearchOptions } {
447
+ return parseProjectSearchInvocationWithSeed(rawArgs, {})
448
+ }
449
+
450
+ function parseProjectSearchInvocationWithSeed(
451
+ rawArgs: unknown[],
452
+ seedOptions: Record<string, unknown>,
453
+ ): { rgArgs: string[]; options: ProjectSearchOptions } {
454
+ const options: Record<string, unknown> = { ...seedOptions }
455
+ let rgArgs: string[] = []
456
+
457
+ if (rawArgs.length > 0) {
458
+ const maybeOptions = rawArgs[rawArgs.length - 1]
459
+ if (isRecord(maybeOptions)) {
460
+ rgArgs = rawArgs.slice(0, -1).map((value) => String(value))
461
+ for (const [key, value] of Object.entries(maybeOptions)) {
462
+ options[key] = value
463
+ }
464
+ } else {
465
+ rgArgs = rawArgs.map((value) => String(value))
466
+ }
467
+ }
468
+
469
+ if (rgArgs.length === 0 && Array.isArray(options.args)) {
470
+ rgArgs = options.args.map((value) => String(value))
471
+ }
472
+
473
+ return {
474
+ rgArgs,
475
+ options: options as ProjectSearchOptions,
476
+ }
477
+ }
478
+
479
+ function parseStringOption(value: unknown): string | null {
480
+ if (typeof value !== 'string') {
481
+ return null
482
+ }
483
+ const trimmed = value.trim()
484
+ return trimmed.length > 0 ? trimmed : null
485
+ }
486
+
487
+ function parseBooleanOption(value: unknown): boolean {
488
+ return value === true || value === 1 || value === '1' || value === 'true' || value === 'yes' || value === 'on'
489
+ }
490
+
491
+ function tryResolveProjectRoot(projectName: string, processRoot: string): string | null {
492
+ try {
493
+ return resolveProjectRoot(projectName, processRoot)
494
+ } catch {
495
+ return null
496
+ }
497
+ }
498
+
499
+ function tryResolveGitIdentityFromProject(projectRoot: string): { owner: string; repo: string } | null {
500
+ try {
501
+ return resolveGitIdentityFromProject(projectRoot)
502
+ } catch {
503
+ return null
504
+ }
505
+ }
506
+
507
+ function resolveProjectSearchCacheRoot(input: {
508
+ processRoot: string
509
+ projectName: string
510
+ owner: string
511
+ repo: string
512
+ section: ProjectSearchTargetSection
513
+ ref: string | null
514
+ cacheDir: string | null
515
+ }): string {
516
+ if (input.cacheDir) {
517
+ return path.resolve(input.cacheDir)
518
+ }
519
+
520
+ const refToken = input.ref ?? 'HEAD'
521
+ const refHash = crypto.createHash('sha256').update(refToken).digest('hex').slice(0, 12)
522
+ const ownerToken = input.owner.replace(/[^a-zA-Z0-9_.-]+/g, '_')
523
+ const repoToken = input.repo.replace(/[^a-zA-Z0-9_.-]+/g, '_')
524
+ const projectToken = input.projectName.replace(/[^a-zA-Z0-9_.-]+/g, '_')
525
+
526
+ return path.join(
527
+ input.processRoot,
528
+ '.cache',
529
+ 'project-search',
530
+ projectToken,
531
+ ownerToken,
532
+ repoToken,
533
+ input.section,
534
+ refHash,
535
+ )
536
+ }
537
+
538
+ async function syncProjectSearchCache(input: {
539
+ owner: string | null
540
+ repo: string | null
541
+ projectRoot: string
542
+ section: ProjectSearchTargetSection
543
+ ref: string | null
544
+ cacheRoot: string
545
+ sourcePreference: 'local' | 'gitea' | 'auto'
546
+ refresh: boolean
547
+ }): Promise<{ refreshed: boolean; source: 'gitea' | 'local' }> {
548
+ const manifestPath = path.join(input.cacheRoot, '.search-cache.json')
549
+ const localSectionRoot = path.join(input.projectRoot, input.section)
550
+ const localAvailable = existsDir(localSectionRoot)
551
+
552
+ if (!input.refresh && existsFile(manifestPath)) {
553
+ const existingSource = (await readCacheSource(manifestPath)) ?? 'local'
554
+
555
+ // Prefer local if available unless the caller explicitly forces remote.
556
+ if (existingSource === 'gitea' && input.sourcePreference !== 'gitea' && localAvailable) {
557
+ // fall through and refresh to local
558
+ } else if (existingSource === 'local' && input.sourcePreference === 'gitea') {
559
+ // fall through and refresh to remote
560
+ } else {
561
+ return { refreshed: false, source: existingSource }
562
+ }
563
+ }
564
+
565
+ await fs.rm(input.cacheRoot, { recursive: true, force: true })
566
+ await fs.mkdir(input.cacheRoot, { recursive: true })
567
+
568
+ const canUseRemote = Boolean(input.owner && input.repo) && canResolveGiteaHost()
569
+ const shouldUseRemote = (() => {
570
+ if (input.sourcePreference === 'gitea') return true
571
+ if (input.sourcePreference === 'local') return false
572
+ // auto: local-first, remote only if local isn't available
573
+ return !localAvailable
574
+ })()
575
+
576
+ if (shouldUseRemote) {
577
+ if (!canUseRemote) {
578
+ const missing = [
579
+ !input.owner ? 'owner' : null,
580
+ !input.repo ? 'repo' : null,
581
+ !canResolveGiteaHost() ? 'GITEA_HOST' : null,
582
+ ].filter((value): value is string => Boolean(value))
583
+ throw new Error(`Remote search requested but missing: ${missing.join(', ')}`)
584
+ }
585
+
586
+ const git = createGitServiceApi({
587
+ config: {
588
+ platform: 'GITEA',
589
+ giteaHost: resolveGiteaHost(),
590
+ giteaToken: process.env.GITEA_TOKEN,
591
+ },
592
+ defaultOwner: input.owner ?? undefined,
593
+ defaultRepo: input.repo ?? undefined,
594
+ })
595
+
596
+ const listContents = resolveRepoContentsApiMethod(git, 'list')
597
+ const viewContents = resolveRepoContentsApiMethod(git, 'view')
598
+
599
+ const remoteFiles = await collectRepoSectionFiles(listContents, input.owner as string, input.repo as string, input.section, input.ref)
600
+ for (const remotePath of remoteFiles) {
601
+ const relative = stripSectionPrefix(remotePath, input.section)
602
+ if (!relative) {
603
+ continue
604
+ }
605
+ const content = await loadRepoFileContent(viewContents, input.owner as string, input.repo as string, remotePath, input.ref)
606
+ const outPath = path.join(input.cacheRoot, relative)
607
+ await fs.mkdir(path.dirname(outPath), { recursive: true })
608
+ await fs.writeFile(outPath, content, 'utf8')
609
+ }
610
+
611
+ await fs.writeFile(
612
+ manifestPath,
613
+ JSON.stringify(
614
+ {
615
+ source: 'gitea',
616
+ owner: input.owner,
617
+ repo: input.repo,
618
+ section: input.section,
619
+ ref: input.ref,
620
+ fileCount: remoteFiles.length,
621
+ updatedAt: new Date().toISOString(),
622
+ },
623
+ null,
624
+ 2,
625
+ ) + '\n',
626
+ 'utf8',
627
+ )
628
+
629
+ return { refreshed: true, source: 'gitea' }
630
+ }
631
+
632
+ if (!localAvailable) {
633
+ throw new Error(`Section directory not found in project: ${localSectionRoot}`)
634
+ }
635
+
636
+ const copied = await copyProjectSectionToCache(localSectionRoot, input.cacheRoot)
637
+
638
+ await fs.writeFile(
639
+ manifestPath,
640
+ JSON.stringify(
641
+ {
642
+ source: 'local',
643
+ owner: input.owner,
644
+ repo: input.repo,
645
+ section: input.section,
646
+ ref: input.ref,
647
+ fileCount: copied,
648
+ updatedAt: new Date().toISOString(),
649
+ },
650
+ null,
651
+ 2,
652
+ ) + '\n',
653
+ 'utf8',
654
+ )
655
+
656
+ return { refreshed: true, source: 'local' }
657
+ }
658
+
659
+ function parseSearchSourceOption(value: unknown, remoteFlag: unknown): 'local' | 'gitea' | 'auto' {
660
+ if (parseBooleanOption(remoteFlag)) {
661
+ return 'gitea'
662
+ }
663
+ const normalized = parseStringOption(value)?.toLowerCase()
664
+ if (!normalized) return 'auto'
665
+ if (normalized === 'local') return 'local'
666
+ if (normalized === 'gitea' || normalized === 'remote') return 'gitea'
667
+ if (normalized === 'auto') return 'auto'
668
+ throw new Error(`Invalid search source: ${normalized}. Expected "local", "gitea", or "auto".`)
669
+ }
670
+
671
+ function canResolveGiteaHost(): boolean {
672
+ return Boolean(process.env.GITEA_HOST ?? process.env.EXAMPLE_GITEA_HOST)
673
+ }
674
+
675
+ async function readCacheSource(manifestPath: string): Promise<'gitea' | 'local' | null> {
676
+ try {
677
+ const raw = await fs.readFile(manifestPath, 'utf8')
678
+ const parsed = JSON.parse(raw)
679
+ const source = (parsed as any)?.source
680
+ if (source === 'gitea' || source === 'local') {
681
+ return source
682
+ }
683
+ return null
684
+ } catch {
685
+ return null
686
+ }
687
+ }
688
+
689
+ async function copyProjectSectionToCache(sectionRoot: string, cacheRoot: string): Promise<number> {
690
+ let copied = 0
691
+ const queue: Array<{ from: string; rel: string }> = [{ from: sectionRoot, rel: '' }]
692
+
693
+ while (queue.length > 0) {
694
+ const current = queue.shift()
695
+ if (!current) {
696
+ continue
697
+ }
698
+
699
+ const entries = await fs.readdir(current.from, { withFileTypes: true })
700
+ for (const entry of entries) {
701
+ if (entry.name.toLowerCase() === PROJECT_ARCHIVE_DIR_NAME) {
702
+ continue
703
+ }
704
+
705
+ const fromPath = path.join(current.from, entry.name)
706
+ const relPath = current.rel ? `${current.rel}/${entry.name}` : entry.name
707
+ const outPath = path.join(cacheRoot, relPath)
708
+
709
+ if (entry.isDirectory()) {
710
+ queue.push({ from: fromPath, rel: relPath })
711
+ continue
712
+ }
713
+
714
+ if (!entry.isFile() && !entry.isSymbolicLink()) {
715
+ continue
716
+ }
717
+
718
+ const content = await readTextIfExists(fromPath)
719
+ if (content === null) {
720
+ continue
721
+ }
722
+ await fs.mkdir(path.dirname(outPath), { recursive: true })
723
+ await fs.writeFile(outPath, content, 'utf8')
724
+ copied += 1
725
+ }
726
+ }
727
+
728
+ return copied
729
+ }
730
+
731
+ function resolveRepoContentsApiMethod(api: GitServiceApi, action: 'list' | 'view'): GitServiceApiMethod {
732
+ const repoNamespace = api.repo
733
+ if (!repoNamespace || typeof repoNamespace !== 'object') {
734
+ throw new Error('Git service API does not expose repo namespace')
735
+ }
736
+
737
+ const contentsNamespace = (repoNamespace as Record<string, unknown>).contents
738
+ if (!contentsNamespace || typeof contentsNamespace !== 'object') {
739
+ throw new Error('Git service API does not expose repo.contents namespace')
740
+ }
741
+
742
+ const method = (contentsNamespace as Record<string, unknown>)[action]
743
+ if (typeof method !== 'function') {
744
+ throw new Error(`Git service API does not expose repo.contents.${action}`)
745
+ }
746
+
747
+ return method as GitServiceApiMethod
748
+ }
749
+
750
+ function buildRepoContentsQueryOptions(ref: string | null): Record<string, unknown> | null {
751
+ if (!ref) {
752
+ return null
753
+ }
754
+
755
+ return {
756
+ query: {
757
+ ref,
758
+ },
759
+ }
760
+ }
761
+
762
+ async function collectRepoSectionFiles(
763
+ listContents: GitServiceApiMethod,
764
+ owner: string,
765
+ repo: string,
766
+ section: ProjectSearchTargetSection,
767
+ ref: string | null,
768
+ ): Promise<string[]> {
769
+ const discovered = new Set<string>()
770
+ const queue: string[] = [section]
771
+ const queryOptions = buildRepoContentsQueryOptions(ref)
772
+
773
+ while (queue.length > 0) {
774
+ const current = queue.shift()
775
+ if (!current) {
776
+ continue
777
+ }
778
+
779
+ const args: unknown[] = [owner, repo, current]
780
+ if (queryOptions) {
781
+ args.push(queryOptions)
782
+ }
783
+
784
+ const result = await listContents(...args)
785
+ if (!result.ok) {
786
+ throw new Error(
787
+ `Failed to list repo contents at ${current} (${owner}/${repo}). status=${result.status}`,
788
+ )
789
+ }
790
+
791
+ const entries = normalizeRepoContentsEntries(result.body, current)
792
+ for (const entry of entries) {
793
+ if (entry.type === 'dir') {
794
+ queue.push(entry.path)
795
+ continue
796
+ }
797
+
798
+ if (entry.type === 'file') {
799
+ discovered.add(entry.path)
800
+ }
801
+ }
802
+ }
803
+
804
+ return Array.from(discovered).sort((left, right) => left.localeCompare(right))
805
+ }
806
+
807
+ function normalizeRepoContentsEntries(
808
+ body: unknown,
809
+ currentPath: string,
810
+ ): Array<{ path: string; type: 'file' | 'dir' | 'other' }> {
811
+ const entries = Array.isArray(body) ? body : [body]
812
+ const normalized: Array<{ path: string; type: 'file' | 'dir' | 'other' }> = []
813
+
814
+ for (const entry of entries) {
815
+ if (!isRecord(entry)) {
816
+ continue
817
+ }
818
+
819
+ const rawType = typeof entry.type === 'string' ? entry.type.toLowerCase() : 'other'
820
+ const type = rawType === 'dir' || rawType === 'file' ? rawType : 'other'
821
+ const explicitPath = typeof entry.path === 'string' && entry.path.trim().length > 0
822
+ ? entry.path.trim().replace(/\\/g, '/')
823
+ : null
824
+ const explicitName = typeof entry.name === 'string' && entry.name.trim().length > 0
825
+ ? entry.name.trim()
826
+ : null
827
+ const resolvedPath = explicitPath
828
+ ?? (explicitName ? `${currentPath.replace(/\\/g, '/')}/${explicitName}` : null)
829
+
830
+ if (!resolvedPath) {
831
+ continue
832
+ }
833
+
834
+ normalized.push({
835
+ path: resolvedPath,
836
+ type,
837
+ })
838
+ }
839
+
840
+ return normalized
841
+ }
842
+
843
+ function stripSectionPrefix(remotePath: string, section: ProjectSearchTargetSection): string {
844
+ const normalized = remotePath.replace(/\\/g, '/')
845
+ if (normalized === section) {
846
+ return ''
847
+ }
848
+ if (normalized.startsWith(`${section}/`)) {
849
+ return normalized.slice(section.length + 1)
850
+ }
851
+ return ''
852
+ }
853
+
854
+ async function loadRepoFileContent(
855
+ viewContents: GitServiceApiMethod,
856
+ owner: string,
857
+ repo: string,
858
+ remotePath: string,
859
+ ref: string | null,
860
+ ): Promise<string> {
861
+ const args: unknown[] = [owner, repo, remotePath]
862
+ const queryOptions = buildRepoContentsQueryOptions(ref)
863
+ if (queryOptions) {
864
+ args.push(queryOptions)
865
+ }
866
+
867
+ const result = await viewContents(...args)
868
+ if (!result.ok) {
869
+ throw new Error(
870
+ `Failed to read repo file ${remotePath} (${owner}/${repo}). status=${result.status}`,
871
+ )
872
+ }
873
+
874
+ const content = extractRepoFileContent(result.body)
875
+ if (content === null) {
876
+ throw new Error(`Repo response for ${remotePath} did not contain textual file content.`)
877
+ }
878
+
879
+ return content
880
+ }
881
+
882
+ function extractRepoFileContent(body: unknown): string | null {
883
+ if (!isRecord(body)) {
884
+ return null
885
+ }
886
+
887
+ if (typeof body.content !== 'string') {
888
+ return null
889
+ }
890
+
891
+ const raw = body.content
892
+ const encoding = typeof body.encoding === 'string' ? body.encoding.toLowerCase() : ''
893
+
894
+ if (encoding === 'base64') {
895
+ try {
896
+ return Buffer.from(raw.replace(/\s+/g, ''), 'base64').toString('utf8')
897
+ } catch {
898
+ return null
899
+ }
900
+ }
901
+
902
+ return raw
903
+ }
904
+
905
+ function parseRgLikeQueryArgs(args: string[]): RgLikeSearchQuery {
906
+ const query: RgLikeSearchQuery = {
907
+ patterns: [],
908
+ paths: [],
909
+ lineNumber: true,
910
+ withFilename: false,
911
+ noFilename: false,
912
+ ignoreCase: false,
913
+ caseSensitive: false,
914
+ smartCase: false,
915
+ fixedStrings: false,
916
+ wordRegexp: false,
917
+ maxCount: null,
918
+ countOnly: false,
919
+ filesWithMatches: false,
920
+ filesWithoutMatch: false,
921
+ onlyMatching: false,
922
+ includeHidden: false,
923
+ globs: [],
924
+ }
925
+
926
+ const positionals: string[] = []
927
+ let stopOptionParsing = false
928
+
929
+ for (let i = 0; i < args.length; i += 1) {
930
+ const arg = args[i]
931
+ if (!stopOptionParsing && arg === '--') {
932
+ stopOptionParsing = true
933
+ continue
934
+ }
935
+
936
+ if (!stopOptionParsing && arg.startsWith('--')) {
937
+ const consumed = parseRgLikeLongOption(arg, args[i + 1], query)
938
+ if (consumed) {
939
+ i += 1
940
+ }
941
+ continue
942
+ }
943
+
944
+ if (!stopOptionParsing && arg.startsWith('-') && arg !== '-') {
945
+ const consumed = parseRgLikeShortOption(arg, args[i + 1], query)
946
+ if (consumed) {
947
+ i += 1
948
+ }
949
+ continue
950
+ }
951
+
952
+ positionals.push(arg)
953
+ }
954
+
955
+ if (query.patterns.length === 0) {
956
+ const positionalPattern = positionals.shift()
957
+ if (!positionalPattern) {
958
+ throw new Error('Missing search pattern. Usage mirrors rg: PATTERN [PATH ...]')
959
+ }
960
+ query.patterns.push(positionalPattern)
961
+ }
962
+
963
+ query.paths = positionals.length > 0 ? positionals : ['.']
964
+
965
+ if (query.filesWithMatches && query.filesWithoutMatch) {
966
+ throw new Error('Cannot combine --files-with-matches and --files-without-match.')
967
+ }
968
+
969
+ return query
970
+ }
971
+
972
+ function parseRgLikeLongOption(option: string, next: string | undefined, query: RgLikeSearchQuery): boolean {
973
+ const equalsIndex = option.indexOf('=')
974
+ const name = equalsIndex >= 0 ? option.slice(0, equalsIndex) : option
975
+ const inlineValue = equalsIndex >= 0 ? option.slice(equalsIndex + 1) : null
976
+
977
+ const readValue = (): string => {
978
+ const value = inlineValue ?? next
979
+ if (value === undefined || value === null || value.length === 0) {
980
+ throw new Error(`Missing value for ${name}`)
981
+ }
982
+ return value
983
+ }
984
+
985
+ switch (name) {
986
+ case '--ignore-case':
987
+ query.ignoreCase = true
988
+ return false
989
+ case '--case-sensitive':
990
+ query.caseSensitive = true
991
+ return false
992
+ case '--smart-case':
993
+ query.smartCase = true
994
+ return false
995
+ case '--fixed-strings':
996
+ query.fixedStrings = true
997
+ return false
998
+ case '--word-regexp':
999
+ query.wordRegexp = true
1000
+ return false
1001
+ case '--line-number':
1002
+ query.lineNumber = true
1003
+ return false
1004
+ case '--with-filename':
1005
+ query.withFilename = true
1006
+ return false
1007
+ case '--no-filename':
1008
+ query.noFilename = true
1009
+ return false
1010
+ case '--count':
1011
+ query.countOnly = true
1012
+ return false
1013
+ case '--files-with-matches':
1014
+ query.filesWithMatches = true
1015
+ return false
1016
+ case '--files-without-match':
1017
+ query.filesWithoutMatch = true
1018
+ return false
1019
+ case '--only-matching':
1020
+ query.onlyMatching = true
1021
+ return false
1022
+ case '--hidden':
1023
+ query.includeHidden = true
1024
+ return false
1025
+ case '--no-ignore':
1026
+ return false
1027
+ case '--glob': {
1028
+ query.globs.push(readValue())
1029
+ return inlineValue === null
1030
+ }
1031
+ case '--regexp': {
1032
+ query.patterns.push(readValue())
1033
+ return inlineValue === null
1034
+ }
1035
+ case '--max-count': {
1036
+ const value = readValue()
1037
+ const parsed = Number.parseInt(value, 10)
1038
+ if (!Number.isFinite(parsed) || parsed < 0) {
1039
+ throw new Error(`Invalid value for --max-count: ${value}`)
1040
+ }
1041
+ query.maxCount = parsed
1042
+ return inlineValue === null
1043
+ }
1044
+ default:
1045
+ throw new Error(`Unsupported rg option: ${name}`)
1046
+ }
1047
+ }
1048
+
1049
+ function parseRgLikeShortOption(option: string, next: string | undefined, query: RgLikeSearchQuery): boolean {
1050
+ const cluster = option.slice(1)
1051
+ if (!cluster) {
1052
+ return false
1053
+ }
1054
+
1055
+ const valueFlags = new Set(['m', 'g', 'e'])
1056
+ let consumedNext = false
1057
+
1058
+ for (let i = 0; i < cluster.length; i += 1) {
1059
+ const token = cluster[i]
1060
+
1061
+ if (valueFlags.has(token)) {
1062
+ const inline = cluster.slice(i + 1)
1063
+ const value = inline.length > 0 ? inline : next
1064
+ if (!value) {
1065
+ throw new Error(`Missing value for -${token}`)
1066
+ }
1067
+
1068
+ if (token === 'm') {
1069
+ const parsed = Number.parseInt(value, 10)
1070
+ if (!Number.isFinite(parsed) || parsed < 0) {
1071
+ throw new Error(`Invalid value for -m: ${value}`)
1072
+ }
1073
+ query.maxCount = parsed
1074
+ } else if (token === 'g') {
1075
+ query.globs.push(value)
1076
+ } else if (token === 'e') {
1077
+ query.patterns.push(value)
1078
+ }
1079
+
1080
+ consumedNext = inline.length === 0
1081
+ break
1082
+ }
1083
+
1084
+ switch (token) {
1085
+ case 'i':
1086
+ query.ignoreCase = true
1087
+ break
1088
+ case 's':
1089
+ query.caseSensitive = true
1090
+ break
1091
+ case 'S':
1092
+ query.smartCase = true
1093
+ break
1094
+ case 'F':
1095
+ query.fixedStrings = true
1096
+ break
1097
+ case 'w':
1098
+ query.wordRegexp = true
1099
+ break
1100
+ case 'n':
1101
+ query.lineNumber = true
1102
+ break
1103
+ case 'H':
1104
+ query.withFilename = true
1105
+ break
1106
+ case 'h':
1107
+ query.noFilename = true
1108
+ break
1109
+ case 'c':
1110
+ query.countOnly = true
1111
+ break
1112
+ case 'l':
1113
+ query.filesWithMatches = true
1114
+ break
1115
+ case 'L':
1116
+ query.filesWithoutMatch = true
1117
+ break
1118
+ case 'o':
1119
+ query.onlyMatching = true
1120
+ break
1121
+ default:
1122
+ throw new Error(`Unsupported rg option: -${token}`)
1123
+ }
1124
+ }
1125
+
1126
+ return consumedNext
1127
+ }
1128
+
1129
+ async function executeRgLikeSearch(
1130
+ cacheRoot: string,
1131
+ query: RgLikeSearchQuery,
1132
+ ): Promise<RgLikeSearchExecutionResult> {
1133
+ const files = await collectSearchFiles(cacheRoot, query)
1134
+ const compiledPatterns = compileRgLikePatterns(query)
1135
+ const outputLines: string[] = []
1136
+ const multipleFiles = query.withFilename || files.length > 1
1137
+
1138
+ let filesMatched = 0
1139
+ let matchedLines = 0
1140
+ let matchedSegments = 0
1141
+
1142
+ for (const filePath of files) {
1143
+ const relativePath = path.relative(cacheRoot, filePath).replace(/\\/g, '/')
1144
+ const content = await fs.readFile(filePath, 'utf8')
1145
+ const normalized = content.replace(/\r/g, '')
1146
+ const lines = normalized.split('\n')
1147
+ if (lines.length > 0 && lines[lines.length - 1] === '') {
1148
+ lines.pop()
1149
+ }
1150
+
1151
+ let fileMatchedLines = 0
1152
+ let fileMatchedSegments = 0
1153
+ const fileLineOutputs: string[] = []
1154
+
1155
+ for (let index = 0; index < lines.length; index += 1) {
1156
+ const lineText = lines[index] ?? ''
1157
+ const matches = matchLineWithPatterns(lineText, compiledPatterns)
1158
+ if (matches.length === 0) {
1159
+ continue
1160
+ }
1161
+
1162
+ fileMatchedLines += 1
1163
+ fileMatchedSegments += matches.length
1164
+
1165
+ if (!query.countOnly && !query.filesWithMatches && !query.filesWithoutMatch) {
1166
+ if (query.onlyMatching) {
1167
+ for (const matchText of matches) {
1168
+ fileLineOutputs.push(formatSearchOutputLine({
1169
+ filePath: relativePath,
1170
+ includeFilePath: multipleFiles && !query.noFilename,
1171
+ lineNumber: index + 1,
1172
+ includeLineNumber: query.lineNumber,
1173
+ text: matchText,
1174
+ }))
1175
+ }
1176
+ } else {
1177
+ fileLineOutputs.push(formatSearchOutputLine({
1178
+ filePath: relativePath,
1179
+ includeFilePath: multipleFiles && !query.noFilename,
1180
+ lineNumber: index + 1,
1181
+ includeLineNumber: query.lineNumber,
1182
+ text: lineText,
1183
+ }))
1184
+ }
1185
+ }
1186
+
1187
+ if (query.maxCount !== null && fileMatchedLines >= query.maxCount) {
1188
+ break
1189
+ }
1190
+ }
1191
+
1192
+ matchedLines += fileMatchedLines
1193
+ matchedSegments += fileMatchedSegments
1194
+
1195
+ if (query.filesWithoutMatch) {
1196
+ if (fileMatchedLines === 0) {
1197
+ outputLines.push(relativePath)
1198
+ }
1199
+ continue
1200
+ }
1201
+
1202
+ if (fileMatchedLines > 0) {
1203
+ filesMatched += 1
1204
+ }
1205
+
1206
+ if (query.filesWithMatches) {
1207
+ if (fileMatchedLines > 0) {
1208
+ outputLines.push(relativePath)
1209
+ }
1210
+ continue
1211
+ }
1212
+
1213
+ if (query.countOnly) {
1214
+ const includeFilePath = multipleFiles && !query.noFilename
1215
+ outputLines.push(includeFilePath ? `${relativePath}:${fileMatchedLines}` : String(fileMatchedLines))
1216
+ continue
1217
+ }
1218
+
1219
+ outputLines.push(...fileLineOutputs)
1220
+ }
1221
+
1222
+ return {
1223
+ filesSearched: files.length,
1224
+ filesMatched,
1225
+ matchedLines,
1226
+ matchedSegments,
1227
+ outputLines,
1228
+ }
1229
+ }
1230
+
1231
+ function formatSearchOutputLine(input: {
1232
+ filePath: string
1233
+ includeFilePath: boolean
1234
+ lineNumber: number
1235
+ includeLineNumber: boolean
1236
+ text: string
1237
+ }): string {
1238
+ const prefixParts: string[] = []
1239
+ if (input.includeFilePath) {
1240
+ prefixParts.push(input.filePath)
1241
+ }
1242
+ if (input.includeLineNumber) {
1243
+ prefixParts.push(String(input.lineNumber))
1244
+ }
1245
+ if (prefixParts.length === 0) {
1246
+ return input.text
1247
+ }
1248
+ return `${prefixParts.join(':')}:${input.text}`
1249
+ }
1250
+
1251
+ function matchLineWithPatterns(line: string, patterns: RgLikeCompiledPattern[]): string[] {
1252
+ const collected: string[] = []
1253
+
1254
+ for (const pattern of patterns) {
1255
+ if (!pattern.test.test(line)) {
1256
+ continue
1257
+ }
1258
+
1259
+ pattern.global.lastIndex = 0
1260
+ let match: RegExpExecArray | null = null
1261
+ while ((match = pattern.global.exec(line)) !== null) {
1262
+ collected.push(match[0] ?? '')
1263
+ if ((match[0] ?? '').length === 0) {
1264
+ pattern.global.lastIndex += 1
1265
+ }
1266
+ }
1267
+ }
1268
+
1269
+ return collected
1270
+ }
1271
+
1272
+ function compileRgLikePatterns(query: RgLikeSearchQuery): RgLikeCompiledPattern[] {
1273
+ return query.patterns.map((pattern) => {
1274
+ const source = query.fixedStrings ? escapeRegExp(pattern) : pattern
1275
+ const wrappedSource = query.wordRegexp ? `\\b(?:${source})\\b` : source
1276
+ const flags = resolveRgLikePatternFlags(pattern, query)
1277
+
1278
+ try {
1279
+ return {
1280
+ test: new RegExp(wrappedSource, flags),
1281
+ global: new RegExp(wrappedSource, `${flags}g`),
1282
+ }
1283
+ } catch (error) {
1284
+ const message = error instanceof Error ? error.message : String(error)
1285
+ throw new Error(`Invalid search pattern "${pattern}": ${message}`)
1286
+ }
1287
+ })
1288
+ }
1289
+
1290
+ function resolveRgLikePatternFlags(pattern: string, query: RgLikeSearchQuery): string {
1291
+ if (query.caseSensitive) {
1292
+ return ''
1293
+ }
1294
+ if (query.ignoreCase) {
1295
+ return 'i'
1296
+ }
1297
+ if (query.smartCase) {
1298
+ return /[A-Z]/.test(pattern) ? '' : 'i'
1299
+ }
1300
+ return ''
1301
+ }
1302
+
1303
+ type CompiledSearchGlob = {
1304
+ raw: string
1305
+ negate: boolean
1306
+ regex: RegExp
1307
+ basenameOnly: boolean
1308
+ }
1309
+
1310
+ async function collectSearchFiles(cacheRoot: string, query: RgLikeSearchQuery): Promise<string[]> {
1311
+ const compiledGlobs = compileSearchGlobs(query.globs)
1312
+ const files = new Set<string>()
1313
+
1314
+ for (const requested of query.paths) {
1315
+ const normalized = requested === '.' ? '' : requested.replace(/\\/g, '/')
1316
+ const candidate = path.resolve(cacheRoot, normalized)
1317
+ if (!isSubPath(cacheRoot, candidate)) {
1318
+ throw new Error(`Path escapes cache root: ${requested}`)
1319
+ }
1320
+
1321
+ let stats: fsSync.Stats
1322
+ try {
1323
+ stats = await fs.stat(candidate)
1324
+ } catch {
1325
+ throw new Error(`Path not found in cached section: ${requested}`)
1326
+ }
1327
+
1328
+ if (stats.isDirectory()) {
1329
+ await collectFilesFromDirectory(cacheRoot, candidate, query.includeHidden, compiledGlobs, files)
1330
+ continue
1331
+ }
1332
+
1333
+ if (stats.isFile() && shouldIncludeFile(cacheRoot, candidate, query.includeHidden, compiledGlobs)) {
1334
+ files.add(candidate)
1335
+ }
1336
+ }
1337
+
1338
+ return Array.from(files).sort((left, right) => left.localeCompare(right))
1339
+ }
1340
+
1341
+ async function collectFilesFromDirectory(
1342
+ cacheRoot: string,
1343
+ directory: string,
1344
+ includeHidden: boolean,
1345
+ globs: CompiledSearchGlob[],
1346
+ files: Set<string>,
1347
+ ): Promise<void> {
1348
+ const entries = await fs.readdir(directory, { withFileTypes: true })
1349
+
1350
+ for (const entry of entries) {
1351
+ if (entry.name === PROJECT_ARCHIVE_DIR_NAME) {
1352
+ continue
1353
+ }
1354
+
1355
+ if (!includeHidden && entry.name.startsWith('.')) {
1356
+ continue
1357
+ }
1358
+
1359
+ const absolute = path.join(directory, entry.name)
1360
+ if (entry.isDirectory()) {
1361
+ await collectFilesFromDirectory(cacheRoot, absolute, includeHidden, globs, files)
1362
+ continue
1363
+ }
1364
+
1365
+ if (entry.isFile() && shouldIncludeFile(cacheRoot, absolute, includeHidden, globs)) {
1366
+ files.add(absolute)
1367
+ }
1368
+ }
1369
+ }
1370
+
1371
+ function shouldIncludeFile(
1372
+ cacheRoot: string,
1373
+ filePath: string,
1374
+ includeHidden: boolean,
1375
+ globs: CompiledSearchGlob[],
1376
+ ): boolean {
1377
+ const relative = path.relative(cacheRoot, filePath).replace(/\\/g, '/')
1378
+ if (!includeHidden && relative.split('/').some((segment) => segment.startsWith('.'))) {
1379
+ return false
1380
+ }
1381
+
1382
+ return matchesSearchGlobs(relative, globs)
1383
+ }
1384
+
1385
+ function compileSearchGlobs(globs: string[]): CompiledSearchGlob[] {
1386
+ return globs
1387
+ .map((raw) => raw.trim())
1388
+ .filter((raw) => raw.length > 0)
1389
+ .map((raw) => {
1390
+ const negate = raw.startsWith('!')
1391
+ const normalized = (negate ? raw.slice(1) : raw).replace(/\\/g, '/')
1392
+ const basenameOnly = !normalized.includes('/')
1393
+
1394
+ return {
1395
+ raw,
1396
+ negate,
1397
+ regex: globToRegExp(normalized),
1398
+ basenameOnly,
1399
+ }
1400
+ })
1401
+ }
1402
+
1403
+ function matchesSearchGlobs(relativePath: string, globs: CompiledSearchGlob[]): boolean {
1404
+ if (globs.length === 0) {
1405
+ return true
1406
+ }
1407
+
1408
+ const includeGlobs = globs.filter((glob) => !glob.negate)
1409
+ const excludeGlobs = globs.filter((glob) => glob.negate)
1410
+ const fileName = relativePath.split('/').pop() ?? relativePath
1411
+
1412
+ const matchesGlob = (glob: CompiledSearchGlob): boolean => {
1413
+ if (glob.basenameOnly) {
1414
+ return glob.regex.test(fileName)
1415
+ }
1416
+ return glob.regex.test(relativePath)
1417
+ }
1418
+
1419
+ if (includeGlobs.length > 0 && !includeGlobs.some((glob) => matchesGlob(glob))) {
1420
+ return false
1421
+ }
1422
+
1423
+ if (excludeGlobs.some((glob) => matchesGlob(glob))) {
1424
+ return false
1425
+ }
1426
+
1427
+ return true
1428
+ }
1429
+
1430
+ function globToRegExp(glob: string): RegExp {
1431
+ let index = 0
1432
+ let source = '^'
1433
+
1434
+ while (index < glob.length) {
1435
+ const char = glob[index]
1436
+
1437
+ if (char === '*') {
1438
+ const next = glob[index + 1]
1439
+ if (next === '*') {
1440
+ source += '.*'
1441
+ index += 2
1442
+ continue
1443
+ }
1444
+ source += '[^/]*'
1445
+ index += 1
1446
+ continue
1447
+ }
1448
+
1449
+ if (char === '?') {
1450
+ source += '[^/]'
1451
+ index += 1
1452
+ continue
1453
+ }
1454
+
1455
+ source += escapeRegExp(char)
1456
+ index += 1
1457
+ }
1458
+
1459
+ source += '$'
1460
+ return new RegExp(source)
1461
+ }
1462
+
274
1463
  type ProjectVersionedFileSpec = {
275
1464
  parts: string[]
276
1465
  base: string