Skip to main content

sup_xml_core/
scanner.rs

1//! Byte cursor over an XML source slice, with an entity-stream stack.
2//!
3//! # Stream-stack model
4//!
5//! Real XML parsers can't treat entity references as plain text substitutions
6//! because the spec says markup inside an entity replacement must be parsed in
7//! the context where the reference appears (XML 1.0 § 4.4.2).  This Scanner
8//! supports that by maintaining a stack of input sources:
9//!
10//! * The **bottom** stream is always the original document bytes.
11//! * Each general-entity reference (`&user_entity;`) **pushes** a new
12//!   stream containing the replacement text.
13//! * When a stream is exhausted, it's **popped** and parsing resumes in the
14//!   stream below.
15//!
16//! While only one stream exists, the Scanner behaves identically to a
17//! traditional single-buffer cursor.  Multi-stream behaviour kicks in once an
18//! entity is expanded.
19//!
20//! # Hot-path layout
21//!
22//! The vast majority of XML documents contain no entity references, so the
23//! Scanner caches the *currently active* `(ptr, len, pos)` view as plain
24//! fields on the struct.  Every cursor accessor — `cur_bytes`, `cur_pos`,
25//! `cur_tail`, `peek`, `advance`, … — is a direct field read with no
26//! branch and no heap indirection at all.  The [`InputStream`] frames Vec
27//! is only touched during `push_entity_stream` / `try_pop_entity_stream`,
28//! which is where the live `cur_*` view is recomputed from the new top of
29//! stack (the original source when the stack is empty, or the top entity
30//! frame otherwise).
31//!
32//! `cur_ptr` is a raw `*const u8` because the lifetime of what it points
33//! at depends on what's currently active: `'src` for the original source,
34//! or the lifetime of an entity frame's owned `Vec<u8>` when an entity is
35//! on top.  Rust's safe borrow checker can't express "either-of-these,"
36//! so we keep the active buffer description as raw pointer + length and
37//! recompute it whenever push/pop changes the top of stack.  Soundness
38//! relies on every accessor's slice/borrow having lifetime tied to
39//! `&self` (the frame can only be removed via `&mut self`, so an active
40//! shared borrow blocks pop).
41//!
42//! [`Parser`]: crate::parser
43//! [`XmlReader`]: crate::reader
44
45use std::borrow::Cow;
46use std::marker::PhantomData;
47
48use memchr::{memchr_iter, memrchr};
49
50use crate::charsets::{
51    ASCII_XML_NAME as ASCII_NAME, ASCII_XML_NAME_CHAR, NS,
52    is_name_char_4e, is_name_char_unicode, is_name_start_char, is_name_start_char_4e,
53};
54use crate::error::{ErrorCode, ErrorDomain, ErrorLevel, Result, XmlError};
55use crate::options::ParseOptions;
56
57/// An entity-replacement input stream pushed on top of the original source.
58///
59/// # Invariant
60///
61/// `bytes` must be valid UTF-8.  All slice-to-`&str` conversions on the
62/// Scanner rely on this; `from_utf8_unchecked` is used liberally.
63pub(crate) struct InputStream<'src> {
64    /// Replacement-text bytes.  Currently always `Cow::Owned` (entities are
65    /// built up as `String`s); the `Cow` type is kept for symmetry.
66    pub bytes: Cow<'src, [u8]>,
67    /// Saved cursor position to restore when this frame is popped — i.e.
68    /// the read position in the stream *below* this one at the moment this
69    /// frame was pushed.  Not used while this frame is the active top
70    /// (then the live position lives in `Scanner::cur_pos`).
71    pub saved_parent_pos: usize,
72    /// Name of the entity that pushed this frame, used for recursion
73    /// detection and error messages.
74    pub entity_name: String,
75    /// Element-stack depth at the moment this stream was pushed.  When the
76    /// stream is popped, the element depth must equal this — otherwise the
77    /// entity introduced unbalanced markup (XML 1.0 § 4.3.2).
78    pub depth_at_push: u32,
79    /// Absolute URL the bytes were originally loaded from, for relative
80    /// URI resolution of nested SYSTEM identifiers (XML 1.0 § 4.2.2 +
81    /// errata E18).  `None` for streams whose bytes don't come from an
82    /// external resource (e.g. an internal parameter entity).
83    pub base_uri: Option<String>,
84}
85
86/// Byte cursor with stream-stack semantics.  See module docs for the hot-
87/// path layout — `cur_ptr`/`cur_len`/`cur_pos` always describe the live
88/// view so accessors don't need to branch.
89///
90/// The `'opt` lifetime parameter is on the [`ParseOptions`] reference held
91/// inside `opts`.  The *outer* Scanner inside an `XmlReader` / `Parser` holds
92/// `Cow::Owned(opts)` and uses `'opt = 'static`.  Short-lived *inner*
93/// Scanners (constructed per start tag to parse attribute values) hold
94/// `Cow::Borrowed(parent_opts)` and inherit the borrow's lifetime — that's
95/// the path the hot loop runs through, and the borrow avoids one
96/// `ParseOptions` clone per tag.
97pub(crate) struct Scanner<'src, 'opt> {
98    /// Pointer to the start of the *currently active* buffer.
99    ///
100    /// # Invariant
101    ///
102    /// Points to either `self.src` (when `entity_streams` is empty) or to
103    /// the heap allocation owned by `entity_streams.last().unwrap().bytes`
104    /// (otherwise).  Both buffers outlive any accessor call that returns
105    /// a slice based on `cur_ptr` because the Scanner owns `entity_streams`
106    /// and holds `src: &'src [u8]`.  Updated by `push_entity_stream` and
107    /// `try_pop_entity_stream` whenever the active buffer changes.
108    cur_ptr: *const u8,
109    /// Length of the currently active buffer (`src.len()` or the top
110    /// entity frame's `bytes.len()`).
111    cur_len: usize,
112    /// Live read position within the active buffer.
113    cur_pos: usize,
114
115    /// Pointer to the start of the original source bytes.  Stored as a
116    /// raw pointer (instead of a `&'src [u8]` field) so we can support
117    /// **in-place mutation** of the source buffer during parsing — see
118    /// [`Self::compact_at`].  When `in_place_mut` is set, writes via that
119    /// pointer are sound because we hold exclusive access to the buffer
120    /// through it (no outstanding `&[u8]` reborrow lives across a write).
121    /// When `in_place_mut` is None (the common case) the pointer is
122    /// derived from a `&'src [u8]` and we only ever read through it.
123    src_ptr: *const u8,
124    /// Original source length.  Equal to `src_ptr`'s allocation length
125    /// initially; in in-place mode shrinks as `compact_at` removes
126    /// expanded-out bytes.
127    src_orig_len: usize,
128    /// When `Some(ptr)`, the scanner is in **in-place destructive mode**:
129    /// `compact_at` can write through this pointer to mutate the source
130    /// buffer.  `ptr` aliases `src_ptr` cast to `*mut` — it's a separate
131    /// field so non-in-place callers can't accidentally trip the write
132    /// path.
133    in_place_mut: Option<std::ptr::NonNull<u8>>,
134
135    /// Active entity-replacement frames, top of stack at the back.  Empty
136    /// in the common no-entity case.  Each frame remembers the parent
137    /// stream's cursor position at the time it was pushed (so pop can
138    /// restore it).
139    entity_streams: Vec<InputStream<'src>>,
140
141    /// Carries the `'src` lifetime through to accessors that produce
142    /// `Cow::Borrowed(&'src str)`, since `cur_ptr` is a raw pointer.
143    _marker: PhantomData<&'src [u8]>,
144
145    pub opts: Cow<'opt, ParseOptions>,
146}
147
148impl<'src, 'opt> Scanner<'src, 'opt> {
149    /// Construct a `Scanner` reading the original source bytes.
150    ///
151    /// `opts` is a `Cow` so the same Scanner type works for both the outer
152    /// (owned) case and the per-tag inner attribute scanner (borrowed),
153    /// avoiding a per-tag `ParseOptions` clone on the hot path.
154    ///
155    /// # Safety / invariant
156    ///
157    /// The caller must ensure `src` is valid UTF-8.
158    pub fn new(src: &'src [u8], opts: Cow<'opt, ParseOptions>) -> Self {
159        Self {
160            cur_ptr: src.as_ptr(),
161            cur_len: src.len(),
162            cur_pos: 0,
163            src_ptr: src.as_ptr(),
164            src_orig_len: src.len(),
165            in_place_mut: None,
166            entity_streams: Vec::new(),
167            _marker: PhantomData,
168            opts,
169        }
170    }
171
172    /// Construct a Scanner that may mutate its source buffer in place
173    /// (destructive parsing).  `compact_at` is enabled in this mode;
174    /// all other behaviour is identical to [`new`](Self::new).
175    ///
176    /// The caller transfers exclusive write access to `src` for the
177    /// scanner's lifetime — no other `&[u8]` or `&mut [u8]` view of
178    /// these bytes may exist concurrently.
179    pub fn new_in_place(src: &'src mut [u8], opts: Cow<'opt, ParseOptions>) -> Self {
180        let mut_ptr = src.as_mut_ptr();
181        let len = src.len();
182        Self {
183            cur_ptr: mut_ptr as *const u8,
184            cur_len: len,
185            cur_pos: 0,
186            src_ptr: mut_ptr as *const u8,
187            src_orig_len: len,
188            in_place_mut: std::ptr::NonNull::new(mut_ptr),
189            entity_streams: Vec::new(),
190            _marker: PhantomData,
191            opts,
192        }
193    }
194
195    /// Original source bytes as a slice.  Constructed on demand from
196    /// `(src_ptr, src_orig_len)` — short-lived; do not hold across any
197    /// `compact_at` call (which mutates the same memory).
198    #[inline]
199    fn src(&self) -> &[u8] {
200        // SAFETY: `src_ptr` and `src_orig_len` describe a live buffer the
201        // scanner holds for its `'src` lifetime (via PhantomData).  Returning
202        // a slice bounded by `&self` keeps it short enough that no caller
203        // can hold it across a mutating method (which requires `&mut self`).
204        unsafe { std::slice::from_raw_parts(self.src_ptr, self.src_orig_len) }
205    }
206
207    /// In-place destructive mutation: replace source bytes at
208    /// `start..end` with `new_bytes`, then memmove the tail left so the
209    /// rest of the buffer stays contiguous.  Decreases the scanner's
210    /// logical source length by `(end - start) - new_bytes.len()`.
211    ///
212    /// **Caller contract:**
213    /// - `new_bytes.len() <= end - start` (must shrink or stay equal)
214    /// - `end <= self.src_orig_len`
215    /// - The scanner's current `cur_pos` must already be **past** `end`
216    ///   (we don't fix up `cur_pos` here; the caller adjusts).  Typical
217    ///   usage: caller is about to emit an event whose source span ended
218    ///   at `end`; the cursor has just advanced past `end`; we mutate
219    ///   `start..end`; the cursor needs no adjustment since the bytes
220    ///   it now points at (past `end`) haven't moved relative to it.
221    ///
222    /// `true` if the scanner was constructed via
223    /// [`new_in_place`](Self::new_in_place) and can therefore service
224    /// [`compact_at`](Self::compact_at) calls.
225    #[inline]
226    pub fn is_in_place(&self) -> bool { self.in_place_mut.is_some() }
227
228    /// Write `byte` at `offset` in the source buffer.  In-place only.
229    /// Caller must guarantee `offset` is strictly behind `cur_pos` (we
230    /// only ever write to bytes the scanner has already read past).
231    ///
232    /// # Panics
233    ///
234    /// Panics if the scanner is not in in-place mode.
235    #[inline]
236    #[allow(dead_code)] // reserved for in-place decoding paths
237    pub fn write_byte_at(&mut self, offset: usize, byte: u8) {
238        let mut_base = self.in_place_mut.expect(
239            "Scanner::write_byte_at requires in-place mode",
240        );
241        debug_assert!(offset < self.src_orig_len);
242        // SAFETY: bounds verified.  Exclusive write access via in_place_mut.
243        unsafe { *mut_base.as_ptr().add(offset) = byte; }
244    }
245
246    /// Write `bytes` at `offset` in the source buffer.  In-place only.
247    /// Like [`compact_at`](Self::compact_at) but without the
248    /// "shrinks the logical length" semantics — used for byte-level
249    /// streaming compaction where the slow path writes its decoded
250    /// output directly into already-read source bytes.  Source bytes at
251    /// `offset..offset+bytes.len()` are overwritten.
252    ///
253    /// # Panics
254    ///
255    /// Panics if the scanner is not in in-place mode.
256    #[inline]
257    #[allow(dead_code)] // reserved for in-place decoding paths
258    pub fn write_bytes_at(&mut self, offset: usize, bytes: &[u8]) {
259        if bytes.is_empty() { return; }
260        let mut_base = self.in_place_mut.expect(
261            "Scanner::write_bytes_at requires in-place mode",
262        );
263        debug_assert!(offset + bytes.len() <= self.src_orig_len);
264        // SAFETY: bounds verified.  Exclusive write access via in_place_mut.
265        // `copy` (not `copy_nonoverlapping`) — the source-buffer regions
266        // may overlap when copying earlier-read literal bytes forward.
267        unsafe {
268            std::ptr::copy(
269                bytes.as_ptr(),
270                mut_base.as_ptr().add(offset),
271                bytes.len(),
272            );
273        }
274    }
275
276    /// Get a source-bytes slice with the scanner's `'src` lifetime.
277    /// Used by callers of `compact_at` to retrieve the post-write
278    /// payload as a `&'src [u8]` (the bytes are stable in the buffer;
279    /// the scanner never relocates them).
280    #[inline]
281    pub fn src_slice(&self, start: usize, end: usize) -> &'src [u8] {
282        &self.src_with_src_lifetime()[start..end]
283    }
284
285    /// # Panics
286    ///
287    /// Panics if the scanner is not in in-place mode (built via
288    /// [`new`](Self::new) rather than [`new_in_place`](Self::new_in_place)).
289    pub fn compact_at(&mut self, start: usize, end: usize, new_bytes: &[u8]) {
290        let mut_base = self.in_place_mut.expect(
291            "Scanner::compact_at requires in-place mode (constructed via new_in_place)",
292        );
293        debug_assert!(start <= end);
294        debug_assert!(end <= self.src_orig_len);
295        debug_assert!(new_bytes.len() <= end - start);
296        // SAFETY: bounds verified above.  We hold exclusive write access
297        // via in_place_mut (the scanner is the sole live `&mut` borrower
298        // of the buffer for its lifetime).  No re-read happens before we
299        // return.
300        unsafe {
301            let dst = mut_base.as_ptr().add(start);
302            std::ptr::copy_nonoverlapping(new_bytes.as_ptr(), dst, new_bytes.len());
303        }
304        // No tail memmove ("garbage tail" approach): bytes at
305        // start + new_bytes.len() .. end remain in the buffer but are
306        // never re-read by the scanner (cur_pos is already past `end`).
307        // Strictly simpler than pugixml-style memmove and semantically
308        // equivalent as long as nothing reads back into freed bytes.
309    }
310
311    /// Re-point the scanner's source view at a new buffer location.
312    ///
313    /// Used by the streaming reader wrapper after it has refilled,
314    /// compacted, or grown its rolling buffer — the operations that
315    /// invalidate the cached `cur_ptr`.  `new_cur_pos` is the
316    /// cursor's new position within the new buffer (typically `0`
317    /// right after a compaction that drops everything consumed so
318    /// far, or the same as the old `cur_pos` after a same-allocation
319    /// refill that only extended the tail).
320    ///
321    /// Also updates the `src_ptr` / `src_orig_len` view so accessors
322    /// that surface "original source" bytes (e.g. for error context)
323    /// see the current buffer rather than a stale allocation.
324    ///
325    /// # Safety
326    ///
327    /// The caller guarantees, for the lifetime of the scanner up to
328    /// the next call to [`rebind`](Self::rebind):
329    ///
330    /// 1. `ptr..ptr+len` is a single allocated buffer the caller
331    ///    holds exclusively (no concurrent writes).
332    /// 2. The bytes `ptr..ptr+len` are valid UTF-8 — the scanner's
333    ///    `from_utf8_unchecked` paths assume this.
334    /// 3. `new_cur_pos <= len`.
335    /// 4. `entity_streams.is_empty()` — the entity-stream stack must
336    ///    be unwound before rebinding, because pushed frames' cached
337    ///    `cur_ptr` would otherwise be silently clobbered.  Debug-
338    ///    asserted; release builds rely on the caller respecting
339    ///    "rebind only between events" (which is when the wrapper's
340    ///    pre-fill check runs anyway).
341    ///
342    /// The wrapper is the only intended caller; this stays
343    /// `pub(crate)` so external code can't reach it without first
344    /// going through the streaming reader's safer surface.
345    #[inline]
346    pub(crate) unsafe fn rebind(&mut self, ptr: *const u8, len: usize, new_cur_pos: usize) {
347        debug_assert!(new_cur_pos <= len, "rebind: cur_pos out of bounds");
348        debug_assert!(self.entity_streams.is_empty(),
349            "rebind: cannot rebind while an entity-replacement stream is active");
350        self.cur_ptr      = ptr;
351        self.cur_len      = len;
352        self.cur_pos      = new_cur_pos;
353        self.src_ptr      = ptr;
354        self.src_orig_len = len;
355    }
356
357    // ── stream-stack API ─────────────────────────────────────────────────────
358
359    /// Are we currently reading from an entity-expansion stream rather than
360    /// the original source?
361    #[inline]
362    pub fn in_entity(&self) -> bool {
363        !self.entity_streams.is_empty()
364    }
365
366    /// Total streams currently active (original source counts as 1).  Used
367    /// by the per-element boundary check to detect start/end-tag pairs that
368    /// straddle an entity boundary.
369    #[inline]
370    pub fn stream_depth(&self) -> usize {
371        1 + self.entity_streams.len()
372    }
373
374    /// Push a new entity-replacement stream onto the stack.
375    ///
376    /// `name` is the entity name (for recursion detection and error messages),
377    /// `bytes` is the replacement text (must be valid UTF-8), and
378    /// `element_depth` is the current XML element-nesting depth at the call
379    /// site (used to enforce balanced-markup at pop time).
380    ///
381    /// Returns an error if pushing this entity would create a reference cycle.
382    pub fn push_entity_stream(
383        &mut self,
384        name: String,
385        bytes: String,
386        element_depth: u32,
387        base_uri: Option<String>,
388    ) -> Result<()> {
389        // XML 1.0 § 4.1 WFC "No Recursion": reject if `name` is already on the
390        // stack.
391        if self.entity_streams.iter().any(|s| s.entity_name == name) {
392            return Err(self.err(format!(
393                "recursive entity reference: &{name}; — XML 1.0 WFC 'No Recursion' forbids \
394                 an entity from being expanded inside its own replacement text"
395            )));
396        }
397        // Save the current (parent) cursor position so pop can restore it.
398        let saved_parent_pos = self.cur_pos;
399        let bytes_vec: Vec<u8> = bytes.into_bytes();
400        // The Vec's heap allocation is stable — it isn't moved when the
401        // outer `entity_streams` Vec reallocates — so it's safe to take
402        // the pointer now and rely on it remaining valid until this frame
403        // is popped.
404        let new_ptr = bytes_vec.as_ptr();
405        let new_len = bytes_vec.len();
406        self.entity_streams.push(InputStream {
407            bytes:            Cow::Owned(bytes_vec),
408            saved_parent_pos,
409            entity_name:      name,
410            depth_at_push:    element_depth,
411            base_uri,
412        });
413        self.cur_ptr = new_ptr;
414        self.cur_len = new_len;
415        self.cur_pos = 0;
416        Ok(())
417    }
418
419    /// Base URI of the innermost entity-stream frame that has one —
420    /// i.e. the URL from which the bytes the parser is currently
421    /// reading were originally fetched.  Returns `None` when no
422    /// active frame has a base URI (e.g. parsing the original
423    /// document source, or expanding an internal parameter entity).
424    ///
425    /// Used by the parser to compute the right base URI for nested
426    /// SYSTEM identifiers in entity declarations (XML 1.0 § 4.2.2 +
427    /// errata E18): a `<!ENTITY % x SYSTEM "rel">` encountered
428    /// inside an external PE resolves `rel` against this URI, not
429    /// against the document URL.
430    pub fn current_base_uri(&self) -> Option<&str> {
431        self.entity_streams.iter().rev()
432            .find_map(|s| s.base_uri.as_deref())
433    }
434
435    /// Snapshot of the top entity frame's metadata, for boundary checks
436    /// before popping.  `None` when no entity is active.
437    pub fn top_entity_info(&self) -> Option<(&str, u32)> {
438        self.entity_streams.last().map(|s| (s.entity_name.as_str(), s.depth_at_push))
439    }
440
441    /// Number of pushed entity-replacement frames stacked on top of
442    /// the original source.  Zero = scanner is on the original
443    /// source.  Used by DTD-context callers to enforce XML 1.0 § 2.8
444    /// WFC: PE Between Declarations — the start and end of a markup
445    /// declaration must come from the same frame, which is what this
446    /// number lets the caller compare across the decl body.
447    #[inline]
448    pub fn entity_stream_depth(&self) -> usize {
449        self.entity_streams.len()
450    }
451
452    // No auto-pop after a byte is consumed: doing so would silently break
453    // callers that captured offsets like `start = cur_pos()` before reading
454    // into a different (now-top) stream.  Explicit pop happens in the slow
455    // paths (parse_char_data, parse_att_value, etc.) via
456    // [`try_pop_entity_stream`](Self::try_pop_entity_stream).
457
458    /// Bytes of the current input stream (the live active buffer).
459    #[inline]
460    pub fn cur_bytes(&self) -> &[u8] {
461        // SAFETY: `cur_ptr`/`cur_len` are maintained by push/pop to point
462        // at either `src` or the top entity frame's owned bytes; both live
463        // at least as long as `&self` (see struct-level invariant).
464        unsafe { std::slice::from_raw_parts(self.cur_ptr, self.cur_len) }
465    }
466
467    /// Bytes of the current stream from the current position to the end.
468    /// The common `memchr3(..., &scan.src[scan.pos..])` pattern becomes
469    /// `memchr3(..., scan.cur_tail())`.
470    #[inline]
471    pub fn cur_tail(&self) -> &[u8] {
472        // SAFETY: `cur_pos <= cur_len` is maintained by all advance/set
473        // methods; the resulting subslice is valid for the same reason as
474        // `cur_bytes`.
475        unsafe {
476            std::slice::from_raw_parts(
477                self.cur_ptr.add(self.cur_pos),
478                self.cur_len - self.cur_pos,
479            )
480        }
481    }
482
483    /// Current byte position within the current stream.
484    #[inline]
485    pub fn cur_pos(&self) -> usize { self.cur_pos }
486
487    /// Advance the current stream's position by `n` bytes (no auto-pop).
488    #[inline]
489    pub fn cur_advance_pos(&mut self, n: usize) { self.cur_pos += n; }
490
491    /// Set the current stream's position to `p`.
492    #[inline]
493    pub fn cur_set_pos(&mut self, p: usize) { self.cur_pos = p; }
494
495    /// Length of the current stream's byte buffer.
496    #[inline]
497    pub fn cur_len(&self) -> usize { self.cur_len }
498
499    /// If the current stream is the original borrowed source, return its
500    /// bytes with the longer `'src` lifetime; otherwise `None`.  Entity
501    /// frames carry `Cow::Owned` bytes that don't live `'src`, so they
502    /// can't be returned with that lifetime.
503    ///
504    /// Used by [`cur_str`](Self::cur_str) to decide whether the resulting
505    /// slice can be returned as `Cow::Borrowed` (lifetime `'src`) or must be
506    /// allocated to detach from the stream's lifetime.
507    #[inline]
508    pub fn current_borrowed_bytes(&self) -> Option<&'src [u8]> {
509        if self.entity_streams.is_empty() { Some(self.src_with_src_lifetime()) } else { None }
510    }
511
512    /// Original source bytes — never reallocated, never reassigned.
513    /// Use only when the caller already knows its byte offsets are
514    /// relative to the original source (e.g., a name scanned at a point
515    /// where `entity_streams` was empty).  Skips the `is_empty` branch
516    /// that [`current_borrowed_bytes`](Self::current_borrowed_bytes)
517    /// would do — meant for the per-event hot path in the reader.
518    #[inline]
519    pub fn src_bytes(&self) -> &'src [u8] { self.src_with_src_lifetime() }
520
521    /// Return the source bytes with the `'src` lifetime.  Used by the
522    /// hot-path accessors that produce `Cow::Borrowed(&'src str)` slices.
523    /// In in-place mode the bytes are still backed by the same heap
524    /// allocation (we never relocate the buffer) so the `'src` lifetime
525    /// remains sound; mutations performed via [`compact_at`] update the
526    /// bytes-at-a-position but never move them.
527    #[inline]
528    fn src_with_src_lifetime(&self) -> &'src [u8] {
529        // SAFETY: `src_ptr` points at a buffer the scanner holds for its
530        // `'src` lifetime (via PhantomData).  Even in in-place mode the
531        // pointer is stable for `'src` — only the bytes change, not the
532        // allocation address.
533        unsafe { std::slice::from_raw_parts(self.src_ptr, self.src_orig_len) }
534    }
535
536    /// `true` if the active stream is the original source — i.e. no
537    /// entity-replacement frame has been pushed.  Reader hot-paths
538    /// that hold the cursor in stack locals (`bytes = src_bytes()`,
539    /// `p = cur_pos()`) MUST gate themselves on this — when an
540    /// entity stream is active, `cur_pos` is relative to the entity
541    /// bytes, not the original source, and indexing `src_bytes()` by
542    /// `cur_pos()` reads from the wrong buffer.
543    #[inline]
544    pub fn on_original_source(&self) -> bool {
545        self.entity_streams.is_empty()
546    }
547
548    /// Slice of the original source between two byte offsets, as `&str`.
549    /// Callers must pass offsets obtained from [`cur_pos`](Self::cur_pos)
550    /// while on the original source; used to capture raw declaration
551    /// spans from the internal subset.
552    pub fn original_slice(&self, start: usize, end: usize) -> &str {
553        let s = start.min(self.src_orig_len);
554        let e = end.min(self.src_orig_len).max(s);
555        // SAFETY: src_ptr/src_orig_len describe the original buffer,
556        // which `transcode_and_validate` guarantees is valid UTF-8 and
557        // which outlives `&self`; [s, e] is bounds-clamped above.
558        unsafe {
559            let bytes = std::slice::from_raw_parts(self.src_ptr.add(s), e - s);
560            std::str::from_utf8_unchecked(bytes)
561        }
562    }
563
564    // ── cursor primitives ────────────────────────────────────────────────────
565
566    /// Peek the next byte in the **current (top) stream only**.  Returns
567    /// `None` if the top stream is exhausted, *even if* a stream below has
568    /// more bytes — syntactic reads (names, tags, entity references) must be
569    /// fully contained within a single stream per XML 1.0 § 4.3.2.  The
570    /// slow paths in `parse_char_data` / `parse_att_value` handle the
571    /// transition between streams explicitly via
572    /// [`try_pop_entity_stream`](Self::try_pop_entity_stream).
573    #[inline]
574    pub fn peek(&self) -> Option<u8> {
575        if self.cur_pos < self.cur_len {
576            // SAFETY: bounds checked above; pointer/length invariant as for
577            // `cur_bytes`.
578            Some(unsafe { *self.cur_ptr.add(self.cur_pos) })
579        } else {
580            None
581        }
582    }
583
584    /// Peek `off` bytes ahead in the **current** stream only.
585    /// Returns `None` if `off` is past the current stream's end — does NOT
586    /// look into streams below.
587    #[inline]
588    pub fn peek_at(&self, off: usize) -> Option<u8> {
589        let p = self.cur_pos.checked_add(off)?;
590        if p < self.cur_len {
591            // SAFETY: bounds checked above.
592            Some(unsafe { *self.cur_ptr.add(p) })
593        } else {
594            None
595        }
596    }
597
598    /// `true` iff we've consumed every byte in every stream.
599    #[inline]
600    pub fn is_eof(&self) -> bool {
601        // Top is exhausted *and* there's no stream below to fall back to.
602        self.cur_pos >= self.cur_len && self.entity_streams.is_empty()
603    }
604
605    /// Does the **current** stream's remaining bytes start with `pat`?
606    /// Does not look across streams.
607    #[inline]
608    pub fn starts_with(&self, pat: &[u8]) -> bool {
609        self.cur_tail().starts_with(pat)
610    }
611
612    pub fn advance(&mut self) -> Option<u8> {
613        // Top-only consume.  Returns `None` if the top stream is exhausted —
614        // explicit pops at the slow-path boundaries handle the transition to
615        // the stream below.  Crossing a stream boundary mid-token would
616        // silently violate XML 1.0 § 4.3.2 (parsed entities must be
617        // syntactically self-contained).
618        //
619        // We no longer track per-byte `(line, col)` here.  The bulk-content
620        // scans (`memchr3` / `cur_advance_pos(n)`) never updated those
621        // counters anyway, so they were already approximate.  Errors are
622        // located by byte offset in `src` and the file:line:col coordinate
623        // is computed lazily by `compute_line_col` in `err()` — accurate,
624        // zero cost on the hot path, only a one-time scan of the input
625        // when an error actually fires.
626        if self.cur_pos >= self.cur_len { return None; }
627        // SAFETY: bounds checked above.
628        let b = unsafe { *self.cur_ptr.add(self.cur_pos) };
629        self.cur_pos += 1;
630        Some(b)
631    }
632
633    pub fn skip_n(&mut self, n: usize) {
634        for _ in 0..n { self.advance(); }
635    }
636
637    pub fn skip_ws(&mut self) {
638        while matches!(self.peek(), Some(b' ' | b'\t' | b'\r' | b'\n')) {
639            self.advance();
640        }
641    }
642
643    /// Byte offset within the *original source* (`src`) at which the parser
644    /// is currently positioned.  When parsing inside an entity replacement
645    /// stream, this returns the position of the entity reference in the
646    /// original document — i.e. the user-visible location, not the offset
647    /// inside the synthetic replacement bytes.
648    pub fn src_offset(&self) -> usize {
649        if let Some(bottom) = self.entity_streams.first() {
650            bottom.saved_parent_pos
651        } else {
652            self.cur_pos
653        }
654    }
655
656    /// Construct a fatal-level parser error at the current source
657    /// position.  Use this for unrecoverable problems (truncated
658    /// input mid-construct, invalid UTF-8 in source bytes, resource
659    /// exhaustion).  `recovery_mode` mode does NOT downgrade
660    /// these — they always abort the parse.
661    pub fn err(&self, msg: impl Into<String>) -> XmlError {
662        self.err_with_level(ErrorLevel::Fatal, msg)
663    }
664
665    /// Construct a parser error at the current source position with
666    /// an explicit severity level.  Use `ErrorLevel::Error` for
667    /// well-formedness violations that recovery mode can repair
668    /// past (mismatched end tag, undefined entity, bare `&` in text,
669    /// etc.); use `ErrorLevel::Fatal` for things recovery cannot
670    /// help with.  See `ParseOptions::recovery_mode` docs for
671    /// the policy.
672    pub fn err_with_level(&self, level: ErrorLevel, msg: impl Into<String>) -> XmlError {
673        let offset = self.src_offset();
674        let (line, col) = compute_line_col(self.src(), offset);
675        // Attribute the error to the document's source URL when known (set
676        // by the file/URL parse entry points), so consumers reporting the
677        // error — lxml's `XMLSyntaxError.filename` — see the real path.
678        // In-memory parses leave `base_url` unset and keep `<input>`.
679        let source = self.opts.base_url.as_deref().unwrap_or("<input>");
680        XmlError::new(ErrorDomain::Parser, level, msg)
681            .at(source, line, col, offset as u64)
682    }
683
684    pub fn expect(&mut self, b: u8) -> Result<()> {
685        match self.advance() {
686            Some(c) if c == b => Ok(()),
687            Some(c) => Err(self.err(format!("expected '{}', got '{}'", b as char, c as char))),
688            None    => Err(self.err(format!("expected '{}', got EOF", b as char))),
689        }
690    }
691
692    pub fn expect_str(&mut self, pat: &[u8]) -> Result<()> {
693        for &b in pat { self.expect(b)?; }
694        Ok(())
695    }
696
697    pub fn expect_ws(&mut self) -> Result<()> {
698        if !matches!(self.peek(), Some(b' ' | b'\t' | b'\r' | b'\n')) {
699            return Err(self.err("expected whitespace"));
700        }
701        Ok(())
702    }
703
704    /// Slice the current stream from `start` to `end` as a string.
705    ///
706    /// Returns `Cow::Borrowed` (lifetime `'src`) when the current stream is
707    /// the original borrowed source — the common case, zero-copy.
708    /// Returns `Cow::Owned` when the current stream is an entity expansion
709    /// (the slice would otherwise dangle when the stream is popped).
710    pub fn cur_str(&self, start: usize, end: usize) -> Cow<'src, str> {
711        if self.entity_streams.is_empty() {
712            // Hot path: bottom of stack, borrowed from `'src`.
713            // SAFETY: Scanner invariant — bytes are valid UTF-8; cursor
714            // operations respect UTF-8 character boundaries.
715            let s = unsafe { std::str::from_utf8_unchecked(&self.src_with_src_lifetime()[start..end]) };
716            Cow::Borrowed(s)
717        } else {
718            // SAFETY: cur_ptr/cur_len point at the top entity frame's bytes.
719            let bytes = unsafe { std::slice::from_raw_parts(self.cur_ptr, self.cur_len) };
720            let s = unsafe { std::str::from_utf8_unchecked(&bytes[start..end]) };
721            Cow::Owned(s.to_string())
722        }
723    }
724
725    /// Byte-output sibling of [`cur_str`](Self::cur_str).  Returns the same
726    /// slice typed as `Cow<'src, [u8]>` — `Cow::Borrowed` when reading from
727    /// the original source (zero-copy), `Cow::Owned` when reading from an
728    /// entity expansion stream (the slice would dangle after the stream is
729    /// popped).
730    ///
731    /// This is the primitive used by `XmlBytesReader`.  `cur_str` is now
732    /// implemented in terms of this plus a no-op `from_utf8_unchecked`
733    /// cast — the bytes are valid UTF-8 by the Scanner's construction-time
734    /// invariant.
735    pub fn cur_slice(&self, start: usize, end: usize) -> Cow<'src, [u8]> {
736        if self.entity_streams.is_empty() {
737            Cow::Borrowed(&self.src_with_src_lifetime()[start..end])
738        } else {
739            // SAFETY: cur_ptr/cur_len point at the top entity frame's bytes.
740            let bytes = unsafe { std::slice::from_raw_parts(self.cur_ptr, self.cur_len) };
741            Cow::Owned(bytes[start..end].to_vec())
742        }
743    }
744
745    /// Append the slice `start..end` of the current stream to `buf`.  Works
746    /// for both borrowed-source and entity-expansion streams, and avoids the
747    /// allocation that `cur_str` would do in the entity case.
748    #[allow(dead_code)] // mirror of `append_cur_bytes`, kept for the `String` payload path
749    pub fn append_cur_str(&self, start: usize, end: usize, buf: &mut String) {
750        // SAFETY: Scanner invariant — bytes are valid UTF-8.
751        let bytes = self.cur_bytes();
752        let s = unsafe { std::str::from_utf8_unchecked(&bytes[start..end]) };
753        buf.push_str(s);
754    }
755
756    /// Byte-output sibling of [`append_cur_str`](Self::append_cur_str).
757    /// Appends the slice `start..end` of the current stream to `buf` as
758    /// raw bytes.  Public for symmetry with `append_cur_str`; the
759    /// in-tree callers all use the normalizing variants
760    /// (`append_text_segment`, `append_attr_segment` in
761    /// `xml_bytes_reader`) so they can apply §2.11 / §3.3.3 on the fly.
762    #[allow(dead_code)]
763    pub fn append_cur_bytes(&self, start: usize, end: usize, buf: &mut Vec<u8>) {
764        let bytes = self.cur_bytes();
765        buf.extend_from_slice(&bytes[start..end]);
766    }
767
768    /// If we're currently reading from an entity-expansion stream, pop it.
769    /// Returns `true` if a pop happened (caller should continue reading from
770    /// the now-current stream), `false` if we're at the bottom of the stack
771    /// (original source) — meaning real EOF for the document.
772    pub fn try_pop_entity_stream(&mut self) -> bool {
773        let Some(popped) = self.entity_streams.pop() else { return false; };
774        // Restore the cursor to where the parent stream left off.
775        self.cur_pos = popped.saved_parent_pos;
776        if let Some(new_top) = self.entity_streams.last() {
777            self.cur_ptr = new_top.bytes.as_ptr();
778            self.cur_len = new_top.bytes.len();
779        } else {
780            self.cur_ptr = self.src_ptr;
781            self.cur_len = self.src_orig_len;
782        }
783        true
784    }
785
786    // ── name scanning ────────────────────────────────────────────────────────
787
788    /// Decode the next Unicode scalar value at the cursor without advancing.
789    /// Reads from the **current** stream only (names cannot span streams per
790    /// XML well-formedness).
791    #[allow(dead_code)] // reserved for char-aware lookahead in name validators
792    pub fn peek_char(&self) -> Option<(char, usize)> {
793        if self.cur_pos >= self.cur_len { return None; }
794        let bytes = self.cur_tail();
795        let b = bytes[0];
796        if b < 0x80 { return Some((b as char, 1)); }
797        let len = utf8_seq_len(b)?;
798        let slice = bytes.get(..len)?;
799        let s = std::str::from_utf8(slice).ok()?;
800        s.chars().next().map(|c| (c, len))
801    }
802
803    /// Scan an XML Name within the current stream and return its
804    /// `(start, end)` byte offsets in that stream.
805    ///
806    /// # Performance
807    ///
808    /// The hot inner loop reads bytes directly through `cur_ptr` and the
809    /// ASCII `NS|NC` table — bypassing `peek`/`advance` so we don't pay the
810    /// per-byte newline check + `col`/`line` bump that those do for each
811    /// step.  Names can't contain newlines (XML 1.0 § 2.3 NameChar), so
812    /// `col` is bumped exactly once at the end by the total byte count.
813    /// LLVM autovectorizes the ASCII run.
814    pub fn scan_name_raw(&mut self) -> Result<(usize, usize)> {
815        let lax = self.opts.skip_name_validation;
816        let start = self.cur_pos;
817        // SAFETY: cur_ptr/cur_len describe the live active buffer (see
818        // struct-level invariants); the slice lives at least as long as
819        // `&mut self` because `entity_streams` can only shrink under
820        // `&mut self`.
821        let bytes: &[u8] = unsafe { std::slice::from_raw_parts(self.cur_ptr, self.cur_len) };
822        let end = self.cur_len;
823        let mut i = start;
824
825        // ── NameStartChar ───────────────────────────────────────────────────
826        if i >= end {
827            return Err(self.err("expected XML name, got EOF"));
828        }
829        let b0 = bytes[i];
830        if b0 < 0x80 {
831            if !lax && ASCII_NAME[b0 as usize] & NS == 0 {
832                return Err(self.err(format!("invalid name-start character {:?}", b0 as char))
833                    .with_code(crate::error::ErrorCode::NameRequired));
834            }
835            i += 1;
836        } else {
837            let len = match utf8_seq_len(b0) {
838                Some(l) if i + l <= end => l,
839                _ => return Err(self.err("invalid UTF-8 in name")),
840            };
841            let slice = &bytes[i..i + len];
842            let c = std::str::from_utf8(slice).ok()
843                .and_then(|s| s.chars().next())
844                .ok_or_else(|| self.err("invalid UTF-8 in name"))?;
845            if !lax && !self.name_start_non_ascii(c) {
846                return Err(self.err(format!("invalid name-start character {:?}", c))
847                    .with_code(crate::error::ErrorCode::NameRequired));
848            }
849            i += len;
850        }
851
852        // ── NameChar* (rest of the name) ────────────────────────────────────
853        //
854        // The ASCII NameChar table `ASCII_XML_NAME_CHAR` is `0` for every
855        // byte that isn't an ASCII name char *or* has the high bit set —
856        // a single lookup-and-compare doubles as the "is ASCII name char?"
857        // *and* the "stop at non-ASCII" test, eliminating the branch on
858        // `b < 0x80` per iteration.  LLVM autovectorizes the loop into
859        // SIMD compares + pshufb on x86_64 / NEON on aarch64, so a 16-byte
860        // name is found in ~2 SIMD steps instead of 16 scalar iterations.
861        //
862        // We tried an 8-byte u64 SWAR fast path for lax mode (`skip_name_
863        // validation = true`).  Result: 4-5% regression on swiss_prot.
864        // The SWAR per-call overhead (~12 cycles for chunk load + four
865        // SWAR comparisons + trailing_zeros + branch) breaks even at
866        // ~6-byte names and only wins for ~10+ bytes.  XML attribute
867        // names average 3-6 bytes; the per-attribute cost is where the
868        // regression came from.  See git history for the SWAR
869        // implementation — kept here as a comment so the next person
870        // tempted to try it knows what they're getting into.
871        let _ = lax;  // kept in scope; SWAR path was its only consumer.
872        // SAFETY: the `i < end` guard inside the while runs *before* the
873        // body, and `end == bytes.len()` by construction earlier in this
874        // function (`bytes = slice::from_raw_parts(self.cur_ptr, self.cur_len)`
875        // and `end = self.cur_len`).  So `i < end` ⇒ `i < bytes.len()`,
876        // which is exactly the precondition `get_unchecked` requires.
877        // We replace the safe `bytes[i]` to drop the per-iteration bounds
878        // check that LLVM doesn't always elide here; this loop is 14.6%
879        // of swiss_prot parse time per profiling.  See CONTRIBUTING.md
880        // § "Unsafe policy".
881        while i < end && ASCII_XML_NAME_CHAR[unsafe { *bytes.get_unchecked(i) } as usize] != 0 {
882            i += 1;
883        }
884
885        // Fell out of the ASCII fast loop — either real end, or a
886        // non-ASCII byte that the lookup table conservatively zeroed.
887        // For non-ASCII, decode and consult the Unicode NameChar tables.
888        while i < end {
889            // SAFETY: while-guard `i < end` and `end == bytes.len()`
890            // (same argument as the fast loop above).
891            let b = unsafe { *bytes.get_unchecked(i) };
892            if b < 0x80 { break; }  // genuine ASCII non-namechar — stop.
893            let len = match utf8_seq_len(b) {
894                Some(l) if i + l <= end => l,
895                _ => break,
896            };
897            if lax {
898                i += len;
899                continue;
900            }
901            let slice = &bytes[i..i + len];
902            let c_opt = std::str::from_utf8(slice).ok().and_then(|s| s.chars().next());
903            match c_opt {
904                Some(c) if self.name_char_non_ascii(c) => i += len,
905                _ => break,
906            }
907            // After a non-ASCII name char, we may follow with more ASCII —
908            // re-enter the fast loop.
909            // SAFETY: same argument as the primary fast loop above.
910            while i < end && ASCII_XML_NAME_CHAR[unsafe { *bytes.get_unchecked(i) } as usize] != 0 {
911                i += 1;
912            }
913        }
914
915        self.cur_pos = i;
916        Ok((start, i))
917    }
918
919    /// Scan a name and return it.  `Cow::Borrowed` when the current stream is
920    /// the original source (zero-copy, lifetime `'src`); `Cow::Owned` when
921    /// the name comes from an entity expansion stream.
922    pub fn scan_name(&mut self) -> Result<Cow<'src, str>> {
923        let (s, e) = self.scan_name_raw()?;
924        Ok(self.cur_str(s, e))
925    }
926
927    /// Scan an XML 1.0 § 2.3 [7] Nmtoken — `(NameChar)+`.  Differs from
928    /// `scan_name_raw` only in skipping the NameStartChar restriction
929    /// on the first byte: an Nmtoken may start with a digit, `-`, `.`,
930    /// or any other NameChar.  Used for ATTLIST enumerations, where
931    /// the values are Nmtokens (`(0|35a|...)`), not Names.
932    pub fn scan_nmtoken(&mut self) -> Result<Cow<'src, str>> {
933        let lax = self.opts.skip_name_validation;
934        let start = self.cur_pos;
935        // SAFETY: cur_ptr/cur_len describe the live active buffer (see
936        // struct-level invariants).
937        let bytes: &[u8] = unsafe { std::slice::from_raw_parts(self.cur_ptr, self.cur_len) };
938        let end = self.cur_len;
939        let mut i = start;
940
941        // ── NameChar+ (no NameStart distinction) ────────────────────
942        // Match scan_name_raw's NameChar loop verbatim, minus the
943        // leading NameStartChar check.
944        while i < end && ASCII_XML_NAME_CHAR[unsafe { *bytes.get_unchecked(i) } as usize] != 0 {
945            i += 1;
946        }
947        while i < end {
948            let b = unsafe { *bytes.get_unchecked(i) };
949            if b < 0x80 { break; }
950            let len = match utf8_seq_len(b) {
951                Some(l) if i + l <= end => l,
952                _ => break,
953            };
954            if lax {
955                i += len;
956                continue;
957            }
958            let slice = &bytes[i..i + len];
959            let c_opt = std::str::from_utf8(slice).ok().and_then(|s| s.chars().next());
960            match c_opt {
961                Some(c) if self.name_char_non_ascii(c) => i += len,
962                _ => break,
963            }
964            while i < end && ASCII_XML_NAME_CHAR[unsafe { *bytes.get_unchecked(i) } as usize] != 0 {
965                i += 1;
966            }
967        }
968
969        if i == start {
970            return Err(self.err("expected Nmtoken (one or more NameChar)"));
971        }
972        self.cur_pos = i;
973        Ok(self.cur_str(start, i))
974    }
975
976    /// Byte-output sibling of [`scan_name`](Self::scan_name).  Returns the
977    /// scanned name as a `Cow<'src, [u8]>` — same memory, no `from_utf8`
978    /// cast.  Used by `XmlBytesReader`.
979    pub fn scan_name_bytes(&mut self) -> Result<Cow<'src, [u8]>> {
980        let (s, e) = self.scan_name_raw()?;
981        Ok(self.cur_slice(s, e))
982    }
983
984    pub fn skip_name(&mut self) -> Result<()> {
985        self.scan_name_raw().map(|_| ())
986    }
987
988    #[inline]
989    fn name_start_non_ascii(&self, c: char) -> bool {
990        if self.opts.xml10_fourth_edition { is_name_start_char_4e(c) } else { is_name_start_char(c) }
991    }
992
993    #[inline]
994    fn name_char_non_ascii(&self, c: char) -> bool {
995        if self.opts.xml10_fourth_edition { is_name_char_4e(c) } else { is_name_char_unicode(c) }
996    }
997}
998
999// ── error-location helper ────────────────────────────────────────────────────
1000
1001/// Compute 1-based `(line, col)` for the given byte offset in `src`.
1002///
1003/// Called only when an error is being constructed.  Uses `memchr_iter` to
1004/// count newlines and `memrchr` to find the start of the offending line, so
1005/// the scan is SIMD-accelerated even though it runs O(n).  Total cost is
1006/// roughly equivalent to one extra pass over the prefix — fine in an error
1007/// path that fires at most once per parse.
1008///
1009/// The hot parsing path no longer updates a per-byte `(line, col)`; instead
1010/// the parser tracks a single byte cursor (the offset in `src`) and we
1011/// translate to the human-friendly coordinate only when constructing the
1012/// error.
1013pub fn compute_line_col(src: &[u8], offset: usize) -> (u32, u32) {
1014    let end = offset.min(src.len());
1015    let prefix = &src[..end];
1016    let line = memchr_iter(b'\n', prefix).count() as u32 + 1;
1017    let col_start = match memrchr(b'\n', prefix) {
1018        Some(n) => n + 1, // first byte after the newline
1019        None => 0,
1020    };
1021    let col = (end - col_start) as u32 + 1;
1022    (line, col)
1023}
1024
1025// ── document-level character validation ──────────────────────────────────────
1026
1027/// Validate every byte in `bytes` against the XML 1.0 § 2.2 Char production.
1028///
1029/// `bytes` must already be valid UTF-8.  This scan rejects:
1030/// * ASCII control characters 0x00–0x08, 0x0B, 0x0C, 0x0E–0x1F
1031///   (everything below 0x20 except TAB, LF, CR)
1032/// * U+FFFE and U+FFFF (encoded as the 3-byte UTF-8 sequences `EF BF BE`
1033///   and `EF BF BF`)
1034///
1035/// UTF-8 itself already rejects surrogate code points (U+D800–U+DFFF) at
1036/// decode time, so we don't need a separate check for those.
1037///
1038/// # Hot path
1039///
1040/// 99% of XML bytes are >= 0x20 and != 0xEF — printable ASCII or non-prefix
1041/// UTF-8 continuation bytes.  The SWAR loop reduces that case to one 8-byte
1042/// `wrapping_sub`/AND/OR pipeline per chunk (no branches per byte), falling
1043/// back to byte-level inspection only on chunks that touch a control byte
1044/// or a `0xEF` prefix.  Called inline at content boundaries (text, attr
1045/// values, comments, CDATA, PI bodies) so the bytes are cache-warm.
1046pub fn validate_xml_chars(bytes: &[u8]) -> Result<()> {
1047    // SWAR fast path: 8 bytes per iter, branchless detection of:
1048    //   - any byte < 0x20 *and* ≠ TAB/LF/CR (forbidden control)
1049    //   - any byte == 0xEF (potential 0xEF 0xBF 0xBE/BF — U+FFFE/FFFF)
1050    //
1051    // All reduced to the classic "has-zero-byte" trick:
1052    //   hasZero(x) = (x - 0x0101…) & !x & 0x8080… != 0
1053    //
1054    // Filtering TAB/LF/CR out of the SWAR mask is the win over the naïve
1055    // "any byte < 0x20" check: documents like swiss_prot have a newline on
1056    // every line (~1–2% of bytes), so without this filter ~16% of 8-byte
1057    // chunks would unnecessarily drop to the byte-level slow path.
1058    //
1059    // We *don't* further refine `0xEF` here — a lone `0xEF` is the legitimate
1060    // UTF-8 lead byte for codepoints U+F000–U+FFFF (CJK / PUA), so chunks
1061    // containing those legitimately drop to the byte-level slow path, which
1062    // is the right behaviour for any docs that actually contain such
1063    // codepoints (rare in practice).  An earlier attempt to add an
1064    // `0xEF`→`0xBF` SWAR follower-check did speed CJK-heavy docs by ~10%
1065    // but added enough per-chunk overhead to regress small ASCII fixtures
1066    // by 15–25%, so the trade-off wasn't worth it.
1067    const LSB: u64 = 0x0101_0101_0101_0101;
1068    const MSB: u64 = 0x8080_8080_8080_8080;
1069    const HI3: u64 = 0xE0E0_E0E0_E0E0_E0E0;
1070    const LO5: u64 = 0x1F1F_1F1F_1F1F_1F1F;
1071    const TAB: u64 = 0x0909_0909_0909_0909;
1072    const LF:  u64 = 0x0A0A_0A0A_0A0A_0A0A;
1073    const CR:  u64 = 0x0D0D_0D0D_0D0D_0D0D;
1074    const EF:  u64 = 0xEFEF_EFEF_EFEF_EFEF;
1075
1076    #[inline(always)]
1077    fn has_zero(x: u64) -> u64 {
1078        x.wrapping_sub(LSB) & !x & MSB
1079    }
1080
1081    let mut i = 0;
1082    while i + 8 <= bytes.len() {
1083        let chunk = u64::from_le_bytes(bytes[i..i + 8].try_into().unwrap());
1084
1085        let any_lt20 = has_zero(chunk & HI3);
1086        let any_ef   = has_zero(chunk ^ EF);
1087
1088        // Cheap first-line filter: most chunks are printable ASCII with
1089        // no `< 0x20` and no `0xEF`, and skip the more expensive TAB/LF/CR
1090        // discrimination entirely.
1091        if (any_lt20 | any_ef) == 0 {
1092            i += 8;
1093            continue;
1094        }
1095
1096        // Chunk has either a low byte or a `0xEF`.  For the low-byte case
1097        // we still want to *exclude* legal TAB/LF/CR before falling into
1098        // the byte-level scalar path — otherwise every chunk containing a
1099        // newline (i.e. most chunks in indented XML) pays scalar cost.
1100        let bad_ctrl = if any_lt20 != 0 {
1101            let low5 = chunk & LO5;
1102            let allowed = has_zero(low5 ^ TAB)
1103                        | has_zero(low5 ^ LF)
1104                        | has_zero(low5 ^ CR);
1105            any_lt20 & !allowed
1106        } else {
1107            0
1108        };
1109
1110        if (bad_ctrl | any_ef) != 0 {
1111            validate_xml_chars_slow(bytes, i, i + 8)?;
1112        }
1113        i += 8;
1114    }
1115    validate_xml_chars_slow(bytes, i, bytes.len())
1116}
1117
1118/// Scalar validation for a `[from..to]` window — used by both the SWAR
1119/// fallback (when a chunk fails the SWAR test) and the tail (last < 8 bytes).
1120#[cold]
1121#[inline(never)]
1122fn validate_xml_chars_slow(bytes: &[u8], from: usize, to: usize) -> Result<()> {
1123    let mut i = from;
1124    while i < to {
1125        let b = bytes[i];
1126        if b < 0x20 {
1127            if !matches!(b, 0x09 | 0x0A | 0x0D) {
1128                return Err(invalid_char_at(bytes, i, format!("U+{b:04X}")));
1129            }
1130        } else if b == 0xEF && i + 2 < bytes.len() && bytes[i + 1] == 0xBF {
1131            match bytes[i + 2] {
1132                0xBE => return Err(invalid_char_at(bytes, i, "U+FFFE".into())),
1133                0xBF => return Err(invalid_char_at(bytes, i, "U+FFFF".into())),
1134                _ => {}
1135            }
1136        }
1137        i += 1;
1138    }
1139    Ok(())
1140}
1141
1142/// Build the structured "invalid XML character" error with line/col
1143/// derived from the same byte index that goes into the message.
1144fn invalid_char_at(bytes: &[u8], i: usize, codepoint: String) -> XmlError {
1145    let (line, col) = compute_line_col(bytes, i);
1146    XmlError::new(
1147        ErrorDomain::Parser,
1148        ErrorLevel::Fatal,
1149        format!("invalid XML character {codepoint} at byte {i} (XML 1.0 § 2.2)"),
1150    )
1151    .with_code(ErrorCode::InvalidChar)
1152    .at("<input>", line, col, i as u64)
1153}
1154
1155// ── character classification ──────────────────────────────────────────────────
1156
1157/// XML 1.0 § 2.2 legal character set.
1158pub fn is_xml_char(c: char) -> bool {
1159    matches!(c as u32,
1160        0x09 | 0x0A | 0x0D |
1161        0x20..=0xD7FF |
1162        0xE000..=0xFFFD |
1163        0x10000..=0x10FFFF
1164    )
1165}
1166
1167/// XML 1.1 § 2.2 legal character set — strictly broader than
1168/// [`is_xml_char`].  XML 1.1 added the C0 control range (`#x1-#x1F`,
1169/// excluding `#x0`) and the C1 controls (`#x7F-#x9F`) to the Char
1170/// production, but most of those are *restricted chars* per § 2.11
1171/// that MUST appear only as character references (never as raw
1172/// bytes).  This function returns true for any code point a `&#...;`
1173/// reference may legally expand to under XML 1.1; the raw-byte
1174/// rejection still happens in [`validate_xml_chars`].
1175pub fn is_xml_11_char(c: char) -> bool {
1176    matches!(c as u32,
1177        0x01..=0xD7FF |
1178        0xE000..=0xFFFD |
1179        0x10000..=0x10FFFF
1180    )
1181}
1182
1183/// XML 1.0 § 2.3 production [13]: PubidChar.
1184pub fn is_pubid_char(b: u8) -> bool {
1185    matches!(b,
1186        0x20 | 0x0D | 0x0A |
1187        b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' |
1188        b'-' | b'\'' | b'(' | b')' | b'+' | b',' | b'.' | b'/' |
1189        b':' | b'=' | b'?' | b';' | b'!' | b'*' | b'#' | b'@' | b'$' | b'_' | b'%'
1190    )
1191}
1192
1193/// Length of the UTF-8 sequence starting with `lead`, or `None` for invalid lead.
1194pub fn utf8_seq_len(lead: u8) -> Option<usize> {
1195    match lead {
1196        0x00..=0x7F => Some(1),
1197        0xC0..=0xDF => Some(2),
1198        0xE0..=0xEF => Some(3),
1199        0xF0..=0xF7 => Some(4),
1200        _ => None,
1201    }
1202}
1203
1204#[cfg(test)]
1205mod tests {
1206    use super::*;
1207    use crate::options::ParseOptions;
1208
1209    fn fresh<'src>(src: &'src [u8]) -> Scanner<'src, 'static> {
1210        Scanner::new(src, Cow::Owned(ParseOptions::default()))
1211    }
1212
1213    #[test]
1214    fn current_base_uri_none_on_original_source() {
1215        let s = fresh(b"<r/>");
1216        assert_eq!(s.current_base_uri(), None);
1217    }
1218
1219    #[test]
1220    fn current_base_uri_returns_top_frames_uri() {
1221        let mut s = fresh(b"<r/>");
1222        s.push_entity_stream(
1223            "outer".to_string(),
1224            "abc".to_string(),
1225            0,
1226            Some("file:///docs/outer.ent".to_string()),
1227        ).unwrap();
1228        assert_eq!(s.current_base_uri(), Some("file:///docs/outer.ent"));
1229    }
1230
1231    #[test]
1232    fn current_base_uri_falls_through_a_none_frame() {
1233        // An internal PE (None base_uri) shadowed over an external
1234        // PE (Some) must still expose the external one's URL —
1235        // E18 expects "innermost frame with a base_uri", not
1236        // "innermost frame, even if its base_uri is None".
1237        let mut s = fresh(b"<r/>");
1238        s.push_entity_stream(
1239            "outer".to_string(),
1240            "abc".to_string(),
1241            0,
1242            Some("file:///docs/outer.ent".to_string()),
1243        ).unwrap();
1244        s.push_entity_stream(
1245            "inner".to_string(),
1246            "def".to_string(),
1247            0,
1248            None,
1249        ).unwrap();
1250        assert_eq!(s.current_base_uri(), Some("file:///docs/outer.ent"));
1251    }
1252
1253    #[test]
1254    fn current_base_uri_uses_innermost_external_frame() {
1255        let mut s = fresh(b"<r/>");
1256        s.push_entity_stream(
1257            "outer".to_string(),
1258            "abc".to_string(),
1259            0,
1260            Some("file:///outer.ent".to_string()),
1261        ).unwrap();
1262        s.push_entity_stream(
1263            "inner".to_string(),
1264            "def".to_string(),
1265            0,
1266            Some("file:///docs/sub/inner.ent".to_string()),
1267        ).unwrap();
1268        // Innermost wins — that's where the bytes we're currently
1269        // reading from came from.
1270        assert_eq!(s.current_base_uri(), Some("file:///docs/sub/inner.ent"));
1271    }
1272
1273    /// `rebind` swaps the scanner's source view to a fresh buffer
1274    /// with the cursor pointing at a new position.  Used by the
1275    /// streaming reader after refill / compaction / growth.
1276    #[test]
1277    fn rebind_swings_cursor_to_new_buffer() {
1278        let a = b"<a/>";
1279        let b = b"<bbbb/>";
1280        let mut s = fresh(a);
1281        // Advance partway into `a` so we can verify cur_pos is
1282        // overwritten (not preserved) by rebind.
1283        s.advance(); s.advance();
1284        assert_eq!(s.cur_pos(), 2);
1285        // Swing onto a fresh buffer, asking for cur_pos at the
1286        // second byte of `b`.
1287        // SAFETY: `b` outlives the assertions below, is valid UTF-8,
1288        // and no entity stream is active.
1289        unsafe { s.rebind(b.as_ptr(), b.len(), 1); }
1290        assert_eq!(s.cur_pos(), 1);
1291        assert_eq!(s.cur_len(), b.len());
1292        // The next byte we read should be `b[1]`, i.e. `b'b'`.
1293        assert_eq!(s.peek(), Some(b'b'));
1294    }
1295
1296    #[test]
1297    fn rebind_lets_subsequent_advances_walk_new_buffer() {
1298        let a = b"xxxx";
1299        let b = b"yyy";
1300        let mut s = fresh(a);
1301        // Drain `a` entirely.
1302        while s.advance().is_some() {}
1303        // SAFETY: `b` is valid UTF-8, outlives the loop, no entity
1304        // stream active.
1305        unsafe { s.rebind(b.as_ptr(), b.len(), 0); }
1306        let mut bytes = Vec::new();
1307        while let Some(byte) = s.advance() { bytes.push(byte); }
1308        assert_eq!(&bytes[..], b"yyy");
1309    }
1310}