Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
},
Expand Down Expand Up @@ -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**.
Expand Down Expand Up @@ -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.

Expand Down
26 changes: 20 additions & 6 deletions internal/lsp/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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),
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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)
}
Expand All @@ -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 {
Expand Down
78 changes: 78 additions & 0 deletions internal/lsp/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down
Loading