use super::error::ParseError;
use super::wellformed::{check_char_refs, check_name, is_name_char, is_name_start_char};
type R = Result<(), ParseError>;
fn err(message: impl Into<String>) -> ParseError {
ParseError::NotWellFormed {
message: message.into(),
}
}
fn reject_colon(name: &str, ctx: &str) -> R {
if name.contains(':') {
return Err(err(format!("a {ctx} must not contain a colon: '{name}'")));
}
Ok(())
}
pub(crate) fn check_doctype(raw: &str) -> R {
let mut c = Cursor::new(raw);
c.skip_s();
c.parse_name("document type name")?;
c.skip_s();
if c.starts_with("SYSTEM") || c.starts_with("PUBLIC") {
parse_external_id(&mut c)?;
c.skip_s();
}
if c.eat('[') {
parse_internal_subset(&mut c)?;
c.skip_s();
}
if !c.at_end() {
return Err(err("unexpected content in the DOCTYPE declaration"));
}
Ok(())
}
struct Cursor<'a> {
s: &'a str,
i: usize,
}
impl<'a> Cursor<'a> {
fn new(s: &'a str) -> Self {
Self { s, i: 0 }
}
fn rest(&self) -> &'a str {
&self.s[self.i..]
}
fn at_end(&self) -> bool {
self.i >= self.s.len()
}
fn peek(&self) -> Option<char> {
self.rest().chars().next()
}
fn bump(&mut self) {
if let Some(c) = self.peek() {
self.i += c.len_utf8();
}
}
fn eat(&mut self, c: char) -> bool {
if self.peek() == Some(c) {
self.bump();
true
} else {
false
}
}
fn eat_str(&mut self, kw: &str) -> bool {
if self.rest().starts_with(kw) {
self.i += kw.len();
true
} else {
false
}
}
fn starts_with(&self, kw: &str) -> bool {
self.rest().starts_with(kw)
}
fn skip_s(&mut self) -> bool {
let mut any = false;
while matches!(self.peek(), Some(' ' | '\t' | '\r' | '\n')) {
self.bump();
any = true;
}
any
}
fn require_s(&mut self, ctx: &str) -> R {
if self.skip_s() {
Ok(())
} else {
Err(err(format!("white space is required {ctx}")))
}
}
fn parse_name(&mut self, ctx: &str) -> Result<&'a str, ParseError> {
let start = self.i;
match self.peek() {
Some(c) if is_name_start_char(c) => self.bump(),
Some(c) => {
return Err(err(format!(
"illegal {ctx} start character U+{:04X}",
c as u32
)));
}
None => return Err(err(format!("expected {ctx}"))),
}
while let Some(c) = self.peek() {
if is_name_char(c) {
self.bump();
} else {
break;
}
}
Ok(&self.s[start..self.i])
}
fn parse_nmtoken(&mut self, ctx: &str) -> Result<&'a str, ParseError> {
let start = self.i;
while let Some(c) = self.peek() {
if is_name_char(c) {
self.bump();
} else {
break;
}
}
if self.i == start {
return Err(err(format!("expected {ctx}")));
}
Ok(&self.s[start..self.i])
}
fn parse_quoted(&mut self, ctx: &str) -> Result<&'a str, ParseError> {
let quote = match self.peek() {
Some(q @ ('"' | '\'')) => q,
_ => return Err(err(format!("expected a quoted {ctx}"))),
};
self.bump();
let start = self.i;
while let Some(c) = self.peek() {
if c == quote {
let inner = &self.s[start..self.i];
self.bump();
return Ok(inner);
}
self.bump();
}
Err(err(format!("unterminated {ctx}")))
}
}
fn parse_external_id(c: &mut Cursor<'_>) -> R {
if c.eat_str("SYSTEM") {
c.require_s("after the SYSTEM keyword")?;
c.parse_quoted("system identifier")?;
} else if c.eat_str("PUBLIC") {
c.require_s("after the PUBLIC keyword")?;
let pubid = c.parse_quoted("public identifier")?;
check_pubid(pubid)?;
c.require_s("between the public and system identifiers")?;
c.parse_quoted("system identifier")?;
} else {
return Err(err("expected SYSTEM or PUBLIC in the external identifier"));
}
Ok(())
}
fn check_pubid(s: &str) -> R {
for c in s.chars() {
if !is_pubid_char(c) {
return Err(err(format!(
"illegal character U+{:04X} in the public identifier",
c as u32
)));
}
}
Ok(())
}
fn is_pubid_char(c: char) -> bool {
matches!(c,
' ' | '\r' | '\n'
| 'a'..='z' | 'A'..='Z' | '0'..='9'
| '-' | '\'' | '(' | ')' | '+' | ',' | '.' | '/' | ':' | '='
| '?' | ';' | '!' | '*' | '#' | '@' | '$' | '_' | '%')
}
fn parse_internal_subset(c: &mut Cursor<'_>) -> R {
let mut pe_used = false;
loop {
c.skip_s();
match c.peek() {
None => return Err(err("unterminated DOCTYPE internal subset")),
Some(']') => {
c.bump();
return Ok(());
}
Some('%') => {
parse_pe_reference(c)?;
pe_used = true;
}
Some('<') => parse_markup_decl(c, pe_used)?,
Some(ch) => {
return Err(err(format!(
"unexpected character U+{:04X} in the internal subset",
ch as u32
)));
}
}
}
}
fn parse_pe_reference(c: &mut Cursor<'_>) -> R {
c.bump(); c.parse_name("parameter-entity name")?;
if !c.eat(';') {
return Err(err("a parameter-entity reference must end with ';'"));
}
Ok(())
}
fn parse_markup_decl(c: &mut Cursor<'_>, pe_used: bool) -> R {
if c.starts_with("<?") {
return parse_pi(c);
}
if c.starts_with("<!--") {
return parse_comment(c);
}
if c.starts_with("<![") {
return Err(err(
"a conditional section is not allowed in the internal DTD subset",
));
}
if c.eat_str("<!ELEMENT") {
return finish_decl(c, pe_used, parse_element_decl);
}
if c.eat_str("<!ATTLIST") {
return finish_decl(c, pe_used, parse_attlist_decl);
}
if c.eat_str("<!ENTITY") {
return finish_decl(c, pe_used, parse_entity_decl);
}
if c.eat_str("<!NOTATION") {
return finish_decl(c, pe_used, parse_notation_decl);
}
Err(err("expected a markup declaration in the internal subset"))
}
fn finish_decl(c: &mut Cursor<'_>, pe_used: bool, parse: fn(&str) -> R) -> R {
let body = take_decl_body(c)?;
if pe_used || has_pe_reference(body) {
return Ok(());
}
parse(body)
}
fn take_decl_body<'a>(c: &mut Cursor<'a>) -> Result<&'a str, ParseError> {
let start = c.i;
let mut quote: Option<char> = None;
while let Some(ch) = c.peek() {
match quote {
Some(q) => {
if ch == q {
quote = None;
}
}
None => match ch {
'"' | '\'' => quote = Some(ch),
'>' => {
let body = &c.s[start..c.i];
c.bump();
return Ok(body);
}
_ => {}
},
}
c.bump();
}
Err(err("unterminated markup declaration"))
}
fn has_pe_reference(body: &str) -> bool {
let mut chars = body.char_indices().peekable();
while let Some((_, ch)) = chars.next() {
if ch == '%'
&& let Some((_, next)) = chars.peek()
&& is_name_start_char(*next)
{
return true;
}
}
false
}
fn parse_pi(c: &mut Cursor<'_>) -> R {
let after = &c.rest()[2..]; let Some(end) = after.find("?>") else {
return Err(err("unterminated processing instruction"));
};
let inner = &after[..end];
c.i += 2 + end + 2;
let target = inner.split([' ', '\t', '\r', '\n']).next().unwrap_or("");
if target.is_empty() {
return Err(err("a processing instruction must have a target"));
}
check_name(target, "processing-instruction target")?;
reject_colon(target, "processing-instruction target")?;
if target.eq_ignore_ascii_case("xml") {
return Err(err(format!(
"'{target}' is a reserved processing-instruction target"
)));
}
Ok(())
}
fn parse_comment(c: &mut Cursor<'_>) -> R {
let after = &c.rest()[4..]; let Some(end) = after.find("-->") else {
return Err(err("unterminated comment"));
};
let inner = &after[..end];
c.i += 4 + end + 3;
if inner.contains("--") {
return Err(err("'--' is not allowed inside a comment"));
}
Ok(())
}
fn parse_element_decl(body: &str) -> R {
let mut c = Cursor::new(body);
c.require_s("after ELEMENT")?;
c.parse_name("element type name")?;
c.require_s("before the content specification")?;
parse_contentspec(&mut c)?;
c.skip_s();
if !c.at_end() {
return Err(err("trailing content in an element declaration"));
}
Ok(())
}
fn parse_contentspec(c: &mut Cursor<'_>) -> R {
if c.eat_str("EMPTY") || c.eat_str("ANY") {
return Ok(());
}
if c.peek() != Some('(') {
return Err(err("expected EMPTY, ANY, or a content model"));
}
let mut look = Cursor::new(c.rest());
look.bump(); look.skip_s();
if look.starts_with("#PCDATA") {
parse_mixed(c)
} else {
parse_children(c)
}
}
fn parse_mixed(c: &mut Cursor<'_>) -> R {
c.eat('('); c.skip_s();
if !c.eat_str("#PCDATA") {
return Err(err("expected #PCDATA in a mixed content model"));
}
let mut had_names = false;
loop {
c.skip_s();
if c.eat('|') {
had_names = true;
c.skip_s();
c.parse_name("element name in a mixed content model")?;
} else {
break;
}
}
c.skip_s();
if !c.eat(')') {
return Err(err("expected ')' to close a mixed content model"));
}
if had_names {
if !c.eat('*') {
return Err(err(
"a mixed content model with element names must end with ')*'",
));
}
} else {
c.eat('*');
}
Ok(())
}
fn parse_children(c: &mut Cursor<'_>) -> R {
parse_cp(c)?;
Ok(())
}
fn parse_cp(c: &mut Cursor<'_>) -> R {
if c.peek() == Some('(') {
parse_group(c)?;
} else {
c.parse_name("element name in a content model")?;
}
if matches!(c.peek(), Some('?' | '*' | '+')) {
c.bump();
}
Ok(())
}
fn parse_group(c: &mut Cursor<'_>) -> R {
c.eat('('); c.skip_s();
parse_cp(c)?;
c.skip_s();
let sep = match c.peek() {
Some(s @ ('|' | ',')) => Some(s),
_ => None,
};
if let Some(sep) = sep {
loop {
c.skip_s();
match c.peek() {
Some(s) if s == sep => {
c.bump();
}
Some('|') | Some(',') => {
return Err(err(
"a content-model group must use a single connector (',' or '|')",
));
}
_ => break,
}
c.skip_s();
parse_cp(c)?;
c.skip_s();
}
}
c.skip_s();
if !c.eat(')') {
return Err(err("expected ')' to close a content-model group"));
}
Ok(())
}
fn parse_attlist_decl(body: &str) -> R {
let mut c = Cursor::new(body);
c.require_s("after ATTLIST")?;
c.parse_name("element type name in an attribute-list declaration")?;
loop {
let had_s = c.skip_s();
if c.at_end() {
break;
}
if !had_s {
return Err(err(
"white space is required before an attribute definition",
));
}
if c.at_end() {
break;
}
parse_att_def(&mut c)?;
}
Ok(())
}
fn parse_att_def(c: &mut Cursor<'_>) -> R {
c.parse_name("attribute name")?;
c.require_s("before the attribute type")?;
parse_att_type(c)?;
c.require_s("before the attribute default")?;
parse_default_decl(c)?;
Ok(())
}
fn parse_att_type(c: &mut Cursor<'_>) -> R {
const TOKENIZED: [&str; 8] = [
"CDATA", "IDREFS", "IDREF", "ID", "ENTITY", "ENTITIES", "NMTOKENS", "NMTOKEN",
];
if c.eat_str("NOTATION") {
c.require_s("after NOTATION")?;
return parse_name_group(c);
}
for kw in TOKENIZED {
if c.eat_str(kw) {
return Ok(());
}
}
if c.peek() == Some('(') {
return parse_enumeration(c);
}
Err(err("expected an attribute type"))
}
fn parse_name_group(c: &mut Cursor<'_>) -> R {
if !c.eat('(') {
return Err(err("expected '(' after NOTATION"));
}
loop {
c.skip_s();
c.parse_name("notation name")?;
c.skip_s();
if c.eat('|') {
continue;
}
break;
}
if !c.eat(')') {
return Err(err("expected ')' to close a NOTATION type"));
}
Ok(())
}
fn parse_enumeration(c: &mut Cursor<'_>) -> R {
if !c.eat('(') {
return Err(err("expected '(' to open an enumeration"));
}
loop {
c.skip_s();
c.parse_nmtoken("enumeration token")?;
c.skip_s();
if c.eat('|') {
continue;
}
break;
}
if !c.eat(')') {
return Err(err("expected ')' to close an enumeration"));
}
Ok(())
}
fn parse_default_decl(c: &mut Cursor<'_>) -> R {
if c.eat_str("#REQUIRED") || c.eat_str("#IMPLIED") {
return Ok(());
}
if c.eat_str("#FIXED") {
c.require_s("after #FIXED")?;
}
parse_att_value(c)
}
fn parse_att_value(c: &mut Cursor<'_>) -> R {
let value = c.parse_quoted("attribute default value")?;
if value.contains('<') {
return Err(err("'<' is not allowed in an attribute default value"));
}
check_char_refs(value, "attribute default value")?;
Ok(())
}
fn parse_entity_decl(body: &str) -> R {
let mut c = Cursor::new(body);
c.require_s("after ENTITY")?;
let is_pe = c.eat('%');
if is_pe {
c.require_s("after '%' in a parameter-entity declaration")?;
}
reject_colon(c.parse_name("entity name")?, "entity name")?;
c.require_s("before the entity definition")?;
if matches!(c.peek(), Some('"' | '\'')) {
let value = c.parse_quoted("entity value")?;
check_char_refs(value, "entity value")?;
} else {
parse_external_id(&mut c)?;
if !is_pe {
let mut look = Cursor::new(c.rest());
if look.skip_s() && look.eat_str("NDATA") {
c.skip_s();
c.eat_str("NDATA");
c.require_s("after NDATA")?;
c.parse_name("notation name")?;
}
}
}
c.skip_s();
if !c.at_end() {
return Err(err("trailing content in an entity declaration"));
}
Ok(())
}
fn parse_notation_decl(body: &str) -> R {
let mut c = Cursor::new(body);
c.require_s("after NOTATION")?;
reject_colon(c.parse_name("notation name")?, "notation name")?;
c.require_s("before the notation identifier")?;
if c.eat_str("SYSTEM") {
c.require_s("after the SYSTEM keyword")?;
c.parse_quoted("system identifier")?;
} else if c.eat_str("PUBLIC") {
c.require_s("after the PUBLIC keyword")?;
let pubid = c.parse_quoted("public identifier")?;
check_pubid(pubid)?;
let mut look = Cursor::new(c.rest());
if look.skip_s() && matches!(look.peek(), Some('"' | '\'')) {
c.require_s("before the system identifier")?;
c.parse_quoted("system identifier")?;
}
} else {
return Err(err("expected SYSTEM or PUBLIC in a notation declaration"));
}
c.skip_s();
if !c.at_end() {
return Err(err("trailing content in a notation declaration"));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn ok(raw: &str) {
assert!(check_doctype(raw).is_ok(), "expected accept: {raw:?}");
}
fn bad(raw: &str) {
assert!(check_doctype(raw).is_err(), "expected reject: {raw:?}");
}
#[test]
fn accepts_minimal_and_external_ids() {
ok("doc");
ok("doc SYSTEM \"foo.dtd\"");
ok("doc PUBLIC \"-//A//B\" \"foo.dtd\"");
ok("doc PUBLIC 'The latest version' 'student.dtd'[\n]");
}
#[test]
fn accepts_common_declarations() {
ok("s [ <!ELEMENT s (#PCDATA)> ]");
ok("s [ <!ELEMENT s (#PCDATA)*> ]");
ok("s [ <!ELEMENT a (b|c|d)+> <!ELEMENT b ANY> ]");
ok("s [ <!ELEMENT r (a,b?,(c|d)*)> ]");
ok("s [ <!ELEMENT r EMPTY> <!ATTLIST r x CDATA #REQUIRED y (a|b) 'a'> ]");
ok("s [ <!ENTITY e \"val\"> <!ENTITY % p \"x\"> ]");
ok("s [ <!ENTITY ext SYSTEM \"e.gif\" NDATA gif> ]");
ok("s [ <!NOTATION n SYSTEM \"x\"> <!NOTATION m PUBLIC \"-//p//id\"> ]");
ok("s [ <?target data?> <!-- a comment --> ]");
}
#[test]
fn rejects_bad_pi_target_in_subset() {
bad("s [ <? ?> ]");
bad("s [ <?xml foo?> ]");
bad("s [ <?1bad x?> ]");
}
#[test]
fn rejects_conditional_section_and_bad_comment() {
bad("s [ <![INCLUDE[ <!ELEMENT a ANY> ]]> ]");
bad("s [ <!-- a -- b --> ]");
}
#[test]
fn rejects_malformed_declarations() {
bad("s [ <!ELEMENT> ]");
bad("s [ <!ELEMENT s > ]");
bad("s [ <!ELEMENT s (a,b|c)> ]"); bad("s [ <!ATTLIST s x BOGUS #IMPLIED> ]");
bad("s [ <!ENTITY> ]");
bad("s [ <!NOTATION n \"x\"> ]"); bad("s [ <!WHAT foo> ]");
}
#[test]
fn rejects_bad_pubid_and_external_keyword_case() {
bad("doc PUBLIC \"a`b\" \"foo.dtd\""); bad("doc system \"foo.dtd\""); }
#[test]
fn accepts_when_parameter_entity_in_play() {
ok("s [ %pe; <!ELEMENT this could be anything> ]");
ok("s [ <!ELEMENT r %content;> ]");
}
}