#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum Kind {
Text,
Comment,
Pi,
Doctype,
Tag,
}
const OPENERS: [(&[u8], Kind); 4] = [
(b"<!--", Kind::Comment),
(b"<![CDATA[", Kind::Text),
(b"<?", Kind::Pi),
(b"<!DOCTYPE", Kind::Doctype),
];
const CDATA_OPEN: &[u8] = b"<![CDATA[";
impl Kind {
const fn opener_len(self) -> usize {
match self {
Self::Text => 0,
Self::Tag => 1,
Self::Pi => 2,
Self::Comment => 4,
Self::Doctype => 9,
}
}
fn classify(rest: &[u8]) -> Option<Self> {
if rest.is_empty() {
return None;
}
if rest[0] != b'<' {
return Some(Self::Text);
}
for (opener, kind) in OPENERS {
if rest.starts_with(opener) {
return Some(kind);
}
if opener.starts_with(rest) {
return None;
}
}
Some(Self::Tag)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum TextState {
Chars,
Cdata,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum DtdState {
Head,
HeadQuote(u8),
Subset,
Comment,
Pi,
Decl,
DeclQuote(u8),
Conditional(usize),
Tail,
}
enum Step {
Advance(usize),
Whole,
NeedMore,
}
#[derive(Debug, Clone, Copy)]
pub(super) struct Probe {
pub(super) start: usize,
kind: Option<Kind>,
scanned_to: usize,
end: Option<usize>,
floor: usize,
quote: Option<u8>,
text: TextState,
dtd: DtdState,
}
impl Probe {
pub(super) const fn at(start: usize) -> Self {
Self {
start,
kind: None,
scanned_to: start,
end: None,
floor: start,
quote: None,
text: TextState::Chars,
dtd: DtdState::Head,
}
}
pub(super) fn shift(&mut self, by: usize) {
self.start -= by;
self.scanned_to -= by;
self.floor -= by;
self.end = self.end.map(|end| end - by);
}
pub(super) fn is_whole(&mut self, text: &str, in_content: bool) -> bool {
if self.end.is_some() {
return true;
}
let Some(kind) = self.decide(text) else {
return false;
};
let whole = match kind {
Kind::Text if !in_content => true,
Kind::Text => self.text_end(text),
Kind::Comment => self.find(text, "-->"),
Kind::Pi => self.find(text, "?>"),
Kind::Doctype => self.doctype_end(text),
Kind::Tag => self.tag_end(text),
};
if whole {
self.end = Some(self.scanned_to);
}
whole
}
pub(super) fn end(&self) -> Option<usize> {
self.end
}
fn decide(&mut self, text: &str) -> Option<Kind> {
if self.kind.is_none() {
let kind = Kind::classify(&text.as_bytes()[self.start..])?;
self.kind = Some(kind);
self.floor = self.start + kind.opener_len();
self.scanned_to = self.floor;
}
self.kind
}
fn find(&mut self, text: &str, closer: &str) -> bool {
let mut from = self
.scanned_to
.saturating_sub(closer.len() - 1)
.max(self.floor);
while !text.is_char_boundary(from) {
from -= 1;
}
if let Some(i) = text[from..].find(closer) {
self.scanned_to = from + i + closer.len();
return true;
}
self.scanned_to = text.len();
false
}
fn text_end(&mut self, text: &str) -> bool {
loop {
match self.text {
TextState::Cdata => {
if !self.find(text, "]]>") {
return false;
}
self.text = TextState::Chars;
self.floor = self.scanned_to;
}
TextState::Chars => match self.next_markup(text) {
Some(true) => return true,
Some(false) => {
self.text = TextState::Cdata;
self.floor = self.scanned_to;
}
None => return false,
},
}
}
}
fn next_markup(&mut self, text: &str) -> Option<bool> {
let bytes = text.as_bytes();
let Some(i) = bytes[self.scanned_to..].iter().position(|&b| b == b'<')
else {
self.scanned_to = bytes.len();
return None;
};
let at = self.scanned_to + i;
let rest = &bytes[at..];
if rest.starts_with(CDATA_OPEN) {
self.scanned_to = at + CDATA_OPEN.len();
return Some(false);
}
self.scanned_to = at;
if CDATA_OPEN.starts_with(rest) {
return None;
}
Some(true)
}
fn tag_end(&mut self, text: &str) -> bool {
for (i, &b) in text.as_bytes()[self.scanned_to..].iter().enumerate() {
match (self.quote, b) {
(Some(q), c) if c == q => self.quote = None,
(None, b'"' | b'\'') => self.quote = Some(b),
(None, b'>') => {
self.scanned_to += i + 1;
return true;
}
_ => {}
}
}
self.scanned_to = text.len();
false
}
fn doctype_end(&mut self, text: &str) -> bool {
let bytes = text.as_bytes();
let mut at = self.scanned_to;
while at < bytes.len() {
match self.dtd_step(&bytes[at..]) {
Step::Advance(n) => at += n,
Step::Whole => {
self.scanned_to = at + 1;
return true;
}
Step::NeedMore => break,
}
}
self.scanned_to = at;
false
}
fn dtd_step(&mut self, rest: &[u8]) -> Step {
match self.dtd {
DtdState::Head => self.dtd_head(rest[0]),
DtdState::HeadQuote(q) => {
self.dtd_quote(rest[0], q, DtdState::Head)
}
DtdState::Subset => self.dtd_subset(rest),
DtdState::Comment => self.dtd_until(rest, b"-->"),
DtdState::Pi => self.dtd_until(rest, b"?>"),
DtdState::Decl => self.dtd_decl(rest[0]),
DtdState::DeclQuote(q) => {
self.dtd_quote(rest[0], q, DtdState::Decl)
}
DtdState::Conditional(depth) => self.dtd_conditional(rest, depth),
DtdState::Tail => Self::dtd_tail(rest[0]),
}
}
fn dtd_head(&mut self, b: u8) -> Step {
match b {
b'"' | b'\'' => self.dtd = DtdState::HeadQuote(b),
b'[' => self.dtd = DtdState::Subset,
b'>' => return Step::Whole,
_ => {}
}
Step::Advance(1)
}
fn dtd_quote(&mut self, b: u8, quote: u8, back_to: DtdState) -> Step {
if b == quote {
self.dtd = back_to;
}
Step::Advance(1)
}
fn dtd_subset(&mut self, rest: &[u8]) -> Step {
const OPENERS: [(&[u8], DtdState); 4] = [
(b"<!--", DtdState::Comment),
(b"<![", DtdState::Conditional(1)),
(b"<?", DtdState::Pi),
(b"<!", DtdState::Decl),
];
match rest[0] {
b']' => {
self.dtd = DtdState::Tail;
Step::Advance(1)
}
b'<' => {
for (opener, state) in OPENERS {
if rest.starts_with(opener) {
self.dtd = state;
return Step::Advance(opener.len());
}
if opener.starts_with(rest) {
return Step::NeedMore;
}
}
self.dtd = DtdState::Decl;
Step::Advance(1)
}
_ => Step::Advance(1),
}
}
fn dtd_until(&mut self, rest: &[u8], closer: &[u8]) -> Step {
if let Some(i) = rest.windows(closer.len()).position(|w| w == closer) {
self.dtd = DtdState::Subset;
return Step::Advance(i + closer.len());
}
if rest.len() < closer.len() {
return Step::NeedMore;
}
Step::Advance(rest.len() - closer.len() + 1)
}
fn dtd_decl(&mut self, b: u8) -> Step {
match b {
b'"' | b'\'' => self.dtd = DtdState::DeclQuote(b),
b'>' => self.dtd = DtdState::Subset,
_ => {}
}
Step::Advance(1)
}
fn dtd_conditional(&mut self, rest: &[u8], depth: usize) -> Step {
if rest.starts_with(b"<![") {
self.dtd = DtdState::Conditional(depth + 1);
return Step::Advance(3);
}
if rest.starts_with(b"]]>") {
self.dtd = match depth {
1 => DtdState::Subset,
deeper => DtdState::Conditional(deeper - 1),
};
return Step::Advance(3);
}
if b"<![".starts_with(rest) || b"]]>".starts_with(rest) {
return Step::NeedMore;
}
Step::Advance(1)
}
fn dtd_tail(b: u8) -> Step {
if matches!(b, b' ' | b'\t' | b'\r' | b'\n') {
return Step::Advance(1);
}
Step::Whole
}
}
#[cfg(test)]
mod tests {
use super::*;
use alloc::format;
fn whole_at(doc: &str) -> Option<usize> {
let mut probe = Probe::at(0);
(1..=doc.len())
.filter(|&n| doc.is_char_boundary(n))
.find(|&n| probe.is_whole(&doc[..n], true))
}
#[test]
fn a_doctype_ends_at_its_own_close_angle_bracket() {
for (doc, end) in [
("<!DOCTYPE a>", 12),
("<!DOCTYPE a [<!-- ] --> <!ENTITY e \"x\">]>", 41),
("<!DOCTYPE a SYSTEM \"x>y.dtd\">", 29),
("<!DOCTYPE a [<!ENTITY e \"x>y\">]>", 32),
("<!DOCTYPE a [<?pi ] > ?><!ENTITY e 'a]b'>]>", 43),
("<!DOCTYPE a [<![INCLUDE[<![IGNORE[ ]]> ]]>]>", 44),
("<!DOCTYPE a PUBLIC \"p]\" 's>' [ ] >", 34),
("<!DOCTYPE a [<!ELEMENT a (#PCDATA)>\n]\n>", 39),
] {
assert_eq!(whole_at(doc), Some(end), "{doc:?}");
}
}
#[test]
fn a_text_run_spans_cdata_sections_and_references() {
let doc = "one <![CDATA[two]]> & <![CDATA[]]> three<b/>";
let lt = doc.rfind("<b/>").expect("tag");
assert_eq!(whole_at(doc), Some(lt + 2), "ends at the tag's `<`");
assert_eq!(whole_at("text<"), None, "`<` alone may open CDATA");
assert_eq!(whole_at("text<![CD"), None, "still undecided");
assert_eq!(whole_at("text<![CDATA[x]]>"), None, "the run goes on");
assert_eq!(whole_at("text<x"), Some(6));
}
#[test]
fn delimiters_inside_quoted_attribute_values_are_data() {
assert_eq!(whole_at("<a x='>' y=\">\">"), Some(15));
assert_eq!(whole_at("<!-- > -->"), Some(10));
assert_eq!(whole_at("<?pi > ?>"), Some(9));
}
#[test]
fn outside_the_root_character_data_is_whole_at_once() {
let mut probe = Probe::at(0);
assert!(probe.is_whole("junk", false));
assert!(!Probe::at(0).is_whole("junk", true));
}
#[test]
fn the_search_resumes_rather_than_restarting() {
let mut probe = Probe::at(0);
let doc = format!("<!--{}-->", "x".repeat(100));
for n in 4..doc.len() {
assert!(!probe.is_whole(&doc[..n], true));
assert!(probe.scanned_to >= n - 2, "resumes near the end");
}
assert!(probe.is_whole(&doc, true));
}
}