From 58cf423b7d0ffe61eac1342daadb1c2a07a45c34 Mon Sep 17 00:00:00 2001 From: Mathieu BISKUPSKI Date: Sun, 5 Jul 2026 17:55:42 +0200 Subject: [PATCH 1/6] Extract DTD parsing into src/dtd.jl (pure move) Assisted-by: Claude (Anthropic) --- benchmarks/profile_039_results.txt | 6 + benchmarks/profile_results.txt | 23 ++ src/XML.jl | 350 +---------------------------- src/dtd.jl | 349 ++++++++++++++++++++++++++++ 4 files changed, 379 insertions(+), 349 deletions(-) create mode 100644 benchmarks/profile_039_results.txt create mode 100644 benchmarks/profile_results.txt create mode 100644 src/dtd.jl diff --git a/benchmarks/profile_039_results.txt b/benchmarks/profile_039_results.txt new file mode 100644 index 0000000..c0927ed --- /dev/null +++ b/benchmarks/profile_039_results.txt @@ -0,0 +1,6 @@ +dev profile vs v0.3.9 (worktree + subprocess) ... + +=== vv0.3.9 — the v0.3.9 column (DOM regimes) === + nodes walked: 567327 (v0.4 walks 882026; v0.4 preserves whitespace Text nodes, 0.3.x dropped them) + parse → DOM: 510.28 ms / 1421.7 MiB + full extraction: 530.06 ms / 1421.7 MiB diff --git a/benchmarks/profile_results.txt b/benchmarks/profile_results.txt new file mode 100644 index 0000000..47788cb --- /dev/null +++ b/benchmarks/profile_results.txt @@ -0,0 +1,23 @@ +file: 14.16 MB +validation — stream events: Cursor=882025 EzXML=1154783 +validation — extract nodes: XML.jl=882026 EzXML=882022 LightXML(elem only)=313452 +validation — lex tokens: 1850962 +validation — bytes touched: XML.jl=9968541 EzXML=4173492 (sanity, should be same order) + +=== (1) STREAM — file → events, no tree built === + XML.jl Cursor 53.7 ms 17.4 MiB + EzXML StreamReader 66.93 ms 35.5 MiB + +=== (2) FULL EXTRACTION — parse + pull every tag/text === + XML.jl (String) 134.97 ms 122.3 MiB + XML.jl (SubString) 110.37 ms 120.3 MiB + EzXML 61.99 ms 53.8 MiB + LightXML (elem) 62.46 ms 57.4 MiB + +=== (3) DECOMPOSE — XML.jl pipeline stages === + read file (I/O) 0.58 ms 13.5 MiB + lex only (tokenize) 37.43 ms 0.0 MiB + parse → DOM 106.64 ms 122.3 MiB + traverse only 6.16 ms 0.0 MiB + +(build ≈ parse − lex; traverse is on a pre-built tree) diff --git a/src/XML.jl b/src/XML.jl index c397370..e9b2e3f 100644 --- a/src/XML.jl +++ b/src/XML.jl @@ -997,355 +997,7 @@ function (o::Node)(args...; attrs...) h(o.tag, old_children..., args...; old_attrs..., attrs...) end -#-----------------------------------------------------------------------------# DTD parsing -struct ElementDecl - name::String - content::String # "EMPTY", "ANY", or content model like "(#PCDATA)" or "(a,b,c)*" -end - -struct AttDecl - element::String - name::String - type::String # "CDATA", "ID", "(val1|val2)", "NOTATION (a|b)", etc. - default::String # "#REQUIRED", "#IMPLIED", "#FIXED \"val\"", or "\"val\"" -end - -struct EntityDecl - name::String - value::Union{Nothing, String} # replacement text (internal entities) - external_id::Union{Nothing, String} # "SYSTEM \"uri\"" or "PUBLIC \"pubid\" \"uri\"" - parameter::Bool -end - -struct NotationDecl - name::String - external_id::String -end - -struct ParsedDTD - root::String - system_id::Union{Nothing, String} - public_id::Union{Nothing, String} - elements::Vector{ElementDecl} - attributes::Vector{AttDecl} - entities::Vector{EntityDecl} - notations::Vector{NotationDecl} -end - -# DTD parsing helpers — each returns (parsed_piece, new_pos) so calls compose. - -# A character that can appear in an XML Name (letters, digits, `_`, `-`, `.`, `:`, and any -# non-ASCII char — mirrors the tokenizer's lenient NAME_BYTE_TABLE rule). -@inline _dtd_is_name_char(c::Char) = - ('a' <= c <= 'z') || ('A' <= c <= 'Z') || ('0' <= c <= '9') || - c == '_' || c == '-' || c == '.' || c == ':' || !isascii(c) - -# Advance past any whitespace. -function _dtd_skip_ws(s, pos) - while pos <= ncodeunits(s) && isspace(s[pos]) - pos += 1 - end - pos -end - -# Read an XML Name token; errors if no name characters are present. -function _dtd_read_name(s, pos) - pos = _dtd_skip_ws(s, pos) - start = pos - while pos <= ncodeunits(s) && _dtd_is_name_char(s[pos]) - pos = nextind(s, pos) - end - start == pos && error("Expected name at position $pos in DTD") - SubString(s, start, prevind(s, pos)), pos -end - -# Read a `"..."` or `'...'` string and return the contents without the surrounding quotes. -function _dtd_read_quoted(s, pos) - pos = _dtd_skip_ws(s, pos) - q = s[pos] - (q == '"' || q == '\'') || error("Expected quoted string at position $pos in DTD") - pos += 1 - start = pos - while pos <= ncodeunits(s) && s[pos] != q - pos += 1 - end - val = SubString(s, start, pos - 1) - pos += 1 - val, pos -end - -# Read a balanced parenthesized expression (e.g. `(a|b|(c,d))`), returning the full -# substring including the outer `(` and `)`. Skips over quoted strings inside. -function _dtd_read_parens(s, pos) - pos = _dtd_skip_ws(s, pos) - s[pos] == '(' || error("Expected '(' at position $pos in DTD") - depth = 1 - start = pos - pos += 1 - while pos <= ncodeunits(s) && depth > 0 - c = s[pos] - if c == '(' - depth += 1 - elseif c == ')' - depth -= 1 - elseif c == '"' || c == '\'' - pos += 1 - while pos <= ncodeunits(s) && s[pos] != c - pos += 1 - end - end - pos += 1 - end - SubString(s, start, pos - 1), pos -end - -# Advance past the next `>` that terminates a markup declaration, ignoring `>` inside -# quoted strings. -function _dtd_skip_to_close(s, pos) - while pos <= ncodeunits(s) && s[pos] != '>' - c = s[pos] - if c == '"' || c == '\'' - pos += 1 - while pos <= ncodeunits(s) && s[pos] != c - pos += 1 - end - end - pos += 1 - end - pos <= ncodeunits(s) ? pos + 1 : pos -end - -# Parse `` — content is either a name (EMPTY/ANY) or a parens -# group with an optional `*`/`+`/`?` quantifier appended. -function _dtd_parse_element(s, pos) - name, pos = _dtd_read_name(s, pos) - pos = _dtd_skip_ws(s, pos) - if s[pos] == '(' - content, pos = _dtd_read_parens(s, pos) - if pos <= ncodeunits(s) && s[pos] in ('*', '+', '?') - content = string(content, s[pos]) - pos += 1 - end - else - content, pos = _dtd_read_name(s, pos) - end - pos = _dtd_skip_to_close(s, pos) - ElementDecl(String(name), String(content)), pos -end - -# Parse `` — emits one AttDecl per attribute. -function _dtd_parse_attlist(s, pos) - element, pos = _dtd_read_name(s, pos) - atts = AttDecl[] - while true - pos = _dtd_skip_ws(s, pos) - (pos > ncodeunits(s) || s[pos] == '>') && break - - name, pos = _dtd_read_name(s, pos) - pos = _dtd_skip_ws(s, pos) - - # Attribute type - if s[pos] == '(' - atype, pos = _dtd_read_parens(s, pos) - else - atype, pos = _dtd_read_name(s, pos) - if atype == "NOTATION" - pos = _dtd_skip_ws(s, pos) - parens, pos = _dtd_read_parens(s, pos) - atype = string("NOTATION ", parens) - end - end - pos = _dtd_skip_ws(s, pos) - - # Default declaration - if s[pos] == '#' - pos += 1 - keyword, pos = _dtd_read_name(s, pos) - if keyword == "FIXED" - pos = _dtd_skip_ws(s, pos) - val, pos = _dtd_read_quoted(s, pos) - default = string("#FIXED \"", val, "\"") - else - default = string("#", keyword) - end - elseif s[pos] == '"' || s[pos] == '\'' - val, pos = _dtd_read_quoted(s, pos) - default = string("\"", val, "\"") - else - error("Expected default declaration at position $pos in DTD") - end - push!(atts, AttDecl(String(element), String(name), String(atype), default)) - end - pos <= ncodeunits(s) && s[pos] == '>' && (pos += 1) - atts, pos -end - -# Parse `` or ``. `%` marks a -# parameter entity (referenced as `%name;` in DTDs only). -function _dtd_parse_entity(s, pos) - pos = _dtd_skip_ws(s, pos) - parameter = false - if pos <= ncodeunits(s) && s[pos] == '%' - parameter = true - pos += 1 - end - name, pos = _dtd_read_name(s, pos) - pos = _dtd_skip_ws(s, pos) - - value = nothing - external_id = nothing - if s[pos] == '"' || s[pos] == '\'' - v, pos = _dtd_read_quoted(s, pos) - value = String(v) - else - keyword, pos = _dtd_read_name(s, pos) - pos = _dtd_skip_ws(s, pos) - if keyword == "SYSTEM" - uri, pos = _dtd_read_quoted(s, pos) - external_id = string("SYSTEM \"", uri, "\"") - elseif keyword == "PUBLIC" - pubid, pos = _dtd_read_quoted(s, pos) - pos = _dtd_skip_ws(s, pos) - uri, pos = _dtd_read_quoted(s, pos) - external_id = string("PUBLIC \"", pubid, "\" \"", uri, "\"") - else - error("Expected SYSTEM, PUBLIC, or quoted value in ENTITY declaration") - end - end - pos = _dtd_skip_to_close(s, pos) - EntityDecl(String(name), value, external_id, parameter), pos -end - -# Parse `` / ``. -function _dtd_parse_notation(s, pos) - name, pos = _dtd_read_name(s, pos) - pos = _dtd_skip_ws(s, pos) - keyword, pos = _dtd_read_name(s, pos) - pos = _dtd_skip_ws(s, pos) - if keyword == "SYSTEM" - uri, pos = _dtd_read_quoted(s, pos) - external_id = string("SYSTEM \"", uri, "\"") - elseif keyword == "PUBLIC" - pubid, pos = _dtd_read_quoted(s, pos) - pos = _dtd_skip_ws(s, pos) - if pos <= ncodeunits(s) && (s[pos] == '"' || s[pos] == '\'') - uri, pos = _dtd_read_quoted(s, pos) - external_id = string("PUBLIC \"", pubid, "\" \"", uri, "\"") - else - external_id = string("PUBLIC \"", pubid, "\"") - end - else - error("Expected SYSTEM or PUBLIC in NOTATION declaration") - end - pos = _dtd_skip_to_close(s, pos) - NotationDecl(String(name), external_id), pos -end - -""" - parse_dtd(value::AbstractString) -> ParsedDTD - parse_dtd(node::Node) -> ParsedDTD - -Parse a DTD value string (from a `DTD` node) into structured declarations. -""" -# Public entry: parse a DTD value string into a structured `ParsedDTD`. Best-effort and -# non-validating — it does not expand parameter-entity references. On a parse failure, blame -# parameter entities only when the DTD actually contains a `%name;` reference; otherwise surface the -# underlying error (a bare '%' can appear legitimately in an entity value, comment, or system id). -const _PE_REF_RE = r"%[A-Za-z_:][A-Za-z0-9._:-]*;" - -function parse_dtd(value::AbstractString) - try - return _parse_dtd_impl(value) - catch e - occursin(_PE_REF_RE, value) && error( - "parse_dtd does not expand parameter-entity references (e.g. `%text;`); structured DTD " * - "access is best-effort and non-validating, but the raw DTD node still round-trips " * - "through write. (underlying parse error: " * sprint(showerror, e) * ")") - rethrow(e) - end -end - -function _parse_dtd_impl(value::AbstractString) - s = String(value) - pos = 1 - - root, pos = _dtd_read_name(s, pos) - pos = _dtd_skip_ws(s, pos) - - # External ID - system_id = nothing - public_id = nothing - if pos <= ncodeunits(s) && _dtd_is_name_char(s[pos]) - keyword, kpos = _dtd_read_name(s, pos) - if keyword == "SYSTEM" - pos = kpos - uri, pos = _dtd_read_quoted(s, pos) - system_id = String(uri) - elseif keyword == "PUBLIC" - pos = kpos - pubid, pos = _dtd_read_quoted(s, pos) - public_id = String(pubid) - pos = _dtd_skip_ws(s, pos) - if pos <= ncodeunits(s) && (s[pos] == '"' || s[pos] == '\'') - uri, pos = _dtd_read_quoted(s, pos) - system_id = String(uri) - end - end - end - - elements = ElementDecl[] - attributes = AttDecl[] - entities = EntityDecl[] - notations = NotationDecl[] - - # Internal subset - pos = _dtd_skip_ws(s, pos) - if pos <= ncodeunits(s) && s[pos] == '[' - pos += 1 - while pos <= ncodeunits(s) - pos = _dtd_skip_ws(s, pos) - pos > ncodeunits(s) && break - s[pos] == ']' && break - - rest = SubString(s, pos) - if startswith(rest, "", s, pos + 4) - isnothing(i) && error("Unterminated comment in DTD") - pos = last(i) + 1 - elseif startswith(rest, "", s, pos + 2) - isnothing(i) && error("Unterminated PI in DTD") - pos = last(i) + 1 - elseif startswith(rest, " 0 + c = s[pos] + if c == '(' + depth += 1 + elseif c == ')' + depth -= 1 + elseif c == '"' || c == '\'' + pos += 1 + while pos <= ncodeunits(s) && s[pos] != c + pos += 1 + end + end + pos += 1 + end + SubString(s, start, pos - 1), pos +end + +# Advance past the next `>` that terminates a markup declaration, ignoring `>` inside +# quoted strings. +function _dtd_skip_to_close(s, pos) + while pos <= ncodeunits(s) && s[pos] != '>' + c = s[pos] + if c == '"' || c == '\'' + pos += 1 + while pos <= ncodeunits(s) && s[pos] != c + pos += 1 + end + end + pos += 1 + end + pos <= ncodeunits(s) ? pos + 1 : pos +end + +# Parse `` — content is either a name (EMPTY/ANY) or a parens +# group with an optional `*`/`+`/`?` quantifier appended. +function _dtd_parse_element(s, pos) + name, pos = _dtd_read_name(s, pos) + pos = _dtd_skip_ws(s, pos) + if s[pos] == '(' + content, pos = _dtd_read_parens(s, pos) + if pos <= ncodeunits(s) && s[pos] in ('*', '+', '?') + content = string(content, s[pos]) + pos += 1 + end + else + content, pos = _dtd_read_name(s, pos) + end + pos = _dtd_skip_to_close(s, pos) + ElementDecl(String(name), String(content)), pos +end + +# Parse `` — emits one AttDecl per attribute. +function _dtd_parse_attlist(s, pos) + element, pos = _dtd_read_name(s, pos) + atts = AttDecl[] + while true + pos = _dtd_skip_ws(s, pos) + (pos > ncodeunits(s) || s[pos] == '>') && break + + name, pos = _dtd_read_name(s, pos) + pos = _dtd_skip_ws(s, pos) + + # Attribute type + if s[pos] == '(' + atype, pos = _dtd_read_parens(s, pos) + else + atype, pos = _dtd_read_name(s, pos) + if atype == "NOTATION" + pos = _dtd_skip_ws(s, pos) + parens, pos = _dtd_read_parens(s, pos) + atype = string("NOTATION ", parens) + end + end + pos = _dtd_skip_ws(s, pos) + + # Default declaration + if s[pos] == '#' + pos += 1 + keyword, pos = _dtd_read_name(s, pos) + if keyword == "FIXED" + pos = _dtd_skip_ws(s, pos) + val, pos = _dtd_read_quoted(s, pos) + default = string("#FIXED \"", val, "\"") + else + default = string("#", keyword) + end + elseif s[pos] == '"' || s[pos] == '\'' + val, pos = _dtd_read_quoted(s, pos) + default = string("\"", val, "\"") + else + error("Expected default declaration at position $pos in DTD") + end + push!(atts, AttDecl(String(element), String(name), String(atype), default)) + end + pos <= ncodeunits(s) && s[pos] == '>' && (pos += 1) + atts, pos +end + +# Parse `` or ``. `%` marks a +# parameter entity (referenced as `%name;` in DTDs only). +function _dtd_parse_entity(s, pos) + pos = _dtd_skip_ws(s, pos) + parameter = false + if pos <= ncodeunits(s) && s[pos] == '%' + parameter = true + pos += 1 + end + name, pos = _dtd_read_name(s, pos) + pos = _dtd_skip_ws(s, pos) + + value = nothing + external_id = nothing + if s[pos] == '"' || s[pos] == '\'' + v, pos = _dtd_read_quoted(s, pos) + value = String(v) + else + keyword, pos = _dtd_read_name(s, pos) + pos = _dtd_skip_ws(s, pos) + if keyword == "SYSTEM" + uri, pos = _dtd_read_quoted(s, pos) + external_id = string("SYSTEM \"", uri, "\"") + elseif keyword == "PUBLIC" + pubid, pos = _dtd_read_quoted(s, pos) + pos = _dtd_skip_ws(s, pos) + uri, pos = _dtd_read_quoted(s, pos) + external_id = string("PUBLIC \"", pubid, "\" \"", uri, "\"") + else + error("Expected SYSTEM, PUBLIC, or quoted value in ENTITY declaration") + end + end + pos = _dtd_skip_to_close(s, pos) + EntityDecl(String(name), value, external_id, parameter), pos +end + +# Parse `` / ``. +function _dtd_parse_notation(s, pos) + name, pos = _dtd_read_name(s, pos) + pos = _dtd_skip_ws(s, pos) + keyword, pos = _dtd_read_name(s, pos) + pos = _dtd_skip_ws(s, pos) + if keyword == "SYSTEM" + uri, pos = _dtd_read_quoted(s, pos) + external_id = string("SYSTEM \"", uri, "\"") + elseif keyword == "PUBLIC" + pubid, pos = _dtd_read_quoted(s, pos) + pos = _dtd_skip_ws(s, pos) + if pos <= ncodeunits(s) && (s[pos] == '"' || s[pos] == '\'') + uri, pos = _dtd_read_quoted(s, pos) + external_id = string("PUBLIC \"", pubid, "\" \"", uri, "\"") + else + external_id = string("PUBLIC \"", pubid, "\"") + end + else + error("Expected SYSTEM or PUBLIC in NOTATION declaration") + end + pos = _dtd_skip_to_close(s, pos) + NotationDecl(String(name), external_id), pos +end + +""" + parse_dtd(value::AbstractString) -> ParsedDTD + parse_dtd(node::Node) -> ParsedDTD + +Parse a DTD value string (from a `DTD` node) into structured declarations. +""" +# Public entry: parse a DTD value string into a structured `ParsedDTD`. Best-effort and +# non-validating — it does not expand parameter-entity references. On a parse failure, blame +# parameter entities only when the DTD actually contains a `%name;` reference; otherwise surface the +# underlying error (a bare '%' can appear legitimately in an entity value, comment, or system id). +const _PE_REF_RE = r"%[A-Za-z_:][A-Za-z0-9._:-]*;" + +function parse_dtd(value::AbstractString) + try + return _parse_dtd_impl(value) + catch e + occursin(_PE_REF_RE, value) && error( + "parse_dtd does not expand parameter-entity references (e.g. `%text;`); structured DTD " * + "access is best-effort and non-validating, but the raw DTD node still round-trips " * + "through write. (underlying parse error: " * sprint(showerror, e) * ")") + rethrow(e) + end +end + +function _parse_dtd_impl(value::AbstractString) + s = String(value) + pos = 1 + + root, pos = _dtd_read_name(s, pos) + pos = _dtd_skip_ws(s, pos) + + # External ID + system_id = nothing + public_id = nothing + if pos <= ncodeunits(s) && _dtd_is_name_char(s[pos]) + keyword, kpos = _dtd_read_name(s, pos) + if keyword == "SYSTEM" + pos = kpos + uri, pos = _dtd_read_quoted(s, pos) + system_id = String(uri) + elseif keyword == "PUBLIC" + pos = kpos + pubid, pos = _dtd_read_quoted(s, pos) + public_id = String(pubid) + pos = _dtd_skip_ws(s, pos) + if pos <= ncodeunits(s) && (s[pos] == '"' || s[pos] == '\'') + uri, pos = _dtd_read_quoted(s, pos) + system_id = String(uri) + end + end + end + + elements = ElementDecl[] + attributes = AttDecl[] + entities = EntityDecl[] + notations = NotationDecl[] + + # Internal subset + pos = _dtd_skip_ws(s, pos) + if pos <= ncodeunits(s) && s[pos] == '[' + pos += 1 + while pos <= ncodeunits(s) + pos = _dtd_skip_ws(s, pos) + pos > ncodeunits(s) && break + s[pos] == ']' && break + + rest = SubString(s, pos) + if startswith(rest, "", s, pos + 4) + isnothing(i) && error("Unterminated comment in DTD") + pos = last(i) + 1 + elseif startswith(rest, "", s, pos + 2) + isnothing(i) && error("Unterminated PI in DTD") + pos = last(i) + 1 + elseif startswith(rest, " Date: Sun, 5 Jul 2026 17:57:42 +0200 Subject: [PATCH 2/6] Extract BOM handling, read entry points and the parser into src/parse.jl (pure move) Assisted-by: Claude (Anthropic) --- src/XML.jl | 244 +-------------------------------------------------- src/parse.jl | 243 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 244 insertions(+), 243 deletions(-) create mode 100644 src/parse.jl diff --git a/src/XML.jl b/src/XML.jl index e9b2e3f..d4e6979 100644 --- a/src/XML.jl +++ b/src/XML.jl @@ -728,249 +728,7 @@ Base.show(io::IO, ::MIME"text/xml", node::Node) = _write_xml(io, node) write(node::Node; indentsize::Int=2) = (io = IOBuffer(); _write_xml(io, node, 0, indentsize); String(take!(io))) write(filename::AbstractString, node::Node; kw...) = open(io -> write(io, node; kw...), filename, "w") write(io::IO, node::Node; indentsize::Int=2) = _write_xml(io, node, 0, indentsize) - -# Honor a leading byte-order mark (XML 1.0 §4.3.3): transcode UTF-16 (LE/BE) to UTF-8 -# and strip a UTF-8 BOM so the parser always sees UTF-8. Ported from #65 (was src/raw.jl). -function _normalize_bom(data::Vector{UInt8}) - n = length(data) - if n >= 2 && data[1] == 0xFF && data[2] == 0xFE # UTF-16 LE - isodd(n) && error("malformed UTF-16: odd number of bytes after the BOM (XML 1.0 §4.3.3)") - return Vector{UInt8}(transcode(String, reinterpret(UInt16, data[3:end]))) - elseif n >= 2 && data[1] == 0xFE && data[2] == 0xFF # UTF-16 BE - isodd(n) && error("malformed UTF-16: odd number of bytes after the BOM (XML 1.0 §4.3.3)") - return Vector{UInt8}(transcode(String, bswap.(reinterpret(UInt16, data[3:end])))) - elseif n >= 3 && data[1] == 0xEF && data[2] == 0xBB && data[3] == 0xBF # UTF-8 BOM - return data[4:end] - end - # No BOM matched. A NUL in the first two bytes means UTF-16/UTF-32 without a BOM — not - # well-formed XML (§4.3.3). Without this, :structural still rejects it downstream, but with a - # cryptic "invalid element name" (the interleaved NULs derail tokenization); this names the real - # cause. Two comparisons, no false positives (well-formed UTF-8 XML can't contain 0x00) — vs - # isvalid(String, data), which would be an O(n) hot-path scan. - n >= 2 && (data[1] == 0x00 || data[2] == 0x00) && - error("UTF-16 without a BOM is not well-formed (XML 1.0 §4.3.3)") - return data -end - -Base.read(filename::AbstractString, ::Type{Node}; wellformed::Symbol=:structural) = parse(String(_normalize_bom(read(filename))), Node; wellformed) -Base.read(io::IO, ::Type{Node}; wellformed::Symbol=:structural) = parse(String(_normalize_bom(read(io))), Node; wellformed) - -#-----------------------------------------------------------------------------# parse -# A leading U+FEFF (BOM as a character) isn't content — drop it so a BOM'd in-memory string -# parses cleanly. (The read path strips the BOM bytes via _normalize_bom; this covers -# parse(::AbstractString), where the bytes have already decoded to a U+FEFF char.) -_drop_bom(s::String)::String = startswith(s, '\ufeff') ? s[nextind(s, 1):end] : s - -# Generic (type-preserving) form so a leading U+FEFF is also dropped inside the type-parametric -# Cursor constructor (SubString today, StringView when mmap lands), not only on the String -# parse(_, Node)/LazyNode paths — keeping all three readers consistent on a BOM'd string. -_drop_bom(s::AbstractString) = startswith(s, Char(0xFEFF)) ? SubString(s, nextind(s, firstindex(s))) : s - -Base.parse(::Type{Node}, xml::AbstractString; wellformed::Symbol=:structural) = parse(xml, Node; wellformed) - -function Base.parse(xml::AbstractString, ::Type{Node}; wellformed::Symbol=:structural) - _parse(_drop_bom(String(xml)), String, unescape, Val(wellformed)) -end - -function Base.parse(xml::AbstractString, ::Type{Node{SubString{String}}}; wellformed::Symbol=:structural) - _parse(_drop_bom(String(xml)), SubString{String}, identity, Val(wellformed)) -end - -# Convert a parser substring to the requested storage type — copy to a fresh String, or -# keep the zero-copy SubString view. -_to(::Type{String}, s::AbstractString) = String(s) -_to(::Type{SubString{String}}, s::SubString{String}) = s - -# Collapse an empty Vector to `nothing` so Node fields store "absent" canonically. -_nothingify(v::Vector) = isempty(v) ? nothing : v - -# Decode the raw bytes of a TEXT/ATTR_VALUE token into the parser's storage type. When the -# tokenizer guarantees no `&` was seen (`has_entities=false`), we skip the entity-decode -# pass entirely. The `convert_text=identity` specialization (SubString parse) skips the -# runtime branch as well — both arms would return the same value. -@inline _text_value(::Type{S}, raw, _, ::typeof(identity)) where {S} = _to(S, raw) -@inline _text_value(::Type{S}, raw, has_entities, convert_text::F) where {S, F} = - has_entities ? convert_text(raw) : _to(S, raw) - -# An XML NameStartChar (lenient on Unicode, mirroring NAME_BYTE_TABLE): a letter, `_`, `:`, or -# any non-ASCII char — but NOT a digit / `-` / `.` (those are valid NameChars, just not first). -@inline _is_name_start(c::Char) = - ('a' <= c <= 'z') || ('A' <= c <= 'Z') || c == '_' || c == ':' || !isascii(c) - -# Document-shape well-formedness (`:structural`/`:strict`): exactly one root element (prolog -# markup with no root is rejected; an empty `""` or a whitespace-only input is still accepted — the -# former an empty Document, the latter a Document whose only child is whitespace Text), any -# top-level Text must be whitespace only, and a DOCTYPE must be a single declaration in the prolog -# (before the root). (`:lenient` skips this — gated + DCE'd.) -function _check_document_wellformed(children) - nroots = 0 - ndtds = 0 - has_markup = false # a Comment / PI / Declaration / CData / DTD at the top level - for (idx, c) in enumerate(children) - nt = nodetype(c) - if nt === Element - nroots += 1 - elseif nt === Text - isempty(strip(value(c))) || error("not well-formed: non-whitespace text at the top level") - else - has_markup = true - if nt === DTD - ndtds += 1 - nroots > 0 && error("not well-formed: DOCTYPE must precede the root element") - elseif nt === Declaration - # §2.8: the XML declaration must be the very first thing in the document. Only the - # first child may be a Declaration; a second (or any later) one is misplaced. - idx == 1 || error("not well-formed: the XML declaration must be the first thing in the document (XML 1.0 §2.8)") - end - end - end - nroots > 1 && error("not well-formed: multiple root elements (found $nroots)") - ndtds > 1 && error("not well-formed: multiple DOCTYPE declarations (found $ndtds)") - nroots == 0 && has_markup && error("not well-formed: no root element") -end - -# XML §2.2 Char production — the code points a character reference may legally denote. Stricter -# than Julia's `isvalid(Char, cp)`, which accepts #x0 and other C0 controls that XML forbids. -_is_xml_char(cp::Integer) = - cp == 0x9 || cp == 0xA || cp == 0xD || - (0x20 <= cp <= 0xD7FF) || (0xE000 <= cp <= 0xFFFD) || (0x10000 <= cp <= 0x10FFFF) - -# `:strict` only: reject a raw character outside the XML §2.2 Char range (e.g. NUL / C0 controls). -# Without this, a literal illegal character passes while its &#...; reference form is rejected — a -# reference-vs-raw asymmetry. DCE'd off the :strict path, so :lenient/:structural pay nothing. -function _check_chars_strict(s::AbstractString) - for c in s - _is_xml_char(UInt32(c)) || - error("not well-formed: character U+$(uppercase(string(UInt32(c); base = 16, pad = 4))) is outside the legal XML range (XML 1.0 §2.2)") - end -end - -# `:strict` only: reject any numeric character reference whose code point is outside the XML Char -# range. Gated + DCE'd off the :strict path, and only called when a token actually carries -# entities, so :lenient/:structural pay nothing. -function _check_charrefs_strict(s::AbstractString) - for m in eachmatch(r"&#([xX]?)([0-9a-fA-F]+);", s) - cp = tryparse(UInt32, m[2]; base = isempty(m[1]) ? 10 : 16) - (cp === nothing || !_is_xml_char(cp)) && - error("not well-formed: illegal character reference \"&#$(m[1])$(m[2]);\"") - end -end - -# Token-stream → Node{S} builder. `convert_text` is `unescape` for parsed content (with entity -# decoding) and `identity` for zero-copy SubString parsing where the caller keeps raw escapes. -# `Val{W}` is the well-formedness level (:lenient / :structural / :strict); its checks compile -# away on :lenient. -function _parse(xml::String, ::Type{S}, convert_text::F, ::Val{W}) where {S, F, W} - tags = S[] - attrs_stack = Vector{Pair{S,S}}[] - children_stack = Vector{Vector{Node{S}}}() - push!(children_stack, Node{S}[]) - - pending_attr_name = SubString(xml, 1, 0) - decl_attrs = nothing - pending_pi_tag = SubString(xml, 1, 0) - pending_pi_value = nothing - - for token in tokenize(xml) - k = token.kind - - if k === TokenKinds.TEXT - rawtext = raw(token, xml) - W === :strict && _check_chars_strict(rawtext) - W === :strict && token.has_entities && _check_charrefs_strict(rawtext) - v = _text_value(S, rawtext, token.has_entities, convert_text) - push!(last(children_stack), Node{S}(Text, nothing, nothing, v, nothing)) - - elseif k === TokenKinds.OPEN_TAG - nm = tag_name(token, xml) - W !== :lenient && (isempty(nm) || !_is_name_start(first(nm))) && - error("not well-formed: invalid element name \"$nm\"") - push!(tags, _to(S, nm)) - push!(attrs_stack, Pair{S,S}[]) - push!(children_stack, Node{S}[]) - - elseif k === TokenKinds.SELF_CLOSE - t = pop!(tags) - a = pop!(attrs_stack) - pop!(children_stack) - push!(last(children_stack), Node{S}(Element, t, _nothingify(a), nothing, nothing)) - - elseif k === TokenKinds.CLOSE_TAG - close_name = tag_name(token, xml) - isempty(tags) && error("Closing tag with no matching open tag.") - t = pop!(tags) - t == close_name || error("Mismatched tags: expected , got .") - a = pop!(attrs_stack) - c = pop!(children_stack) - push!(last(children_stack), Node{S}(Element, t, _nothingify(a), nothing, isempty(c) ? nothing : c)) - - elseif k === TokenKinds.ATTR_NAME - pending_attr_name = raw(token, xml) - - elseif k === TokenKinds.ATTR_VALUE - rawval = attr_value(token, xml) - W !== :lenient && occursin('<', rawval) && error("not well-formed: '<' in attribute value (XML 1.0 §3.1)") - W === :strict && _check_chars_strict(rawval) - W === :strict && token.has_entities && _check_charrefs_strict(rawval) - val = _text_value(S, rawval, token.has_entities, convert_text) - name = _to(S, pending_attr_name) - if decl_attrs !== nothing - any(p -> first(p) == name, decl_attrs) && error("Duplicate attribute: $name") - push!(decl_attrs, name => val) - elseif !isempty(attrs_stack) - any(p -> first(p) == name, last(attrs_stack)) && error("Duplicate attribute: $name") - push!(last(attrs_stack), name => val) - end - - elseif k === TokenKinds.XML_DECL_OPEN - decl_attrs = Pair{S,S}[] - - elseif k === TokenKinds.XML_DECL_CLOSE - W !== :lenient && length(children_stack) > 1 && - error("not well-formed: XML declaration inside element content") - a = isempty(decl_attrs) ? nothing : decl_attrs - push!(last(children_stack), Node{S}(Declaration, nothing, a, nothing, nothing)) - decl_attrs = nothing - - elseif k === TokenKinds.COMMENT_CONTENT - cmt = raw(token, xml) - W === :strict && _check_chars_strict(cmt) - W === :strict && occursin("--", cmt) && error("not well-formed: \"--\" within a comment") - W === :strict && endswith(cmt, '-') && error("not well-formed: \"-\" immediately before \"-->\" in a comment (XML 1.0 §2.5)") - push!(last(children_stack), Node{S}(Comment, nothing, nothing, _to(S, cmt), nothing)) - - elseif k === TokenKinds.CDATA_CONTENT - cdata = raw(token, xml) - W === :strict && _check_chars_strict(cdata) - push!(last(children_stack), Node{S}(CData, nothing, nothing, _to(S, cdata), nothing)) - - elseif k === TokenKinds.DOCTYPE_CONTENT - W !== :lenient && length(children_stack) > 1 && - error("not well-formed: DOCTYPE declaration inside element content") - push!(last(children_stack), Node{S}(DTD, nothing, nothing, _to(S, lstrip(raw(token, xml))), nothing)) - - elseif k === TokenKinds.PI_OPEN - pending_pi_tag = pi_target(token, xml) - W === :strict && (isempty(pending_pi_tag) || !_is_name_start(first(pending_pi_tag))) && - error("not well-formed: invalid processing-instruction target \"$pending_pi_tag\"") - pending_pi_value = nothing - - elseif k === TokenKinds.PI_CONTENT - content = lstrip(raw(token, xml)) - W === :strict && _check_chars_strict(content) - pending_pi_value = isempty(content) ? nothing : _to(S, content) - - elseif k === TokenKinds.PI_CLOSE - push!(last(children_stack), Node{S}(ProcessingInstruction, _to(S, pending_pi_tag), nothing, pending_pi_value, nothing)) - end - end - - !isempty(tags) && error("Unclosed tags: $(join(tags, ", "))") - doc_children = only(children_stack) - W !== :lenient && _check_document_wellformed(doc_children) - Node{S}(Document, nothing, nothing, nothing, isempty(doc_children) ? nothing : doc_children) -end - +include("parse.jl") #-----------------------------------------------------------------------------# h (HTML/XML element builder) """ h(tag, children...; attrs...) diff --git a/src/parse.jl b/src/parse.jl new file mode 100644 index 0000000..afcad3c --- /dev/null +++ b/src/parse.jl @@ -0,0 +1,243 @@ + +# Honor a leading byte-order mark (XML 1.0 §4.3.3): transcode UTF-16 (LE/BE) to UTF-8 +# and strip a UTF-8 BOM so the parser always sees UTF-8. Ported from #65 (was src/raw.jl). +function _normalize_bom(data::Vector{UInt8}) + n = length(data) + if n >= 2 && data[1] == 0xFF && data[2] == 0xFE # UTF-16 LE + isodd(n) && error("malformed UTF-16: odd number of bytes after the BOM (XML 1.0 §4.3.3)") + return Vector{UInt8}(transcode(String, reinterpret(UInt16, data[3:end]))) + elseif n >= 2 && data[1] == 0xFE && data[2] == 0xFF # UTF-16 BE + isodd(n) && error("malformed UTF-16: odd number of bytes after the BOM (XML 1.0 §4.3.3)") + return Vector{UInt8}(transcode(String, bswap.(reinterpret(UInt16, data[3:end])))) + elseif n >= 3 && data[1] == 0xEF && data[2] == 0xBB && data[3] == 0xBF # UTF-8 BOM + return data[4:end] + end + # No BOM matched. A NUL in the first two bytes means UTF-16/UTF-32 without a BOM — not + # well-formed XML (§4.3.3). Without this, :structural still rejects it downstream, but with a + # cryptic "invalid element name" (the interleaved NULs derail tokenization); this names the real + # cause. Two comparisons, no false positives (well-formed UTF-8 XML can't contain 0x00) — vs + # isvalid(String, data), which would be an O(n) hot-path scan. + n >= 2 && (data[1] == 0x00 || data[2] == 0x00) && + error("UTF-16 without a BOM is not well-formed (XML 1.0 §4.3.3)") + return data +end + +Base.read(filename::AbstractString, ::Type{Node}; wellformed::Symbol=:structural) = parse(String(_normalize_bom(read(filename))), Node; wellformed) +Base.read(io::IO, ::Type{Node}; wellformed::Symbol=:structural) = parse(String(_normalize_bom(read(io))), Node; wellformed) + +#-----------------------------------------------------------------------------# parse +# A leading U+FEFF (BOM as a character) isn't content — drop it so a BOM'd in-memory string +# parses cleanly. (The read path strips the BOM bytes via _normalize_bom; this covers +# parse(::AbstractString), where the bytes have already decoded to a U+FEFF char.) +_drop_bom(s::String)::String = startswith(s, '\ufeff') ? s[nextind(s, 1):end] : s + +# Generic (type-preserving) form so a leading U+FEFF is also dropped inside the type-parametric +# Cursor constructor (SubString today, StringView when mmap lands), not only on the String +# parse(_, Node)/LazyNode paths — keeping all three readers consistent on a BOM'd string. +_drop_bom(s::AbstractString) = startswith(s, Char(0xFEFF)) ? SubString(s, nextind(s, firstindex(s))) : s + +Base.parse(::Type{Node}, xml::AbstractString; wellformed::Symbol=:structural) = parse(xml, Node; wellformed) + +function Base.parse(xml::AbstractString, ::Type{Node}; wellformed::Symbol=:structural) + _parse(_drop_bom(String(xml)), String, unescape, Val(wellformed)) +end + +function Base.parse(xml::AbstractString, ::Type{Node{SubString{String}}}; wellformed::Symbol=:structural) + _parse(_drop_bom(String(xml)), SubString{String}, identity, Val(wellformed)) +end + +# Convert a parser substring to the requested storage type — copy to a fresh String, or +# keep the zero-copy SubString view. +_to(::Type{String}, s::AbstractString) = String(s) +_to(::Type{SubString{String}}, s::SubString{String}) = s + +# Collapse an empty Vector to `nothing` so Node fields store "absent" canonically. +_nothingify(v::Vector) = isempty(v) ? nothing : v + +# Decode the raw bytes of a TEXT/ATTR_VALUE token into the parser's storage type. When the +# tokenizer guarantees no `&` was seen (`has_entities=false`), we skip the entity-decode +# pass entirely. The `convert_text=identity` specialization (SubString parse) skips the +# runtime branch as well — both arms would return the same value. +@inline _text_value(::Type{S}, raw, _, ::typeof(identity)) where {S} = _to(S, raw) +@inline _text_value(::Type{S}, raw, has_entities, convert_text::F) where {S, F} = + has_entities ? convert_text(raw) : _to(S, raw) + +# An XML NameStartChar (lenient on Unicode, mirroring NAME_BYTE_TABLE): a letter, `_`, `:`, or +# any non-ASCII char — but NOT a digit / `-` / `.` (those are valid NameChars, just not first). +@inline _is_name_start(c::Char) = + ('a' <= c <= 'z') || ('A' <= c <= 'Z') || c == '_' || c == ':' || !isascii(c) + +# Document-shape well-formedness (`:structural`/`:strict`): exactly one root element (prolog +# markup with no root is rejected; an empty `""` or a whitespace-only input is still accepted — the +# former an empty Document, the latter a Document whose only child is whitespace Text), any +# top-level Text must be whitespace only, and a DOCTYPE must be a single declaration in the prolog +# (before the root). (`:lenient` skips this — gated + DCE'd.) +function _check_document_wellformed(children) + nroots = 0 + ndtds = 0 + has_markup = false # a Comment / PI / Declaration / CData / DTD at the top level + for (idx, c) in enumerate(children) + nt = nodetype(c) + if nt === Element + nroots += 1 + elseif nt === Text + isempty(strip(value(c))) || error("not well-formed: non-whitespace text at the top level") + else + has_markup = true + if nt === DTD + ndtds += 1 + nroots > 0 && error("not well-formed: DOCTYPE must precede the root element") + elseif nt === Declaration + # §2.8: the XML declaration must be the very first thing in the document. Only the + # first child may be a Declaration; a second (or any later) one is misplaced. + idx == 1 || error("not well-formed: the XML declaration must be the first thing in the document (XML 1.0 §2.8)") + end + end + end + nroots > 1 && error("not well-formed: multiple root elements (found $nroots)") + ndtds > 1 && error("not well-formed: multiple DOCTYPE declarations (found $ndtds)") + nroots == 0 && has_markup && error("not well-formed: no root element") +end + +# XML §2.2 Char production — the code points a character reference may legally denote. Stricter +# than Julia's `isvalid(Char, cp)`, which accepts #x0 and other C0 controls that XML forbids. +_is_xml_char(cp::Integer) = + cp == 0x9 || cp == 0xA || cp == 0xD || + (0x20 <= cp <= 0xD7FF) || (0xE000 <= cp <= 0xFFFD) || (0x10000 <= cp <= 0x10FFFF) + +# `:strict` only: reject a raw character outside the XML §2.2 Char range (e.g. NUL / C0 controls). +# Without this, a literal illegal character passes while its &#...; reference form is rejected — a +# reference-vs-raw asymmetry. DCE'd off the :strict path, so :lenient/:structural pay nothing. +function _check_chars_strict(s::AbstractString) + for c in s + _is_xml_char(UInt32(c)) || + error("not well-formed: character U+$(uppercase(string(UInt32(c); base = 16, pad = 4))) is outside the legal XML range (XML 1.0 §2.2)") + end +end + +# `:strict` only: reject any numeric character reference whose code point is outside the XML Char +# range. Gated + DCE'd off the :strict path, and only called when a token actually carries +# entities, so :lenient/:structural pay nothing. +function _check_charrefs_strict(s::AbstractString) + for m in eachmatch(r"&#([xX]?)([0-9a-fA-F]+);", s) + cp = tryparse(UInt32, m[2]; base = isempty(m[1]) ? 10 : 16) + (cp === nothing || !_is_xml_char(cp)) && + error("not well-formed: illegal character reference \"&#$(m[1])$(m[2]);\"") + end +end + +# Token-stream → Node{S} builder. `convert_text` is `unescape` for parsed content (with entity +# decoding) and `identity` for zero-copy SubString parsing where the caller keeps raw escapes. +# `Val{W}` is the well-formedness level (:lenient / :structural / :strict); its checks compile +# away on :lenient. +function _parse(xml::String, ::Type{S}, convert_text::F, ::Val{W}) where {S, F, W} + tags = S[] + attrs_stack = Vector{Pair{S,S}}[] + children_stack = Vector{Vector{Node{S}}}() + push!(children_stack, Node{S}[]) + + pending_attr_name = SubString(xml, 1, 0) + decl_attrs = nothing + pending_pi_tag = SubString(xml, 1, 0) + pending_pi_value = nothing + + for token in tokenize(xml) + k = token.kind + + if k === TokenKinds.TEXT + rawtext = raw(token, xml) + W === :strict && _check_chars_strict(rawtext) + W === :strict && token.has_entities && _check_charrefs_strict(rawtext) + v = _text_value(S, rawtext, token.has_entities, convert_text) + push!(last(children_stack), Node{S}(Text, nothing, nothing, v, nothing)) + + elseif k === TokenKinds.OPEN_TAG + nm = tag_name(token, xml) + W !== :lenient && (isempty(nm) || !_is_name_start(first(nm))) && + error("not well-formed: invalid element name \"$nm\"") + push!(tags, _to(S, nm)) + push!(attrs_stack, Pair{S,S}[]) + push!(children_stack, Node{S}[]) + + elseif k === TokenKinds.SELF_CLOSE + t = pop!(tags) + a = pop!(attrs_stack) + pop!(children_stack) + push!(last(children_stack), Node{S}(Element, t, _nothingify(a), nothing, nothing)) + + elseif k === TokenKinds.CLOSE_TAG + close_name = tag_name(token, xml) + isempty(tags) && error("Closing tag with no matching open tag.") + t = pop!(tags) + t == close_name || error("Mismatched tags: expected , got .") + a = pop!(attrs_stack) + c = pop!(children_stack) + push!(last(children_stack), Node{S}(Element, t, _nothingify(a), nothing, isempty(c) ? nothing : c)) + + elseif k === TokenKinds.ATTR_NAME + pending_attr_name = raw(token, xml) + + elseif k === TokenKinds.ATTR_VALUE + rawval = attr_value(token, xml) + W !== :lenient && occursin('<', rawval) && error("not well-formed: '<' in attribute value (XML 1.0 §3.1)") + W === :strict && _check_chars_strict(rawval) + W === :strict && token.has_entities && _check_charrefs_strict(rawval) + val = _text_value(S, rawval, token.has_entities, convert_text) + name = _to(S, pending_attr_name) + if decl_attrs !== nothing + any(p -> first(p) == name, decl_attrs) && error("Duplicate attribute: $name") + push!(decl_attrs, name => val) + elseif !isempty(attrs_stack) + any(p -> first(p) == name, last(attrs_stack)) && error("Duplicate attribute: $name") + push!(last(attrs_stack), name => val) + end + + elseif k === TokenKinds.XML_DECL_OPEN + decl_attrs = Pair{S,S}[] + + elseif k === TokenKinds.XML_DECL_CLOSE + W !== :lenient && length(children_stack) > 1 && + error("not well-formed: XML declaration inside element content") + a = isempty(decl_attrs) ? nothing : decl_attrs + push!(last(children_stack), Node{S}(Declaration, nothing, a, nothing, nothing)) + decl_attrs = nothing + + elseif k === TokenKinds.COMMENT_CONTENT + cmt = raw(token, xml) + W === :strict && _check_chars_strict(cmt) + W === :strict && occursin("--", cmt) && error("not well-formed: \"--\" within a comment") + W === :strict && endswith(cmt, '-') && error("not well-formed: \"-\" immediately before \"-->\" in a comment (XML 1.0 §2.5)") + push!(last(children_stack), Node{S}(Comment, nothing, nothing, _to(S, cmt), nothing)) + + elseif k === TokenKinds.CDATA_CONTENT + cdata = raw(token, xml) + W === :strict && _check_chars_strict(cdata) + push!(last(children_stack), Node{S}(CData, nothing, nothing, _to(S, cdata), nothing)) + + elseif k === TokenKinds.DOCTYPE_CONTENT + W !== :lenient && length(children_stack) > 1 && + error("not well-formed: DOCTYPE declaration inside element content") + push!(last(children_stack), Node{S}(DTD, nothing, nothing, _to(S, lstrip(raw(token, xml))), nothing)) + + elseif k === TokenKinds.PI_OPEN + pending_pi_tag = pi_target(token, xml) + W === :strict && (isempty(pending_pi_tag) || !_is_name_start(first(pending_pi_tag))) && + error("not well-formed: invalid processing-instruction target \"$pending_pi_tag\"") + pending_pi_value = nothing + + elseif k === TokenKinds.PI_CONTENT + content = lstrip(raw(token, xml)) + W === :strict && _check_chars_strict(content) + pending_pi_value = isempty(content) ? nothing : _to(S, content) + + elseif k === TokenKinds.PI_CLOSE + push!(last(children_stack), Node{S}(ProcessingInstruction, _to(S, pending_pi_tag), nothing, pending_pi_value, nothing)) + end + end + + !isempty(tags) && error("Unclosed tags: $(join(tags, ", "))") + doc_children = only(children_stack) + W !== :lenient && _check_document_wellformed(doc_children) + Node{S}(Document, nothing, nothing, nothing, isempty(doc_children) ? nothing : doc_children) +end + From 9d812fd2793b1874803079168876927f1c12a31e Mon Sep 17 00:00:00 2001 From: Mathieu BISKUPSKI Date: Sun, 5 Jul 2026 17:58:09 +0200 Subject: [PATCH 3/6] Extract the XML writer into src/write.jl (pure move) Assisted-by: Claude (Anthropic) --- src/XML.jl | 178 +-------------------------------------------------- src/write.jl | 177 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 178 insertions(+), 177 deletions(-) create mode 100644 src/write.jl diff --git a/src/XML.jl b/src/XML.jl index d4e6979..2b1f152 100644 --- a/src/XML.jl +++ b/src/XML.jl @@ -551,183 +551,7 @@ function Base.show(io::IO, o::Node) end end -#-----------------------------------------------------------------------------# show (text/xml) - -# Write XML-escaped content directly to IO (single pass, no intermediate string) -function _write_escaped(io::IO, s::String) - start = 1 - i = 1 - n = ncodeunits(s) - @inbounds while i <= n - b = codeunit(s, i) - esc = if b == UInt8('&'); "&" - elseif b == UInt8('<'); "<" - elseif b == UInt8('>'); ">" - elseif b == UInt8('"'); """ - elseif b == UInt8('\''); "'" - else - i += 1 - continue - end - i > start && GC.@preserve s Base.unsafe_write(io, pointer(s, start), (i - start) % UInt) - print(io, esc) - i += 1 - start = i - end - start <= n && GC.@preserve s Base.unsafe_write(io, pointer(s, start), (n - start + 1) % UInt) - nothing -end - -# Cached indentation strings to avoid repeated allocation -const _MAX_CACHED_INDENT = 64 -const _INDENT_STRINGS = [" " ^ n for n in 0:_MAX_CACHED_INDENT] -@inline function _indent_str(n::Int) - 0 <= n <= _MAX_CACHED_INDENT && return @inbounds _INDENT_STRINGS[n + 1] - " " ^ n -end - -# Serialize `key="escaped-value"` pairs for an attributes vector (no leading space outside). -# Uses byte-level `Base.write` instead of `print` to avoid the varargs-print dispatch -# overhead that shows up under profile when an element has many attributes. -function _print_attrs(io::IO, attributes) - isnothing(attributes) && return - for (k, v) in attributes - Base.write(io, UInt8(' ')) - Base.write(io, k) - Base.write(io, UInt8('=')) - Base.write(io, UInt8('"')) - _write_escaped(io, v) - Base.write(io, UInt8('"')) - end -end - -# Whitespace-only Text — emitted by the parser to round-trip source whitespace; pretty -# printing regenerates indentation from the tree shape and drops these. -@inline function _is_ignorable_text(node::Node) - node.nodetype === Text && !isnothing(node.value) && all(isspace, node.value) -end - -# Mixed content = at least one Text/CData child carrying actual (non-whitespace) data. -# In that case the original whitespace is significant and we must not reformat. -function _has_significant_text(children) - for c in children - nt = c.nodetype - if nt === Text - (!isnothing(c.value) && !all(isspace, c.value)) && return true - elseif nt === CData - return true - end - end - false -end - -# Main XML serializer. `depth` controls indentation; `preserve` propagates `xml:space= -# "preserve"` semantics down the subtree so we don't reformat whitespace-sensitive content. -function _write_xml(io::IO, node::Node, depth::Int=0, indent::Int=2, preserve::Bool=false) - pad = preserve ? "" : _indent_str(indent * depth) - nt = node.nodetype - if nt === Text - _write_escaped(io, node.value) - elseif nt === Element - # Check xml:space on this element - child_preserve = preserve - if !isnothing(node.attributes) - for (k, v) in node.attributes - k == "xml:space" && (child_preserve = v == "preserve") - end - end - Base.write(io, pad) - Base.write(io, UInt8('<')) - Base.write(io, node.tag) - _print_attrs(io, node.attributes) - ch = node.children - if isnothing(ch) || isempty(ch) - Base.write(io, UInt8('/')) - Base.write(io, UInt8('>')) - elseif length(ch) == 1 && only(ch).nodetype === Text - Base.write(io, UInt8('>')) - _write_xml(io, only(ch), 0, 0, child_preserve) - Base.write(io, UInt8('<')) - Base.write(io, UInt8('/')) - Base.write(io, node.tag) - Base.write(io, UInt8('>')) - else - # If real Text or any CData lives among the children, treat as mixed - # content and preserve the original layout. Otherwise pretty-print - # and skip whitespace-only Text children — those were emitted by the - # parser purely to round-trip source whitespace, and the writer - # regenerates indentation from the tree shape. - effective_preserve = child_preserve || _has_significant_text(ch) - if effective_preserve - Base.write(io, UInt8('>')) - else - Base.write(io, UInt8('>')) - Base.write(io, UInt8('\n')) - end - for child in ch - if !effective_preserve && _is_ignorable_text(child) - continue - end - _write_xml(io, child, depth + 1, indent, effective_preserve) - effective_preserve || Base.write(io, UInt8('\n')) - end - effective_preserve || Base.write(io, pad) - Base.write(io, UInt8('<')) - Base.write(io, UInt8('/')) - Base.write(io, node.tag) - Base.write(io, UInt8('>')) - end - elseif nt === Declaration - Base.write(io, pad) - Base.write(io, "") - elseif nt === ProcessingInstruction - Base.write(io, pad) - Base.write(io, "") - elseif nt === Comment - Base.write(io, pad) - Base.write(io, "") - elseif nt === CData - Base.write(io, pad) - Base.write(io, "") - elseif nt === DTD - Base.write(io, pad) - Base.write(io, "')) - elseif nt === Document - ch = node.children - if !isnothing(ch) - # Drop whitespace-only Text between top-level nodes when pretty - # printing (XML grammar disallows text at document level, so any - # such Text comes from inter-node whitespace in the source). - visible = preserve ? ch : filter(!_is_ignorable_text, ch) - n_visible = length(visible) - for (i, child) in enumerate(visible) - _write_xml(io, child, 0, indent, preserve) - i < n_visible && Base.write(io, UInt8('\n')) - end - end - end -end - -Base.show(io::IO, ::MIME"text/xml", node::Node) = _write_xml(io, node) - -#-----------------------------------------------------------------------------# write / read -write(node::Node; indentsize::Int=2) = (io = IOBuffer(); _write_xml(io, node, 0, indentsize); String(take!(io))) -write(filename::AbstractString, node::Node; kw...) = open(io -> write(io, node; kw...), filename, "w") -write(io::IO, node::Node; indentsize::Int=2) = _write_xml(io, node, 0, indentsize) +include("write.jl") include("parse.jl") #-----------------------------------------------------------------------------# h (HTML/XML element builder) """ diff --git a/src/write.jl b/src/write.jl new file mode 100644 index 0000000..c3260f5 --- /dev/null +++ b/src/write.jl @@ -0,0 +1,177 @@ +#-----------------------------------------------------------------------------# show (text/xml) + +# Write XML-escaped content directly to IO (single pass, no intermediate string) +function _write_escaped(io::IO, s::String) + start = 1 + i = 1 + n = ncodeunits(s) + @inbounds while i <= n + b = codeunit(s, i) + esc = if b == UInt8('&'); "&" + elseif b == UInt8('<'); "<" + elseif b == UInt8('>'); ">" + elseif b == UInt8('"'); """ + elseif b == UInt8('\''); "'" + else + i += 1 + continue + end + i > start && GC.@preserve s Base.unsafe_write(io, pointer(s, start), (i - start) % UInt) + print(io, esc) + i += 1 + start = i + end + start <= n && GC.@preserve s Base.unsafe_write(io, pointer(s, start), (n - start + 1) % UInt) + nothing +end + +# Cached indentation strings to avoid repeated allocation +const _MAX_CACHED_INDENT = 64 +const _INDENT_STRINGS = [" " ^ n for n in 0:_MAX_CACHED_INDENT] +@inline function _indent_str(n::Int) + 0 <= n <= _MAX_CACHED_INDENT && return @inbounds _INDENT_STRINGS[n + 1] + " " ^ n +end + +# Serialize `key="escaped-value"` pairs for an attributes vector (no leading space outside). +# Uses byte-level `Base.write` instead of `print` to avoid the varargs-print dispatch +# overhead that shows up under profile when an element has many attributes. +function _print_attrs(io::IO, attributes) + isnothing(attributes) && return + for (k, v) in attributes + Base.write(io, UInt8(' ')) + Base.write(io, k) + Base.write(io, UInt8('=')) + Base.write(io, UInt8('"')) + _write_escaped(io, v) + Base.write(io, UInt8('"')) + end +end + +# Whitespace-only Text — emitted by the parser to round-trip source whitespace; pretty +# printing regenerates indentation from the tree shape and drops these. +@inline function _is_ignorable_text(node::Node) + node.nodetype === Text && !isnothing(node.value) && all(isspace, node.value) +end + +# Mixed content = at least one Text/CData child carrying actual (non-whitespace) data. +# In that case the original whitespace is significant and we must not reformat. +function _has_significant_text(children) + for c in children + nt = c.nodetype + if nt === Text + (!isnothing(c.value) && !all(isspace, c.value)) && return true + elseif nt === CData + return true + end + end + false +end + +# Main XML serializer. `depth` controls indentation; `preserve` propagates `xml:space= +# "preserve"` semantics down the subtree so we don't reformat whitespace-sensitive content. +function _write_xml(io::IO, node::Node, depth::Int=0, indent::Int=2, preserve::Bool=false) + pad = preserve ? "" : _indent_str(indent * depth) + nt = node.nodetype + if nt === Text + _write_escaped(io, node.value) + elseif nt === Element + # Check xml:space on this element + child_preserve = preserve + if !isnothing(node.attributes) + for (k, v) in node.attributes + k == "xml:space" && (child_preserve = v == "preserve") + end + end + Base.write(io, pad) + Base.write(io, UInt8('<')) + Base.write(io, node.tag) + _print_attrs(io, node.attributes) + ch = node.children + if isnothing(ch) || isempty(ch) + Base.write(io, UInt8('/')) + Base.write(io, UInt8('>')) + elseif length(ch) == 1 && only(ch).nodetype === Text + Base.write(io, UInt8('>')) + _write_xml(io, only(ch), 0, 0, child_preserve) + Base.write(io, UInt8('<')) + Base.write(io, UInt8('/')) + Base.write(io, node.tag) + Base.write(io, UInt8('>')) + else + # If real Text or any CData lives among the children, treat as mixed + # content and preserve the original layout. Otherwise pretty-print + # and skip whitespace-only Text children — those were emitted by the + # parser purely to round-trip source whitespace, and the writer + # regenerates indentation from the tree shape. + effective_preserve = child_preserve || _has_significant_text(ch) + if effective_preserve + Base.write(io, UInt8('>')) + else + Base.write(io, UInt8('>')) + Base.write(io, UInt8('\n')) + end + for child in ch + if !effective_preserve && _is_ignorable_text(child) + continue + end + _write_xml(io, child, depth + 1, indent, effective_preserve) + effective_preserve || Base.write(io, UInt8('\n')) + end + effective_preserve || Base.write(io, pad) + Base.write(io, UInt8('<')) + Base.write(io, UInt8('/')) + Base.write(io, node.tag) + Base.write(io, UInt8('>')) + end + elseif nt === Declaration + Base.write(io, pad) + Base.write(io, "") + elseif nt === ProcessingInstruction + Base.write(io, pad) + Base.write(io, "") + elseif nt === Comment + Base.write(io, pad) + Base.write(io, "") + elseif nt === CData + Base.write(io, pad) + Base.write(io, "") + elseif nt === DTD + Base.write(io, pad) + Base.write(io, "')) + elseif nt === Document + ch = node.children + if !isnothing(ch) + # Drop whitespace-only Text between top-level nodes when pretty + # printing (XML grammar disallows text at document level, so any + # such Text comes from inter-node whitespace in the source). + visible = preserve ? ch : filter(!_is_ignorable_text, ch) + n_visible = length(visible) + for (i, child) in enumerate(visible) + _write_xml(io, child, 0, indent, preserve) + i < n_visible && Base.write(io, UInt8('\n')) + end + end + end +end + +Base.show(io::IO, ::MIME"text/xml", node::Node) = _write_xml(io, node) + +#-----------------------------------------------------------------------------# write / read +write(node::Node; indentsize::Int=2) = (io = IOBuffer(); _write_xml(io, node, 0, indentsize); String(take!(io))) +write(filename::AbstractString, node::Node; kw...) = open(io -> write(io, node; kw...), filename, "w") +write(io::IO, node::Node; indentsize::Int=2) = _write_xml(io, node, 0, indentsize) From 8ad2cf596fbef94c93e84be680c07f498c85fe62 Mon Sep 17 00:00:00 2001 From: Mathieu BISKUPSKI Date: Sun, 5 Jul 2026 17:58:37 +0200 Subject: [PATCH 4/6] Extract node types, accessors, navigation, equality, indexing, mutation and REPL show into src/node.jl (pure move) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The satellite reader includes (xpath/lazynode/cursor), which sat mid-file inside the navigation section, are hoisted next to include("node.jl") — nothing after them depends on the lazy readers at load time. Assisted-by: Claude (Anthropic) --- src/XML.jl | 470 +--------------------------------------------------- src/node.jl | 469 +++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 470 insertions(+), 469 deletions(-) create mode 100644 src/node.jl diff --git a/src/XML.jl b/src/XML.jl index 2b1f152..b9322e2 100644 --- a/src/XML.jl +++ b/src/XML.jl @@ -79,478 +79,10 @@ function unescape(x::SubString{String}) replace(String(x), _ENTITY_RE => _unescape_entity) end -#-----------------------------------------------------------------------------# NodeType -""" - NodeType: - - Document # prolog & root Element - - DTD # - - Declaration # - - ProcessingInstruction # - - Comment # - - CData # - - Element # children... - - Text # text - -NodeTypes can be used to construct XML.Nodes: - - Document(children...) - DTD(value) - Declaration(; attributes) - ProcessingInstruction(tag, content) - Comment(text) - CData(text) - Element(tag, children...; attributes) - Text(text) -""" -@enum NodeType::UInt8 CData Comment Declaration Document DTD Element ProcessingInstruction Text - -#-----------------------------------------------------------------------------# Attributes -""" - Attributes{S} <: AbstractDict{S, S} - -An ordered dictionary of XML attributes backed by a `Vector{Pair{S, S}}`. -Returned by [`attributes`](@ref). Preserves insertion order and supports the -full `AbstractDict` interface (`get`, `haskey`, `keys`, `values`, iteration, etc.). -""" -struct Attributes{S} <: AbstractDict{S, S} - entries::Vector{Pair{S, S}} -end - -Base.length(a::Attributes) = length(a.entries) -Base.iterate(a::Attributes, state...) = iterate(a.entries, state...) - -function Base.getindex(a::Attributes, key::AbstractString) - for (k, v) in a.entries - k == key && return v - end - throw(KeyError(key)) -end - -function Base.get(a::Attributes, key::AbstractString, default) - for (k, v) in a.entries - k == key && return v - end - default -end - -function Base.haskey(a::Attributes, key::AbstractString) - any(p -> first(p) == key, a.entries) -end - -Base.keys(a::Attributes) = first.(a.entries) -Base.values(a::Attributes) = last.(a.entries) - -#-----------------------------------------------------------------------------# Node -""" - Node{S} - -In-memory DOM node parameterized on the string storage type `S` (typically `String`, or -`SubString{String}` for zero-copy parsing). Every kind of XML node — `Element`, `Text`, -`Comment`, `CData`, `ProcessingInstruction`, `Declaration`, `DTD`, `Document` — is -represented by a single `Node{S}` whose [`NodeType`](@ref) determines which fields are -populated. - - parse(xml, Node) # parse a string into a Node{String} - parse(xml, Node{SubString{String}}) # zero-copy variant - read(filename, Node) # read & parse a file - -Use the accessor functions ([`nodetype`](@ref), [`tag`](@ref), [`attributes`](@ref), -[`value`](@ref), [`children`](@ref)) rather than the raw fields when navigating a tree. -Integer indexing returns children (`node[1]`); string indexing returns attribute values -(`node["class"]`). -""" -struct Node{S} - nodetype::NodeType - tag::Union{Nothing, S} - attributes::Union{Nothing, Vector{Pair{S, S}}} - value::Union{Nothing, S} - children::Union{Nothing, Vector{Node{S}}} - - function Node{S}(nodetype::NodeType, tag, attributes, value, children) where {S} - if nodetype in (Text, Comment, CData, DTD) - isnothing(tag) && isnothing(attributes) && !isnothing(value) && isnothing(children) || - error("$nodetype nodes only accept a value.") - elseif nodetype === Element - !isnothing(tag) && isnothing(value) || - error("Element nodes require a tag and no value.") - elseif nodetype === Declaration - isnothing(tag) && isnothing(value) && isnothing(children) || - error("Declaration nodes only accept attributes.") - elseif nodetype === ProcessingInstruction - !isnothing(tag) && isnothing(attributes) && isnothing(children) || - error("ProcessingInstruction nodes require a tag and only accept a value.") - elseif nodetype === Document - isnothing(tag) && isnothing(attributes) && isnothing(value) || - error("Document nodes only accept children.") - end - new{S}(nodetype, tag, attributes, value, children) - end -end - -#-----------------------------------------------------------------------------# interface -""" - nodetype(node) -> NodeType - -Return the [`NodeType`](@ref) of `node` (`Element`, `Text`, `Comment`, `CData`, -`ProcessingInstruction`, `Declaration`, `DTD`, or `Document`). -""" -nodetype(o::Node) = o.nodetype - -""" - tag(node) -> Union{String, SubString{String}, Nothing} - -Return the tag name of `node`. Defined for `Element` (element name) and -`ProcessingInstruction` (target name); returns `nothing` for other node types. -""" -tag(o::Node) = o.tag - -""" - attributes(node::Node) -> Union{Nothing, Attributes{String}} - -Return the attributes of an `Element` or `Declaration` node as an [`Attributes`](@ref) dict, -or `nothing` if the node has no attributes. - -!!! note "Changed in v0.4" - In previous versions, `attributes` returned an `OrderedDict` from OrderedCollections.jl. - It now returns an [`Attributes`](@ref), an ordered `AbstractDict` backed by a - `Vector{Pair}`. -""" -attributes(o::Node) = isnothing(o.attributes) ? nothing : Attributes(o.attributes) - -""" - value(node) -> Union{String, SubString{String}, Nothing} - -Return the textual content of `node`. Defined for `Text`, `Comment`, `CData`, `DTD`, and -`ProcessingInstruction`; returns `nothing` for `Element`, `Declaration`, and `Document` -(use [`children`](@ref) for those). -""" -value(o::Node) = o.value - -""" - children(node) -> Vector{Node} or () - -Return the child nodes of `node` in document order. Returns an empty tuple `()` for nodes -that cannot have children (e.g. `Text`, `Comment`, `CData`). -""" -children(o::Node) = something(o.children, ()) - -""" - eachelement(node) - -Lazy iterator over the child *elements* of `node`, skipping every other node type -(`Text`, `Comment`, `CData`, `ProcessingInstruction`, …). Since v0.4 preserves -inter-element whitespace, `children` on pretty-printed documents interleaves -whitespace `Text` nodes with elements — `eachelement` is the idiomatic way to -iterate the elements only. Works with both `Node` and `LazyNode`. - - for el in eachelement(node) - # el isa Element node - end - -See also [`elements`](@ref). -""" -eachelement(node) = Iterators.filter(n -> nodetype(n) === Element, children(node)) - -""" - elements(node) -> Vector - -The child *elements* of `node` in document order — the collected counterpart of -[`eachelement`](@ref). -""" -elements(node) = collect(eachelement(node)) - -""" - is_simple(node) -> Bool - -Return `true` if `node` is an `Element` with no attributes and exactly one `Text` or -`CData` child — i.e. the `content` pattern with no nested markup. See also -[`simple_value`](@ref). -""" -is_simple(o::Node) = o.nodetype === Element && - (isnothing(o.attributes) || isempty(o.attributes)) && - !isnothing(o.children) && length(o.children) == 1 && - o.children[1].nodetype in (Text, CData) - -""" - simple_value(node) -> String - -Return the textual content of a simple element (see [`is_simple`](@ref)). Errors if -`node` is not simple. -""" -simple_value(o::Node) = is_simple(o) ? o.children[1].value : - error("`simple_value` is only defined for simple nodes.") - -""" - is_simple_value(node) -> Union{Nothing, String, SubString{String}} - -Combined predicate-and-accessor: return the simple text/CData value of `node` if it is a -simple element (see [`is_simple`](@ref)), or `nothing` otherwise. Avoids the redundant -tokenization that `is_simple(n) ? simple_value(n) : ...` does on `LazyNode`. -""" -is_simple_value(o::Node) = is_simple(o) ? o.children[1].value : nothing - -#-----------------------------------------------------------------------------# tree navigation - -""" - parent(child::Node, root::Node) -> Node - -Return the parent of `child` within the tree rooted at `root`. - -Since `Node` does not store parent pointers, this performs a tree search from `root`. -Throws an error if `child` is not found or if `child === root`. - -!!! warning "Value identity" - `Node` is an immutable value type, so the search matches by structural equality (`===`). In a - tree containing value-identical sibling nodes (e.g. two empty `` elements), this may - return the parent of the *first* match rather than the specific node passed. The same applies to - [`depth`](@ref), [`siblings`](@ref), and the XPath `..` axis. A path-based redesign is planned. -""" -function Base.parent(child::Node, root::Node) - child === root && error("Root node has no parent.") - result = _find_parent(child, root) - isnothing(result) && error("Node not found in tree.") - result -end - -# Depth-first search for `child` within `current`; returns the containing node or nothing. -function _find_parent(child::Node, current::Node) - for c in children(current) - c === child && return current - result = _find_parent(child, c) - isnothing(result) || return result - end - nothing -end - -""" - depth(child::Node, root::Node) -> Int - -Return the depth of `child` within the tree rooted at `root` (root has depth 0). - -Since `Node` does not store parent pointers, this performs a tree search from `root`. -Throws an error if `child` is not found in the tree. -""" -function depth(child::Node, root::Node) - child === root && return 0 - result = _find_depth(child, root, 0) - isnothing(result) && error("Node not found in tree.") - result -end - -# Depth-first search returning the depth of `child` relative to `current` (where children -# of `current` are at depth `d + 1`), or nothing if not found. -function _find_depth(child::Node, current::Node, d::Int) - for c in children(current) - c === child && return d + 1 - result = _find_depth(child, c, d + 1) - isnothing(result) || return result - end - nothing -end - -""" - siblings(child::Node, root::Node) -> Vector{Node} - -Return the siblings of `child` (other children of the same parent) within the tree rooted -at `root`. The returned vector does not include `child` itself. - -Throws an error if `child` is the root or is not found in the tree. -""" -function siblings(child::Node, root::Node) - p = parent(child, root) - [c for c in children(p) if c !== child] -end - +include("node.jl") include("xpath.jl") include("lazynode.jl") include("cursor.jl") - - -#-----------------------------------------------------------------------------# _to_node -# Coerce a positional argument to a Node{String}: identity for nodes, wrap non-nodes as -# Text. The middle method rejects non-String parameterizations to keep mixed-storage trees -# from being silently constructed. -_to_node(n::Node{String}) = n -_to_node(n::Node) = throw(ArgumentError("Expected Node{String}, got $(typeof(n))")) -_to_node(x) = Node{String}(Text, nothing, nothing, string(x), nothing) - -#-----------------------------------------------------------------------------# NodeType constructors -# Make each NodeType variant callable as a constructor: `Element("div", ...)`, -# `Text("hi")`, etc. Dispatches on `T` to validate args/kwargs and build the right Node. -# A valid XML 1.0 Name (§2.3): a NameStartChar followed by NameChars, using the same lenient -# per-character rule as the tokenizer (every non-ASCII char is accepted; the exact Unicode ranges -# are not enforced) — so a constructed node's name is one the parser would also accept and -# round-trip, rather than a string like "" or "1bad" that serializes to malformed XML. -_is_xml_name(s::AbstractString) = !isempty(s) && _is_name_start(first(s)) && all(_dtd_is_name_char, s) - -function (T::NodeType)(args...; attrs...) - S = String - if T in (Text, Comment, CData, DTD) - length(args) == 1 || error("$T nodes require exactly one value argument.") - !isempty(attrs) && error("$T nodes do not accept attributes.") - v = string(only(args)) - # A value containing its own close delimiter is un-representable: write would emit XML that - # re-parses split into multiple nodes. (DTD is excluded — its internal subset legitimately - # contains '>'.) - T === Comment && occursin("-->", v) && error("Comment content cannot contain \"-->\": $(repr(v))") - T === CData && occursin("]]>", v) && error("CData content cannot contain \"]]>\": $(repr(v))") - Node{S}(T, nothing, nothing, v, nothing) - elseif T === Element - isempty(args) && error("Element nodes require at least a tag.") - t = string(first(args)) - _is_xml_name(t) || error("invalid XML element name $(repr(t)): must be an XML 1.0 Name") - a = Pair{S,S}[String(k) => String(v) for (k, v) in pairs(attrs)] - c = Node{S}[_to_node(x) for x in args[2:end]] - Node{S}(T, t, a, nothing, c) - elseif T === Declaration - !isempty(args) && error("Declaration nodes only accept keyword attributes.") - a = isempty(attrs) ? nothing : [String(k) => String(v) for (k, v) in pairs(attrs)] - Node{S}(T, nothing, a, nothing, nothing) - elseif T === ProcessingInstruction - length(args) >= 1 || error("ProcessingInstruction nodes require a target.") - length(args) <= 2 || error("ProcessingInstruction nodes accept a target and optional content.") - !isempty(attrs) && error("ProcessingInstruction nodes do not accept attributes.") - t = string(args[1]) - _is_xml_name(t) || error("invalid XML processing-instruction target $(repr(t)): must be an XML 1.0 Name") - v = length(args) == 2 ? string(args[2]) : nothing - v !== nothing && occursin("?>", v) && error("ProcessingInstruction content cannot contain \"?>\": $(repr(v))") - Node{S}(T, t, nothing, v, nothing) - elseif T === Document - !isempty(attrs) && error("Document nodes do not accept attributes.") - c = Node{S}[_to_node(x) for x in args] - Node{S}(T, nothing, nothing, nothing, c) - end -end - -#-----------------------------------------------------------------------------# equality -# Treat `nothing` and an empty collection as equivalent so that an absent attribute / -# children field compares equal to an explicitly empty one. -_eq(::Nothing, ::Nothing) = true -_eq(::Nothing, b) = isempty(b) -_eq(a, ::Nothing) = isempty(a) -_eq(a, b) = a == b - -# Attribute equality is order-insensitive per XML spec. -function _attrs_eq(a, b) - a_empty = isnothing(a) || isempty(a) - b_empty = isnothing(b) || isempty(b) - a_empty && b_empty && return true - (a_empty != b_empty) && return false - length(a) != length(b) && return false - for p in a - p in b || return false - end - true -end - -function Base.:(==)(a::Node, b::Node) - a.nodetype == b.nodetype && - a.tag == b.tag && - _attrs_eq(a.attributes, b.attributes) && - a.value == b.value && - _eq(a.children, b.children) -end - -#-----------------------------------------------------------------------------# indexing -Base.getindex(o::Node, i::Integer) = children(o)[i] -Base.getindex(o::Node, ::Colon) = children(o) -Base.lastindex(o::Node) = lastindex(children(o)) -Base.only(o::Node) = only(children(o)) -Base.length(o::Node) = length(children(o)) - -function Base.get(o::Node, key::AbstractString, default) - isnothing(o.attributes) && return default - for (k, v) in o.attributes - k == key && return v - end - default -end - -const _MISSING_ATTR = gensym(:missing_attr) - -function Base.getindex(o::Node, key::AbstractString) - val = get(o, key, _MISSING_ATTR) - val === _MISSING_ATTR && throw(KeyError(key)) - val -end - -function Base.haskey(o::Node, key::AbstractString) - get(o, key, _MISSING_ATTR) !== _MISSING_ATTR -end - -Base.keys(o::Node) = isnothing(o.attributes) ? () : first.(o.attributes) - -#-----------------------------------------------------------------------------# mutation -function Base.setindex!(o::Node, val, i::Integer) - isnothing(o.children) && error("Node has no children.") - o.children[i] = _to_node(val) -end - -function Base.setindex!(o::Node, val, key::AbstractString) - isnothing(o.attributes) && error("Node has no attributes.") - v = string(val) - for i in eachindex(o.attributes) - if first(o.attributes[i]) == key - o.attributes[i] = key => v - return v - end - end - push!(o.attributes, key => v) - v -end - -function Base.push!(a::Node, b) - isnothing(a.children) && error("Node does not accept children.") - push!(a.children, _to_node(b)) - a -end - -function Base.pushfirst!(a::Node, b) - isnothing(a.children) && error("Node does not accept children.") - pushfirst!(a.children, _to_node(b)) - a -end - -#-----------------------------------------------------------------------------# show (REPL) -function Base.show(io::IO, o::Node) - nt = o.nodetype - print(io, nt) - if nt === Text - print(io, ' ', repr(o.value)) - elseif nt === Element - print(io, " <", o.tag) - if !isnothing(o.attributes) - for (k, v) in o.attributes - print(io, ' ', k, '=', '"', v, '"') - end - end - print(io, '>') - n = length(children(o)) - n > 0 && print(io, n == 1 ? " (1 child)" : " ($n children)") - elseif nt === DTD - print(io, " ') - elseif nt === Declaration - print(io, " ") - elseif nt === ProcessingInstruction - print(io, " ") - elseif nt === Comment - print(io, " ") - elseif nt === CData - print(io, " ") - elseif nt === Document - n = length(children(o)) - n > 0 && print(io, n == 1 ? " (1 child)" : " ($n children)") - end -end - include("write.jl") include("parse.jl") #-----------------------------------------------------------------------------# h (HTML/XML element builder) diff --git a/src/node.jl b/src/node.jl new file mode 100644 index 0000000..f8f4c71 --- /dev/null +++ b/src/node.jl @@ -0,0 +1,469 @@ +#-----------------------------------------------------------------------------# NodeType +""" + NodeType: + - Document # prolog & root Element + - DTD # + - Declaration # + - ProcessingInstruction # + - Comment # + - CData # + - Element # children... + - Text # text + +NodeTypes can be used to construct XML.Nodes: + + Document(children...) + DTD(value) + Declaration(; attributes) + ProcessingInstruction(tag, content) + Comment(text) + CData(text) + Element(tag, children...; attributes) + Text(text) +""" +@enum NodeType::UInt8 CData Comment Declaration Document DTD Element ProcessingInstruction Text + +#-----------------------------------------------------------------------------# Attributes +""" + Attributes{S} <: AbstractDict{S, S} + +An ordered dictionary of XML attributes backed by a `Vector{Pair{S, S}}`. +Returned by [`attributes`](@ref). Preserves insertion order and supports the +full `AbstractDict` interface (`get`, `haskey`, `keys`, `values`, iteration, etc.). +""" +struct Attributes{S} <: AbstractDict{S, S} + entries::Vector{Pair{S, S}} +end + +Base.length(a::Attributes) = length(a.entries) +Base.iterate(a::Attributes, state...) = iterate(a.entries, state...) + +function Base.getindex(a::Attributes, key::AbstractString) + for (k, v) in a.entries + k == key && return v + end + throw(KeyError(key)) +end + +function Base.get(a::Attributes, key::AbstractString, default) + for (k, v) in a.entries + k == key && return v + end + default +end + +function Base.haskey(a::Attributes, key::AbstractString) + any(p -> first(p) == key, a.entries) +end + +Base.keys(a::Attributes) = first.(a.entries) +Base.values(a::Attributes) = last.(a.entries) + +#-----------------------------------------------------------------------------# Node +""" + Node{S} + +In-memory DOM node parameterized on the string storage type `S` (typically `String`, or +`SubString{String}` for zero-copy parsing). Every kind of XML node — `Element`, `Text`, +`Comment`, `CData`, `ProcessingInstruction`, `Declaration`, `DTD`, `Document` — is +represented by a single `Node{S}` whose [`NodeType`](@ref) determines which fields are +populated. + + parse(xml, Node) # parse a string into a Node{String} + parse(xml, Node{SubString{String}}) # zero-copy variant + read(filename, Node) # read & parse a file + +Use the accessor functions ([`nodetype`](@ref), [`tag`](@ref), [`attributes`](@ref), +[`value`](@ref), [`children`](@ref)) rather than the raw fields when navigating a tree. +Integer indexing returns children (`node[1]`); string indexing returns attribute values +(`node["class"]`). +""" +struct Node{S} + nodetype::NodeType + tag::Union{Nothing, S} + attributes::Union{Nothing, Vector{Pair{S, S}}} + value::Union{Nothing, S} + children::Union{Nothing, Vector{Node{S}}} + + function Node{S}(nodetype::NodeType, tag, attributes, value, children) where {S} + if nodetype in (Text, Comment, CData, DTD) + isnothing(tag) && isnothing(attributes) && !isnothing(value) && isnothing(children) || + error("$nodetype nodes only accept a value.") + elseif nodetype === Element + !isnothing(tag) && isnothing(value) || + error("Element nodes require a tag and no value.") + elseif nodetype === Declaration + isnothing(tag) && isnothing(value) && isnothing(children) || + error("Declaration nodes only accept attributes.") + elseif nodetype === ProcessingInstruction + !isnothing(tag) && isnothing(attributes) && isnothing(children) || + error("ProcessingInstruction nodes require a tag and only accept a value.") + elseif nodetype === Document + isnothing(tag) && isnothing(attributes) && isnothing(value) || + error("Document nodes only accept children.") + end + new{S}(nodetype, tag, attributes, value, children) + end +end + +#-----------------------------------------------------------------------------# interface +""" + nodetype(node) -> NodeType + +Return the [`NodeType`](@ref) of `node` (`Element`, `Text`, `Comment`, `CData`, +`ProcessingInstruction`, `Declaration`, `DTD`, or `Document`). +""" +nodetype(o::Node) = o.nodetype + +""" + tag(node) -> Union{String, SubString{String}, Nothing} + +Return the tag name of `node`. Defined for `Element` (element name) and +`ProcessingInstruction` (target name); returns `nothing` for other node types. +""" +tag(o::Node) = o.tag + +""" + attributes(node::Node) -> Union{Nothing, Attributes{String}} + +Return the attributes of an `Element` or `Declaration` node as an [`Attributes`](@ref) dict, +or `nothing` if the node has no attributes. + +!!! note "Changed in v0.4" + In previous versions, `attributes` returned an `OrderedDict` from OrderedCollections.jl. + It now returns an [`Attributes`](@ref), an ordered `AbstractDict` backed by a + `Vector{Pair}`. +""" +attributes(o::Node) = isnothing(o.attributes) ? nothing : Attributes(o.attributes) + +""" + value(node) -> Union{String, SubString{String}, Nothing} + +Return the textual content of `node`. Defined for `Text`, `Comment`, `CData`, `DTD`, and +`ProcessingInstruction`; returns `nothing` for `Element`, `Declaration`, and `Document` +(use [`children`](@ref) for those). +""" +value(o::Node) = o.value + +""" + children(node) -> Vector{Node} or () + +Return the child nodes of `node` in document order. Returns an empty tuple `()` for nodes +that cannot have children (e.g. `Text`, `Comment`, `CData`). +""" +children(o::Node) = something(o.children, ()) + +""" + eachelement(node) + +Lazy iterator over the child *elements* of `node`, skipping every other node type +(`Text`, `Comment`, `CData`, `ProcessingInstruction`, …). Since v0.4 preserves +inter-element whitespace, `children` on pretty-printed documents interleaves +whitespace `Text` nodes with elements — `eachelement` is the idiomatic way to +iterate the elements only. Works with both `Node` and `LazyNode`. + + for el in eachelement(node) + # el isa Element node + end + +See also [`elements`](@ref). +""" +eachelement(node) = Iterators.filter(n -> nodetype(n) === Element, children(node)) + +""" + elements(node) -> Vector + +The child *elements* of `node` in document order — the collected counterpart of +[`eachelement`](@ref). +""" +elements(node) = collect(eachelement(node)) + +""" + is_simple(node) -> Bool + +Return `true` if `node` is an `Element` with no attributes and exactly one `Text` or +`CData` child — i.e. the `content` pattern with no nested markup. See also +[`simple_value`](@ref). +""" +is_simple(o::Node) = o.nodetype === Element && + (isnothing(o.attributes) || isempty(o.attributes)) && + !isnothing(o.children) && length(o.children) == 1 && + o.children[1].nodetype in (Text, CData) + +""" + simple_value(node) -> String + +Return the textual content of a simple element (see [`is_simple`](@ref)). Errors if +`node` is not simple. +""" +simple_value(o::Node) = is_simple(o) ? o.children[1].value : + error("`simple_value` is only defined for simple nodes.") + +""" + is_simple_value(node) -> Union{Nothing, String, SubString{String}} + +Combined predicate-and-accessor: return the simple text/CData value of `node` if it is a +simple element (see [`is_simple`](@ref)), or `nothing` otherwise. Avoids the redundant +tokenization that `is_simple(n) ? simple_value(n) : ...` does on `LazyNode`. +""" +is_simple_value(o::Node) = is_simple(o) ? o.children[1].value : nothing + +#-----------------------------------------------------------------------------# tree navigation + +""" + parent(child::Node, root::Node) -> Node + +Return the parent of `child` within the tree rooted at `root`. + +Since `Node` does not store parent pointers, this performs a tree search from `root`. +Throws an error if `child` is not found or if `child === root`. + +!!! warning "Value identity" + `Node` is an immutable value type, so the search matches by structural equality (`===`). In a + tree containing value-identical sibling nodes (e.g. two empty `` elements), this may + return the parent of the *first* match rather than the specific node passed. The same applies to + [`depth`](@ref), [`siblings`](@ref), and the XPath `..` axis. A path-based redesign is planned. +""" +function Base.parent(child::Node, root::Node) + child === root && error("Root node has no parent.") + result = _find_parent(child, root) + isnothing(result) && error("Node not found in tree.") + result +end + +# Depth-first search for `child` within `current`; returns the containing node or nothing. +function _find_parent(child::Node, current::Node) + for c in children(current) + c === child && return current + result = _find_parent(child, c) + isnothing(result) || return result + end + nothing +end + +""" + depth(child::Node, root::Node) -> Int + +Return the depth of `child` within the tree rooted at `root` (root has depth 0). + +Since `Node` does not store parent pointers, this performs a tree search from `root`. +Throws an error if `child` is not found in the tree. +""" +function depth(child::Node, root::Node) + child === root && return 0 + result = _find_depth(child, root, 0) + isnothing(result) && error("Node not found in tree.") + result +end + +# Depth-first search returning the depth of `child` relative to `current` (where children +# of `current` are at depth `d + 1`), or nothing if not found. +function _find_depth(child::Node, current::Node, d::Int) + for c in children(current) + c === child && return d + 1 + result = _find_depth(child, c, d + 1) + isnothing(result) || return result + end + nothing +end + +""" + siblings(child::Node, root::Node) -> Vector{Node} + +Return the siblings of `child` (other children of the same parent) within the tree rooted +at `root`. The returned vector does not include `child` itself. + +Throws an error if `child` is the root or is not found in the tree. +""" +function siblings(child::Node, root::Node) + p = parent(child, root) + [c for c in children(p) if c !== child] +end + + + +#-----------------------------------------------------------------------------# _to_node +# Coerce a positional argument to a Node{String}: identity for nodes, wrap non-nodes as +# Text. The middle method rejects non-String parameterizations to keep mixed-storage trees +# from being silently constructed. +_to_node(n::Node{String}) = n +_to_node(n::Node) = throw(ArgumentError("Expected Node{String}, got $(typeof(n))")) +_to_node(x) = Node{String}(Text, nothing, nothing, string(x), nothing) + +#-----------------------------------------------------------------------------# NodeType constructors +# Make each NodeType variant callable as a constructor: `Element("div", ...)`, +# `Text("hi")`, etc. Dispatches on `T` to validate args/kwargs and build the right Node. +# A valid XML 1.0 Name (§2.3): a NameStartChar followed by NameChars, using the same lenient +# per-character rule as the tokenizer (every non-ASCII char is accepted; the exact Unicode ranges +# are not enforced) — so a constructed node's name is one the parser would also accept and +# round-trip, rather than a string like "" or "1bad" that serializes to malformed XML. +_is_xml_name(s::AbstractString) = !isempty(s) && _is_name_start(first(s)) && all(_dtd_is_name_char, s) + +function (T::NodeType)(args...; attrs...) + S = String + if T in (Text, Comment, CData, DTD) + length(args) == 1 || error("$T nodes require exactly one value argument.") + !isempty(attrs) && error("$T nodes do not accept attributes.") + v = string(only(args)) + # A value containing its own close delimiter is un-representable: write would emit XML that + # re-parses split into multiple nodes. (DTD is excluded — its internal subset legitimately + # contains '>'.) + T === Comment && occursin("-->", v) && error("Comment content cannot contain \"-->\": $(repr(v))") + T === CData && occursin("]]>", v) && error("CData content cannot contain \"]]>\": $(repr(v))") + Node{S}(T, nothing, nothing, v, nothing) + elseif T === Element + isempty(args) && error("Element nodes require at least a tag.") + t = string(first(args)) + _is_xml_name(t) || error("invalid XML element name $(repr(t)): must be an XML 1.0 Name") + a = Pair{S,S}[String(k) => String(v) for (k, v) in pairs(attrs)] + c = Node{S}[_to_node(x) for x in args[2:end]] + Node{S}(T, t, a, nothing, c) + elseif T === Declaration + !isempty(args) && error("Declaration nodes only accept keyword attributes.") + a = isempty(attrs) ? nothing : [String(k) => String(v) for (k, v) in pairs(attrs)] + Node{S}(T, nothing, a, nothing, nothing) + elseif T === ProcessingInstruction + length(args) >= 1 || error("ProcessingInstruction nodes require a target.") + length(args) <= 2 || error("ProcessingInstruction nodes accept a target and optional content.") + !isempty(attrs) && error("ProcessingInstruction nodes do not accept attributes.") + t = string(args[1]) + _is_xml_name(t) || error("invalid XML processing-instruction target $(repr(t)): must be an XML 1.0 Name") + v = length(args) == 2 ? string(args[2]) : nothing + v !== nothing && occursin("?>", v) && error("ProcessingInstruction content cannot contain \"?>\": $(repr(v))") + Node{S}(T, t, nothing, v, nothing) + elseif T === Document + !isempty(attrs) && error("Document nodes do not accept attributes.") + c = Node{S}[_to_node(x) for x in args] + Node{S}(T, nothing, nothing, nothing, c) + end +end + +#-----------------------------------------------------------------------------# equality +# Treat `nothing` and an empty collection as equivalent so that an absent attribute / +# children field compares equal to an explicitly empty one. +_eq(::Nothing, ::Nothing) = true +_eq(::Nothing, b) = isempty(b) +_eq(a, ::Nothing) = isempty(a) +_eq(a, b) = a == b + +# Attribute equality is order-insensitive per XML spec. +function _attrs_eq(a, b) + a_empty = isnothing(a) || isempty(a) + b_empty = isnothing(b) || isempty(b) + a_empty && b_empty && return true + (a_empty != b_empty) && return false + length(a) != length(b) && return false + for p in a + p in b || return false + end + true +end + +function Base.:(==)(a::Node, b::Node) + a.nodetype == b.nodetype && + a.tag == b.tag && + _attrs_eq(a.attributes, b.attributes) && + a.value == b.value && + _eq(a.children, b.children) +end + +#-----------------------------------------------------------------------------# indexing +Base.getindex(o::Node, i::Integer) = children(o)[i] +Base.getindex(o::Node, ::Colon) = children(o) +Base.lastindex(o::Node) = lastindex(children(o)) +Base.only(o::Node) = only(children(o)) +Base.length(o::Node) = length(children(o)) + +function Base.get(o::Node, key::AbstractString, default) + isnothing(o.attributes) && return default + for (k, v) in o.attributes + k == key && return v + end + default +end + +const _MISSING_ATTR = gensym(:missing_attr) + +function Base.getindex(o::Node, key::AbstractString) + val = get(o, key, _MISSING_ATTR) + val === _MISSING_ATTR && throw(KeyError(key)) + val +end + +function Base.haskey(o::Node, key::AbstractString) + get(o, key, _MISSING_ATTR) !== _MISSING_ATTR +end + +Base.keys(o::Node) = isnothing(o.attributes) ? () : first.(o.attributes) + +#-----------------------------------------------------------------------------# mutation +function Base.setindex!(o::Node, val, i::Integer) + isnothing(o.children) && error("Node has no children.") + o.children[i] = _to_node(val) +end + +function Base.setindex!(o::Node, val, key::AbstractString) + isnothing(o.attributes) && error("Node has no attributes.") + v = string(val) + for i in eachindex(o.attributes) + if first(o.attributes[i]) == key + o.attributes[i] = key => v + return v + end + end + push!(o.attributes, key => v) + v +end + +function Base.push!(a::Node, b) + isnothing(a.children) && error("Node does not accept children.") + push!(a.children, _to_node(b)) + a +end + +function Base.pushfirst!(a::Node, b) + isnothing(a.children) && error("Node does not accept children.") + pushfirst!(a.children, _to_node(b)) + a +end + +#-----------------------------------------------------------------------------# show (REPL) +function Base.show(io::IO, o::Node) + nt = o.nodetype + print(io, nt) + if nt === Text + print(io, ' ', repr(o.value)) + elseif nt === Element + print(io, " <", o.tag) + if !isnothing(o.attributes) + for (k, v) in o.attributes + print(io, ' ', k, '=', '"', v, '"') + end + end + print(io, '>') + n = length(children(o)) + n > 0 && print(io, n == 1 ? " (1 child)" : " ($n children)") + elseif nt === DTD + print(io, " ') + elseif nt === Declaration + print(io, " ") + elseif nt === ProcessingInstruction + print(io, " ") + elseif nt === Comment + print(io, " ") + elseif nt === CData + print(io, " ") + elseif nt === Document + n = length(children(o)) + n > 0 && print(io, n == 1 ? " (1 child)" : " ($n children)") + end +end + From 259f7956e734d39a448fd3e12e3576084edb77c3 Mon Sep 17 00:00:00 2001 From: Mathieu BISKUPSKI Date: Sun, 5 Jul 2026 17:59:04 +0200 Subject: [PATCH 5/6] Extract escape/unescape into src/escape.jl (pure move) Assisted-by: Claude (Anthropic) --- src/XML.jl | 63 +-------------------------------------------------- src/escape.jl | 62 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 62 deletions(-) create mode 100644 src/escape.jl diff --git a/src/XML.jl b/src/XML.jl index b9322e2..b423f4a 100644 --- a/src/XML.jl +++ b/src/XML.jl @@ -17,68 +17,7 @@ using .XMLTokenizer: XMLTokenizer, tokenize, tag_name, attr_value, pi_target, raw, TokenKinds, Token, Tokenizer, TokenizerState -#-----------------------------------------------------------------------------# escape/unescape -const ESCAPE_CHARS = ('&' => "&", '<' => "<", '>' => ">", '\'' => "'", '"' => """) - -""" - escape(x::AbstractString) -> String - -Escape the five XML predefined entities: `&` `<` `>` `'` `"`. - -!!! note "Changed in v0.4" - `escape` is no longer idempotent. In previous versions, already-escaped sequences like - `&` were left untouched. Now every `&` is escaped, so `escape("&")` produces - `"&amp;"`. Call `escape` only on raw, unescaped text. -""" -escape(x::AbstractString) = replace(x, ESCAPE_CHARS...) - -# Replace a numeric character reference with its Unicode character. -# Numeric character references encode characters by code point: decimal (é → é) or hex (é → é). -function _unescape_charref(ref::AbstractString) - is_hex = length(ref) > 3 && ref[3] in ('x', 'X') - digits = SubString(ref, is_hex ? 4 : 3, length(ref) - 1) - cp = tryparse(UInt32, digits; base = is_hex ? 16 : 10) - !isnothing(cp) && isvalid(Char, cp) ? string(Char(cp)) : ref -end - -# One regex matching any supported reference: the five predefined entities plus a decimal -# or hex numeric character reference. `unescape` applies it in a SINGLE `replace` pass, so a -# reference that resolves to '&' (e.g. `&`) is never re-scanned as the start of a new -# entity — `replace` substitutes left-to-right over the original string and never re-reads -# what it emitted. -const _ENTITY_RE = r"&(?:amp|lt|gt|apos|quot|#[0-9]+|#[xX][0-9a-fA-F]+);" - -function _unescape_entity(m::AbstractString) - m == "&" && return "&" - m == "<" && return "<" - m == ">" && return ">" - m == "'" && return "'" - m == """ && return "\"" - return _unescape_charref(m) # numeric ref (the only remaining alternative); verbatim if out of range -end - -""" - unescape(x::AbstractString) -> String - unescape(x::SubString{String}) -> Union{SubString{String}, String} - -Unescape XML entities in `x`: the five predefined entities (`&` `<` `>` `'` -`"`) and numeric character references (`{`, `«`). Each reference is processed -exactly once (no double-unescaping). - -When `x` is a `SubString{String}` containing no `&`, the input is returned unchanged with -no allocation — the common case for typical XML attribute and text content. -""" -function unescape(x::AbstractString) - s = string(x) - occursin('&', s) || return s - replace(s, _ENTITY_RE => _unescape_entity) -end - -function unescape(x::SubString{String}) - occursin('&', x) || return x - replace(String(x), _ENTITY_RE => _unescape_entity) -end - +include("escape.jl") include("node.jl") include("xpath.jl") include("lazynode.jl") diff --git a/src/escape.jl b/src/escape.jl new file mode 100644 index 0000000..d584a83 --- /dev/null +++ b/src/escape.jl @@ -0,0 +1,62 @@ +#-----------------------------------------------------------------------------# escape/unescape +const ESCAPE_CHARS = ('&' => "&", '<' => "<", '>' => ">", '\'' => "'", '"' => """) + +""" + escape(x::AbstractString) -> String + +Escape the five XML predefined entities: `&` `<` `>` `'` `"`. + +!!! note "Changed in v0.4" + `escape` is no longer idempotent. In previous versions, already-escaped sequences like + `&` were left untouched. Now every `&` is escaped, so `escape("&")` produces + `"&amp;"`. Call `escape` only on raw, unescaped text. +""" +escape(x::AbstractString) = replace(x, ESCAPE_CHARS...) + +# Replace a numeric character reference with its Unicode character. +# Numeric character references encode characters by code point: decimal (é → é) or hex (é → é). +function _unescape_charref(ref::AbstractString) + is_hex = length(ref) > 3 && ref[3] in ('x', 'X') + digits = SubString(ref, is_hex ? 4 : 3, length(ref) - 1) + cp = tryparse(UInt32, digits; base = is_hex ? 16 : 10) + !isnothing(cp) && isvalid(Char, cp) ? string(Char(cp)) : ref +end + +# One regex matching any supported reference: the five predefined entities plus a decimal +# or hex numeric character reference. `unescape` applies it in a SINGLE `replace` pass, so a +# reference that resolves to '&' (e.g. `&`) is never re-scanned as the start of a new +# entity — `replace` substitutes left-to-right over the original string and never re-reads +# what it emitted. +const _ENTITY_RE = r"&(?:amp|lt|gt|apos|quot|#[0-9]+|#[xX][0-9a-fA-F]+);" + +function _unescape_entity(m::AbstractString) + m == "&" && return "&" + m == "<" && return "<" + m == ">" && return ">" + m == "'" && return "'" + m == """ && return "\"" + return _unescape_charref(m) # numeric ref (the only remaining alternative); verbatim if out of range +end + +""" + unescape(x::AbstractString) -> String + unescape(x::SubString{String}) -> Union{SubString{String}, String} + +Unescape XML entities in `x`: the five predefined entities (`&` `<` `>` `'` +`"`) and numeric character references (`{`, `«`). Each reference is processed +exactly once (no double-unescaping). + +When `x` is a `SubString{String}` containing no `&`, the input is returned unchanged with +no allocation — the common case for typical XML attribute and text content. +""" +function unescape(x::AbstractString) + s = string(x) + occursin('&', s) || return s + replace(s, _ENTITY_RE => _unescape_entity) +end + +function unescape(x::SubString{String}) + occursin('&', x) || return x + replace(String(x), _ENTITY_RE => _unescape_entity) +end + From c614e134f0044b69a86d794a08ff758012cb5313 Mon Sep 17 00:00:00 2001 From: Mathieu BISKUPSKI Date: Sun, 5 Jul 2026 18:01:03 +0200 Subject: [PATCH 6/6] Turn src/XML.jl into a commented include manifest; changelog note The include order is documented as the dependency contract, with a slot reserved for the future flatnode.jl reader (v0.5). dtd.jl joins the manifest block (it had stayed at its original position after extraction). Assisted-by: Claude (Anthropic) --- CHANGELOG.md | 9 +++++++++ src/XML.jl | 18 ++++++++++-------- 2 files changed, 19 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c156e5..cd57c99 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,15 @@ All notable changes to XML.jl will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Internal + +- Source layout: the `src/XML.jl` monolith (1409 lines) is split into dedicated files — + `escape.jl`, `node.jl`, `write.jl`, `parse.jl`, `dtd.jl` — alongside the existing + `XMLTokenizer.jl`/`lazynode.jl`/`cursor.jl`/`xpath.jl`. Pure moves, no behavior change; + `src/XML.jl` is now the commented include manifest. + ## [0.4.1] - 2026-07-05 ### Added diff --git a/src/XML.jl b/src/XML.jl index b423f4a..389b974 100644 --- a/src/XML.jl +++ b/src/XML.jl @@ -17,13 +17,16 @@ using .XMLTokenizer: XMLTokenizer, tokenize, tag_name, attr_value, pi_target, raw, TokenKinds, Token, Tokenizer, TokenizerState -include("escape.jl") -include("node.jl") -include("xpath.jl") -include("lazynode.jl") -include("cursor.jl") -include("write.jl") -include("parse.jl") +# Include order is the dependency contract: types before readers, readers before entry points. +include("escape.jl") # ESCAPE_CHARS + escape/unescape — leaf, everything below may call it +include("node.jl") # NodeType, Attributes, Node + accessors/navigation/equality/mutation/show +include("xpath.jl") # xpath over Node trees (needs Node + accessors) +include("lazynode.jl") # LazyNode reader (needs NodeType/Attributes; extends the generic accessors) +include("cursor.jl") # Cursor pull reader (needs NodeType/Attributes) +# (flatnode.jl will slot here — read-only columnar reader, planned for v0.5) +include("write.jl") # XML writer: _write_xml/_write_escaped + XML.write entry points +include("parse.jl") # BOM normalization, Base.read entry points, the VPA parser +include("dtd.jl") # DTD/DOCTYPE parsing (independent: uses only the tokenizer) #-----------------------------------------------------------------------------# h (HTML/XML element builder) """ h(tag, children...; attrs...) @@ -50,7 +53,6 @@ function (o::Node)(args...; attrs...) h(o.tag, old_children..., args...; old_attrs..., attrs...) end -include("dtd.jl") #-----------------------------------------------------------------------------# deprecations Base.@deprecate_binding simplevalue simple_value false