diff options
| -rw-r--r-- | config.toml.example | 5 | ||||
| -rw-r--r-- | go.mod | 1 | ||||
| -rw-r--r-- | go.sum | 8 | ||||
| -rw-r--r-- | internal/appconfig/config.go | 76 | ||||
| -rw-r--r-- | internal/appconfig/config_test.go | 117 | ||||
| -rw-r--r-- | internal/hexailsp/run.go | 16 | ||||
| -rw-r--r-- | internal/ignore/checker.go | 90 | ||||
| -rw-r--r-- | internal/ignore/checker_test.go | 282 | ||||
| -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 | ||||
| -rw-r--r-- | internal/version.go | 2 |
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) @@ -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 @@ -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" |
