Skip to content

improvement: record the define line on code interface functions - #2971

Merged
zachdaniel merged 1 commit into
ash-project:mainfrom
onnimonni:improvement/define-line
Sep 27, 2026
Merged

zachdaniel merged 1 commit into
ash-project:mainfrom
onnimonni:improvement/define-line

Conversation

@onnimonni

Copy link
Copy Markdown
Contributor

Closes #2970.

Generated code interface functions record line: 1 (the defmodule line, where define_interface/3 expands) and file: {"…/code_interface.ex", N} in their debug info, so editors send go-to-definition to the top of the domain or resource. This records the line of the function's define instead:

debug info stacktrace frame
before line: 1, file: {"deps/ash/lib/ash/code_interface.ex", N} code_interface.ex:<body line>
after line: 18, file: {"deps/ash/lib/ash/code_interface.ex", N} unchanged

How

define_at wraps each generated def (nine sites; the diff is mostly re-indentation, git diff -w is +77 lines in lib/) and evaluates it with Code.eval_quoted/3 and the environment's line set to the define line from Spark.Dsl.Entity.anno/1. It falls back to the current line when Spark recorded no annotation or the annotation belongs to another file (define_interface/3 called by hand from a third module). The def node's own :line meta is dropped so the environment's line applies; the file and the body's lines still come from location: :keep.

The environment is captured once per define_interface/3 expansion rather than per def: __ENV__ expands to a large literal, and evaluating one per generated function made compiling a resource with 120 generated functions 60% slower. Captured once it is 12% (2.7 s to 3.1 s in the test env, Code.compile_string, 6 runs). Namespaced interfaces (namespace: on define or on the resource/domain) go through the same define_interface/3 inside Module.create/3 with the host's file, so they get the same lines.

