Commit Graph

144 Commits

Author SHA1 Message Date
2a77b56d2f feat(xpath): eval-time namespace context and XPointer xmlns()/xpath1() schemes
- Eval-time prefix bindings: Query.evaluate(over:namespaces:) and
  value(at:...:namespaces:) supply prefix to URI bindings, so a name test
  like q:item resolves q to a URI and matches by namespace regardless of
  the document's own prefix. The match is namespace-aware only when
  bindings are present, so existing unprefixed/prefix-string queries are
  unchanged.
- XPointer schemes: xpath1() evaluates an XPath expression (like
  xpointer()), and xmlns(p=uri) binds a prefix for the expression parts
  that follow it, threaded into the XPath evaluation.

The range() scheme (and so XInclude range inclusion) remains.

Part of #88
2026-06-08 16:00:15 +02:00
0c9aa47233 Merge epic/87-xinclude: content negotiation and cycle detection
Part of #87
2026-06-08 15:52:19 +02:00
b13cdca739 feat(xinclude): content-negotiation hints, cycle detection, text+fragment
Three XInclude gaps from the audit:

- accept / accept-language / encoding are surfaced to the loader via a new
  XIncludeRequest and a process(_:base:loading:) overload, so a loader can
  honor content negotiation. The existing loadingURI form is kept.
- Cycle detection tracks the visited (resolved-URI) set along the current
  inclusion chain and raises XIncludeError.cycle, instead of relying only
  on the depth backstop; a non-cyclic diamond include is not flagged.
- parse="text" combined with an xpointer is rejected
  (XIncludeError.textWithFragment), since a fragment cannot select within
  unparsed text.

xpointer range() inclusion still depends on the XPointer range() scheme
(tracked in the XPointer issue).

Part of #87
2026-06-08 15:52:18 +02:00
68a8ff2887 Merge epic/80-schematron: dynamic messages and phases
Part of #80
2026-06-08 15:45:28 +02:00
68122ad887 feat(schematron): dynamic message content and phase support
Two ISO Schematron gaps, the most validator-relevant first:

- Dynamic messages: an assert/report message is now a template, so
  <value-of select=> renders the string value of its XPath at the context
  node and <name> renders the node's name. A finding can report actual
  values ("Age 120 must be under 100") instead of static text.
- Phases: <phase id><active pattern=> scopes which patterns run.
  validate(_:phase:) honors a requested phase or the schema's
  defaultPhase; nil or #ALL runs every pattern.

let variables, abstract rules/extends, diagnostics, and key()/document()
remain (the last depends on XSLT-style function injection).

Part of #80
2026-06-08 15:45:27 +02:00
5341ec3d46 test(validation): per-rule isolation tests in the OpenAPIKit style
Install each builtin validation alone on a blank validator, run it
against a seeded subject, and assert the resulting errors by count,
reason, and coding path, matching the validation-rules recipe.

- Structural.uniqueAttributes (multi-error, one per duplicate).
- DTD.contentModel, DTD.attributeDeclarations (located reasons).
- DTD.undeclaredElement (the single-error Bool form: Failed to satisfy).
- DTD.identifierIntegrity (tree-wide, located at the document root).
- XSD.contentValidity (located at the document element).
2026-06-08 15:39:18 +02:00
ef0c64c45c test(validation): unit-test the remaining combinators and error rendering
Bring the validation-framework unit tests up to the OpenAPIKit idiom's
bar: each combinator is exercised as a pure function against a hand-built
context.

- The comparison operators (>, >=, <, <=, !=) lifting a KeyPath and a
  literal into a predicate.
- lift running child validations against a lifted value.
- unwrap erroring on nil and otherwise running child validations.
- The erased wrapper yielding two errors when the same value of the same
  type is applied twice.
- ValidationError rendering: at-path, at-root-of-document, and the
  stripped trailing period.
2026-06-08 15:34:34 +02:00
f505a45f0f Merge epic/86-catalog: suffix entries and prefer attribute
Part of #86
2026-06-08 15:31:10 +02:00
b7da5652f3 feat(catalog): systemSuffix/uriSuffix entries and the prefer attribute
Two OASIS catalog gaps from the audit:

- systemSuffix and uriSuffix entries: an identifier ending in the rule's
  suffix maps to its URI, longest suffix winning, tried after exact and
  rewrite matches.
- prefer=public|system on the catalog: a system match always wins, and
  the public identifier is consulted as a fallback only when prefer is
  public or no system identifier was given (the OASIS rule). The default
  stays prefer=public.

