Descripex (descripex v0.11.0)

Copy Markdown View Source

Single-source API declarations for self-describing Elixir functions.

The api macro is the sole source of truth for function documentation. It generates @doc text, emits @doc hints: metadata for machine consumption, validates param names at compile time, and produces __api__/0 and __api__/1 introspection functions.

Usage

defmodule MyLib.Funding do
  use Descripex, namespace: "/funding"

  api(:annualize, "Annualize a per-period funding rate to APR.",
    params: [
      rate: [kind: :value, description: "Per-period funding rate as decimal"],
      period_hours: [kind: :value, default: 8, description: "Hours per period"]
    ],
    returns: %{type: :float, description: "Annualized percentage rate (APR)"}
  )

  @spec annualize(number(), pos_integer()) :: float()
  def annualize(rate, period_hours \\ 8), do: ...
end

No separate @doc block needed — the macro generates it from the declaration.

Introspection

MyLib.Funding.__api__()
# => [%{name: :annualize, arity: 2, ...}, ...]

MyLib.Funding.__api__(:annualize)
# => %{name: :annualize, arity: 2, param_order: [:rate, :period_hours], spec: "...", hints: %{...}}

The param_order field lists the positional parameter names in declaration order (including defaulted params). Consumers that dispatch named arguments positionally — e.g. mapping MCP/JSON tool arguments onto apply(module, fun, args)must order arguments by param_order, not by Map.keys(hints.params). The hints[:params] map discards declaration order, so Map.keys/1 returns hash order and silently swaps multi-parameter calls.

param_order lists every declared positional param, including those with defaults. A consumer that omits an optional argument must dispatch on the function's lower arity rather than blindly mapping all of param_order — the defaulted tail can be dropped from the right.

__api__/0 vs the BEAM doc chunk

__api__/0 is the runtime-enriched introspection surface: it fills hints.params.<name>.schema / hints.opts.<name>.schema from the function's @spec and declared type: via enrich_with_specs/2. The BEAM doc chunk (Code.fetch_docs/1meta[:hints]) is the raw declared surface — written at compile time, before the module can read its own specs, so it is not enriched.

This asymmetry is intentional. The two surfaces therefore diverge on :schema for any param/opt that gains a spec-derived schema. Consumers that assert the two are equal (e.g. to verify each @doc hints: block is attached to the correctly named function) must not compare them raw — normalize both with normalize_for_doc_compare/1, which strips every :schema key:

Descripex.normalize_for_doc_compare(Mod.__api__(:f).hints) ==
  Descripex.normalize_for_doc_compare(meta_hints)

Summary

Functions

Return the list of modules registered with this library.

Build machine-readable hints map from an api declaration's description and options.

Return a Level 1 overview of all modules in this library.

Return Level 2 function list for a module (by full atom or short name).

Return Level 3 function detail (or nil if not found).

Declare an api whose opts is a compile-time variable, not a literal keyword list.

Enrich compile-time api entries with specs fetched at runtime.

Generate human-readable @doc text from an api declaration's description and options.

Strip every :schema key from a hints map so the runtime-enriched __api__/0 surface can be compared for equality against the raw compile-time doc chunk (Code.fetch_docs/1meta[:hints]).

Functions

__descripex_modules__()

@spec __descripex_modules__() :: [module()]

Return the list of modules registered with this library.

build_hints(description, opts)

@spec build_hints(
  String.t(),
  keyword()
) :: map()

Build machine-readable hints map from an api declaration's description and options.

describe()

@spec describe() :: [map()]

Return a Level 1 overview of all modules in this library.

describe(mod_or_short)

@spec describe(module() | atom()) :: [map()]

Return Level 2 function list for a module (by full atom or short name).

describe(mod_or_short, func_name)

@spec describe(module() | atom(), atom()) :: map() | nil

Return Level 3 function detail (or nil if not found).

emit_api(name, description, opts)

(macro)
@spec emit_api(atom(), String.t(), Macro.t()) :: Macro.t()

Declare an api whose opts is a compile-time variable, not a literal keyword list.

api/3 runs preprocess_schemas/1 on the opts AST at macro-expansion time, which only works when opts is a literal keyword-list AST. Callers that build opts inside a for-comprehension or any other macro-time variable cannot use api/3. emit_api/3 emits the identical @doc, @doc hints:, and accumulator entry as api/3, but skips schema preprocessing — so it accepts a variable opts AST.

Compile-time validation (__before_compile__) still fires for emit_api/3 declarations, identically to api/3, since both accumulate into @descripex_api_declarations.

Schema keys are NOT preprocessed

Because preprocessing is skipped, the caller is responsible for pre-converting any schema: keys to JSON Schema maps before passing them in. For-comprehension callers typically declare no schema: keys. If you have a literal opts keyword list (with or without schema:), use api/3 instead — emit_api/3 raises ArgumentError on a literal keyword-list opts to steer you to the macro that runs preprocessing.

Example

for {name, opts} <- compile_time_method_defs() do
  emit_api(name, "Generated declaration", opts)
end

enrich_with_specs(module, entries)

@spec enrich_with_specs(module(), [map()]) :: [map()]

Enrich compile-time api entries with specs fetched at runtime.

generate_doc(description, opts)

@spec generate_doc(
  String.t(),
  keyword()
) :: String.t()

Generate human-readable @doc text from an api declaration's description and options.

normalize_for_doc_compare(hints)

@spec normalize_for_doc_compare(map()) :: map()

Strip every :schema key from a hints map so the runtime-enriched __api__/0 surface can be compared for equality against the raw compile-time doc chunk (Code.fetch_docs/1meta[:hints]).

__api__/0 fills hints.params.<name>.schema / hints.opts.<name>.schema from @spec/type: at runtime (see enrich_with_specs/2), but the doc chunk is written at compile time and is not enriched — a module can't read its own specs at __before_compile__. So the two surfaces diverge on :schema, and a consumer that asserts they are equal (e.g. to detect api() misattachment) false-positives purely on the injected schema.

This drops all schema keys — author-declared and spec-injected alike, which are indistinguishable once merged — from :params, :opts, and :returns. Apply it to both surfaces before comparing:

Descripex.normalize_for_doc_compare(Mod.__api__(:f).hints) ==
  Descripex.normalize_for_doc_compare(meta_hints)