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.
Types
@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.
@type content_node() :: Statifier.Document.Raise.t() | Statifier.Document.Log.t() | Statifier.Document.Assign.t() | Statifier.Document.If.t() | Statifier.Document.Foreach.t() | Statifier.Document.Script.t() | Statifier.Document.Send.t()
The executable-content node types lowering currently supports.
@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.
@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 }