Skip to content

Commit 9e448a6

Browse files
committed
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.
1 parent 5f1e468 commit 9e448a6

3 files changed

Lines changed: 115 additions & 6 deletions

File tree

README.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,7 @@ vim.lsp.config('dexter', {
149149
filetypes = { 'elixir', 'eelixir', 'heex' },
150150
init_options = {
151151
followDelegates = true, -- jump through defdelegate to the target function
152+
-- definitionStyle = "all", -- "all" returns all function heads; "first" jumps to the first one
152153
-- stdlibPath = "", -- override Elixir stdlib path (auto-detected)
153154
-- debug = false, -- verbose logging to stderr (view with :LspLog)
154155
},
@@ -233,6 +234,21 @@ To override the binary path manually, add this to your `settings.json`:
233234
}
234235
```
235236

237+
To configure LSP options (see [LSP options](#lsp-options)):
238+
239+
```json
240+
{
241+
"lsp": {
242+
"dexter": {
243+
"initialization_options": {
244+
"followDelegates": true,
245+
"definitionStyle": "first"
246+
}
247+
}
248+
}
249+
}
250+
```
251+
236252
### Emacs
237253

238254
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`
478494
Dexter reads `initializationOptions` from your editor configuration:
479495

480496
- **`followDelegates`** (boolean, default: `true`): follow `defdelegate` targets on lookup.
497+
- **`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.
481498
- **`stdlibPath`** (string): override the Elixir stdlib directory to index. Defaults to auto-detection; use this if your install is non-standard.
482499
- **`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.
483500

internal/lsp/server.go

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ type Server struct {
6161
client protocol.Client
6262
followDelegates bool
6363
debug bool
64+
definitionStyle string // "all" (default) or "first": controls multi-head definition results
6465
mixBin string // resolved path to the mix binary
6566

6667
formatters map[string]*formatterProcess // formatterExs path → persistent formatter
@@ -102,6 +103,7 @@ func NewServer(s *store.Store, projectRoot string) *Server {
102103
projectRoot: projectRoot,
103104
explicitRoot: projectRoot != "",
104105
followDelegates: true,
106+
definitionStyle: "all",
105107
usingCache: make(map[string]*usingCacheEntry),
106108
depsCache: make(map[string]bool),
107109
}
@@ -298,6 +300,11 @@ func (s *Server) Initialize(ctx context.Context, params *protocol.InitializePara
298300
if v, ok := opts["debug"].(bool); ok {
299301
s.debug = v
300302
}
303+
if v, ok := opts["definitionStyle"].(string); ok {
304+
if v == "all" || v == "first" {
305+
s.definitionStyle = v
306+
}
307+
}
301308
}
302309
if os.Getenv("DEXTER_DEBUG") == "true" {
303310
s.debug = true
@@ -606,20 +613,20 @@ func (s *Server) Definition(ctx context.Context, params *protocol.DefinitionPara
606613
}
607614
if err == nil && len(results) > 0 {
608615
s.debugf("Definition: found %d result(s) in store for %s.%s", len(results), fullModule, functionName)
609-
return storeResultsToLocations(filterOutTypes(results)), nil
616+
return s.applyDefinitionStyle(storeResultsToLocations(filterOutTypes(results))), nil
610617
}
611618

612619
// fullModule may not directly define the function — try its use chain
613620
// (e.g. `import MyApp.Factory` where MyApp.Factory uses ExMachina).
614621
if results := s.lookupThroughUseOf(fullModule, functionName); len(results) > 0 {
615622
s.debugf("Definition: found %d result(s) via use chain of %s for %s", len(results), fullModule, functionName)
616-
return storeResultsToLocations(filterOutTypes(results)), nil
623+
return s.applyDefinitionStyle(storeResultsToLocations(filterOutTypes(results))), nil
617624
}
618625

619626
// Fallback for use-chain inline defs (not stored as module definitions)
620627
if results := s.lookupThroughUse(text, functionName, aliases); len(results) > 0 {
621628
s.debugf("Definition: found %d result(s) via current file use chain for %s", len(results), functionName)
622-
return storeResultsToLocations(filterOutTypes(results)), nil
629+
return s.applyDefinitionStyle(storeResultsToLocations(filterOutTypes(results))), nil
623630
}
624631

625632
s.debugf("Definition: no result found for bare function %q in module %q", functionName, fullModule)
@@ -641,13 +648,13 @@ func (s *Server) Definition(ctx context.Context, params *protocol.DefinitionPara
641648
}
642649
if err == nil && len(results) > 0 {
643650
s.debugf("Definition: found %d result(s) in store for %s.%s", len(results), fullModule, functionName)
644-
return storeResultsToLocations(filterOutTypes(results)), nil
651+
return s.applyDefinitionStyle(storeResultsToLocations(filterOutTypes(results))), nil
645652
}
646653
// Not directly defined — the function may have been injected by a
647654
// `use` macro in fullModule's source (e.g. Oban.Worker injects `new`).
648655
if results := s.lookupThroughUseOf(fullModule, functionName); len(results) > 0 {
649656
s.debugf("Definition: found %d result(s) via use chain of %s for %s", len(results), fullModule, functionName)
650-
return storeResultsToLocations(results), nil
657+
return s.applyDefinitionStyle(storeResultsToLocations(results)), nil
651658
}
652659
s.debugf("Definition: no result for %s.%s", fullModule, functionName)
653660
}
@@ -657,7 +664,14 @@ func (s *Server) Definition(ctx context.Context, params *protocol.DefinitionPara
657664
if err != nil || len(results) == 0 {
658665
return nil, nil
659666
}
660-
return storeResultsToLocations(results), nil
667+
return s.applyDefinitionStyle(storeResultsToLocations(results)), nil
668+
}
669+
670+
func (s *Server) applyDefinitionStyle(locations []protocol.Location) []protocol.Location {
671+
if s.definitionStyle == "first" && len(locations) > 1 {
672+
return locations[:1]
673+
}
674+
return locations
661675
}
662676

663677
func storeResultsToLocations(results []store.LookupResult) []protocol.Location {

internal/lsp/server_test.go

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -253,6 +253,84 @@ func TestServer_InitializationOptions_FollowDelegates(t *testing.T) {
253253
}
254254
}
255255

256+
func TestServer_InitializationOptions_DefinitionStyle(t *testing.T) {
257+
server, cleanup := setupTestServer(t)
258+
defer cleanup()
259+
260+
// Default should be "all"
261+
if server.definitionStyle != "all" {
262+
t.Errorf("definitionStyle should default to %q, got %q", "all", server.definitionStyle)
263+
}
264+
265+
// Simulate initializationOptions with definitionStyle="first"
266+
opts := map[string]interface{}{
267+
"definitionStyle": "first",
268+
}
269+
if v, ok := opts["definitionStyle"].(string); ok {
270+
if v == "all" || v == "first" {
271+
server.definitionStyle = v
272+
}
273+
}
274+
275+
if server.definitionStyle != "first" {
276+
t.Errorf("definitionStyle should be %q after setting, got %q", "first", server.definitionStyle)
277+
}
278+
279+
// Invalid value should not change the setting
280+
server.definitionStyle = "all"
281+
opts = map[string]interface{}{
282+
"definitionStyle": "bogus",
283+
}
284+
if v, ok := opts["definitionStyle"].(string); ok {
285+
if v == "all" || v == "first" {
286+
server.definitionStyle = v
287+
}
288+
}
289+
290+
if server.definitionStyle != "all" {
291+
t.Errorf("definitionStyle should remain %q for invalid value, got %q", "all", server.definitionStyle)
292+
}
293+
}
294+
295+
func TestServer_ApplyDefinitionStyle(t *testing.T) {
296+
server, cleanup := setupTestServer(t)
297+
defer cleanup()
298+
299+
locs := []protocol.Location{
300+
{URI: "file:///a.ex", Range: lineRange(0)},
301+
{URI: "file:///a.ex", Range: lineRange(5)},
302+
{URI: "file:///a.ex", Range: lineRange(9)},
303+
}
304+
305+
// Default "all" returns everything
306+
got := server.applyDefinitionStyle(locs)
307+
if len(got) != 3 {
308+
t.Errorf("expected 3 locations with style %q, got %d", "all", len(got))
309+
}
310+
311+
// "first" returns only the first
312+
server.definitionStyle = "first"
313+
got = server.applyDefinitionStyle(locs)
314+
if len(got) != 1 {
315+
t.Errorf("expected 1 location with style %q, got %d", "first", len(got))
316+
}
317+
if got[0].Range.Start.Line != 0 {
318+
t.Errorf("expected first location (line 0), got line %d", got[0].Range.Start.Line)
319+
}
320+
321+
// Single location is unaffected by "first"
322+
got = server.applyDefinitionStyle(locs[:1])
323+
if len(got) != 1 {
324+
t.Errorf("expected 1 location with style %q and single input, got %d", "first", len(got))
325+
}
326+
327+
// Empty slice is unaffected
328+
got = server.applyDefinitionStyle(nil)
329+
if len(got) != 0 {
330+
t.Errorf("expected 0 locations for nil input, got %d", len(got))
331+
}
332+
}
333+
256334
func definitionAt(t *testing.T, server *Server, uri string, line, col uint32) []protocol.Location {
257335
t.Helper()
258336
result, err := server.Definition(context.Background(), &protocol.DefinitionParams{

0 commit comments

Comments
 (0)