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 aname => bodymap — 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 keepdefaultwhen 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/2is 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/1returns{: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.supersubstitution is shallow: only{:output, ["block", "super"], []}nodes directly in an override's own body are replaced — it does not recurse into nestedif/forbranches 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
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"}]}}
@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"], []}]}
@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>"}]
@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>"}]}