View Source Alembic.Inheritance (alembic v0.1.0)

Template inheritance: {% extends "base.html" %} / {% block name %} / {% endblock %} and multi-level chains. Pure — no direct file I/O; a loader function is injected so this module is testable without a filesystem.

Two-pass structure, directly analogous to a multi-pass compiler:

  • Pass 1 — collect (collect_blocks/1): walk a template's AST and gather its {:block, name, body} definitions into a name => body map — a symbol-collection pass.
  • Pass 2 — resolve (resolve/2): walk a (possibly different) template's AST and splice each {:block, name, default} node's body for the matching override from the collected map, or keep default when there is none — a symbol-resolution / linking pass. The child's block bodies are the object-file symbols; the parent template is the shared library with unresolved references; resolve/2 is the linker.

resolve/2 always splices the winning body directly into the surrounding node list — {:block, _, _} nodes never survive into the final AST. Alembic.Evaluator has no clause for :block at all; by the time preprocess/2 hands it an AST, every block has already become ordinary content.

Multi-level resolution order (why blocks can't resolve level-by-level)

Naively resolving each parent/child pair as soon as it's loaded doesn't work for 3+ levels: resolving grandparent-vs-parent first would splice away the grandparent's {:block, "title", _} placeholder before the child ever gets a chance to override it. Instead, resolve_chain/3 walks all the way up to the root ancestor (the first template with no {:extends, _}), merging each level's own blocks into an accumulator as it goes (closer-to-child levels win on a name collision), and only calls resolve/2 once, at the root, against the root's never-touched original AST.

Deviations from a literal reading of issue 1.4.4

  • collect_blocks/1 returns {:ok, map} | {:error, {:duplicate_block, name}}, not a bare map — the issue's own task list requires erroring on a duplicate block name, which a bare-map return type cannot express.

  • block.super substitution is shallow: only {:output, ["block", "super"], []} nodes directly in an override's own body are replaced — it does not recurse into nested if/for branches inside that override. This matches the issue's own worked example, which only shows top-level usage.

Summary

Functions

Pass 1 — walks an AST (recursing into if/for branches) and collects its {:block, name, body} definitions into a name => body map.

Entry point for the top-level render pipeline, called before Alembic.Evaluator.eval/2. When the AST has no top-level {:extends, _}, it still runs through resolve/2 with an empty override map — a template can itself define {:block, _, _} nodes (a "base" layout, rendered standalone rather than through a child that extends it) and those still need their default bodies spliced in; the Evaluator has no clause for a raw :block node.

Pass 2 — walks an AST, splicing each {:block, name, default} node's body for child_blocks[name] when present, keeping default otherwise. {:block, _, _} nodes never survive into the result.

Walks a {:extends, _} chain to its root ancestor, merging each level's blocks as it goes (closer-to-child levels win), then resolves once against the root's own AST. loader_fn fetches a parent template's source given its name — see Alembic.Loader.build_loader/1.

Types

@type loader_fn() :: (String.t() -> {:ok, String.t()} | {:error, term()})
@type reason() ::
  {:duplicate_block, String.t()}
  | {:circular_inheritance, String.t()}
  | :inheritance_depth_exceeded
  | {:parent_compile_error, term()}
  | term()

Functions

@spec collect_blocks([Alembic.AST.ast_node()]) ::
  {:ok, %{required(String.t()) => [Alembic.AST.ast_node()]}}
  | {:error, {:duplicate_block, String.t()}}

Pass 1 — walks an AST (recursing into if/for branches) and collects its {:block, name, body} definitions into a name => body map.

Examples

iex> ast = [{:text, "<html>"}, {:block, "title", [{:text, "Default"}]}, {:text, "</html>"}]
iex> Alembic.Inheritance.collect_blocks(ast)
{:ok, %{"title" => [{:text, "Default"}]}}
Link to this function

preprocess(ast, loader_fn)

View Source
@spec preprocess([Alembic.AST.ast_node()], loader_fn()) ::
  {:ok, [Alembic.AST.ast_node()]} | {:error, reason()}

Entry point for the top-level render pipeline, called before Alembic.Evaluator.eval/2. When the AST has no top-level {:extends, _}, it still runs through resolve/2 with an empty override map — a template can itself define {:block, _, _} nodes (a "base" layout, rendered standalone rather than through a child that extends it) and those still need their default bodies spliced in; the Evaluator has no clause for a raw :block node.

Examples

iex> {:ok, tokens} = Alembic.Lexer.tokenize("hello {{ name }}")
iex> {:ok, ast} = Alembic.Parser.parse(tokens)
iex> Alembic.Inheritance.preprocess(ast, fn _ -> {:error, :unused} end)
{:ok, [{:text, "hello "}, {:output, ["name"], []}]}
Link to this function

resolve(nodes, child_blocks)

View Source
@spec resolve([Alembic.AST.ast_node()], %{
  required(String.t()) => [Alembic.AST.ast_node()]
}) :: [
  Alembic.AST.ast_node()
]

Pass 2 — walks an AST, splicing each {:block, name, default} node's body for child_blocks[name] when present, keeping default otherwise. {:block, _, _} nodes never survive into the result.

Examples

iex> parent = [{:text, "<a>"}, {:block, "x", [{:text, "default"}]}, {:text, "</a>"}]
iex> Alembic.Inheritance.resolve(parent, %{"x" => [{:text, "override"}]})
[{:text, "<a>"}, {:text, "override"}, {:text, "</a>"}]
Link to this function

resolve_chain(ast, loader_fn, visited \\ MapSet.new())

View Source
@spec resolve_chain([Alembic.AST.ast_node()], loader_fn(), MapSet.t()) ::
  {:ok, [Alembic.AST.ast_node()]} | {:error, reason()}

Walks a {:extends, _} chain to its root ancestor, merging each level's blocks as it goes (closer-to-child levels win), then resolves once against the root's own AST. loader_fn fetches a parent template's source given its name — see Alembic.Loader.build_loader/1.

Examples

iex> base_source = ~s(<html>{% block title %}Default{% endblock %}</html>)
iex> loader = fn "base.html" -> {:ok, base_source} end
iex> {:ok, tokens} = Alembic.Lexer.tokenize(~s({% extends "base.html" %}{% block title %}Custom{% endblock %}))
iex> {:ok, child_ast} = Alembic.Parser.parse(tokens)
iex> Alembic.Inheritance.resolve_chain(child_ast, loader)
{:ok, [{:text, "<html>"}, {:text, "Custom"}, {:text, "</html>"}]}