From ef4e0ffa28bac8d6cd83fd780ce9c269509f43d5 Mon Sep 17 00:00:00 2001 From: Nick C Date: Thu, 16 Apr 2026 11:07:28 +0200 Subject: [PATCH] Add definitionStyle option to control multi-head goto definition When a function has multiple heads/clauses, editors like Zed show a picker UI instead of jumping directly. The new "definitionStyle" initializationOption ("all" or "first") lets users choose whether to return all definition sites or just the first one. --- README.md | 17 ++++++++ internal/lsp/server.go | 26 ++++++++++--- internal/lsp/server_test.go | 78 +++++++++++++++++++++++++++++++++++++ 3 files changed, 115 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 0eb3925..c1bfbee 100644 --- a/README.md +++ b/README.md @@ -149,6 +149,7 @@ vim.lsp.config('dexter', { filetypes = { 'elixir', 'eelixir', 'heex' }, init_options = { followDelegates = true, -- jump through defdelegate to the target function + -- definitionStyle = "all", -- "all" returns all function heads; "first" jumps to the first one -- stdlibPath = "", -- override Elixir stdlib path (auto-detected) -- debug = false, -- verbose logging to stderr (view with :LspLog) }, @@ -233,6 +234,21 @@ To override the binary path manually, add this to your `settings.json`: } ``` +To configure LSP options (see [LSP options](#lsp-options)): + +```json +{ + "lsp": { + "dexter": { + "initialization_options": { + "followDelegates": true, + "definitionStyle": "first" + } + } + } +} +``` + ### Emacs The emacs instructions assume you're using **use-package**. @@ -478,6 +494,7 @@ If the persistent process can't start, dexter falls back to running `mix format` Dexter reads `initializationOptions` from your editor configuration: - **`followDelegates`** (boolean, default: `true`): follow `defdelegate` targets on lookup. +- **`definitionStyle`** (string, default: `"all"`): controls how many locations are returned when a function has multiple heads (clauses). `"all"` returns every definition site; `"first"` returns only the first one, which makes editors like Zed jump directly instead of showing a picker. - **`stdlibPath`** (string): override the Elixir stdlib directory to index. Defaults to auto-detection; use this if your install is non-standard. - **`debug`** (boolean, default: `false`): enable verbose logging to stderr. Logs timing and resolution details for every definition, hover, references, and rename request. Can also be enabled via the `DEXTER_DEBUG=true` environment variable. diff --git a/internal/lsp/server.go b/internal/lsp/server.go index 72c78de..8d15d72 100644 --- a/internal/lsp/server.go +++ b/internal/lsp/server.go @@ -61,6 +61,7 @@ type Server struct { client protocol.Client followDelegates bool debug bool + definitionStyle string // "all" (default) or "first": controls multi-head definition results mixBin string // resolved path to the mix binary formatters map[string]*formatterProcess // formatterExs path → persistent formatter @@ -102,6 +103,7 @@ func NewServer(s *store.Store, projectRoot string) *Server { projectRoot: projectRoot, explicitRoot: projectRoot != "", followDelegates: true, + definitionStyle: "all", usingCache: make(map[string]*usingCacheEntry), depsCache: make(map[string]bool), } @@ -298,6 +300,11 @@ func (s *Server) Initialize(ctx context.Context, params *protocol.InitializePara if v, ok := opts["debug"].(bool); ok { s.debug = v } + if v, ok := opts["definitionStyle"].(string); ok { + if v == "all" || v == "first" { + s.definitionStyle = v + } + } } if os.Getenv("DEXTER_DEBUG") == "true" { s.debug = true @@ -603,20 +610,20 @@ func (s *Server) Definition(ctx context.Context, params *protocol.DefinitionPara } if err == nil && len(results) > 0 { s.debugf("Definition: found %d result(s) in store for %s.%s", len(results), fullModule, functionName) - return storeResultsToLocations(filterOutTypes(results)), nil + return s.applyDefinitionStyle(storeResultsToLocations(filterOutTypes(results))), nil } // fullModule may not directly define the function — try its use chain // (e.g. `import MyApp.Factory` where MyApp.Factory uses ExMachina). if results := s.lookupThroughUseOf(fullModule, functionName); len(results) > 0 { s.debugf("Definition: found %d result(s) via use chain of %s for %s", len(results), fullModule, functionName) - return storeResultsToLocations(filterOutTypes(results)), nil + return s.applyDefinitionStyle(storeResultsToLocations(filterOutTypes(results))), nil } // Fallback for use-chain inline defs (not stored as module definitions) if results := s.lookupThroughUse(text, functionName, aliases); len(results) > 0 { s.debugf("Definition: found %d result(s) via current file use chain for %s", len(results), functionName) - return storeResultsToLocations(filterOutTypes(results)), nil + return s.applyDefinitionStyle(storeResultsToLocations(filterOutTypes(results))), nil } s.debugf("Definition: no result found for bare function %q in module %q", functionName, fullModule) @@ -638,13 +645,13 @@ func (s *Server) Definition(ctx context.Context, params *protocol.DefinitionPara } if err == nil && len(results) > 0 { s.debugf("Definition: found %d result(s) in store for %s.%s", len(results), fullModule, functionName) - return storeResultsToLocations(filterOutTypes(results)), nil + return s.applyDefinitionStyle(storeResultsToLocations(filterOutTypes(results))), nil } // Not directly defined — the function may have been injected by a // `use` macro in fullModule's source (e.g. Oban.Worker injects `new`). if results := s.lookupThroughUseOf(fullModule, functionName); len(results) > 0 { s.debugf("Definition: found %d result(s) via use chain of %s for %s", len(results), fullModule, functionName) - return storeResultsToLocations(results), nil + return s.applyDefinitionStyle(storeResultsToLocations(results)), nil } s.debugf("Definition: no result for %s.%s", fullModule, functionName) } @@ -654,7 +661,14 @@ func (s *Server) Definition(ctx context.Context, params *protocol.DefinitionPara if err != nil || len(results) == 0 { return nil, nil } - return storeResultsToLocations(results), nil + return s.applyDefinitionStyle(storeResultsToLocations(results)), nil +} + +func (s *Server) applyDefinitionStyle(locations []protocol.Location) []protocol.Location { + if s.definitionStyle == "first" && len(locations) > 1 { + return locations[:1] + } + return locations } func storeResultsToLocations(results []store.LookupResult) []protocol.Location { diff --git a/internal/lsp/server_test.go b/internal/lsp/server_test.go index 89ccfb3..ad80145 100644 --- a/internal/lsp/server_test.go +++ b/internal/lsp/server_test.go @@ -253,6 +253,84 @@ func TestServer_InitializationOptions_FollowDelegates(t *testing.T) { } } +func TestServer_InitializationOptions_DefinitionStyle(t *testing.T) { + server, cleanup := setupTestServer(t) + defer cleanup() + + // Default should be "all" + if server.definitionStyle != "all" { + t.Errorf("definitionStyle should default to %q, got %q", "all", server.definitionStyle) + } + + // Simulate initializationOptions with definitionStyle="first" + opts := map[string]interface{}{ + "definitionStyle": "first", + } + if v, ok := opts["definitionStyle"].(string); ok { + if v == "all" || v == "first" { + server.definitionStyle = v + } + } + + if server.definitionStyle != "first" { + t.Errorf("definitionStyle should be %q after setting, got %q", "first", server.definitionStyle) + } + + // Invalid value should not change the setting + server.definitionStyle = "all" + opts = map[string]interface{}{ + "definitionStyle": "bogus", + } + if v, ok := opts["definitionStyle"].(string); ok { + if v == "all" || v == "first" { + server.definitionStyle = v + } + } + + if server.definitionStyle != "all" { + t.Errorf("definitionStyle should remain %q for invalid value, got %q", "all", server.definitionStyle) + } +} + +func TestServer_ApplyDefinitionStyle(t *testing.T) { + server, cleanup := setupTestServer(t) + defer cleanup() + + locs := []protocol.Location{ + {URI: "file:///a.ex", Range: lineRange(0)}, + {URI: "file:///a.ex", Range: lineRange(5)}, + {URI: "file:///a.ex", Range: lineRange(9)}, + } + + // Default "all" returns everything + got := server.applyDefinitionStyle(locs) + if len(got) != 3 { + t.Errorf("expected 3 locations with style %q, got %d", "all", len(got)) + } + + // "first" returns only the first + server.definitionStyle = "first" + got = server.applyDefinitionStyle(locs) + if len(got) != 1 { + t.Errorf("expected 1 location with style %q, got %d", "first", len(got)) + } + if got[0].Range.Start.Line != 0 { + t.Errorf("expected first location (line 0), got line %d", got[0].Range.Start.Line) + } + + // Single location is unaffected by "first" + got = server.applyDefinitionStyle(locs[:1]) + if len(got) != 1 { + t.Errorf("expected 1 location with style %q and single input, got %d", "first", len(got)) + } + + // Empty slice is unaffected + got = server.applyDefinitionStyle(nil) + if len(got) != 0 { + t.Errorf("expected 0 locations for nil input, got %d", len(got)) + } +} + func definitionAt(t *testing.T, server *Server, uri string, line, col uint32) []protocol.Location { t.Helper() result, err := server.Definition(context.Background(), &protocol.DefinitionParams{