schematron 0.1.0

Schematron: pure Rust implementation of ISO/IEC 19757-3 rule-based XML validation, with its own XML parser, XPath 1.0 engine, XPath 2.0 subset, schema linting, and SVRL reporting. By SixArm.com.
Documentation
//! XPath 1.0 values and the conversions between them.
//!
//! XPath 1.0 has exactly four types, and the conversions between them are
//! total and lossy in ways that matter. They are implemented here literally,
//! including the number-to-string format, which is the detail engines most
//! often get wrong.

use crate::xml::{Document, NodeId};

/// An XPath 1.0 value.
///
/// # Examples
///
/// ```
/// use schematron::xpath::Value;
///
/// assert_eq!(Value::Number(3.0).to_xpath_string_scalar(), "3");
/// assert!(Value::String("x".into()).to_boolean_scalar());
/// assert!(!Value::String(String::new()).to_boolean_scalar());
/// ```
#[derive(Debug, Clone, PartialEq)]
pub enum Value {
    /// A set of nodes, kept sorted by document order and deduplicated.
    NodeSet(Vec<NodeId>),
    /// A boolean.
    Boolean(bool),
    /// A number, an IEEE 754 double as XPath 1.0 requires.
    Number(f64),
    /// A string.
    String(String),
}

impl Value {
    /// The name of this value's type, for error messages.
    #[must_use]
    pub const fn type_name(&self) -> &'static str {
        match self {
            Value::NodeSet(_) => "node-set",
            Value::Boolean(_) => "boolean",
            Value::Number(_) => "number",
            Value::String(_) => "string",
        }
    }

    /// Converts to boolean per XPath 1.0.
    ///
    /// A node-set is true when it is non-empty; a number when it is neither
    /// zero nor NaN; a string when it is non-empty.
    #[must_use]
    pub fn to_boolean(&self) -> bool {
        match self {
            Value::NodeSet(nodes) => !nodes.is_empty(),
            Value::Boolean(b) => *b,
            Value::Number(n) => *n != 0.0 && !n.is_nan(),
            Value::String(s) => !s.is_empty(),
        }
    }

    /// Converts to boolean without needing a document.
    ///
    /// Identical to [`Value::to_boolean`]; provided so that documentation
    /// examples can convert a scalar without constructing a tree.
    #[must_use]
    pub fn to_boolean_scalar(&self) -> bool {
        self.to_boolean()
    }

    /// Converts to string per XPath 1.0.
    ///
    /// A node-set becomes the string value of its first node in document
    /// order, or the empty string when it is empty.
    #[must_use]
    pub fn to_xpath_string(&self, document: &Document) -> String {
        match self {
            Value::NodeSet(nodes) => nodes
                .first()
                .map_or_else(String::new, |&n| document.string_value(n)),
            Value::Boolean(b) => if *b { "true" } else { "false" }.to_string(),
            Value::Number(n) => format_number(*n),
            Value::String(s) => s.clone(),
        }
    }

    /// Converts a non-node-set to string.
    ///
    /// Returns the empty string for a node-set, which has no string value
    /// without a document to look nodes up in.
    #[must_use]
    pub fn to_xpath_string_scalar(&self) -> String {
        match self {
            Value::NodeSet(_) => String::new(),
            Value::Boolean(b) => if *b { "true" } else { "false" }.to_string(),
            Value::Number(n) => format_number(*n),
            Value::String(s) => s.clone(),
        }
    }

    /// Converts to number per XPath 1.0.
    ///
    /// Anything that is not a valid XPath number becomes NaN, rather than
    /// being an error, which is why a typo in a numeric comparison silently
    /// yields false instead of failing.
    #[must_use]
    pub fn to_number(&self, document: &Document) -> f64 {
        match self {
            Value::Number(n) => *n,
            Value::Boolean(b) => f64::from(u8::from(*b)),
            Value::String(s) => parse_number(s),
            Value::NodeSet(_) => parse_number(&self.to_xpath_string(document)),
        }
    }

    /// Returns the node-set, or `None` for the other three types.
    #[must_use]
    pub fn as_node_set(&self) -> Option<&[NodeId]> {
        match self {
            Value::NodeSet(nodes) => Some(nodes),
            _ => None,
        }
    }
}

/// Parses a string as an XPath number.
///
/// XPath 1.0 accepts optional leading and trailing whitespace, an optional
/// sign, and a decimal number with no exponent. Rust's `f64::from_str` accepts
/// more than that — `1e5`, `inf`, `NaN`, `0x1p3` — so the input is screened
/// before it is handed over.
#[must_use]
pub fn parse_number(input: &str) -> f64 {
    let trimmed = input.trim_matches(|c: char| c.is_ascii_whitespace());
    if trimmed.is_empty() {
        return f64::NAN;
    }
    let body = trimmed.strip_prefix('-').unwrap_or(trimmed);
    let valid = !body.is_empty()
        && body.chars().all(|c| c.is_ascii_digit() || c == '.')
        && body.matches('.').count() <= 1
        && body != ".";
    if !valid {
        return f64::NAN;
    }
    trimmed.parse::<f64>().unwrap_or(f64::NAN)
}

