Skip to main content

serde_xml/
reader.rs

1//! Low-level XML reader/tokenizer.
2//!
3//! This module provides a fast, zero-copy XML tokenizer that produces events
4//! for elements, attributes, text content, and other XML constructs.
5
6use crate::error::{Error, Position, Result};
7use crate::escape::unescape;
8use memchr::memchr;
9use std::borrow::Cow;
10
11/// Whitespace lookup table for fast checking.
12static IS_WHITESPACE: [bool; 256] = {
13    let mut lut = [false; 256];
14    lut[b' ' as usize] = true;
15    lut[b'\t' as usize] = true;
16    lut[b'\n' as usize] = true;
17    lut[b'\r' as usize] = true;
18    lut
19};
20
21/// Name start character lookup table.
22static IS_NAME_START: [bool; 256] = {
23    let mut lut = [false; 256];
24    let mut i = b'A';
25    while i <= b'Z' {
26        lut[i as usize] = true;
27        i += 1;
28    }
29    let mut i = b'a';
30    while i <= b'z' {
31        lut[i as usize] = true;
32        i += 1;
33    }
34    lut[b'_' as usize] = true;
35    lut[b':' as usize] = true;
36    // Allow high bytes for UTF-8
37    let mut i: usize = 0x80;
38    while i < 256 {
39        lut[i] = true;
40        i += 1;
41    }
42    lut
43};
44
45/// Name character lookup table.
46static IS_NAME_CHAR: [bool; 256] = {
47    let mut lut = IS_NAME_START;
48    let mut i = b'0';
49    while i <= b'9' {
50        lut[i as usize] = true;
51        i += 1;
52    }
53    lut[b'-' as usize] = true;
54    lut[b'.' as usize] = true;
55    lut
56};
57
58/// An XML event produced by the reader.
59#[derive(Debug, Clone, PartialEq)]
60pub enum XmlEvent<'a> {
61    /// XML declaration: <?xml version="1.0"?>
62    XmlDecl {
63        /// XML version (e.g., "1.0").
64        version: Cow<'a, str>,
65        /// Character encoding (e.g., "UTF-8").
66        encoding: Option<Cow<'a, str>>,
67        /// Standalone declaration.
68        standalone: Option<bool>,
69    },
70    /// Start of an element: <name attr="value">
71    StartElement {
72        /// Element name.
73        name: Cow<'a, str>,
74        /// Element attributes.
75        attributes: Vec<Attribute<'a>>,
76    },
77    /// End of an element: </name>
78    EndElement {
79        /// Element name.
80        name: Cow<'a, str>,
81    },
82    /// Empty element: <name attr="value"/>
83    EmptyElement {
84        /// Element name.
85        name: Cow<'a, str>,
86        /// Element attributes.
87        attributes: Vec<Attribute<'a>>,
88    },
89    /// Text content between elements.
90    Text(Cow<'a, str>),
91    /// CDATA section: <![CDATA[...]]>
92    CData(Cow<'a, str>),
93    /// Comment: <!-- ... -->
94    Comment(Cow<'a, str>),
95    /// Processing instruction: <?target data?>
96    ProcessingInstruction {
97        /// Processing instruction target.
98        target: Cow<'a, str>,
99        /// Processing instruction data.
100        data: Option<Cow<'a, str>>,
101    },
102    /// End of document.
103    Eof,
104}
105
106/// An XML attribute.
107#[derive(Debug, Clone, PartialEq)]
108pub struct Attribute<'a> {
109    /// The attribute name.
110    pub name: Cow<'a, str>,
111    /// The attribute value.
112    pub value: Cow<'a, str>,
113}
114
115/// A fast, zero-copy XML reader.
116pub struct XmlReader<'a> {
117    input: &'a [u8],
118    pos: usize,
119    /// Stack of open element names for validation.
120    element_stack: Vec<&'a str>,
121}
122
123impl<'a> XmlReader<'a> {
124    /// Creates a new XML reader from a string.
125    #[inline]
126    #[allow(clippy::should_implement_trait)]
127    pub fn from_str(s: &'a str) -> Self {
128        Self::from_bytes(s.as_bytes())
129    }
130
131    /// Creates a new XML reader from bytes.
132    #[inline]
133    pub fn from_bytes(input: &'a [u8]) -> Self {
134        Self {
135            input,
136            pos: 0,
137            element_stack: Vec::with_capacity(8), // Pre-allocate for typical nesting
138        }
139    }
140
141    /// Returns the current position in the input.
142    ///
143    /// Line/column are computed on demand by scanning the consumed input, so
144    /// the tokenizer's hot path only has to track the byte offset.
145    #[inline]
146    pub fn position(&self) -> Position {
147        self.position_at(self.pos)
148    }
149
150    /// Returns whether there are any open elements.
151    #[inline]
152    pub fn depth(&self) -> usize {
153        self.element_stack.len()
154    }
155
156    /// Reads the next XML event.
157    ///
158    /// Text content is trimmed of leading and trailing whitespace, and
159    /// whitespace-only text nodes are skipped entirely (they never produce
160    /// a [`XmlEvent::Text`] event).
161    #[inline]
162    pub fn next_event(&mut self) -> Result<XmlEvent<'a>> {
163        self.skip_whitespace_fast();
164
165        if self.pos >= self.input.len() {
166            if let Some(tag) = self.element_stack.pop() {
167                return Err(Error::unclosed_tag(tag).with_position(self.position()));
168            }
169            return Ok(XmlEvent::Eof);
170        }
171
172        if self.input[self.pos] == b'<' {
173            self.read_tag()
174        } else {
175            self.read_text()
176        }
177    }
178
179    /// Fast whitespace skipping using lookup table.
180    #[inline(always)]
181    fn skip_whitespace_fast(&mut self) {
182        while self.pos < self.input.len() && IS_WHITESPACE[self.input[self.pos] as usize] {
183            self.pos += 1;
184        }
185    }
186
187    /// Reads text content using memchr for fast scanning.
188    #[inline]
189    fn read_text(&mut self) -> Result<XmlEvent<'a>> {
190        let start = self.pos;
191
192        // Fast path: find '<' using SIMD-accelerated memchr
193        match memchr(b'<', &self.input[self.pos..]) {
194            Some(offset) => self.pos += offset,
195            None => self.pos = self.input.len(),
196        }
197
198        let text = std::str::from_utf8(&self.input[start..self.pos])
199            .map_err(|_| Error::new(crate::error::ErrorKind::InvalidUtf8).with_position(self.position()))?;
200
201        // Trim whitespace from text
202        let trimmed = text.trim();
203        if trimmed.is_empty() {
204            return self.next_event();
205        }
206
207        // Unescape XML entities
208        match unescape(trimmed) {
209            Ok(unescaped) => Ok(XmlEvent::Text(unescaped)),
210            Err(e) => {
211                // e.position is a byte offset into `trimmed`; map it back to
212                // an offset in the input for a precise error position.
213                let leading_ws = text.len() - text.trim_start().len();
214                let err_pos = self.position_at(start + leading_ws + e.position);
215                Err(Error::invalid_escape(e.entity).with_position(err_pos))
216            }
217        }
218    }
219
220    /// Computes the position at byte `offset` by scanning the input up to it.
221    ///
222    /// Lines are 1-based and advance on `\n`; columns are 1-based character
223    /// counts (UTF-8 continuation bytes do not advance the column). Only
224    /// called on error paths and explicit `position()` calls, so the O(offset)
225    /// scan is off the hot path.
226    fn position_at(&self, offset: usize) -> Position {
227        let mut line = 1;
228        let mut column = 1;
229        for &b in &self.input[..offset] {
230            if b == b'\n' {
231                line += 1;
232                column = 1;
233            } else if b & 0xC0 != 0x80 {
234                column += 1;
235            }
236        }
237        Position { line, column, offset }
238    }
239
240    /// Reads a tag (element, comment, CDATA, PI, or declaration).
241    #[inline]
242    fn read_tag(&mut self) -> Result<XmlEvent<'a>> {
243        debug_assert_eq!(self.input[self.pos], b'<');
244        self.pos += 1;
245
246        if self.pos >= self.input.len() {
247            return Err(Error::unexpected_eof().with_position(self.position()));
248        }
249
250        match self.input[self.pos] {
251            b'/' => self.read_end_element(),
252            b'?' => self.read_processing_instruction(),
253            b'!' => self.read_special(),
254            _ => self.read_start_element(),
255        }
256    }
257
258    /// Reads a start element or empty element.
259    #[inline]
260    fn read_start_element(&mut self) -> Result<XmlEvent<'a>> {
261        let name = self.read_name()?;
262        let attributes = self.read_attributes()?;
263
264        self.skip_whitespace_fast();
265
266        if self.pos >= self.input.len() {
267            return Err(Error::unexpected_eof().with_position(self.position()));
268        }
269
270        if self.input[self.pos] == b'/' {
271            // Empty element: <name/>
272            self.pos += 1;
273            self.expect_char(b'>')?;
274            Ok(XmlEvent::EmptyElement {
275                name: Cow::Borrowed(name),
276                attributes,
277            })
278        } else if self.input[self.pos] == b'>' {
279            // Start element: <name>
280            self.pos += 1;
281            self.element_stack.push(name);
282            Ok(XmlEvent::StartElement {
283                name: Cow::Borrowed(name),
284                attributes,
285            })
286        } else {
287            Err(Error::syntax("expected '>' or '/>'").with_position(self.position()))
288        }
289    }
290
291    /// Reads an end element.
292    #[inline]
293    fn read_end_element(&mut self) -> Result<XmlEvent<'a>> {
294        debug_assert_eq!(self.input[self.pos], b'/');
295        self.pos += 1;
296
297        let name = self.read_name()?;
298        self.skip_whitespace_fast();
299        self.expect_char(b'>')?;
300
301        // Validate matching tags
302        match self.element_stack.pop() {
303            Some(expected) if expected == name => Ok(XmlEvent::EndElement {
304                name: Cow::Borrowed(name),
305            }),
306            Some(expected) => Err(Error::mismatched_tag(expected, name).with_position(self.position())),
307            None => Err(Error::syntax(format!("unexpected closing tag: {}", name))
308                .with_position(self.position())),
309        }
310    }
311
312    /// Reads a processing instruction.
313    fn read_processing_instruction(&mut self) -> Result<XmlEvent<'a>> {
314        debug_assert_eq!(self.input[self.pos], b'?');
315        self.pos += 1;
316
317        let target = self.read_name()?;
318
319        // Check for XML declaration
320        if target.eq_ignore_ascii_case("xml") {
321            return self.read_xml_decl();
322        }
323
324        self.skip_whitespace_fast();
325
326        // Read data until ?> using memchr for speed
327        let data_start = self.pos;
328
329        while self.pos + 1 < self.input.len() {
330            if let Some(offset) = memchr(b'?', &self.input[self.pos..]) {
331                let check_pos = self.pos + offset;
332                if check_pos + 1 < self.input.len() && self.input[check_pos + 1] == b'>' {
333                    self.pos = check_pos;
334
335                    let data = std::str::from_utf8(&self.input[data_start..self.pos])
336                        .map_err(|_| Error::new(crate::error::ErrorKind::InvalidUtf8).with_position(self.position()))?;
337                    self.pos += 2;
338                    return Ok(XmlEvent::ProcessingInstruction {
339                        target: Cow::Borrowed(target),
340                        data: if data.trim().is_empty() {
341                            None
342                        } else {
343                            Some(Cow::Borrowed(data.trim()))
344                        },
345                    });
346                }
347                // Not the end, continue searching
348                self.pos = check_pos + 1;
349            } else {
350                break;
351            }
352        }
353
354        Err(Error::syntax("unterminated processing instruction").with_position(self.position()))
355    }
356
357    /// Reads an XML declaration.
358    fn read_xml_decl(&mut self) -> Result<XmlEvent<'a>> {
359        let attributes = self.read_attributes()?;
360        self.skip_whitespace_fast();
361
362        if self.pos + 1 >= self.input.len()
363            || self.input[self.pos] != b'?'
364            || self.input[self.pos + 1] != b'>'
365        {
366            return Err(Error::syntax("expected '?>'").with_position(self.position()));
367        }
368        self.pos += 2;
369
370        let mut version = None;
371        let mut encoding = None;
372        let mut standalone = None;
373
374        for attr in attributes {
375            match attr.name.as_ref() {
376                "version" => version = Some(attr.value),
377                "encoding" => encoding = Some(attr.value),
378                "standalone" => {
379                    standalone = Some(attr.value.as_ref() == "yes");
380                }
381                _ => {}
382            }
383        }
384
385        Ok(XmlEvent::XmlDecl {
386            version: version.unwrap_or(Cow::Borrowed("1.0")),
387            encoding,
388            standalone,
389        })
390    }
391
392    /// Reads special constructs (comments, CDATA, DOCTYPE).
393    fn read_special(&mut self) -> Result<XmlEvent<'a>> {
394        debug_assert_eq!(self.input[self.pos], b'!');
395        self.pos += 1;
396
397        if self.pos >= self.input.len() {
398            return Err(Error::unexpected_eof().with_position(self.position()));
399        }
400
401        // Check for comment: <!--
402        if self.pos + 1 < self.input.len()
403            && self.input[self.pos] == b'-'
404            && self.input[self.pos + 1] == b'-'
405        {
406            return self.read_comment();
407        }
408
409        // Check for CDATA: <![CDATA[
410        if self.pos + 6 < self.input.len() && &self.input[self.pos..self.pos + 7] == b"[CDATA[" {
411            return self.read_cdata();
412        }
413
414        // Check for DOCTYPE
415        if self.pos + 6 < self.input.len() && self.input[self.pos..].starts_with(b"DOCTYPE") {
416            return self.skip_doctype();
417        }
418
419        Err(Error::syntax("unknown construct after '<!'").with_position(self.position()))
420    }
421
422    /// Reads a comment using memchr for fast end detection.
423    fn read_comment(&mut self) -> Result<XmlEvent<'a>> {
424        self.pos += 2; // Skip --
425        let start = self.pos;
426
427        // Search for --> using memchr
428        while self.pos + 2 < self.input.len() {
429            if let Some(offset) = memchr(b'-', &self.input[self.pos..]) {
430                let check_pos = self.pos + offset;
431                if check_pos + 2 < self.input.len()
432                    && self.input[check_pos + 1] == b'-'
433                    && self.input[check_pos + 2] == b'>'
434                {
435                    let comment = std::str::from_utf8(&self.input[start..check_pos])
436                        .map_err(|_| Error::new(crate::error::ErrorKind::InvalidUtf8).with_position(self.position()))?;
437                    self.pos = check_pos + 3;
438                    return Ok(XmlEvent::Comment(Cow::Borrowed(comment.trim())));
439                }
440                self.pos = check_pos + 1;
441            } else {
442                break;
443            }
444        }
445
446        Err(Error::syntax("unterminated comment").with_position(self.position()))
447    }
448
449    /// Reads a CDATA section using memchr for fast end detection.
450    fn read_cdata(&mut self) -> Result<XmlEvent<'a>> {
451        self.pos += 7; // Skip [CDATA[
452        let start = self.pos;
453
454        // Search for ]]> using memchr
455        while self.pos + 2 < self.input.len() {
456            if let Some(offset) = memchr(b']', &self.input[self.pos..]) {
457                let check_pos = self.pos + offset;
458                if check_pos + 2 < self.input.len()
459                    && self.input[check_pos + 1] == b']'
460                    && self.input[check_pos + 2] == b'>'
461                {
462                    let data = std::str::from_utf8(&self.input[start..check_pos])
463                        .map_err(|_| Error::new(crate::error::ErrorKind::InvalidUtf8).with_position(self.position()))?;
464                    self.pos = check_pos + 3;
465                    return Ok(XmlEvent::CData(Cow::Borrowed(data)));
466                }
467                self.pos = check_pos + 1;
468            } else {
469                break;
470            }
471        }
472
473        Err(Error::syntax("unterminated CDATA section").with_position(self.position()))
474    }
475
476    /// Skips a DOCTYPE declaration.
477    ///
478    /// Tracks nested `<`/`>` depth, ignoring angle brackets that appear
479    /// inside quoted (`"..."` or `'...'`) literals.
480    fn skip_doctype(&mut self) -> Result<XmlEvent<'a>> {
481        let mut depth = 1;
482
483        while self.pos < self.input.len() && depth > 0 {
484            match self.input[self.pos] {
485                b'<' => depth += 1,
486                b'>' => depth -= 1,
487                quote @ (b'"' | b'\'') => {
488                    // Skip over the quoted literal so any '<' or '>' inside
489                    // it is not counted toward the depth.
490                    let literal_start = self.pos + 1;
491                    match memchr(quote, &self.input[literal_start..]) {
492                        Some(offset) => {
493                            self.pos = literal_start + offset;
494                            // The closing quote is consumed below.
495                        }
496                        None => {
497                            self.pos = self.input.len();
498                            break;
499                        }
500                    }
501                }
502                _ => {}
503            }
504
505            self.pos += 1;
506        }
507
508        // Skip to next event
509        self.next_event()
510    }
511
512    /// Reads an XML name using lookup table.
513    #[inline]
514    fn read_name(&mut self) -> Result<&'a str> {
515        let start = self.pos;
516
517        // First character must be a name start char
518        if self.pos >= self.input.len() {
519            return Err(Error::unexpected_eof().with_position(self.position()));
520        }
521
522        let first = self.input[self.pos];
523        if !IS_NAME_START[first as usize] {
524            return Err(Error::invalid_name(format!("invalid name start character: {:?}", first as char))
525                .with_position(self.position()));
526        }
527        self.pos += 1;
528
529        // Subsequent characters - use lookup table
530        while self.pos < self.input.len() && IS_NAME_CHAR[self.input[self.pos] as usize] {
531            self.pos += 1;
532        }
533
534        std::str::from_utf8(&self.input[start..self.pos])
535            .map_err(|_| Error::new(crate::error::ErrorKind::InvalidUtf8).with_position(self.position()))
536    }
537
538    /// Reads element attributes.
539    #[inline]
540    fn read_attributes(&mut self) -> Result<Vec<Attribute<'a>>> {
541        // Lazily allocated: attribute-less elements never touch the heap here.
542        let mut attributes = Vec::new();
543
544        loop {
545            self.skip_whitespace_fast();
546
547            if self.pos >= self.input.len() {
548                break;
549            }
550
551            // Check for end of attributes
552            let c = self.input[self.pos];
553            if c == b'>' || c == b'/' || c == b'?' {
554                break;
555            }
556
557            // Read attribute name
558            let name = self.read_name()?;
559            self.skip_whitespace_fast();
560
561            // Expect '='
562            self.expect_char(b'=')?;
563            self.skip_whitespace_fast();
564
565            // Read attribute value
566            let value = self.read_attribute_value()?;
567
568            attributes.push(Attribute {
569                name: Cow::Borrowed(name),
570                value,
571            });
572        }
573
574        Ok(attributes)
575    }
576
577    /// Reads an attribute value using memchr for fast quote finding.
578    #[inline]
579    fn read_attribute_value(&mut self) -> Result<Cow<'a, str>> {
580        if self.pos >= self.input.len() {
581            return Err(Error::unexpected_eof().with_position(self.position()));
582        }
583
584        let quote = self.input[self.pos];
585        if quote != b'"' && quote != b'\'' {
586            return Err(Error::syntax("expected quote").with_position(self.position()));
587        }
588        self.pos += 1;
589
590        let start = self.pos;
591
592        // Find closing quote using memchr
593        match memchr(quote, &self.input[self.pos..]) {
594            Some(offset) => {
595                let value = std::str::from_utf8(&self.input[start..self.pos + offset])
596                    .map_err(|_| Error::new(crate::error::ErrorKind::InvalidUtf8).with_position(self.position()))?;
597                self.pos += offset + 1; // value + closing quote
598
599                // Unescape the value
600                match unescape(value) {
601                    Ok(unescaped) => Ok(unescaped),
602                    Err(e) => {
603                        // e.position is a byte offset into `value`; map it
604                        // back to an offset in the input.
605                        let err_pos = self.position_at(start + e.position);
606                        Err(Error::invalid_escape(e.entity).with_position(err_pos))
607                    }
608                }
609            }
610            None => Err(Error::syntax("unterminated attribute value").with_position(self.position())),
611        }
612    }
613
614    /// Expects a specific character.
615    #[inline(always)]
616    fn expect_char(&mut self, expected: u8) -> Result<()> {
617        if self.pos >= self.input.len() {
618            return Err(Error::unexpected_eof().with_position(self.position()));
619        }
620
621        if self.input[self.pos] != expected {
622            return Err(Error::syntax(format!(
623                "expected '{}', found '{}'",
624                expected as char,
625                self.input[self.pos] as char
626            ))
627            .with_position(self.position()));
628        }
629
630        self.pos += 1;
631        Ok(())
632    }
633}
634
635#[cfg(test)]
636mod tests {
637    use super::*;
638
639    #[test]
640    fn test_simple_element() {
641        let mut reader = XmlReader::from_str("<root></root>");
642
643        match reader.next_event().unwrap() {
644            XmlEvent::StartElement { name, attributes } => {
645                assert_eq!(name, "root");
646                assert!(attributes.is_empty());
647            }
648            _ => panic!("expected StartElement"),
649        }
650
651        match reader.next_event().unwrap() {
652            XmlEvent::EndElement { name } => {
653                assert_eq!(name, "root");
654            }
655            _ => panic!("expected EndElement"),
656        }
657
658        assert!(matches!(reader.next_event().unwrap(), XmlEvent::Eof));
659    }
660
661    #[test]
662    fn test_empty_element() {
663        let mut reader = XmlReader::from_str("<root/>");
664
665        match reader.next_event().unwrap() {
666            XmlEvent::EmptyElement { name, attributes } => {
667                assert_eq!(name, "root");
668                assert!(attributes.is_empty());
669            }
670            _ => panic!("expected EmptyElement"),
671        }
672
673        assert!(matches!(reader.next_event().unwrap(), XmlEvent::Eof));
674    }
675
676    #[test]
677    fn test_attributes() {
678        let mut reader = XmlReader::from_str(r#"<root id="1" name="test"/>"#);
679
680        match reader.next_event().unwrap() {
681            XmlEvent::EmptyElement { name, attributes } => {
682                assert_eq!(name, "root");
683                assert_eq!(attributes.len(), 2);
684                assert_eq!(attributes[0].name, "id");
685                assert_eq!(attributes[0].value, "1");
686                assert_eq!(attributes[1].name, "name");
687                assert_eq!(attributes[1].value, "test");
688            }
689            _ => panic!("expected EmptyElement"),
690        }
691    }
692
693    #[test]
694    fn test_text_content() {
695        let mut reader = XmlReader::from_str("<root>Hello, World!</root>");
696
697        reader.next_event().unwrap(); // StartElement
698
699        match reader.next_event().unwrap() {
700            XmlEvent::Text(text) => {
701                assert_eq!(text, "Hello, World!");
702            }
703            _ => panic!("expected Text"),
704        }
705    }
706
707    #[test]
708    fn test_escaped_text() {
709        let mut reader = XmlReader::from_str("<root>&lt;Hello&gt;</root>");
710
711        reader.next_event().unwrap(); // StartElement
712
713        match reader.next_event().unwrap() {
714            XmlEvent::Text(text) => {
715                assert_eq!(text, "<Hello>");
716            }
717            _ => panic!("expected Text"),
718        }
719    }
720
721    #[test]
722    fn test_xml_declaration() {
723        let mut reader = XmlReader::from_str(r#"<?xml version="1.0" encoding="UTF-8"?><root/>"#);
724
725        match reader.next_event().unwrap() {
726            XmlEvent::XmlDecl { version, encoding, standalone } => {
727                assert_eq!(version, "1.0");
728                assert_eq!(encoding.as_deref(), Some("UTF-8"));
729                assert_eq!(standalone, None);
730            }
731            _ => panic!("expected XmlDecl"),
732        }
733    }
734
735    #[test]
736    fn test_comment() {
737        let mut reader = XmlReader::from_str("<!-- This is a comment --><root/>");
738
739        match reader.next_event().unwrap() {
740            XmlEvent::Comment(comment) => {
741                assert_eq!(comment, "This is a comment");
742            }
743            _ => panic!("expected Comment"),
744        }
745    }
746
747    #[test]
748    fn test_cdata() {
749        let mut reader = XmlReader::from_str("<root><![CDATA[<special>content</special>]]></root>");
750
751        reader.next_event().unwrap(); // StartElement
752
753        match reader.next_event().unwrap() {
754            XmlEvent::CData(data) => {
755                assert_eq!(data, "<special>content</special>");
756            }
757            _ => panic!("expected CData"),
758        }
759    }
760
761    #[test]
762    fn test_nested_elements() {
763        let xml = r#"<root><child1><grandchild/></child1><child2/></root>"#;
764        let mut reader = XmlReader::from_str(xml);
765
766        let events: Vec<_> = std::iter::from_fn(|| {
767            match reader.next_event() {
768                Ok(XmlEvent::Eof) => None,
769                Ok(event) => Some(event),
770                Err(_) => None,
771            }
772        }).collect();
773
774        assert_eq!(events.len(), 6);
775    }
776
777    #[test]
778    fn test_mismatched_tags() {
779        let mut reader = XmlReader::from_str("<root></wrong>");
780        reader.next_event().unwrap(); // StartElement
781        assert!(reader.next_event().is_err());
782    }
783
784    #[test]
785    fn test_unclosed_tag() {
786        let mut reader = XmlReader::from_str("<root>");
787        reader.next_event().unwrap(); // StartElement
788        assert!(reader.next_event().is_err());
789    }
790
791    #[test]
792    fn test_processing_instruction() {
793        let mut reader = XmlReader::from_str("<?target data?><root/>");
794
795        match reader.next_event().unwrap() {
796            XmlEvent::ProcessingInstruction { target, data } => {
797                assert_eq!(target, "target");
798                assert_eq!(data.as_deref(), Some("data"));
799            }
800            _ => panic!("expected ProcessingInstruction"),
801        }
802    }
803
804    #[test]
805    fn test_attribute_with_single_quotes() {
806        let mut reader = XmlReader::from_str("<root attr='value'/>");
807
808        match reader.next_event().unwrap() {
809            XmlEvent::EmptyElement { attributes, .. } => {
810                assert_eq!(attributes[0].value, "value");
811            }
812            _ => panic!("expected EmptyElement"),
813        }
814    }
815
816    #[test]
817    fn test_position_tracking() {
818        let xml = "<root>\n  <child/>\n</root>";
819        let mut reader = XmlReader::from_str(xml);
820
821        reader.next_event().unwrap(); // <root>
822        reader.next_event().unwrap(); // <child/>
823
824        let pos = reader.position();
825        assert!(pos.line >= 2);
826    }
827
828    #[test]
829    fn test_depth_tracking() {
830        let mut reader = XmlReader::from_str("<a><b><c></c></b></a>");
831
832        assert_eq!(reader.depth(), 0);
833        reader.next_event().unwrap(); // <a>
834        assert_eq!(reader.depth(), 1);
835        reader.next_event().unwrap(); // <b>
836        assert_eq!(reader.depth(), 2);
837        reader.next_event().unwrap(); // <c>
838        assert_eq!(reader.depth(), 3);
839        reader.next_event().unwrap(); // </c>
840        assert_eq!(reader.depth(), 2);
841    }
842
843    #[test]
844    fn test_invalid_escape_has_position() {
845        let mut reader = XmlReader::from_str("<a>&bogus;</a>");
846        reader.next_event().unwrap(); // <a>
847
848        let err = reader.next_event().unwrap_err();
849        let pos = err.position().expect("invalid escape error should carry a position");
850        assert_eq!(pos.line, 1);
851        assert_eq!(pos.column, 4);
852        assert_eq!(pos.offset, 3);
853    }
854
855    #[test]
856    fn test_invalid_escape_in_attribute_has_position() {
857        let mut reader = XmlReader::from_str(r#"<a b="&bogus;"/>"#);
858
859        let err = reader.next_event().unwrap_err();
860        let pos = err.position().expect("invalid escape error should carry a position");
861        assert_eq!(pos.line, 1);
862        assert_eq!(pos.column, 7);
863        assert_eq!(pos.offset, 6);
864    }
865
866    #[test]
867    fn test_multiline_attribute_value_position() {
868        let mut reader = XmlReader::from_str("<root attr=\"a\nb\">\n  <child/>\n</root>");
869
870        reader.next_event().unwrap(); // <root>
871        assert_eq!(reader.position().line, 2);
872        reader.next_event().unwrap(); // <child/>
873        assert_eq!(reader.position().line, 3);
874    }
875
876    #[test]
877    fn test_multibyte_column_tracking() {
878        let mut reader = XmlReader::from_str("<a>héllo</a>");
879
880        reader.next_event().unwrap(); // <a>
881        reader.next_event().unwrap(); // text
882        // "héllo" is 5 characters (6 bytes); columns count characters
883        assert_eq!(reader.position().column, 9);
884        assert_eq!(reader.position().offset, 9);
885    }
886
887    #[test]
888    fn test_doctype_with_quoted_gt() {
889        let mut reader = XmlReader::from_str(r#"<!DOCTYPE root SYSTEM "weird>name"><root/>"#);
890
891        match reader.next_event().unwrap() {
892            XmlEvent::EmptyElement { name, .. } => assert_eq!(name, "root"),
893            other => panic!("expected EmptyElement, got {:?}", other),
894        }
895        assert!(matches!(reader.next_event().unwrap(), XmlEvent::Eof));
896    }
897
898    #[test]
899    fn test_doctype_internal_subset() {
900        let mut reader =
901            XmlReader::from_str("<!DOCTYPE root [<!ELEMENT root (#PCDATA)>]><root/>");
902
903        match reader.next_event().unwrap() {
904            XmlEvent::EmptyElement { name, .. } => assert_eq!(name, "root"),
905            other => panic!("expected EmptyElement, got {:?}", other),
906        }
907        assert!(matches!(reader.next_event().unwrap(), XmlEvent::Eof));
908    }
909
910    #[test]
911    fn test_doctype_unterminated_quote() {
912        // Unterminated quoted literal inside DOCTYPE: must terminate cleanly
913        // (no hang, no panic). The reader skips to end of input and reports Eof.
914        let mut reader = XmlReader::from_str(r#"<!DOCTYPE root SYSTEM "oops"#);
915        assert!(matches!(reader.next_event().unwrap(), XmlEvent::Eof));
916    }
917}