Add support for struct auto-completion#57
Conversation
Adds two more sources of struct-type inference for variable dot-completion: - @SPEC parameter typespecs: parameters annotated `t()` or `Module.t()` resolve to that struct (pattern matches still take precedence). - ExCk return-type lookup: `var = Mod.func(...)` resolves `var.` against the struct returned by the compiled function, queried over a new `return_type_struct` op on the persistent BEAM process.
- Detect `&fn/arity` captures in `FindBareFunctionCalls` so renames and find-references include capture call sites alongside direct and pipe calls. - Only apply the `000_`-prefixed `SortText` to variable completions in struct value positions (where a variable is the likely target). Previously the prefix leaked into all `funcPrefix != ""` completions, changing sort order of unrelated completions. - Drop the unused `source []byte` parameter from `countCallArity`. - Replace the `(line, len(text))`-based hack with a new `AllVariableFunctionCalls` that scans every function body in the file. `prewarmStructFieldsFromText` was previously only scanning the last function definition because `VariableFunctionCalls` anchors to the most recent `def` before the offset.
The BEAM formatter script hardcoded `_build/dev/lib/*/ebin` and only
looked at the build root passed by the Go side. Projects that compile
only with `MIX_ENV=test` or that keep their compiled deps in a sibling
sub-project (e.g. `apps/<app>/_build` while the file being edited lives
in `libs/<lib>/` with no `_build` of its own) couldn't load plugins like
Styler — the warning fired and the formatter silently fell back to the
standard formatter.
Try `_build/{dev,test,prod}/lib/*/ebin` in priority order, and if
nothing is found at the build root, descend one and two levels to pick
up nested mix projects' `_build` dirs. Extracted the shared logic into
`Dexter.CodePath.prepend_compiled_deps/1` so the top-level boot path and
each per-formatter init share it.
- Remove Macro.Env from knownNonStructTypes: it is a real struct with fields like module, file, line, etc. This fixes struct field autocompletion for variables typed as Macro.Env.t() in @SPEC. - Remove redundant tokenText helper and use parser.TokenText consistently throughout the file.
Use prevSignificantToken instead of raw tokens[idx-1] so that valid Elixir whitespace patterns like 'user\n.name' and 'user.\nname' are correctly detected as variable field access.
- lineCouldBeStructValueSpaceTrigger now scans previous lines for the
%Module{ opening brace, fixing space-triggered completions in
multi-line struct value positions like:
%User{
name: |
- maybePrewarmStructFields now tracks in-flight prewarms per docURI
to prevent goroutine pile-up on rapid keystrokes. A new prewarm is
skipped if one is already running for the same document; the next
change event after completion triggers a fresh prewarm.
When warmStructFields detects a structFieldGen mismatch (cache invalidated during the async lookup, e.g. for an unrelated module), it bailed out early but never reset the entry's loading flag. If the entry itself wasn't deleted, it remained stuck with loading=true forever. Subsequent calls to cachedStructFieldsOrWarmWithLogging saw the stale flag and returned immediately without spawning a new goroutine, so struct field completion for that module never worked until a server restart or independent invalidation. Reset loading=false on the entry when discarding a stale generation result.
Resolved conflict in internal/lsp/server.go: kept both sides — struct-support's existing completion logic plus main's new tree-sitter variable scope completion block. Also fixed a pre-existing 3-vs-4 return value mismatch in GetTree call.
Detect more patterns that indicate a variable holds a struct, enabling
field autocompletion on variable.field access without needing %Module{}
pattern matches in the same function.
New inference sources:
- Pipe chains: var = A.foo() |> B.bar(x) resolves to B.bar, not A.foo
- Bare local calls: var = build_user(x) stores __MODULE__, resolved via
@SPEC or ExCk lookup on the current module
- with/case <- matches: var <- Mod.func() treated like = assignments
- Struct update syntax: %User{var | ...} infers var as User struct
- Pipe starting with bare value: var = x |> Mod.func()
- @SPEC return types: @SPEC func() :: User.t() infers return struct
for bare local calls, avoiding BEAM round-trips for uncompiled modules
- Destructured patterns: {:ok, var} = Mod.func() extracts all variable
names from the left-hand side pattern
Also:
- Expanded knownNonStructTypes (22 entries) to avoid false @SPEC matches
- Unified readCallArgs helper deduplicates paren/no-paren arity counting
- Prewarm filter extended to trigger on = assignments (not just %)
- Cold cache returns IsIncomplete to signal editor re-query
- Added integration tests for Ecto.Multi struct completion
0f45d8d to
378ee65
Compare
When the BEAM returns loaded-but-empty fields (e.g. a module without a defstruct), cachedStructFieldsOrWarm sets ready=true with zero fields. Both completion paths treated this the same as a cold cache and returned IsIncomplete: true, causing the editor to re-query indefinitely. Now only return IsIncomplete when !ready (cache still warming). When ready but empty, return nil (no completions available).
- Remove URI, Regex, Range, MapSet, Date, Time, NaiveDateTime, DateTime, File.Stat, IO.Stream, and Task from knownNonStructTypes. These are all real Elixir structs with defstruct; including them here caused classifySpecType to return "" for @SPEC annotations referencing them, silently disabling struct field completion. - Fix pipe chain arity in scanVariableFunctionCalls: add +1 for the implicit piped first argument in all three pipe-chain code paths (initial pipe check, initial pipe check bare call, and pipe chain following loop). Previously readCallArgs only counted explicit args, causing ExCk ReturnTypeStruct lookups to fail with wrong arity. - Fix race condition in warmStructFields: add per-entry generation tracking to structFieldCacheEntry. Stale goroutines now compare against the entry's generation (not the global gen), preventing them from resetting a newer goroutine's loading flag. This also handles stale loading state by allowing new requests to detect old-generation entries and start a fresh warm instead of waiting indefinitely.
When BEAM type inference collapses to :term through Repo/with wrappers, fall back to parsing the callee's source @SPEC. Recognize structs wrapped in {:ok, ...} tuples and inside union types, picking the single unambiguous struct branch. Scope spec lookups to the target module in multi-module files, and let variable scans fall back to the enclosing module body (or file) when the cursor sits outside any def.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 6e4f4f9. Configure here.
| // @spec return type before querying the BEAM. This works even when the | ||
| // module hasn't been compiled yet. | ||
| if call.Module == "__MODULE__" { | ||
| if structRef := findSpecReturnType(tf.tokens, tf.source, call.Function, call.Arity); structRef != "" { |
There was a problem hiding this comment.
Unscoped spec search matches wrong module in multi-module files
Medium Severity
When call.Module == "__MODULE__", findSpecReturnType searches from byte offset 0 through the entire file's tokens for a matching @spec. In files containing multiple modules with identically-named functions, this incorrectly matches a @spec from a different module. The scoped variant findSpecReturnTypeAfter exists and is correctly used in resolveCrossModuleSpecReturnStruct with a proper startOffset, but the __MODULE__ fast-path here doesn't scope the search to the enclosing module.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit 6e4f4f9. Configure here.


#43
Note
Medium Risk
Large completion and BEAM-protocol changes affect editor behavior and sidecar startup; failures degrade to incomplete or empty completions rather than crashing, but wrong inference could suggest misleading fields.
Overview
Adds struct-aware completion to the Elixir LSP: field keys inside
%Module{...}(including updates and multiline literals), fields aftervariable.when the variable’s type is known, and local variables (plus existing function/keyword items) in struct value positions aftername:(with a space trigger).Type inference is expanded in the token layer: struct bindings from patterns/assignments,
@specparameter and return types (including{:ok, Module.t()}unions), assignments fromModule.func/ pipes /<-, and cross-module@specfallback when BEAM inference is too weak.The BEAM CodeIntel sidecar gains
struct_fields(from__struct__/0) andreturn_type_struct(from ExCk chunks), with Go clients, async cache + prewarm on open/change, and invalidation on reindex/BEAM eviction.Dexter.CodePathalso finds compiledebinpaths in nested/umbrella layouts so formatter plugins and intel work when_buildlives under sibling apps.Reviewed by Cursor Bugbot for commit 6e4f4f9. Bugbot is set up for automated code reviews on this repo. Configure here.