/// Formats a number as XPath 1.0's `string()` function does.
///
/// The rules that differ from Rust's own formatting: NaN is `NaN`, infinities
/// are `Infinity` and `-Infinity`, negative zero is `0`, integers carry no
/// decimal point, and exponential notation is never used — so `1e21` prints
/// as twenty-two characters, not as `1e21`.
///
/// # Examples
///
/// ```
/// use schematron::xpath::format_number;
///
/// assert_eq!(format_number(3.0), "3");
/// assert_eq!(format_number(-0.0), "0");
/// assert_eq!(format_number(0.5), "0.5");
/// assert_eq!(format_number(f64::NAN), "NaN");
/// assert_eq!(format_number(1e21), "1000000000000000000000");
/// ```
#[must_use]
pub fn format_number(value: f64) -> String {
    if value.is_nan() {
        return "NaN".to_string();
    }
    if value.is_infinite() {
        return if value > 0.0 { "Infinity" } else { "-Infinity" }.to_string();
    }
    if value == 0.0 {
        // Covers both zeroes; XPath has no negative zero in its lexical space.
        return "0".to_string();
    }
    if value.fract() == 0.0 && value.abs() < 1e21 {
        // Integral and small enough that no rounding is involved.
        return format!("{value:.0}");
    }

    // `{}` gives the shortest representation that round-trips, which is what
    // XPath wants, but it switches to exponential notation for extreme
    // magnitudes, which XPath forbids.
    let shortest = format!("{value}");
    if !shortest.contains('e') && !shortest.contains('E') {
        return shortest;
    }
    expand_exponential(&shortest)
}

/// Rewrites a Rust exponential rendering into plain decimal notation.
fn expand_exponential(input: &str) -> String {
    let (mantissa, exponent) = input
        .split_once(['e', 'E'])
        .expect("caller checked that an exponent is present");
    let exponent: i32 = exponent.parse().unwrap_or(0);

    let negative = mantissa.starts_with('-');
    let mantissa = mantissa.trim_start_matches('-');
    let (int_part, frac_part) = mantissa.split_once('.').unwrap_or((mantissa, ""));
    let digits: String = format!("{int_part}{frac_part}");
    // Where the decimal point sits after shifting by the exponent.
    let point = i32::try_from(int_part.len()).unwrap_or(i32::MAX) + exponent;

    let mut out = String::new();
    if negative {
        out.push('-');
    }
    if point <= 0 {
        out.push_str("0.");
        for _ in 0..-point {
            out.push('0');
        }
        out.push_str(digits.trim_end_matches('0'));
    } else {
        let point = usize::try_from(point).unwrap_or(0);
        if point >= digits.len() {
            out.push_str(&digits);
            for _ in 0..point - digits.len() {
                out.push('0');
            }
        } else {
            out.push_str(&digits[..point]);
            let tail = digits[point..].trim_end_matches('0');
            if !tail.is_empty() {
                out.push('.');
                out.push_str(tail);
            }
        }
    }
    if out.is_empty() || out == "-" {
        out.push('0');
    }
    out
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn format_integers_without_a_point() {
        assert_eq!(format_number(3.0), "3");
        assert_eq!(format_number(-3.0), "-3");
        assert_eq!(format_number(0.0), "0");
        assert_eq!(format_number(-0.0), "0");
    }

    #[test]
    fn format_fractions_shortest_round_trip() {
        assert_eq!(format_number(0.5), "0.5");
        assert_eq!(format_number(-1.25), "-1.25");
        assert_eq!(format_number(1.0 / 3.0), "0.3333333333333333");
    }

    #[test]
    fn format_specials() {
        assert_eq!(format_number(f64::NAN), "NaN");
        assert_eq!(format_number(f64::INFINITY), "Infinity");
        assert_eq!(format_number(f64::NEG_INFINITY), "-Infinity");
    }

    #[test]
    fn format_never_uses_exponential_notation() {
        assert_eq!(format_number(1e21), "1000000000000000000000");
        assert_eq!(format_number(1e-7), "0.0000001");
        assert!(!format_number(1e300).contains('e'));
    }

    #[test]
    fn parse_rejects_what_xpath_rejects() {
        assert!(parse_number("1e5").is_nan());
        assert!(parse_number("inf").is_nan());
        assert!(parse_number("NaN").is_nan());
        assert!(parse_number("0x10").is_nan());
        assert!(parse_number("").is_nan());
        assert!(parse_number("+1").is_nan());
    }

    #[test]
    fn parse_accepts_what_xpath_accepts() {
        assert_eq!(parse_number(" 42 "), 42.0);
        assert_eq!(parse_number("-1.5"), -1.5);
        assert_eq!(parse_number(".5"), 0.5);
        assert_eq!(parse_number("5."), 5.0);
    }

    #[test]
    fn boolean_conversion() {
        assert!(Value::Number(1.0).to_boolean());
        assert!(!Value::Number(0.0).to_boolean());
        assert!(!Value::Number(f64::NAN).to_boolean());
        assert!(!Value::NodeSet(vec![]).to_boolean());
        assert!(Value::String("0".into()).to_boolean());
    }
}