summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--config.toml.example5
-rw-r--r--go.mod1
-rw-r--r--go.sum8
-rw-r--r--internal/appconfig/config.go76
-rw-r--r--internal/appconfig/config_test.go117
-rw-r--r--internal/hexailsp/run.go16
-rw-r--r--internal/ignore/checker.go90
-rw-r--r--internal/ignore/checker_test.go282
-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
-rw-r--r--internal/version.go2
15 files changed, 832 insertions, 15 deletions
diff --git a/config.toml.example b/config.toml.example
index 81d8ba0..f732300 100644
--- a/config.toml.example
+++ b/config.toml.example
@@ -148,3 +148,8 @@ temperature = 0.2
[stats]
# window_minutes = 60 # sliding window for global stats (Σ@window); min 1, max 1440
+
+[ignore]
+# gitignore = true # respect .gitignore patterns (default: true)
+# extra_patterns = ["*.min.js", "vendor/**", "*.generated.go"]
+# lsp_notify_ignored = true # show "file ignored" in LSP completions (default: true)
diff --git a/go.mod b/go.mod
index 5e6dfd2..6ae1d08 100644
--- a/go.mod
+++ b/go.mod
@@ -26,6 +26,7 @@ require (
github.com/muesli/reflow v0.3.0 // indirect
github.com/muesli/termenv v0.15.2 // indirect
github.com/rivo/uniseg v0.4.6 // indirect
+ github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 // indirect
github.com/sahilm/fuzzy v0.1.1-0.20230530133925-c48e322e2a8f // indirect
golang.org/x/sync v0.1.0 // indirect
golang.org/x/sys v0.12.0 // indirect
diff --git a/go.sum b/go.sum
index 2ce28eb..33a6a77 100644
--- a/go.sum
+++ b/go.sum
@@ -10,6 +10,7 @@ github.com/charmbracelet/lipgloss v0.9.1 h1:PNyd3jvaJbg4jRHKWXnCj1akQm4rh8dbEzN1
github.com/charmbracelet/lipgloss v0.9.1/go.mod h1:1mPmG4cxScwUQALAAnacHaigiiHB9Pmr+v1VEawJl6I=
github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 h1:q2hJAaP1k2wIvVRd/hEHD7lacgqrCPS+k8g1MndzfWY=
github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81/go.mod h1:YynlIjWYF8myEu6sdkwKIvGQq+cOckRm6So2avqoYAk=
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
@@ -33,12 +34,17 @@ github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo
github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.6 h1:Sovz9sDSwbOz9tgUy8JpT+KgCkPYJEN/oYzlJiYTNLg=
github.com/rivo/uniseg v0.4.6/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
+github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 h1:OkMGxebDjyw0ULyrTYWeN0UNCCkmCWfjPnIA2W6oviI=
+github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06/go.mod h1:+ePHsJ1keEjQtpvf9HHw0f4ZeJ0TLRsxhunSI2hYJSs=
github.com/sahilm/fuzzy v0.1.1-0.20230530133925-c48e322e2a8f h1:MvTmaQdww/z0Q4wrYjDSCcZ78NoftLQyHBSLW/Cx79Y=
github.com/sahilm/fuzzy v0.1.1-0.20230530133925-c48e322e2a8f/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -49,3 +55,5 @@ golang.org/x/term v0.6.0 h1:clScbb1cHjoCkyRbWwBEUZ5H/tIFu5TAXIqaZD0Gcjw=
golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U=
golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/internal/appconfig/config.go b/internal/appconfig/config.go
index 87ad9ff..8ec29ae 100644
--- a/internal/appconfig/config.go
+++ b/internal/appconfig/config.go
@@ -113,6 +113,11 @@ type App struct {
TmuxCustomMenuHotkey string `json:"-" toml:"-"`
// Stats
StatsWindowMinutes int `json:"-" toml:"-"`
+
+ // Ignore: gitignore-aware file filtering for LSP
+ IgnoreGitignore *bool `json:"-" toml:"-"`
+ IgnoreExtraPatterns []string `json:"-" toml:"-"`
+ IgnoreLSPNotify *bool `json:"-" toml:"-"`
}
// CustomAction describes a user-defined code action.
@@ -180,9 +185,15 @@ func newDefaultConfig() App {
// Stats
StatsWindowMinutes: 60,
+
+ // Ignore: respect .gitignore by default, notify in LSP by default
+ IgnoreGitignore: boolPtr(true),
+ IgnoreLSPNotify: boolPtr(true),
}
}
+func boolPtr(b bool) *bool { return &b }
+
// Load reads configuration from a file and merges with defaults.
// It respects the XDG Base Directory Specification.
func Load(logger *log.Logger) App { return LoadWithOptions(logger, LoadOptions{}) }
@@ -236,11 +247,11 @@ func LoadWithOptions(logger *log.Logger, opts LoadOptions) App {
}
// loadProjectConfig attempts to load .hexaiconfig.toml from the project root and
-// merges it into cfg. Uses opts.ProjectRoot if set, otherwise auto-detects via findGitRoot().
+// merges it into cfg. Uses opts.ProjectRoot if set, otherwise auto-detects via FindGitRoot().
func loadProjectConfig(logger *log.Logger, opts LoadOptions, cfg *App) {
projectRoot := strings.TrimSpace(opts.ProjectRoot)
if projectRoot == "" {
- projectRoot = findGitRoot()
+ projectRoot = FindGitRoot()
}
if projectRoot == "" {
return
@@ -269,6 +280,7 @@ type fileConfig struct {
Prompts sectionPrompts `toml:"prompts"`
Tmux sectionTmux `toml:"tmux"`
Stats sectionStats `toml:"stats"`
+ Ignore sectionIgnore `toml:"ignore"`
}
type sectionGeneral struct {
@@ -313,6 +325,14 @@ type sectionStats struct {
WindowMinutes int `toml:"window_minutes"`
}
+// sectionIgnore controls gitignore-aware file filtering. Files matching
+// these patterns are skipped for completions and code actions.
+type sectionIgnore struct {
+ Gitignore *bool `toml:"gitignore"`
+ ExtraPatterns []string `toml:"extra_patterns"`
+ LSPNotifyIgnored *bool `toml:"lsp_notify_ignored"`
+}
+
type sectionOpenAI struct {
Model string `toml:"model"`
BaseURL string `toml:"base_url"`
@@ -629,6 +649,16 @@ func (fc *fileConfig) toApp() App {
out.StatsWindowMinutes = fc.Stats.WindowMinutes
}
+ // ignore
+ if fc.Ignore.Gitignore != nil || len(fc.Ignore.ExtraPatterns) > 0 || fc.Ignore.LSPNotifyIgnored != nil {
+ tmp := App{
+ IgnoreGitignore: fc.Ignore.Gitignore,
+ IgnoreExtraPatterns: fc.Ignore.ExtraPatterns,
+ IgnoreLSPNotify: fc.Ignore.LSPNotifyIgnored,
+ }
+ out.mergeBasics(&tmp)
+ }
+
return out
}
@@ -925,6 +955,16 @@ func (a *App) mergeBasics(other *App) {
if s := strings.TrimSpace(other.Provider); s != "" {
a.Provider = s
}
+ // Ignore settings
+ if other.IgnoreGitignore != nil {
+ a.IgnoreGitignore = other.IgnoreGitignore
+ }
+ if len(other.IgnoreExtraPatterns) > 0 {
+ a.IgnoreExtraPatterns = slices.Clone(other.IgnoreExtraPatterns)
+ }
+ if other.IgnoreLSPNotify != nil {
+ a.IgnoreLSPNotify = other.IgnoreLSPNotify
+ }
}
// mergeSurfaceModels copies per-surface model and temperature overrides.
@@ -1141,17 +1181,17 @@ const ProjectConfigFilename = ".hexaiconfig.toml"
// ProjectConfigPath returns the path to the per-project config file if a git repository
// root is detected from the current working directory. Returns empty string otherwise.
func ProjectConfigPath() string {
- root := findGitRoot()
+ root := FindGitRoot()
if root == "" {
return ""
}
return filepath.Join(root, ProjectConfigFilename)
}
-// findGitRoot walks up from the current working directory looking for a .git
-// directory or file (worktrees use a .git file). Returns the directory
-// containing .git, or empty string if none is found.
-func findGitRoot() string {
+// FindGitRoot walks up from the current working directory to find the nearest
+// .git directory or file (worktrees use a .git file), returning its parent
+// path or "" if none is found.
+func FindGitRoot() string {
dir, err := os.Getwd()
if err != nil {
return ""
@@ -1402,6 +1442,28 @@ func loadFromEnv(logger *log.Logger) *App {
any = true
}
+ // Ignore settings (bool: "true"/"1" or "false"/"0")
+ if s := getenv("HEXAI_IGNORE_GITIGNORE"); s != "" {
+ b := s == "true" || s == "1"
+ out.IgnoreGitignore = &b
+ any = true
+ }
+ if s := getenv("HEXAI_IGNORE_EXTRA_PATTERNS"); s != "" {
+ parts := strings.Split(s, ",")
+ out.IgnoreExtraPatterns = nil
+ for _, p := range parts {
+ if t := strings.TrimSpace(p); t != "" {
+ out.IgnoreExtraPatterns = append(out.IgnoreExtraPatterns, t)
+ }
+ }
+ any = true
+ }
+ if s := getenv("HEXAI_IGNORE_LSP_NOTIFY"); s != "" {
+ b := s == "true" || s == "1"
+ out.IgnoreLSPNotify = &b
+ any = true
+ }
+
if !any {
return nil
}
diff --git a/internal/appconfig/config_test.go b/internal/appconfig/config_test.go
index 75cc7ee..b9dfe3a 100644
--- a/internal/appconfig/config_test.go
+++ b/internal/appconfig/config_test.go
@@ -625,9 +625,9 @@ func TestFindGitRoot(t *testing.T) {
if err := os.Chdir(nested); err != nil {
t.Fatalf("chdir: %v", err)
}
- root := findGitRoot()
+ root := FindGitRoot()
if root != dir {
- t.Fatalf("findGitRoot() = %q, want %q", root, dir)
+ t.Fatalf("FindGitRoot() = %q, want %q", root, dir)
}
// Test from a dir with no .git ancestor
@@ -635,9 +635,9 @@ func TestFindGitRoot(t *testing.T) {
if err := os.Chdir(noGit); err != nil {
t.Fatalf("chdir: %v", err)
}
- root = findGitRoot()
+ root = FindGitRoot()
if root != "" {
- t.Fatalf("findGitRoot() = %q, want empty", root)
+ t.Fatalf("FindGitRoot() = %q, want empty", root)
}
}
@@ -784,3 +784,112 @@ func TestProjectConfigPath(t *testing.T) {
t.Fatalf("ProjectConfigPath() = %q, want empty", path)
}
}
+
+func TestIgnoreConfig_Defaults(t *testing.T) {
+ clearHexaiEnv(t)
+ cfg := Load(nil)
+ if cfg.IgnoreGitignore == nil || !*cfg.IgnoreGitignore {
+ t.Error("expected IgnoreGitignore default true")
+ }
+ if cfg.IgnoreLSPNotify == nil || !*cfg.IgnoreLSPNotify {
+ t.Error("expected IgnoreLSPNotify default true")
+ }
+ if len(cfg.IgnoreExtraPatterns) != 0 {
+ t.Errorf("expected empty IgnoreExtraPatterns, got %v", cfg.IgnoreExtraPatterns)
+ }
+}
+
+func TestIgnoreConfig_FromFile(t *testing.T) {
+ clearHexaiEnv(t)
+ dir := t.TempDir()
+ cfgPath := filepath.Join(dir, "config.toml")
+ writeFile(t, cfgPath, `
+[ignore]
+gitignore = false
+extra_patterns = ["*.min.js", "dist/**"]
+lsp_notify_ignored = false
+`)
+ cfg := LoadWithOptions(newLogger(), LoadOptions{ConfigPath: cfgPath})
+ if cfg.IgnoreGitignore == nil || *cfg.IgnoreGitignore {
+ t.Error("expected IgnoreGitignore false from file")
+ }
+ if cfg.IgnoreLSPNotify == nil || *cfg.IgnoreLSPNotify {
+ t.Error("expected IgnoreLSPNotify false from file")
+ }
+ want := []string{"*.min.js", "dist/**"}
+ if !reflect.DeepEqual(cfg.IgnoreExtraPatterns, want) {
+ t.Errorf("IgnoreExtraPatterns = %v, want %v", cfg.IgnoreExtraPatterns, want)
+ }
+}
+
+func TestIgnoreConfig_EnvOverrides(t *testing.T) {
+ clearHexaiEnv(t)
+ dir := t.TempDir()
+ cfgPath := filepath.Join(dir, "config.toml")
+ writeFile(t, cfgPath, `
+[ignore]
+gitignore = true
+lsp_notify_ignored = true
+`)
+ withEnv(t, "HEXAI_IGNORE_GITIGNORE", "false")
+ withEnv(t, "HEXAI_IGNORE_LSP_NOTIFY", "0")
+ withEnv(t, "HEXAI_IGNORE_EXTRA_PATTERNS", "*.bak,*.tmp")
+ cfg := LoadWithOptions(newLogger(), LoadOptions{ConfigPath: cfgPath})
+ if cfg.IgnoreGitignore == nil || *cfg.IgnoreGitignore {
+ t.Error("expected IgnoreGitignore false from env override")
+ }
+ if cfg.IgnoreLSPNotify == nil || *cfg.IgnoreLSPNotify {
+ t.Error("expected IgnoreLSPNotify false from env override")
+ }
+ want := []string{"*.bak", "*.tmp"}
+ if !reflect.DeepEqual(cfg.IgnoreExtraPatterns, want) {
+ t.Errorf("IgnoreExtraPatterns = %v, want %v", cfg.IgnoreExtraPatterns, want)
+ }
+}
+
+func TestIgnoreConfig_ProjectOverride(t *testing.T) {
+ clearHexaiEnv(t)
+ dir := t.TempDir()
+ cfgPath := filepath.Join(dir, "config.toml")
+ writeFile(t, cfgPath, `
+[ignore]
+gitignore = true
+`)
+ // Set up a fake git repo with project override
+ projectDir := t.TempDir()
+ if err := os.Mkdir(filepath.Join(projectDir, ".git"), 0o755); err != nil {
+ t.Fatalf("mkdir .git: %v", err)
+ }
+ projectCfg := filepath.Join(projectDir, ProjectConfigFilename)
+ writeFile(t, projectCfg, `
+[ignore]
+gitignore = false
+extra_patterns = ["build/**"]
+`)
+ cfg := LoadWithOptions(newLogger(), LoadOptions{ConfigPath: cfgPath, ProjectRoot: projectDir})
+ if cfg.IgnoreGitignore == nil || *cfg.IgnoreGitignore {
+ t.Error("expected project override to set IgnoreGitignore false")
+ }
+ want := []string{"build/**"}
+ if !reflect.DeepEqual(cfg.IgnoreExtraPatterns, want) {
+ t.Errorf("IgnoreExtraPatterns = %v, want %v", cfg.IgnoreExtraPatterns, want)
+ }
+}
+
+func TestIgnoreConfig_DisableGitignore(t *testing.T) {
+ clearHexaiEnv(t)
+ dir := t.TempDir()
+ cfgPath := filepath.Join(dir, "config.toml")
+ writeFile(t, cfgPath, `
+[ignore]
+gitignore = false
+`)
+ cfg := LoadWithOptions(newLogger(), LoadOptions{ConfigPath: cfgPath})
+ if cfg.IgnoreGitignore == nil || *cfg.IgnoreGitignore {
+ t.Error("expected IgnoreGitignore false")
+ }
+ // LSP notify should still be true (default, not overridden)
+ if cfg.IgnoreLSPNotify == nil || !*cfg.IgnoreLSPNotify {
+ t.Error("expected IgnoreLSPNotify to remain true (default)")
+ }
+}
diff --git a/internal/hexailsp/run.go b/internal/hexailsp/run.go
index e2aaf9d..18f5aa5 100644
--- a/internal/hexailsp/run.go
+++ b/internal/hexailsp/run.go
@@ -10,6 +10,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/lsp"
@@ -66,10 +67,15 @@ func RunWithFactory(logPath string, configPath string, stdin io.Reader, stdout i
client = buildClientIfNil(cfg, client)
factory = ensureFactory(factory)
+ // Create gitignore-aware file checker for LSP filtering
+ gitRoot := appconfig.FindGitRoot()
+ useGI := cfg.IgnoreGitignore == nil || *cfg.IgnoreGitignore
+ ignoreChecker := ignore.New(gitRoot, useGI, cfg.IgnoreExtraPatterns)
+
store := runtimeconfig.New(cfg)
logContext := strings.TrimSpace(logPath) != ""
loadOpts := appconfig.LoadOptions{ConfigPath: strings.TrimSpace(configPath)}
- opts := makeServerOptions(cfg, logContext, client, loadOpts)
+ opts := makeServerOptions(cfg, logContext, client, loadOpts, ignoreChecker)
opts.ConfigLoadOptions = loadOpts
opts.ConfigStore = store
server := factory(stdin, stdout, logger, opts)
@@ -83,7 +89,10 @@ func RunWithFactory(logPath string, configPath string, stdin io.Reader, stdout i
if newClient := buildClientIfNil(updated, nil); newClient != nil {
client = newClient
}
- opts := makeServerOptions(updated, logContext, client, loadOpts)
+ // Update ignore checker patterns on config hot-reload
+ useGI := updated.IgnoreGitignore == nil || *updated.IgnoreGitignore
+ ignoreChecker.Update(useGI, updated.IgnoreExtraPatterns)
+ opts := makeServerOptions(updated, logContext, client, loadOpts, ignoreChecker)
opts.ConfigStore = store
configurable.ApplyOptions(opts)
})
@@ -156,7 +165,7 @@ func ensureFactory(factory ServerFactory) ServerFactory {
}
}
-func makeServerOptions(cfg appconfig.App, logContext bool, client llm.Client, loadOpts appconfig.LoadOptions) lsp.ServerOptions {
+func makeServerOptions(cfg appconfig.App, logContext bool, client llm.Client, loadOpts appconfig.LoadOptions, ignoreChecker *ignore.Checker) lsp.ServerOptions {
// Map custom actions from appconfig to lsp type
var customs []lsp.CustomAction
if len(cfg.CustomActions) > 0 {
@@ -214,5 +223,6 @@ func makeServerOptions(cfg appconfig.App, logContext bool, client llm.Client, lo
PromptSimplifySystem: cfg.PromptCodeActionSimplifySystem,
PromptSimplifyUser: cfg.PromptCodeActionSimplifyUser,
CustomActions: customs,
+ IgnoreChecker: ignoreChecker,
}
}
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)
+ }
+ }
+}
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.
diff --git a/internal/version.go b/internal/version.go
index 737fa97..0e66355 100644
--- a/internal/version.go
+++ b/internal/version.go
@@ -1,4 +1,4 @@
// Summary: Hexai semantic version identifier used by CLI and LSP binaries.
package internal
-const Version = "0.17.0"
+const Version = "0.18.0"