Statifier.Compiler (Statifier v2.0.0)

Copy Markdown View Source

The fourth arrow of the parser pipeline: a validated %Statifier.Document{} in, {:ok, %Statifier.Machine{}} | {:error, [Statifier.Compiler.Error.t()]} out (docs/architecture.md:47-51). Nothing downstream of this pass ever sees a %Statifier.Document{} again - the interpreter accepts only a Machine (docs/architecture.md principle 4).

This phase interns every state to a flat, document-order tuple with parent pointers and self-inclusive descendant ranges (ADR-0005), resolves every initial to indexes, compiles every <transition> element - including an <initial> element's own transition and a :history state's default - into Statifier.Machine.Transition via its own transition pass, and compiles every executable-content node reachable through onentry, onexit, or a transition's own content into Statifier.Machine.Content, plus every :final state's <donedata> into Statifier.Machine.Donedata, via its own executable-content pass.

One walk, not two (states); a real second pass (transitions, content,

donedata)

The "numbering walk" and "reference resolution" are, for initial specifically, one traversal rather than two: a state's initial can only legally name a descendant of that state (validator checks 3 and 7), and descendants are always finished - numbered, and their own ids entered into id_to_index - before their ancestor is, because this walk is post-order on the way out of each subtree. So id_to_index already holds every id resolve_initial/3 could legally need by the time it runs, and resolving inline avoids threading the raw Statifier.Document.State tree through a second pass just to re-ask questions the first pass already had the answer to.

Transition target resolution cannot make the same move: a transition's target is not constrained to be a descendant of its source, so it can be a forward reference to a state the walk has not reached yet (id_to_index incomplete at the point the transition is visited). t_index and c_index assignment themselves are done inline, in the same walk that numbers states - each state's own <onentry>/<onexit> content, then its own transitions (its plain transitions, then its <initial> element's transition, in that order - :history's own transitions are its default) and each transition's own content, are assigned dense index values the moment the state itself is visited, before the walk descends into that state's children - mirroring how the state's own index is assigned before its children are numbered, and matching the source's own onentry*, onexit*, transition*, initial? element order (spec 3.3-3.9). That is enough to keep every t_index/c_index dense and correctly interleaved in document order without a second walk. What genuinely waits for the whole walk to finish is turning each transition's raw target id list into resolved indexes and compiling every expression (a transition's cond, a <log expr=...>, a <content>'s folded value) - all of that needs either the complete id_to_index or nothing but the raw node itself, so build_transitions/3, build_contents/2, build_donedata_map/1, and build_data_elements/2 each run once, after walk_siblings/4 returns. The first three's errors accumulate together, sorted by location.start_offset, mirroring Statifier.Lowering.finalize/2 (lib/statifier/lowering.ex:137-141) - a document with a bad cond on one transition and a bad <log expr=...> elsewhere reports both. build_data_elements/2 never joins that merge - a <data expr> that fails to compile is captured onto the compiled Statifier.Machine.Data node as {:invalid, error} rather than failing compile/1, so a document with a bad cond and a bad <data expr> reports only the cond's error.

<data> gets its own dense index space, d_index, assigned the same way t_index/c_index are: a state's own <datamodel>'s <data> children are assigned the moment the state itself is visited, before the walk descends into its children (assign_data/3). The root's own top-level <datamodel> is assigned before walk_siblings/4 is first called, so top-level <data> occupy d_index 0..n-1, preceding every state-scoped one.

All of the walk's mutable state - id_to_index, the states accumulator, the next unused t_index/c_index/d_index, the transitions/contents/donedata/data accumulators - travels together as one acc() map, rather than as seven-plus positional arguments: Credo.Check.Refactor.FunctionArity's limit is 8, and a numbering walk that also assigns c_index and d_index genuinely needs more independent pieces of state than that once each is its own argument.

Summary

Types

The numbering walk's threaded state (see moduledoc). id_to_index and states_acc belong to the interning pass; t_next/transitions_acc to the transition pass; c_next/contents_acc/donedata_acc to the executable-content pass; d_next/data_acc to the <data> pass - data_acc keyed by d_index, each entry carrying its raw %Statifier.Document.Data{} and the index of the state whose own <datamodel> declared it, so build_data_elements/2 can compile in d_index order and compile/1 can later group entries back by state index for with_data/2.

Functions

Compiles an already-validated %Statifier.Document{} into a flat %Statifier.Machine{}: every state interned to a document-order index with parent pointers and self-inclusive descendant ranges, every initial resolved to indexes, every transition (including an <initial> element's own and a :history state's default) compiled to Statifier.Machine.Transition, and every reachable executable-content node compiled to Statifier.Machine.Content or Statifier.Machine.Donedata.

