@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/mcp/cli.mjs +1 -1
- package/mcp/cli.ts +3 -3
- package/mcp/client.test.ts +13 -0
- package/mcp/client.ts +12 -4
- package/mcp/server.test.ts +133 -0
- package/mcp/server.ts +1344 -107
- package/package.json +1 -1
- package/projects.ts +1189 -0
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
|