fn is_html_whitespace(c: char) -> bool {
matches!(c, ' ' | '\t' | '\u{0C}' | '\n' | '\r')
}
pub(crate) fn check_id(value: &str) -> Result<(), String> {
if value.is_empty() {
return Err("ID must not be empty".to_string());
}
if value.chars().any(is_html_whitespace) {
return Err("ID must not contain whitespace".to_string());
}
Ok(())
}
pub(crate) fn check_idref(value: &str) -> Result<(), String> {
check_id(value)
}
pub(crate) fn check_idrefs(value: &str) -> Result<(), String> {
if value.chars().any(|c| !is_html_whitespace(c)) {
Ok(())
} else {
Err("IDREFS must contain at least one non-whitespace character".to_string())
}
}
pub(crate) fn check_non_empty_string(value: &str) -> Result<(), String> {
if value.is_empty() {
Err("value must not be empty".to_string())
} else {
Ok(())
}
}
pub(crate) fn check_string(_value: &str) -> Result<(), String> {
Ok(())
}
pub(crate) fn values_equal_ascii_case_insensitive(a: &str, b: &str) -> bool {
a.eq_ignore_ascii_case(b)
}
pub(crate) fn check_string_without_line_breaks(value: &str) -> Result<(), String> {
if value.contains('\n') || value.contains('\r') {
Err("value must not contain line breaks".to_string())
} else {
Ok(())
}
}
pub(crate) fn check_zero(value: &str) -> Result<(), String> {
if value == "0" {
Ok(())
} else {
Err("value must be exactly \"0\"".to_string())
}
}
pub(crate) fn check_integer(value: &str) -> Result<(), String> {
if value.is_empty() {
return Err("integer must not be empty".to_string());
}
let chars: Vec<char> = value.chars().collect();
let start = if chars[0] == '-' { 1 } else { 0 };
for &c in &chars[start..] {
if !c.is_ascii_digit() {
return Err(format!("invalid integer: non-digit character '{c}'"));
}
}
Ok(())
}
pub(crate) fn check_integer_non_negative(value: &str) -> Result<(), String> {
if value.is_empty() {
return Err("integer must not be empty".to_string());
}
if !value.chars().all(|c| c.is_ascii_digit()) {
return Err("value must contain only ASCII digits, no sign".to_string());
}
Ok(())
}
pub(crate) fn check_integer_positive(value: &str) -> Result<(), String> {
check_integer_non_negative(value)?;
if value.chars().all(|c| c == '0') {
return Err("value must not be all zeros".to_string());
}
Ok(())
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum FloatVariant {
Any,
NonNegative,
Positive,
}
fn check_float_value(value: &str, variant: FloatVariant) -> Result<(), String> {
let chars: Vec<char> = value.chars().collect();
let n = chars.len();
let mut i = 0usize;
let mut negative = false;
if i < n && chars[i] == '-' {
if variant == FloatVariant::Positive {
return Err("float-positive must not start with '-'".to_string());
}
negative = true;
i += 1;
}
let mut saw_mantissa_digit = false;
let mut mantissa_all_zero = true;
if i < n && chars[i] == '.' {
i += 1;
let frac_start = i;
while i < n && chars[i].is_ascii_digit() {
if chars[i] != '0' {
mantissa_all_zero = false;
}
saw_mantissa_digit = true;
i += 1;
}
if i == frac_start {
return Err("expected digit(s) after '.'".to_string());
}
} else {
let int_start = i;
while i < n && chars[i].is_ascii_digit() {
if chars[i] != '0' {
mantissa_all_zero = false;
}
saw_mantissa_digit = true;
i += 1;
}
if i == int_start {
return Err("expected digit(s) or '.'".to_string());
}
if i < n && chars[i] == '.' {
i += 1;
let frac_start = i;
while i < n && chars[i].is_ascii_digit() {
if chars[i] != '0' {
mantissa_all_zero = false;
}
saw_mantissa_digit = true;
i += 1;
}
if i == frac_start {
return Err("expected digit(s) after '.'".to_string());
}
}
}
if !saw_mantissa_digit {
return Err("no mantissa digits found".to_string());
}
if variant == FloatVariant::NonNegative && negative && !mantissa_all_zero {
return Err(
"float-non-negative: a negative value must be an all-zero mantissa (e.g. -0, -0.0)"
.to_string(),
);
}
if i < n && (chars[i] == 'e' || chars[i] == 'E') {
i += 1;
if i < n && (chars[i] == '+' || chars[i] == '-') {
i += 1;
}
let exp_start = i;
while i < n && chars[i].is_ascii_digit() {
i += 1;
}
if i == exp_start {
return Err("expected digit(s) in exponent".to_string());
}
}
if i != n {
return Err(format!("unexpected trailing character at position {i}"));
}
if variant == FloatVariant::Positive && mantissa_all_zero {
return Err("float-positive: value must not be zero".to_string());
}
Ok(())
}
pub(crate) fn check_float(value: &str) -> Result<(), String> {
check_float_value(value, FloatVariant::Any)
}
pub(crate) fn check_float_non_negative(value: &str) -> Result<(), String> {
check_float_value(value, FloatVariant::NonNegative)
}
pub(crate) fn check_float_positive(value: &str) -> Result<(), String> {
check_float_value(value, FloatVariant::Positive)
}
pub(crate) fn check_hash_name(value: &str) -> Result<(), String> {
if value.starts_with('#') && value.len() > 1 {
Ok(())
} else {
Err("hash-name must start with '#' followed by at least one character".to_string())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn id_valid() {
assert!(check_id("foo").is_ok());
}
#[test]
fn id_empty_invalid() {
assert!(check_id("").is_err());
}
#[test]
fn id_whitespace_invalid() {
assert!(check_id("foo bar").is_err());
assert!(check_id("foo\tbar").is_err());
assert!(check_id("foo\u{0C}bar").is_err());
}
#[test]
fn idref_delegates_to_id() {
assert!(check_idref("foo").is_ok());
assert!(check_idref("foo bar").is_err());
assert!(check_idref("").is_err());
}
#[test]
fn idrefs_valid() {
assert!(check_idrefs("foo bar").is_ok());
}
#[test]
fn idrefs_empty_invalid() {
assert!(check_idrefs("").is_err());
}
#[test]
fn idrefs_all_whitespace_invalid() {
assert!(check_idrefs(" \t\n").is_err());
}
#[test]
fn non_empty_string_valid() {
assert!(check_non_empty_string("foo").is_ok());
}
#[test]
fn non_empty_string_empty_invalid() {
assert!(check_non_empty_string("").is_err());
}
#[test]
fn non_empty_string_whitespace_only_is_valid() {
assert!(check_non_empty_string(" ").is_ok());
}
#[test]
fn string_always_valid() {
assert!(check_string("anything").is_ok());
assert!(check_string("").is_ok());
assert!(check_string(" \n\t").is_ok());
}
#[test]
fn string_values_equal_ascii_case_insensitive() {
assert!(values_equal_ascii_case_insensitive("Foo", "foo"));
assert!(!values_equal_ascii_case_insensitive("Foo", "bar"));
assert!(!values_equal_ascii_case_insensitive("Straße", "STRASSE"));
}
#[test]
fn string_without_line_breaks_valid() {
assert!(check_string_without_line_breaks("foo bar").is_ok());
}
#[test]
fn string_without_line_breaks_empty_is_valid() {
assert!(check_string_without_line_breaks("").is_ok());
}
#[test]
fn string_without_line_breaks_rejects_newlines() {
assert!(check_string_without_line_breaks("foo\nbar").is_err());
assert!(check_string_without_line_breaks("foo\rbar").is_err());
}
#[test]
fn zero_exact_match_valid() {
assert!(check_zero("0").is_ok());
}
#[test]
fn zero_empty_invalid() {
assert!(check_zero("").is_err());
}
#[test]
fn zero_rejects_variants() {
assert!(check_zero("00").is_err());
assert!(check_zero("-0").is_err());
assert!(check_zero("0 ").is_err());
}
#[test]
fn integer_valid() {
assert!(check_integer("42").is_ok());
assert!(check_integer("-42").is_ok());
}
#[test]
fn integer_empty_invalid() {
assert!(check_integer("").is_err());
}
#[test]
fn integer_leading_zeros_allowed() {
assert!(check_integer("007").is_ok());
}
#[test]
fn integer_lone_minus_passes_per_vnu_quirk() {
assert!(check_integer("-").is_ok());
}
#[test]
fn integer_rejects_leading_plus() {
assert!(check_integer("+42").is_err());
}
#[test]
fn integer_rejects_non_digit() {
assert!(check_integer("4a").is_err());
}
#[test]
fn integer_non_negative_valid() {
assert!(check_integer_non_negative("007").is_ok());
}
#[test]
fn integer_non_negative_empty_invalid() {
assert!(check_integer_non_negative("").is_err());
}
#[test]
fn integer_non_negative_rejects_any_sign() {
assert!(check_integer_non_negative("-1").is_err());
assert!(check_integer_non_negative("+1").is_err());
}
#[test]
fn integer_positive_valid() {
assert!(check_integer_positive("42").is_ok());
}
#[test]
fn integer_positive_empty_invalid() {
assert!(check_integer_positive("").is_err());
}
#[test]
fn integer_positive_all_zero_invalid() {
assert!(check_integer_positive("0").is_err());
assert!(check_integer_positive("000").is_err());
}
#[test]
fn integer_positive_leading_zero_nonzero_value_valid() {
assert!(check_integer_positive("001").is_ok());
}
#[test]
fn float_valid() {
assert!(check_float("42.5").is_ok());
}
#[test]
fn float_empty_invalid() {
assert!(check_float("").is_err());
}
#[test]
fn float_leading_dot_form_valid() {
assert!(check_float(".5").is_ok());
}
#[test]
fn float_negative_valid() {
assert!(check_float("-1").is_ok());
}
#[test]
fn float_rejects_leading_plus() {
assert!(check_float("+1").is_err());
}
#[test]
fn float_exponent_form_valid() {
assert!(check_float("1e10").is_ok());
assert!(check_float("1.5e-3").is_ok());
}
#[test]
fn float_rejects_incomplete_forms() {
assert!(check_float("5.").is_err());
assert!(check_float("5e").is_err());
assert!(check_float("5e+").is_err());
}
#[test]
fn float_non_negative_valid() {
assert!(check_float_non_negative("1.5").is_ok());
}
#[test]
fn float_non_negative_empty_invalid() {
assert!(check_float_non_negative("").is_err());
}
#[test]
fn float_non_negative_zero_spellings_valid() {
assert!(check_float_non_negative("-0").is_ok());
assert!(check_float_non_negative("-0.0").is_ok());
assert!(check_float_non_negative("-0.000e5").is_ok());
}
#[test]
fn float_non_negative_rejects_real_negative() {
assert!(check_float_non_negative("-1").is_err());
}
#[test]
fn float_non_negative_exponent_form_valid() {
assert!(check_float_non_negative("1e10").is_ok());
}
#[test]
fn float_positive_valid() {
assert!(check_float_positive("0.01").is_ok());
}
#[test]
fn float_positive_empty_invalid() {
assert!(check_float_positive("").is_err());
}
#[test]
fn float_positive_rejects_all_zero() {
assert!(check_float_positive("0").is_err());
assert!(check_float_positive("0.0").is_err());
assert!(check_float_positive("0e10").is_err());
}
#[test]
fn float_positive_rejects_minus() {
assert!(check_float_positive("-1").is_err());
}
#[test]
fn float_positive_exponent_form_valid() {
assert!(check_float_positive("1e10").is_ok());
}
#[test]
fn hash_name_valid() {
assert!(check_hash_name("#foo").is_ok());
}
#[test]
fn hash_name_empty_invalid() {
assert!(check_hash_name("").is_err());
}
#[test]
fn hash_name_bare_hash_invalid() {
assert!(check_hash_name("#").is_err());
}
#[test]
fn hash_name_missing_hash_invalid() {
assert!(check_hash_name("foo").is_err());
}
}