Types

acc()

@type acc() :: %{
  id_to_index: %{optional(String.t()) => non_neg_integer()},
  states_acc: %{optional(non_neg_integer()) => Statifier.Machine.State.t()},
  t_next: non_neg_integer(),
  transitions_acc: %{optional(non_neg_integer()) => map()},
  c_next: non_neg_integer(),
  contents_acc: %{
    optional(non_neg_integer()) =>
      Statifier.Document.Raise.t()
      | Statifier.Document.Log.t()
      | Statifier.Document.Assign.t()
      | Statifier.Document.Script.t()
      | Statifier.Document.Send.t()
      | Statifier.Document.Cancel.t()
      | %{if: Statifier.Document.If.t(), branches: [[non_neg_integer()]]}
      | %{foreach: Statifier.Document.Foreach.t(), content: [non_neg_integer()]}
  },
  donedata_acc: %{
    optional(non_neg_integer()) => Statifier.Document.Donedata.t()
  },
  d_next: non_neg_integer(),
  data_acc: %{
    optional(non_neg_integer()) => %{
      data: Statifier.Document.Data.t(),
      state_index: non_neg_integer()
    }
  },
  invoke_acc: %{optional(non_neg_integer()) => [Statifier.Machine.Invoke.t()]},
  invoke_errors: [Statifier.Compiler.Error.t()]
}

The numbering walk's threaded state (see moduledoc). id_to_index and states_acc belong to the interning pass; t_next/transitions_acc to the transition pass; c_next/contents_acc/donedata_acc to the executable-content pass; d_next/data_acc to the <data> pass - data_acc keyed by d_index, each entry carrying its raw %Statifier.Document.Data{} and the index of the state whose own <datamodel> declared it, so build_data_elements/2 can compile in d_index order and compile/1 can later group entries back by state index for with_data/2.

contents_acc's value type is asymmetric on purpose: <raise>, <log>, <assign>, and <send> are stored raw, since none of the four has children of its own to number (<send>'s own <param>/<content> fold onto its own fields rather than becoming further numbered content nodes, the same way <invoke>'s do) - but an <if> and a <foreach> do, so their entries carry the raw node alongside the c_index list(s) assign_content_nodes/2's own recursion assigned: %{if: DIf.t(), branches: [[non_neg_integer()]]} for an <if> (one list per branch), %{foreach: DForeach.t(), content: [non_neg_integer()]} for a <foreach> (one flat list, one nesting level shallower) - mirroring transitions_acc's own %{transition: ..., source: ..., content: ...} shape (:396-408).

invoke_acc/invoke_errors belong to the <invoke> pass - the one pass that does not fit the "numbered during the walk, compiled in a deferred pass" split every other field above follows. A Machine.Invoke's <finalize> is executable content, so its Machine.Block needs the same c_next counter every <onentry>/<onexit> block draws from (assign_blocks/2), which only exists while the walk is threading acc through walk_siblings/4 - so the whole %Statifier.Machine.Invoke{}, finalize block included, is built complete during the walk and accumulated into invoke_acc, keyed by owning state index, rather than deferred the way donedata_acc is. Building inline means a typeexpr/ srcexpr/<content>/<param> compile failure cannot join the deferred passes' own collect/1 merge the way build_transitions/3's and build_contents/2's do - invoke_errors is where those land instead, concatenated into compile/1's own error merge before the final sort. A namelist entry's compile failure never reaches invoke_errors: it defers to runtime instead, captured as {:invalid, error} on the entry's own %Machine.Param{} (build_namelist_param/5, 5.9.4).

Functions

compile(document)

@spec compile(document :: Statifier.Document.t()) ::
  {:ok, Statifier.Machine.t()} | {:error, [Statifier.Compiler.Error.t()]}

Compiles an already-validated %Statifier.Document{} into a flat %Statifier.Machine{}: every state interned to a document-order index with parent pointers and self-inclusive descendant ranges, every initial resolved to indexes, every transition (including an <initial> element's own and a :history state's default) compiled to Statifier.Machine.Transition, and every reachable executable-content node compiled to Statifier.Machine.Content or Statifier.Machine.Donedata.

Returns {:ok, machine} on success. A Document that reached this stage is expected to already be structurally valid - compile/1 does not re-run validator checks - so {:error, errors} here has two possible sources: a compiler defect (an id that fails to resolve during the numbering walk), or a malformed input document whose element class still rejects at load time under docs/datamodel.md's per-element-class deferral policy (cond, <log expr>, <content expr>, <foreach array>, and <param>, among others - see that policy paragraph for the full, current list). Callers should not assume the tuple is always unexpected: routine error-event handling still applies to the load-time-rejecting classes, while a defect is genuinely unexpected.