@file was the other option (remoteoss/dexter#108). It would also move the function's file for stacktraces while the body's lines stay code_interface.ex's, so crashes would report meaningless lines in the user's domain file.

Testing

  • New test compiles a resource with two defines and asserts each generated function's recorded line and that its file is still code_interface.ex. It fails on main (1 vs 13). The debug-info compiler option is switched on around the compile and restored in after, since mix test runs with it off.
  • mix test: 4231 passed. mix format --check-formatted and mix credo --strict on the changed files: clean.
  • End to end in a Phoenix app with Send generated functions to the line that declared them remoteoss/dexter#110: go-to-definition on get_room_by_slug!/list_rooms! goes to the define lines (chat_ash.ex:18/:16) instead of :1. The app's tests pass on this branch.

Contributor checklist

  • Bug fixes include regression tests
  • Features include unit/acceptance tests

https://claude.ai/code/session_01WmwbB8WxStVckYjNgJv5v5

Generated code interface functions now record the line of their `define` in the
module's debug info, so editors and language servers can jump to it instead of
the top of the domain or resource. The file and the body's lines stay
code_interface.ex, so stacktraces are unchanged.
@zachdaniel
zachdaniel merged commit d03004a into ash-project:main Sep 27, 2026
41 of 45 checks passed
@zachdaniel

Copy link
Copy Markdown
Contributor

🚀 Thank you for your contribution! 🚀

JesseHerrick added a commit to remoteoss/dexter that referenced this pull request Sep 28, 2026
A function a macro generated has no source definition, so go-to-definition
fell back to the top of its module. The compiled module's debug info records
where each def came from: its :line, and a `file: {path, line}` entry left by
`@file` or `quote location: :keep`. When that path is the module's own source,
the line is where the code asked for the function; when it names the
generator's own file, :line is used instead.

beam.ReadDefinitionLines reads those locations from the Dbgi chunk and steps
over clause bodies without allocating. generatedDefinitionResultsFor uses a
recorded line only when the debug info was compiled from the file being
opened, the BEAM is not older than it, and the line falls after the module's
own line. Every other case keeps the module result.

Bare-call definition and call-hierarchy preparation call it directly.
Qualified definition, the references declaration, and `dexter lookup` reach
it through LookupName, which takes a recorded line even for a strict lookup,
since that line is the function's own definition.

No framework is recognized by name. Ash code interfaces (#108) resolve to
their `define` line once Ash includes ash-project/ash#2971, which expands
each generated def at that line; with released Ash they resolve to line 1 as
before.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
JesseHerrick added a commit to remoteoss/dexter that referenced this pull request Oct 3, 2026
Closes #108. Supersedes #102, which is folded in here. Go-to-definition
for DSL-generated functions such as Ash code interfaces. It does not
special-case any framework. The Ash side is ash-project/ash#2971, which
is merged and will ship in the next Ash release after v3.33.11. With
released Ash, a code interface goes to its `define` line too, from the
generic declaring-call search described below.

## Summary

A function that a macro generates has no source definition, so
go-to-definition fell back to the top of its module. The compiled
module's `Dbgi` chunk records the location of each def:

- Its `:line` is the line in the module's own file that was compiled
when the def was made. Usually this is the macro call or the line where
a before-compile hook ran, but a generator can expand the def at any
line.
- A `file: {path, line}` entry comes from `@file` or from `quote
location: :keep`. When `path` is the module's own source, this line is
where the code asked for the function. When `path` is another file, it
is the generator's own implementation, and `:line` is used instead.

Released Ash records `line: 1, file:
{"deps/ash/lib/ash/code_interface.ex", N}` for every generated def.
After ash#2971, Ash expands each generated def at its `define` line, so
the debug info is `line: <define line>, file:
{"deps/ash/lib/ash/code_interface.ex", N}`. The foreign-file rule above
picks up `:line`. Ash did not use `@file`, because the function body's
lines still come from `code_interface.ex`, so stacktraces would show the
user's file with Ash's line numbers. Their approach keeps stacktraces
unchanged.

## Changes

- `beam.ReadDefinitionLines` walks `{:debug_info_v1, :elixir_erl,
{:elixir_v1, map, specs}}` and reads only `file`, `relative_file`, and
`definitions`. It steps over clause bodies without allocating. Clause
ASTs can nest very deep, so the ETF reader now steps over a term with a
count of the terms still to skip instead of recursion: any nesting costs
no stack, a count larger than the bytes left is rejected as corrupt, and
the old depth guard is gone. This is also about 1.8x faster on large
Dbgi chunks; Docs parsing speed is unchanged. Modules without Elixir
debug info (`debug_info: false`, Erlang modules, no chunk) return an
error and keep the fallback.
- `generatedDefinitionResultsFor` uses a recorded line only when all of
these conditions are true:
- The debug info was compiled from the file being opened (same absolute
path, or the relative path matches as a suffix, for a project that was
moved or is reached through a symlink).
- The line is after the module's own line. A line at or before it adds
nothing to the module result.
In all other cases, the result stays as before. A BEAM older than the
source still gives its line. Dexter cannot compile the project, so a
stale BEAM is the usual state while you edit, and the line from the last
compile is closer than the module line. Only edits to the declaring file
move it, and the next compile makes it exact. The line is not corrected
for those edits: every way to do that meant guessing at the compiled
text, and could send the editor to a wrong line that looked right.
- From #102: `beam.Function.Line` keeps the Docs chunk anno, and
`beam.ReadSourcePath` reads `:source` from the `CInf` chunk. The Docs
anno is often the same line as the Dbgi `:line`, but not always: Ash's
code interfaces give the docs line 1. It is used when a module has no
debug info, and `CInf` says which file the line is in.
- From #102: a generated module with no source row, such as a Spark
entity module, goes to the file it was compiled from. When that path
does not exist here (a BEAM built elsewhere), it is rebased onto
`lib/<app>`, `deps/<app>/lib/<app>`, or `deps/<app>`. The app name comes
from the recorded path, because Spark builds Ash's entity modules in
Ash's ebin from Spark's source. If no file is found, the lexical parent
stays the answer.
- Bare-call definition and call-hierarchy preparation call it directly.
Qualified definition, the references declaration, and `dexter lookup`
get it through `LookupName` (shared since #107). A recorded line is the
function's own definition, so `LookupName` returns it for a strict
(`ExactModule`) lookup too. Without a recorded line, strict and fallback
lookups do not change.
- The debug info and compile info are read on the first definition
request that needs them. It is memoized on the module's
generated-function cache entry, so the BEAM stamp invalidates it with
everything else.

### Also in this PR

- **Declaring call.** When the only line the BEAM records is the module
line (a `@before_compile` hook, or released Ash), the module's body is
searched for the call whose first argument is the function's name as an
atom. When several calls spell the name, as an Ash action and the code
interface that runs it do (`update :publish` and `define :publish`), the
macro whose calls name the most of the module's generated functions
wins: `define` names every interface, an action names only itself. A tie
keeps the module line.
- **Clauses.** A function with a clause per DSL call (`route :get`,
`route :post`) goes to every clause, from the line Dbgi records for each
clause.
- **Past the end of the file.** A recorded line the current text does
not have (`quote line: 99`) is dropped.
- **Module names.** Go-to-definition on a module that exists only as a
BEAM (`Module.create`) goes to the file and line the compiler recorded.
A module that a macro made with `defmodule unquote(name)` and a name it
computed records no module line, so it goes to its first function's
line.
- **Imports.** A bare call to a generated function of an imported module
resolves.
- **Defs in macro quotes.** The parser no longer indexes a def inside a
`quote` in a macro other than `__using__` as a function of the macro's
module. `defmacro route ... quote do def handle` made the index claim
that the DSL defines `handle/2`, so a consumer that imports the DSL
resolved `Consumer.handle` into the macro's body before the BEAM was
asked. What `__using__` injects and a quote in a helper function are
still indexed.
- **Older Elixir.** Elixir 1.17 and earlier record the module line under
`line`, not `anno`; both are read. A `location: :keep` macro defined
above its caller in the same file is no longer taken for an `@file`
stamp.
- **Worktree moves (fix for #111).** `git worktree move` renames the
directory and then rewrites its `.git` file in place, so a watcher can
read it empty and take the worktree for a plain directory: the fsnotify
backend reported every file in it, and the FSEvents backend ran a full
reconcile. A `.git` file that names no git directory yet now makes the
directory a pending top, which is checked again once git is done. This
was the cause of the flaky
`TestFSNotifyWatcherSkipsWorktreeAddedWhileRunning` on Linux; both
backends now have a deterministic test for it.
- **Compressed BEAMs.** A BEAM compiled with the `compressed` option (a
gzip stream, used by some Erlang dependencies) is decompressed instead
of rejected.
- **Integration tests.**
`TestDefinition_GeneratedFunctionsFromCompiler*` compile a DSL fixture
of each shape with `mix` (with and without debug info, and with a stale
source) and run in the integration job.

## Performance

A cold read on real Ash modules takes 0.8–2 ms (inflate plus walk;
`Repro.Chat`'s Dbgi is 19 KB compressed and 350 KB inflated). It happens
once per BEAM stamp and only on a definition miss for a generated
function. The parser change below bumps `IndexVersion` (and the daemon
contract), so each workspace rebuilds its index once after the upgrade,
through the parallel full build.

## Validation

- `go test ./...` and `make lint` pass.
- New `internal/beam` tests: own-file location, foreign location, plain
`:line`, macros and private defs, stripped or Erlang debug info,
truncation at every seventh byte, a clause body 200,000 ETF levels deep,
and a test that compiles real modules with `elixirc` (skipped if Elixir
is not installed) for `@file`, `location: :keep`, and plain quotes. It
also checks that the Docs anno and the `CInf` source agree with the
debug info.
- New LSP tests: qualified and bare definitions and call hierarchy go to
the recorded line, for both the `@file` shape and the Ash shape (`:line`
with a generator `file`). `LookupName` returns the recorded line with
and without `ExactModule`, and a strict lookup without a recorded line
keeps the module. A stale BEAM goes to the line it recorded, and never
to a line past the end of the file. A foreign location with no useful
`:line`, and debug info from a different source file, keep the module
line. The Docs anno gives the line when there is no Dbgi, and does not
when there is no `CInf` source. A sourceless generated module goes to
its rebased `deps` file from Dbgi or Docs, and to its lexical parent
when that file is missing.
- End to end on the repro app from #108 against Ash `main` (9fa5088,
includes ash#2971), with `lspprobe` for definition and `dexter lookup
--strict` for the CLI. Both give the same lines:

| Probe | main | this branch |
|---|---|---|
| `Chat.get_room_by_slug!` | `chat.ex:1` | `chat.ex:7` |
| `Chat.create_room` | `chat.ex:1` | `chat.ex:8` |
| `Repro.Chat.Room.rename` (resource `code_interface`) | `room.ex:1` |
`room.ex:19` |

End to end on a project with no dependencies and a DSL in the project,
for macros that are not Ash (`main` gives line 1 of `user.ex` for all of
them except `hello`):

| Generator | this branch |
|---|---|
| plain `quote` (`field :email`) | `user.ex:4` |
| `location: :keep` | `user.ex:6` |
| `@file {file, line}` | `user.ex:8` |
| `__using__` injection | `dsl.ex:5` (unchanged, the `def` in the quote)
|
| nested `defmodule` from a macro | `user.ex:10` |
| `Module.create` in the generator's file (no source row) | `dsl.ex:43`
|

The same results hold for a copy of the project that was not recompiled,
for an umbrella copied the same way (`apps/<app>/lib`), and for a
project in a directory named with `é` and `日本`.
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.

Record the define line on generated code interface functions

2 participants