Statifier.Parser.Handler (Statifier v2.0.0)

Copy Markdown View Source

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

cursor()

@type cursor() ::
  {offset :: non_neg_integer(), line :: pos_integer(), column :: pos_integer()}

frame()

@type frame() :: %{
  record: Statifier.Parser.Markup.t() | nil,
  attributes: [Statifier.Parser.DOM.Attribute.t()],
  children: [Statifier.Parser.DOM.Element.child()]
}

t()

@type t() :: %{
  source: binary(),
  markup: [Statifier.Parser.Markup.t()],
  cursor: cursor(),
  stack: [frame()],
  text: binary()
}

Functions

init(source, markup)

@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).