Skip to content

Add support for struct auto-completion#57

Open
JesseHerrick wants to merge 15 commits into
mainfrom
struct-support
Open

Add support for struct auto-completion#57
JesseHerrick wants to merge 15 commits into
mainfrom
struct-support

Conversation

@JesseHerrick
Copy link
Copy Markdown
Member

@JesseHerrick JesseHerrick commented May 4, 2026

#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 after variable. when the variable’s type is known, and local variables (plus existing function/keyword items) in struct value positions after name: (with a space trigger).

Type inference is expanded in the token layer: struct bindings from patterns/assignments, @spec parameter and return types (including {:ok, Module.t()} unions), assignments from Module.func / pipes / <-, and cross-module @spec fallback when BEAM inference is too weak.

The BEAM CodeIntel sidecar gains struct_fields (from __struct__/0) and return_type_struct (from ExCk chunks), with Go clients, async cache + prewarm on open/change, and invalidation on reindex/BEAM eviction. Dexter.CodePath also finds compiled ebin paths in nested/umbrella layouts so formatter plugins and intel work when _build lives under sibling apps.

Reviewed by Cursor Bugbot for commit 6e4f4f9. Bugbot is set up for automated code reviews on this repo. Configure here.

Comment thread internal/lsp/server.go
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.
Comment thread internal/lsp/elixir.go
Comment thread internal/lsp/server.go Outdated
- 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.
Comment thread internal/lsp/elixir.go
Comment thread internal/lsp/elixir.go Outdated
- 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.
Comment thread internal/lsp/server.go
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.
Comment thread internal/lsp/server.go Outdated
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
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).
Comment thread internal/lsp/elixir.go Outdated
Comment thread internal/lsp/elixir.go
JesseHerrick and others added 2 commits May 26, 2026 00:48
- 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.
Copy link
Copy Markdown

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ 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.

Comment thread internal/lsp/server.go
// @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 != "" {
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 6e4f4f9. Configure here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant