oxml 0.0.11

A pure Rust XML toolkit with zero unsafe code: parsing, an ergonomic tree, and XPath
Documentation
// SPDX-License-Identifier: MIT OR Apache-2.0
// Copyright (c) 2026 oxml. All rights reserved.

//! Typed extraction: naming the Rust type a query's value should have.

use alloc::format;
use alloc::string::String;
use alloc::vec::Vec;

use super::{Value, XPath, XPathError, float};
use crate::tree::{Document, NodeId};

/// A type an [`XPath`] result can be extracted into.
///
/// Implemented for `String`, `f64`, `i64`, `bool` and
/// [`NodeId`]. The conversions follow `XPath` 1.0's own
/// rules with one deliberate exception, documented on the `f64`
/// implementation: a value that is not a number is an error rather
/// than `NaN`, because a caller who names a numeric type wants a
/// number, not a value that poisons every comparison downstream.
pub trait FromXPath: Sized {
    /// Extract `Self` from an evaluated value.
    ///
    /// # Errors
    ///
    /// [`TypeError`] when the value cannot represent `Self` -- what
    /// that means is documented per implementation.
    fn from_value(value: &Value, doc: &Document) -> Result<Self, TypeError>;
}

/// Why a value could not be extracted as the requested type.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TypeError {
    /// What went wrong, in a form a person can act on.
    pub message: String,
}

impl core::fmt::Display for TypeError {
    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
        f.write_str(&self.message)
    }
}

#[cfg(feature = "std")]
impl std::error::Error for TypeError {}

impl FromXPath for String {
    /// `XPath`'s `string()` conversion, which cannot fail.
    fn from_value(value: &Value, doc: &Document) -> Result<Self, TypeError> {
        Ok(value.to_str(doc))
    }
}

impl FromXPath for f64 {
    /// `XPath`'s `number()` conversion, except that `NaN` is an error.
    ///
    /// The specification says a non-numeric string converts to `NaN`.
    /// That is the right behaviour *inside* an expression, where
    /// `NaN`'s comparison rules are part of the language, and this
    /// crate's `Value::to_number` implements it faithfully. At the
    /// boundary into Rust it is the wrong default: a caller writing
    /// `xpath_one::<f64>("//price")` wants the price, and handing back
    /// `NaN` defers the failure to whatever arithmetic touches it
    /// next, stripped of any hint of where it came from.
    fn from_value(value: &Value, doc: &Document) -> Result<Self, TypeError> {
        let n = value.to_number(doc);
        if n.is_nan() {
            return Err(TypeError {
                message: format!("`{}` is not a number", value.to_str(doc)),
            });
        }
        Ok(n)
    }
}

impl FromXPath for i64 {
    /// An `f64` extraction that must also be integral and in range.
    ///
    /// `XPath` 1.0 has no integer type -- every number is an `f64` --
    /// so this is a convenience for the common case of counts and
    /// years. `1.5` is an error, not a truncation: a caller asking for
    /// an integer should not silently receive the floor of something
    /// that was not one.
    fn from_value(value: &Value, doc: &Document) -> Result<Self, TypeError> {
        let n = f64::from_value(value, doc)?;
        // `fract` lives in std, not core; `float::trunc` is the shim
        // this module routes every such call through, so the no_std
        // build breaks in one file rather than a dozen.
        //
        // `n != trunc(n)` is an exact integrality test -- the one case
        // where comparing floats for equality is right rather than
        // sloppy. An epsilon would misclassify values near an integer.
        #[allow(clippy::float_cmp)]
        let fractional = n != float::trunc(n);
        if fractional {
            return Err(TypeError {
                message: format!("`{n}` is not an integer"),
            });
        }
        // Beyond 2^53 an f64 no longer represents every integer, so a
        // value out there may not be the integer it appears to be.
        if n.abs() > 9_007_199_254_740_992.0 {
            return Err(TypeError {
                message: format!(
                    "`{n}` is outside the exactly-representable range"
                ),
            });
        }
        #[allow(clippy::cast_possible_truncation)]
        Ok(n as Self)
    }
}