xml:base / RFC 3986 relative-URI resolution of entry URIs and the legacy
SGML catalog format remain.

Part of #86
2026-06-08 15:31:10 +02:00
5b24530a72 Merge epic/91-serialization: serializer and writer options
Closes #91
2026-06-08 15:24:50 +02:00
cd2f30a262 feat(emitting): attribute quote style, line endings, xml:space, writer namespaces
Broaden the serializer and writer options from the audit:

- attributeQuote selects single or double quotes; the active quote is
  escaped in values (apostrophe as &apos; in single-quote mode).
- lineEnding controls the inserted newline (set to CRLF for \r\n output).
- xml:space=preserve suppresses pretty-print indentation for an element
  and its subtree; xml:space=default re-enables it.
- Writer gains writeStartElementNS(prefix:localName:namespaceURI:) and
  writeNamespace(prefix:uri:), so callers no longer hand-format prefixes
  and xmlns attributes.

CDATA-vs-escape and entity re-escaping strategies are left as a
follow-up; the current escaping is the safe default.

Closes #91
2026-06-08 15:24:49 +02:00
57aa06b705 Merge epic/90-dom-helpers: TreeNode editing helpers
Closes #90
2026-06-08 15:19:33 +02:00
741e65cae1 feat(model): TreeNode editing helpers (attributes, shallow copy, order, normalize)
Add the DOM/tree editing primitives from the audit that the editor use
case needs:

- Attribute helpers: attributeValue(_:), setAttribute(_:_:) (update in
  place or append), removeAttribute(_:).
- shallowCopy() copies a node alone, complementing the deep copy().
- precedes(_:) compares two nodes in document order (ancestor before
  descendant, earlier sibling before later; unrelated nodes compare false).
- normalize() coalesces adjacent text children, drops empty text, and
  recurses into elements; CDATA is left intact.

First-class namespace/DOCTYPE/entity-reference nodes (an invasive
TreeNodeKind change) and cross-document adoption/ownerDocument (no
document-identity model) are left for a follow-up.

Closes #90
2026-06-08 15:19:33 +02:00
0fb7d08303 Merge epic/89-regex-categories: Unicode categories and class subtraction
Closes #89
2026-06-08 15:15:47 +02:00
f5c04058dc feat(regex): Unicode category escapes and character-class subtraction
Close the two XSD-regex gaps from the audit:

- \p{...} and \P{...} now resolve Unicode general categories (one-letter
  groups like \p{L} and two-letter codes like \p{Lu}/\p{Nd}) through
  Swift's own scalar generalCategory, so no character tables are vendored.
  A curated set of well-known \p{Is<Block>} blocks is supported; an
  unknown category or block name is a compile error rather than a guess.
- Character-class subtraction [a-z-[aeiou]] removes the subtrahend from a
  class, and nests ([a-z-[b-d-[c]]]) and composes with categories.

Categories and subtraction work both as standalone atoms and inside
character classes, so XSD pattern facets that use them now validate.

Closes #89
2026-06-08 15:15:46 +02:00
dd6300538c Merge epic/96-xml11: line-ending normalization and XML 1.1
Closes #96
2026-06-08 15:07:22 +02:00
5351a5c777 feat(parsing): line-ending normalization and XML 1.1 line terminators
Normalize line endings in the Reader as the spec requires, which was not
done before:

- CR and CRLF fold to a single line feed (XML 1.0), so every downstream
  scan sees line feeds only.
- When the declaration names version 1.1, NEL (U+0085) and LINE SEPARATOR
  (U+2028), and a CR followed by NEL, also fold to a line feed; in 1.0
  those characters are preserved as data.

The XML 1.1 name-character productions are identical to XML 1.0 Fifth
Edition, which the character classifier already implements, so names need
no change. The 1.1 character range (C0 controls permitted only as
references) is not yet enforced.

Closes #96
2026-06-08 15:07:21 +02:00
bb70798049 Merge epic/98-reader-breadth: xmlReader breadth
Closes #98
2026-06-08 15:01:55 +02:00
26649e5c73 feat(parsing): xmlReader breadth: attribute navigation, namespaces, lang, position
Broaden the TextReader pull cursor toward libxml2's xmlTextReader:

- Attribute nodes: moveToFirstAttribute/moveToNextAttribute/
  moveToAttribute(_:)/moveToElement reposition the cursor onto each
  attribute as an .attribute node and back.
