diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json new file mode 100644 index 0000000..928b5ae --- /dev/null +++ b/.claude-plugin/marketplace.json @@ -0,0 +1,14 @@ +{ + "name": "dexter", + "owner": { + "name": "remoteoss", + "url": "https://github.com/remoteoss" + }, + "plugins": [ + { + "name": "dexter-lsp", + "source": "./plugins/dexter-lsp", + "description": "Dexter language server for Elixir (.ex, .exs, .heex)" + } + ] +} diff --git a/README.md b/README.md index f7a844a..fda9090 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ A fast, full-featured Elixir LSP optimized for large Elixir codebases. - [Configuring format on save](#configuring-format-on-save) - [Neovim (with nvim-lspconfig — \< 0.11)](#neovim-with-nvim-lspconfig---011) - [Zed](#zed) + - [Claude Code](#claude-code) - [Emacs](#emacs) - [Eglot](#eglot) - [Emacs version \>= 30](#emacs-version--30) @@ -319,6 +320,29 @@ name = "elixir" language-servers = ["dexter"] ``` +### Claude Code + +[Claude Code](https://claude.ai/code) supports Dexter as an Elixir LSP via its plugin system, enabling go-to-definition, hover docs, find references, and more when working on `.ex`, `.exs`, and `.heex` files. + +**Prerequisites:** The LSP tool must be enabled. Add this to your `~/.claude/settings.json`: + +```json +{ + "env": { + "ENABLE_LSP_TOOL": "1" + } +} +``` + +**Install:** + +```sh +claude plugin marketplace add remoteoss/dexter +claude plugin install dexter-lsp@dexter +``` + +That's it. Open an Elixir project in Claude Code and Dexter will handle go-to-definition, hover documentation, find references, and more. + ## Why build another LSP? Remote has one of the largest Elixir codebases in existence (at least that we're aware of), now around 57k files. As our codebase has grown, we've had more and more struggles with language servers. We had found that they simply couldn't keep up with such a large codebase. On large codebases like ours, existing LSPs take hours to index, and even after indexing, operations like go-to-definition and go-to-references are still slow. On top of that, changing branches means a whole new round of indexing. The result has been frustration. Many of us on the engineering team had all but given up on the idea of ever having a working LSP. diff --git a/internal/lsp/server.go b/internal/lsp/server.go index 4f1d83e..d9d44a9 100644 --- a/internal/lsp/server.go +++ b/internal/lsp/server.go @@ -294,6 +294,26 @@ func (s *Server) notifyOTPMismatch(stderr string) { // === LSP Lifecycle === +// coerceBool accepts a JSON bool or a JSON string ("true"/"false"/"1"/"0"…). +// Claude Code plugin template substitution (e.g. "${user_config.debug}") produces +// a string, not a bool, so we accept both forms. +func coerceBool(v interface{}) (bool, bool) { + switch x := v.(type) { + case bool: + return x, true + case string: + if x == "" { + return false, false + } + b, err := strconv.ParseBool(x) + if err != nil { + return false, false + } + return b, true + } + return false, false +} + func (s *Server) Initialize(ctx context.Context, params *protocol.InitializeParams) (*protocol.InitializeResult, error) { // Note: unlike cmd/main.go, the LSP deliberately does NOT pass "mix.exs" // to store.FindProjectRoot. In a monorepo we want to anchor on @@ -315,13 +335,13 @@ func (s *Server) Initialize(ctx context.Context, params *protocol.InitializePara var explicitStdlibPath string if opts, ok := params.InitializationOptions.(map[string]interface{}); ok { - if v, ok := opts["followDelegates"].(bool); ok { + if v, ok := coerceBool(opts["followDelegates"]); ok { s.followDelegates = v } if v, ok := opts["stdlibPath"].(string); ok { explicitStdlibPath = v } - if v, ok := opts["debug"].(bool); ok { + if v, ok := coerceBool(opts["debug"]); ok { s.debug = v } } diff --git a/internal/lsp/server_test.go b/internal/lsp/server_test.go index 48eced8..ea6c279 100644 --- a/internal/lsp/server_test.go +++ b/internal/lsp/server_test.go @@ -231,25 +231,52 @@ end`) }) } -func TestServer_InitializationOptions_FollowDelegates(t *testing.T) { - server, cleanup := setupTestServer(t) - defer cleanup() - - // Default should be true - if !server.followDelegates { - t.Error("followDelegates should default to true") - } - - // Simulate initializationOptions with followDelegates=false - opts := map[string]interface{}{ - "followDelegates": false, - } - if v, ok := opts["followDelegates"].(bool); ok { - server.followDelegates = v - } +func TestServer_InitializationOptions(t *testing.T) { + t.Run("defaults", func(t *testing.T) { + server, cleanup := setupTestServer(t) + defer cleanup() + if !server.followDelegates { + t.Error("followDelegates should default to true") + } + if server.debug { + t.Error("debug should default to false") + } + }) - if server.followDelegates { - t.Error("followDelegates should be false after setting via initializationOptions") + // Claude Code plugin template substitution yields strings, not booleans. + // Verify coerceBool handles both native bools and string-encoded bools. + cases := []struct { + name string + opts map[string]interface{} + wantFollowDel bool + wantDebug bool + }{ + {"bool true/false", map[string]interface{}{"followDelegates": false, "debug": true}, false, true}, + {"string true/false", map[string]interface{}{"followDelegates": "false", "debug": "true"}, false, true}, + {"string 1/0", map[string]interface{}{"followDelegates": "0", "debug": "1"}, false, true}, + {"empty string leaves default", map[string]interface{}{"followDelegates": "", "debug": ""}, true, false}, + {"unsupported type leaves default", map[string]interface{}{"followDelegates": 1, "debug": 0}, true, false}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + server, cleanup := setupTestServer(t) + defer cleanup() + dir := t.TempDir() + _, err := server.Initialize(context.Background(), &protocol.InitializeParams{ + RootURI: protocol.DocumentURI("file://" + dir), + InitializationOptions: tc.opts, + }) + if err != nil { + t.Fatalf("Initialize returned error: %v", err) + } + if server.followDelegates != tc.wantFollowDel { + t.Errorf("followDelegates: got %v, want %v", server.followDelegates, tc.wantFollowDel) + } + if server.debug != tc.wantDebug { + t.Errorf("debug: got %v, want %v", server.debug, tc.wantDebug) + } + }) } } diff --git a/plugins/dexter-lsp/.claude-plugin/plugin.json b/plugins/dexter-lsp/.claude-plugin/plugin.json new file mode 100644 index 0000000..91b85ea --- /dev/null +++ b/plugins/dexter-lsp/.claude-plugin/plugin.json @@ -0,0 +1,43 @@ +{ + "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", + "name": "dexter-lsp", + "description": "Dexter language server for Elixir (.ex, .exs, .heex)", + "version": "0.1.0", + "author": { + "name": "remoteoss", + "url": "https://github.com/remoteoss" + }, + "homepage": "https://github.com/remoteoss/dexter", + "repository": "https://github.com/remoteoss/dexter", + "license": "MIT", + "keywords": ["elixir", "lsp", "dexter", "phoenix", "heex"], + "lspServers": { + "dexter": { + "command": "dexter", + "args": ["lsp"], + "extensionToLanguage": { + ".ex": "elixir", + ".exs": "elixir", + ".heex": "phoenix-heex" + }, + "initializationOptions": { + "followDelegates": "${user_config.follow_delegates}", + "debug": "${user_config.debug}" + } + } + }, + "userConfig": { + "follow_delegates": { + "type": "boolean", + "title": "Follow delegates", + "description": "Jump through defdelegate to the target function definition", + "default": true + }, + "debug": { + "type": "boolean", + "title": "Debug logging", + "description": "Enable verbose LSP logging to stderr", + "default": false + } + } +}