Adze.Definition (Adze v0.1.0)

Copy Markdown View Source

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. @moduledoc is excluded (module-level).
  • @type, @typep, @opaque, @callback, and @macrocallback are 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

definition_spec()

@type definition_spec() :: String.t() | {atom(), non_neg_integer()}

opts()

@type opts() :: [{:include_attrs, [atom()]}]

t()

@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

find(source, spec, opts \\ [])

@spec find(String.t(), definition_spec(), opts()) ::
  {:ok, t()} | {:error, :not_found} | {:error, term()}

find_file(path, spec, opts \\ [])

@spec find_file(Path.t(), definition_spec(), opts()) ::
  {:ok, t()} | {:error, :not_found} | {:error, term()}

list(source, opts \\ [])

@spec list(String.t(), opts()) :: {:ok, [t()]} | {:error, term()}

list_file(path, opts \\ [])

@spec list_file(Path.t(), opts()) :: {:ok, [t()]} | {:error, term()}