impl FromXPath for bool {
    /// `XPath`'s `boolean()` conversion, which cannot fail.
    fn from_value(value: &Value, doc: &Document) -> Result<Self, TypeError> {
        let _ = doc;
        Ok(value.to_boolean())
    }
}

impl FromXPath for NodeId {
    /// The first node of a node-set, in document order.
    ///
    /// An empty node-set is an error, and so is a value that is not a
    /// node-set at all -- `string(//a)` produces a string, and there
    /// is no node to hand back.
    fn from_value(value: &Value, doc: &Document) -> Result<Self, TypeError> {
        let _ = doc;
        match value.nodes() {
            Some([first, ..]) => Ok(*first),
            Some([]) => Err(TypeError {
                message: String::from("the expression matched no nodes"),
            }),
            None => Err(TypeError {
                message: String::from(
                    "the expression does not produce a node-set",
                ),
            }),
        }
    }
}

/// Why a typed query failed: either the expression, or the type.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum QueryError {
    /// The expression did not compile.
    Compile(XPathError),
    /// The expression evaluated, but its value cannot represent the
    /// requested type.
    Type(TypeError),
}

impl core::fmt::Display for QueryError {
    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
        match self {
            Self::Compile(e) => write!(f, "{e}"),
            Self::Type(e) => write!(f, "{e}"),
        }
    }
}

#[cfg(feature = "std")]
impl std::error::Error for QueryError {}

impl Document {
    /// Evaluate an expression and extract one typed value.
    ///
    /// ```
    /// # #[cfg(feature = "xpath")] {
    /// let doc = oxml::parse("<order><price>9.99</price><qty>3</qty></order>").unwrap();
    /// let price: f64 = doc.xpath_one("number(//price)").unwrap();
    /// let qty: i64 = doc.xpath_one("number(//qty)").unwrap();
    /// let has_discount: bool = doc.xpath_one("count(//discount) > 0").unwrap();
    /// assert_eq!(price, 9.99);
    /// assert_eq!(qty, 3);
    /// assert!(!has_discount);
    /// # }
    /// ```
    ///
    /// # Errors
    ///
    /// [`QueryError::Compile`] if the expression is invalid,
    /// [`QueryError::Type`] if its value cannot represent `T` -- for
    /// `f64` that includes `NaN`, deliberately; see
    /// [`FromXPath`].
    pub fn xpath_one<T: FromXPath>(&self, expr: &str) -> Result<T, QueryError> {
        let compiled = XPath::compile(expr).map_err(QueryError::Compile)?;
        let value = compiled.evaluate(self);
        T::from_value(&value, self).map_err(QueryError::Type)
    }

    /// Evaluate an expression and extract every matched node as `T`.
    ///
    /// The expression must produce a node-set; each node's
    /// string-value is then converted independently, so one
    /// unconvertible node fails the whole call rather than being
    /// silently skipped.
    ///
    /// ```
    /// # #[cfg(feature = "xpath")] {
    /// let doc = oxml::parse(
    ///     "<cart><item price='9.99'/><item price='7.50'/></cart>",
    /// ).unwrap();
    /// let prices: Vec<f64> = doc.xpath_all("//item/@price").unwrap();
    /// assert_eq!(prices, [9.99, 7.5]);
    /// # }
    /// ```
    ///
    /// # Errors
    ///
    /// [`QueryError::Compile`] if the expression is invalid,
    /// [`QueryError::Type`] if it does not produce a node-set or any
    /// node's value cannot represent `T`.
    pub fn xpath_all<T: FromXPath>(
        &self,
        expr: &str,
    ) -> Result<Vec<T>, QueryError> {
        let compiled = XPath::compile(expr).map_err(QueryError::Compile)?;
        let value = compiled.evaluate(self);
        let Some(nodes) = value.nodes() else {
            return Err(QueryError::Type(TypeError {
                message: String::from(
                    "the expression does not produce a node-set",
                ),
            }));
        };
        nodes
            .iter()
            .map(|id| {
                let single = Value::NodeSet(alloc::vec![*id]);
                T::from_value(&single, self).map_err(QueryError::Type)
            })
            .collect()
    }
}