Skip to content
Open
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 @@ -153,6 +153,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 @@ -270,6 +271,21 @@ If Zed shows a *"could not detect Elixir stdlib"* warning on startup — common

Equivalently, set the `DEXTER_ELIXIR_LIB_ROOT` environment variable via `lsp.dexter.binary.env`.

To configure other LSP options, such as returning only the first matching function head, add them to the same `initialization_options` object (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 @@ -566,6 +582,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 for this editor session. Logs timing and resolution details for every definition, hover, references, and rename request to your editor's LSP log and to the workspace daemon's log (see [Debugging](#debugging)). Can also be enabled via the `DEXTER_DEBUG=true` environment variable.
- **`maxTransientDocuments`** (integer, default: `50`): cap on how many lazily-loaded buffers the server retains in memory. When an LSP client (e.g. Claude Code) queries a file it never opened via `didOpen`, dexter reads it from disk and caches it. Editor-owned buffers are unaffected; only disk-loaded entries are subject to LRU eviction. Set to `0` to disable transient caching.
Expand Down
229 changes: 229 additions & 0 deletions internal/lsp/elixir.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,182 @@ func (tf *TokenizedFile) FullExpressionAtCursor(line, col int) CursorContext {
return ctx
}

// 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 {
endOffset := parser.LineColToOffset(tf.lineStarts, line, endCol-1)
if endOffset >= 0 && parser.TokenAtOffset(tf.interp, endOffset) >= 0 {
// The interpolation stream intentionally contains only references, not
// the delimiters required to determine a call's arity.
return -1
}
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 {

@flowerett flowerett Sep 29, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[important] Exact-arity filtering requires Elixir expression nesting, but this scanner only tracks delimiters: it counts commas inside fn bodies as outer arguments, omits the implicit keyword-list argument from trailing do blocks, treats /N as a capture without requiring &, and cannot inspect interpolation or parenthesis-free calls. The later module fallback cannot heal a wrong count—it returns the module or an unrelated arity—so valid calls can jump to the wrong definition. Derive arity with a block-aware TokenWalker, and decline exact filtering when the call form remains ambiguous.

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
}

w := parser.NewTokenWalker(source, tokens)
w.SetPos(endIdx + 1)
w.SkipToNextSig()
j := w.Pos()

arity := -1
switch {
case j < n && tokens[j].Kind == parser.TokOpenParen:
var closeIdx int
arity, closeIdx = countCallArgs(source, tokens, j)
if arity >= 0 {
w.SetPos(closeIdx + 1)
w.SkipToNextSig()
if w.CurrentKind() == parser.TokDo {
startOffset := parser.LineColToOffset(lineStarts, line, startCol)
startIdx := parser.TokenAtOffset(tokens, startOffset)
prev := w.PreviousSigPos(startIdx)
if prev >= 0 && tokenCanOwnFollowingExpression(tokens[prev].Kind) {
return -1
}
arity++
}
}
case j < n && tokens[j].Kind == parser.TokOther &&
tokens[j].End-tokens[j].Start == 1 && source[tokens[j].Start] == '/':
// Capture syntax: &Foo.bar/2
startOffset := parser.LineColToOffset(lineStarts, line, startCol)
startIdx := parser.TokenAtOffset(tokens, startOffset)
prev := w.PreviousSigPos(startIdx)
if prev < 0 || tokens[prev].Kind != parser.TokOther ||
tokens[prev].End-tokens[prev].Start != 1 || source[tokens[prev].Start] != '&' {
return -1
}
w.SetPos(j + 1)
w.SkipToNextSig()
k := w.Pos()
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 prev := w.PreviousSigPos(startIdx); prev >= 0 && tokens[prev].Kind == parser.TokPipe {
return arity + 1
}
}

return arity
}

// countCallArgs counts top-level arguments inside a parenthesized call,
// starting at openIdx which must be a TokOpenParen. It returns the arity and
// matching close-token index, or -1, -1 when the expression is unbalanced.
func countCallArgs(source []byte, tokens []parser.Token, openIdx int) (int, int) {
if openIdx >= len(tokens) || tokens[openIdx].Kind != parser.TokOpenParen {
return -1, -1
}
w := parser.NewTokenWalker(source, tokens)
w.SetPos(openIdx)
w.Advance()
args := 0
hasContent := false
keywordTail := false
for w.More() {
pos := w.Pos()
kind := w.CurrentKind()
switch kind {
case parser.TokCloseParen, parser.TokCloseBracket, parser.TokCloseBrace, parser.TokCloseAngle:
if w.Depth() == 1 && w.BlockDepth() == 0 {
if hasContent {
return args + 1, pos
}
return 0, pos
}
case parser.TokComma:
if w.Depth() == 1 && w.BlockDepth() == 0 {
// Elixir's trailing keyword syntax is one list argument even
// though its entries are separated by top-level commas.
if keywordTail {
w.Advance()
continue
}
args++
hasContent = false
w.Advance()
continue
Comment on lines +191 to +202

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[important] Exact arity remains wrong when an argument is an unparenthesized special form: target(if ready, do: x, else: y) and target(for x <- xs, y <- ys, do: x) count the inner commas as arguments of target. The later semantic lookup cannot heal the incorrect exact-arity selection, so navigation can jump to another arity or the module declaration. Track expression ownership for special forms, or return unknown arity when comma ownership is ambiguous.

}
hasContent = true
case parser.TokColon:
prev := w.PreviousSigPos(pos)
if w.Depth() == 1 && w.BlockDepth() == 0 && prev > openIdx && tokens[prev].Kind == parser.TokIdent {
keywordTail = true
}
hasContent = true
case parser.TokEOL, parser.TokComment:
// skip
default:
hasContent = true
}
w.Advance()
}
return -1, -1
}

func tokenCanOwnFollowingExpression(kind parser.TokenKind) bool {
switch kind {
case parser.TokIdent, parser.TokModule, parser.TokNumber, parser.TokString,
parser.TokHeredoc, parser.TokSigil, parser.TokCharLiteral, parser.TokAtom,
parser.TokCloseParen, parser.TokCloseBracket, parser.TokCloseBrace, parser.TokCloseAngle:
return true
default:
return false
}
}
Comment thread
flowerett marked this conversation as resolved.

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++ {
Expand Down Expand Up @@ -116,6 +292,59 @@ func (tf *TokenizedFile) FindTypeDefinition(functionName string) (int, bool) {
return tf.findDefinition(functionName, true)
}

// FindDefinitionLines returns all matching callable or type definition lines.
// An arity below zero keeps every arity. preferType selects the namespace to
// prefer when a type and callable share a name.
func (tf *TokenizedFile) FindDefinitionLines(functionName string, arity int, preferType bool) []int {
var functionLines, typeLines []int
w := parser.NewTokenWalker(tf.source, tf.tokens)
for w.More() {
i := w.Pos()
tok := w.Current()
w.Advance()
switch tok.Kind {
case parser.TokDef, parser.TokDefp, parser.TokDefmacro, parser.TokDefmacrop,
parser.TokDefguard, parser.TokDefguardp, parser.TokDefdelegate:
name, j, ok := parser.StaticDeclarationName(tf.source, tf.tokens, tf.n, i)
if !ok || name != functionName {
continue
}
maxArity, defaultCount := 0, 0
pj := tokNextSig(tf.tokens, tf.n, j+1)
if pj < tf.n && tf.tokens[pj].Kind == parser.TokOpenParen {
maxArity, defaultCount, _, _ = parser.CollectParams(tf.source, tf.tokens, tf.n, pj)
}
if arity < 0 || (arity >= maxArity-defaultCount && arity <= maxArity) {
functionLines = append(functionLines, tok.Line)
Comment on lines +298 to +318

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[important] This scan collects matching declarations from every module in the buffer, so a bare foo() in the first module can return foo/0 declarations from nested or later modules. The module-aware semantic lookup cannot heal this because the current-buffer path returns early. Restrict declarations to the scope belonging to fullModule.

}

case parser.TokAttrType:
name, j, ok := parser.StaticDeclarationName(tf.source, tf.tokens, tf.n, i)
if !ok || name != functionName {
continue
}
typeArity := 0
pj := tokNextSig(tf.tokens, tf.n, j+1)
if pj < tf.n && tf.tokens[pj].Kind == parser.TokOpenParen {
typeArity, _, _, _ = parser.CollectParams(tf.source, tf.tokens, tf.n, pj)
}
if arity < 0 || arity == typeArity {
typeLines = append(typeLines, tok.Line)
}
}
}
if preferType {
if len(typeLines) > 0 {
return typeLines
}
return functionLines
}
if len(functionLines) > 0 {
return functionLines
}
return typeLines
}

// findDefinition returns the line of the first matching definition. A module
// may declare both a type and a function under one name — Ecto.Schema has
// `@type schema` above `defmacro schema/2` — so file order alone cannot decide
Expand Down
72 changes: 72 additions & 0 deletions internal/lsp/elixir_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -437,6 +437,78 @@ func TestExpressionAtCursor_ExprBounds(t *testing.T) {
}
}

func TestArityAtCallsite_KeywordTailCountsAsOneArgument(t *testing.T) {
code := "SharedLib.Repo.insert(changeset, returning: true, on_conflict: :replace)"
tf := NewTokenizedFile(code)
ctx := tf.ExpressionAtCursor(0, strings.Index(code, "insert")+2)
if got := tf.ArityAtCallsite(0, ctx.ExprStart, ctx.ExprEnd); got != 2 {
t.Fatalf("ArityAtCallsite() = %d, want 2", got)
}
}

func TestArityAtCallsite_ComplexForms(t *testing.T) {
tests := []struct {
name string
code string
want int
}{
{
name: "commas in fn body do not add arguments",
code: "SharedLib.Worker.run(fn left, right -> {left, right} end)",
want: 1,
},
{
name: "trailing do block is a keyword list argument",
code: "SharedLib.Worker.run(:value) do\n :ok\nend",
want: 2,
},
{
name: "inline do keyword tail is one argument",
code: "SharedLib.Worker.run(:value, do: :ok, else: :error)",
want: 2,
},
{
name: "slash without capture is ambiguous",
code: "SharedLib.Worker.run / 2",
want: -1,
},
{
name: "capture slash supplies arity",
code: "&SharedLib.Worker.run/2",
want: 2,
},
{
name: "outer block ownership is ambiguous",
code: "if SharedLib.Worker.run(:value) do\n :ok\nend",
want: -1,
},
{
name: "parenthesis-free call is ambiguous",
code: "SharedLib.Worker.run :value, mode: :fast",
want: -1,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tf := NewTokenizedFile(tt.code)
ctx := tf.ExpressionAtCursor(0, strings.Index(tt.code, "run")+1)
if got := tf.ArityAtCallsite(0, ctx.ExprStart, ctx.ExprEnd); got != tt.want {
t.Fatalf("ArityAtCallsite() = %d, want %d", got, tt.want)
}
})
}
}

func TestArityAtCallsite_InterpolationIsAmbiguous(t *testing.T) {
code := `"#{SharedLib.Worker.run(:value)}"`
tf := NewTokenizedFile(code)
ctx := tf.ExpressionAtCursor(0, strings.Index(code, "run")+1)
if got := tf.ArityAtCallsite(0, ctx.ExprStart, ctx.ExprEnd); got != -1 {
t.Fatalf("ArityAtCallsite() = %d, want -1", got)
}
}

func TestCursorContext_Expr(t *testing.T) {
tests := []struct {
mod, fn, want string
Expand Down
Loading
Loading