Logical-definition primitive.
A definition is a def (or defp / defmacro / defmacrop /
defguard / defguardp / defdelegate) bundled with its leading
allowlisted attributes (@doc, @spec, @typedoc, @impl,
@deprecated, @since) and any leading comments those nodes carry.
Used by write ops (mv, extract, topo) — the unit of mechanical
edit is always the whole group, never a bare def.
Grouping rules
- Group key is
{kind, name, arity}. Multi-clause defs that share the same key and are AST-adjacent collapse into one definition. - Blank lines do not break adjacency — only intervening AST nodes do.
- Allowlisted attributes directly preceding the def attach to it.
@moduledocis excluded (module-level). @type,@typep,@opaque,@callback, and@macrocallbackare also valid attachment targets for@doc/@spec/@typedoc/etc. Treat them as consumers — when one appears, it absorbs any pending allowlisted attrs and resets state. No definition is emitted for them (definitions are def-family only); they just prevent attrs intended for a type or callback from poisoning the next def.- A non-allowlisted attribute (e.g.
@some_const 5,@dialyzer ...,@job opts) interleaved between an allowlisted attribute and the def is genuinely ambiguous and raises{:error, {:ambiguous_attribute, info}}— the caller decides whether to skip the attr or attach it. - Leading comments hang off the next AST node (Sourceror behavior) so the walk collects them from each attribute it pulls in.
Ambiguous attribute error shape
{:error,
{:ambiguous_attribute,
%{
def: %{kind: :def, name: :foo, arity: 0, line: 12},
attributes: [%{name: :spec, line: 10}, %{name: :doc, line: 9}],
intervening: [%{name: :some_const, line: 11}]
}}}def— the def whose attachment is ambiguous.attributes— the allowlisted attrs that were pending.intervening— the non-allowlisted attrs that interrupted the run. Always at least one.
Widening the allowlist
Some Elixir libraries introduce attributes that semantically belong to
the next def — Oban Pro's @job, the Decorator library's @decorate,
occasionally @dialyzer. There are two ways to treat them as
attachable:
Per call — pass include_attrs: in opts:
Adze.Definition.find(source, "foo/1", include_attrs: [:job, :decorate])Project-wide — set the application config in config/config.exs:
config :adze, include_attrs: [:job, :decorate]This is the recommended path when consuming adze as a project dep
(mix adze ...): set the allowlist once based on what your project
uses, and every Adze.Definition call — including those made by
future mv / extract / topo write ops — picks it up.
Both sources are merged with the built-in allowlist (@doc, @spec,
@typedoc, @impl, @deprecated, @since). Per-call include_attrs
adds to the application config; it does not override.
User-provided names take precedence over the built-in consumer list
(@type, @typep, @opaque, @callback, @macrocallback) — if you
pass [:type] (you probably shouldn't), @type will attach to a def
instead of acting as a consumer.
Summary
Types
@type definition_spec() :: String.t() | {atom(), non_neg_integer()}
@type opts() :: [{:include_attrs, [atom()]}]
@type t() :: %Adze.Definition{ arity: non_neg_integer(), kind: atom(), module: String.t(), name: atom(), nodes: [Macro.t()], parts: %{ leading_comments: [map()], attributes: [{atom(), Macro.t()}], clauses: [Macro.t()] }, range: Sourceror.Range.t() | nil, visibility: :public | :private }
Functions
@spec find(String.t(), definition_spec(), opts()) :: {:ok, t()} | {:error, :not_found} | {:error, term()}
@spec find_file(Path.t(), definition_spec(), opts()) :: {:ok, t()} | {:error, :not_found} | {:error, term()}