use assertables::*;
use schematron::{Document, Schema};
fn schema_with(binding: &str, body: &str) -> String {
format!(
r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron" queryBinding="{binding}">{body}</schema>"#
)
}
fn check(test: &str, document: &str) -> bool {
let source = schema_with(
"xslt2",
&format!(r#"<pattern><rule context="a"><assert test="{test}">failed</assert></rule></pattern>"#),
);
let schema = Schema::from_str(&source)
.unwrap_or_else(|e| panic!("schema with test {test:?} should compile: {e}"));
let document = Document::from_str(document).expect("document should parse");
schema.validate(&document).expect("validation should run").is_valid()
}
fn compile_error(binding: &str, test: &str) -> String {
let source = schema_with(
binding,
&format!(r#"<pattern><rule context="a"><assert test="{test}">m</assert></rule></pattern>"#),
);
Schema::from_str(&source).map_or_else(
|error| error.to_string(),
|_| panic!("test {test:?} unexpectedly compiled under {binding}"),
)
}
#[test]
fn matches_tests_a_regular_expression() {
assert!(check("matches(@x, '^[0-9]+$')", r#"<a x="123"/>"#));
assert!(!check("matches(@x, '^[0-9]+$')", r#"<a x="12a"/>"#));
assert!(check("matches(@x, '[0-9]')", r#"<a x="ab1cd"/>"#));
}
#[test]
fn matches_honours_the_flags_argument() {
assert!(!check("matches(@x, '^abc$')", r#"<a x="ABC"/>"#));
assert!(check("matches(@x, '^abc$', 'i')", r#"<a x="ABC"/>"#));
}
#[test]
fn replace_rewrites_with_group_references() {
assert!(check(
"replace(@x, '([0-9]+)-([0-9]+)', '$2-$1') = '34-12'",
r#"<a x="12-34"/>"#
));
}
#[test]
fn a_conditional_chooses_a_branch() {
let test = "if (@type = 'credit') then number(@total) < 0 else number(@total) >= 0";
assert!(check(test, r#"<a type="credit" total="-5"/>"#));
assert!(!check(test, r#"<a type="credit" total="5"/>"#));
assert!(check(test, r#"<a type="debit" total="5"/>"#));
assert!(!check(test, r#"<a type="debit" total="-5"/>"#));
}
#[test]
fn a_conditional_evaluates_only_the_taken_branch() {
assert!(check("if (true()) then true() else boolean(1 | 2)", "<a/>"));
assert!(check("if (false()) then boolean(1 | 2) else true()", "<a/>"));
}
#[test]
fn string_functions() {
assert!(check("upper-case(@x) = 'ABC'", r#"<a x="abc"/>"#));
assert!(check("lower-case(@x) = 'abc'", r#"<a x="ABC"/>"#));
assert!(check("ends-with(@x, '.pdf')", r#"<a x="doc.pdf"/>"#));
assert!(!check("ends-with(@x, '.pdf')", r#"<a x="doc.txt"/>"#));
}
#[test]
fn string_join_concatenates_a_node_set_in_document_order() {
assert!(check(
"string-join(b, ',') = 'x,y,z'",
"<a><b>x</b><b>y</b><b>z</b></a>"
));
}
#[test]
fn numeric_functions() {
assert!(check("abs(number(@x)) = 5", r#"<a x="-5"/>"#));
assert!(check("min(b) = 1", "<a><b>3</b><b>1</b><b>2</b></a>"));
assert!(check("max(b) = 3", "<a><b>3</b><b>1</b><b>2</b></a>"));
assert!(check("avg(b) = 2", "<a><b>3</b><b>1</b><b>2</b></a>"));
}
#[test]
fn exists_and_empty_are_opposites() {
assert!(check("exists(b)", "<a><b/></a>"));
assert!(!check("exists(b)", "<a/>"));
assert!(check("empty(b)", "<a/>"));
assert!(!check("empty(b)", "<a><b/></a>"));
}
#[test]
fn two_point_zero_functions_are_refused_under_a_one_point_zero_binding() {
for binding in ["xslt", "xpath"] {
let message = compile_error(binding, "matches(@x, 'y')");
assert_contains!(message, "matches()");
assert_contains!(message, "XPath 2.0");
assert_contains!(message, "xslt2");
}
}
#[test]
fn a_conditional_is_refused_under_a_one_point_zero_binding() {
let message = compile_error("xslt", "if (b) then 1 else 2");
assert_contains!(message, "XPath 2.0 syntax");
assert_contains!(message, "xslt2");
}
#[test]
fn a_conditional_requires_both_branches() {
let message = compile_error("xslt2", "if (b) then 1");
assert_contains!(message, "else");
}
#[test]
fn constructs_still_needing_phase_two_b_say_so() {
for (test, expected) in [
("count(for-each(b, 1))", "does not implement"),
("trace(b, 'x')", "does not implement"),
("resolve-uri('a', 'b')", "does not implement"),
] {
let message = compile_error("xslt2", test);
assert_contains!(message, expected);
assert_contains!(message, "spec/xpath2/");
}
}
#[test]
fn constructs_still_needing_the_type_system_say_so() {
let message = compile_error("xslt2", "adjust-date-to-timezone(@d, @z)");
assert_contains!(message, "date and time");
assert_contains!(message, "spec/xpath2/");
}
#[test]
fn a_malformed_regular_expression_fails_when_the_schema_loads() {
let message = compile_error("xslt2", "matches(@x, '[unclosed')");
assert_contains!(message, "did not compile");
assert_contains!(message, "[unclosed");
}
#[test]
fn an_unknown_regular_expression_flag_fails_when_the_schema_loads() {
let message = compile_error("xslt2", "matches(@x, 'y', 'q')");
assert_contains!(message, "flag");
}
#[test]
fn a_computed_regular_expression_is_checked_at_evaluation_time() {
let source = schema_with(
"xslt2",
r#"<pattern><rule context="a"><assert test="matches('x', @pattern)">m</assert></rule></pattern>"#,
);
let schema = Schema::from_str(&source).expect("schema should compile");
let document = Document::from_str(r#"<a pattern="[unclosed"/>"#).unwrap();
let error = schema.validate(&document).unwrap_err();
assert_contains!(error.to_string(), "did not compile");
}
#[test]
fn xpath_one_point_zero_still_works_under_a_two_point_zero_binding() {
assert!(check("count(b) = 2", "<a><b/><b/></a>"));
assert!(check("b/@x = '1'", r#"<a><b x="1"/></a>"#));
assert!(check("normalize-space(@x) = 'a b'", r#"<a x=" a b "/>"#));
assert!(check("substring(@x, 2, 3) = 'bcd'", r#"<a x="abcde"/>"#));
}
#[test]
fn bindings_above_two_point_zero_are_still_refused() {
for binding in ["xslt3", "xpath3", "xpath31"] {
let message = compile_error(binding, "true()");
assert_contains!(message, "unsupported query binding");
}
}
#[test]
fn the_documented_divergence_from_real_xpath_two_holds() {
assert!(!check("number(@x) + 1 > 0", r#"<a x="not-a-number"/>"#));
assert!(!check("'x' > 0", "<a/>"));
}
#[test]
fn a_sequence_can_be_constructed_and_counted() {
assert!(check("count((1, 2, 3)) = 3", "<a/>"));
assert!(check("count(()) = 0", "<a/>"));
assert!(check("count((1, (2, 3), 4)) = 4", "<a/>"));
}
#[test]
fn a_sequence_can_mix_nodes_and_atomic_values() {
assert!(check("count((b, 'x', 1)) = 4", "<a><b/><b/></a>"));
}
#[test]
fn a_range_counts_up_and_is_empty_when_descending() {
assert!(check("count(1 to 5) = 5", "<a/>"));
assert!(check("count(5 to 1) = 0", "<a/>"));
assert!(check("count(3 to 3) = 1", "<a/>"));
}
#[test]
fn an_absurd_range_is_an_error_rather_than_an_allocation() {
let source = schema_with(
"xslt2",
r#"<pattern><rule context="a"><assert test="count(1 to 100000000) > 0">m</assert></rule></pattern>"#,
);
let schema = Schema::from_str(&source).expect("schema should compile");
let document = Document::from_str("<a/>").unwrap();
let error = schema.validate(&document).unwrap_err();
assert_contains!(error.to_string(), "exceeds the limit");
}
#[test]
fn for_iterates_and_concatenates() {
assert!(check("count(for $b in b return $b) = 3", "<a><b/><b/><b/></a>"));
assert!(check(
"count(for $b in b return $b/c) = 2",
"<a><b><c/></b><b><c/></b><b/></a>"
));
assert!(check("count(for $n in (1, 2) return (1 to 3)) = 6", "<a/>"));
}
#[test]
fn some_and_every_quantify_over_a_sequence() {
let doc = r#"<a><b n="1"/><b n="2"/><b n="3"/></a>"#;
assert!(check("some $b in b satisfies number($b/@n) = 2", doc));
assert!(!check("some $b in b satisfies number($b/@n) = 9", doc));
assert!(check("every $b in b satisfies number($b/@n) > 0", doc));
assert!(!check("every $b in b satisfies number($b/@n) > 1", doc));
}
#[test]
fn every_is_true_for_an_empty_input_and_some_is_false() {
assert!(check("every $b in nothing satisfies false()", "<a/>"));
assert!(!check("some $b in nothing satisfies true()", "<a/>"));
}
#[test]
fn quantifiers_work_over_a_constructed_sequence() {
assert!(check("some $n in (1 to 10) satisfies $n = 7", "<a/>"));
assert!(check("every $n in (2, 4, 6) satisfies $n mod 2 = 0", "<a/>"));
}
#[test]
fn a_bound_variable_does_not_escape_its_expression() {
let message = compile_error(
"xslt2",
"(every $b in b satisfies true()) and $b",
);
assert_contains!(message, "$b");
}
#[test]
fn a_variable_bound_by_a_quantifier_is_in_scope_inside_it() {
assert!(check("every $n in (1, 2) satisfies $n > 0", "<a/>"));
assert!(check("count(for $n in (1, 2) return $n) = 2", "<a/>"));
assert!(check("some $n in (1 to 3) satisfies $n = 2", "<a/>"));
}
#[test]
fn tokenize_splits_into_a_sequence() {
assert!(check("count(tokenize(@x, ',')) = 3", r#"<a x="p,q,r"/>"#));
assert!(check(
"string-join(tokenize(@x, ',\\s*'), '-') = 'p-q-r'",
r#"<a x="p, q, r"/>"#
));
}
#[test]
fn distinct_values_removes_duplicates_keeping_order() {
assert!(check(
"string-join(distinct-values((1, 2, 1, 3, 2)), ',') = '1,2,3'",
"<a/>"
));
assert!(check("count(distinct-values(b)) = 2", "<a><b>x</b><b>y</b><b>x</b></a>"));
}
#[test]
fn index_of_reports_one_based_positions() {
assert!(check("index-of(('a', 'b', 'c'), 'b') = 2", "<a/>"));
assert!(check("count(index-of(('a', 'b', 'a'), 'a')) = 2", "<a/>"));
assert!(check("count(index-of(('a', 'b'), 'z')) = 0", "<a/>"));
}
#[test]
fn reverse_reverses_a_sequence() {
assert!(check("string-join(reverse((1, 2, 3)), ',') = '3,2,1'", "<a/>"));
assert!(check("count(reverse(())) = 0", "<a/>"));
}
#[test]
fn unordered_returns_every_item_unchanged() {
assert!(check("count(unordered((3, 1, 2))) = 3", "<a/>"));
assert!(check("string-join(unordered((3, 1, 2)), ',') = '3,1,2'", "<a/>"));
}
#[test]
fn subsequence_selects_by_one_based_position() {
assert!(check(
"string-join(subsequence(('a', 'b', 'c', 'd', 'e'), 3), ',') = 'c,d,e'",
"<a/>"
));
assert!(check(
"string-join(subsequence(('a', 'b', 'c', 'd', 'e'), 2, 3), ',') = 'b,c,d'",
"<a/>"
));
assert!(check(
"string-join(subsequence(('a', 'b', 'c', 'd', 'e'), 1.5), ',') = 'b,c,d,e'",
"<a/>"
));
assert!(check("count(subsequence(('a', 'b'), 5)) = 0", "<a/>"));
}
#[test]
fn insert_before_splices_in_a_sequence() {
assert!(check(
"string-join(insert-before(('a', 'b', 'c'), 2, ('x', 'y')), ',') = 'a,x,y,b,c'",
"<a/>"
));
assert!(check(
"string-join(insert-before(('a', 'b'), -3, 'x'), ',') = 'x,a,b'",
"<a/>"
));
assert!(check(
"string-join(insert-before(('a', 'b'), 99, 'x'), ',') = 'a,b,x'",
"<a/>"
));
}
#[test]
fn remove_drops_one_item_by_position() {
assert!(check(
"string-join(remove(('a', 'b', 'c'), 2), ',') = 'a,c'",
"<a/>"
));
assert!(check(
"string-join(remove(('a', 'b', 'c'), 99), ',') = 'a,b,c'",
"<a/>"
));
assert!(check("count(remove((), 1)) = 0", "<a/>"));
}
#[test]
fn cardinality_checks_pass_through_a_matching_sequence() {
assert!(check("count(zero-or-one(())) = 0", "<a/>"));
assert!(check("zero-or-one((1)) = 1", "<a/>"));
assert!(check("string-join(one-or-more((1, 2)), ',') = '1,2'", "<a/>"));
assert!(check("exactly-one((1)) = 1", "<a/>"));
}
#[test]
fn cardinality_checks_raise_on_a_mismatch() {
let too_many = eval_error("zero-or-one((1, 2))", "<a/>");
assert_contains!(too_many, "zero-or-one()");
assert_contains!(too_many, "2 items");
let empty = eval_error("one-or-more(())", "<a/>");
assert_contains!(empty, "one-or-more()");
let two = eval_error("exactly-one((1, 2))", "<a/>");
assert_contains!(two, "exactly-one()");
assert_contains!(two, "2 items");
let zero = eval_error("exactly-one(())", "<a/>");
assert_contains!(zero, "exactly-one()");
}
#[test]
fn data_atomizes_nodes_and_passes_atomic_values_through() {
assert!(check("data(@x) eq 'open'", r#"<a x="open"/>"#));
assert!(check("data(1) = 1", "<a/>"));
assert!(check("string-join(data((@x, 'y')), ',') = 'open,y'", r#"<a x="open"/>"#));
}
#[test]
fn deep_equal_compares_atomic_sequences_by_value_and_length() {
assert!(check("deep-equal((1, 2, 'x'), (1, 2, 'x'))", "<a/>"));
assert!(!check("deep-equal((1, 2), (1, 2, 3))", "<a/>"));
assert!(!check("deep-equal((1, 2), (1, 9))", "<a/>"));
assert!(!check("deep-equal(1, 'a')", "<a/>"));
assert!(check("deep-equal(avg(()), avg(()))", "<a/>"));
}
#[test]
fn deep_equal_compares_elements_structurally() {
let doc = r#"<a>
<p><b x="1"><i>t</i></b></p>
<q><b x="1"><i>t</i></b></q>
<r><b x="2"><i>t</i></b></r>
<s><b x="1" y="2"><i>t</i></b></s>
<t><b y="2" x="1"><i>t</i></b></t>
<u><b><i>t</i><j>u</j></b></u>
<v><b><j>u</j><i>t</i></b></v>
</a>"#;
assert!(check("deep-equal(p/b, q/b)", doc));
assert!(!check("deep-equal(p/b, r/b)", doc));
assert!(check("deep-equal(s/b, t/b)", doc));
assert!(!check("deep-equal(u/b, v/b)", doc));
assert!(!check("deep-equal(p/b/i, 't')", doc));
}
#[test]
fn aggregates_accept_sequences_as_well_as_node_sets() {
assert!(check("sum((1, 2, 3)) = 6", "<a/>"));
assert!(check("min((3, 1, 2)) = 1", "<a/>"));
assert!(check("max((3, 1, 2)) = 3", "<a/>"));
assert!(check("avg((2, 4)) = 3", "<a/>"));
assert!(check("exists((1))", "<a/>"));
assert!(check("empty(())", "<a/>"));
}
#[test]
fn a_sequence_compares_existentially_like_a_node_set() {
assert!(check("(1, 2, 3) = 2", "<a/>"));
assert!(!check("(1, 2, 3) = 9", "<a/>"));
assert!(check("(1, 2, 3) > 2", "<a/>"));
assert!(check("((1, 2) = 1) and ((1, 2) != 1)", "<a/>"));
}
#[test]
fn a_multi_item_sequence_has_no_effective_boolean_value() {
let source = schema_with(
"xslt2",
r#"<pattern><rule context="a"><assert test="if ((1, 2)) then true() else false()">m</assert></rule></pattern>"#,
);
let schema = Schema::from_str(&source).expect("schema should compile");
let document = Document::from_str("<a/>").unwrap();
let error = schema.validate(&document).unwrap_err();
assert_contains!(error.to_string(), "effective boolean value");
}
#[test]
fn sequence_syntax_is_refused_under_a_one_point_zero_binding() {
for (test, fragment) in [
("count((1, 2))", "sequence"),
("count(1 to 3)", "`to` range"),
("count(for $b in b return $b)", "for"),
("some $b in b satisfies true()", "some"),
("every $b in b satisfies true()", "every"),
] {
let message = compile_error("xslt", test);
assert_contains!(message, fragment);
assert_contains!(message, "xslt2");
}
}
#[test]
fn keywords_are_not_reserved_words() {
assert!(check("count(to) = 1", "<a><to/></a>"));
assert!(check("count(for) = 1", "<a><for/></a>"));
assert!(check("count(some) = 1", "<a><some/></a>"));
assert!(check("some $n in (1 to 3) satisfies $n = 2", "<a/>"));
assert!(check("count(for $n in (1, 2) return ($n)) = 2", "<a/>"));
}
const FIXED_NOW: f64 = 1_787_313_600.0;
fn check_at_fixed_time(test: &str, document: &str) -> bool {
use schematron::validate::ValidateOptions;
let source = schema_with(
"xslt2",
&format!(
r#"<ns prefix="xs" uri="http://www.w3.org/2001/XMLSchema"/>
<pattern><rule context="a"><assert test="{test}">failed</assert></rule></pattern>"#
),
);
let schema = Schema::from_str(&source)
.unwrap_or_else(|e| panic!("schema with test {test:?} should compile: {e}"));
let document = Document::from_str(document).expect("document should parse");
let options = ValidateOptions::new().with_current_time(FIXED_NOW);
schema
.validate_with(&document, &options)
.expect("validation should run")
.is_valid()
}
#[test]
fn the_clock_reports_the_supplied_instant() {
assert!(check_at_fixed_time("current-date() = xs:date('2026-08-21Z')", "<a/>"));
assert!(check_at_fixed_time(
"current-dateTime() = xs:dateTime('2026-08-21T12:00:00Z')",
"<a/>"
));
assert!(check_at_fixed_time("year-from-date(current-date()) = 2026", "<a/>"));
}
#[test]
fn the_canonical_schematron_example_now_runs() {
let test = "@ContractDate < current-date()";
assert!(check_at_fixed_time(test, r#"<a ContractDate="2020-01-01"/>"#));
assert!(!check_at_fixed_time(test, r#"<a ContractDate="2099-12-31"/>"#));
}
#[test]
fn an_untyped_value_is_cast_to_the_other_operands_type() {
assert!(check_at_fixed_time(
"@d = xs:date('2026-08-21')",
r#"<a d="2026-08-21"/>"#
));
assert!(check_at_fixed_time(
"@d > xs:date('2020-01-01')",
r#"<a d="2026-08-21"/>"#
));
}
#[test]
fn a_timezone_offset_is_honoured_rather_than_compared_as_text() {
assert!(check_at_fixed_time(
"xs:dateTime('2026-08-21T00:00:00+01:00') < xs:dateTime('2026-08-21T00:00:00Z')",
"<a/>"
));
}
#[test]
fn date_components_can_be_read() {
let doc = r#"<a d="2026-08-21" t="10:30:05" dt="2026-08-21T10:30:05"/>"#;
assert!(check_at_fixed_time("year-from-date(@d) = 2026", doc));
assert!(check_at_fixed_time("month-from-date(@d) = 8", doc));
assert!(check_at_fixed_time("day-from-date(@d) = 21", doc));
assert!(check_at_fixed_time("hours-from-time(@t) = 10", doc));
assert!(check_at_fixed_time("minutes-from-time(@t) = 30", doc));
assert!(check_at_fixed_time("seconds-from-time(@t) = 5", doc));
assert!(check_at_fixed_time("hours-from-dateTime(@dt) = 10", doc));
}
#[test]
fn a_malformed_date_is_an_error_not_a_false_test() {
use schematron::validate::ValidateOptions;
let source = schema_with(
"xslt2",
r#"<pattern><rule context="a">
<assert test="@d < current-date()">m</assert>
</rule></pattern>"#,
);
let schema = Schema::from_str(&source).expect("schema should compile");
let document = Document::from_str(r#"<a d="2026-02-30"/>"#).unwrap();
let options = ValidateOptions::new().with_current_time(FIXED_NOW);
let error = schema.validate_with(&document, &options).unwrap_err();
assert_contains!(error.to_string(), "2026-02-30");
assert_contains!(error.to_string(), "xs:date");
}
#[test]
fn the_constructors_reject_impossible_values_when_the_schema_loads() {
use schematron::validate::ValidateOptions;
let source = schema_with(
"xslt2",
r#"<ns prefix="xs" uri="http://www.w3.org/2001/XMLSchema"/>
<pattern><rule context="a">
<assert test="xs:date('2026-13-01') < current-date()">m</assert>
</rule></pattern>"#,
);
let schema = Schema::from_str(&source).expect("schema should compile");
let document = Document::from_str("<a/>").unwrap();
let options = ValidateOptions::new().with_current_time(FIXED_NOW);
let error = schema.validate_with(&document, &options).unwrap_err();
assert_contains!(error.to_string(), "2026-13-01");
}
#[test]
fn the_clock_is_stable_across_a_whole_run() {
assert!(check_at_fixed_time(
"current-date() = current-date() and current-dateTime() = current-dateTime()",
"<a/>"
));
}
#[test]
fn a_run_without_a_supplied_instant_still_works() {
assert!(check("year-from-date(current-date()) > 2000", "<a/>"));
}
#[test]
fn date_functions_are_refused_under_a_one_point_zero_binding() {
let message = compile_error("xslt", "current-date()");
assert_contains!(message, "XPath 2.0");
assert_contains!(message, "xslt2");
}
#[test]
fn calling_the_clock_without_a_run_instant_is_an_error() {
use schematron::xpath::{evaluate, parse, EvalContext, Namespaces, Variables, XPathVersion};
let document = Document::from_str("<a/>").unwrap();
let expr = parse("current-date()").unwrap();
let variables = Variables::new();
let namespaces = Namespaces::new();
let context = EvalContext::new(&document, document.root(), &variables, &namespaces)
.with_version(XPathVersion::V2);
let error = evaluate(&expr, &context).unwrap_err();
assert_contains!(error.message, "instant");
}
fn eval_error(test: &str, document: &str) -> String {
let source = schema_with(
"xslt2",
&format!(
r#"<ns prefix="xs" uri="http://www.w3.org/2001/XMLSchema"/>
<pattern><rule context="a"><assert test="{test}">m</assert></rule></pattern>"#
),
);
let schema = Schema::from_str(&source)
.unwrap_or_else(|e| panic!("schema with test {test:?} should compile: {e}"));
let document = Document::from_str(document).expect("document should parse");
schema.validate(&document).map_or_else(
|error| error.to_string(),
|_| panic!("test {test:?} unexpectedly evaluated without error"),
)
}
#[test]
fn value_comparisons_compare_two_values() {
assert!(check("'a' eq 'a'", "<a/>"));
assert!(!check("'a' eq 'b'", "<a/>"));
assert!(check("'a' ne 'b'", "<a/>"));
assert!(check("1 lt 2", "<a/>"));
assert!(check("2 le 2", "<a/>"));
assert!(check("3 gt 2", "<a/>"));
assert!(check("2 ge 2", "<a/>"));
}
#[test]
fn a_node_atomizes_to_its_string_value() {
assert!(check("@x eq 'open'", r#"<a x="open"/>"#));
assert!(check("b eq 'text'", "<a><b>text</b></a>"));
assert!(check("@x lt 'M'", r#"<a x="ABC"/>"#));
}
#[test]
fn more_than_one_item_is_an_error_where_a_general_comparison_is_not() {
let doc = r#"<a><b q="1"/><b q="2"/></a>"#;
assert!(check("b/@q = 1", doc));
let message = eval_error("b/@q eq '1'", doc);
assert_contains!(message, "2 items");
assert_contains!(message, "exactly one");
assert_contains!(message, "=");
}
#[test]
fn mismatched_types_are_an_error() {
let message = eval_error("@n eq 1", r#"<a n="1"/>"#);
assert_contains!(message, "string");
assert_contains!(message, "number");
assert!(check("number(@n) eq 1", r#"<a n="1"/>"#));
assert!(check("@n eq '1'", r#"<a n="1"/>"#));
}
#[test]
fn an_untyped_value_is_not_coerced_to_a_date() {
let message = eval_error("@d eq xs:date('2020-01-01')", r#"<a d="2020-01-01"/>"#);
assert_contains!(message, "xs:date");
assert!(check(
"xs:date(@d) eq xs:date('2020-01-01')",
r#"<a d="2020-01-01"/>"#
));
}
#[test]
fn dates_compare_by_instant() {
assert!(check(
"xs:date('2020-01-01') lt xs:date('2026-08-21')",
"<a/>"
));
assert!(check(
"xs:dateTime('2026-08-21T00:00:00+01:00') lt xs:dateTime('2026-08-21T00:00:00Z')",
"<a/>"
));
}
#[test]
fn an_empty_operand_yields_the_empty_sequence() {
assert!(!check("missing eq 'x'", "<a/>"));
assert!(!check("missing ne 'x'", "<a/>"));
assert!(!check("'x' eq missing", "<a/>"));
assert!(!check("missing lt 1", "<a/>"));
}
#[test]
fn value_comparisons_are_refused_under_a_one_point_zero_binding() {
for op in ["eq", "ne", "lt", "le", "gt", "ge"] {
let message = compile_error("xslt", &format!("'a' {op} 'b'"));
assert_contains!(message, "value comparison");
assert_contains!(message, "xslt2");
}
}
#[test]
fn the_operator_names_are_still_element_names_in_name_position() {
assert!(check("count(eq) = 1", "<a><eq/></a>"));
assert!(check("count(lt) = 1", "<a><lt/></a>"));
assert!(check("count(a/ge) = 0", "<a/>"));
}
#[test]
fn booleans_compare_with_false_below_true() {
assert!(check("false() lt true()", "<a/>"));
assert!(check("true() eq true()", "<a/>"));
assert!(check("false() ne true()", "<a/>"));
}
#[test]
fn nan_is_never_equal_and_never_ordered() {
assert!(!check("number('x') eq number('x')", "<a/>"));
assert!(!check("number('x') lt 1", "<a/>"));
assert!(!check("number('x') ge 1", "<a/>"));
assert!(check("number('x') ne number('x')", "<a/>"));
}
#[test]
fn subtracting_two_dates_measures_the_distance() {
assert!(check(
"xs:date('2026-08-21') - xs:date('2026-01-01') eq xs:dayTimeDuration('P232D')",
"<a/>"
));
assert!(check(
"days-from-duration(xs:date('2026-08-21') - xs:date('2026-01-01')) = 232",
"<a/>"
));
}
#[test]
fn the_constraint_a_schema_actually_wants_to_write() {
let test = "xs:date(@end) - xs:date(@start) le xs:dayTimeDuration('P90D')";
assert!(check(test, r#"<a start="2026-01-01" end="2026-03-01"/>"#));
assert!(!check(test, r#"<a start="2026-01-01" end="2026-08-21"/>"#));
}
#[test]
fn a_duration_moves_a_date() {
assert!(check(
"xs:date('2026-01-01') + xs:dayTimeDuration('P90D') eq xs:date('2026-04-01')",
"<a/>"
));
assert!(check(
"xs:date('2026-04-01') - xs:dayTimeDuration('P90D') eq xs:date('2026-01-01')",
"<a/>"
));
assert!(check(
"xs:date('2026-01-01') + xs:yearMonthDuration('P1Y6M') eq xs:date('2027-07-01')",
"<a/>"
));
}
#[test]
fn adding_months_clamps_the_day_rather_than_overflowing() {
assert!(check(
"xs:date('2026-01-31') + xs:yearMonthDuration('P1M') eq xs:date('2026-02-28')",
"<a/>"
));
assert!(check(
"xs:date('2024-01-31') + xs:yearMonthDuration('P1M') eq xs:date('2024-02-29')",
"<a/>"
));
}
#[test]
fn durations_add_and_subtract_within_a_subtype() {
assert!(check(
"xs:dayTimeDuration('P1D') + xs:dayTimeDuration('P2D') eq xs:dayTimeDuration('P3D')",
"<a/>"
));
assert!(check(
"xs:yearMonthDuration('P1Y') - xs:yearMonthDuration('P6M') eq xs:yearMonthDuration('P6M')",
"<a/>"
));
}
#[test]
fn mixing_the_two_duration_subtypes_is_an_error() {
let message = eval_error(
"xs:yearMonthDuration('P1M') + xs:dayTimeDuration('P30D') eq xs:dayTimeDuration('P30D')",
"<a/>",
);
assert_contains!(message, "xs:yearMonthDuration");
assert_contains!(message, "xs:dayTimeDuration");
}
#[test]
fn durations_of_different_subtypes_do_not_compare() {
let message = eval_error(
"xs:yearMonthDuration('P1M') lt xs:dayTimeDuration('P30D')",
"<a/>",
);
assert_contains!(message, "same type");
}
#[test]
fn duration_components_can_be_read() {
assert!(check(
"days-from-duration(xs:dayTimeDuration('P1DT2H30M15S')) = 1",
"<a/>"
));
assert!(check(
"hours-from-duration(xs:dayTimeDuration('P1DT2H30M15S')) = 2",
"<a/>"
));
assert!(check(
"minutes-from-duration(xs:dayTimeDuration('P1DT2H30M15S')) = 30",
"<a/>"
));
assert!(check(
"seconds-from-duration(xs:dayTimeDuration('P1DT2H30M15S')) = 15",
"<a/>"
));
assert!(check("years-from-duration(xs:yearMonthDuration('P1Y6M')) = 1", "<a/>"));
assert!(check("months-from-duration(xs:yearMonthDuration('P1Y6M')) = 6", "<a/>"));
}
#[test]
fn a_negative_duration_signs_every_component() {
assert!(check(
"days-from-duration(xs:dayTimeDuration('-P3D')) = -3",
"<a/>"
));
assert!(check(
"xs:date('2026-01-01') - xs:date('2026-01-04') eq xs:dayTimeDuration('-P3D')",
"<a/>"
));
}
#[test]
fn a_component_accessor_refuses_the_wrong_subtype() {
let message = eval_error("days-from-duration(xs:yearMonthDuration('P1M')) = 0", "<a/>");
assert_contains!(message, "xs:dayTimeDuration");
}
#[test]
fn adding_two_dates_is_an_error() {
let message = eval_error(
"xs:date('2026-01-01') + xs:date('2026-01-02') eq xs:date('2026-01-01')",
"<a/>",
);
assert_contains!(message, "cannot be added");
}
#[test]
fn a_malformed_duration_is_an_error() {
let message = eval_error("xs:dayTimeDuration('P1X') eq xs:dayTimeDuration('P1D')", "<a/>");
assert_contains!(message, "P1X");
let message = eval_error("xs:dayTimeDuration('P1M') eq xs:dayTimeDuration('P1D')", "<a/>");
assert_contains!(message, "P1M");
}
#[test]
fn duration_constructors_are_refused_under_a_one_point_zero_binding() {
let message = compile_error("xslt", "xs:dayTimeDuration('P1D')");
assert_contains!(message, "XPath 2.0");
}
#[test]
fn is_asks_about_identity_not_content() {
let doc = "<a><b>x</b><c>x</c></a>";
assert!(check("b = c", doc));
assert!(!check("b is c", doc));
assert!(check("b is b", doc));
assert!(check("b[1] is b[1]", doc));
}
#[test]
fn document_order_operators() {
let doc = "<a><b/><c/></a>";
assert!(check("b << c", doc));
assert!(!check("c << b", doc));
assert!(check("c >> b", doc));
assert!(!check("b >> c", doc));
assert!(!check("b << b", doc));
assert!(!check("b >> b", doc));
}
#[test]
fn a_node_comparison_takes_exactly_one_node() {
let doc = "<a><b/><b/></a>";
let message = eval_error("b is b", doc);
assert_contains!(message, "2 nodes");
assert_contains!(message, "exactly one");
let message = eval_error("'x' is 'x'", "<a/>");
assert_contains!(message, "node comparison");
}
#[test]
fn an_empty_node_operand_yields_the_empty_sequence() {
assert!(!check("missing is missing", "<a/>"));
assert!(!check("missing << b", "<a><b/></a>"));
}
#[test]
fn node_comparisons_are_refused_under_a_one_point_zero_binding() {
for test in ["b is c", "b << c", "b >> c"] {
let message = compile_error("xslt", test);
assert_contains!(message, "node comparison");
assert_contains!(message, "xslt2");
}
}
#[test]
fn is_is_still_an_element_name_in_name_position() {
assert!(check("count(is) = 1", "<a><is/></a>"));
}
#[test]
fn a_duration_scales_by_a_number() {
assert!(check(
"xs:dayTimeDuration('P1D') * 3 eq xs:dayTimeDuration('P3D')",
"<a/>"
));
assert!(check(
"3 * xs:dayTimeDuration('P1D') eq xs:dayTimeDuration('P3D')",
"<a/>"
));
assert!(check(
"xs:dayTimeDuration('P6D') div 2 eq xs:dayTimeDuration('P3D')",
"<a/>"
));
assert!(check(
"xs:yearMonthDuration('P1Y') * 2 eq xs:yearMonthDuration('P2Y')",
"<a/>"
));
}
#[test]
fn dividing_two_durations_gives_a_number() {
assert!(check(
"xs:dayTimeDuration('P90D') div xs:dayTimeDuration('P30D') = 3",
"<a/>"
));
assert!(check(
"xs:yearMonthDuration('P1Y') div xs:yearMonthDuration('P3M') = 4",
"<a/>"
));
}
#[test]
fn multiplying_two_durations_is_an_error() {
let message = eval_error(
"xs:dayTimeDuration('P1D') * xs:dayTimeDuration('P1D') eq xs:dayTimeDuration('P1D')",
"<a/>",
);
assert_contains!(message, "cannot be multiplied");
}
#[test]
fn months_scale_as_whole_months() {
assert!(check(
"xs:yearMonthDuration('P1M') * 1.5 eq xs:yearMonthDuration('P2M')",
"<a/>"
));
}
#[test]
fn the_implicit_timezone_defaults_to_utc() {
assert!(check(
"xs:dateTime('2026-08-21T00:00:00') eq xs:dateTime('2026-08-21T00:00:00Z')",
"<a/>"
));
}
#[test]
fn the_implicit_timezone_can_be_set() {
use schematron::validate::ValidateOptions;
let source = schema_with(
"xslt2",
r#"<ns prefix="xs" uri="http://www.w3.org/2001/XMLSchema"/>
<pattern><rule context="a">
<assert test="xs:dateTime('2026-08-21T00:00:00') eq xs:dateTime('2026-08-21T05:00:00Z')">m</assert>
</rule></pattern>"#,
);
let schema = Schema::from_str(&source).expect("schema should compile");
let document = Document::from_str("<a/>").unwrap();
assert!(!schema.validate(&document).unwrap().is_valid());
let options = ValidateOptions::new().with_implicit_timezone(-5 * 60);
assert!(schema.validate_with(&document, &options).unwrap().is_valid());
}
#[test]
fn timezone_from_a_value_reports_its_own_offset() {
assert!(check(
"timezone-from-dateTime(xs:dateTime('2026-08-21T00:00:00+01:00')) eq xs:dayTimeDuration('PT1H')",
"<a/>"
));
assert!(check(
"timezone-from-date(xs:date('2026-08-21Z')) eq xs:dayTimeDuration('PT0S')",
"<a/>"
));
assert!(check(
"empty(timezone-from-date(xs:date('2026-08-21')))",
"<a/>"
));
}
#[test]
fn implicit_timezone_reports_the_runs_setting() {
assert!(check("implicit-timezone() eq xs:dayTimeDuration('PT0S')", "<a/>"));
}
#[test]
fn castable_as_reports_instead_of_aborting() {
assert!(check("@d castable as xs:date", r#"<a d="2026-08-21"/>"#));
assert!(!check("@d castable as xs:date", r#"<a d="2026-02-30"/>"#));
assert!(!check("@d castable as xs:date", r#"<a d="not-a-date"/>"#));
let guarded = "not(@d castable as xs:date) or xs:date(@d) lt xs:date('2030-01-01')";
assert!(check(guarded, r#"<a d="2026-08-21"/>"#));
assert!(check(guarded, r#"<a d="rubbish"/>"#));
}
#[test]
fn castable_as_checks_the_lexical_form() {
assert!(check("'12' castable as xs:integer", "<a/>"));
assert!(!check("'12.5' castable as xs:integer", "<a/>"));
assert!(check("'12.5' castable as xs:decimal", "<a/>"));
assert!(!check("'x' castable as xs:double", "<a/>"));
assert!(check("'true' castable as xs:boolean", "<a/>"));
assert!(!check("'yes' castable as xs:boolean", "<a/>"));
assert!(check("'P90D' castable as xs:dayTimeDuration", "<a/>"));
assert!(!check("'P90D' castable as xs:yearMonthDuration", "<a/>"));
}
#[test]
fn cast_as_converts_and_the_result_is_usable() {
assert!(check("'2026-08-21' cast as xs:date eq xs:date('2026-08-21')", "<a/>"));
assert!(check("'12' cast as xs:integer = 12", "<a/>"));
assert!(check("@n cast as xs:double + 1 = 3", r#"<a n="2"/>"#));
assert!(check("'true' cast as xs:boolean", "<a/>"));
}
#[test]
fn cast_as_errors_where_castable_as_reports_false() {
assert!(!check("'x' castable as xs:date", "<a/>"));
let message = eval_error("'x' cast as xs:date eq xs:date('2026-08-21')", "<a/>");
assert_contains!(message, "cannot be cast");
}
#[test]
fn an_empty_operand_needs_the_optional_marker() {
assert!(!check("missing castable as xs:date", "<a/>"));
assert!(check("missing castable as xs:date?", "<a/>"));
let message = eval_error("(missing cast as xs:date) eq xs:date('2026-08-21')", "<a/>");
assert_contains!(message, "nothing to cast");
}
#[test]
fn instance_of_matches_node_kinds() {
let doc = "<a><b/>text<!--c--></a>";
assert!(check(". instance of element()", doc));
assert!(check(". instance of node()", doc));
assert!(check(". instance of item()", doc));
assert!(check("b instance of element()", doc));
assert!(check("text() instance of text()", doc));
assert!(check("comment() instance of comment()", doc));
assert!(!check("b instance of attribute()", doc));
}
#[test]
fn instance_of_matches_a_named_element() {
let doc = "<a><b/></a>";
assert!(check("b instance of element(b)", doc));
assert!(!check("b instance of element(c)", doc));
}
#[test]
fn instance_of_matches_atomic_types() {
assert!(check("'x' instance of xs:string", "<a/>"));
assert!(check("true() instance of xs:boolean", "<a/>"));
assert!(check("xs:date('2026-08-21') instance of xs:date", "<a/>"));
assert!(check(
"xs:dayTimeDuration('P1D') instance of xs:dayTimeDuration",
"<a/>"
));
assert!(check("'x' instance of xs:anyAtomicType", "<a/>"));
assert!(!check("@n instance of xs:string", r#"<a n="1"/>"#));
}
#[test]
fn a_literal_carries_the_type_it_is_written_as() {
assert!(check("1 instance of xs:integer", "<a/>"));
assert!(!check("1.0 instance of xs:integer", "<a/>"));
assert!(check("1.0 instance of xs:decimal", "<a/>"));
assert!(check("1.5 instance of xs:decimal", "<a/>"));
assert!(!check("1.5 instance of xs:integer", "<a/>"));
}
#[test]
fn an_integer_is_also_a_decimal_but_not_a_double() {
assert!(check("1 instance of xs:decimal", "<a/>"));
assert!(!check("1 instance of xs:double", "<a/>"));
assert!(!check("1 instance of xs:float", "<a/>"));
assert!(check("1 instance of xs:anyAtomicType", "<a/>"));
}
#[test]
fn cast_as_tags_the_result_with_the_type_named() {
assert!(check("'1' cast as xs:integer instance of xs:integer", "<a/>"));
assert!(check("'1.5' cast as xs:decimal instance of xs:decimal", "<a/>"));
assert!(check("'1.5' cast as xs:float instance of xs:float", "<a/>"));
assert!(check("'1.5' cast as xs:double instance of xs:double", "<a/>"));
assert!(check("'1' castable as xs:integer", "<a/>"));
}
#[test]
fn a_for_bound_item_keeps_its_tag() {
assert!(check("some $x in (1, 2) satisfies $x instance of xs:integer", "<a/>"));
assert!(check(
"(for $x in (1.5) return $x) instance of xs:decimal",
"<a/>"
));
}
#[test]
fn computed_numbers_are_still_double() {
assert!(check("(1 + 1) instance of xs:double", "<a/>"));
assert!(!check("(1 + 1) instance of xs:integer", "<a/>"));
assert!(check("count((1, 2)) instance of xs:double", "<a/>"));
assert!(check("every $x in (1 to 3) satisfies $x instance of xs:double", "<a/>"));
}
#[test]
fn occurrence_indicators_bound_the_length() {
let doc = "<a><b/><b/></a>";
assert!(check("b instance of element()*", doc));
assert!(check("b instance of element()+", doc));
assert!(!check("b instance of element()", doc));
assert!(!check("b instance of element()?", doc));
assert!(check("missing instance of element()*", doc));
assert!(check("missing instance of element()?", doc));
assert!(!check("missing instance of element()+", doc));
}
#[test]
fn empty_sequence_matches_only_nothing() {
assert!(check("missing instance of empty-sequence()", "<a/>"));
assert!(!check("b instance of empty-sequence()", "<a><b/></a>"));
}
#[test]
fn treat_as_passes_through_or_errors() {
assert!(check("(b treat as element()) instance of element()", "<a><b/></a>"));
let message = eval_error("(b treat as attribute()) instance of node()", "<a><b/></a>");
assert_contains!(message, "treat as");
}
#[test]
fn cast_as_refuses_a_node_or_sequence_type() {
let message = compile_error("xslt2", "@d cast as element()");
assert_contains!(message, "atomic type");
}
#[test]
fn type_operators_bind_tighter_than_comparison() {
assert!(check("'12' cast as xs:integer = 12", "<a/>"));
assert!(check("'12' castable as xs:integer = true()", "<a/>"));
}
#[test]
fn type_operators_are_refused_under_a_one_point_zero_binding() {
for (test, fragment) in [
("@d castable as xs:date", "castable as"),
("@d cast as xs:date", "cast as"),
(". instance of element()", "instance of"),
(". treat as element()", "treat as"),
] {
let message = compile_error("xslt", test);
assert_contains!(message, fragment);
assert_contains!(message, "xslt2");
}
}
#[test]
fn the_keywords_are_still_element_names_in_name_position() {
assert!(check("count(cast) = 1", "<a><cast/></a>"));
assert!(check("count(instance) = 1", "<a><instance/></a>"));
assert!(check("count(treat) = 1", "<a><treat/></a>"));
}
#[test]
fn kind_tests_work_as_path_node_tests() {
const DOC: &str = r#"<a>text<b id="1"/><c/><b id="2"/><!--note--><?pi data?></a>"#;
assert!(check("count(element()) = 3", DOC));
assert!(check("count(element(b)) = 2", DOC));
assert!(check("count(element(*)) = 3", DOC));
assert!(check("count(self::element(a)) = 1", DOC));
assert!(check("count(b/attribute()) = 2", DOC));
assert!(check("count(b/attribute(id)) = 2", DOC));
assert!(check("count(b/attribute(absent)) = 0", DOC));
assert!(check("count(b/child::attribute()) = 0", DOC));
assert!(check("count(/self::document-node()) = 1", DOC));
assert!(check("count(ancestor-or-self::document-node()) = 1", DOC));
assert!(check("count(/document-node()) = 0", DOC));
assert!(check("count(node()) = 6", DOC));
assert!(check("count(text()) = 1", DOC));
assert!(check("count(comment()) = 1", DOC));
assert!(check("count(processing-instruction()) = 1", DOC));
}
#[test]
fn kind_tests_as_node_tests_are_refused_under_a_one_point_zero_binding() {
for (test, written) in [
("count(element())", "element()"),
("count(element(b))", "element()"),
("count(b/attribute())", "attribute()"),
("count(self::document-node())", "document-node()"),
] {
let message = compile_error("xslt", test);
assert_contains!(message, written);
assert_contains!(message, "kind test");
assert_contains!(message, "xslt2");
}
}
#[test]
fn an_element_named_like_a_kind_test_is_still_a_name_test() {
const DOC: &str = "<a><element/><attribute/></a>";
assert!(check("count(element) = 1", DOC));
assert!(check("count(attribute) = 1", DOC));
assert!(check("name(element) = 'element'", DOC));
}
#[test]
fn nested_ranges_and_loops_are_bounded_together() {
const DOC: &str = "<a/>";
assert!(check("count(1 to 999999) = 999999", DOC));
assert!(check("count(for $i in 1 to 999 return for $j in 1 to 999 return $j) = 998001", DOC));
assert!(check("count(for $i in 1 to 10 return $i * 2) = 10", DOC));
assert!(check("some $i in 1 to 10 satisfies $i = 5", DOC));
for test in [
"count(for $i in 1 to 999 return for $j in 1 to 999 return for $k in 1 to 999 return $k)",
"count(for $i in 1 to 4000 return for $j in 1 to 4000 return $j)",
] {
let source = schema_with(
"xslt2",
&format!(r#"<pattern><rule context="a"><assert test="{test}">m</assert></rule></pattern>"#),
);
let schema = Schema::from_str(&source).expect("it compiles; the cost is at evaluation");
let document = Document::from_str(DOC).unwrap();
let error = schema
.validate(&document)
.expect_err("an expression this large must be refused, not run");
assert_contains!(error.to_string(), "items");
}
}
#[test]
fn the_budget_does_not_leak_between_expressions() {
let source = schema_with(
"xslt2",
r#"<pattern>
<rule context="a">
<assert test="count(1 to 900000) = 900000">first</assert>
<assert test="count(1 to 900000) = 900000">second</assert>
</rule>
</pattern>"#,
);
let schema = Schema::from_str(&source).unwrap();
let document = Document::from_str("<a/>").unwrap();
let report = schema.validate(&document).expect("both must run");
assert!(report.is_valid(), "{}", report.to_text());
}