summaryrefslogtreecommitdiff
path: root/internal/lsp
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-02-08 10:39:51 +0200
committerPaul Buetow <paul@buetow.org>2026-02-08 10:39:51 +0200
commitd5b13224737a9f66c3d5113a885603b32867d740 (patch)
treed482cc965a65be22604800fe6772279c52961b99 /internal/lsp
parentbd698b257a548d835fbc2675ff5be5e1a69ff229 (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/lsp')
-rw-r--r--internal/lsp/handlers_codeaction.go8
-rw-r--r--internal/lsp/handlers_completion.go12
-rw-r--r--internal/lsp/handlers_document.go4
-rw-r--r--internal/lsp/handlers_ignore.go41
-rw-r--r--internal/lsp/ignore_test.go175
-rw-r--r--internal/lsp/server.go10
6 files changed, 250 insertions, 0 deletions
diff --git a/internal/lsp/handlers_codeaction.go b/internal/lsp/handlers_codeaction.go
index 24429a1..4562954 100644
--- a/internal/lsp/handlers_codeaction.go
+++ b/internal/lsp/handlers_codeaction.go
@@ -22,6 +22,14 @@ func (s *Server) handleCodeAction(req Request) {
}
return
}
+ // Skip code actions for gitignored / extra-pattern-ignored files
+ if ignored, reason := s.isFileIgnored(p.TextDocument.URI); ignored {
+ logging.Logf("lsp ", "code action skipped: file ignored (%s) uri=%s", reason, p.TextDocument.URI)
+ if len(req.ID) != 0 {
+ s.reply(req.ID, []CodeAction{}, nil)
+ }
+ return
+ }
d := s.getDocument(p.TextDocument.URI)
if d == nil || len(d.lines) == 0 || s.currentLLMClient() == nil {
if len(req.ID) != 0 {
diff --git a/internal/lsp/handlers_completion.go b/internal/lsp/handlers_completion.go
index 28da503..6350c59 100644
--- a/internal/lsp/handlers_completion.go
+++ b/internal/lsp/handlers_completion.go
@@ -37,6 +37,18 @@ func (s *Server) handleCompletion(req Request) {
var p CompletionParams
var docStr string
if err := json.Unmarshal(req.Params, &p); err == nil {
+ // Skip completion for gitignored / extra-pattern-ignored files
+ if ignored, reason := s.isFileIgnored(p.TextDocument.URI); ignored {
+ logging.Logf("lsp ", "completion skipped: file ignored (%s) uri=%s", reason, p.TextDocument.URI)
+ if s.ignoreLSPNotifyEnabled() {
+ s.reply(req.ID, CompletionList{IsIncomplete: false, Items: []CompletionItem{
+ {Label: "[hexai] file ignored", Detail: reason},
+ }}, nil)
+ } else {
+ s.reply(req.ID, CompletionList{IsIncomplete: false, Items: nil}, nil)
+ }
+ return
+ }
// Log trigger information for every completion request from client
tk, tch := extractTriggerInfo(p)
logging.Logf("lsp ", "completion trigger kind=%d char=%q uri=%s line=%d char=%d",
diff --git a/internal/lsp/handlers_document.go b/internal/lsp/handlers_document.go
index a047324..b907014 100644
--- a/internal/lsp/handlers_document.go
+++ b/internal/lsp/handlers_document.go
@@ -16,6 +16,10 @@ func (s *Server) handleDidOpen(req Request) {
if err := json.Unmarshal(req.Params, &p); err == nil {
s.setDocument(p.TextDocument.URI, p.TextDocument.Text)
s.markActivity()
+ // Log when an ignored file is opened (document still stored for editor sync)
+ if ignored, reason := s.isFileIgnored(p.TextDocument.URI); ignored {
+ logging.Logf("lsp ", "file opened (ignored): %s (%s)", p.TextDocument.URI, reason)
+ }
}
}
diff --git a/internal/lsp/handlers_ignore.go b/internal/lsp/handlers_ignore.go
new file mode 100644
index 0000000..bbd2dfa
--- /dev/null
+++ b/internal/lsp/handlers_ignore.go
@@ -0,0 +1,41 @@
+// Summary: Helpers for gitignore-aware file filtering in LSP handlers.
+package lsp
+
+import (
+ "net/url"
+ "strings"
+)
+
+// isFileIgnored checks whether the file at the given LSP URI should be ignored.
+// Returns false when no ignore checker is configured.
+func (s *Server) isFileIgnored(uri string) (bool, string) {
+ if s.ignoreChecker == nil {
+ return false, ""
+ }
+ absPath := uriToPath(uri)
+ if absPath == "" {
+ return false, ""
+ }
+ return s.ignoreChecker.IsIgnored(absPath)
+}
+
+// ignoreLSPNotifyEnabled returns whether to show "file ignored" completion items
+// when a file is ignored. Reads from the IgnoreLSPNotify config field.
+func (s *Server) ignoreLSPNotifyEnabled() bool {
+ s.mu.RLock()
+ defer s.mu.RUnlock()
+ return s.cfg.IgnoreLSPNotify == nil || *s.cfg.IgnoreLSPNotify
+}
+
+// uriToPath converts a file:// URI to an absolute file path.
+// Returns empty string for non-file URIs.
+func uriToPath(uri string) string {
+ if !strings.HasPrefix(uri, "file://") {
+ return ""
+ }
+ parsed, err := url.Parse(uri)
+ if err != nil {
+ return ""
+ }
+ return parsed.Path
+}
diff --git a/internal/lsp/ignore_test.go b/internal/lsp/ignore_test.go
new file mode 100644
index 0000000..5414137
--- /dev/null
+++ b/internal/lsp/ignore_test.go
@@ -0,0 +1,175 @@
+package lsp
+
+import (
+ "io"
+ "log"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "codeberg.org/snonux/hexai/internal/appconfig"
+ "codeberg.org/snonux/hexai/internal/ignore"
+ "codeberg.org/snonux/hexai/internal/llm"
+)
+
+// newIgnoreTestServer creates a Server with an ignore checker configured
+// from the given gitRoot and extra patterns.
+func newIgnoreTestServer(gitRoot string, useGI bool, extra []string, notifyIgnored *bool) *Server {
+ cfg := appconfig.App{
+ IgnoreLSPNotify: notifyIgnored,
+ InlineOpen: ">!",
+ InlineClose: ">",
+ ChatSuffix: ">",
+ ChatPrefixes: []string{"?", "!", ":", ";"},
+ }
+ s := &Server{
+ logger: log.New(io.Discard, "", 0),
+ docs: make(map[string]*document),
+ cfg: cfg,
+ altClients: make(map[string]llm.Client),
+ ignoreChecker: ignore.New(gitRoot, useGI, extra),
+ }
+ return s
+}
+
+func boolPtr(b bool) *bool { return &b }
+
+func TestHandleCompletion_IgnoredFile_WithNotify(t *testing.T) {
+ dir := t.TempDir()
+ if err := os.WriteFile(filepath.Join(dir, ".gitignore"), []byte("*.log\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ s := newIgnoreTestServer(dir, true, nil, boolPtr(true))
+
+ uri := "file://" + filepath.Join(dir, "debug.log")
+ ignored, _ := s.isFileIgnored(uri)
+ if !ignored {
+ t.Fatal("expected file to be ignored")
+ }
+
+ // Verify notify is enabled
+ if !s.ignoreLSPNotifyEnabled() {
+ t.Fatal("expected LSP notify enabled")
+ }
+}
+
+func TestHandleCompletion_IgnoredFile_WithoutNotify(t *testing.T) {
+ dir := t.TempDir()
+ if err := os.WriteFile(filepath.Join(dir, ".gitignore"), []byte("*.log\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ s := newIgnoreTestServer(dir, true, nil, boolPtr(false))
+
+ uri := "file://" + filepath.Join(dir, "debug.log")
+
+ ignored, _ := s.isFileIgnored(uri)
+ if !ignored {
+ t.Fatal("expected file to be ignored")
+ }
+ if s.ignoreLSPNotifyEnabled() {
+ t.Fatal("expected LSP notify disabled")
+ }
+}
+
+func TestHandleCompletion_NonIgnoredFile(t *testing.T) {
+ dir := t.TempDir()
+ if err := os.WriteFile(filepath.Join(dir, ".gitignore"), []byte("*.log\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ s := newIgnoreTestServer(dir, true, nil, boolPtr(true))
+
+ uri := "file://" + filepath.Join(dir, "main.go")
+
+ ignored, _ := s.isFileIgnored(uri)
+ if ignored {
+ t.Fatal("main.go should not be ignored")
+ }
+}
+
+func TestHandleCodeAction_IgnoredFile(t *testing.T) {
+ dir := t.TempDir()
+ if err := os.WriteFile(filepath.Join(dir, ".gitignore"), []byte("*.log\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ s := newIgnoreTestServer(dir, true, nil, nil)
+
+ uri := "file://" + filepath.Join(dir, "app.log")
+
+ ignored, reason := s.isFileIgnored(uri)
+ if !ignored {
+ t.Fatal("expected app.log to be ignored")
+ }
+ if reason != "matched .gitignore pattern" {
+ t.Errorf("unexpected reason: %s", reason)
+ }
+}
+
+func TestHandleDidOpen_IgnoredFile(t *testing.T) {
+ dir := t.TempDir()
+ if err := os.WriteFile(filepath.Join(dir, ".gitignore"), []byte("*.log\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ s := newIgnoreTestServer(dir, true, nil, nil)
+ uri := "file://" + filepath.Join(dir, "app.log")
+
+ // Simulate didOpen — document should be stored even if ignored
+ s.setDocument(uri, "log content")
+ d := s.getDocument(uri)
+ if d == nil {
+ t.Fatal("document should be stored even for ignored files")
+ }
+
+ ignored, _ := s.isFileIgnored(uri)
+ if !ignored {
+ t.Fatal("expected app.log to be ignored")
+ }
+}
+
+func TestIsFileIgnored_NoChecker(t *testing.T) {
+ s := &Server{
+ logger: log.New(io.Discard, "", 0),
+ docs: make(map[string]*document),
+ altClients: make(map[string]llm.Client),
+ // ignoreChecker is nil
+ }
+
+ ignored, reason := s.isFileIgnored("file:///some/file.log")
+ if ignored {
+ t.Fatal("nil checker should not ignore anything")
+ }
+ if reason != "" {
+ t.Errorf("expected empty reason, got %q", reason)
+ }
+}
+
+func TestUriToPath(t *testing.T) {
+ tests := []struct {
+ uri string
+ want string
+ }{
+ {"file:///home/user/file.go", "/home/user/file.go"},
+ {"file:///tmp/test.log", "/tmp/test.log"},
+ {"", ""},
+ {"https://example.com", ""},
+ {"file:///path/with%20space/file.go", "/path/with space/file.go"},
+ }
+ for _, tc := range tests {
+ got := uriToPath(tc.uri)
+ if got != tc.want {
+ t.Errorf("uriToPath(%q) = %q, want %q", tc.uri, got, tc.want)
+ }
+ }
+}
+
+func TestIgnoreLSPNotifyEnabled_NilConfig(t *testing.T) {
+ // When IgnoreLSPNotify is nil, defaults to true
+ s := &Server{
+ logger: log.New(io.Discard, "", 0),
+ docs: make(map[string]*document),
+ altClients: make(map[string]llm.Client),
+ cfg: appconfig.App{},
+ }
+ if !s.ignoreLSPNotifyEnabled() {
+ t.Error("expected notify enabled when config is nil (default)")
+ }
+}
diff --git a/internal/lsp/server.go b/internal/lsp/server.go
index c226ab4..a5a8a2a 100644
--- a/internal/lsp/server.go
+++ b/internal/lsp/server.go
@@ -12,6 +12,7 @@ import (
"time"
"codeberg.org/snonux/hexai/internal/appconfig"
+ "codeberg.org/snonux/hexai/internal/ignore"
"codeberg.org/snonux/hexai/internal/llm"
"codeberg.org/snonux/hexai/internal/logging"
"codeberg.org/snonux/hexai/internal/runtimeconfig"
@@ -50,6 +51,9 @@ type Server struct {
completionsDisabled bool
+ // Gitignore-aware file checker (nil when disabled)
+ ignoreChecker *ignore.Checker
+
// Dispatch table for JSON-RPC methods → handler functions
handlers map[string]func(Request)
}
@@ -101,6 +105,9 @@ type ServerOptions struct {
// Custom actions
CustomActions []CustomAction
+
+ // Gitignore-aware file checker (optional)
+ IgnoreChecker *ignore.Checker
}
// CustomAction mirrors user-defined code actions passed from config.
@@ -204,6 +211,9 @@ func (s *Server) applyOptions(opts ServerOptions) {
s.llmProvider = canonicalProvider(s.cfg.Provider)
}
s.altClients = make(map[string]llm.Client)
+ if opts.IgnoreChecker != nil {
+ s.ignoreChecker = opts.IgnoreChecker
+ }
}
// ApplyOptions updates the server's configuration at runtime.