The Saxy.Handler that builds a Statifier.Parser.DOM tree, attaching the
positions Saxy does not supply.
Six clauses, and not one of them mentions an element name. v1's handler
lowered elements into typed structs as it went, which needed a clause per
(element type x parent context) pair - 73 of them in one module
(../statifier/lib/statifier/parser/scxml/state_stack.ex). Building a
generic node instead makes :end_element a single clause: append a child
to the open frame, whatever either of them is called.
The zip
Saxy gives semantics and no positions; Statifier.Parser.Markup gives
positions and no semantics. Both produce element boundaries in document
order, and that is the join key - the Nth start-tag record the scanner
found is the Nth :start_element Saxy emits - so this handler carries the
scanner's records as a queue and pops exactly one per element event.
The two passes disagreeing is the mechanism's one real risk, so it is
checked rather than assumed: every popped record's name is compared against
the name in the Saxy event and a mismatch aborts with
{:location_desync, expected, got} instead of attaching a wrong location.
Because Saxy does no namespace processing and no expansion touches names,
the two spellings are byte-identical whenever the scan is in sync.
Every pop advances cursor to the popped record's end, which is what makes
text spans computable: a run of character data covers everything between
the cursor and the start of the next queued record, raw source included.
Attribute values
build_attributes/3 normalizes each attribute's value per XML 1.0 3.3.3
(ADR-0043), walking the raw slice (Location.slice(value_location, source))
against Saxy's expanded value with Location.normalize_attribute_value/3 -
Saxy's value alone cannot tell a literal newline from an expanded ,
so the raw text is what disambiguates. When the scanner has no record for an
attribute, or the record's value_location is nil, there is no raw slice
to walk and Saxy's value stands unnormalized.
Character data
add_text/2 folds each text run's value per XML 1.0 2.11 (ADR-0045),
walking the run's raw span (text_span/1) against an unfolded
accumulation of Saxy's characters with Location.normalize_character_data/3.
The accumulation, not value itself, is what gets re-walked on every event
of a coalescing run: walk_units/3's raw-versus-expanded pairing requires
both sides to empty together, and a raw \r no longer pairs against an
already-folded \n the way it pairs against Saxy's still-unfolded \r\n.
State carries this accumulation in a text field, reset implicitly by the
coalescing check already in add_text/2 - a fresh run starts from ""
because its frame's head child is not a DOM.Text. When the raw slice and
the accumulation desync, normalize_character_data/3 degrades to the
unfolded value, the same posture attribute normalization takes.
Summary
Functions
The initial handler state for source and its scanned markup records.
Types
@type cursor() :: {offset :: non_neg_integer(), line :: pos_integer(), column :: pos_integer()}
@type frame() :: %{ record: Statifier.Parser.Markup.t() | nil, attributes: [Statifier.Parser.DOM.Attribute.t()], children: [Statifier.Parser.DOM.Element.child()] }
@type t() :: %{ source: binary(), markup: [Statifier.Parser.Markup.t()], cursor: cursor(), stack: [frame()], text: binary() }
Functions
@spec init(source :: binary(), markup :: [Statifier.Parser.Markup.t()]) :: t()
The initial handler state for source and its scanned markup records.
The stack starts with one frame standing in for the document itself, so
:end_element never has to special-case the root: the root element is
appended to that frame like any other child, and :end_document reads it
back out.
text starts empty; it is the unfolded accumulation add_text/2 grows for
whichever run is currently open (see the moduledoc's "Character data"
section).