// Copyright 2025 International Digital Economy Academy
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
///|
/// Create a new reader from a string
pub fn Reader::from_string(input : String) -> Reader {
let chars = input.to_array()
// Skip BOM (Byte Order Mark, U+FEFF) if present at start
let start_pos = if chars.length() > 0 && chars[0] == '\u{FEFF}' {
1
} else {
0
}
{
input: chars,
pos: start_pos,
offset: start_pos,
line: 1,
column: 1,
entities: Map([]),
entity_expansion_remaining: MAX_ENTITY_EXPANSION_CHARS,
attr_types: Map([]),
tag_stack: [],
seen_root: false,
root_closed: false,
seen_content: false,
just_saw_decl: false,
pending_events: [],
internal_subset_events: [],
just_saw_doctype: false,
had_bom: start_pos > 0,
}
}
///|
/// Create a new reader from a file path
pub fn Reader::from_file(path : String) -> Reader raise @fs.IOError {
Reader::from_string(@fs.read_file_to_string(path))
}
///|
/// Check if the reader has reached the end
pub fn Reader::is_eof(self : Reader) -> Bool {
self.pos >= self.input.length()
}
///|
/// Get current line number (1-indexed)
pub fn Reader::line(self : Reader) -> Int {
self.line
}
///|
/// Get current column number (1-indexed)
pub fn Reader::column(self : Reader) -> Int {
self.column
}
///|
fn Reader::source_position(self : Reader) -> SourcePosition {
{ offset: self.offset, line: self.line, column: self.column, }
}
///|
fn source_span(start : SourcePosition, end : SourcePosition) -> SourceSpan {
{ start, end, }
}
///|
fn Reader::expand_entity_value(
self : Reader,
value : String,
) -> String raise XmlErrorKind {
let seen : Map[String, Bool] = Map([])
let (expanded, remaining) = expand_entity_value_with_remaining(
value,
self.entities,
seen,
self.entity_expansion_remaining,
)
self.entity_expansion_remaining = remaining
expanded
}
///|
fn Reader::push_pending_event(self : Reader, event : Event) -> Unit {
self.pending_events.push(event)
}
///|
fn Reader::pop_pending_event(self : Reader) -> Event {
self.pending_events.remove(0)
}
///|
/// Read all events until EOF (inclusive) as an Array.
/// Includes the final Eof event in the result.
/// Raises XmlError if parsing fails.
pub fn Reader::read_events_until_eof(
self : Reader,
) -> Array[Event] raise XmlError {
let events : Array[Event] = []
for ;; {
let event = self.read_event()
events.push(event)
if event.kind is Eof {
return events
}
}
}
///|
/// Peek at current character without advancing
fn Reader::peek(self : Reader) -> Char? {
if self.pos < self.input.length() {
Some(self.input[self.pos])
} else {
None
}
}
///|
/// Peek at character at offset from current position
fn Reader::peek_at(self : Reader, offset : Int) -> Char? {
let idx = self.pos + offset
if idx < self.input.length() {
Some(self.input[idx])
} else {
None
}
}
///|
/// Advance by one character
fn Reader::advance(self : Reader) -> Char? {
if self.pos < self.input.length() {
let c = self.input[self.pos]
self.pos += 1
self.offset += if c.to_int() > 0xFFFF { 2 } else { 1 }
if c == '\r' {
self.line += 1
self.column = 1
} else if c == '\n' {
if self.pos < 2 || self.input[self.pos - 2] != '\r' {
self.line += 1
}
self.column = 1
} else {
self.column += 1
}
Some(c)
} else {
None
}
}
///|
/// Skip whitespace characters
fn Reader::skip_whitespace(self : Reader) -> Unit {
while self.peek() is Some(c) && is_whitespace(c) {
let _ = self.advance()
}
}
///|
/// Skip whitespace characters and return count of characters skipped
fn Reader::skip_whitespace_count(self : Reader) -> Int {
let mut count = 0
while self.peek() is Some(c) && is_whitespace(c) {
let _ = self.advance()
count += 1
}
count
}
///|
/// Read characters while predicate is true
fn Reader::read_while(self : Reader, pred : (Char) -> Bool) -> String {
let buf = StringBuilder()
while self.peek() is Some(c) && pred(c) {
buf.write_char(c)
let _ = self.advance()
}
buf.to_string()
}
///|
/// Read until a specific string is found (exclusive)
fn Reader::read_until(self : Reader, end : String) -> String raise XmlErrorKind {
let buf = StringBuilder()
let end_chars = end.to_array()
let end_len = end_chars.length()
while !self.is_eof() {
// Check if we're at the end marker
let mut found = true
for i = 0; i < end_len; i = i + 1 {
match self.peek_at(i) {
Some(c) if c == end_chars[i] => continue
_ => {
found = false
break
}
}
}
if found {
return buf.to_string()
}
match self.advance() {
Some(c) => {
// Validate character is allowed in XML content
let code = c.to_int()
if !is_valid_xml_content_char(code) {
raise InvalidSyntax("invalid character in content")
}
buf.write_char(c)
}
None => raise UnexpectedEof
}
}
raise UnexpectedEof
}
///|
/// Skip a specific string
fn Reader::skip_string(self : Reader, s : String) -> Bool {
let chars = s.to_array()
for i = 0; i < chars.length(); i = i + 1 {
match self.peek_at(i) {
Some(c) if c == chars[i] => continue
_ => return false
}
}
// All matched, advance
for i = 0; i < chars.length(); i = i + 1 {
let _ = self.advance()
}
true
}
///|
/// Check if next chars match a string (without consuming)
fn Reader::check_string(self : Reader, s : String) -> Bool {
let chars = s.to_array()
for i = 0; i < chars.length(); i = i + 1 {
match self.peek_at(i) {
Some(c) if c == chars[i] => continue
_ => return false
}
}
true
}
///|
/// Reject non-markup content after the root element with a span covering the
/// complete offending token. Whitespace in the epilog is legal and is not
/// included in the error span.
fn Reader::reject_trailing_content(self : Reader) -> Unit raise XmlError {
self.skip_whitespace()
let start = self.source_position()
match self.peek() {
Some('&') => {
let _ = self.advance()
while self.peek() is Some(c) &&
c != ';' &&
c != '<' &&
c != '&' &&
!is_whitespace(c) {
let _ = self.advance()
}
if self.peek() is Some(';') {
let _ = self.advance()
}
raise At(
error=InvalidSyntax("character reference after root element"),
span=source_span(start, self.source_position()),
)
}
Some(c) if c != '<' => {
while self.peek() is Some(c) && c != '<' && c != '&' {
let _ = self.advance()
}
raise At(
error=InvalidSyntax("content after root element"),
span=source_span(start, self.source_position()),
)
}
_ => ()
}
}
///|
/// Read the next XML event
pub fn Reader::read_event(self : Reader) -> Event raise XmlError {
if self.is_eof() &&
self.pending_events.length() == 0 &&
self.tag_stack.length() > 0 {
let unclosed = self.tag_stack[self.tag_stack.length() - 1]
raise At(
error=InvalidSyntax("unclosed element: <" + unclosed.0 + ">"),
span=unclosed.1,
)
}
if self.root_closed && self.pending_events.length() == 0 {
self.reject_trailing_content()
}
let start = self.source_position()
self.read_event_internal() catch {
error => raise At(error~, span=source_span(start, self.source_position()))
}
}
///|
fn Reader::read_event_internal(self : Reader) -> Event raise XmlErrorKind {
// Return pending events first (from split text)
if self.pending_events.length() > 0 {
let event = self.pop_pending_event()
// Skip empty Text events
match event.kind {
Text(s) if s == "" => return self.read_event_internal()
_ => {
self.seen_content = true
return event
}
}
}
if self.is_eof() {
// Check for unclosed tags
if self.tag_stack.length() > 0 {
let unclosed = self.tag_stack[self.tag_stack.length() - 1]
raise InvalidSyntax("unclosed element: <" + unclosed.0 + ">")
}
// Empty document is not valid
if !self.seen_root {
raise InvalidSyntax("empty document - no root element")
}
let position = self.source_position()
return { kind: Eof, span: source_span(position, position), }
}
let event_start = self.source_position()
match self.peek() {
Some('<') => {
let parsed = self.read_tag(event_start)
if self.had_bom && parsed is Decl(_) {
self.just_saw_decl = true
return self.read_event_internal()
}
// Track root element state
match parsed {
Start(_) => {
self.seen_content = true
if self.root_closed {
raise InvalidSyntax("content after root element")
} else if self.tag_stack.length() == 1 {
// Just pushed the root element
self.seen_root = true
}
}
Empty(_) => {
self.seen_content = true
// Empty element: if no root seen yet, this is the root and it's now closed
if self.root_closed {
raise InvalidSyntax("content after root element")
} else if !self.seen_root {
self.seen_root = true
self.root_closed = true
}
}
End(_) => {
self.seen_content = true
if self.tag_stack.length() == 0 && self.seen_root {
// Root element just closed
self.root_closed = true
}
}
CData(_) => {
self.seen_content = true
// CDATA only allowed inside elements
if self.root_closed {
raise InvalidSyntax("CDATA section after root element")
}
if !self.seen_root || self.tag_stack.length() == 0 {
raise InvalidSyntax("CDATA section not allowed in prolog")
}
}
Text(_) => {
self.seen_content = true
self.just_saw_decl = false
}
Comment(_) => {
self.seen_content = true
self.just_saw_decl = false
}
DocType(_) => {
self.seen_content = true
self.just_saw_decl = false
self.just_saw_doctype = true // Track for internal subset event ordering
}
PI(_) => {
self.seen_content = true
self.just_saw_decl = false
}
Decl(_) => self.just_saw_decl = true // Skip next whitespace
Eof => ()
}
{ kind: parsed, span: source_span(event_start, self.source_position()), }
}
Some(_) =>
// Check for non-whitespace content in prolog
if !self.seen_root {
// Before root element: only literal whitespace is allowed
if self.just_saw_decl {
// Skip whitespace after XML declaration (lxml behavior)
while self.peek() is Some(c) {
if c == ' ' || c == '\t' || c == '\n' || c == '\r' {
let _ = self.advance()
} else if c == '<' {
break
} else if c == '&' {
raise InvalidSyntax("character reference before root element")
} else {
raise InvalidSyntax("content before root element")
}
}
self.just_saw_decl = false
return self.read_event_internal()
}
// Read prolog whitespace
let text_event = self.read_whitespace_only()
// Mark that we've seen content
self.seen_content = true
// Move internal subset events to pending queue
if self.just_saw_doctype {
self.just_saw_doctype = false
while self.internal_subset_events.length() > 0 {
self.push_pending_event(self.internal_subset_events.remove(0))
}
}
{
kind: text_event,
span: source_span(event_start, self.source_position()),
}
} else {
let text_event = self.read_text()
// Skip empty Text events (lxml doesn't emit them)
match text_event.kind {
Text(s) if s == "" => return self.read_event_internal()
_ => {
// Mark that we've seen content (affects XML declaration placement check)
self.seen_content = true
text_event
}
}
}
None => {
// Check for unclosed tags at EOF
if self.tag_stack.length() > 0 {
let unclosed = self.tag_stack[self.tag_stack.length() - 1]
raise InvalidSyntax("unclosed element: <" + unclosed.0 + ">")
}
// Empty document is not valid
if !self.seen_root {
raise InvalidSyntax("empty document - no root element")
}
let position = self.source_position()
{ kind: Eof, span: source_span(position, position), }
}
}
}
///|
/// Read a tag (element, comment, CDATA, PI, DOCTYPE, etc.)
fn Reader::read_tag(
self : Reader,
start : SourcePosition,
) -> EventKind raise XmlErrorKind {
let _ = self.advance() // consume '<'
match self.peek() {
Some('/') => self.read_end_tag()
Some('!') => self.read_special()
Some('?') => self.read_pi()
Some(_) => self.read_start_tag(start)
None => raise UnexpectedEof
}
}
///|
/// Read an end tag:
fn Reader::read_end_tag(self : Reader) -> EventKind raise XmlErrorKind {
let _ = self.advance() // consume '/'
let name = self.read_name()
// Empty end tag name is invalid
if name == "" {
raise InvalidSyntax("empty end tag name")
}
// Validate tag matching
if self.tag_stack.length() == 0 {
raise InvalidSyntax("unexpected end tag, no matching start tag")
}
let expected = self.tag_stack.pop().unwrap().0
if expected != name {
raise InvalidSyntax(
"end tag mismatch: expected " + expected + ">, found " + name + ">",
)
}
self.skip_whitespace()
match self.peek() {
Some('>') => {
let _ = self.advance()
End(name)
}
_ => raise InvalidSyntax("expected '>' after end tag name")
}
}
///|
/// Read a start tag or empty element: or
fn Reader::read_start_tag(
self : Reader,
start : SourcePosition,
) -> EventKind raise XmlErrorKind {
let name = self.read_name()
let attributes = self.read_attributes(name)
self.skip_whitespace()
match self.peek() {
Some('/') => {
let _ = self.advance() // consume '/'
// No whitespace allowed between / and > per XML spec
match self.peek() {
Some('>') => {
let _ = self.advance()
Empty({ name, attributes, })
}
_ => raise InvalidSyntax("expected '>' after '/'")
}
}
Some('>') => {
let _ = self.advance()
self.tag_stack.push((name, source_span(start, self.source_position())))
Start({ name, attributes, })
}
_ => raise InvalidSyntax("expected '>' or '/>' at end of start tag")
}
}
///|
/// Read special tags: , ,
fn Reader::read_special(self : Reader) -> EventKind raise XmlErrorKind {
let _ = self.advance() // consume '!'
if self.check_string("--") {
// Comment
let _ = self.skip_string("--")
let content = self.read_comment_content()
Comment(content)
} else if self.check_string("[CDATA[") {
// CDATA - validate position before parsing
if self.root_closed {
raise InvalidSyntax("CDATA section after root element")
}
if !self.seen_root || self.tag_stack.length() == 0 {
raise InvalidSyntax("CDATA section not allowed in prolog")
}
let _ = self.skip_string("[CDATA[")
let content = self.read_until("]]>")
let _ = self.skip_string("]]>")
CData(content)
} else if self.check_string("DOCTYPE") {
// DOCTYPE
let _ = self.skip_string("DOCTYPE")
self.skip_whitespace()
let name = self.read_while(fn(c) {
c != '>' && c != '[' && !is_whitespace(c)
})
// Parse external ID if present (SYSTEM or PUBLIC)
self.skip_whitespace()
if self.check_string("SYSTEM") {
let _ = self.skip_string("SYSTEM")
self.skip_whitespace()
// Skip system literal
self.skip_quoted_string()
} else if self.check_string("PUBLIC") {
let _ = self.skip_string("PUBLIC")
self.skip_whitespace()
// Validate and skip public ID literal
self.read_and_validate_public_id()
self.skip_whitespace()
// Skip system literal
self.skip_quoted_string()
}
// Skip everything else until we find '[' or '>'
while self.peek() is Some(c) && c != '[' && c != '>' {
if c == '-' && self.check_string("--") {
// SGML-style comments are not allowed in XML
raise InvalidSyntax(
"SGML-style comments (--) not allowed in XML declarations",
)
} else {
let _ = self.advance()
}
}
// Check for internal subset
if self.peek() is Some('[') {
// Parse internal subset [...] and extract entities
let _ = self.advance() // consume '['
self.parse_internal_subset()
}
// Skip rest until >, but reject PE references (not allowed outside internal subset)
while self.peek() is Some(c) && c != '>' {
if c == '%' {
raise InvalidSyntax(
"parameter entity reference not allowed outside internal subset",
)
}
let _ = self.advance()
}
let _ = self.advance() // consume '>'
DocType(name)
} else {
raise InvalidSyntax("invalid special tag after '"
fn Reader::read_comment_content(self : Reader) -> String raise XmlErrorKind {
let buf = StringBuilder()
while !self.is_eof() {
// Check for "--"
if self.check_string("--") {
let _ = self.skip_string("--")
// Must be followed by ">" to be valid comment end
match self.peek() {
Some('>') => {
let _ = self.advance()
return buf.to_string()
}
_ => raise InvalidSyntax("'--' is not allowed in comments")
}
}
match self.advance() {
Some(c) => {
let code = c.to_int()
if !is_valid_xml_content_char(code) {
raise InvalidSyntax("invalid character in comment")
}
buf.write_char(c)
}
None => raise UnexpectedEof
}
}
raise UnexpectedEof
}
///|
/// Read processing instruction:
fn Reader::read_pi(self : Reader) -> EventKind raise XmlErrorKind {
let _ = self.advance() // consume '?'
let target = self.read_name()
// PI target is required
if target == "" {
raise InvalidSyntax("processing instruction target is required")
}
// Check for "xml" in any case - case-insensitive match for reserved target
let target_lower = target.to_lower()
if target_lower == "xml" {
if target != "xml" {
// "XML", "Xml", etc. are not valid - only lowercase "xml" is allowed
raise InvalidSyntax("PI target 'xml' is reserved (case-insensitive)")
}
// XML declaration
self.read_xml_decl()
} else {
// After target, must have whitespace before data, or ?> to end
match self.peek() {
Some(c) if is_whitespace(c) => {
// Skip whitespace before data
let _ = self.advance()
let data = self.read_pi_data()
let _ = self.skip_string("?>")
PI(target~, data~)
}
Some('?') =>
// Check for ?>
if self.check_string("?>") {
let _ = self.skip_string("?>")
PI(target~, data="")
} else {
raise InvalidSyntax("expected whitespace or '?>' after PI target")
}
_ => raise InvalidSyntax("expected whitespace or '?>' after PI target")
}
}
}
///|
/// Read PI data, validating characters
fn Reader::read_pi_data(self : Reader) -> String raise XmlErrorKind {
let buf = StringBuilder()
while !self.is_eof() {
// Check if we're at the end marker ?>
if self.check_string("?>") {
return buf.to_string()
}
match self.advance() {
Some(c) => {
let code = c.to_int()
if !is_valid_xml_content_char(code) {
raise InvalidSyntax("invalid character in processing instruction")
}
buf.write_char(c)
}
None => raise UnexpectedEof
}
}
raise UnexpectedEof
}
///|
/// Read XML declaration:
/// Strict validation per XML 1.0 spec:
/// - Pseudo-attributes must be lowercase (version, encoding, standalone)
/// - Order: version (required), encoding (optional), standalone (optional)
/// - No duplicates allowed
/// - standalone must be "yes" or "no"
/// - Whitespace required between pseudo-attributes
fn Reader::read_xml_decl(self : Reader) -> EventKind raise XmlErrorKind {
// XML declaration must appear at the very beginning of the document
// Check if any content has been seen (elements, text, DOCTYPE, etc.)
if self.seen_content {
raise InvalidSyntax("XML declaration must be at the start of the document")
}
// Check if inside element (tag_stack not empty)
if self.tag_stack.length() > 0 {
raise InvalidSyntax("XML declaration cannot appear inside an element")
}
let mut version = ""
let mut encoding : String? = None
let mut standalone : String? = None
let mut seen_version = false
let mut seen_encoding = false
let mut seen_standalone = false
let mut need_whitespace = false
self.skip_whitespace()
while !self.check_string("?>") {
// Check for required whitespace between pseudo-attributes
let had_whitespace = self.skip_whitespace_count() > 0
if self.check_string("?>") {
break
}
if need_whitespace && !had_whitespace {
raise InvalidSyntax(
"whitespace required between XML declaration pseudo-attributes",
)
}
let attr_name = self.read_name()
if attr_name == "" {
break
}
self.skip_whitespace()
match self.peek() {
Some('=') => {
let _ = self.advance()
}
_ => raise InvalidSyntax("expected '=' in XML declaration")
}
self.skip_whitespace()
// Use raw attribute value (no entity decoding) for XML declaration
let value = self.read_raw_attr_value()
match attr_name {
"version" => {
if seen_version {
raise InvalidSyntax("duplicate 'version' in XML declaration")
}
if seen_encoding || seen_standalone {
raise InvalidSyntax("'version' must come first in XML declaration")
}
// Validate version format: VersionNum ::= '1.' [0-9]+
if !is_valid_version(value) {
raise InvalidSyntax("invalid version: " + value)
}
seen_version = true
version = value
}
"encoding" => {
if seen_encoding {
raise InvalidSyntax("duplicate 'encoding' in XML declaration")
}
if !seen_version {
raise InvalidSyntax("'version' must come before 'encoding'")
}
if seen_standalone {
raise InvalidSyntax("'encoding' must come before 'standalone'")
}
// Validate encoding name: [A-Za-z]([A-Za-z0-9._-])*
if !is_valid_encoding_name(value) {
raise InvalidSyntax("invalid encoding name: " + value)
}
seen_encoding = true
encoding = Some(value)
}
"standalone" => {
if seen_standalone {
raise InvalidSyntax("duplicate 'standalone' in XML declaration")
}
if !seen_version {
raise InvalidSyntax("'version' must come before 'standalone'")
}
// standalone must be exactly "yes" or "no" (case-sensitive)
if value != "yes" && value != "no" {
raise InvalidSyntax(
"'standalone' must be 'yes' or 'no', got: " + value,
)
}
seen_standalone = true
standalone = Some(value)
}
_ => {
// Reject uppercase variants and unknown attributes
if attr_name == "VERSION" ||
attr_name == "Version" ||
(attr_name.to_lower() == "version" && attr_name != "version") {
raise InvalidSyntax("'version' must be lowercase in XML declaration")
}
if attr_name == "ENCODING" ||
attr_name == "Encoding" ||
(attr_name.to_lower() == "encoding" && attr_name != "encoding") {
raise InvalidSyntax("'encoding' must be lowercase in XML declaration")
}
if attr_name == "STANDALONE" ||
attr_name == "Standalone" ||
(attr_name.to_lower() == "standalone" && attr_name != "standalone") {
raise InvalidSyntax(
"'standalone' must be lowercase in XML declaration",
)
}
raise InvalidSyntax(
"unknown pseudo-attribute in XML declaration: " + attr_name,
)
}
}
need_whitespace = true
}
let _ = self.skip_string("?>")
// Validate UTF-16 encoding - must have BOM if declared as UTF-16
match encoding {
Some(enc) => {
let enc_upper = enc.to_upper()
if enc_upper == "UTF-16" ||
enc_upper == "UTF-16LE" ||
enc_upper == "UTF-16BE" {
// UTF-16 encoded content should have BOM (U+FEFF) at start
// If input is ASCII/UTF-8 claiming to be UTF-16, it's invalid
if self.input.length() > 0 && self.input[0] != '\u{FEFF}' {
// Check if content looks like ASCII (no high bytes)
// Real UTF-16 would have null bytes in ASCII text
let all_ascii = self.input.length() < 100 &&
self.input.iter().all(fn(c) { c.to_int() < 128 })
if all_ascii {
raise InvalidSyntax(
"encoding declared as UTF-16 but content is ASCII/UTF-8",
)
}
}
}
}
None => ()
}
Decl(version~, encoding~, standalone~)
}
///|
/// Read element name (validates XML NameStartChar + NameChar)
fn Reader::read_name(self : Reader) -> String raise XmlErrorKind {
// First character must be a NameStartChar
match self.peek() {
Some(c) if is_name_start_char(c) => {
let _ = self.advance()
let buf = StringBuilder()
buf.write_char(c)
// Rest can be any NameChar
while self.peek() is Some(nc) && is_name_char(nc) {
buf.write_char(nc)
let _ = self.advance()
}
buf.to_string()
}
Some(c) if is_name_char(c) =>
// Character is valid NameChar but not NameStartChar (e.g., '-', '.', digit)
raise InvalidSyntax("name cannot start with '" + c.to_string() + "'")
_ => ""
}
}
///|
/// Read attributes
fn Reader::read_attributes(
self : Reader,
elem_name : String,
) -> Array[XmlAttribute] raise XmlErrorKind {
let attributes : Array[XmlAttribute] = []
let mut need_whitespace = false
while true {
let had_whitespace = self.skip_whitespace_count() > 0
match self.peek() {
Some(c) if is_name_start_char(c) => {
// Whitespace required between attributes (but not before the first)
if need_whitespace && !had_whitespace {
raise InvalidSyntax("whitespace required between attributes")
}
let attribute_start = self.source_position()
let name_start = attribute_start
let attr_name = self.read_name()
let name_end = self.source_position()
self.skip_whitespace()
match self.peek() {
Some('=') => {
let _ = self.advance()
self.skip_whitespace()
let (raw_value, value_span) = self.read_attr_value()
// Normalize attribute value based on declared type
let normalized_value = self.normalize_attr_by_type(
elem_name, attr_name, raw_value,
)
// Reject duplicate attributes
let exists = attributes
.iter()
.any(fn(attribute) { attribute.name == attr_name })
if exists {
raise InvalidSyntax("duplicate attribute: " + attr_name)
}
attributes.push({
name: attr_name,
value: normalized_value,
span: source_span(attribute_start, self.source_position()),
name_span: source_span(name_start, name_end),
value_span,
})
need_whitespace = true
}
_ => raise InvalidSyntax("expected '=' after attribute name")
}
}
Some('/') | Some('>') => break
Some(c) if is_name_char(c) =>
// Invalid attribute name start (e.g., starts with digit or '-')
raise InvalidSyntax(
"attribute name cannot start with '" + c.to_string() + "'",
)
Some(c) =>
raise InvalidSyntax(
"unexpected character in tag: '" + c.to_string() + "'",
)
None => break
}
}
attributes
}
///|
/// Normalize attribute value based on declared type in DTD.
/// CDATA: only replace newlines/tabs with spaces
/// Tokenized types (ID, IDREF, NMTOKEN, etc.): collapse whitespace, trim
fn Reader::normalize_attr_by_type(
self : Reader,
elem_name : String,
attr_name : String,
value : String,
) -> String {
let key = elem_name + ":" + attr_name
let attr_type = self.attr_types.get(key).unwrap_or("CDATA")
if attr_type == "CDATA" {
// CDATA: preserve character references, return as-is (lxml behavior)
value
} else {
// Tokenized types: collapse whitespace and trim
let buf = StringBuilder()
let mut in_whitespace = true // Start true to skip leading
let mut need_space = false
for c in value {
if c == ' ' || c == '\t' || c == '\n' || c == '\r' {
if !in_whitespace {
need_space = true
in_whitespace = true
}
} else {
if need_space {
buf.write_char(' ')
need_space = false
}
buf.write_char(c)
in_whitespace = false
}
}
buf.to_string()
}
}
///|
/// Read raw attribute value (quoted) without entity decoding
/// Used for XML declaration attributes where entities should be kept as-is
fn Reader::read_raw_attr_value(self : Reader) -> String raise XmlErrorKind {
let quote = match self.peek() {
Some('"') => '"'
Some('\'') => '\''
_ => raise InvalidSyntax("expected quoted attribute value")
}
let _ = self.advance() // consume opening quote
let buf = StringBuilder()
while !self.is_eof() {
match self.peek() {
Some(c) if c == quote => {
let _ = self.advance()
break
}
Some(c) => {
buf.write_char(c)
let _ = self.advance()
}
None => break
}
}
buf.to_string()
}
///|
/// Read attribute value (quoted) and decode entities
fn Reader::read_attr_value(
self : Reader,
) -> (String, SourceSpan) raise XmlErrorKind {
let quote = match self.peek() {
Some('"') => '"'
Some('\'') => '\''
_ => raise InvalidSyntax("expected quoted attribute value")
}
let _ = self.advance() // consume opening quote
let value_start = self.source_position()
let buf = StringBuilder()
while !self.is_eof() {
match self.peek() {
Some(c) if c == quote => {
let value_end = self.source_position()
let _ = self.advance()
return (buf.to_string(), source_span(value_start, value_end))
}
Some('<') =>
// < is not allowed in attribute values
raise InvalidSyntax("'<' not allowed in attribute value")
Some('&') => {
let _ = self.advance()
// Read entity name
let entity_name = self.read_while(fn(c) {
c != ';' && c != quote && c != '<'
})
match self.peek() {
Some(';') => {
let _ = self.advance()
// First check if it's a character reference
if entity_name.has_prefix("#") {
// Character reference - decode directly
let entity = "&" + entity_name + ";"
let decoded = unescape(entity)
buf.write_string(decoded)
} else {
// Entity reference - expand with validation
let entity_ref = "&" + entity_name + ";"
let expanded = self.expand_entity_value(entity_ref)
// Normalize whitespace in entity expansion (unlike char refs, which are preserved)
for c in expanded {
if c == '\t' || c == '\n' || c == '\r' {
buf.write_char(' ')
} else {
buf.write_char(c)
}
}
}
}
_ =>
// Entity reference must end with ';'
raise InvalidSyntax("entity reference must end with ';'")
}
}
Some(c) => {
// Check for invalid XML characters
let code = c.to_int()
if !is_valid_xml_content_char(code) {
raise InvalidSyntax("invalid character in attribute value")
}
// Normalize whitespace: tab, newline, CR -> space
if c == '\t' || c == '\n' || c == '\r' {
buf.write_char(' ')
} else {
buf.write_char(c)
}
let _ = self.advance()
}
None => break
}
}
(buf.to_string(), source_span(value_start, self.source_position()))
}
///|
/// Normalize line endings in a string: \r\n -> \n, \r -> \n
fn normalize_line_endings(s : String) -> String {
let chars = s.to_array()
let buf = StringBuilder()
let len = chars.length()
let mut i = 0
while i < len {
if chars[i] == '\r' {
buf.write_char('\n')
// Skip following \n if present
if i + 1 < len && chars[i + 1] == '\n' {
i += 1
}
} else {
buf.write_char(chars[i])
}
i += 1
}
buf.to_string()
}
///|
/// Check if a string contains actual XML markup (not just escaped chars)
fn has_markup(s : String) -> Bool {
let chars = s.to_array()
let len = chars.length()
let mut i = 0
while i < len {
if chars[i] == '<' {
// Check if followed by name start char (element) or / (end tag)
// or ! (comment/CDATA) or ? (PI)
if i + 1 < len {
let next = chars[i + 1]
if is_name_start_char(next) || next == '/' || next == '!' || next == '?' {
return true
}
}
} else if chars[i] == '&' {
// Entity references also count as "markup" for text splitting purposes
return true
}
i += 1
}
false
}
///|
/// Check if a string forms well-formed XML content (balanced tags).
/// This distinguishes real markup from character-reference `<` in entity values.
fn is_well_formed_content(s : String) -> Bool {
// Try to parse as XML content - if it fails, it's not well-formed
// Wrap in root element so parser accepts CDATA, etc.
let test_reader = Reader::from_string("" + s + "")
for ;; {
try test_reader.read_event() catch {
_ => break false
} noraise {
{ kind: Eof, .. } => break true
_ => continue
}
}
}
///|
/// Parse entity content as XML markup and push events to pending queue
fn Reader::parse_entity_content(
self : Reader,
content : String,
entity_span : SourceSpan,
) -> Unit {
// Create a temporary reader to parse the entity content
let sub_reader = Reader::from_string(content)
// Copy entity definitions to sub-reader
for k, v in self.entities {
sub_reader.entities.set(k, v)
}
// Disable root element checking for entity content
sub_reader.seen_root = true
// Add dummy tag to stack so CDATA is allowed (we're inside element context)
let position = sub_reader.source_position()
sub_reader.tag_stack.push(
("_entity_content_", source_span(position, position)),
)
// Parse all events from entity content
while true {
let event = sub_reader.read_event_internal() catch { _ => break }
match event.kind {
Eof => break
Start(element) => {
let attributes = element.attributes.map(fn(attribute) -> XmlAttribute {
{
name: attribute.name,
value: attribute.value,
span: entity_span,
name_span: entity_span,
value_span: entity_span,
}
})
self.push_pending_event({
kind: Start({ name: element.name, attributes, }),
span: entity_span,
})
}
Empty(element) => {
let attributes = element.attributes.map(fn(attribute) -> XmlAttribute {
{
name: attribute.name,
value: attribute.value,
span: entity_span,
name_span: entity_span,
value_span: entity_span,
}
})
self.push_pending_event({
kind: Empty({ name: element.name, attributes, }),
span: entity_span,
})
}
parsed_kind =>
self.push_pending_event({ kind: parsed_kind, span: entity_span, })
}
}
}
///|
/// Read text content - splits on entities to match lxml behavior
fn Reader::read_text(self : Reader) -> Event raise XmlErrorKind {
let buf = StringBuilder()
let mut text_start = self.source_position()
while !self.is_eof() {
match self.peek() {
Some('<') => break
Some('&') => {
// Push current buffer as text event before processing entity
let current_text = buf.to_string()
if current_text.length() > 0 {
self.push_pending_event({
kind: Text(current_text),
span: source_span(text_start, self.source_position()),
})
buf.reset()
}
// Read entity
let entity_start = self.source_position()
let _ = self.advance() // consume '&'
// Check for valid entity name start (# for char ref, or NameStartChar)
match self.peek() {
Some('#') => {
// Character reference
let entity_name = self.read_while(fn(c) { c != ';' && c != '<' })
match self.peek() {
Some(';') => {
let _ = self.advance()
let entity = "&" + entity_name + ";"
let decoded = unescape(entity)
// Character references preserve literal character (no line ending normalization)
self.push_pending_event({
kind: Text(decoded),
span: source_span(entity_start, self.source_position()),
})
}
_ => raise InvalidSyntax("entity reference must end with ';'")
}
}
Some(c) if is_name_start_char(c) => {
// Entity reference - read name
let entity_name = self.read_name()
match self.peek() {
Some(';') => {
let _ = self.advance()
// Get the stored entity value (may still have & etc.)
match self.entities.get(entity_name) {
Some(stored_value) => {
let entity_ref = "&" + entity_name + ";"
let expanded = self.expand_entity_value(entity_ref)
// Check if stored value has markup and is well-formed
// Use stored value (with & intact) for well-formedness check
if has_markup(stored_value) &&
is_well_formed_content(stored_value) {
// Parse stored entity content as XML (handles & correctly)
self.parse_entity_content(
stored_value,
source_span(entity_start, self.source_position()),
)
} else {
// Expand fully and return as text
let normalized = normalize_line_endings(expanded)
self.push_pending_event({
kind: Text(normalized),
span: source_span(entity_start, self.source_position()),
})
}
}
None => {
// Built-in entity (like &) - expand and push as text
let entity_ref = "&" + entity_name + ";"
let expanded = self.expand_entity_value(entity_ref)
let normalized = normalize_line_endings(expanded)
self.push_pending_event({
kind: Text(normalized),
span: source_span(entity_start, self.source_position()),
})
}
}
}
_ => raise InvalidSyntax("entity reference must end with ';'")
}
}
_ => raise InvalidSyntax("invalid entity reference")
}
text_start = self.source_position()
}
Some(']') => {
// Check for forbidden ]]> in content
if self.check_string("]]>") {
raise InvalidSyntax("']]>' is not allowed in element content")
}
buf.write_char(']')
let _ = self.advance()
}
Some(c) => {
// Check for invalid XML characters
let code = c.to_int()
if !is_valid_xml_content_char(code) {
raise InvalidSyntax("invalid character in content")
}
// Normalize line endings: \r\n -> \n, \r -> \n
if c == '\r' {
let _ = self.advance()
// Check if followed by \n
if self.peek() == Some('\n') {
let _ = self.advance()
}
buf.write_char('\n')
} else {
buf.write_char(c)
let _ = self.advance()
}
}
None => break
}
}
// Push any remaining text
let final_text = buf.to_string()
if final_text.length() > 0 {
self.push_pending_event({
kind: Text(final_text),
span: source_span(text_start, self.source_position()),
})
}
// Return first pending event, or empty Text if none
if self.pending_events.length() > 0 {
self.pop_pending_event()
} else {
let position = self.source_position()
{ kind: Text(""), span: source_span(position, position), }
}
}
///|
/// Read whitespace-only content (for prolog and misc after root)
/// Rejects any character references, entity references, or non-whitespace
fn Reader::read_whitespace_only(self : Reader) -> EventKind raise XmlErrorKind {
let buf = StringBuilder()
while !self.is_eof() {
match self.peek() {
Some('<') => break
Some('&') =>
// Character/entity references not allowed outside root element
if self.root_closed {
raise InvalidSyntax("character reference after root element")
} else {
raise InvalidSyntax("character reference before root element")
}
Some(c) => {
let code = c.to_int()
if c == ' ' || c == '\t' || c == '\n' || c == '\r' {
buf.write_char(c)
let _ = self.advance()
} else if code == 0xFEFF {
// Skip BOM (byte order mark) - don't include in output
let _ = self.advance()
// Non-whitespace content not allowed outside root element
} else if self.root_closed {
raise InvalidSyntax("content after root element")
} else {
raise InvalidSyntax("content before root element")
}
}
None => break
}
}
Text(buf.to_string())
}
///|
/// Check if character code is valid in XML content
/// Valid: #x9 | #xA | #xD | [#x20-#xD7FF] | [#xE000-#xFFFD] | [#x10000-#x10FFFF]
fn is_valid_xml_content_char(code : Int) -> Bool {
code == 0x9 ||
code == 0xA ||
code == 0xD ||
(code >= 0x20 && code <= 0xD7FF) ||
(code >= 0xE000 && code <= 0xFFFD) ||
(code >= 0x10000 && code <= 0x10FFFF)
}
///|
/// Validate version: VersionNum ::= '1.' [0-9]+
fn is_valid_version(version : String) -> Bool {
let chars = version.to_array()
// Must be at least "1.0"
if chars.length() < 3 {
return false
}
// Must start with "1."
if chars[0] != '1' || chars[1] != '.' {
return false
}
// Must have at least one digit after "1."
if chars.length() < 3 {
return false
}
// Rest must be digits only
for i = 2; i < chars.length(); i = i + 1 {
let c = chars[i]
if c < '0' || c > '9' {
return false
}
}
true
}
///|
/// Validate encoding name: [A-Za-z]([A-Za-z0-9._-])*
fn is_valid_encoding_name(name : String) -> Bool {
let chars = name.to_array()
if chars.length() == 0 {
return false
}
// First character must be a letter
let first = chars[0]
if !((first >= 'A' && first <= 'Z') || (first >= 'a' && first <= 'z')) {
return false
}
// Rest must be letters, digits, '.', '_', or '-'
for i = 1; i < chars.length(); i = i + 1 {
let c = chars[i]
if !((c >= 'A' && c <= 'Z') ||
(c >= 'a' && c <= 'z') ||
(c >= '0' && c <= '9') ||
c == '.' ||
c == '_' ||
c == '-') {
return false
}
}
true
}
///|
/// Skip a quoted string (single or double quotes)
fn Reader::skip_quoted_string(self : Reader) -> Unit {
match self.peek() {
Some('"') => {
let _ = self.advance()
while self.peek() is Some(c) && c != '"' {
let _ = self.advance()
}
if self.peek() == Some('"') {
let _ = self.advance()
}
}
Some('\'') => {
let _ = self.advance()
while self.peek() is Some(c) && c != '\'' {
let _ = self.advance()
}
if self.peek() == Some('\'') {
let _ = self.advance()
}
}
_ => ()
}
}
///|
/// Read and validate a Public ID literal
/// PubidChar ::= #x20 | #xD | #xA | [a-zA-Z0-9] | [-'()+,./:=?;!*#@$_%]
fn Reader::read_and_validate_public_id(
self : Reader,
) -> Unit raise XmlErrorKind {
let quote = match self.peek() {
Some('"') => '"'
Some('\'') => '\''
_ => return
}
let _ = self.advance() // consume opening quote
while self.peek() is Some(c) && c != quote {
if !is_pubid_char(c) {
raise InvalidSyntax(
"invalid character in public ID: '" + c.to_string() + "'",
)
}
let _ = self.advance()
}
if self.peek() == Some(quote) {
let _ = self.advance()
}
}
///|
/// Check if a character is valid in a Public ID
fn is_pubid_char(c : Char) -> Bool {
// PubidChar ::= #x20 | #xD | #xA | [a-zA-Z0-9] | [-'()+,./:=?;!*#@$_%]
c == ' ' ||
c == '\r' ||
c == '\n' ||
(c >= 'a' && c <= 'z') ||
(c >= 'A' && c <= 'Z') ||
(c >= '0' && c <= '9') ||
c == '-' ||
c == '\'' ||
c == '(' ||
c == ')' ||
c == '+' ||
c == ',' ||
c == '.' ||
c == '/' ||
c == ':' ||
c == '=' ||
c == '?' ||
c == ';' ||
c == '!' ||
c == '*' ||
c == '#' ||
c == '@' ||
c == '$' ||
c == '_' ||
c == '%'
}
///|
/// Parse DTD internal subset content until ]
/// Extracts entity declarations and stores them
fn Reader::parse_internal_subset(self : Reader) -> Unit raise XmlErrorKind {
while !self.is_eof() {
self.skip_whitespace()
match self.peek() {
Some(']') => {
let _ = self.advance()
return
}
Some('<') => {
let declaration_start = self.source_position()
let _ = self.advance()
if self.peek() is Some('!') {
let _ = self.advance()
// Check for conditional sections and CDATA (not allowed in internal subset)
if self.check_string("[INCLUDE[") || self.check_string("[IGNORE[") {
raise InvalidSyntax(
"conditional sections not allowed in internal DTD subset",
)
}
if self.check_string("[CDATA[") {
raise InvalidSyntax("CDATA section not allowed in DTD")
}
if self.check_string("ENTITY") {
// Parse entity declaration
let _ = self.skip_string("ENTITY")
// Check that whitespace follows ENTITY keyword
if !(self.peek() is Some(c) && is_whitespace(c)) {
raise InvalidSyntax("whitespace required after ENTITY")
}
self.skip_whitespace()
// Check for parameter entity
if self.peek() is Some('%') {
// Parameter entity - must have whitespace after %
let _ = self.advance() // skip %
if !(self.peek() is Some(c) && is_whitespace(c)) {
raise InvalidSyntax(
"whitespace required after % in parameter entity",
)
}
self.skip_whitespace()
let _ = self.read_name_lenient() // skip name
self.skip_whitespace()
// Parse and validate PE value (less strict than general entity)
match self.peek() {
Some('"') => {
let _ = self.advance()
let raw_value = self.read_entity_value('"')
let _ = self.advance()
validate_pe_value(raw_value)
self.skip_to_char_strict('>')
}
Some('\'') => {
let _ = self.advance()
let raw_value = self.read_entity_value('\'')
let _ = self.advance()
validate_pe_value(raw_value)
self.skip_to_char_strict('>')
}
_ => self.skip_to_char_strict('>')
}
} else {
// General entity - validate name starts with valid character
match self.peek() {
Some(c) if is_name_start_char(c) => ()
_ =>
raise InvalidSyntax(
"invalid character at start of entity name",
)
}
let name = self.read_name_lenient()
// Require at least one whitespace after entity name
if !(self.peek() is Some(c) && is_whitespace(c)) {
raise InvalidSyntax(
"whitespace required after entity name in declaration",
)
}
self.skip_whitespace()
// Check if entity already exists - if so, skip (first wins)
let is_duplicate = self.entities.contains(name)
// Check if it's an internal entity (quoted value) or external
match self.peek() {
Some('"') => {
let _ = self.advance()
let raw_value = self.read_entity_value('"')
let _ = self.advance() // closing quote
// Only validate and store if not duplicate
if !is_duplicate {
validate_entity_value(raw_value)
// Per XML spec 4.5: expand only char refs, keep entity refs
let value = expand_char_refs_only(raw_value) catch {
_ => raw_value
}
self.entities.set(name, value)
}
self.skip_to_char_strict('>')
}
Some('\'') => {
let _ = self.advance()
let raw_value = self.read_entity_value('\'')
let _ = self.advance() // closing quote
// Only validate and store if not duplicate
if !is_duplicate {
validate_entity_value(raw_value)
// Per XML spec 4.5: expand only char refs, keep entity refs
let value = expand_char_refs_only(raw_value) catch {
_ => raw_value
}
self.entities.set(name, value)
}
self.skip_to_char_strict('>')
}
_ => {
// External entity (SYSTEM/PUBLIC)
let keyword = self.read_name_lenient()
self.skip_whitespace()
if keyword == "SYSTEM" {
// Parse the system literal without resolving it. External
// entity access requires an explicit resolver API.
match self.peek() {
Some('"') => {
let _ = self.advance()
let _ = self.read_entity_value('"')
let _ = self.advance()
}
Some('\'') => {
let _ = self.advance()
let _ = self.read_entity_value('\'')
let _ = self.advance()
}
_ => ()
}
}
self.skip_to_char_strict('>')
}
}
}
} else if self.check_string("ATTLIST") {
let _ = self.skip_string("ATTLIST")
parse_attlist(self)
} else if self.check_string("ELEMENT") {
let _ = self.skip_string("ELEMENT")
parse_element_decl(self)
} else if self.check_string("--") {
// Comment in DTD:
let _ = self.skip_string("--")
let comment_buf = StringBuilder()
while !self.is_eof() {
if self.check_string("--") {
let _ = self.skip_string("--")
if self.peek() == Some('>') {
let _ = self.advance()
break
} else {
// -- not followed by > is invalid in XML comments
raise InvalidSyntax("'--' in comment must be followed by '>'")
}
}
match self.advance() {
Some(c) => comment_buf.write_char(c)
None => ()
}
}
self.internal_subset_events.push({
kind: Comment(comment_buf.to_string()),
span: source_span(declaration_start, self.source_position()),
})
} else if self.check_string("NOTATION") {
// NOTATION declaration - validate PUBLIC ID if present
let _ = self.skip_string("NOTATION")
self.skip_whitespace()
let _ = self.read_name() // notation name
self.skip_whitespace()
if self.check_string("PUBLIC") {
let _ = self.skip_string("PUBLIC")
self.skip_whitespace()
self.read_and_validate_public_id()
self.skip_whitespace()
// Optional system literal
if self.peek() is Some('"') || self.peek() is Some('\'') {
self.skip_quoted_string()
}
} else if self.check_string("SYSTEM") {
let _ = self.skip_string("SYSTEM")
self.skip_whitespace()
self.skip_quoted_string()
}
self.skip_to_char_strict('>')
} else {
// Other declarations - skip to >
self.skip_declaration()
}
} else if self.peek() is Some('?') {
// Processing instruction in DTD - emit as event
let _ = self.advance() // skip ?
// Read target name
let target = self.read_name()
if target == "" {
raise InvalidSyntax("processing instruction target is required")
}
// Check if it's an XML declaration (not allowed in DTD)
let target_lower = target.to_lower()
if target_lower == "xml" {
raise InvalidSyntax("XML declaration not allowed in DTD")
}
// After target, must have whitespace before data, or ?>
match self.peek() {
Some(c) if is_whitespace(c) => {
// Skip leading whitespace
let _ = self.advance()
// Read PI data
let data_buf = StringBuilder()
while !self.is_eof() {
if self.check_string("?>") {
let _ = self.skip_string("?>")
break
}
match self.advance() {
Some(ch) => data_buf.write_char(ch)
None => break
}
}
// Push PI event to internal subset queue (will be emitted after prolog whitespace)
self.internal_subset_events.push({
kind: PI(target~, data=data_buf.to_string()),
span: source_span(declaration_start, self.source_position()),
})
}
Some('?') =>
// Check for ?>
if self.check_string("?>") {
let _ = self.skip_string("?>")
// Push PI event with empty data to internal subset queue
self.internal_subset_events.push({
kind: PI(target~, data=""),
span: source_span(declaration_start, self.source_position()),
})
} else {
raise InvalidSyntax(
"expected whitespace or '?>' after PI target",
)
}
_ =>
raise InvalidSyntax("expected whitespace or '?>' after PI target")
}
} else {
// Unknown - skip
let _ = self.advance()
}
}
Some('%') => {
// Parameter entity reference - must be %Name; with no whitespace
let _ = self.advance() // consume %
// Name must start immediately after %
match self.peek() {
Some(c) if is_name_start_char(c) => {
// Read the name
let _ = self.read_name()
// Must be immediately followed by ;
match self.peek() {
Some(';') => {
let _ = self.advance()
}
_ =>
raise InvalidSyntax(
"parameter entity reference must end with ';'",
)
}
}
_ => raise InvalidSyntax("invalid parameter entity reference")
}
}
Some(_) => {
let _ = self.advance()
}
None => return
}
}
}
///|
/// Skip to a character, consuming it, and checking for SGML comments
fn Reader::skip_to_char_strict(
self : Reader,
target : Char,
) -> Unit raise XmlErrorKind {
while self.peek() is Some(c) && c != target {
if c == '-' && self.check_string("--") {
raise InvalidSyntax(
"SGML-style comments (--) not allowed in XML declarations",
)
}
let _ = self.advance()
}
let _ = self.advance()
}
///|
/// Skip a DTD declaration (handles quoted strings)
/// Also checks for SGML-style comments (-- ... --) which are not valid in XML
fn Reader::skip_declaration(self : Reader) -> Unit raise XmlErrorKind {
while self.peek() is Some(c) && c != '>' {
if c == '"' {
let _ = self.advance()
while self.peek() is Some(c2) && c2 != '"' {
let _ = self.advance()
}
let _ = self.advance()
} else if c == '\'' {
let _ = self.advance()
while self.peek() is Some(c2) && c2 != '\'' {
let _ = self.advance()
}
let _ = self.advance()
} else if c == '-' && self.check_string("--") {
// SGML-style comments are not allowed in XML
raise InvalidSyntax(
"SGML-style comments (--) not allowed in XML declarations",
)
} else {
let _ = self.advance()
}
}
let _ = self.advance()
}
///|
/// Read name leniently (for DTD parsing where we don't validate strictly)
fn Reader::read_name_lenient(self : Reader) -> String {
let buf = StringBuilder()
while self.peek() is Some(c) && is_name_char_lenient(c) {
buf.write_char(c)
let _ = self.advance()
}
buf.to_string()
}
///|
/// Lenient name character check for DTD parsing
fn is_name_char_lenient(c : Char) -> Bool {
!(c == ' ' ||
c == '\t' ||
c == '\n' ||
c == '\r' ||
c == '<' ||
c == '>' ||
c == '"' ||
c == '\'' ||
c == '=' ||
c == '[' ||
c == ']')
}
///|
/// Read entity value (handles nested entity references)
fn Reader::read_entity_value(
self : Reader,
quote : Char,
) -> String raise XmlErrorKind {
let buf = StringBuilder()
while self.peek() is Some(c) && c != quote {
// Validate character
let code = c.to_int()
if !is_valid_xml_content_char(code) {
raise InvalidSyntax("invalid character in entity value")
}
buf.write_char(c)
let _ = self.advance()
}
buf.to_string()
}
///|
fn is_whitespace(c : Char) -> Bool {
c == ' ' || c == '\t' || c == '\n' || c == '\r'
}
///|
/// Check if character is a valid XML NameStartChar (per XML 1.0 spec)
/// NameStartChar ::= ":" | [A-Z] | "_" | [a-z] | [#xC0-#xD6] | [#xD8-#xF6] |
/// [#xF8-#x2FF] | [#x370-#x37D] | [#x37F-#x1FFF] |
/// [#x200C-#x200D] | [#x2070-#x218F] | [#x2C00-#x2FEF] |
/// [#x3001-#xD7FF] | [#xF900-#xFDCF] | [#xFDF0-#xFFFD] |
/// [#x10000-#xEFFFF]
fn is_name_start_char(c : Char) -> Bool {
let code = c.to_int()
c == ':' ||
c == '_' ||
(c >= 'A' && c <= 'Z') ||
(c >= 'a' && c <= 'z') ||
(code >= 0xC0 && code <= 0xD6) ||
(code >= 0xD8 && code <= 0xF6) ||
(code >= 0xF8 && code <= 0x2FF) ||
(code >= 0x370 && code <= 0x37D) ||
(code >= 0x37F && code <= 0x1FFF) ||
(code >= 0x200C && code <= 0x200D) ||
(code >= 0x2070 && code <= 0x218F) ||
(code >= 0x2C00 && code <= 0x2FEF) ||
(code >= 0x3001 && code <= 0xD7FF) ||
(code >= 0xF900 && code <= 0xFDCF) ||
(code >= 0xFDF0 && code <= 0xFFFD) ||
(code >= 0x10000 && code <= 0xEFFFF)
}
///|
/// Check if character is a valid XML NameChar (per XML 1.0 spec)
/// NameChar ::= NameStartChar | "-" | "." | [0-9] | #xB7 |
/// [#x0300-#x036F] | [#x203F-#x2040]
fn is_name_char(c : Char) -> Bool {
if is_name_start_char(c) {
return true
}
let code = c.to_int()
c == '-' ||
c == '.' ||
(c >= '0' && c <= '9') ||
code == 0xB7 ||
(code >= 0x300 && code <= 0x36F) ||
(code >= 0x203F && code <= 0x2040)
}