1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
// SPDX-License-Identifier: MIT OR Apache-2.0
// Copyright (c) 2026 oxml. All rights reserved.
//! Property-based tests.
//!
//! Example-based tests check the cases someone thought of. These state
//! invariants that must hold for *every* input, and let proptest search
//! for the counterexample. Failures are recorded in
//! `proptest-regressions/` and replayed on every later run, so a bug
//! found once is tested for forever.
use oxml::{Limits, NodeKind, XPath, parse, parse_with};
use proptest::prelude::*;
/// Arbitrary bytes, biased towards things that look like XML.
fn xml_ish() -> impl Strategy<Value = String> {
prop_oneof![
// Free-form: finds the shapes nobody would write by hand.
2 => "\\PC{0,200}",
// Fragments assembled from real tokens: gets past the parser's
// first byte, which free-form text rarely does.
3 => proptest::collection::vec(
prop_oneof![
Just("<a>".to_owned()),
Just("</a>".to_owned()),
Just("<b/>".to_owned()),
Just("<a x=\"1\">".to_owned()),
Just("text".to_owned()),
Just("<!--c-->".to_owned()),
Just("<![CDATA[d]]>".to_owned()),
Just("&".to_owned()),
Just("A".to_owned()),
Just("<?pi?>".to_owned()),
Just(" ".to_owned()),
],
0..24,
).prop_map(|parts| parts.concat()),
]
}
/// A well-formed document, built by construction.
fn well_formed() -> impl Strategy<Value = String> {
let leaf = prop_oneof![
Just("<e/>".to_owned()),
Just("<e>t</e>".to_owned()),
Just("<e a=\"v\">t</e>".to_owned()),
Just("<e><!--c--></e>".to_owned()),
];
leaf.prop_recursive(4, 32, 3, |inner| {
proptest::collection::vec(inner, 0..3)
.prop_map(|kids| format!("<n>{}</n>", kids.concat()))
})
}
proptest! {
/// The parser is total: it returns, whatever it is given.
#[test]
fn parsing_never_panics(src in xml_ish()) {
let _ = parse(&src);
}
/// Tightening a limit can only ever reject more, never accept more.
#[test]
fn tighter_limits_never_accept_more(src in xml_ish()) {
if parse_with(&src, Limits::strict()).is_ok() {
prop_assert!(parse_with(&src, Limits::default()).is_ok());
prop_assert!(parse_with(&src, Limits::permissive()).is_ok());
}
}
/// Parsing is deterministic — same input, same tree shape.
#[test]
fn parsing_is_deterministic(src in xml_ish()) {
match (parse(&src), parse(&src)) {
(Ok(a), Ok(b)) => {
prop_assert_eq!(a.len(), b.len());
prop_assert_eq!(a.text(a.root()), b.text(b.root()));
}
(Err(a), Err(b)) => {
prop_assert_eq!(a.offset, b.offset);
}
_ => prop_assert!(false, "one parse succeeded and one failed"),
}
}
/// Anything `well_formed` builds must actually parse. If this
/// fails, either the generator or the parser is wrong — and both
/// are worth knowing.
#[test]
fn constructed_documents_parse(src in well_formed()) {
prop_assert!(parse(&src).is_ok(), "generator produced: {}", src);
}
/// Every tree that parses is internally coherent.
#[test]
fn trees_are_coherent(src in well_formed()) {
let doc = parse(&src).expect("well-formed by construction");
prop_assert_eq!(doc.parent(doc.root()), None);
for id in doc.descendants() {
for child in doc.children(id) {
prop_assert_eq!(doc.parent(*child), Some(id));
}
// Attributes are reachable but are never children.
for attr in doc.attribute_nodes(id) {
prop_assert_eq!(doc.parent(*attr), Some(id));
prop_assert!(!doc.children(id).contains(attr));
}
// Adjacent text is coalesced during parsing.
for pair in doc.children(id).windows(2) {
let a = matches!(doc.kind(pair[0]), Some(NodeKind::Text(_)));
let b = matches!(doc.kind(pair[1]), Some(NodeKind::Text(_)));
prop_assert!(!(a && b), "adjacent text nodes");
}
}
}
/// Walking up from any node reaches the root in finite steps.
#[test]
fn parent_chains_terminate(src in well_formed()) {
let doc = parse(&src).expect("well-formed");
for id in doc.descendants() {
let mut hops = 0usize;
let mut cur = Some(id);
while let Some(n) = cur {
cur = doc.parent(n);
hops += 1;
prop_assert!(hops <= doc.len(), "chain does not terminate");
}
}
}
/// XPath compilation is total and deterministic.
#[test]
fn xpath_compilation_never_panics(expr in "\\PC{0,60}") {
if let Ok(a) = XPath::compile(&expr) {
let b = XPath::compile(&expr).expect("compiled once already");
prop_assert_eq!(format!("{:?}", a.expr()), format!("{:?}", b.expr()));
}
}
/// `count(X)` agrees with the number of nodes `X` selects. Two
/// independent paths through the evaluator that must not diverge.
#[test]
fn count_agrees_with_the_node_set(src in well_formed()) {
let doc = parse(&src).expect("well-formed");
for path in ["//n", "//e", "//e/@a", "//text()", "//comment()"] {
let nodes = XPath::compile(path)
.expect("fixed expression")
.evaluate(&doc)
.nodes()
.map_or(0, <[oxml::NodeId]>::len);
let counted = XPath::compile(&format!("count({path})"))
.expect("fixed expression")
.evaluate(&doc)
.to_str(&doc);
prop_assert_eq!(counted, nodes.to_string(), "for {}", path);
}
}
/// Number formatting round-trips: whatever XPath prints must parse
/// back to the same value. This is the property the `log10`/`powf`
/// removal was about — it must hold identically on every backend.
#[test]
fn formatted_numbers_round_trip(n in proptest::num::f64::NORMAL) {
let doc = parse("<a/>").expect("well-formed");
let expr = format!("{n:?} * 1");
let Ok(x) = XPath::compile(&expr) else { return Ok(()) };
let printed = x.evaluate(&doc).to_str(&doc);
if let Ok(back) = printed.parse::<f64>() {
// 15 significant digits, so allow the last two to differ.
let tolerance = n.abs() * 1e-13;
prop_assert!(
(back - n).abs() <= tolerance.max(f64::MIN_POSITIVE),
"{n} printed as {printed}, parsed back as {back}"
);
}
}
/// An error offset always falls on a character boundary of the
/// document it came from.
///
/// `Error::line_column` slices the input at the offset, and
/// slicing a `str` off a boundary panics. The scanner works in
/// bytes, so nothing in the type system enforces this -- only
/// every arm that constructs an `Error` agreeing to be careful.
/// Rendering a diagnostic must never be more dangerous than the
/// failure being reported.
#[test]
fn error_offsets_land_on_character_boundaries(input in xml_ish()) {
if let Err(e) = parse(&input) {
prop_assert!(
e.offset > input.len() || input.is_char_boundary(e.offset),
"offset {} splits a character in {input:?} ({:?})",
e.offset,
e.kind
);
// The rendering itself must survive regardless.
let _ = e.line_column(&input);
}
}
/// `line_column` never panics, even when handed a document other
/// than the one that produced the error.
///
/// The signature invites this: it takes any `&str`, and a caller
/// holding the original bytes of a transcoded document has offsets
/// that no longer correspond. A wrong column is a nuisance; a
/// panic in the error path takes down the process.
#[test]
fn line_column_survives_a_mismatched_document(
input in xml_ish(),
other in xml_ish(),
) {
if let Err(e) = parse(&input) {
let (line, column) = e.line_column(&other);
prop_assert!(line >= 1 && column >= 1, "positions are one-based");
}
}
}