Commit Graph

24 Commits

Author SHA1 Message Date
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
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
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
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
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
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
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
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
4e5debd557 feat(parsing): resumable push / feed streaming API
Add a resumable XML scanner that reports a token only once its terminator is
fully buffered and otherwise signals need-more (the Expat XML_TOK_PARTIAL
model), and PushParser with feed(_:)/finish() driving a SAXHandler while
retaining only the current incomplete token plus the open-element stack.
events(feeding:) exposes an AsyncThrowingStream of events over any async
sequence of text chunks. Splitting a document at any byte boundary, down to
one character per feed, yields identical events. Collapse the completed
roadmap into the legend summary.

Closes #4
Closes #5
Closes #6
Closes #7
Closes #1
2026-06-08 07:21:22 +02:00
aafd99589e feat(parse): xmlTextReader pull cursor
Add PureXML.Parsing.TextReader (via PureXML.reader), a pull cursor over the
streaming core. read() advances node by node, exposing nodeKind, name,
value, depth, attributes, and isEmptyElement. A childless element is
reported once as an empty element, normalizing <a/> and <a></a>. The
documentType accessor surfaces the DTD read so far as the validation hook.

Closes #33
2026-06-08 03:18:00 +02:00
c75c97aa9b feat(dtd): external and parameter entities, secure by default
Add internal parameter entities (parsed, stored, and expanded within the
DTD, including bare %name; references that inject markup declarations,
bounded by depth and the expansion budget). Record external general
entities and the external subset identifier, and load them only through an
injected EntityResolver (a struct of closures). The default refusing
resolver fetches nothing, so XXE stays closed: an external entity fails as
undefined and the external subset is left unread. Internal declarations win
over the external subset.

