Skip to main content

xpath_eval/
value.rs

1//! The XPath 1.0 value model (§3.1: the four object types) and the value
2//! conversion functions (§4.2-§4.4), implemented against the verbatim spec
3//! quotes in `plan/04-evaluation-core.md`.
4
5use crate::document::Node;
6
7/// An XPath 1.0 value: one of the four object types (§3.1) — "node-set
8/// (an unordered collection of nodes without duplicates), boolean, number
9/// ... [or] string".
10///
11/// Parameterized only over the node type `N`, not over a separate lifetime
12/// — a concrete `N` already implements `Node<'a>` for one specific `'a`
13/// (e.g. `TestNode<'a>`), so that lifetime doesn't need to be repeated here
14/// (the plan's sketched `Value<'a, N: Node<'a>>` shape is adjusted to avoid
15/// an otherwise-unused lifetime parameter on the enum itself).
16#[derive(Clone, PartialEq)]
17pub enum Value<N> {
18    NodeSet(Vec<N>),
19    Boolean(bool),
20    /// An IEEE-754 double-precision floating point number.
21    Number(f64),
22    String(String),
23}
24
25// Manual `Debug` (rather than `#[derive(Debug)]`) so `Value` itself does not
26// pick up an unconditional `N: Debug` bound — `Node` (`document.rs`) does not
27// require `Debug`, and this type should stay usable for any conforming `N`.
28impl<N: std::fmt::Debug> std::fmt::Debug for Value<N> {
29    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
30        match self {
31            Value::NodeSet(nodes) => f.debug_tuple("NodeSet").field(nodes).finish(),
32            Value::Boolean(b) => f.debug_tuple("Boolean").field(b).finish(),
33            Value::Number(n) => f.debug_tuple("Number").field(n).finish(),
34            Value::String(s) => f.debug_tuple("String").field(s).finish(),
35        }
36    }
37}
38
39impl<'a, N: Node<'a>> Value<N> {
40    /// §4.3, verbatim: "a number is true if and only if it is neither
41    /// positive or negative zero nor NaN; a node-set is true if and only if
42    /// it is non-empty; a string is true if and only if its length is
43    /// non-zero".
44    pub fn to_boolean(&self) -> bool {
45        match self {
46            Value::NodeSet(nodes) => !nodes.is_empty(),
47            Value::Boolean(b) => *b,
48            Value::Number(n) => *n != 0.0 && !n.is_nan(),
49            Value::String(s) => !s.is_empty(),
50        }
51    }
52
53    /// §4.4: string→number (via the node-set→string→number chain for
54    /// node-sets), boolean→number ("true is converted to 1; false to 0"),
55    /// and the identity for numbers.
56    pub fn to_number(&self) -> f64 {
57        match self {
58            Value::NodeSet(_) => string_to_number(&self.to_xpath_string()),
59            Value::Boolean(b) => {
60                if *b {
61                    1.0
62                } else {
63                    0.0
64                }
65            }
66            Value::Number(n) => *n,
67            Value::String(s) => string_to_number(s),
68        }
69    }
70
71    /// §4.2: node-set→string ("the string-value of the node ... that is
72    /// first in document order. If the node-set is empty, an empty string
73    /// is returned"), number→string (see `number_to_string`), and
74    /// boolean→string ("false"/"true").
75    ///
76    /// Named `to_xpath_string` rather than `to_string` so this inherent
77    /// method does not collide with (and get flagged by clippy as shadowing)
78    /// `std::string::ToString` — `Value` deliberately does not implement
79    /// `Display`/`ToString`.
80    pub fn to_xpath_string(&self) -> String {
81        match self {
82            Value::NodeSet(nodes) => first_in_document_order(nodes)
83                .map(|n| n.string_value())
84                .unwrap_or_default(),
85            Value::Boolean(b) => if *b { "true" } else { "false" }.to_string(),
86            Value::Number(n) => number_to_string(*n),
87            Value::String(s) => s.clone(),
88        }
89    }
90}
91
92/// The node in `nodes` that is first in document order, or `None` if empty.
93/// `nodes` is a node-set (unordered per the data model), so this must not
94/// assume `nodes[0]` is already the document-order-first node.
95fn first_in_document_order<'a, N: Node<'a>>(nodes: &[N]) -> Option<N> {
96    nodes.iter().copied().min_by(|a, b| a.document_order(*b))
97}
98
99/// §4.2, number→string, verbatim: "NaN is converted to the string NaN;
100/// positive zero is converted to the string 0; negative zero is converted
101/// to the string 0; positive infinity is converted to the string Infinity;
102/// negative infinity is converted to the string -Infinity; if the number is
103/// an integer, the number is represented in decimal form as a Number with
104/// no decimal point and no leading zeros, preceded by a minus sign (-) if
105/// the number is negative; otherwise, the number is represented in decimal
106/// form as a Number including a decimal point with at least one digit
107/// before the decimal point and at least one digit after the decimal
108/// point, preceded by a minus sign (-) if the number is negative [...]".
109///
110/// Rust's `f64::to_string()`/`Display` already produces a correctly-rounded,
111/// shortest-round-tripping decimal expansion with no exponential notation,
112/// no ".0" suffix on integer-valued floats, and at least one digit on each
113/// side of the point for non-integers (verified experimentally, including
114/// for large/small magnitudes) — so only the special values (NaN/Infinity/
115/// negative zero) need overriding here, not general-purpose formatting.
116fn number_to_string(n: f64) -> String {
117    if n.is_nan() {
118        return "NaN".to_string();
119    }
120    if n.is_infinite() {
121        return if n > 0.0 {
122            "Infinity".to_string()
123        } else {
124            "-Infinity".to_string()
125        };
126    }
127    if n == 0.0 {
128        // `n == 0.0` is true for both +0.0 and -0.0 (IEEE-754), but Rust's
129        // Display prints "-0" for negative zero — the spec maps both to "0".
130        return "0".to_string();
131    }
132    n.to_string()
133}
134
135/// §4.4, string→number, verbatim: "a string that consists of optional
136/// whitespace followed by an optional minus sign followed by a Number
137/// followed by whitespace is converted to the IEEE 754 number that is
138/// nearest ... to the mathematical value represented by the string; any
139/// other string is converted to NaN".
140///
141/// The "Number" referred to is grammar production [30] `Number ::= Digits
142/// ('.' Digits?)? | '.' Digits` — notably *not* the same as what Rust's
143/// `f64::from_str` accepts (which additionally allows exponents, leading
144/// `+`, `inf`/`infinity`/`nan`, etc.). This function hand-validates the
145/// stricter XPath grammar first and only then hands the validated
146/// digits-and-at-most-one-dot substring to `f64::from_str` for the actual
147/// IEEE-754 rounding.
148pub(crate) fn string_to_number(s: &str) -> f64 {
149    let trimmed = s.trim_matches(|c: char| matches!(c, ' ' | '\t' | '\r' | '\n'));
150    let (negative, rest) = match trimmed.strip_prefix('-') {
151        Some(r) => (true, r),
152        None => (false, trimmed),
153    };
154    let b = rest.as_bytes();
155    let mut pos = 0;
156    while pos < b.len() && b[pos].is_ascii_digit() {
157        pos += 1;
158    }
159    let has_int_digits = pos > 0;
160    let mut has_frac_digits = false;
161    if pos < b.len() && b[pos] == b'.' {
162        pos += 1;
163        let frac_start = pos;
164        while pos < b.len() && b[pos].is_ascii_digit() {
165            pos += 1;
166        }
167        has_frac_digits = pos > frac_start;
168    }
169    // The whole (trimmed, sign-stripped) string must be consumed, and it
170    // must contain at least one digit somewhere (either side of the '.').
171    if pos != b.len() || !(has_int_digits || has_frac_digits) {
172        return f64::NAN;
173    }
174    let digits = &rest[..pos];
175    let full = if negative {
176        format!("-{digits}")
177    } else {
178        digits.to_string()
179    };
180    full.parse::<f64>().unwrap_or(f64::NAN)
181}
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186    use crate::document::fixture::build;
187
188    // ---- to_boolean (§4.3) ---------------------------------------------
189
190    #[test]
191    fn boolean_number_rules() {
192        assert!(!Value::<crate::document::fixture::TestNode>::Number(0.0).to_boolean());
193        assert!(!Value::<crate::document::fixture::TestNode>::Number(-0.0).to_boolean());
194        assert!(!Value::<crate::document::fixture::TestNode>::Number(f64::NAN).to_boolean());
195        assert!(Value::<crate::document::fixture::TestNode>::Number(1.0).to_boolean());
196        assert!(Value::<crate::document::fixture::TestNode>::Number(-1.0).to_boolean());
197    }
198
199    #[test]
200    fn boolean_string_rules() {
201        assert!(!Value::<crate::document::fixture::TestNode>::String(String::new()).to_boolean());
202        assert!(Value::<crate::document::fixture::TestNode>::String("x".into()).to_boolean());
203    }
204
205    #[test]
206    fn boolean_node_set_rules() {
207        let f = build();
208        assert!(!Value::NodeSet::<crate::document::fixture::TestNode>(Vec::new()).to_boolean());
209        assert!(Value::NodeSet(vec![f.doc.node(f.span)]).to_boolean());
210    }
211
212    // ---- to_number (§4.4) -----------------------------------------------
213
214    #[test]
215    fn number_boolean_rules() {
216        assert_eq!(
217            Value::<crate::document::fixture::TestNode>::Boolean(true).to_number(),
218            1.0
219        );
220        assert_eq!(
221            Value::<crate::document::fixture::TestNode>::Boolean(false).to_number(),
222            0.0
223        );
224    }
225
226    #[test]
227    fn number_string_valid_forms() {
228        assert_eq!(string_to_number("42"), 42.0);
229        assert_eq!(string_to_number("42.5"), 42.5);
230        assert_eq!(string_to_number(".5"), 0.5);
231        assert_eq!(string_to_number("5."), 5.0);
232        assert_eq!(string_to_number("-42"), -42.0);
233        assert_eq!(string_to_number("  42  "), 42.0);
234        assert_eq!(string_to_number("\t-3.5\n"), -3.5);
235    }
236
237    #[test]
238    fn number_string_malformed_is_nan() {
239        assert!(string_to_number("abc").is_nan());
240        assert!(string_to_number("").is_nan());
241        assert!(string_to_number("--5").is_nan());
242        assert!(string_to_number("1.2.3").is_nan());
243        assert!(
244            string_to_number("1e5").is_nan(),
245            "exponent notation is not XPath Number syntax"
246        );
247        assert!(
248            string_to_number("+5").is_nan(),
249            "leading plus is not permitted"
250        );
251        assert!(
252            string_to_number("5 6").is_nan(),
253            "embedded whitespace is not permitted"
254        );
255        assert!(string_to_number("Infinity").is_nan());
256        assert!(string_to_number("NaN").is_nan());
257    }
258
259    #[test]
260    fn number_node_set_uses_first_in_document_order_string_value() {
261        let f = build();
262        // span_attr's string-value is "1" — a valid Number.
263        let value = Value::NodeSet(vec![f.doc.node(f.span_attr)]);
264        assert_eq!(value.to_number(), 1.0);
265        // Order in the Vec must not matter — document order does: `comment`
266        // (document-order index before `text2`) is picked even though it is
267        // listed second here, and its string-value ("note") is not a Number.
268        let value = Value::NodeSet(vec![f.doc.node(f.text2), f.doc.node(f.comment)]);
269        assert!(
270            value.to_number().is_nan(),
271            "comment's string-value ('note') is not a Number"
272        );
273    }
274
275    #[test]
276    fn number_empty_node_set_is_nan() {
277        let value = Value::NodeSet::<crate::document::fixture::TestNode>(Vec::new());
278        assert!(value.to_number().is_nan());
279    }
280
281    // ---- to_xpath_string (§4.2) ------------------------------------------
282
283    #[test]
284    fn string_boolean_rules() {
285        assert_eq!(
286            Value::<crate::document::fixture::TestNode>::Boolean(true).to_xpath_string(),
287            "true"
288        );
289        assert_eq!(
290            Value::<crate::document::fixture::TestNode>::Boolean(false).to_xpath_string(),
291            "false"
292        );
293    }
294
295    #[test]
296    fn string_number_special_values() {
297        assert_eq!(
298            Value::<crate::document::fixture::TestNode>::Number(f64::NAN).to_xpath_string(),
299            "NaN"
300        );
301        assert_eq!(
302            Value::<crate::document::fixture::TestNode>::Number(0.0).to_xpath_string(),
303            "0"
304        );
305        assert_eq!(
306            Value::<crate::document::fixture::TestNode>::Number(-0.0).to_xpath_string(),
307            "0"
308        );
309        assert_eq!(
310            Value::<crate::document::fixture::TestNode>::Number(f64::INFINITY).to_xpath_string(),
311            "Infinity"
312        );
313        assert_eq!(
314            Value::<crate::document::fixture::TestNode>::Number(f64::NEG_INFINITY)
315                .to_xpath_string(),
316            "-Infinity"
317        );
318    }
319
320    #[test]
321    #[allow(clippy::approx_constant)] // 3.14 is a plain test literal, not an approximation of pi
322    fn string_number_integer_vs_decimal_formatting() {
323        assert_eq!(
324            Value::<crate::document::fixture::TestNode>::Number(3.0).to_xpath_string(),
325            "3"
326        );
327        assert_eq!(
328            Value::<crate::document::fixture::TestNode>::Number(-3.0).to_xpath_string(),
329            "-3"
330        );
331        assert_eq!(
332            Value::<crate::document::fixture::TestNode>::Number(100.0).to_xpath_string(),
333            "100"
334        );
335        assert_eq!(
336            Value::<crate::document::fixture::TestNode>::Number(3.14).to_xpath_string(),
337            "3.14"
338        );
339        assert_eq!(
340            Value::<crate::document::fixture::TestNode>::Number(0.5).to_xpath_string(),
341            "0.5"
342        );
343        assert_eq!(
344            Value::<crate::document::fixture::TestNode>::Number(-0.5).to_xpath_string(),
345            "-0.5"
346        );
347    }
348
349    #[test]
350    fn string_node_set_rules() {
351        let f = build();
352        let value = Value::NodeSet::<crate::document::fixture::TestNode>(Vec::new());
353        assert_eq!(value.to_xpath_string(), "");
354        let value = Value::NodeSet(vec![f.doc.node(f.text0)]);
355        assert_eq!(value.to_xpath_string(), "Hello ");
356        // Vec order must not matter — document order picks the first node.
357        let value = Value::NodeSet(vec![f.doc.node(f.text2), f.doc.node(f.text0)]);
358        assert_eq!(value.to_xpath_string(), "Hello ");
359    }
360}