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/lsp | |
| 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/lsp')
| -rw-r--r-- | internal/lsp/handlers_codeaction.go | 8 | ||||
| -rw-r--r-- | internal/lsp/handlers_completion.go | 12 | ||||
| -rw-r--r-- | internal/lsp/handlers_document.go | 4 | ||||
| -rw-r--r-- | internal/lsp/handlers_ignore.go | 41 | ||||
| -rw-r--r-- | internal/lsp/ignore_test.go | 175 | ||||
| -rw-r--r-- | internal/lsp/server.go | 10 |
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. |