- Namespace accessors: namespaceURI, localName, prefix from the current
  element's or attribute's resolved name.
- xml:lang: inherited down the tree, overridable per element, reset when
  it goes out of scope.
- Position: lineNumber/columnNumber report where each node begins, so a
  streaming consumer can locate findings in the source. EventReader now
  records each node's start mark.

DOCTYPE and entity-reference nodes are not surfaced (the DTD is exposed
through documentType, and entities are expanded inline); whitespace is
not split into significant/insignificant (needs the content model);
baseURI needs a document URI. The accurate positioning path for editor
findings remains readTree with source ranges.

Closes #98
2026-06-08 15:01:48 +02:00
9168f990d6 feat(parsing): surface the XML declaration (version, encoding, standalone)
Parse the XML declaration's pseudo-attributes instead of skipping the
<?xml?> processing instruction:

- version, encoding, and standalone are surfaced on a new XMLDeclaration,
  returned from parseWithDocumentType and via PureXML.xmlDeclaration(_:).
- The pseudo-attributes are validated: fixed order (version, encoding,
  standalone), known names only, and standalone must be yes or no. A
  malformed declaration is rejected.
- The declared version is now readable, a prerequisite for XML 1.1.

parseWithDocumentType returns a ParsedDocument struct (node,
documentType, declaration) rather than a 2-tuple; the member names are
unchanged so existing call sites are unaffected.

Closes #95
2026-06-08 14:53:04 +02:00
c51287becb Merge epic/72-dtd-conditional-notations: DTD conditional sections and notations
Closes #72
2026-06-08 14:39:43 +02:00
7f1aada240 feat(dtd): conditional sections, notations, NDATA, external parameter entities
Deepen DTD processing in the doctype scanner:

- Conditional sections are processed, not skipped: INCLUDE sections have
  their declarations scanned, IGNORE sections are discarded, the keyword
  may be a parameter-entity reference, and nested sections are handled.
- <!NOTATION> declarations are parsed and stored on DocumentType.
- Unparsed entities (<!ENTITY ... NDATA n>) are parsed and stored with
  their notation, distinct from general entities.
- External parameter entities are loaded through the injected resolver so
  their declarations expand; the default refusing resolver loads nothing,
  keeping XXE closed.
- NOTATION attributes are a distinct type, validated against the declared
  notations (value must be listed and the notation declared).

Tracking unparsed entities also lets ENTITY/ENTITIES attribute values be
validated against the declared NDATA entities, closing the gap left when
#73 could only check name syntax.

Closes #72
2026-06-08 14:39:42 +02:00
13733b3b8d Merge epic/73-dtd-attributes: DTD attribute defaulting and tokenized types
Closes #73
2026-06-08 14:31:43 +02:00
a041b8beca feat(dtd): attribute defaulting, tokenized types, value normalization
Complete the DTD attribute handling found in the audit:

- Default and #FIXED attribute values are applied to the parsed tree.
  parseApplyingInternalDTDDefaults returns a tree where every element
  carries the attributes its <!ATTLIST> defaults, so an editor sees the
  same effective attributes a validating processor would.
- NMTOKEN, NMTOKENS, ENTITY, ENTITIES are validated: NMTOKEN(S) must be
  XML name tokens, ENTITY/ENTITIES must be XML names. Previously all fell
  through to unconstrained CDATA.
- DTD default values are normalized per XML 1.0: character and predefined
  entity references are expanded, literal whitespace becomes a space, and
  non-CDATA values are collapsed and trimmed.

ENTITY/ENTITIES check name syntax only; that the named entity is a
declared unparsed entity is not verified, since NDATA entity tracking is
not yet modeled.

Closes #73
2026-06-08 14:31:42 +02:00
fad5f26994 Merge epic/78-xsd-namespaces: XSD namespace qualification and xs:all
Closes #78
2026-06-08 14:24:24 +02:00
2b7138ab61 feat(schema): XSD namespace qualification and xs:all constraints
Make XSD validation namespace-aware and enforce the form defaults and
xs:all structural constraints that were previously ignored:

- targetNamespace is tracked: a schema's global elements are in its
  target namespace, and an instance's root element must be in it.
- elementFormDefault / attributeFormDefault (and per-declaration form)
  qualify local element and attribute declarations. The content-model
  matcher already compares namespace URIs, so qualified declarations
  now match only same-namespace instance nodes; attribute matching
  became namespace-aware too.