Closes #29
2026-06-08 03:08:20 +02:00
c82fef085e feat(parsing): spec-exact XML character classification (chvalid parity)
Add PureXML.Parsing.XMLCharacter as the single source of truth for character
classes, implementing the exact XML 1.0 (Fifth Edition) Char, NameStartChar, and
NameChar productions as public scalar predicates, plus isValidName. The scanner's
name recognition now delegates to these exact ranges (replacing a loose isLetter
check) and handles precomposed and combining-mark names. Closes #35.
Reimplemented from the XML grammar; no upstream code copied. 4 tests; 98 total.
2026-06-08 02:52:42 +02:00
85172923e8 feat(parsing): full encoding support (UTF-32, ISO-8859-1, Windows-1252)
Decode UTF-32 (both byte orders, BOM or sniffed) and the single-byte encodings
ISO-8859-1 and Windows-1252, selected by the XML declaration's encoding name,
in addition to UTF-8 and UTF-16. The streaming byte path also handles UTF-32.
Standard library only, no Foundation. Closes #28. Reimplemented from the
documented encoding algorithms; no upstream code copied. 4 tests; 94 total.
2026-06-08 02:39:25 +02:00
ef682808d4 feat(parsing): add SAX2 push-callback API
Add PureXML.Parsing.SAXHandler (a struct of optional callbacks) and
PureXML.parse(_:sax:), which drives the handler from the streaming event core,
delivering start/end document, start/end element, characters, CDATA, comment,
and processing-instruction callbacks. Element names are namespace-resolved.
Closes the SAX2 epic (#19). Reimplemented from the libxml2 SAX2 model; no
upstream code copied. 5 tests; 84 total.
2026-06-08 02:26:04 +02:00
5cbe938826 feat(validation): add DTD attribute (ATTLIST) validation
Surface <!ATTLIST> declarations from the parser and validate attributes against
them. Reimplemented from the DTD attribute-list spec; no upstream code copied.

- DocumentType gains attributeLists (raw ATTLIST bodies per element); the
  DoctypeScanner captures them and the EventReader plumbs them through.
- AttributeListParser parses a body into AttributeDeclaration values
  (AttributeType: cdata or enumeration; AttributeDefault: required/implied/
  fixed/value).
- DTDSchema now checks #REQUIRED presence, #FIXED value match (when present),
  and enumerated-value membership, in addition to content models.
- Tokenized types (ID/IDREF/NMTOKEN) parse but are not yet value-enforced.
- 5 attribute tests; 75 total.
2026-06-08 02:07:01 +02:00
f02561b01d feat(validation): add DTD content-model validation
Surface the parsed <!DOCTYPE> internal subset's <!ELEMENT> declarations from the
parser and validate a tree against them. Reimplemented from the DTD content-model
spec; no upstream code copied.

- DoctypeScanner captures <!ELEMENT name model> declarations; the parser surfaces
  them as Parsing.DocumentType (via parseWithDocumentType).
- Validation.ContentModel + ContentModelParser parse a model string (EMPTY, ANY,
  (#PCDATA), mixed, and element content with sequence/choice and ?/*/+).
- ContentModelMatcher matches the child element-name sequence against a model as
  a regular language (NFA-position matching).
- Validation.DTDSchema.validate reports issues for EMPTY/PCDATA/mixed violations,
  element content with stray text, and child sequences that do not match.
- PureXML.validateAgainstInternalDTD(_:) parses and validates in one call.
- ATTLIST validation is out of scope. 9 tests; 70 total.
2026-06-08 01:59:00 +02:00
01a35cc6e6 feat(parser): optional DTD with internal entities and billion-laughs defense
Add opt-in DTD processing behind Limits(allowDoctype:), off by default. When on,
DoctypeScanner reads the <!DOCTYPE> internal subset and honors internal
<!ENTITY name "value"> general entities, which EntityDecoder expands recursively
in text and attribute values.

Safety is mandatory and reimplemented from the Expat amplification-accounting
model (no upstream code copied):
- only entity-expanded characters count against a shared budget
  (Limits.maxEntityExpansion); overflow raises amplificationLimitExceeded, which
  defeats billion-laughs.
- a visiting set detects recursive entities (recursiveEntity).
- undeclared references raise undefinedEntity.
- external entities are never stored or loaded, so XXE stays closed.

The default still rejects <!DOCTYPE>. 8 DTD tests (incl. billion-laughs and the
external-entity refusal); 49 total.
2026-06-08 01:17:09 +02:00
204dd3895e feat(parser): streaming byte decode (incremental UTF-8/UTF-16)
Add PureXML.parse(pullingBytes:) and events(pullingBytes:) over an incremental
byte source (() -> UInt8?). StreamingDecoder detects the encoding from the
leading bytes, strips any BOM, and yields one scalar at a time as a Character,
so a byte stream is never fully buffered. UTF-8 (1 to 4 bytes) and UTF-16 (with
surrogate pairs) are decoded incrementally; invalid sequences become the Unicode
replacement character. Standard library only, no Foundation. Reimplemented from
the documented UTF decoding rules; no upstream code copied.
2026-06-08 01:07:24 +02:00
43f82312e6 feat(parser): resolve XML namespaces at the start-element boundary
Add namespace resolution following the XML Namespaces rules, reimplemented in
Swift from libxml2's SAX2 model; no upstream code copied.

- QualifiedName gains an optional resolved namespaceURI (nil for names built
  directly or in no namespace), with a resolved(namespaceURI:) helper.
- NamespaceContext maintains a scope stack of xmlns bindings as elements open
  and close, resolving element and attribute names against it.
- A default namespace applies to unprefixed element names but never to
  attributes; the xml prefix is built in; xmlns declarations are preserved as
  attributes so documents round-trip; an unbound prefix raises
  ParseError.undefinedNamespacePrefix. End-tag matching stays lexical.
- Namespace-free documents are unchanged (URIs remain nil).
- 7 namespace tests; 39 total.
2026-06-08 01:03:29 +02:00
e4b2e0127d feat(parser): accept raw bytes with UTF-8/UTF-16 encoding detection
Add PureXML.parse(bytes:) and events(bytes:) for raw [UInt8] input. The encoding
is detected following the XML sniff order (BOM first, then the <? byte pattern,
defaulting to UTF-8) and decoded with the Swift standard library only, so the
package stays Foundation-free and portable. UTF-8 and UTF-16 (both byte orders,
with or without a BOM) are supported, the encoding set the XML spec requires.
Malformed input (odd-length UTF-16) raises ParseError.malformedEncoding.

PureXML.Parsing.InputEncoding.detect(_:) exposes the detector. The lint rule
optional_data_string_conversion is disabled because its suggested fix is a
Foundation API this package cannot use. Reimplemented from the documented XML
byte-detection algorithm; no upstream code copied.
2026-06-08 00:52:56 +02:00
4856a3fcf4 feat(parser): add bounded-by-default limits (depth, name, content)
Add PureXML.Parsing.Limits: configurable caps on nesting depth (default 256,
matching libxml2), name length, and content length, enforced during scanning in
the event reader. This protects against pathological input (deeply nested or
oversized documents) and keeps the recursive Model.Node value safe to build and
tear down (extreme depth otherwise overflows on ARC release). Threaded through
Parser.parse and the top-level parse/events API. Reimplemented from libxml2's
bounded-by-default posture; no upstream code copied.
2026-06-08 00:40:34 +02:00
5c306482e5 feat(parser): add a streaming, iterative XML parser
Replace the notImplemented stub with a real parser built on a pull-based,
streaming event core that never requires the whole document in memory.

- Parsing.EventReader: pulls characters through a source closure with a bounded
  lookahead buffer and emits one Parsing.Event at a time, holding only an
  open-element stack. Drives chunked/streamed input.
- Parsing.Parser: builds a Model.Node tree iteratively over the event core
  (explicit stack, no recursion).
- Reader: streaming character cursor (closure-backed, bounded buffer) replacing
  the previous whole-string Array.
- EntityDecoder: the five predefined entities and decimal/hex char references.
- Handles elements, attributes, text, comments, CDATA, processing instructions,
  and an XML declaration in the prolog. Rejects <!DOCTYPE> by default (no
  DTD/XXE/entity-expansion exposure).
- Top-level API: parse(_:), parse(pulling:), events(_:), events(pulling:).
- 21 tests including event-stream and chunked-source coverage.
2026-06-08 00:10:50 +02:00
708390242c chore: scaffold PureXML pure-Swift XML package
Root Swift package mirroring PureYAML's structure, rules, and verification
gates. Includes the XML node model (Node, Element, Attribute, QualifiedName),
a working emitter (Serializer) with escaping and pretty/compact options, and
structural validation. The parser surface is stable; parse() raises
notImplemented until the scanner lands, pinned by a test.

- dependency-free, namespaced under PureXML
- portability and style gates carried over (style, namespacing, forbidden
  patterns, swiftformat, swiftlint, linux, wasm, windows CI)
- 11 tests passing
2026-06-07 23:20:20 +02:00