From 9e448a6fdd5b457fd3f64c553de84e56c2fec93e Mon Sep 17 00:00:00 2001 From: Nick C Date: Thu, 16 Apr 2026 11:07:28 +0200 Subject: [PATCH 1/2] 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 1a6d9fd..2736aaf 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 492f421..7d5d85c 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 @@ -606,20 +613,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) @@ -641,13 +648,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) } @@ -657,7 +664,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 64813ab..495ebfc 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{ From bdcc215acad101c6befad99508ce5b5e0261e9b1 Mon Sep 17 00:00:00 2001 From: Nick C Date: Wed, 22 Apr 2026 18:19:55 +0200 Subject: [PATCH 2/2] Filter goto-definition by call arity Two related bugs made goto-definition return multiple Location entries for calls a human reads as resolving to a single definition. Zed renders those multi-location results as multi-cursor selections (rather than a picker), which is what knoebber reported on issue #38. 1. LookupFunction did not filter by arity, so `Foo.square(3)` returned both `square/1` and `square/2` rows when both were defined. Added LookupFunctionByArity and compute the call-site arity in Definition() via a new TokenizedFile.ArityAtCallsite helper that handles parens, zero-arg calls, `&Foo.bar/2` captures, and pipe context. 2. lookupFollowDelegate only followed delegates when every row was a delegate, so `defdelegate foo/1` + `def foo/2` in the same module returned both rows instead of following the /1 delegate. Rewrote it to partition by arity and follow per-arity, and added LookupFollowDelegateByArity. Existing LookupFunction / LookupFollowDelegate signatures are unchanged so the 20+ other callers still work; only Definition() is arity-aware. Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/lsp/elixir.go | 131 +++++++++++++++++++++++++++++++++ internal/lsp/server.go | 11 +-- internal/lsp/server_test.go | 141 ++++++++++++++++++++++++++++++++++++ internal/store/store.go | 83 +++++++++++++++------ 4 files changed, 340 insertions(+), 26 deletions(-) diff --git a/internal/lsp/elixir.go b/internal/lsp/elixir.go index 20d9200..1def19d 100644 --- a/internal/lsp/elixir.go +++ b/internal/lsp/elixir.go @@ -52,6 +52,137 @@ func (tf *TokenizedFile) FullExpressionAtCursor(line, col int) CursorContext { return FullExpressionAtCursor(tf.tokens, tf.source, tf.lineStarts, line, col) } +// ArityAtCallsite returns the call arity at the given expression position, or +// -1 when arity can't be determined. Handles: +// - Foo.bar(a, b) → 2 +// - Foo.bar() → 0 +// - &Foo.bar/2 → 2 (capture syntax) +// - x |> Foo.bar(y) → 2 (pipe injects one implicit arg) +// - Foo.bar → -1 (no call suffix, arity unknown) +// +// line is 0-based. startCol/endCol are the expression's 0-based column bounds +// (as returned in CursorContext.ExprStart/ExprEnd). +func (tf *TokenizedFile) ArityAtCallsite(line, startCol, endCol int) int { + return arityAtCallsite(tf.tokens, tf.source, tf.lineStarts, line, startCol, endCol) +} + +func arityAtCallsite(tokens []parser.Token, source []byte, lineStarts []int, line, startCol, endCol int) int { + n := len(tokens) + if n == 0 || endCol <= 0 { + return -1 + } + + // Locate the last token of the expression (index of the char at endCol-1). + endOffset := parser.LineColToOffset(lineStarts, line, endCol-1) + if endOffset < 0 { + return -1 + } + endIdx := parser.TokenAtOffset(tokens, endOffset) + if endIdx < 0 { + return -1 + } + + // Scan forward past whitespace/comments for the token immediately after + // the expression. + j := endIdx + 1 + for j < n && (tokens[j].Kind == parser.TokEOL || tokens[j].Kind == parser.TokComment) { + j++ + } + + arity := -1 + switch { + case j < n && tokens[j].Kind == parser.TokOpenParen: + arity = countCallArgs(tokens, n, j) + case j < n && tokens[j].Kind == parser.TokOther && + tokens[j].End-tokens[j].Start == 1 && source[tokens[j].Start] == '/': + // Capture syntax: &Foo.bar/2 + k := j + 1 + if k < n && tokens[k].Kind == parser.TokNumber { + if a, ok := parseNumberTokenArity(source, tokens[k]); ok { + arity = a + } + } + } + + if arity < 0 { + return -1 + } + + // Pipe adjustment: if the expression is the RHS of a |>, add one for the + // implicit first argument. + startOffset := parser.LineColToOffset(lineStarts, line, startCol) + if startOffset >= 0 { + startIdx := parser.TokenAtOffset(tokens, startOffset) + if startIdx > 0 { + for k := startIdx - 1; k >= 0; k-- { + kind := tokens[k].Kind + if kind == parser.TokPipe { + return arity + 1 + } + if kind != parser.TokEOL && kind != parser.TokComment { + break + } + } + } + } + + return arity +} + +// countCallArgs counts top-level arguments inside a parenthesized call, +// starting at openIdx which must be a TokOpenParen. Returns -1 if the parens +// are unbalanced. +func countCallArgs(tokens []parser.Token, n, openIdx int) int { + if openIdx >= n || tokens[openIdx].Kind != parser.TokOpenParen { + return -1 + } + depth := 1 + args := 0 + hasContent := false + for i := openIdx + 1; i < n && depth > 0; i++ { + switch tokens[i].Kind { + case parser.TokOpenParen, parser.TokOpenBracket, parser.TokOpenBrace, parser.TokOpenAngle: + depth++ + hasContent = true + case parser.TokCloseParen, parser.TokCloseBracket, parser.TokCloseBrace, parser.TokCloseAngle: + depth-- + if depth == 0 { + if hasContent { + return args + 1 + } + return 0 + } + case parser.TokComma: + if depth == 1 { + args++ + hasContent = false + continue + } + hasContent = true + case parser.TokEOL, parser.TokComment: + // skip + default: + hasContent = true + } + } + return -1 +} + +func parseNumberTokenArity(source []byte, t parser.Token) (int, bool) { + text := source[t.Start:t.End] + n := 0 + for _, b := range text { + if b < '0' || b > '9' { + return 0, false + } + n = n*10 + int(b-'0') + if n > 255 { // arity fits in a byte in practice + return 0, false + } + } + return n, true +} + // FirstDefmodule returns the first defmodule name found, or "". func (tf *TokenizedFile) FirstDefmodule() string { for i := 0; i < tf.n; i++ { diff --git a/internal/lsp/server.go b/internal/lsp/server.go index 7d5d85c..128cb7c 100644 --- a/internal/lsp/server.go +++ b/internal/lsp/server.go @@ -556,6 +556,7 @@ func (s *Server) Definition(ctx context.Context, params *protocol.DefinitionPara expr := tf.ResolveModuleExpr(exprCtx.Expr(), lineNum) moduleRef, functionName := ExtractModuleAndFunction(expr) + callArity := tf.ArityAtCallsite(lineNum, exprCtx.ExprStart, exprCtx.ExprEnd) if moduleRef != "" { if aliasParent, inBlock := ExtractAliasBlockParent(lines, lineNum); inBlock { @@ -565,7 +566,7 @@ func (s *Server) Definition(ctx context.Context, params *protocol.DefinitionPara aliases := tf.ExtractAliasesInScope(lineNum) s.mergeAliasesFromUse(text, aliases) - s.debugf("Definition: expr=%q module=%q function=%q", expr, moduleRef, functionName) + s.debugf("Definition: expr=%q module=%q function=%q arity=%d", expr, moduleRef, functionName, callArity) // Bare identifier — check variable first (cheap tree-sitter lookup), then functions if moduleRef == "" { @@ -607,9 +608,9 @@ func (s *Server) Definition(ctx context.Context, params *protocol.DefinitionPara var results []store.LookupResult var err error if s.followDelegates { - results, err = s.store.LookupFollowDelegate(fullModule, functionName) + results, err = s.store.LookupFollowDelegateByArity(fullModule, functionName, callArity) } else { - results, err = s.store.LookupFunction(fullModule, functionName) + results, err = s.store.LookupFunctionByArity(fullModule, functionName, callArity) } if err == nil && len(results) > 0 { s.debugf("Definition: found %d result(s) in store for %s.%s", len(results), fullModule, functionName) @@ -642,9 +643,9 @@ func (s *Server) Definition(ctx context.Context, params *protocol.DefinitionPara var results []store.LookupResult var err error if s.followDelegates { - results, err = s.store.LookupFollowDelegate(fullModule, functionName) + results, err = s.store.LookupFollowDelegateByArity(fullModule, functionName, callArity) } else { - results, err = s.store.LookupFunction(fullModule, functionName) + results, err = s.store.LookupFunctionByArity(fullModule, functionName, callArity) } if err == nil && len(results) > 0 { s.debugf("Definition: found %d result(s) in store for %s.%s", len(results), fullModule, functionName) diff --git a/internal/lsp/server_test.go b/internal/lsp/server_test.go index 495ebfc..7c3aa87 100644 --- a/internal/lsp/server_test.go +++ b/internal/lsp/server_test.go @@ -331,6 +331,147 @@ func TestServer_ApplyDefinitionStyle(t *testing.T) { } } +// --- Duplicate-location reproductions (issue #38, knoebber's follow-up) --- +// +// knoebber reported that Zed's goto-definition opens the references picker even +// when "the function is defined once in another module", implying Dexter is +// returning more than one Location in cases where a human sees a single +// definition. These tests pin the scenarios we suspect: each asserts exactly 1 +// Location from Definition(). A failure here means the handler is returning +// duplicates for a single-definition call and likely reproduces the bug. + +// Sanity baseline: one def, one caller — must be a single Location. +func TestDefinition_SingleDef_ReturnsOneLocation(t *testing.T) { + server, cleanup := setupTestServer(t) + defer cleanup() + + indexFile(t, server.store, server.projectRoot, "lib/math.ex", `defmodule MyApp.Math do + def add(a, b), do: a + b +end +`) + + callerPath := filepath.Join(server.projectRoot, "lib", "caller.ex") + callerContent := `defmodule MyApp.Caller do + def run, do: MyApp.Math.add(1, 2) +end +` + indexFile(t, server.store, server.projectRoot, "lib/caller.ex", callerContent) + callerURI := "file://" + callerPath + server.docs.Set(callerURI, callerContent) + + // Cursor on "add" in MyApp.Math.add(1, 2) + locs := definitionAt(t, server, callerURI, 1, 25) + if len(locs) != 1 { + t.Fatalf("expected exactly 1 location for single def, got %d: %+v", len(locs), locs) + } +} + +// Multi-arity: def foo/1 and def foo/2 both defined once each. A call to foo/1 +// should only return the foo/1 line — but LookupFunction ignores arity, so we +// expect this to currently return 2 locations (the bug). +func TestDefinition_MultiArity_ReturnsOneLocation(t *testing.T) { + server, cleanup := setupTestServer(t) + defer cleanup() + + indexFile(t, server.store, server.projectRoot, "lib/math.ex", `defmodule MyApp.Math do + def square(x), do: x * x + + def square(x, factor), do: (x * x) * factor +end +`) + + callerPath := filepath.Join(server.projectRoot, "lib", "caller.ex") + callerContent := `defmodule MyApp.Caller do + def run, do: MyApp.Math.square(3) +end +` + indexFile(t, server.store, server.projectRoot, "lib/caller.ex", callerContent) + callerURI := "file://" + callerPath + server.docs.Set(callerURI, callerContent) + + // Cursor on "square" in MyApp.Math.square(3) + locs := definitionAt(t, server, callerURI, 1, 28) + if len(locs) != 1 { + t.Fatalf("expected exactly 1 location for square/1 call, got %d — "+ + "LookupFunction is not filtering by arity: %+v", len(locs), locs) + } +} + +// defdelegate + def with the same name in the same module. The caller writes +// Mod.do_thing(x); the human reads this as "one definition" (the delegate) but +// LookupFollowDelegate returns both the defdelegate line and the def line +// because allDelegates is false. +func TestDefinition_DelegateAndDefSameName_ReturnsOneLocation(t *testing.T) { + server, cleanup := setupTestServer(t) + defer cleanup() + + indexFile(t, server.store, server.projectRoot, "lib/worker.ex", `defmodule MyApp.Worker do + def call(attrs), do: {:ok, attrs} +end +`) + + indexFile(t, server.store, server.projectRoot, "lib/api.ex", `defmodule MyApp.Api do + defdelegate do_thing(x), to: MyApp.Worker, as: :call + + def do_thing(x, y), do: {x, y} +end +`) + + callerPath := filepath.Join(server.projectRoot, "lib", "caller.ex") + callerContent := `defmodule MyApp.Caller do + def run, do: MyApp.Api.do_thing("hello") +end +` + indexFile(t, server.store, server.projectRoot, "lib/caller.ex", callerContent) + callerURI := "file://" + callerPath + server.docs.Set(callerURI, callerContent) + + // Cursor on "do_thing" in MyApp.Api.do_thing("hello") + locs := definitionAt(t, server, callerURI, 1, 27) + if len(locs) != 1 { + t.Fatalf("expected exactly 1 location for delegate-then-def call, got %d — "+ + "LookupFollowDelegate returns both the defdelegate and def rows: %+v", len(locs), locs) + } +} + +// Multiple heads of the same arity — the original PR #39 scenario. This is +// *not* a bug; it's what Jesse called "a feature". The test documents the +// current behavior: all heads returned with style="all", only first with +// style="first". +func TestDefinition_MultipleHeadsSameArity_StyleControlled(t *testing.T) { + server, cleanup := setupTestServer(t) + defer cleanup() + + indexFile(t, server.store, server.projectRoot, "lib/accounts.ex", `defmodule MyApp.Accounts do + def fetch_user(%{id: id}), do: id + def fetch_user(email) when is_binary(email), do: email + def fetch_user(id) when is_integer(id), do: id +end +`) + + callerPath := filepath.Join(server.projectRoot, "lib", "caller.ex") + callerContent := `defmodule MyApp.Caller do + def run, do: MyApp.Accounts.fetch_user("nick@example.com") +end +` + indexFile(t, server.store, server.projectRoot, "lib/caller.ex", callerContent) + callerURI := "file://" + callerPath + server.docs.Set(callerURI, callerContent) + + // Cursor on "fetch_user" in MyApp.Accounts.fetch_user("...") + locs := definitionAt(t, server, callerURI, 1, 32) + if len(locs) < 2 { + t.Fatalf("expected multiple locations for 3 function heads with style=all, got %d", len(locs)) + } + + // With style="first", caller should see only the first head. + server.definitionStyle = "first" + locs = definitionAt(t, server, callerURI, 1, 32) + if len(locs) != 1 { + t.Fatalf("expected exactly 1 location with definitionStyle=first, got %d", len(locs)) + } +} + func definitionAt(t *testing.T, server *Server, uri string, line, col uint32) []protocol.Location { t.Helper() result, err := server.Definition(context.Background(), &protocol.DefinitionParams{ diff --git a/internal/store/store.go b/internal/store/store.go index e295918..e57c75f 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -623,6 +623,21 @@ func (s *Store) LookupFunction(module, function string) ([]LookupResult, error) ) } +// LookupFunctionByArity returns definitions for a function name, optionally +// filtered by arity. When arity < 0, behavior matches LookupFunction (all +// arities returned). When arity >= 0, results are filtered to exact matches, +// preventing callers from getting e.g. `foo/1` and `foo/2` rows for a call +// that only uses one of them. +func (s *Store) LookupFunctionByArity(module, function string, arity int) ([]LookupResult, error) { + if arity < 0 { + return s.LookupFunction(module, function) + } + return s.queryLookup( + "SELECT file_path, line, kind, arity, delegate_to, delegate_as FROM definitions WHERE module = ? AND function = ? AND arity = ? AND kind NOT IN ('module', 'defprotocol', 'defimpl', 'callback', 'macrocallback') ORDER BY CASE WHEN kind IN ('type', 'opaque') THEN 1 ELSE 0 END, line", + module, function, arity, + ) +} + // CallbackResult holds a @callback or @macrocallback definition with its arity. type CallbackResult struct { FilePath string @@ -1104,42 +1119,68 @@ func (s *Store) NextFunctionLine(filePath string, startLine int) int { } func (s *Store) LookupFollowDelegate(module, function string) ([]LookupResult, error) { - return s.lookupFollowDelegate(module, function, 0) + return s.LookupFollowDelegateByArity(module, function, -1) } -func (s *Store) lookupFollowDelegate(module, function string, depth int) ([]LookupResult, error) { +// LookupFollowDelegateByArity is like LookupFollowDelegate but filters by +// arity when arity >= 0. Delegate-following is partitioned per arity, so a +// module that mixes `defdelegate foo/1, to: X` with `def foo/2` still follows +// the /1 delegate for a /1 call without being blocked by the non-delegate /2 +// row. +func (s *Store) LookupFollowDelegateByArity(module, function string, arity int) ([]LookupResult, error) { + return s.lookupFollowDelegate(module, function, arity, 0) +} + +func (s *Store) lookupFollowDelegate(module, function string, arity, depth int) ([]LookupResult, error) { if depth > 5 { return nil, nil } - results, err := s.LookupFunction(module, function) + results, err := s.LookupFunctionByArity(module, function, arity) if err != nil { return nil, err } + if len(results) == 0 { + return nil, nil + } - // If all results are defdelegates, follow them to the target - allDelegates := len(results) > 0 + // Group by arity. Within each arity group, follow if every row is a + // delegate. This handles the defdelegate/1 + def/2 mix correctly. + byArity := make(map[int][]LookupResult, len(results)) + order := make([]int, 0, len(results)) for _, r := range results { - if r.Kind != "defdelegate" || r.DelegateTo == "" { - allDelegates = false - break + if _, seen := byArity[r.Arity]; !seen { + order = append(order, r.Arity) } + byArity[r.Arity] = append(byArity[r.Arity], r) } - if allDelegates { - targetModule := results[0].DelegateTo - targetFunc := function - if results[0].DelegateAs != "" { - targetFunc = results[0].DelegateAs - } - targetResults, err := s.lookupFollowDelegate(targetModule, targetFunc, depth+1) - if err != nil { - return nil, err + var out []LookupResult + for _, a := range order { + group := byArity[a] + allDelegates := true + for _, r := range group { + if r.Kind != "defdelegate" || r.DelegateTo == "" { + allDelegates = false + break + } } - if len(targetResults) > 0 { - return targetResults, nil + if allDelegates { + targetModule := group[0].DelegateTo + targetFunc := function + if group[0].DelegateAs != "" { + targetFunc = group[0].DelegateAs + } + targetResults, err := s.lookupFollowDelegate(targetModule, targetFunc, a, depth+1) + if err != nil { + return nil, err + } + if len(targetResults) > 0 { + out = append(out, targetResults...) + continue + } } + out = append(out, group...) } - - return results, nil + return out, nil }