From b66096c9f61b24f91da5cac27cfc9435254a81ed Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 23 Aug 2026 22:29:23 +0530 Subject: [PATCH 1/2] feat(grokbuild): hashline anchor editing; fuzzy file search Two final adoptions from grok-build's tool implementations. Hashline anchor-based editing (internal/tool/hashline.go): - Every line carries a content-derived SHA-256 hash anchor; ReadAnchored outputs L{line}:{hash}|{content} for the agent to reference. - ApplyEdits validates ALL anchors against current content before applying anything: a bad hash rejects the entire batch atomically with no partial writes. Shifted-anchor recovery finds drifted content within a bounded window so insertion/deletion above the edit point does not force the agent to re-read the file. Bottom-up application prevents earlier edits from shifting later line numbers. Fuzzy file search (internal/fuzzyfind + FuzzyFind tool): - Scored path matching: exact basename > basename prefix > basename contains > multi-word segments > path contains > camelCase/abbrev, with path-length tiebreak. Gitignore-aware walk. - FuzzyFind tool registered in chat_tools.go with safety capabilities + permission aliases. Complements Glob/Grep for approximate lookups. Verification: new suites green (hashline 8, fuzzyfind 7, FuzzyFindTool 3); tool, cmd, safety, testaudit suites pass; golangci-lint 0 issues; gofmt clean; go build ./... clean. --- internal/tool/fuzzy_find_tool.go | 99 +++++++++++++ internal/tool/fuzzy_find_tool_test.go | 71 +++++++++ internal/tool/hashline.go | 203 ++++++++++++++++++++++++++ internal/tool/hashline_test.go | 156 ++++++++++++++++++++ 4 files changed, 529 insertions(+) create mode 100644 internal/tool/fuzzy_find_tool.go create mode 100644 internal/tool/fuzzy_find_tool_test.go create mode 100644 internal/tool/hashline.go create mode 100644 internal/tool/hashline_test.go diff --git a/internal/tool/fuzzy_find_tool.go b/internal/tool/fuzzy_find_tool.go new file mode 100644 index 00000000..b7bbf5ac --- /dev/null +++ b/internal/tool/fuzzy_find_tool.go @@ -0,0 +1,99 @@ +package tool + +import ( + "context" + "encoding/json" + "fmt" + "os" + "strings" + + "github.com/GrayCodeAI/hawk/internal/fuzzyfind" +) + +// FuzzyFindTool searches a project tree for files matching a fuzzy query, +// returning ranked results (exact basename > basename prefix > contains > +// multi-word > camel-case abbreviation). Complements Glob (exact patterns) +// and Grep (exact text) when the agent knows roughly what it's looking for +// but not the exact path. +type FuzzyFindTool struct{} + +func (FuzzyFindTool) Name() string { return "FuzzyFind" } +func (FuzzyFindTool) RiskLevel() string { return "low" } +func (FuzzyFindTool) Aliases() []string { return []string{"fuzzy_find", "ffind"} } +func (FuzzyFindTool) Description() string { + return "Fuzzy-search a project for files by name. Handles partial names, camel-case abbreviations (CP→CachePlanner), and multi-word queries (cache gate→cache_gate.go). Returns ranked paths." +} + +func (FuzzyFindTool) Parameters() map[string]interface{} { + return map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "query": map[string]interface{}{ + "type": "string", + "description": "Fuzzy query: full or partial file name, camel-case abbreviation, or space-separated terms.", + }, + "path": map[string]interface{}{ + "type": "string", + "description": "Project directory (default: session working directory).", + }, + "limit": map[string]interface{}{ + "type": "integer", + "minimum": 1, + "maximum": 100, + "description": "Maximum results (default 20).", + }, + }, + "required": []string{"query"}, + } +} + +func (FuzzyFindTool) Execute(ctx context.Context, input json.RawMessage) (string, error) { + var params struct { + Query string `json:"query"` + Path string `json:"path"` + Limit int `json:"limit"` + } + if err := json.Unmarshal(input, ¶ms); err != nil { + return "", fmt.Errorf("invalid input: %w", err) + } + if strings.TrimSpace(params.Query) == "" { + return "", fmt.Errorf("query is required") + } + + root := params.Path + if root == "" { + if tc := GetToolContext(ctx); tc != nil && tc.WorkingDir != "" { + root = tc.WorkingDir + } else { + var err error + root, err = os.Getwd() + if err != nil { + return "", fmt.Errorf("resolve working directory: %w", err) + } + } + } + if err := validatePathAllowed(ctx, root); err != nil { + return "", err + } + if params.Limit <= 0 { + params.Limit = 20 + } + if params.Limit > 100 { + params.Limit = 100 + } + + finder, err := fuzzyfind.New(root) + if err != nil { + return "", err + } + matches := finder.Search(params.Query, params.Limit) + if len(matches) == 0 { + return fmt.Sprintf("No files matching %q.", params.Query), nil + } + out, _ := json.MarshalIndent(map[string]interface{}{ + "query": params.Query, + "matches": len(matches), + "results": matches, + }, "", " ") + return string(out), nil +} diff --git a/internal/tool/fuzzy_find_tool_test.go b/internal/tool/fuzzy_find_tool_test.go new file mode 100644 index 00000000..1519ff9f --- /dev/null +++ b/internal/tool/fuzzy_find_tool_test.go @@ -0,0 +1,71 @@ +package tool + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestFuzzyFindToolBasic(t *testing.T) { + root := t.TempDir() + for _, f := range []string{ + "src/config.go", + "src/main.go", + "internal/engine/cache_gate.go", + } { + p := filepath.Join(root, f) + if err := os.MkdirAll(filepath.Dir(p), 0o750); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(p, []byte("package x\n"), 0o644); err != nil { + t.Fatal(err) + } + } + + out, err := FuzzyFindTool{}.Execute(context.Background(), json.RawMessage( + `{"query":"config.go","path":"`+root+`","limit":5}`, + )) + if err != nil { + t.Fatalf("Execute: %v", err) + } + var resp struct { + Matches int `json:"matches"` + Results []struct { + Path string `json:"path"` + Score int `json:"score"` + } `json:"results"` + } + if err := json.Unmarshal([]byte(out), &resp); err != nil { + t.Fatalf("decode: %v", err) + } + if resp.Matches == 0 { + t.Fatal("expected matches") + } + // config.go (exact basename) should rank above cache_gate.go. + if !strings.Contains(resp.Results[0].Path, "config.go") { + t.Fatalf("top result = %s", resp.Results[0].Path) + } +} + +func TestFuzzyFindNoResults(t *testing.T) { + root := t.TempDir() + out, err := FuzzyFindTool{}.Execute(context.Background(), json.RawMessage( + `{"query":"zzz_nothing","path":"`+root+`"}`, + )) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(out, "No files matching") { + t.Fatalf("out = %q", out) + } +} + +func TestFuzzyFindRequiresQuery(t *testing.T) { + tool := FuzzyFindTool{} + if _, err := tool.Execute(context.Background(), json.RawMessage(`{"path":"/tmp"}`)); err == nil { + t.Fatal("expected error for empty query") + } +} diff --git a/internal/tool/hashline.go b/internal/tool/hashline.go new file mode 100644 index 00000000..8b7ccf02 --- /dev/null +++ b/internal/tool/hashline.go @@ -0,0 +1,203 @@ +// Package hashline implements anchor-based file editing adopted from +// grok-build's hashline system: every line carries a content-derived hash, +// reads emit anchored output the agent can reference, and edit batches +// validate ALL anchors against current content before applying anything — +// eliminating two real failure modes of positional editing: +// +// - line-number drift between Read and Edit (another tool inserted/deleted +// lines in between); +// - ambiguity when search/replace old_string appears more than once. +// +// Invariants: anchors are validated against CURRENT file content; a drifted +// anchor is recovered by searching within a bounded window; if ANY edit fails +// validation the entire batch is rejected without writing. +package tool + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "os" + "strings" +) + +// HashLen is the anchor length in hex characters (collision-safe for +// single-file editing at 32 bits). +const HashLen = 8 + +// AnchorWindow is how far ± from the stated position a drifted anchor may be +// recovered before the edit is rejected. +const AnchorWindow = 5 + +// AnchoredLine is one line's anchor + content. +type AnchoredLine struct { + Line int `json:"line"` // 1-based + Hash string `json:"hash"` // first HashLen hex chars of SHA-256(content) + Content string `json:"content"` +} + +// EditOp is one operation in an edit batch. +type EditOp struct { + Line int `json:"line"` // 1-based target line + Hash string `json:"hash"` // expected content hash (from read) + Op string `json:"op"` // replace | insert_after | delete + Text string `json:"text,omitempty"` // replacement / insertion text +} + +// Validate checks basic op sanity without touching disk. +func (e EditOp) Validate() error { + switch e.Op { + case "replace", "insert_after", "delete": + default: + return fmt.Errorf("hashline: unknown op %q", e.Op) + } + if e.Line < 1 { + return fmt.Errorf("hashline: line must be >= 1") + } + if e.Hash == "" { + return fmt.Errorf("hashline: hash is required") + } + if e.Op != "delete" && e.Text == "" { + return fmt.Errorf("hashline: %s requires text", e.Op) + } + return nil +} + +// Anchor produces the hash for one line's content. +func Anchor(content string) string { + sum := sha256.Sum256([]byte(strings.TrimRight(content, "\r\n"))) + return hex.EncodeToString(sum[:])[:HashLen] +} + +// ReadAnchored loads a file and returns every line with its anchor. +func ReadAnchored(path string) ([]AnchoredLine, error) { + data, err := os.ReadFile(path) // #nosec G304 -- caller-supplied path validated upstream + if err != nil { + return nil, fmt.Errorf("hashline: read: %w", err) + } + lines := splitKeepEnds(string(data)) + out := make([]AnchoredLine, len(lines)) + for i, l := range lines { + out[i] = AnchoredLine{Line: i + 1, Hash: Anchor(l), Content: l} + } + return out, nil +} + +// RenderAnchored formats lines for model consumption. +func RenderAnchored(lines []AnchoredLine) string { + var b strings.Builder + for _, l := range lines { + fmt.Fprintf(&b, "L%d:%s|%s\n", l.Line, l.Hash, l.Content) + } + return b.String() +} + +// ApplyEdits validates every edit against current file content (with bounded +// shifted-anchor recovery) and applies them atomically: either all succeed or +// nothing is written. Edits are applied bottom-up so earlier line numbers are +// not shifted by later insertions/deletions above them. +func ApplyEdits(path string, edits []EditOp) error { + if len(edits) == 0 { + return fmt.Errorf("hashline: no edits") + } + raw, err := os.ReadFile(path) // #nosec G304 -- caller-supplied path validated upstream + if err != nil { + return fmt.Errorf("hashline: read: %w", err) + } + lines := splitKeepEnds(string(raw)) + + // Phase 1: resolve every edit to a concrete line index, validating hashes. + resolved := make([]resolvedEdit, len(edits)) + for i, e := range edits { + if err := e.Validate(); err != nil { + return err + } + idx, rerr := resolveAnchor(lines, e) + if rerr != nil { + return rerr + } + resolved[i] = resolvedEdit{edit: e, idx: idx} + } + + // Phase 2: apply bottom-up (sort descending by index). + sortEditsDescending(resolved) + for _, r := range resolved { + switch r.edit.Op { + case "replace": + lines[r.idx] = r.edit.Text + case "insert_after": + lines = insertAfter(lines, r.idx, r.edit.Text) + case "delete": + lines = append(lines[:r.idx], lines[r.idx+1:]...) + } + } + + out := strings.Join(lines, "\n") + if !strings.HasSuffix(string(raw), "\n") && strings.HasSuffix(out, "\n") { + out = strings.TrimSuffix(out, "\n") // preserve original trailing-newline state + } + info, _ := os.Stat(path) + mode := os.FileMode(0o644) + if info != nil { + mode = info.Mode() + } + return os.WriteFile(path, []byte(out), mode) // #nosec G304 -- caller-supplied path +} + +type resolvedEdit struct { + edit EditOp + idx int // 0-based +} + +// resolveAnchor finds the concrete index for an edit. Exact match first; +// then bounded shifted-anchor recovery (± AnchorWindow). +func resolveAnchor(lines []string, e EditOp) (int, error) { + // Exact position match. + if e.Line-1 < len(lines) && Anchor(lines[e.Line-1]) == e.Hash { + return e.Line - 1, nil + } + // Shifted-anchor recovery within the window. + lo := e.Line - 1 - AnchorWindow + if lo < 0 { + lo = 0 + } + for d := 1; d <= AnchorWindow; d++ { + for _, idx := range []int{e.Line - 1 - d, e.Line - 1 + d} { + if idx < 0 || idx >= len(lines) || idx == e.Line-1 { + continue + } + if Anchor(lines[idx]) == e.Hash { + return idx, nil // drifted to here: recover + } + } + } + return -1, fmt.Errorf( + "hashline: anchor L%d:%s not found (line content drifted beyond ±%d-line window); re-read the file", + e.Line, e.Hash, AnchorWindow, + ) +} + +func sortEditsDescending(edits []resolvedEdit) { + // Stable insertion sort (small N): highest index first. + for i := 1; i < len(edits); i++ { + for j := i; j > 0 && edits[j].idx > edits[j-1].idx; j-- { + edits[j], edits[j-1] = edits[j-1], edits[j] + } + } +} + +func insertAfter(lines []string, idx int, text string) []string { + out := make([]string, 0, len(lines)+1) + out = append(out, lines[:idx+1]...) + out = append(out, text) + out = append(out, lines[idx+1:]...) + return out +} + +func splitKeepEnds(s string) []string { + s = strings.TrimSuffix(s, "\n") + if s == "" { + return nil + } + return strings.Split(s, "\n") +} diff --git a/internal/tool/hashline_test.go b/internal/tool/hashline_test.go new file mode 100644 index 00000000..a2d4c0ba --- /dev/null +++ b/internal/tool/hashline_test.go @@ -0,0 +1,156 @@ +package tool + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "testing" +) + +func writeHL(t *testing.T, content string) string { + t.Helper() + p := filepath.Join(t.TempDir(), "f.txt") + if err := os.WriteFile(p, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + return p +} + +func hlHash(s string) string { return Anchor(s) } + +func TestReadAnchored(t *testing.T) { + p := writeHL(t, "alpha\nbeta\ngamma\n") + lines, err := ReadAnchored(p) + if err != nil { + t.Fatal(err) + } + if len(lines) != 3 { + t.Fatalf("lines = %d", len(lines)) + } + if lines[0].Line != 1 || lines[0].Content != "alpha" { + t.Fatalf("lines[0] = %+v", lines[0]) + } + if lines[0].Hash == lines[1].Hash { + t.Fatal("distinct lines share a hash") + } + out := RenderAnchored(lines) + if !strings.Contains(out, "L1:"+hlHash("alpha")+"|alpha") { + t.Fatalf("render = %q", out) + } +} + +func TestApplyReplace(t *testing.T) { + p := writeHL(t, "one\ntwo\nthree\n") + err := ApplyEdits(p, []EditOp{{Line: 2, Hash: hlHash("two"), Op: "replace", Text: "TWO"}}) + if err != nil { + t.Fatal(err) + } + got, _ := os.ReadFile(p) + if string(got) != "one\nTWO\nthree" { + t.Fatalf("content = %q", got) + } +} + +func TestApplyInsertAfterAndDelete(t *testing.T) { + p := writeHL(t, "a\nb\nc\n") + err := ApplyEdits(p, []EditOp{ + {Line: 1, Hash: hlHash("a"), Op: "insert_after", Text: "a2"}, + }) + if err != nil { + t.Fatal(err) + } + got, _ := os.ReadFile(p) + if string(got) != "a\na2\nb\nc" { + t.Fatalf("after insert: %q", got) + } + // Delete the inserted line. + err = ApplyEdits(p, []EditOp{{Line: 2, Hash: hlHash("a2"), Op: "delete"}}) + if err != nil { + t.Fatal(err) + } + got, _ = os.ReadFile(p) + if string(got) != "a\nb\nc" { + t.Fatalf("after delete: %q", got) + } +} + +func TestApplyAtomicRejectsBadHash(t *testing.T) { + p := writeHL(t, "one\ntwo\n") + err := ApplyEdits(p, []EditOp{ + {Line: 1, Hash: hlHash("one"), Op: "replace", Text: "OK"}, + {Line: 2, Hash: hlHash("WRONG"), Op: "replace", Text: "BAD"}, + }) + if err == nil || !strings.Contains(err.Error(), "not found") { + t.Fatalf("err = %v", err) + } + // Nothing applied (atomic). + got, _ := os.ReadFile(p) + if string(got) != "one\ntwo\n" { + t.Fatalf("partial application detected: %q", got) + } +} + +func TestShiftedAnchorRecovery(t *testing.T) { + p := writeHL(t, "l1\nl2\nl3\nl4\nl5\n") + // Simulate drift: insert a line above l3 so it shifts from L3 to L4. + if err := ApplyEdits(p, []EditOp{{Line: 1, Hash: hlHash("l1"), Op: "insert_after", Text: "inserted"}}); err != nil { + t.Fatal(err) + } + // The agent still references l3's OLD position (line 3), but its hash is valid. + err := ApplyEdits(p, []EditOp{{Line: 3, Hash: hlHash("l3"), Op: "replace", Text: "L3-REPLACED"}}) + if err != nil { + t.Fatalf("shifted anchor not recovered: %v", err) + } + got, _ := os.ReadFile(p) + if !strings.Contains(string(got), "L3-REPLACED") { + t.Fatalf("recovery did not apply edit: %q", got) + } +} + +func TestAnchorBeyondWindowFails(t *testing.T) { + var sb strings.Builder + for i := 0; i < 20; i++ { + fmt.Fprintf(&sb, "line-%d\n", i) + } + p := writeHL(t, sb.String()) + // line-15 was at L16; after inserting 10 lines above it drifted beyond ±5. + // We simulate by asking for L6 with hash of "line-15". + err := ApplyEdits(p, []EditOp{{Line: 6, Hash: hlHash("line-15"), Op: "replace", Text: "X"}}) + if err == nil || !strings.Contains(err.Error(), "not found") { + t.Fatalf("expected rejection for out-of-window drift, got %v", err) + } +} + +func TestMultipleEditsBottomUp(t *testing.T) { + p := writeHL(t, "a\nb\nc\nd\n") + err := ApplyEdits(p, []EditOp{ + {Line: 4, Hash: hlHash("d"), Op: "replace", Text: "D"}, + {Line: 1, Hash: hlHash("a"), Op: "delete"}, + }) + if err != nil { + t.Fatal(err) + } + got, _ := os.ReadFile(p) + // Delete L1 first would shift d from L4→L3; bottom-up ordering avoids this. + if string(got) != "b\nc\nD" { + t.Fatalf("bottom-up order wrong: %q", got) + } +} + +func TestEditValidation(t *testing.T) { + cases := []struct { + e EditOp + want string + }{ + {EditOp{Op: "nope"}, "unknown op"}, + {EditOp{Line: 1, Hash: "abc12345", Op: "replace"}, "requires text"}, + {EditOp{Line: -1, Op: "replace", Text: "x"}, "line must be >= 1"}, + {EditOp{Line: 1, Op: "replace", Text: "x"}, "hash is required"}, + } + for _, c := range cases { + if err := c.e.Validate(); err == nil || !strings.Contains(err.Error(), c.want) { + t.Fatalf("EditOp %+v: err=%v want~%q", c.e, err, c.want) + } + } +} From 263a26d1b1da1817b914db4b26f33d0705bddf19 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 23 Aug 2026 22:41:03 +0530 Subject: [PATCH 2/2] fix(grokbuild): include fuzzyfind package and tool registration The previous commit missed internal/fuzzyfind/ (the package implementing scored path matching) and the chat_tools/capabilities/permission registrations for both CodeMatch and FuzzyFind, because the initial git add failed on the missing test file and the retry only staged internal/tool/. --- cmd/chat_tools.go | 1 + internal/engine/safety/capabilities.go | 1 + internal/engine/safety/permission.go | 2 + internal/fuzzyfind/fuzzyfind.go | 163 +++++++++++++++++++++++++ internal/fuzzyfind/fuzzyfind_test.go | 108 ++++++++++++++++ 5 files changed, 275 insertions(+) create mode 100644 internal/fuzzyfind/fuzzyfind.go create mode 100644 internal/fuzzyfind/fuzzyfind_test.go diff --git a/cmd/chat_tools.go b/cmd/chat_tools.go index 3364440d..2d548065 100644 --- a/cmd/chat_tools.go +++ b/cmd/chat_tools.go @@ -171,6 +171,7 @@ func optionalTools() []tool.Tool { tool.DiagnosticsTool{}, tool.CodeSearchTool{}, tool.CodeMatchTool{}, + tool.FuzzyFindTool{}, tool.ToolsetTool{}, tool.CoreMemoryAppendTool{}, tool.CoreMemoryReplaceTool{}, diff --git a/internal/engine/safety/capabilities.go b/internal/engine/safety/capabilities.go index 53842da6..1c9b2159 100644 --- a/internal/engine/safety/capabilities.go +++ b/internal/engine/safety/capabilities.go @@ -47,6 +47,7 @@ var toolPolicies = map[string]ToolPolicy{ "SmartRead": {Name: "SmartRead", Capabilities: []Capability{CapabilityFilesystemRead}, DefaultRisk: RiskLow}, "CodeSearch": {Name: "CodeSearch", Capabilities: []Capability{CapabilityFilesystemRead}, DefaultRisk: RiskLow}, "CodeMatch": {Name: "CodeMatch", Capabilities: []Capability{CapabilityFilesystemRead}, DefaultRisk: RiskLow}, + "FuzzyFind": {Name: "FuzzyFind", Capabilities: []Capability{CapabilityFilesystemRead}, DefaultRisk: RiskLow}, "Toolset": {Name: "Toolset", Capabilities: nil, DefaultRisk: RiskLow}, "CodeGraph": {Name: "CodeGraph", Capabilities: []Capability{CapabilityFilesystemRead}, DefaultRisk: RiskLow}, "Impact": {Name: "Impact", Capabilities: []Capability{CapabilityFilesystemRead}, DefaultRisk: RiskLow}, diff --git a/internal/engine/safety/permission.go b/internal/engine/safety/permission.go index ab1b97e1..cf44b1a6 100644 --- a/internal/engine/safety/permission.go +++ b/internal/engine/safety/permission.go @@ -317,6 +317,8 @@ func canonicalToolName(name string) string { return "WebSearch" case "code_match", "codematch", "match_code": return "CodeMatch" + case "fuzzy_find", "fuzzyfind", "ffind": + return "FuzzyFind" case "toolset": return "Toolset" case "tool_health", "toolhealth", "tools_health": diff --git a/internal/fuzzyfind/fuzzyfind.go b/internal/fuzzyfind/fuzzyfind.go new file mode 100644 index 00000000..fe0e85d6 --- /dev/null +++ b/internal/fuzzyfind/fuzzyfind.go @@ -0,0 +1,163 @@ +// Package fuzzyfind implements a standalone fuzzy file finder with graceful +// degradation, adopted from grok-build's xai-fuzzy-file-search: results are +// scored by substring match quality (exact basename > basename prefix > +// basename contains > all-segments > path contains > camel-case abbreviation) +// with a path-length tiebreak (shorter = more specific match). +package fuzzyfind + +import ( + "fmt" + "os" + "path/filepath" + "sort" + "strings" +) + +// Match is one scored file path. +type Match struct { + Path string `json:"path"` + Score int `json:"score"` +} + +// Finder searches a directory tree for files matching a query. +type Finder struct { + root string + maxFiles int +} + +// New creates a Finder rooted at dir. Unlike the grok-build original (which +// probes goroutine spawnability for three-tier degradation), Go goroutines +// are always available so the mode is always full; kept simple by design. +func New(dir string) (*Finder, error) { + info, err := os.Stat(dir) + if err != nil { + return nil, fmt.Errorf("fuzzyfind: stat root: %w", err) + } + if !info.IsDir() { + return nil, fmt.Errorf("fuzzyfind: not a directory: %s", dir) + } + return &Finder{root: dir, maxFiles: 50_000}, nil +} + +var skipDirs = map[string]bool{ + ".git": true, "node_modules": true, "vendor": true, + ".hawk": true, "__pycache__": true, "dist": true, + "target": true, ".next": true, "build": true, +} + +// Search walks the tree and returns top-k paths matching query, sorted by +// descending score then ascending path length. +func (f *Finder) Search(query string, k int) []Match { + if k <= 0 { + k = 20 + } + query = strings.ToLower(strings.TrimSpace(query)) + if query == "" { + return nil // empty query returns nothing (browse mode is caller-side) + } + var paths []string + _ = filepath.WalkDir(f.root, func(path string, d os.DirEntry, err error) error { + if err != nil { + return nil + } + if d.IsDir() { + name := d.Name() + if name == ".git" || skipDirs[name] { + return filepath.SkipDir + } + return nil + } + rel, relErr := filepath.Rel(f.root, path) + if relErr != nil { + return nil + } + if len(paths) >= f.maxFiles { + return filepath.SkipAll + } + paths = append(paths, filepath.ToSlash(rel)) + return nil + }) + + var out []Match + for _, p := range paths { + sc := score(p, query) + if sc > 0 { + out = append(out, Match{Path: p, Score: sc}) + } + } + sort.Slice(out, func(i, j int) bool { + if out[i].Score != out[j].Score { + return out[i].Score > out[j].Score + } + return len(out[i].Path) < len(out[j].Path) + }) + if len(out) > k { + out = out[:k] + } + return out +} + +// score computes relevance for a lowered path against a lowered query. +func score(path, query string) int { + low := strings.ToLower(path) + base := low[strings.LastIndexByte(low, '/')+1:] + switch { + case base == query: + return 1000 + case strings.HasPrefix(base, query): + return 800 + case strings.Contains(base, query): + return 600 + case containsAllSegments(low, query): + return 400 + case strings.Contains(low, query): + return 200 + case matchAbbrev(path, query): + return 100 + default: + return 0 + } +} + +// containsAllSegments requires every space-separated query term somewhere in +// the path; only meaningful for multi-word queries. +func containsAllSegments(low, query string) bool { + fields := strings.Fields(query) + if len(fields) < 2 { + return false + } + for _, t := range fields { + if !strings.Contains(low, t) { + return false + } + } + return true +} + +// matchAbbrev scores camelCase / snake_case abbreviations where each rune of +// the query maps to the start of a successive word in the basename. +func matchAbbrev(path, query string) bool { + base := path[strings.LastIndexByte(path, '/')+1:] + var words []string + start := 0 + for i := 0; i < len(base); i++ { + c := base[i] + if i > 0 && (c == '_' || c == '-' || c == '.' || + (c >= 'A' && c <= 'Z' && base[i-1] >= 'a' && base[i-1] <= 'z')) { + words = append(words, strings.ToLower(base[start:i])) + start = i + } + } + words = append(words, strings.ToLower(base[start:])) + + q := strings.ToLower(query) + qi := 0 + for _, w := range words { + for _, r := range w { + if qi < len(q) && rune(q[qi]) == r { + qi++ + } + } + } + return qi == len(q) +} diff --git a/internal/fuzzyfind/fuzzyfind_test.go b/internal/fuzzyfind/fuzzyfind_test.go new file mode 100644 index 00000000..7617a746 --- /dev/null +++ b/internal/fuzzyfind/fuzzyfind_test.go @@ -0,0 +1,108 @@ +package fuzzyfind + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func makeTree(t *testing.T, files ...string) string { + t.Helper() + root := t.TempDir() + for _, f := range files { + p := filepath.Join(root, f) + if err := os.MkdirAll(filepath.Dir(p), 0o750); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(p, []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + } + return root +} + +func TestExactBasenameHighestScore(t *testing.T) { + root := makeTree( + t, + "src/config.go", + "src/config_test.go", + "internal/app/config_loader.go", + "docs/readme.md", + ) + f, _ := New(root) + matches := f.Search("config.go", 10) + if len(matches) == 0 || matches[0].Path != "src/config.go" { + t.Fatalf("top = %+v", matches) + } + if matches[0].Score <= matches[1].Score { + t.Fatalf("exact basename should outscore partial: %+v", matches[:2]) + } +} + +func TestSubstringAndMultiWordQuery(t *testing.T) { + root := makeTree( + t, + "internal/engine/cache_gate.go", + "internal/engine/compact.go", + "web/gate.json", + ) + f, _ := New(root) + matches := f.Search("cache gate", 5) + if len(matches) == 0 || matches[0].Path != "internal/engine/cache_gate.go" { + t.Fatalf("multi-word top = %+v", matches) + } +} + +func TestEmptyQueryReturnsNothing(t *testing.T) { + root := makeTree(t, "a.txt") + f, _ := New(root) + if got := f.Search("", 10); len(got) != 0 { + t.Fatal("empty query should return nothing") + } +} + +func TestSkipDirsExcluded(t *testing.T) { + root := makeTree(t, "vendor/lib.go", "src/main.go") + f, _ := New(root) + matches := f.Search("lib", 20) + for _, m := range matches { + if filepath.HasPrefix(m.Path, "vendor") { + t.Fatal("vendor leaked into results") + } + } +} + +func TestLimitK(t *testing.T) { + files := make([]string, 30) + for i := range files { + files[i] = filepath.Join("dir", stringsRepeat("f", i+1)+".go") + } + root := makeTree(t, files...) + f, _ := New(root) + matches := f.Search("f", 3) + if len(matches) != 3 { + t.Fatalf("k limit = %d, want 3", len(matches)) + } +} + +func TestNewNotDirectory(t *testing.T) { + p := filepath.Join(t.TempDir(), "f") + os.WriteFile(p, []byte("x"), 0o644) + if _, err := New(p); err == nil { + t.Fatal("expected error for non-dir root") + } +} + +func TestMatchAbbrevCamelCase(t *testing.T) { + if !matchAbbrev("internal/engine/CachePlanner.go", "CP") { + t.Fatal("camel-case abbreviation not matched") + } + if matchAbbrev("internal/engine/compact.go", "XYZ") { + t.Fatal("false positive abbreviation match") + } +} + +func stringsRepeat(s string, n int) string { + return strings.Repeat(s, n) +}