package builtin // grep_engine.go -- Grep 双引擎:Ripgrep 引擎 + 纯 Go 内置引擎. // // 升华改进(ELEVATED): 双引擎策略 - rg 可用时走 ripgrep(性能最优,支持 PCRE2), // 不可用时走纯 Go regexp(零依赖兜底).这比只依赖 rg 更好: // 跨行业场景(企业管理,数据分析,嵌入式设备)可能不在开发者环境中. // 替代方案:只用 ripgrep(原始设计,性能更好,但有外部依赖, // 且在 Docker alpine 镜像等环境中需要额外安装). // // 反向论证(为什么可能不该改): // - 两个引擎的搜索结果可能有微妙差异(regex 方言不同) // → 解决:rg 默认用 Rust regex,与 Go regexp 语法高度一致 // - 增加了代码复杂度和维护成本 // → 回应:接口抽象让每个引擎独立演进,实际更好维护 import ( "bufio" "bytes" "context" "fmt" "io" "io/fs" "os" "os/exec" "path/filepath" "regexp" "sort" "strconv" "strings" "sync" "git.flytoex.net/yuanwei/flyto-agent/pkg/execenv" ) // GrepEngine 是搜索引擎接口. type GrepEngine interface { // Search 执行搜索,返回格式化后的输出,匹配文件数,总匹配数. Search(ctx context.Context, params *GrepParams) (*GrepResult, error) // Name 返回引擎名称. Name() string } // GrepParams 包含 Grep 搜索的所有参数. type GrepParams struct { Pattern string SearchPath string // 搜索路径(文件或目录) SearchDir string // 搜索的基目录(用于相对路径计算) IsFile bool // 搜索路径是否为单个文件 Glob string OutputMode string // content / files_with_matches / count ContextBefore int // -B ContextAfter int // -A ContextBoth int // -C(同时设置 before 和 after) CaseInsensitive bool HeadLimit int // 输出上限 FileType string Multiline bool Offset int // 跳过前 N 条 } // GrepResult 是搜索结果. type GrepResult struct { Output string MatchedFiles int TotalMatches int LimitReached bool } // ---------- 引擎选择 ---------- // DetectGrepEngine 检测系统是否有 rg(ripgrep),有就用,没有就纯 Go 兜底. // // M1 方案 β: 接受 executor 参数, rg 引擎走 Executor.Command. 内置引擎纯 Go // 实现无子进程, 不需要 executor. exec.LookPath 是能力探测(本地)而非子进程 // 执行, 不走 Executor -- Executor 语义是"跑子进程", 能力探测留在本地. func DetectGrepEngine(executor execenv.Executor) GrepEngine { if path, err := exec.LookPath("rg"); err == nil { return NewRipgrepEngine(path, executor) } return NewBuiltinGrepEngine() } // ---------- Ripgrep 引擎 ---------- // RipgrepEngine 调用系统 rg 命令执行搜索. type RipgrepEngine struct { rgPath string executor execenv.Executor } // NewRipgrepEngine 创建 Ripgrep 引擎. executor 不能为 nil (方案 β 严格 DI). func NewRipgrepEngine(rgPath string, executor execenv.Executor) *RipgrepEngine { if executor == nil { panic("builtin.NewRipgrepEngine: executor is nil (方案 β 严格 DI)") } return &RipgrepEngine{rgPath: rgPath, executor: executor} } func (e *RipgrepEngine) Name() string { return "ripgrep" } // Search 使用 rg 执行搜索. func (e *RipgrepEngine) Search(ctx context.Context, params *GrepParams) (*GrepResult, error) { args := e.buildArgs(params) // 精妙之处(CLEVER): 同时捕获 stdout 和 stderr--rg 在无匹配时 exit code=1, // 在出错时 exit code=2.需要区分"无结果"和"真正出错". // M1: 走 Executor.Command, Spec.Stdout/Stderr 用 Writer 模式, Process.Run() // 一步完成 Start+Wait, 和原 cmd.Run() 等价. WorkDir 取代 cmd.Dir. // Env 用 FullInheritMap: ClassWorkspaceTool 是引擎调系统工具对租户 workspace // 操作, 本地需要 PATH/HOME/LANG 等, 云端 backend 在 microVM 里 bind /workspace // 无网络, 天然收紧. var stdout, stderr bytes.Buffer proc := e.executor.Command(ctx, execenv.Spec{ Class: execenv.ClassWorkspaceTool, Path: e.rgPath, Args: args, Env: execenv.FullInheritMap(nil), Stdout: &stdout, Stderr: &stderr, WorkDir: params.SearchDir, }) err := proc.Run() // rg exit code: 0=found, 1=not found, 2=error // // 精妙之处(CLEVER): 用 duck typing `interface{ ExitCode() int }` 提取退出码, // 不绑定 *exec.ExitError 具体类型. 云端 backend 可返回自己的 ExitError 类型 // (只需实现 ExitCode() int 方法). 替代方案: err.(*exec.ExitError).ExitCode() // 锁死 POSIX os/exec 实现, 违反 Executor/Process 门框红线. if err != nil { if ec, ok := err.(interface{ ExitCode() int }); ok { if ec.ExitCode() == 1 { // 无匹配 return &GrepResult{Output: "", MatchedFiles: 0, TotalMatches: 0}, nil } if ec.ExitCode() == 2 { // rg 报错,降级到内置引擎 builtin := NewBuiltinGrepEngine() return builtin.Search(ctx, params) } } // 其他错误(如 context cancelled),降级 if ctx.Err() != nil { return nil, ctx.Err() } builtin := NewBuiltinGrepEngine() return builtin.Search(ctx, params) } return e.parseOutput(stdout.String(), params) } // buildArgs 构建 rg 命令行参数. func (e *RipgrepEngine) buildArgs(params *GrepParams) []string { var args []string // 搜索隐藏文件 args = append(args, "--hidden") // 精妙之处(CLEVER): 最大列宽 500 字符--防止 minified JS/CSS 等 // 单行超长文件污染输出(一行可能 100KB+),同时 500 字符对代码行绰绰有余. args = append(args, "--max-columns", "500") // VCS 目录排除 // 升华改进(ELEVATED): 排除所有主流 VCS 目录,不仅是 .git. // 替代方案:只排除 .git(原始设计,覆盖 95% 场景但遗漏 svn/hg 用户). for _, vcs := range []string{".git", ".svn", ".hg", ".bzr", ".jj", ".sl"} { args = append(args, "--glob", "!"+vcs+"/") } // 大小写不敏感 if params.CaseInsensitive { args = append(args, "-i") } // 多行模式 if params.Multiline { args = append(args, "-U") } // 文件类型过滤 if params.FileType != "" { args = append(args, "--type", params.FileType) } // Glob 过滤 if params.Glob != "" { patterns := splitGlobPatterns(params.Glob) for _, p := range patterns { args = append(args, "--glob", p) } } // 输出模式 switch params.OutputMode { case "files_with_matches": args = append(args, "-l") case "count": args = append(args, "-c") default: // content args = append(args, "-n") // 行号 // 上下文行 if params.ContextBoth > 0 { args = append(args, "-C", strconv.Itoa(params.ContextBoth)) } else { if params.ContextBefore > 0 { args = append(args, "-B", strconv.Itoa(params.ContextBefore)) } if params.ContextAfter > 0 { args = append(args, "-A", strconv.Itoa(params.ContextAfter)) } } } // 精妙之处(CLEVER): pattern 以 "-" 开头时用 -e 前缀, // 防止被 rg 当作命令行参数解析(如搜索 "--help" 字样). if strings.HasPrefix(params.Pattern, "-") { args = append(args, "-e", params.Pattern) } else { args = append(args, params.Pattern) } // 搜索路径 if params.IsFile { args = append(args, params.SearchPath) } else { args = append(args, params.SearchDir) } return args } // parseOutput 解析 rg 输出并应用 head_limit 和 offset. func (e *RipgrepEngine) parseOutput(raw string, params *GrepParams) (*GrepResult, error) { if raw == "" { return &GrepResult{Output: "", MatchedFiles: 0, TotalMatches: 0}, nil } lines := strings.Split(strings.TrimRight(raw, "\n"), "\n") totalEntries := len(lines) // 路径相对化 // 升华改进(ELEVATED): 将绝对路径转为相对路径,节省 token. // 替代方案:保留绝对路径(原始设计,信息无损但浪费 token). if params.SearchDir != "" { for i, line := range lines { lines[i] = relativizePath(line, params.SearchDir) } } // 应用 offset if params.Offset > 0 && params.Offset < len(lines) { lines = lines[params.Offset:] } else if params.Offset >= len(lines) { return &GrepResult{Output: "", MatchedFiles: 0, TotalMatches: 0}, nil } // 应用 head_limit limitReached := false if params.HeadLimit > 0 && len(lines) > params.HeadLimit { lines = lines[:params.HeadLimit] limitReached = true } // 统计匹配文件数 matchedFiles := countMatchedFiles(lines, params.OutputMode) output := strings.Join(lines, "\n") + "\n" if limitReached { output += fmt.Sprintf("\n... (results truncated at %d entries, total: %d)", params.HeadLimit, totalEntries) } return &GrepResult{ Output: output, MatchedFiles: matchedFiles, TotalMatches: totalEntries, LimitReached: limitReached, }, nil } // relativizePath 将输出行中的绝对路径转为相对路径. func relativizePath(line, baseDir string) string { prefix := baseDir + "/" if strings.HasPrefix(line, prefix) { return line[len(prefix):] } // 处理 path:line:content 格式 if idx := strings.Index(line, ":"); idx > 0 { path := line[:idx] if strings.HasPrefix(path, prefix) { return path[len(prefix):] + line[idx:] } } return line } // countMatchedFiles 统计匹配的文件数. func countMatchedFiles(lines []string, mode string) int { seen := make(map[string]bool) for _, line := range lines { if line == "" || line == "--" { continue } switch mode { case "files_with_matches": seen[line] = true case "count": if idx := strings.LastIndex(line, ":"); idx > 0 { seen[line[:idx]] = true } default: // content if idx := strings.Index(line, ":"); idx > 0 { seen[line[:idx]] = true } } } return len(seen) } // ---------- 内置 Go 引擎 ---------- // BuiltinGrepEngine 使用纯 Go regexp 执行搜索. type BuiltinGrepEngine struct{} // NewBuiltinGrepEngine 创建内置 Grep 引擎. func NewBuiltinGrepEngine() *BuiltinGrepEngine { return &BuiltinGrepEngine{} } func (e *BuiltinGrepEngine) Name() string { return "builtin" } // Search 使用 Go regexp 执行搜索. func (e *BuiltinGrepEngine) Search(ctx context.Context, params *GrepParams) (*GrepResult, error) { // 编译正则表达式 regexPattern := params.Pattern if params.CaseInsensitive { regexPattern = "(?i)" + regexPattern } // 升华改进(ELEVATED): 多行模式用 (?s) 修饰符使 . 匹配换行符. // 替代方案:不支持多行模式(原始设计,简单但功能受限). if params.Multiline { regexPattern = "(?s)" + regexPattern } re, err := regexp.Compile(regexPattern) if err != nil { return nil, fmt.Errorf("invalid regex pattern: %v", err) } // 收集文件 var files []string if params.IsFile { files = []string{params.SearchPath} } else { files, err = collectFilesForGrep(ctx, params.SearchDir, params.Glob, params.FileType) if err != nil { return nil, fmt.Errorf("error collecting files: %v", err) } } // 计算有效的上下文行数 ctxBefore := params.ContextBefore ctxAfter := params.ContextAfter if params.ContextBoth > 0 { ctxBefore = params.ContextBoth ctxAfter = params.ContextBoth } var builder strings.Builder matchedFiles := 0 totalMatches := 0 outputLines := 0 skippedEntries := 0 limitReached := false for _, file := range files { select { case <-ctx.Done(): return nil, ctx.Err() default: } if isFileBinary(file) { continue } var matches []builtinMatch if params.Multiline { matches, err = searchFileMultiline(file, re) } else { matches, err = searchFileBuiltin(file, re, ctxBefore, ctxAfter) } if err != nil { continue } if len(matches) == 0 { continue } matchedFiles++ totalMatches += len(matches) relPath, _ := filepath.Rel(params.SearchDir, file) if relPath == "" { relPath = file } switch params.OutputMode { case "files_with_matches": // offset 处理 if params.Offset > 0 && skippedEntries < params.Offset { skippedEntries++ continue } if params.HeadLimit > 0 && outputLines >= params.HeadLimit { limitReached = true break } builder.WriteString(relPath) builder.WriteByte('\n') outputLines++ case "count": if params.Offset > 0 && skippedEntries < params.Offset { skippedEntries++ continue } if params.HeadLimit > 0 && outputLines >= params.HeadLimit { limitReached = true break } fmt.Fprintf(&builder, "%s:%d\n", relPath, len(matches)) outputLines++ default: // content for _, m := range matches { if params.Offset > 0 && skippedEntries < params.Offset { skippedEntries++ continue } if params.HeadLimit > 0 && outputLines >= params.HeadLimit { limitReached = true break } if len(m.context) > 0 { for _, cl := range m.context { builder.WriteString(cl) builder.WriteByte('\n') outputLines++ } builder.WriteString("--\n") outputLines++ } else { text := truncateLine(m.text, 500) fmt.Fprintf(&builder, "%s:%d:%s\n", relPath, m.line, text) outputLines++ } } } if limitReached { break } } output := builder.String() if limitReached { output += fmt.Sprintf("\n... (results truncated at %d entries, total matches: %d in %d files)", params.HeadLimit, totalMatches, matchedFiles) } return &GrepResult{ Output: output, MatchedFiles: matchedFiles, TotalMatches: totalMatches, LimitReached: limitReached, }, nil } // builtinMatch 表示内置引擎的单个匹配结果. type builtinMatch struct { file string line int text string context []string } // searchFileBuiltin 在文件中逐行搜索正则匹配(纯 Go 实现). func searchFileBuiltin(filePath string, re *regexp.Regexp, ctxBefore, ctxAfter int) ([]builtinMatch, error) { f, err := os.Open(filePath) if err != nil { return nil, err } defer f.Close() scanner := bufio.NewScanner(f) scanner.Buffer(make([]byte, 0, 256*1024), 256*1024) var allLines []string var matchLineNums []int lineNum := 0 for scanner.Scan() { lineNum++ line := scanner.Text() allLines = append(allLines, line) if re.MatchString(line) { matchLineNums = append(matchLineNums, lineNum) } } if err := scanner.Err(); err != nil { return nil, err } var matches []builtinMatch for _, ln := range matchLineNums { lineText := allLines[ln-1] highlighted := re.ReplaceAllStringFunc(lineText, func(s string) string { return ">>" + s + "<<" }) m := builtinMatch{ file: filePath, line: ln, text: highlighted, } if ctxBefore > 0 || ctxAfter > 0 { start := ln - ctxBefore - 1 if start < 0 { start = 0 } end := ln + ctxAfter if end > len(allLines) { end = len(allLines) } var ctxLines []string for i := start; i < end; i++ { prefix := " " displayLine := allLines[i] if i == ln-1 { prefix = ">" displayLine = highlighted } displayLine = truncateLine(displayLine, 500) ctxLines = append(ctxLines, fmt.Sprintf("%s %s:%d:%s", prefix, filePath, i+1, displayLine)) } m.context = ctxLines } matches = append(matches, m) } return matches, nil } // searchFileMultiline 多行搜索(读取整个文件内容后匹配). func searchFileMultiline(filePath string, re *regexp.Regexp) ([]builtinMatch, error) { data, err := os.ReadFile(filePath) if err != nil { return nil, err } content := string(data) locs := re.FindAllStringIndex(content, -1) if len(locs) == 0 { return nil, nil } // 精妙之处(CLEVER): 预计算每个字节偏移对应的行号,O(n) 一次遍历, // 之后每次匹配查行号 O(1).比每次匹配都从头计数高效. lineStarts := []int{0} for i, b := range content { if b == '\n' { lineStarts = append(lineStarts, i+1) } } findLine := func(offset int) int { lo, hi := 0, len(lineStarts)-1 for lo <= hi { mid := (lo + hi) / 2 if lineStarts[mid] <= offset { lo = mid + 1 } else { hi = mid - 1 } } return lo // 1-based line number } var matches []builtinMatch for _, loc := range locs { lineNum := findLine(loc[0]) matchText := content[loc[0]:loc[1]] // 截取匹配所在行(从行首到行末或匹配末尾后的换行) lineStart := loc[0] for lineStart > 0 && content[lineStart-1] != '\n' { lineStart-- } lineEnd := loc[1] for lineEnd < len(content) && content[lineEnd] != '\n' { lineEnd++ } fullLine := content[lineStart:lineEnd] highlighted := strings.ReplaceAll(fullLine, matchText, ">>"+matchText+"<<") matches = append(matches, builtinMatch{ file: filePath, line: lineNum, text: truncateLine(highlighted, 500), }) } return matches, nil } // ---------- 文件类型映射 ---------- // 升华改进(ELEVATED): 语言类型映射表,兼容 rg --type 的类型名. // 替代方案:不支持类型过滤,只用 glob(原始设计,简单但不便于按语言搜索). var fileTypeMap = map[string][]string{ "go": {".go"}, "js": {".js", ".jsx", ".mjs"}, "ts": {".ts", ".tsx", ".mts"}, "py": {".py", ".pyi"}, "rust": {".rs"}, "java": {".java"}, "c": {".c", ".h"}, "cpp": {".cpp", ".cc", ".cxx", ".hpp", ".hh", ".hxx"}, "cs": {".cs"}, "rb": {".rb"}, "php": {".php"}, "swift": {".swift"}, "kotlin": {".kt", ".kts"}, "scala": {".scala"}, "html": {".html", ".htm"}, "css": {".css"}, "json": {".json"}, "yaml": {".yaml", ".yml"}, "toml": {".toml"}, "xml": {".xml"}, "sql": {".sql"}, "sh": {".sh", ".bash", ".zsh"}, "md": {".md", ".markdown"}, "lua": {".lua"}, "r": {".r", ".R"}, "dart": {".dart"}, "zig": {".zig"}, "elixir": {".ex", ".exs"}, "erlang": {".erl", ".hrl"}, "haskell": {".hs", ".lhs"}, "clojure": {".clj", ".cljs", ".cljc"}, "vim": {".vim"}, "dockerfile": {"Dockerfile"}, "make": {"Makefile", "makefile", "GNUmakefile"}, } // fileMatchesType 检查文件是否属于指定类型. func fileMatchesType(filePath string, fileType string) bool { exts, ok := fileTypeMap[fileType] if !ok { return true // 未知类型不过滤 } name := filepath.Base(filePath) ext := filepath.Ext(filePath) for _, e := range exts { if strings.HasPrefix(e, ".") { if strings.EqualFold(ext, e) { return true } } else { // 无扩展名的特殊文件(如 Dockerfile, Makefile) if name == e { return true } } } return false } // collectFilesForGrep 收集目录下的文件(支持 glob + type 过滤 + .gitignore). func collectFilesForGrep(ctx context.Context, dir string, globPattern string, fileType string) ([]string, error) { ignorePatterns := CollectIgnorePatterns(dir) globPatterns := splitGlobPatterns(globPattern) // ELEVATED: 规范化根目录路径,保证 symlink 前缀比较时路径形式一致. // 参见 symlinkSafe 的注释了解完整攻击向量与防护逻辑. rootReal, err := filepath.EvalSymlinks(dir) if err != nil { rootReal = dir } var files []string err = filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error { if err != nil { return nil } select { case <-ctx.Done(): return ctx.Err() default: } // ELEVATED: symlink 安全检查 -- 防止 `ln -s /etc/passwd link_in_dir` 越权读取. if d.Type()&fs.ModeSymlink != 0 { if !symlinkSafe(path, rootReal) { return nil } } relPath, relErr := filepath.Rel(dir, path) if relErr != nil { return nil } if d.IsDir() && path != dir { if IsIgnored(relPath, ignorePatterns, true) { return filepath.SkipDir } } if d.IsDir() { return nil } if IsIgnored(relPath, ignorePatterns, false) { return nil } if isBinaryExtension(filepath.Ext(path)) { return nil } // 文件类型过滤 if fileType != "" && !fileMatchesType(path, fileType) { return nil } // Glob 过滤 if len(globPatterns) > 0 { matched := false name := d.Name() for _, gp := range globPatterns { if m, _ := filepath.Match(gp, name); m { matched = true break } if globMatch(gp, relPath) { matched = true break } } if !matched { return nil } } files = append(files, path) return nil }) return files, err } // ---------- files_with_matches 并行 stat 排序 ---------- // sortFilesByMtime 对 files_with_matches 结果并行 stat 排序. // ctx 用于取消:当 ctx 已取消时,等待 semaphore 的 goroutine 会提前退出. func sortFilesByMtime(ctx context.Context, paths []string) []string { if len(paths) == 0 { return paths } if len(paths) > mtimeSortThreshold { // 大结果集:字母序 sort.Strings(paths) return paths } // 小结果集:并行 stat 按 mtime 排序 type fileTime struct { path string modTime int64 } results := make([]fileTime, len(paths)) var wg sync.WaitGroup sem := make(chan struct{}, statConcurrency) for i, p := range paths { wg.Add(1) go func(idx int, path string) { defer wg.Done() select { case sem <- struct{}{}: case <-ctx.Done(): return } defer func() { <-sem }() info, err := os.Stat(path) if err != nil { results[idx] = fileTime{path: path, modTime: 0} return } results[idx] = fileTime{path: path, modTime: info.ModTime().UnixNano()} }(i, p) } wg.Wait() sort.Slice(results, func(i, j int) bool { return results[i].modTime > results[j].modTime }) sorted := make([]string, len(results)) for i, r := range results { sorted[i] = r.path } return sorted } // isFileBinary 通过读取文件前 512 字节检测是否为二进制. // 精妙之处(CLEVER): 512 字节是 HTTP content sniffing 的标准长度(RFC 7231), // 复用这个阈值既有工业标准背书,又能快速判断. func isFileBinary(filePath string) bool { f, err := os.Open(filePath) if err != nil { return false } defer f.Close() buf := make([]byte, 512) n, err := f.Read(buf) if err != nil && err != io.EOF { return false } if n == 0 { return false } return bytes.ContainsRune(buf[:n], 0) }