Statifier.Document (Statifier v2.0.0)

Copy Markdown View Source

The typed parse target: what lowering produces, and what the validator and the Machine compiler consume.

This is the layer docs/architecture.md:47 names directly: "Document (typed structs, source locations, uncompiled expressions)". Interning, indexing, and expression compilation are the compiler's job, not this layer's - a %Statifier.Document{} tree is walkable and unambiguous, not fast, and it deliberately stays the pre-validation type so it can hold the malformed shapes the validator exists to report (docs/architecture.md:33-36, principle 4).

Raw expressions

cond, log's expr, and content's expr are String.t() | nil - the exact predicator source the author wrote, uncompiled. Compilation into the {:static, term()} | {:compiled, %Predicator.Compiled{}, source} sum type happens once at Machine-build time (docs/datamodel.md), so nothing in lib/statifier/document/ may reference Predicator.

The attribute_locations contract

Every node with source attributes carries an attribute_locations map, keyed by an attribute name atom drawn from a fixed, known set per element, valued by that attribute's value span - the text inside the quotes, which is what ADR-0014's arithmetic needs and what a diagnostic underlines. The node's own location already covers the whole element for anything coarser.

The map's defining rule: an entry exists only for an attribute that was actually written in the source. Lowering applies defaults (:external, :early, :shallow), so a field's runtime value alone cannot say whether the author wrote it or the default applied. Map.has_key?(node.attribute_locations, :type) is exactly the "was it written" question, and the same lookup is the location a diagnostic would want. A struct with a non-nil default (like Statifier.Document.Transition.type) and no corresponding key in attribute_locations means the default applied, not that the author wrote the default's value explicitly.

Inherited from Statifier.Parser.DOM.Attribute: the stored string is entity-expanded while the span covers raw source, so an offset inside a value containing an entity or character reference does not map 1:1 onto the source.

xmlns versus namespace

xmlns is the literal attribute as the author wrote it (or nil when absent) - it never resolves prefixes. namespace is the URI the root element's name actually resolves to after prefix scoping (Statifier.Lowering.Namespace.resolve/2), stamped on by Statifier.Lowering.lower/1. The two differ exactly for a prefix-declared root such as <s:scxml xmlns:s="...">, where xmlns is nil but namespace is the resolved URI; the validator's check 9 reads namespace, not xmlns, so a spec-conformant prefixed document is not misreported as missing its namespace.

Why <scxml> is its own struct, not a State with kind: :scxml

%Statifier.Document{} is the root, and there is no kind: :scxml on Statifier.Document.State. The alternative - %Document{root: %State{kind: :scxml}} - was considered because the compiler interns the root as state index 0 with kind: :scxml, so that LCCA and get_transition_domain fall out without a special case. It is rejected here for three reasons.

First, the compiler's root state is a synthesis, not a copy: it needs an index, a range, and resolved initial indexes, none of which the Document has. Building it from %Document{}'s fields is one function either way, so the :scxml kind costs the compiler nothing to create and buys the Document layer nothing to carry.

Second, the split would put <scxml>'s attributes in two places: initial and location on the root state, name/version/binding/datamodel on the wrapper, because those four are not state fields on any other kind. One struct holding all of <scxml>'s attributes is the honest shape.

Third, the generic-walk argument that favored the single State struct (Statifier.Document.State's moduledoc) does not transfer here. A walk over every state is document.states |> Enum.flat_map(&walk/1) - one entry clause, not a per-kind dispatch - and Document.states has the same [State.t()] type as State.states, so the recursion below the entry point is uniform either way.

The compiler synthesizes its index-0 root state from these fields directly rather than copying a %State{} that never existed at this layer.

Summary

Types

Value spans only, keyed by the attribute's name as an atom drawn from a fixed, known set per element. See the moduledoc for the "written, not defaulted" contract this map carries.

The executable-content node types lowering currently supports.

The kinds a Statifier.Document.State can have. Equal to the element name that produced it - <initial> is not a member (it has no id and is a slot on its parent, not a targetable state; see Statifier.Document.Initial), and neither are the compiler-only widenings :atomic / :compound.

t()

Types

attribute_locations()

@type attribute_locations() :: %{optional(atom()) => Statifier.Parser.Location.t()}

Value spans only, keyed by the attribute's name as an atom drawn from a fixed, known set per element. See the moduledoc for the "written, not defaulted" contract this map carries.

content_node()

The executable-content node types lowering currently supports.

state_kind()

@type state_kind() :: :state | :parallel | :final | :history

The kinds a Statifier.Document.State can have. Equal to the element name that produced it - <initial> is not a member (it has no id and is a slot on its parent, not a targetable state; see Statifier.Document.Initial), and neither are the compiler-only widenings :atomic / :compound.

t()

@type t() :: %Statifier.Document{
  attribute_locations: attribute_locations(),
  binding: :early | :late,
  datamodel: String.t() | nil,
  datamodel_element: Statifier.Document.Datamodel.t() | nil,
  initial: [String.t()],
  location: Statifier.Parser.Location.t(),
  name: String.t() | nil,
  namespace: String.t() | nil,
  scripts: [Statifier.Document.Script.t()],
  states: [Statifier.Document.State.t()],
  version: String.t() | nil,
  xmlns: String.t() | nil
}