use std::collections::HashMap;
use std::sync::Arc;
use crate::XmlVersion;
use crate::errors::WellFormedError;
use crate::wellformed::PREDEFINED_ENTITIES;
use crate::wellformed::chars::{
is_char, is_literal_char, is_name, is_name_char, is_name_start_char, is_ncname, is_nmtoken,
is_pubid_char, is_space,
};
use crate::wellformed::limits::{Budget, MAX_ENTITY_DEPTH};
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) enum Entity {
Internal(String),
External,
Unparsed,
}
#[derive(Clone, Debug, Default)]
pub(crate) struct Dtd {
pub entities: HashMap<String, Entity>,
pub incomplete: bool,
}
fn err(message: impl Into<String>) -> WellFormedError {
WellFormedError::InvalidDoctype(message.into())
}
type Checked<T> = Result<T, WellFormedError>;
pub(crate) fn validate_doctype(
body: &str,
version: XmlVersion,
namespace_aware: bool,
standalone: bool,
) -> Checked<Dtd> {
let mut scanner = Scanner::new(body, version, namespace_aware, standalone);
let mut dtd = Dtd::default();
scanner.doctype(&mut dtd)?;
Ok(dtd)
}
struct Scanner<'a> {
input: &'a str,
pos: usize,
version: XmlVersion,
namespace_aware: bool,
standalone: bool,
budget: Arc<Budget>,
}
impl<'a> Scanner<'a> {
fn new(input: &'a str, version: XmlVersion, namespace_aware: bool, standalone: bool) -> Self {
Self {
input,
pos: 0,
version,
namespace_aware,
standalone,
budget: Budget::new(),
}
}
const fn mark_incomplete(&self, dtd: &mut Dtd) {
if !self.standalone {
dtd.incomplete = true;
}
}
fn ncname(&mut self, what: &str) -> Checked<&'a str> {
let name = self.name(what)?;
if self.namespace_aware && !is_ncname(name) {
return Err(WellFormedError::InvalidQName(name.to_owned()));
}
Ok(name)
}
#[inline]
fn rest(&self) -> &'a str {
&self.input[self.pos..]
}
#[inline]
fn peek(&self) -> Option<char> {
self.rest().chars().next()
}
#[inline]
fn bump(&mut self) -> Option<char> {
let ch = self.peek()?;
self.pos += ch.len_utf8();
Some(ch)
}
#[inline]
fn eat(&mut self, ch: char) -> bool {
if self.peek() == Some(ch) {
self.pos += ch.len_utf8();
true
} else {
false
}
}
#[inline]
fn eat_str(&mut self, text: &str) -> bool {
if self.rest().starts_with(text) {
self.pos += text.len();
true
} else {
false
}
}
fn skip_space(&mut self) -> bool {
let start = self.pos;
while matches!(self.peek(), Some(ch) if is_space(ch)) {
self.pos += 1;
}
self.pos != start
}
fn require_space(&mut self, what: &str) -> Checked<()> {
if self.skip_space() {
Ok(())
} else {
Err(err(format!("white space is required {}", what)))
}
}
fn name(&mut self, what: &str) -> Checked<&'a str> {
let start = self.pos;
match self.peek() {
Some(ch) if is_name_start_char(ch) => self.pos += ch.len_utf8(),
_ => return Err(err(format!("a name is expected {}", what))),
}
while matches!(self.peek(), Some(ch) if is_name_char(ch)) {
self.pos += self.peek().map_or(0, char::len_utf8);
}
Ok(&self.input[start..self.pos])
}
fn nmtoken(&mut self, what: &str) -> Checked<&'a str> {
let start = self.pos;
while matches!(self.peek(), Some(ch) if is_name_char(ch)) {
self.pos += self.peek().map_or(0, char::len_utf8);
}
if self.pos == start {
Err(err(format!("a name token is expected {}", what)))
} else {
Ok(&self.input[start..self.pos])
}
}
fn doctype(&mut self, dtd: &mut Dtd) -> Checked<()> {
self.check_characters()?;
self.name("as the document type name")?;
if self.skip_space() && matches!(self.peek(), Some('S' | 'P')) {
self.external_id()?;
self.mark_incomplete(dtd);
self.skip_space();
}
if self.eat('[') {
self.internal_subset(dtd)?;
if !self.eat(']') {
return Err(err("`]` is expected at the end of the internal subset"));
}
self.skip_space();
}
if self.pos != self.input.len() {
return Err(err(format!(
"unexpected `{}` after the document type declaration",
self.snippet()
)));
}
Ok(())
}
fn check_characters(&self) -> Checked<()> {
match self
.input
.chars()
.find(|ch| !is_literal_char(*ch, self.version))
{
Some(ch) => Err(WellFormedError::InvalidCharacter(ch)),
None => Ok(()),
}
}
fn snippet(&self) -> String {
self.rest().chars().take(16).collect()
}
fn external_id(&mut self) -> Checked<()> {
if self.eat_str("SYSTEM") {
self.require_space("after `SYSTEM`")?;
self.system_literal()?;
Ok(())
} else if self.eat_str("PUBLIC") {
self.require_space("after `PUBLIC`")?;
self.pubid_literal()?;
self.require_space("between the public and system identifiers")?;
self.system_literal()?;
Ok(())
} else {
Err(err(format!(
"`SYSTEM` or `PUBLIC` is expected, found `{}`",
self.snippet()
)))
}
}
fn external_or_public_id(&mut self) -> Checked<()> {
if self.eat_str("SYSTEM") {
self.require_space("after `SYSTEM`")?;
self.system_literal()?;
Ok(())
} else if self.eat_str("PUBLIC") {
self.require_space("after `PUBLIC`")?;
self.pubid_literal()?;
let checkpoint = self.pos;
if self.skip_space() && matches!(self.peek(), Some('"' | '\'')) {
self.system_literal()?;
} else {
self.pos = checkpoint;
}
Ok(())
} else {
Err(err(format!(
"`SYSTEM` or `PUBLIC` is expected, found `{}`",
self.snippet()
)))
}
}
fn system_literal(&mut self) -> Checked<&'a str> {
let quote = match self.peek() {
Some(quote @ ('"' | '\'')) => {
self.pos += 1;
quote
}
_ => return Err(err("a quoted system identifier is expected")),
};
let start = self.pos;
loop {
match self.bump() {
Some(ch) if ch == quote => return Ok(&self.input[start..self.pos - 1]),
Some(_) => {}
None => return Err(err("the system identifier is not closed")),
}
}
}
fn pubid_literal(&mut self) -> Checked<&'a str> {
let quote = match self.peek() {
Some(quote @ ('"' | '\'')) => {
self.pos += 1;
quote
}
_ => return Err(err("a quoted public identifier is expected")),
};
let start = self.pos;
loop {
match self.bump() {
Some(ch) if ch == quote => return Ok(&self.input[start..self.pos - 1]),
Some(ch) if is_pubid_char(ch) => {}
Some(_) => {
let literal = self.input[start..]
.split(quote)
.next()
.unwrap_or_default()
.to_owned();
return Err(WellFormedError::InvalidPublicId(literal));
}
None => return Err(err("the public identifier is not closed")),
}
}
}
fn internal_subset(&mut self, dtd: &mut Dtd) -> Checked<()> {
loop {
self.skip_space();
match self.peek() {
None | Some(']') => return Ok(()),
Some('%') => {
self.pe_reference()?;
self.mark_incomplete(dtd);
}
Some('<') => self.markup_decl(dtd)?,
Some(_) => {
return Err(err(format!(
"a markup declaration is expected, found `{}`",
self.snippet()
)));
}
}
}
}
fn pe_reference(&mut self) -> Checked<()> {
debug_assert_eq!(self.peek(), Some('%'));
self.pos += 1;
self.name("in a parameter entity reference")?;
if self.eat(';') {
Ok(())
} else {
Err(err(
"`;` is expected at the end of a parameter entity reference",
))
}
}
fn markup_decl(&mut self, dtd: &mut Dtd) -> Checked<()> {
if self.eat_str("<!--") {
return self.comment();
}
if self.eat_str("<?") {
return self.processing_instruction();
}
if self.eat_str("<!ELEMENT") {
return self.element_decl();
}
if self.eat_str("<!ATTLIST") {
return self.attlist_decl(dtd);
}
if self.eat_str("<!ENTITY") {
return self.entity_decl(dtd);
}
if self.eat_str("<!NOTATION") {
return self.notation_decl();
}
if self.rest().starts_with("<![") {
return Err(err(
"conditional sections are only allowed in the external subset",
));
}
Err(err(format!(
"unknown markup declaration `{}`",
self.snippet()
)))
}
fn comment(&mut self) -> Checked<()> {
let start = self.pos;
let Some(end) = self.rest().find("-->") else {
return Err(err("a comment inside the DTD is not closed"));
};
let body = &self.input[start..start + end];
if body.contains("--") {
return Err(err("`--` is not allowed inside a comment"));
}
if body.ends_with('-') {
return Err(err("a comment cannot end with `-`"));
}
self.pos = start + end + 3;
Ok(())
}
fn processing_instruction(&mut self) -> Checked<()> {
let target = self.name("as a processing instruction target")?;
if target.eq_ignore_ascii_case("xml") {
return Err(WellFormedError::ReservedPiTarget(target.to_owned()));
}
if !self.rest().starts_with("?>") {
self.require_space("after a processing instruction target")?;
}
match self.rest().find("?>") {
Some(end) => {
self.pos += end + 2;
Ok(())
}
None => Err(err("a processing instruction inside the DTD is not closed")),
}
}
fn element_decl(&mut self) -> Checked<()> {
self.require_space("after `<!ELEMENT`")?;
self.name("as an element type name")?;
self.require_space("after an element type name")?;
self.content_spec()?;
self.skip_space();
self.close_decl("<!ELEMENT")
}
fn content_spec(&mut self) -> Checked<()> {
if self.eat_str("EMPTY") || self.eat_str("ANY") {
return Ok(());
}
if self.peek() != Some('(') {
return Err(err(format!(
"`EMPTY`, `ANY` or a content model is expected, found `{}`",
self.snippet()
)));
}
let checkpoint = self.pos;
self.pos += 1;
self.skip_space();
if self.rest().starts_with("#PCDATA") {
self.pos = checkpoint;
self.mixed()
} else {
self.pos = checkpoint;
self.children()
}
}
fn mixed(&mut self) -> Checked<()> {
debug_assert_eq!(self.peek(), Some('('));
self.pos += 1;
self.skip_space();
if !self.eat_str("#PCDATA") {
return Err(err(
"`#PCDATA` is expected at the start of a mixed content model",
));
}
let mut has_names = false;
loop {
self.skip_space();
if self.eat('|') {
has_names = true;
self.skip_space();
self.name("in a mixed content model")?;
} else {
break;
}
}
if !self.eat(')') {
return Err(err(format!(
"`)` is expected to close a mixed content model, found `{}`",
self.snippet()
)));
}
if has_names {
if !self.eat('*') {
return Err(err("`*` is required after `(#PCDATA | ...)`"));
}
} else {
self.eat('*');
}
Ok(())
}
fn children(&mut self) -> Checked<()> {
self.group()?;
self.occurrence();
Ok(())
}
fn group(&mut self) -> Checked<()> {
if !self.eat('(') {
return Err(err(format!(
"`(` is expected to start a content model, found `{}`",
self.snippet()
)));
}
self.skip_space();
self.content_particle()?;
let mut separator: Option<char> = None;
loop {
self.skip_space();
match self.peek() {
Some(sep @ ('|' | ',')) => {
match separator {
None => separator = Some(sep),
Some(expected) if expected == sep => {}
Some(expected) => {
return Err(err(format!(
"a content model cannot mix the `{}` and `{}` connectors",
expected, sep
)));
}
}
self.pos += 1;
self.skip_space();
self.content_particle()?;
}
Some(')') => {
self.pos += 1;
return Ok(());
}
_ => {
return Err(err(format!(
"`|`, `,` or `)` is expected in a content model, found `{}`",
self.snippet()
)));
}
}
}
}
fn content_particle(&mut self) -> Checked<()> {
if self.peek() == Some('(') {
self.group()?;
} else {
self.name("in a content model")?;
}
self.occurrence();
Ok(())
}
fn occurrence(&mut self) {
let _ = self.eat('?') || self.eat('*') || self.eat('+');
}
fn attlist_decl(&mut self, dtd: &Dtd) -> Checked<()> {
self.require_space("after `<!ATTLIST`")?;
self.name("as an element type name")?;
loop {
let checkpoint = self.pos;
if !self.skip_space() {
break;
}
if matches!(self.peek(), None | Some('>')) {
self.pos = checkpoint;
break;
}
self.att_def(dtd)?;
}
self.skip_space();
self.close_decl("<!ATTLIST")
}
fn att_def(&mut self, dtd: &Dtd) -> Checked<()> {
self.name("as an attribute name")?;
self.require_space("after an attribute name")?;
self.att_type()?;
self.require_space("before an attribute default declaration")?;
self.default_decl(dtd)
}
fn att_type(&mut self) -> Checked<()> {
for keyword in [
"CDATA", "IDREFS", "IDREF", "ID", "ENTITIES", "ENTITY", "NMTOKENS", "NMTOKEN",
] {
if self.rest().starts_with(keyword) {
let after = self.input[self.pos + keyword.len()..].chars().next();
if matches!(after, Some(ch) if is_name_char(ch)) {
continue;
}
self.pos += keyword.len();
return Ok(());
}
}
if self.eat_str("NOTATION") {
self.require_space("after `NOTATION`")?;
return self.enumeration(true);
}
if self.peek() == Some('(') {
return self.enumeration(false);
}
Err(err(format!(
"an attribute type is expected, found `{}`",
self.snippet()
)))
}
fn enumeration(&mut self, names_only: bool) -> Checked<()> {
if !self.eat('(') {
return Err(err("`(` is expected to start an enumerated attribute type"));
}
loop {
self.skip_space();
if names_only {
self.name("in a notation attribute type")?;
} else {
let token = self.nmtoken("in an enumerated attribute type")?;
if !is_nmtoken(token) {
return Err(WellFormedError::InvalidName(token.to_owned()));
}
}
self.skip_space();
if self.eat('|') {
continue;
}
if self.eat(')') {
return Ok(());
}
return Err(err(format!(
"`|` or `)` is expected in an enumerated attribute type, found `{}`",
self.snippet()
)));
}
}
fn default_decl(&mut self, dtd: &Dtd) -> Checked<()> {
if self.eat_str("#REQUIRED") || self.eat_str("#IMPLIED") {
return Ok(());
}
if self.eat_str("#FIXED") {
self.require_space("after `#FIXED`")?;
}
let value = self.att_value()?;
let mut stack = Vec::new();
check_attribute_value(value, dtd, self.version, &mut stack, &self.budget)
}
fn att_value(&mut self) -> Checked<&'a str> {
let quote = match self.peek() {
Some(quote @ ('"' | '\'')) => {
self.pos += 1;
quote
}
_ => {
return Err(err(format!(
"a quoted attribute value is expected, found `{}`",
self.snippet()
)));
}
};
let start = self.pos;
loop {
match self.bump() {
Some(ch) if ch == quote => return Ok(&self.input[start..self.pos - 1]),
Some('<') => {
return Err(err("`<` is not allowed in an attribute value"));
}
Some('&') => {
self.reference()?;
}
Some(_) => {}
None => return Err(err("an attribute value is not closed")),
}
}
}
fn entity_decl(&mut self, dtd: &mut Dtd) -> Checked<()> {
self.require_space("after `<!ENTITY`")?;
let parameter = self.eat('%');
if parameter {
self.require_space("after `%` in a parameter entity declaration")?;
self.mark_incomplete(dtd);
}
let name = self.ncname("as an entity name")?.to_owned();
self.require_space("after an entity name")?;
match self.peek() {
Some('"' | '\'') => {
let replacement = self.entity_value()?;
if !parameter {
dtd.entities
.entry(name)
.or_insert(Entity::Internal(replacement));
}
}
_ => {
self.external_id()?;
let mut unparsed = false;
let checkpoint = self.pos;
if self.skip_space() {
if self.eat_str("NDATA") {
if parameter {
return Err(err(
"`NDATA` is not allowed in a parameter entity declaration",
));
}
self.require_space("after `NDATA`")?;
self.name("as a notation name")?;
unparsed = true;
} else {
self.pos = checkpoint;
}
}
if !parameter {
let entity = if unparsed {
Entity::Unparsed
} else {
Entity::External
};
dtd.entities.entry(name).or_insert(entity);
}
}
}
self.skip_space();
self.close_decl("<!ENTITY")
}
fn entity_value(&mut self) -> Checked<String> {
let quote = match self.peek() {
Some(quote @ ('"' | '\'')) => {
self.pos += 1;
quote
}
_ => return Err(err("a quoted entity value is expected")),
};
let mut replacement = String::new();
loop {
match self.bump() {
Some(ch) if ch == quote => return Ok(replacement),
Some('%') => {
return Err(err(
"a parameter entity reference cannot occur inside a markup declaration of the internal subset",
));
}
Some('&') => {
let start = self.pos - 1;
match self.reference()? {
Some(ch) => replacement.push(ch),
None => replacement.push_str(&self.input[start..self.pos]),
}
}
Some(ch) => replacement.push(ch),
None => return Err(err("an entity value is not closed")),
}
}
}
fn reference(&mut self) -> Checked<Option<char>> {
let start = self.pos;
if self.eat('#') {
let radix = if self.eat('x') { 16 } else { 10 };
let digits_start = self.pos;
while matches!(self.peek(), Some(ch) if ch.is_digit(radix)) {
self.pos += 1;
}
let digits = &self.input[digits_start..self.pos];
if digits.is_empty() || !self.eat(';') {
return Err(WellFormedError::InvalidReference(format!(
"&{}",
self.input[start..].chars().take(12).collect::<String>()
)));
}
let code = u32::from_str_radix(digits, radix).ok();
return match code.and_then(char::from_u32) {
Some(ch) if is_char(ch, self.version) => Ok(Some(ch)),
_ => Err(WellFormedError::InvalidReference(
self.input[start - 1..self.pos].to_owned(),
)),
};
}
self.name("in an entity reference")?;
if self.eat(';') {
Ok(None)
} else {
Err(WellFormedError::InvalidReference(
self.input[start - 1..self.pos].to_owned(),
))
}
}
fn notation_decl(&mut self) -> Checked<()> {
self.require_space("after `<!NOTATION`")?;
self.ncname("as a notation name")?;
self.require_space("after a notation name")?;
self.external_or_public_id()?;
self.skip_space();
self.close_decl("<!NOTATION")
}
fn close_decl(&mut self, what: &str) -> Checked<()> {
if self.eat('>') {
Ok(())
} else {
Err(err(format!(
"`>` is expected to close a `{}` declaration, found `{}`",
what,
self.snippet()
)))
}
}
}
pub(crate) fn check_attribute_value(
value: &str,
dtd: &Dtd,
version: XmlVersion,
stack: &mut Vec<String>,
budget: &Budget,
) -> Checked<()> {
let mut rest = value;
while let Some(at) = rest.find('&') {
rest = &rest[at + 1..];
let Some(end) = rest.find(';') else {
return Err(WellFormedError::InvalidReference(format!(
"&{}",
rest.chars().take(12).collect::<String>()
)));
};
let (name, tail) = rest.split_at(end);
check_attribute_reference(name, dtd, version, stack, budget)?;
rest = &tail[1..];
}
Ok(())
}
fn check_attribute_reference(
name: &str,
dtd: &Dtd,
version: XmlVersion,
stack: &mut Vec<String>,
budget: &Budget,
) -> Checked<()> {
if name.starts_with('#') {
return check_character_reference(name, version);
}
if !is_name(name) {
return Err(WellFormedError::InvalidName(name.to_owned()));
}
if PREDEFINED_ENTITIES.contains(&name) {
return Ok(());
}
match dtd.entities.get(name) {
Some(Entity::Unparsed) => Err(WellFormedError::UnparsedEntityReference(name.to_owned())),
Some(Entity::External) => Err(WellFormedError::ExternalEntityInAttributeValue(
name.to_owned(),
)),
Some(Entity::Internal(replacement)) => {
if stack.iter().any(|open| open == name) || stack.len() >= MAX_ENTITY_DEPTH {
return Err(WellFormedError::RecursiveEntity(name.to_owned()));
}
if replacement.contains('<') {
return Err(WellFormedError::LessThanInAttributeValue(name.to_owned()));
}
budget.charge(name, replacement.len())?;
let replacement = replacement.clone();
stack.push(name.to_owned());
let outcome = check_attribute_value(&replacement, dtd, version, stack, budget);
stack.pop();
outcome.map_err(|error| match error {
limit @ WellFormedError::EntityExpansionLimit { .. } => limit,
other => WellFormedError::IllFormedEntity {
entity: name.to_owned(),
reason: other.to_string(),
},
})
}
None if dtd.incomplete => Ok(()),
None => Err(WellFormedError::UndeclaredEntity(name.to_owned())),
}
}
pub(crate) fn check_character_reference(name: &str, version: XmlVersion) -> Checked<()> {
let digits = name.strip_prefix('#').unwrap_or(name);
let (digits, radix) = match digits.strip_prefix('x') {
Some(hex) => (hex, 16),
None => (digits, 10),
};
let valid = !digits.is_empty()
&& digits.chars().all(|ch| ch.is_digit(radix))
&& u32::from_str_radix(digits, radix)
.ok()
.and_then(char::from_u32)
.is_some_and(|ch| is_char(ch, version));
if valid {
Ok(())
} else {
Err(WellFormedError::InvalidReference(format!("&{};", name)))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn check(body: &str) -> Result<Dtd, WellFormedError> {
validate_doctype(body, XmlVersion::Implicit1_0, false, false)
}
fn check_standalone(body: &str) -> Result<Dtd, WellFormedError> {
validate_doctype(body, XmlVersion::Implicit1_0, false, true)
}
#[test]
fn minimal() -> Result<(), WellFormedError> {
let dtd = check("doc")?;
assert!(dtd.entities.is_empty());
assert!(!dtd.incomplete);
Ok(())
}
#[test]
fn external_subset_marks_incomplete() -> Result<(), WellFormedError> {
assert!(check("doc SYSTEM \"doc.dtd\"")?.incomplete);
assert!(check("doc PUBLIC \"-//X//DTD//EN\" \"doc.dtd\"")?.incomplete);
Ok(())
}
#[test]
fn standalone_documents_are_never_incomplete() -> Result<(), WellFormedError> {
assert!(!check_standalone("doc SYSTEM \"doc.dtd\"")?.incomplete);
assert!(!check_standalone("doc PUBLIC \"-//X//DTD//EN\" \"doc.dtd\"")?.incomplete);
assert!(!check_standalone("doc [<!ENTITY % p SYSTEM 'p'>%p;]")?.incomplete);
assert!(check("doc [<!ENTITY % p SYSTEM 'p'>%p;]")?.incomplete);
Ok(())
}
#[test]
fn collects_entities() -> Result<(), WellFormedError> {
let dtd = check(
"doc [<!ENTITY e \"<text\"><!NOTATION n SYSTEM \"n\"><!ENTITY u SYSTEM \"u\" NDATA n>]",
)?;
assert_eq!(
dtd.entities.get("e"),
Some(&Entity::Internal("<text".into()))
);
assert_eq!(dtd.entities.get("u"), Some(&Entity::Unparsed));
Ok(())
}
#[test]
fn rejects_bad_declarations() {
for body in [
"doc [<!DUNNO x>]",
"doc [<!ELEMENT doc CDATA>]",
"doc [<!ELEMENT doc (a, (b) | c)?>]",
"doc [<!ELEMENT doc ((#PCDATA))>]",
"doc [<!ELEMENT doc (#PCDATA)+>]",
"doc [<!ELEMENT doc (a *)>]",
"doc [<!ELEMENT doc (a) *>]",
"doc [<!ATTLIST doc a1 (foo,bar) #IMPLIED>]",
"doc [<!ATTLIST doc a1 NAME #IMPLIED>]",
"doc [<!ATTLIST doc a1(foo|bar) #IMPLIED>]",
"doc [<!ENTITY foo PUBLIC \"some public id\">]",
"doc [<!ENTITY foo\"some text\">]",
"doc [<!ENTITY foo \"&\">]",
"doc [<!ENTITY #DEFAULT \"default\">]",
"doc [<![INCLUDE[ ]]>]",
"doc [<!NOTATION not1 PUBLIC '\"'>]",
"doc -- a comment -- []",
"doc [<!ELEMENT doc EMPTY><doc/>]",
"doc [<!ENTITY % e \"\"><!ENTITY foo \"%e;\">]",
"doc [<!ELEMENT doc (#PCDATA)><!ATTLIST doc a CDATA \"&e;\"><!ENTITY e 'v'>]",
"doc [<!ELEMENT doc (#PCDATA)><!ENTITY e SYSTEM 'e'><!ATTLIST doc a CDATA \"&e;\">]",
] {
assert!(check(body).is_err(), "expected an error for `{body}`");
}
}
#[test]
fn accepts_good_declarations() -> Result<(), WellFormedError> {
for body in [
"doc [<!ELEMENT doc EMPTY>]",
"doc [<!ELEMENT doc ANY>]",
"doc [<!ELEMENT doc (#PCDATA)>]",
"doc [<!ELEMENT doc (#PCDATA | a | b)*>]",
"doc [<!ELEMENT doc (a, b?, (c | d)+)*>]",
"doc [<!ATTLIST doc a CDATA #IMPLIED b (x | y) 'x' c NOTATION (n) #REQUIRED>]",
"doc [<!NOTATION n SYSTEM 'n'><!ENTITY e SYSTEM 'e' NDATA n>]",
"doc [<!-- comment --><?pi data?>]",
"doc [<!ENTITY % p 'text'>]",
"doc [<!ELEMENT doc (#PCDATA)><!ENTITY e 'v'><!ATTLIST doc a CDATA \"&e;\">]",
] {
check(body)?;
}
Ok(())
}
}