mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-19 13:33:35 +08:00
454 lines
13 KiB
TypeScript
454 lines
13 KiB
TypeScript
/**
|
||
* SearchService — 工作区文件搜索 & 会话历史搜索
|
||
*
|
||
* 优先使用 ripgrep (rg),不可用时降级到 grep。
|
||
*/
|
||
|
||
import { spawn } from 'child_process'
|
||
import * as fs from 'fs/promises'
|
||
import * as path from 'path'
|
||
import * as os from 'os'
|
||
import { ApiError } from '../middleware/errorHandler.js'
|
||
|
||
export type SearchResult = {
|
||
file: string
|
||
line: number
|
||
text: string
|
||
context?: string[]
|
||
}
|
||
|
||
export type SessionSearchResult = {
|
||
sessionId: string
|
||
title: string
|
||
matchCount: number
|
||
matches: Array<{ line: number; text: string }>
|
||
}
|
||
|
||
export class SearchService {
|
||
// ---------------------------------------------------------------------------
|
||
// 工作区搜索
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/** 使用 ripgrep 搜索工作目录 */
|
||
async searchWorkspace(
|
||
query: string,
|
||
options?: {
|
||
cwd?: string
|
||
maxResults?: number
|
||
glob?: string
|
||
caseSensitive?: boolean
|
||
},
|
||
): Promise<SearchResult[]> {
|
||
if (!query) {
|
||
throw ApiError.badRequest('Search query is required')
|
||
}
|
||
|
||
const cwd = options?.cwd || process.cwd()
|
||
const maxResults = options?.maxResults || 200
|
||
|
||
// 尝试 rg,降级到 grep
|
||
const hasRg = await this.commandExists('rg')
|
||
if (hasRg) {
|
||
try {
|
||
return await this.searchWithRipgrep(query, cwd, maxResults, options)
|
||
} catch {
|
||
// rg 执行失败,降级到 grep
|
||
}
|
||
}
|
||
|
||
const hasGrep = await this.commandExists('grep')
|
||
if (hasGrep) {
|
||
try {
|
||
return await this.searchWithGrep(query, cwd, maxResults, options)
|
||
} catch {
|
||
// grep failed or is not available; fall back to a portable search.
|
||
}
|
||
}
|
||
|
||
return this.searchWithFilesystem(query, cwd, maxResults, options)
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 会话历史搜索
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/** 搜索会话历史文件 */
|
||
async searchSessions(query: string): Promise<SessionSearchResult[]> {
|
||
if (!query) {
|
||
throw ApiError.badRequest('Search query is required')
|
||
}
|
||
|
||
const configDir =
|
||
process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude')
|
||
const projectsDir = path.join(configDir, 'projects')
|
||
|
||
const results: SessionSearchResult[] = []
|
||
|
||
try {
|
||
await fs.access(projectsDir)
|
||
} catch {
|
||
// 目录不存在,返回空
|
||
return results
|
||
}
|
||
|
||
// 遍历 projects/ 下的 JSONL 会话文件
|
||
const entries = await this.walkJsonlFiles(projectsDir)
|
||
const lowerQuery = query.toLowerCase()
|
||
|
||
for (const filePath of entries) {
|
||
try {
|
||
const raw = await fs.readFile(filePath, 'utf-8')
|
||
const lines = raw.split('\n').filter(Boolean)
|
||
|
||
const matches: Array<{ line: number; text: string }> = []
|
||
let title = path.basename(filePath, '.jsonl')
|
||
|
||
for (let i = 0; i < lines.length; i++) {
|
||
const line = lines[i]
|
||
if (line.toLowerCase().includes(lowerQuery)) {
|
||
// 尝试提取可读文本
|
||
try {
|
||
const obj = JSON.parse(line) as Record<string, unknown>
|
||
const text =
|
||
typeof obj.message === 'string'
|
||
? obj.message
|
||
: typeof obj.content === 'string'
|
||
? obj.content
|
||
: line.slice(0, 200)
|
||
|
||
// 提取 title
|
||
if (i === 0 && typeof obj.title === 'string') {
|
||
title = obj.title
|
||
}
|
||
|
||
matches.push({ line: i + 1, text: text.slice(0, 300) })
|
||
} catch {
|
||
matches.push({ line: i + 1, text: line.slice(0, 200) })
|
||
}
|
||
}
|
||
}
|
||
|
||
if (matches.length > 0) {
|
||
const sessionId = path.basename(filePath, '.jsonl')
|
||
results.push({
|
||
sessionId,
|
||
title,
|
||
matchCount: matches.length,
|
||
matches: matches.slice(0, 20), // 每个会话最多 20 条匹配
|
||
})
|
||
}
|
||
} catch {
|
||
// 跳过无法读取的文件
|
||
}
|
||
}
|
||
|
||
return results
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// ripgrep 搜索
|
||
// ---------------------------------------------------------------------------
|
||
|
||
private async searchWithRipgrep(
|
||
query: string,
|
||
cwd: string,
|
||
maxResults: number,
|
||
options?: {
|
||
glob?: string
|
||
caseSensitive?: boolean
|
||
},
|
||
): Promise<SearchResult[]> {
|
||
const args = ['--json', '--max-count', String(maxResults)]
|
||
|
||
if (options?.caseSensitive === false) {
|
||
args.push('--ignore-case')
|
||
}
|
||
|
||
// 添加上下文行
|
||
args.push('-C', '4')
|
||
|
||
if (options?.glob) {
|
||
args.push('--glob', options.glob)
|
||
}
|
||
|
||
args.push('--', query, cwd)
|
||
|
||
const output = await this.runCommand('rg', args)
|
||
return this.parseRipgrepJson(output, maxResults)
|
||
}
|
||
|
||
/** 解析 ripgrep JSON 输出 */
|
||
private parseRipgrepJson(
|
||
output: string,
|
||
maxResults: number,
|
||
): SearchResult[] {
|
||
const results: SearchResult[] = []
|
||
const lines = output.split('\n').filter(Boolean)
|
||
|
||
// 收集上下文:key = `${file}:${matchLine}`
|
||
const contextMap = new Map<
|
||
string,
|
||
{ file: string; line: number; text: string; context: string[] }
|
||
>()
|
||
|
||
for (const line of lines) {
|
||
try {
|
||
const obj = JSON.parse(line) as Record<string, unknown>
|
||
if (obj.type === 'match') {
|
||
const data = obj.data as {
|
||
path?: { text?: string }
|
||
line_number?: number
|
||
lines?: { text?: string }
|
||
submatches?: unknown[]
|
||
}
|
||
|
||
const file = data.path?.text || ''
|
||
const lineNum = data.line_number || 0
|
||
const text = (data.lines?.text || '').replace(/\n$/, '')
|
||
const key = `${file}:${lineNum}`
|
||
|
||
contextMap.set(key, { file, line: lineNum, text, context: [] })
|
||
} else if (obj.type === 'context') {
|
||
// 上下文行归属到最近的 match
|
||
const data = obj.data as {
|
||
path?: { text?: string }
|
||
line_number?: number
|
||
lines?: { text?: string }
|
||
}
|
||
const text = (data.lines?.text || '').replace(/\n$/, '')
|
||
|
||
// 附加到最后一个相同文件的 match
|
||
const file = data.path?.text || ''
|
||
for (const [key, entry] of contextMap) {
|
||
if (key.startsWith(file + ':')) {
|
||
entry.context.push(text)
|
||
}
|
||
}
|
||
}
|
||
} catch {
|
||
// 跳过无法解析的行
|
||
}
|
||
}
|
||
|
||
for (const entry of contextMap.values()) {
|
||
if (results.length >= maxResults) break
|
||
results.push({
|
||
file: entry.file,
|
||
line: entry.line,
|
||
text: entry.text,
|
||
context: entry.context.length > 0 ? entry.context : undefined,
|
||
})
|
||
}
|
||
|
||
return results
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// grep 降级
|
||
// ---------------------------------------------------------------------------
|
||
|
||
private async searchWithGrep(
|
||
query: string,
|
||
cwd: string,
|
||
maxResults: number,
|
||
options?: {
|
||
glob?: string
|
||
caseSensitive?: boolean
|
||
},
|
||
): Promise<SearchResult[]> {
|
||
const args = ['-rn', '--max-count', String(maxResults)]
|
||
|
||
if (options?.caseSensitive === false) {
|
||
args.push('-i')
|
||
}
|
||
|
||
if (options?.glob) {
|
||
args.push('--include', options.glob)
|
||
}
|
||
|
||
args.push('--', query, cwd)
|
||
|
||
const output = await this.runCommand('grep', args)
|
||
return this.parseGrepOutput(output, maxResults)
|
||
}
|
||
|
||
/** 解析 grep 输出 (file:line:text) */
|
||
private parseGrepOutput(output: string, maxResults: number): SearchResult[] {
|
||
const results: SearchResult[] = []
|
||
const lines = output.split('\n').filter(Boolean)
|
||
|
||
for (const line of lines) {
|
||
if (results.length >= maxResults) break
|
||
|
||
// grep -n 输出格式: file:line:text
|
||
const match = line.match(/^(.+?):(\d+):(.*)$/)
|
||
if (match) {
|
||
results.push({
|
||
file: match[1],
|
||
line: parseInt(match[2], 10),
|
||
text: match[3],
|
||
})
|
||
}
|
||
}
|
||
|
||
return results
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Portable filesystem fallback
|
||
// ---------------------------------------------------------------------------
|
||
|
||
private async searchWithFilesystem(
|
||
query: string,
|
||
cwd: string,
|
||
maxResults: number,
|
||
options?: {
|
||
glob?: string
|
||
caseSensitive?: boolean
|
||
},
|
||
): Promise<SearchResult[]> {
|
||
const results: SearchResult[] = []
|
||
const needle = options?.caseSensitive === false ? query.toLowerCase() : query
|
||
|
||
await this.searchDirectory(cwd, needle, results, maxResults, {
|
||
caseSensitive: options?.caseSensitive !== false,
|
||
glob: options?.glob,
|
||
})
|
||
|
||
return results
|
||
}
|
||
|
||
private async searchDirectory(
|
||
dir: string,
|
||
needle: string,
|
||
results: SearchResult[],
|
||
maxResults: number,
|
||
options: {
|
||
caseSensitive: boolean
|
||
glob?: string
|
||
},
|
||
): Promise<void> {
|
||
if (results.length >= maxResults) return
|
||
|
||
let entries: import('fs').Dirent[]
|
||
try {
|
||
entries = await fs.readdir(dir, { withFileTypes: true })
|
||
} catch {
|
||
return
|
||
}
|
||
|
||
for (const entry of entries) {
|
||
if (results.length >= maxResults) return
|
||
if (entry.name === 'node_modules' || entry.name === '.git') continue
|
||
|
||
const fullPath = path.join(dir, entry.name)
|
||
if (entry.isDirectory()) {
|
||
await this.searchDirectory(fullPath, needle, results, maxResults, options)
|
||
continue
|
||
}
|
||
|
||
if (!entry.isFile()) continue
|
||
if (options.glob && !this.matchesSimpleGlob(entry.name, options.glob)) continue
|
||
|
||
await this.searchFile(fullPath, needle, results, maxResults, options.caseSensitive)
|
||
}
|
||
}
|
||
|
||
private async searchFile(
|
||
filePath: string,
|
||
needle: string,
|
||
results: SearchResult[],
|
||
maxResults: number,
|
||
caseSensitive: boolean,
|
||
): Promise<void> {
|
||
let content: string
|
||
try {
|
||
const buffer = await fs.readFile(filePath)
|
||
if (buffer.includes(0)) return
|
||
content = buffer.toString('utf8')
|
||
} catch {
|
||
return
|
||
}
|
||
|
||
const lines = content.split(/\r?\n/)
|
||
for (let index = 0; index < lines.length && results.length < maxResults; index++) {
|
||
const haystack = caseSensitive ? lines[index] : lines[index].toLowerCase()
|
||
if (!haystack.includes(needle)) continue
|
||
|
||
results.push({
|
||
file: filePath,
|
||
line: index + 1,
|
||
text: lines[index],
|
||
})
|
||
}
|
||
}
|
||
|
||
private matchesSimpleGlob(fileName: string, glob: string): boolean {
|
||
if (!glob.includes('*')) return fileName === glob
|
||
const escaped = glob.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*')
|
||
return new RegExp(`^${escaped}$`).test(fileName)
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 工具方法
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/** 运行外部命令,返回 stdout */
|
||
private runCommand(cmd: string, args: string[]): Promise<string> {
|
||
return new Promise((resolve, reject) => {
|
||
const proc = spawn(cmd, args, { stdio: ['ignore', 'pipe', 'pipe'] })
|
||
const chunks: Buffer[] = []
|
||
|
||
proc.stdout.on('data', (chunk: Buffer) => chunks.push(chunk))
|
||
|
||
proc.on('close', (code) => {
|
||
const output = Buffer.concat(chunks).toString('utf-8')
|
||
// rg/grep 返回 1 表示无匹配,不视为错误
|
||
if (code === 0 || code === 1) {
|
||
resolve(output)
|
||
} else {
|
||
reject(
|
||
new Error(`Command "${cmd}" exited with code ${code}: ${output}`),
|
||
)
|
||
}
|
||
})
|
||
|
||
proc.on('error', (err) => {
|
||
reject(err)
|
||
})
|
||
})
|
||
}
|
||
|
||
/** 检测命令是否存在 */
|
||
private commandExists(cmd: string): Promise<boolean> {
|
||
return new Promise((resolve) => {
|
||
const lookup = process.platform === 'win32' ? 'where' : 'which'
|
||
const proc = spawn(lookup, [cmd], { stdio: 'ignore' })
|
||
proc.on('close', (code) => resolve(code === 0))
|
||
proc.on('error', () => resolve(false))
|
||
})
|
||
}
|
||
|
||
/** 递归查找 .jsonl 文件 */
|
||
private async walkJsonlFiles(dir: string): Promise<string[]> {
|
||
const results: string[] = []
|
||
|
||
try {
|
||
const entries = await fs.readdir(dir, { withFileTypes: true })
|
||
for (const entry of entries) {
|
||
const fullPath = path.join(dir, entry.name)
|
||
if (entry.isDirectory()) {
|
||
const sub = await this.walkJsonlFiles(fullPath)
|
||
results.push(...sub)
|
||
} else if (entry.name.endsWith('.jsonl')) {
|
||
results.push(fullPath)
|
||
}
|
||
}
|
||
} catch {
|
||
// 跳过不可访问的目录
|
||
}
|
||
|
||
return results
|
||
}
|
||
}
|