- xs:all groups are checked at compile time: every member must be an
  element occurring at most once, the group may occur at most once, and
  nested model groups inside an all are rejected (a SchemaError).

When a schema has no target namespace the stamping is a no-op, so
unqualified schemas validate exactly as before.

Cross-namespace type references through xs:import resolve by local name
only; full multi-namespace type tables are not modeled.

Closes #78
2026-06-08 14:24:17 +02:00
23b7c081fc Merge epic/76-xsd-derivation-control: XSD derivation control
Closes #76
2026-06-08 14:14:29 +02:00
4fad612281 feat(schema): XSD derivation control (abstract, block, final, redefine)
Read and enforce the XSD 1.0 derivation controls that were previously
ignored:

- abstract elements may not appear in an instance directly, only through
  a substitution member; an element of an abstract complex type must
  supply an xsi:type naming a concrete derived type.
- block on a type forbids the disallowed xsi:type substitutions; block on
  an element drops substitution-group members that derive by a blocked
  method.
- final on a type rejects, at compile time, any type that derives from it
  by a final method.
- xs:redefine requires the redefined type to derive from itself.

Track each named complex type's base and derivation method as the
backbone these checks walk. Also compose complexContent extension and
restriction content models, which were previously dropped: an extension
now inherits its base's attributes and content (base first, then the
extension, in sequence).

Complex-type restriction is not verified to be a structurally valid
subset of its base (Particle Valid (Restriction)); that limit is
documented at its sites.

Closes #76
2026-06-08 14:14:23 +02:00
76d7ccfedc Merge epic/77-xsd-wildcards: XSD wildcards and list built-ins
Closes #77
2026-06-08 13:53:27 +02:00
b01499743c feat(schema): XSD wildcards and list built-in datatypes
Add xs:any and xs:anyAttribute wildcards with namespace and
processContents constraints (skip/lax/strict), and resolve the
list built-in datatypes (IDREFS, ENTITIES, NMTOKENS) to their
item types so each list item validates.

WildcardNamespace models ##any/##other/enumerated URI sets;
Wildcard.admits gates undeclared attributes and wildcard-matched
children. ContentNFA.follow exposes the follow set for completions.

