From 6c776d000e4070deaaded04ae09445376241a18f Mon Sep 17 00:00:00 2001 From: Paolo Quadri Date: Tue, 5 May 2026 16:51:52 +0200 Subject: [PATCH 1/5] docs: add Claude Code LSP setup guide Explains how to wire up Dexter as the Elixir LSP for Claude Code's LSP tool (go-to-definition, hover docs, find references) using a local plugin manifest. --- README.md | 64 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/README.md b/README.md index f7a844a..2c027d7 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,69 @@ 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" + } +} +``` + +**1. Create the plugin manifest:** + +```sh +mkdir -p ~/.claude/plugins/local/.claude-plugin +mkdir -p ~/.claude/plugins/local/plugins/dexter-lsp +``` + +Create `~/.claude/plugins/local/.claude-plugin/marketplace.json`. If the file already exists, add the `dexter-lsp` entry to the `plugins` array. + +```json +{ + "$schema": "https://claude.ai/schemas/marketplace.json", + "name": "local", + "description": "Local plugins", + "owner": { "name": "local" }, + "plugins": [ + { + "name": "dexter-lsp", + "description": "Dexter language server for Elixir (.ex, .exs, .heex)", + "version": "1.0.0", + "author": { "name": "local" }, + "source": "./plugins/dexter-lsp", + "category": "development", + "strict": false, + "lspServers": { + "dexter": { + "command": "dexter", + "args": ["lsp"], + "extensionToLanguage": { + ".ex": "elixir", + ".exs": "elixir", + ".heex": "phoenix-heex" + } + } + } + } + ] +} +``` + +**2. Register the marketplace and install:** + +```sh +claude plugin marketplace add ~/.claude/plugins/local +claude plugin install dexter-lsp@local +``` + +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. From 8fb8fa271b5e8f7632a1266a6cb891aa2ff6de93 Mon Sep 17 00:00:00 2001 From: Paolo Quadri Date: Tue, 5 May 2026 20:28:57 +0200 Subject: [PATCH 2/5] feat: add Claude Code marketplace and dexter-lsp plugin Turns the dexter repo into a Claude Code marketplace so users can install Dexter as their Elixir LSP with two commands: claude plugin marketplace add remoteoss/dexter claude plugin install dexter-lsp@dexter The plugin wires up `dexter lsp` for .ex/.exs/.heex files and exposes followDelegates and debug as user-configurable options. --- .claude-plugin/marketplace.json | 14 ++++++ README.md | 46 ++----------------- plugins/dexter-lsp/.claude-plugin/plugin.json | 44 ++++++++++++++++++ 3 files changed, 61 insertions(+), 43 deletions(-) create mode 100644 .claude-plugin/marketplace.json create mode 100644 plugins/dexter-lsp/.claude-plugin/plugin.json 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 2c027d7..fda9090 100644 --- a/README.md +++ b/README.md @@ -334,51 +334,11 @@ language-servers = ["dexter"] } ``` -**1. Create the plugin manifest:** +**Install:** ```sh -mkdir -p ~/.claude/plugins/local/.claude-plugin -mkdir -p ~/.claude/plugins/local/plugins/dexter-lsp -``` - -Create `~/.claude/plugins/local/.claude-plugin/marketplace.json`. If the file already exists, add the `dexter-lsp` entry to the `plugins` array. - -```json -{ - "$schema": "https://claude.ai/schemas/marketplace.json", - "name": "local", - "description": "Local plugins", - "owner": { "name": "local" }, - "plugins": [ - { - "name": "dexter-lsp", - "description": "Dexter language server for Elixir (.ex, .exs, .heex)", - "version": "1.0.0", - "author": { "name": "local" }, - "source": "./plugins/dexter-lsp", - "category": "development", - "strict": false, - "lspServers": { - "dexter": { - "command": "dexter", - "args": ["lsp"], - "extensionToLanguage": { - ".ex": "elixir", - ".exs": "elixir", - ".heex": "phoenix-heex" - } - } - } - } - ] -} -``` - -**2. Register the marketplace and install:** - -```sh -claude plugin marketplace add ~/.claude/plugins/local -claude plugin install dexter-lsp@local +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. diff --git a/plugins/dexter-lsp/.claude-plugin/plugin.json b/plugins/dexter-lsp/.claude-plugin/plugin.json new file mode 100644 index 0000000..4f3fd75 --- /dev/null +++ b/plugins/dexter-lsp/.claude-plugin/plugin.json @@ -0,0 +1,44 @@ +{ + "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", + "name": "dexter-lsp", + "description": "Dexter language server for Elixir (.ex, .exs, .heex)", + "version": "0.6.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" + }, + "restartOnCrash": true, + "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 + } + } +} From 6b3c52ff0f5a5a88dfeb0eb3d3bf5842f7080265 Mon Sep 17 00:00:00 2001 From: Paolo Quadri Date: Tue, 5 May 2026 20:35:42 +0200 Subject: [PATCH 3/5] =?UTF-8?q?fix:=20remove=20restartOnCrash=20=E2=80=94?= =?UTF-8?q?=20not=20yet=20implemented=20in=20Claude=20Code=20runtime?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- plugins/dexter-lsp/.claude-plugin/plugin.json | 1 - 1 file changed, 1 deletion(-) diff --git a/plugins/dexter-lsp/.claude-plugin/plugin.json b/plugins/dexter-lsp/.claude-plugin/plugin.json index 4f3fd75..4e33d7b 100644 --- a/plugins/dexter-lsp/.claude-plugin/plugin.json +++ b/plugins/dexter-lsp/.claude-plugin/plugin.json @@ -20,7 +20,6 @@ ".exs": "elixir", ".heex": "phoenix-heex" }, - "restartOnCrash": true, "initializationOptions": { "followDelegates": "${user_config.follow_delegates}", "debug": "${user_config.debug}" From 305f540c2e4ce780a8ec76dfd7b720d0d417ebb7 Mon Sep 17 00:00:00 2001 From: Paolo Quadri Date: Wed, 6 May 2026 09:43:21 +0200 Subject: [PATCH 4/5] fix: set plugin version to 0.1.0 --- plugins/dexter-lsp/.claude-plugin/plugin.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/dexter-lsp/.claude-plugin/plugin.json b/plugins/dexter-lsp/.claude-plugin/plugin.json index 4e33d7b..91b85ea 100644 --- a/plugins/dexter-lsp/.claude-plugin/plugin.json +++ b/plugins/dexter-lsp/.claude-plugin/plugin.json @@ -2,7 +2,7 @@ "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "dexter-lsp", "description": "Dexter language server for Elixir (.ex, .exs, .heex)", - "version": "0.6.0", + "version": "0.1.0", "author": { "name": "remoteoss", "url": "https://github.com/remoteoss" From 06e95239e06d6de38a5d3968f607b3d9e2d7420b Mon Sep 17 00:00:00 2001 From: Paolo Quadri Date: Wed, 6 May 2026 15:06:59 +0200 Subject: [PATCH 5/5] fix: accept string-encoded booleans in LSP initializationOptions Claude Code plugin template substitution (${user_config.X}) always produces a JSON string even when the userConfig type is "boolean". The strict `.(bool)` type assertions silently dropped the user's followDelegates/debug settings, leaving them stuck at hardcoded defaults. Add a coerceBool helper that accepts both native bool and string forms ("true"/"false"/"1"/"0"). Expand the initializationOptions test to call server.Initialize and cover both forms. --- internal/lsp/server.go | 24 ++++++++++++-- internal/lsp/server_test.go | 63 ++++++++++++++++++++++++++----------- 2 files changed, 67 insertions(+), 20 deletions(-) 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) + } + }) } }