package lsp import ( "strings" "testing" "github.com/zzet/gortex/internal/semantic" ) // TestSpecForExtension verifies the per-extension lookup table is // populated for every claimed extension and that case is normalised. func TestSpecForExtension(t *testing.T) { cases := []struct { ext string wantName string }{ {".go", "gopls"}, {".ts", "typescript-language-server"}, {".tsx", "typescript-language-server"}, {".py", "pyright"}, {".pyi", "pyright"}, {".rs", "rust-analyzer"}, {".cpp", "clangd"}, {".HPP", "clangd"}, // case folding {".java", "jdtls"}, {".kt", "kotlin-language-server"}, {".cs", "omnisharp"}, {".rb", "ruby-lsp"}, {".php", "phpactor"}, {".lua", "lua-language-server"}, {".swift", "sourcekit-lsp"}, {".hs", "haskell-language-server"}, {".ex", "elixir-ls"}, {".ml", "ocamllsp"}, {".zig", "zls"}, } for _, c := range cases { t.Run(c.ext, func(t *testing.T) { s := SpecForExtension(c.ext) if s == nil { t.Fatalf("no spec for %q", c.ext) } if s.Name != c.wantName { t.Fatalf("ext %q: got %s, want %s", c.ext, s.Name, c.wantName) } }) } if SpecForExtension(".unknown_ext") != nil { t.Fatal("expected nil for unknown ext") } if SpecForExtension("") != nil { t.Fatal("empty ext should not map") } } // TestSpecForPath verifies path-based routing extracts the extension // even from messy paths. func TestSpecForPath(t *testing.T) { if s := SpecForPath("/abs/path/to/main.go"); s == nil || s.Name != "gopls" { t.Fatalf("unexpected spec for go path: %v", s) } if s := SpecForPath("README.md"); s != nil { t.Fatalf("expected nil spec for markdown, got %v", s) } if s := SpecForPath("/x.RS"); s == nil || s.Name != "rust-analyzer" { t.Fatalf("uppercase rust path: %v", s) } } // TestLanguageIDForPath checks per-extension languageId resolution. func TestLanguageIDForPath(t *testing.T) { cases := map[string]string{ "app.tsx": "typescriptreact", "main.ts": "typescript", "a.jsx": "javascriptreact", "foo.py": "python", "pkg/x.rs": "rust", "a.cpp": "cpp", "a.h": "c", // mapping wins } for path, want := range cases { got := LanguageIDForPath(path) if got != want { t.Errorf("path %q: got %q, want %q", path, got, want) } } if LanguageIDForPath("README.md") != "" { t.Fatal("expected empty languageId for unknown ext") } } // TestSpecByName ensures every entry in Servers is reachable by name. func TestSpecByName(t *testing.T) { for _, want := range Servers { got := SpecByName(want.Name) if got == nil { t.Errorf("SpecByName(%q) = nil", want.Name) continue } if got.Name != want.Name { t.Errorf("SpecByName(%q).Name = %q", want.Name, got.Name) } } if SpecByName("nope") != nil { t.Fatal("unknown name should return nil") } } // TestRegistryContributesDefaultProviders proves that initialisation // adds an entry per spec to the semantic.DefaultConfig provider list. func TestRegistryContributesDefaultProviders(t *testing.T) { cfg := semantic.DefaultConfig() names := make(map[string]bool, len(cfg.Providers)) for _, p := range cfg.Providers { names[p.Name] = true } for _, s := range Servers { if !names[s.Name] { t.Errorf("DefaultConfig missing %q", s.Name) } } } // TestExtensionsCoverEverySpec asserts every spec lists at least one // extension — a spec with no extensions can't be routed to. func TestExtensionsCoverEverySpec(t *testing.T) { for _, s := range Servers { if len(s.Extensions) == 0 { t.Errorf("spec %q has no extensions", s.Name) } for _, e := range s.Extensions { if !strings.HasPrefix(e, ".") { t.Errorf("spec %q ext %q must start with '.'", s.Name, e) } } } } // TestPyreflyAndTsgoSpecs verifies the pyrefly and tsgo LSP servers are // registered as first-class specs without disturbing the default // Python / TypeScript routing. func TestPyreflyAndTsgoSpecs(t *testing.T) { pyrefly := SpecByName("pyrefly") if pyrefly == nil { t.Fatal("pyrefly spec not registered") } if pyrefly.Command != "pyrefly" || len(pyrefly.Args) == 0 { t.Errorf("pyrefly invocation: %s %v", pyrefly.Command, pyrefly.Args) } if len(pyrefly.Languages) == 0 || pyrefly.Languages[0] != "python" { t.Errorf("pyrefly should cover python, got %v", pyrefly.Languages) } tsgo := SpecByName("tsgo") if tsgo == nil { t.Fatal("tsgo spec not registered") } if tsgo.Command != "tsgo" || len(tsgo.Args) == 0 { t.Errorf("tsgo invocation: %s %v", tsgo.Command, tsgo.Args) } // Default extension routing must be unchanged — the incumbents // register earlier in Servers and keep .py / .ts. if s := SpecForExtension(".py"); s == nil || s.Name != "pyright" { t.Errorf(".py should still route to pyright, got %v", s) } if s := SpecForExtension(".ts"); s == nil || s.Name != "typescript-language-server" { t.Errorf(".ts should still route to typescript-language-server, got %v", s) } // Both must be contributed to the default provider list. cfg := semantic.DefaultConfig() have := map[string]bool{} for _, p := range cfg.Providers { have[p.Name] = true } if !have["pyrefly"] || !have["tsgo"] { t.Errorf("pyrefly/tsgo missing from DefaultConfig providers") } } // TestSpecWithOverrides verifies .gortex.yaml command / args / env // overrides are applied to a copy without mutating the built-in spec — // the path by which jdtls gets a pinned JRE and launcher args. func TestSpecWithOverrides(t *testing.T) { if SpecWithOverrides(nil, "x", nil, nil) != nil { t.Error("nil base must return nil") } base := SpecByName("jdtls") if base == nil { t.Fatal("jdtls spec missing") } // No overrides → a copy with inherited values. cp := SpecWithOverrides(base, "", nil, nil) if cp == base { t.Error("must return a copy, not the same pointer") } if cp.Command != base.Command { t.Errorf("command should be inherited, got %q", cp.Command) } // Overrides applied. cp = SpecWithOverrides(base, "/opt/jdtls/bin/jdtls", []string{"--jvm-arg=-Xmx4G"}, []string{"JAVA_HOME=/opt/jdk21"}) if cp.Command != "/opt/jdtls/bin/jdtls" { t.Errorf("command override not applied: %q", cp.Command) } if len(cp.Args) != 1 || cp.Args[0] != "--jvm-arg=-Xmx4G" { t.Errorf("args override not applied: %v", cp.Args) } if len(cp.Env) != 1 || cp.Env[0] != "JAVA_HOME=/opt/jdk21" { t.Errorf("env override not applied: %v", cp.Env) } // The built-in spec must stay pristine. if len(base.Env) != 0 || base.Command != "jdtls" { t.Errorf("base spec mutated: command=%q env=%v", base.Command, base.Env) } } // TestClangdDisablesClangTidy pins that the clangd enrichment spec turns off // clang-tidy: Gortex consumes semantic graph signal, not lint diagnostics, and // a broad repo .clang-tidy can crash clangd into a reconnect/high-CPU loop. func TestClangdDisablesClangTidy(t *testing.T) { spec := SpecByName("clangd") if spec == nil { t.Fatal("clangd spec missing") } joined := strings.Join(spec.Args, " ") if !strings.Contains(joined, "--clang-tidy=false") { t.Errorf("clangd args must disable clang-tidy, got %v", spec.Args) } // Cross-file references still need the background index — it must NOT be // disabled. if strings.Contains(joined, "--background-index=false") { t.Errorf("clangd must keep --background-index for cross-file references, got %v", spec.Args) } }