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}