Closes #77
2026-06-08 13:53:22 +02:00
fe11bd0e56 docs(roadmap): collapse #92 and #94 to shipped, #71 the open epic
The validation framework + recovering reader (#92) and editor integration (#94)
are complete; they fold into the shipped legend. The beyond-parity libxml2 gaps
(#71) are the remaining open epic.
2026-06-08 13:37:21 +02:00
1b370f2fdb Merge quick-fixes slice 4 (#94): structured fixes from completions 2026-06-08 13:35:34 +02:00
d667b4cf1e feat(editor): structured quick-fixes from completions
Slice 4 of #94 (completes the epic). Quick-fixes are derived from the structured
schema completions, never by parsing a diagnostic message:

- TreeNode gains contentRange (the span between an element's start and end tags),
  captured by the ranged reader, so an inserted attribute or child is placed
  exactly.
- QuickFix and TextEdit model an editor edit (a replacement over a source range;
  an insertion is a zero-width range).
- QuickFixEngine turns a Completions into fixes: a required-but-absent attribute
  becomes an insertion before the start tag's >, and a still-expected required
  child becomes an insertion before the end tag. Schema.Document.quickFixes(at:in:)
  is the entry over a ranged tree.

Tests apply a fix and re-validate: adding the required id, or inserting the
required qty, yields XML the schema then accepts; a complete element offers none.

#94 (editor integration) is complete: ranged recovering read, unified lint,
schema completions, and quick-fixes.
2026-06-08 13:35:33 +02:00
1e89790e87 Merge completions slice 3 (#94): schema-driven expected-here 2026-06-08 13:27:37 +02:00
b3e126c430 feat(editor): schema-driven completions and what-is-missing
Slice 3 of #94. From the compiled schema, compute exactly what is allowed at an
element, for autocomplete and "what's missing".

- ContentNFA.follow(after:) returns the precise follow-set: the element-name
  labels the content automaton can accept next after the children already
  present, plus whether the content may legally end there.
- Schema.Completions reports the allowed next elements, the complete flag (false
  means a required child is still expected), a wildcard flag, and each declared
  attribute with its required and present status.
- CompletionEngine resolves an element's declared type along a coding path (the
  global declaration, then each parent's content model) and computes the
  completions; Schema.Document.completions(at:in:) is the entry over a ranged
  tree.

Tests pin the follow-set as the children accumulate (name then qty then the
optional note, complete flipping true), the attribute required/present status,
and resolution for a nested element by its coding path.
2026-06-08 13:27:37 +02:00
57c0c3b2da feat(editor): unified lint surface merging parse and validation diagnostics
Slice 2 of #94. One call for an editor: PureXML.lint reads the document once into
the ranged, best-effort tree (never throwing), then merges the parse diagnostics
and the validation findings into a single source-ranged, severity-tagged list in
document order.

- LintDiagnostic carries message, severity, and the SourceRange to highlight.
- lint(_:validate:) takes a node-based validate closure that runs over the
  RECOVERED tree, so validation works even when the document is not well-formed:
  a truncated, schema-invalid document yields both a parse diagnostic and the
  validation finding, each with a range.
- Schema.Document gains validate(_ node:) so a caller validates the same tree it
  is editing; validate(_ xml:) delegates to it.

Tests cover a clean lint, well-formedness-only lint, a range-located validation
finding, and the both-truncated-and-invalid editor case.
2026-06-08 13:18:29 +02:00
4659ea33c8 feat(editor): source spans on the tree, mapping findings to positions
Slice 1 of #94 (editor integration). The keystone: a located validation finding
can now be mapped back to a source range.

- TreeNode gains a sourceRange (start/end Mark); SourceRange is a half-open span.
- PureXML.readTree builds a mutable, parent-aware, best-effort tree whose every
  node carries its span (an element from the < of its start tag to past its end
  tag; leaves over the text they consumed). Never throws, recovers in place,
  returns located diagnostics.
- TreeNode.node(at:) navigates a validation coding path to its node using the
  same canonical convention the validators build paths with (a sibling index
  only when a name repeats), and sourceRange(at:) returns the span. So
  Document.validate's ValidationError.codingPath resolves to characters in the
  document.

Tests prove the spans, the navigation, the end-to-end bridge (an XSD finding to
a source range), and that ranged reading of invalid input never crashes.
2026-06-08 13:14:29 +02:00
df1d93d21b Merge XSD nillable + value constraints (#75) 2026-06-08 13:03:07 +02:00
4345e6170e feat(xsd): xsi:nil/nillable and fixed/default value constraints
Closes #75, on the validation framework. XSD instance-level features:

- nillable elements: an xsi:nil="true" element is accepted (its content model is
  not checked) only when the element is declared nillable and carries no content;
  xsi:nil on a non-nillable element, or a nilled element with content, is a
  located error.
- fixed value constraints: a present fixed attribute must equal its fixed value,
  and a fixed element's text must match.
- default/fixed are parsed onto AttributeUse (per use) and collected per element
  name (nillable set + value constraints), threaded through XSDContext to the
  ComplexValidator.

All findings are located ValidationErrors. Collection and helper methods move to
same-file extensions to stay within type-body limits.
2026-06-08 13:03:06 +02:00
13d965b764 Merge Schematron framework slice 5 (#92): retire Issue, unified ValidationError 2026-06-08 12:49:19 +02:00
d682f85daf refactor(validation): migrate Schematron, retire Issue
Slice 5 of #92. The last validator moves onto the framework and the legacy
Issue type is removed, so all validation now flows through one error model.

- ValidationError gains a severity (error by default, warning for advisory
  findings), chosen so Schematron's assert/report distinction survives in the
  unified type.
- Schematron is a Validation<Node, Void> over the document root; a failed assert
  is an error finding, a matched report a warning. Each finding is located at the
  context node its rule fired on (built from the element chain, with a sibling
  index only when a name repeats), not the document root.
- Schematron.validate returns [ValidationError] and runs through a Validator.
- Validation.Issue and its Severity-based result are deleted; Severity now backs
  ValidationError.

Every validation layer (structural, DTD, XSD content and identity, Schematron)
now produces located ValidationError values through the shared framework.
2026-06-08 12:49:19 +02:00
e925ba6bea Merge XSD validation framework slice 4 (#92): located XSD results 2026-06-08 12:43:21 +02:00
4765af0624 refactor(validation): migrate XSD validation onto the framework
Slice 4 of #92. The XSD complex-type and identity-constraint validators move
onto the validation framework, producing located results.

- ComplexValidator threads a coding path through its (inherently recursive)
  descent, so every content-model, attribute, and simple-type violation is now
  a ValidationError located by path ("... at path: order/qty"). Child steps
  carry a sibling index only when a name repeats.
- IdentityValidator returns ValidationError instead of Issue.
- A new XSD rules enum wraps both as Validation<Node, XSDContext> values gated to
  the document root, combined by Validator<XSDContext>. XSDContext carries the
  type table, the identity constraints, and the root declaration.
- Schema.Document.validate builds the context and runs the validator, returning
  [ValidationError].

XSD content-model validation is recursive by nature (a child's type depends on
its position), so the rules delegate to the recursion while contributing to one
error collection. Schematron is the last validator on Issue and migrates next.
2026-06-08 12:43:20 +02:00
4bcd7e2787 Merge DTD validation framework slice 3 (#92): located DTD results 2026-06-08 12:36:57 +02:00
89f8ccc95a refactor(validation): migrate DTD validation onto the framework
Slice 3 of #92. The DTD validator's imperative walk becomes declared Validation
values over a DTDSchema document:

- DTDSchema is now just the schema (content models + attribute declarations) and
  serves as the rules' Document context; its validate/walk methods are gone.
- contentModel and attributeDeclarations are Validation<Element, DTDSchema>, so
  every content-model and attribute violation is now located by coding path
  ("... at path: r/a"). undeclaredElement is added only in strict mode.
- identifierIntegrity is a Validation<Node, DTDSchema> gated to the root, doing
  the document-wide ID-uniqueness and IDREF-resolution checks in one pass.
- DTD.validator(strict:) assembles the rule set; validateAgainstInternalDTD runs
  it and returns [ValidationError] instead of [Issue].

A new test pins the located result. Schema (XSD) and Schematron validators still
use Issue and migrate in the following slices.
2026-06-08 12:36:56 +02:00
d4e9d07727 Merge in-reader recovery (#93): keep elements, pop to matching tags 2026-06-08 12:24:44 +02:00
78a8b628a5 feat(parsing): in-reader recovery keeps elements and pops to matching tags
Closes #93. Recovery moves into a recovering mode on EventReader, so malformed
input is repaired in place rather than dropped to the next markup boundary:

- A duplicate attribute is recorded and the later occurrence dropped, keeping the
  element. <a id='1' id='2'>y</a> now reads as <a id="1">y</a> with one
  diagnostic (was an empty tree with three).
- A mismatched or unexpected end tag pops the open stack to the matching ancestor,
  implicitly closing the elements nested inside it. <a><b><c>x</b></a> reads as
  <a><b><c>x</c></b></a> with a single diagnostic.
- Pre-root junk reports "unexpected character" instead of the misleading
  "content after the root element".

In recovering mode next() no longer throws: it records a Diagnostic and emits
best-effort events, popping to a match on a mismatch and stopping on truncation
so the builder closes what is open. The strict path is unchanged. readRecovering
becomes a plain non-throwing drain that takes the reader's diagnostics. Recovery
stays deterministic; new tests pin the kept-element and pop-to-match results.
2026-06-08 12:24:43 +02:00
22c82cc273 Merge recovering reader slice 2 (#92): deterministic best-effort read 2026-06-08 12:15:19 +02:00
b1d82e533d feat(parsing): recovering reader that never crashes on invalid input
Slice 2 of #92. Add PureXML.read, a deterministic recovering reader: it never
throws and never traps, returning the maximal best-effort tree plus one located
Diagnostic per problem.

- ReadResult { node, diagnostics } is the Equatable result type, so a
  deterministic read can be compared whole.
- Diagnostic carries a message and a structured source Mark; a thrown ParseError
  maps to one directly (ParseError.mark exposes the position each case carries).
- The reader resynchronizes after a failure (EventReader.recover(from:)):
  progress-aware, so a failure that already consumed the bad construct retries
  from there, while a stuck failure skips to the next markup start; it always
  advances when input remains, guaranteeing termination.
- The tree builder assembles leniently: elements left open by truncation are
  closed, an end tag with no open element is dropped. Well-formed input yields
  exactly parse(_:)'s tree with no diagnostics.

Tests pin exact trees and diagnostics for truncation, mismatched tags, and stray
junk, prove determinism (same bytes give an identical ReadResult), and fuzz 400
seeded inputs (arbitrary plus truncated valid docs) asserting no crash.

Recovery here is resync-based; finer in-reader recovery (keep the element on a
duplicate attribute, pop to a matching open tag) is the next slice.
2026-06-08 12:15:18 +02:00
1e6e15eea7 Merge validation framework slice 1 (#92): composable Validation + structural rule 2026-06-08 12:00:35 +02:00