diff options
| author | Paul Buetow <paul@buetow.org> | 2026-02-08 10:39:51 +0200 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-02-08 10:39:51 +0200 |
| commit | d5b13224737a9f66c3d5113a885603b32867d740 (patch) | |
| tree | d482cc965a65be22604800fe6772279c52961b99 /internal/ignore | |
| parent | bd698b257a548d835fbc2675ff5be5e1a69ff229 (diff) | |
add gitignore-aware file filtering for LSP completions and code actionsv0.18.0
Files matching .gitignore patterns or user-configured extra patterns are
now skipped for completions and code actions. Configurable via [ignore]
section in config.toml with gitignore, extra_patterns, and
lsp_notify_ignored options. Includes hot-reload support and env var
overrides (HEXAI_IGNORE_*).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Diffstat (limited to 'internal/ignore')
| -rw-r--r-- | internal/ignore/checker.go | 90 | ||||
| -rw-r--r-- | internal/ignore/checker_test.go | 282 |
2 files changed, 372 insertions, 0 deletions
diff --git a/internal/ignore/checker.go b/internal/ignore/checker.go new file mode 100644 index 0000000..92129b8 --- /dev/null +++ b/internal/ignore/checker.go @@ -0,0 +1,90 @@ +// Summary: Thread-safe gitignore-aware file checker that combines .gitignore +// patterns with user-configured extra patterns. Used by the LSP server to +// skip completions and code actions for ignored files. +package ignore + +import ( + "path/filepath" + "strings" + "sync" + + gitignore "github.com/sabhiram/go-gitignore" +) + +// Checker evaluates whether an absolute file path should be ignored based on +// .gitignore patterns and/or user-configured extra patterns. It is safe for +// concurrent use. +type Checker struct { + mu sync.RWMutex + gitRoot string + giMatcher *gitignore.GitIgnore // compiled .gitignore (nil when disabled or missing) + exMatcher *gitignore.GitIgnore // compiled extra patterns (nil when empty) +} + +// New creates a Checker. If useGitignore is true and gitRoot is non-empty, it +// loads .gitignore from gitRoot. extraPatterns are always compiled (gitignore +// syntax). +func New(gitRoot string, useGitignore bool, extraPatterns []string) *Checker { + c := &Checker{gitRoot: gitRoot} + c.compile(useGitignore, extraPatterns) + return c +} + +// IsIgnored returns whether absPath should be ignored and a human-readable +// reason string. When the checker is nil, nothing is ignored. +func (c *Checker) IsIgnored(absPath string) (ignored bool, reason string) { + if c == nil { + return false, "" + } + c.mu.RLock() + defer c.mu.RUnlock() + + rel, inside := c.relPath(absPath) + + // Only check gitignore when the path is inside the git root + if inside && c.giMatcher != nil && c.giMatcher.MatchesPath(rel) { + return true, "matched .gitignore pattern" + } + if c.exMatcher != nil && c.exMatcher.MatchesPath(rel) { + return true, "matched extra ignore pattern" + } + return false, "" +} + +// Update recompiles matchers for hot-reload. Thread-safe. +func (c *Checker) Update(useGitignore bool, extraPatterns []string) { + c.mu.Lock() + defer c.mu.Unlock() + c.compile(useGitignore, extraPatterns) +} + +// compile builds the gitignore and extra-pattern matchers. Must be called +// under c.mu write lock (or during construction). +func (c *Checker) compile(useGitignore bool, extraPatterns []string) { + c.giMatcher = nil + c.exMatcher = nil + + if useGitignore && c.gitRoot != "" { + giPath := filepath.Join(c.gitRoot, ".gitignore") + if gi, err := gitignore.CompileIgnoreFile(giPath); err == nil { + c.giMatcher = gi + } + } + if len(extraPatterns) > 0 { + c.exMatcher = gitignore.CompileIgnoreLines(extraPatterns...) + } +} + +// relPath converts absPath to a path relative to gitRoot. Returns the +// relative path and true if the path is inside the git root; otherwise +// returns the original path and false. +func (c *Checker) relPath(absPath string) (string, bool) { + if c.gitRoot == "" { + return absPath, false + } + rel, err := filepath.Rel(c.gitRoot, absPath) + if err != nil || strings.HasPrefix(rel, "..") { + return absPath, false + } + return rel, true +} diff --git a/internal/ignore/checker_test.go b/internal/ignore/checker_test.go new file mode 100644 index 0000000..3e3384c --- /dev/null +++ b/internal/ignore/checker_test.go @@ -0,0 +1,282 @@ +package ignore + +import ( + "os" + "path/filepath" + "sync" + "testing" +) + +// writeGitignore creates a .gitignore in dir with the given lines. +func writeGitignore(t *testing.T, dir string, lines ...string) { + t.Helper() + content := "" + for _, l := range lines { + content += l + "\n" + } + if err := os.WriteFile(filepath.Join(dir, ".gitignore"), []byte(content), 0o644); err != nil { + t.Fatalf("write .gitignore: %v", err) + } +} + +func TestSimpleWildcard(t *testing.T) { + dir := t.TempDir() + writeGitignore(t, dir, "*.log") + c := New(dir, true, nil) + + if ign, _ := c.IsIgnored(filepath.Join(dir, "app.log")); !ign { + t.Error("expected app.log to be ignored") + } + if ign, _ := c.IsIgnored(filepath.Join(dir, "debug.log")); !ign { + t.Error("expected debug.log to be ignored") + } + if ign, _ := c.IsIgnored(filepath.Join(dir, "app.go")); ign { + t.Error("app.go should not be ignored") + } + if ign, _ := c.IsIgnored(filepath.Join(dir, "log.txt")); ign { + t.Error("log.txt should not be ignored") + } +} + +func TestDirectoryPattern(t *testing.T) { + dir := t.TempDir() + writeGitignore(t, dir, "build/") + c := New(dir, true, nil) + + if ign, _ := c.IsIgnored(filepath.Join(dir, "build", "output.js")); !ign { + t.Error("expected build/output.js to be ignored") + } + if ign, _ := c.IsIgnored(filepath.Join(dir, "rebuild", "x")); ign { + t.Error("rebuild/x should not be ignored") + } +} + +func TestDoubleStarPattern(t *testing.T) { + dir := t.TempDir() + writeGitignore(t, dir, "**/temp") + c := New(dir, true, nil) + + if ign, _ := c.IsIgnored(filepath.Join(dir, "a", "b", "temp")); !ign { + t.Error("expected a/b/temp to be ignored") + } + if ign, _ := c.IsIgnored(filepath.Join(dir, "temp")); !ign { + t.Error("expected temp to be ignored") + } +} + +func TestNegation(t *testing.T) { + dir := t.TempDir() + writeGitignore(t, dir, "*.log", "!important.log") + c := New(dir, true, nil) + + if ign, _ := c.IsIgnored(filepath.Join(dir, "debug.log")); !ign { + t.Error("expected debug.log to be ignored") + } + if ign, _ := c.IsIgnored(filepath.Join(dir, "important.log")); ign { + t.Error("important.log should not be ignored (negated)") + } +} + +func TestComments(t *testing.T) { + dir := t.TempDir() + writeGitignore(t, dir, "# comment", "*.tmp") + c := New(dir, true, nil) + + if ign, _ := c.IsIgnored(filepath.Join(dir, "x.tmp")); !ign { + t.Error("expected x.tmp to be ignored") + } + // A file literally named "# comment" should not be ignored + if ign, _ := c.IsIgnored(filepath.Join(dir, "# comment")); ign { + t.Error("file named '# comment' should not be ignored") + } +} + +func TestExtensionGroups(t *testing.T) { + dir := t.TempDir() + writeGitignore(t, dir, "*.out", "*.html") + c := New(dir, true, nil) + + if ign, _ := c.IsIgnored(filepath.Join(dir, "coverage.out")); !ign { + t.Error("expected coverage.out to be ignored") + } + if ign, _ := c.IsIgnored(filepath.Join(dir, "main.go")); ign { + t.Error("main.go should not be ignored") + } +} + +func TestNestedDirs(t *testing.T) { + dir := t.TempDir() + writeGitignore(t, dir, "vendor/**") + c := New(dir, true, nil) + + if ign, _ := c.IsIgnored(filepath.Join(dir, "vendor", "lib", "x.go")); !ign { + t.Error("expected vendor/lib/x.go to be ignored") + } + if ign, _ := c.IsIgnored(filepath.Join(dir, "myvendor", "x")); ign { + t.Error("myvendor/x should not be ignored") + } +} + +func TestExtraPatternsOnly(t *testing.T) { + // No gitignore, only extra patterns + c := New("", false, []string{"*.min.js", "dist/**"}) + + if ign, reason := c.IsIgnored("/project/app.min.js"); !ign { + t.Error("expected app.min.js to be ignored") + } else if reason != "matched extra ignore pattern" { + t.Errorf("unexpected reason: %s", reason) + } + if ign, _ := c.IsIgnored("/project/dist/bundle.js"); !ign { + t.Error("expected dist/bundle.js to be ignored") + } + if ign, _ := c.IsIgnored("/project/app.js"); ign { + t.Error("app.js should not be ignored") + } +} + +func TestCombinedGitignoreAndExtra(t *testing.T) { + dir := t.TempDir() + writeGitignore(t, dir, "*.log") + c := New(dir, true, []string{"*.min.js"}) + + // gitignore match + if ign, reason := c.IsIgnored(filepath.Join(dir, "app.log")); !ign { + t.Error("expected app.log to be ignored") + } else if reason != "matched .gitignore pattern" { + t.Errorf("unexpected reason: %s", reason) + } + // extra pattern match + if ign, reason := c.IsIgnored(filepath.Join(dir, "app.min.js")); !ign { + t.Error("expected app.min.js to be ignored") + } else if reason != "matched extra ignore pattern" { + t.Errorf("unexpected reason: %s", reason) + } + // neither match + if ign, _ := c.IsIgnored(filepath.Join(dir, "main.go")); ign { + t.Error("main.go should not be ignored") + } +} + +func TestNilChecker(t *testing.T) { + var c *Checker + if ign, _ := c.IsIgnored("/some/file.go"); ign { + t.Error("nil checker should never ignore") + } +} + +func TestEmptyChecker(t *testing.T) { + c := New("", false, nil) + if ign, _ := c.IsIgnored("/some/file.go"); ign { + t.Error("empty checker should never ignore") + } +} + +func TestUpdatePatterns(t *testing.T) { + dir := t.TempDir() + writeGitignore(t, dir, "*.log") + c := New(dir, true, nil) + + if ign, _ := c.IsIgnored(filepath.Join(dir, "app.log")); !ign { + t.Error("expected app.log ignored initially") + } + + // Update: disable gitignore, add extra pattern + c.Update(false, []string{"*.tmp"}) + + if ign, _ := c.IsIgnored(filepath.Join(dir, "app.log")); ign { + t.Error("app.log should not be ignored after disabling gitignore") + } + if ign, _ := c.IsIgnored(filepath.Join(dir, "x.tmp")); !ign { + t.Error("expected x.tmp ignored after update") + } +} + +func TestThreadSafety(t *testing.T) { + dir := t.TempDir() + writeGitignore(t, dir, "*.log") + c := New(dir, true, nil) + + var wg sync.WaitGroup + // Concurrent reads + for i := 0; i < 50; i++ { + wg.Add(1) + go func() { + defer wg.Done() + c.IsIgnored(filepath.Join(dir, "app.log")) + c.IsIgnored(filepath.Join(dir, "main.go")) + }() + } + // Concurrent updates + for i := 0; i < 10; i++ { + wg.Add(1) + go func() { + defer wg.Done() + c.Update(true, []string{"*.tmp"}) + }() + } + wg.Wait() +} + +func TestNoGitRoot(t *testing.T) { + // gitRoot empty but gitignore enabled — should not crash, gitignore has no effect + c := New("", true, []string{"*.bak"}) + + if ign, _ := c.IsIgnored("/any/file.go"); ign { + t.Error("should not ignore .go files") + } + if ign, _ := c.IsIgnored("/any/file.bak"); !ign { + t.Error("extra patterns should still work without git root") + } +} + +func TestPathOutsideGitRoot(t *testing.T) { + dir := t.TempDir() + writeGitignore(t, dir, "*.log") + c := New(dir, true, nil) + + // Path outside the git root — relPath returns absolute, gitignore won't match + if ign, _ := c.IsIgnored("/completely/elsewhere/app.log"); ign { + t.Error("files outside git root should not be matched by gitignore") + } +} + +func TestMixedRealGitignore(t *testing.T) { + dir := t.TempDir() + writeGitignore(t, dir, + "# Build outputs", + "bin/", + "*.exe", + "*.dll", + "", + "# Dependencies", + "vendor/**", + "", + "# IDE", + ".idea/", + ".vscode/", + ) + c := New(dir, true, nil) + + ignored := []string{ + filepath.Join(dir, "bin", "app"), + filepath.Join(dir, "main.exe"), + filepath.Join(dir, "vendor", "lib", "x.go"), + filepath.Join(dir, ".idea", "workspace.xml"), + } + for _, p := range ignored { + if ign, _ := c.IsIgnored(p); !ign { + t.Errorf("expected %s to be ignored", p) + } + } + + allowed := []string{ + filepath.Join(dir, "main.go"), + filepath.Join(dir, "internal", "app.go"), + filepath.Join(dir, "README.md"), + } + for _, p := range allowed { + if ign, _ := c.IsIgnored(p); ign { + t.Errorf("%s should not be ignored", p) + } + } +} |
