1use std::collections::HashMap;
43
44use sup_xml_core::xpath::eval::{
45 eval_expr, EvalCtx, StaticContext, Value, XPathBindings,
46};
47use sup_xml_core::xpath::{parse_xpath, DocIndex, DocIndexLike, Expr, NodeId, XPathNodeKind};
48use sup_xml_tree::dom::{Document, Node, NodeKind};
49
50use crate::error::XsltError;
51
52type Result<T> = std::result::Result<T, XsltError>;
53
54pub const SCH_NS_ISO: &str = "http://purl.oclc.org/dsdl/schematron";
55pub const SCH_NS_1_5: &str = "http://www.ascc.net/xml/schematron";
56
57fn is_schematron_element(node: &Node) -> bool {
58 if !node.is_element() { return false; }
59 let uri = node.namespace.get().map(|ns| ns.href()).unwrap_or("");
60 uri == SCH_NS_ISO || uri == SCH_NS_1_5
61}
62
63#[derive(Debug)]
67pub struct Schematron {
68 namespaces: HashMap<String, String>,
70 lets: Vec<Let>,
72 patterns: Vec<Pattern>,
73 phases: HashMap<String, Vec<String>>,
78 default_phase: Option<String>,
82}
83
84#[derive(Debug)]
85struct Pattern {
86 id: Option<String>,
87 #[allow(dead_code)]
92 name: Option<String>,
93 rules: Vec<Rule>,
94}
95
96#[derive(Debug)]
97struct Rule {
98 context: Expr,
99 lets: Vec<Let>,
100 asserts: Vec<Assertion>,
101}
102
103#[derive(Debug)]
104struct Let {
105 name: String,
106 value: Expr,
107}
108
109#[derive(Debug)]
110struct Assertion {
111 kind: AssertKind,
112 test: Expr,
113 message: Vec<MessagePart>,
117 id: Option<String>,
118 role: Option<String>,
119}
120
121#[derive(Debug, Clone, Copy)]
122enum AssertKind { Assert, Report }
123
124#[derive(Debug)]
125enum MessagePart {
126 Text(String),
127 ValueOf(Expr),
129 Name(Expr),
131}
132
133impl Schematron {
136 pub fn compile(schema_doc: &Document) -> Result<Self> {
145 Self::compile_with_loader(schema_doc, &crate::loader::NullLoader, None)
146 }
147
148 pub fn compile_with_loader(
162 schema_doc: &Document,
163 loader: &dyn crate::loader::Loader,
164 base: Option<&str>,
165 ) -> Result<Self> {
166 let root = schema_doc.root();
167 if !is_schematron_element(root) || root.local_name() != "schema" {
168 return Err(XsltError::InvalidStylesheet(
169 "Schematron root element must be sch:schema".into(),
170 ));
171 }
172
173 let mut s = Schematron {
174 namespaces: HashMap::new(),
175 lets: Vec::new(),
176 patterns: Vec::new(),
177 phases: HashMap::new(),
178 default_phase: attr(root, "defaultPhase").map(|s| s.to_string()),
179 };
180 let mut abs_patterns: HashMap<String, &Node> = HashMap::new();
186 let mut abs_rules: HashMap<String, &Node> = HashMap::new();
187 collect_abstract_patterns(root, &mut abs_patterns);
188 collect_abstract_rules(root, &mut abs_rules);
189 let abstracts = Abstracts { patterns: abs_patterns, rules: abs_rules };
190 process_schema_children(root, loader, base, &abstracts, &mut s)?;
191 let has_concrete_rule = s.patterns.iter().any(|p| !p.rules.is_empty());
199 let has_abstract = !abstracts.patterns.is_empty() || !abstracts.rules.is_empty();
200 if !has_concrete_rule && !has_abstract {
201 return Err(XsltError::InvalidStylesheet(
202 "Schematron schema defines no rules".into(),
203 ));
204 }
205 Ok(s)
206 }
207
208 pub fn compile_str(text: &str) -> Result<Self> {
211 let opts = sup_xml_core::ParseOptions {
212 namespace_aware: true, ..Default::default()
213 };
214 let doc = sup_xml_core::parse_str(text, &opts).map_err(XsltError::from)?;
215 Self::compile(&doc)
216 }
217
218 pub fn compile_str_with_loader(
222 text: &str,
223 loader: &dyn crate::loader::Loader,
224 base: Option<&str>,
225 ) -> Result<Self> {
226 let opts = sup_xml_core::ParseOptions {
227 namespace_aware: true, ..Default::default()
228 };
229 let doc = sup_xml_core::parse_str(text, &opts).map_err(XsltError::from)?;
230 Self::compile_with_loader(&doc, loader, base)
231 }
232
233 pub fn compile_iso(
254 schema_text: &str,
255 base_dir: &str,
256 loader: &dyn crate::loader::Loader,
257 ) -> Result<IsoSchematronValidator> {
258 let svrl_path = format!("{base_dir}/iso_svrl_for_xslt1.xsl");
259 let svrl_text = loader.load(&svrl_path, None)?;
260 let svrl = crate::Stylesheet::compile_str_with_loader(
261 &svrl_text, loader, Some(&svrl_path))?;
262 let opts = sup_xml_core::ParseOptions {
263 namespace_aware: true, ..Default::default()
264 };
265 let schema_doc = sup_xml_core::parse_str(schema_text, &opts)
266 .map_err(XsltError::from)?;
267 let validator_xsl = svrl.apply(&schema_doc)?.to_string()?;
268 let validator = crate::Stylesheet::compile_str_with_loader(
269 &validator_xsl, loader, Some(&svrl_path))?;
270 Ok(IsoSchematronValidator { validator })
271 }
272}
273
274pub struct IsoSchematronValidator {
279 validator: crate::Stylesheet,
280}
281
282impl IsoSchematronValidator {
283 pub fn validate_to_svrl(
287 &self,
288 instance_doc: &sup_xml_tree::dom::Document,
289 ) -> Result<String> {
290 let result = self.validator.apply(instance_doc)?;
291 Ok(result.to_string()?)
292 }
293
294 pub fn validate_str(&self, instance_text: &str) -> Result<String> {
296 let opts = sup_xml_core::ParseOptions {
297 namespace_aware: true, ..Default::default()
298 };
299 let doc = sup_xml_core::parse_str(instance_text, &opts)
300 .map_err(XsltError::from)?;
301 self.validate_to_svrl(&doc)
302 }
303}
304
305struct Abstracts<'a> {
310 patterns: HashMap<String, &'a Node<'a>>,
311 rules: HashMap<String, &'a Node<'a>>,
312}
313
314type Params<'a> = Option<&'a HashMap<String, String>>;
319
320fn collect_abstract_patterns<'a>(parent: &'a Node<'a>, out: &mut HashMap<String, &'a Node<'a>>) {
325 walk_abstracts(parent, out, "pattern");
326}
327
328fn collect_abstract_rules<'a>(parent: &'a Node<'a>, out: &mut HashMap<String, &'a Node<'a>>) {
329 walk_abstracts(parent, out, "rule");
330}
331
332fn walk_abstracts<'a>(
333 parent: &'a Node<'a>,
334 out: &mut HashMap<String, &'a Node<'a>>,
335 want: &str,
336) {
337 for child in parent.children() {
338 if !is_schematron_element(child) { continue; }
339 let local = child.local_name();
340 if local == want && attr(child, "abstract") == Some("true") {
341 if let Some(id) = attr(child, "id") {
342 out.insert(id.to_string(), child);
343 }
344 }
345 if local == "pattern" && attr(child, "abstract") != Some("true") {
350 walk_abstracts(child, out, want);
351 }
352 }
353}
354
355fn substitute_params(s: &str, params: Params) -> String {
360 let Some(params) = params else { return s.to_string(); };
361 if params.is_empty() { return s.to_string(); }
362 let mut out = String::with_capacity(s.len());
363 let mut iter = s.char_indices().peekable();
364 while let Some((_, c)) = iter.next() {
365 if c == '$' {
366 if let Some(&(name_start, first)) = iter.peek() {
367 if is_ncname_start(first) {
368 iter.next();
369 let mut name_end = name_start + first.len_utf8();
370 while let Some(&(j, c)) = iter.peek() {
371 if is_ncname_cont(c) {
372 iter.next();
373 name_end = j + c.len_utf8();
374 } else {
375 break;
376 }
377 }
378 let name = &s[name_start..name_end];
379 if let Some(v) = params.get(name) {
380 out.push_str(v);
381 } else {
382 out.push('$');
383 out.push_str(name);
384 }
385 continue;
386 }
387 }
388 out.push('$');
389 } else {
390 out.push(c);
391 }
392 }
393 out
394}
395
396fn is_ncname_start(c: char) -> bool {
397 matches!(c, 'a'..='z' | 'A'..='Z' | '_')
400}
401
402fn is_ncname_cont(c: char) -> bool {
403 is_ncname_start(c) || c.is_ascii_digit() || c == '-' || c == '.'
404}
405
406fn parse_xpath_with_params(s: &str, params: Params) -> Result<Expr> {
409 parse_xpath(&substitute_params(s, params)).map_err(XsltError::from)
410}
411
412fn process_schema_children<'a, 'b>(
416 parent: &'b Node<'b>,
417 loader: &dyn crate::loader::Loader,
418 base: Option<&str>,
419 abstracts: &Abstracts<'a>,
420 out: &mut Schematron,
421) -> Result<()> {
422 for child in parent.children() {
423 if !is_schematron_element(child) { continue; }
424 match child.local_name() {
425 "ns" => {
426 if let (Some(p), Some(u)) = (
427 attr(child, "prefix"), attr(child, "uri"),
428 ) {
429 out.namespaces.insert(p.to_string(), u.to_string());
430 }
431 }
432 "let" => out.lets.push(compile_let(child, None)?),
433 "pattern" => {
434 if attr(child, "abstract") == Some("true") { continue; }
438 if let Some(abs_id) = attr(child, "is-a") {
439 out.patterns.push(instantiate_abstract_pattern(
440 child, abs_id, loader, base, abstracts,
441 )?);
442 } else {
443 out.patterns.push(compile_pattern(child, loader, base, abstracts, None)?);
444 }
445 }
446 "phase" => {
447 if let Some(id) = attr(child, "id") {
448 let mut active = Vec::new();
449 for c in child.children() {
450 if is_schematron_element(c)
451 && c.local_name() == "active"
452 {
453 if let Some(p) = attr(c, "pattern") {
454 active.push(p.to_string());
455 }
456 }
457 }
458 out.phases.insert(id.to_string(), active);
459 }
460 }
461 "include" => {
462 let (doc, target_lookup, sub_base) = load_include(child, loader, base)?;
463 let target = locate_include_target(doc.root(), target_lookup.as_deref())?;
464 if target.local_name() == "schema" && is_schematron_element(target) {
468 process_schema_children(target, loader, sub_base.as_deref(), abstracts, out)?;
469 } else {
470 process_schema_one(target, loader, sub_base.as_deref(), abstracts, out)?;
471 }
472 }
473 _ => {}
475 }
476 }
477 Ok(())
478}
479
480fn process_schema_one<'a, 'b>(
484 node: &'b Node<'b>,
485 loader: &dyn crate::loader::Loader,
486 base: Option<&str>,
487 abstracts: &Abstracts<'a>,
488 out: &mut Schematron,
489) -> Result<()> {
490 if !is_schematron_element(node) { return Ok(()); }
491 match node.local_name() {
492 "ns" => {
493 if let (Some(p), Some(u)) = (attr(node, "prefix"), attr(node, "uri")) {
494 out.namespaces.insert(p.to_string(), u.to_string());
495 }
496 }
497 "let" => out.lets.push(compile_let(node, None)?),
498 "pattern" => {
499 if attr(node, "abstract") == Some("true") { return Ok(()); }
500 if let Some(abs_id) = attr(node, "is-a") {
501 out.patterns.push(instantiate_abstract_pattern(
502 node, abs_id, loader, base, abstracts,
503 )?);
504 } else {
505 out.patterns.push(compile_pattern(node, loader, base, abstracts, None)?);
506 }
507 }
508 _ => {} }
510 Ok(())
511}
512
513fn instantiate_abstract_pattern<'a, 'b>(
518 instance: &'b Node<'b>,
519 abs_id: &str,
520 loader: &dyn crate::loader::Loader,
521 base: Option<&str>,
522 abstracts: &Abstracts<'a>,
523) -> Result<Pattern> {
524 let abstract_node = abstracts.patterns.get(abs_id).ok_or_else(|| {
525 XsltError::UnresolvedReference(format!(
526 "<pattern is-a='{abs_id}'> references an abstract pattern that wasn't declared"
527 ))
528 })?;
529 let mut params: HashMap<String, String> = HashMap::new();
531 for c in instance.children() {
532 if !is_schematron_element(c) || c.local_name() != "param" { continue; }
533 let n = attr(c, "name").ok_or_else(|| XsltError::InvalidStylesheet(
534 "sch:param requires a name= attribute".into(),
535 ))?;
536 let v = attr(c, "value").ok_or_else(|| XsltError::InvalidStylesheet(
537 "sch:param requires a value= attribute".into(),
538 ))?;
539 params.insert(n.to_string(), v.to_string());
540 }
541 let id = attr(instance, "id").map(|s| s.to_string());
543 let name = attr(instance, "name").map(|s| s.to_string());
544 let mut rules = Vec::new();
545 process_pattern_children(abstract_node, loader, base, abstracts, Some(¶ms), &mut rules)?;
546 Ok(Pattern { id, name, rules })
547}
548
549fn compile_pattern<'a, 'b>(
550 node: &'b Node<'b>,
551 loader: &dyn crate::loader::Loader,
552 base: Option<&str>,
553 abstracts: &Abstracts<'a>,
554 params: Params,
555) -> Result<Pattern> {
556 let id = attr(node, "id").map(|s| s.to_string());
557 let name = attr(node, "name").map(|s| s.to_string());
558 let mut rules = Vec::new();
559 process_pattern_children(node, loader, base, abstracts, params, &mut rules)?;
560 Ok(Pattern { id, name, rules })
561}
562
563fn process_pattern_children<'a, 'b>(
564 parent: &'b Node<'b>,
565 loader: &dyn crate::loader::Loader,
566 base: Option<&str>,
567 abstracts: &Abstracts<'a>,
568 params: Params,
569 rules: &mut Vec<Rule>,
570) -> Result<()> {
571 for child in parent.children() {
572 if !is_schematron_element(child) { continue; }
573 match child.local_name() {
574 "rule" => {
575 if attr(child, "abstract") == Some("true") { continue; }
578 rules.push(compile_rule(child, loader, base, abstracts, params)?);
579 }
580 "include" => {
581 let (doc, target_lookup, sub_base) = load_include(child, loader, base)?;
582 let target = locate_include_target(doc.root(), target_lookup.as_deref())?;
583 match (is_schematron_element(target), target.local_name()) {
586 (true, "pattern") => process_pattern_children(target, loader, sub_base.as_deref(), abstracts, params, rules)?,
587 (true, "rule") => rules.push(compile_rule(target, loader, sub_base.as_deref(), abstracts, params)?),
588 _ => return Err(XsltError::InvalidStylesheet(format!(
589 "sch:include inside <pattern> must point to a <pattern> or <rule>; got <{}>",
590 target.name(),
591 ))),
592 }
593 }
594 _ => {}
595 }
596 }
597 Ok(())
598}
599
600fn compile_rule<'a, 'b>(
601 node: &'b Node<'b>,
602 loader: &dyn crate::loader::Loader,
603 base: Option<&str>,
604 abstracts: &Abstracts<'a>,
605 params: Params,
606) -> Result<Rule> {
607 let ctx = attr(node, "context").ok_or_else(|| XsltError::InvalidStylesheet(
608 "sch:rule requires a context= attribute".into(),
609 ))?;
610 let context = parse_xpath_with_params(ctx, params)?;
611 let mut lets = Vec::new();
612 let mut asserts = Vec::new();
613 process_rule_children(node, loader, base, abstracts, params, &mut lets, &mut asserts)?;
614 Ok(Rule { context, lets, asserts })
615}
616
617fn process_rule_children<'a, 'b>(
618 parent: &'b Node<'b>,
619 loader: &dyn crate::loader::Loader,
620 base: Option<&str>,
621 abstracts: &Abstracts<'a>,
622 params: Params,
623 lets: &mut Vec<Let>,
624 asserts: &mut Vec<Assertion>,
625) -> Result<()> {
626 for child in parent.children() {
627 if !is_schematron_element(child) { continue; }
628 match child.local_name() {
629 "let" => lets.push(compile_let(child, params)?),
630 "assert" => asserts.push(compile_assertion(child, AssertKind::Assert, params)?),
631 "report" => asserts.push(compile_assertion(child, AssertKind::Report, params)?),
632 "extends" => {
633 let rule_id = attr(child, "rule").ok_or_else(|| XsltError::InvalidStylesheet(
638 "sch:extends requires a rule= attribute".into(),
639 ))?;
640 let abstract_rule = abstracts.rules.get(rule_id).ok_or_else(||
641 XsltError::UnresolvedReference(format!(
642 "<extends rule='{rule_id}'> references an abstract rule that wasn't declared"
643 ))
644 )?;
645 process_rule_children(abstract_rule, loader, base, abstracts, params, lets, asserts)?;
646 }
647 "include" => {
648 let (doc, target_lookup, sub_base) = load_include(child, loader, base)?;
649 let target = locate_include_target(doc.root(), target_lookup.as_deref())?;
650 match (is_schematron_element(target), target.local_name()) {
654 (true, "rule") => process_rule_children(target, loader, sub_base.as_deref(), abstracts, params, lets, asserts)?,
655 (true, "assert") => asserts.push(compile_assertion(target, AssertKind::Assert, params)?),
656 (true, "report") => asserts.push(compile_assertion(target, AssertKind::Report, params)?),
657 (true, "let") => lets.push(compile_let(target, params)?),
658 _ => return Err(XsltError::InvalidStylesheet(format!(
659 "sch:include inside <rule> must point to <rule>/<assert>/<report>/<let>; got <{}>",
660 target.name(),
661 ))),
662 }
663 }
664 _ => {}
665 }
666 }
667 Ok(())
668}
669
670fn load_include(
675 node: &Node,
676 loader: &dyn crate::loader::Loader,
677 base: Option<&str>,
678) -> Result<(Document, Option<String>, Option<String>)> {
679 let href = attr(node, "href").ok_or_else(|| XsltError::InvalidStylesheet(
680 "sch:include requires an href= attribute".into(),
681 ))?;
682 let (path, fragment) = match href.find('#') {
685 Some(i) => (&href[..i], Some(href[i+1..].to_string())),
686 None => (href, None),
687 };
688 let text = loader.load(path, base)?;
689 let opts = sup_xml_core::ParseOptions {
690 namespace_aware: true, ..Default::default()
691 };
692 let doc = sup_xml_core::parse_str(&text, &opts).map_err(XsltError::from)?;
693 let resolved_base = loader.resolve(path, base).ok();
694 Ok((doc, fragment, resolved_base))
695}
696
697fn locate_include_target<'a>(
702 root: &'a Node<'a>,
703 fragment: Option<&str>,
704) -> Result<&'a Node<'a>> {
705 let Some(id) = fragment else { return Ok(root); };
706 find_by_id(root, id).ok_or_else(|| XsltError::InvalidStylesheet(format!(
707 "sch:include fragment '#{id}' not found in loaded document"
708 )))
709}
710
711fn find_by_id<'a>(node: &'a Node<'a>, id: &str) -> Option<&'a Node<'a>> {
712 if node.is_element() {
713 if attr(node, "id") == Some(id) {
714 return Some(node);
715 }
716 for a in node.attributes() {
718 if a.local_name() == "id"
719 && a.namespace.get().and_then(|n| n.prefix()) == Some("xml") && a.value() == id {
720 return Some(node);
721 }
722 }
723 for c in node.children() {
724 if let Some(hit) = find_by_id(c, id) { return Some(hit); }
725 }
726 }
727 None
728}
729
730fn compile_let(node: &Node, params: Params) -> Result<Let> {
731 let name = attr(node, "name").ok_or_else(|| XsltError::InvalidStylesheet(
732 "sch:let requires a name= attribute".into(),
733 ))?.to_string();
734 let value = attr(node, "value").ok_or_else(|| XsltError::InvalidStylesheet(
735 "sch:let requires a value= attribute".into(),
736 ))?;
737 Ok(Let { name, value: parse_xpath_with_params(value, params)? })
738}
739
740fn compile_assertion(node: &Node, kind: AssertKind, params: Params) -> Result<Assertion> {
741 let test = attr(node, "test").ok_or_else(|| XsltError::InvalidStylesheet(
742 format!("sch:{} requires a test= attribute",
743 if matches!(kind, AssertKind::Assert) { "assert" } else { "report" }),
744 ))?;
745 let test = parse_xpath_with_params(test, params)?;
746 Ok(Assertion {
747 kind,
748 test,
749 message: compile_message(node, params)?,
750 id: attr(node, "id").map(str::to_string),
751 role: attr(node, "role").map(str::to_string),
752 })
753}
754
755fn compile_message(node: &Node, params: Params) -> Result<Vec<MessagePart>> {
756 let mut parts = Vec::new();
757 for child in node.children() {
758 match child.kind {
759 NodeKind::Text | NodeKind::CData => {
760 parts.push(MessagePart::Text(child.content().to_string()));
761 }
762 NodeKind::Element if is_schematron_element(child) => {
763 match child.local_name() {
764 "value-of" => {
765 let sel = attr(child, "select").ok_or_else(||
766 XsltError::InvalidStylesheet(
767 "sch:value-of requires select=".into()))?;
768 parts.push(MessagePart::ValueOf(
769 parse_xpath_with_params(sel, params)?));
770 }
771 "name" => {
772 let path = attr(child, "path").unwrap_or(".");
773 parts.push(MessagePart::Name(
774 parse_xpath_with_params(path, params)?));
775 }
776 _ => {
777 parts.push(MessagePart::Text(child_text(child)));
780 }
781 }
782 }
783 _ => {}
784 }
785 }
786 Ok(parts)
787}
788
789fn child_text(node: &Node) -> String {
790 let mut s = String::new();
791 for c in node.children() {
792 if matches!(c.kind, NodeKind::Text | NodeKind::CData) {
793 s.push_str(c.content());
794 }
795 }
796 s
797}
798
799fn attr<'a>(node: &'a Node, name: &str) -> Option<&'a str> {
800 for a in node.attributes() {
801 if a.name() == name && !a.name().contains(':') {
802 return Some(a.value());
803 }
804 }
805 None
806}
807
808#[derive(Debug, Clone)]
812pub struct Finding {
813 pub kind: FindingKind,
814 pub message: String,
815 pub pattern_id: Option<String>,
818 pub assertion_id: Option<String>,
819 pub role: Option<String>,
820 pub location_id: String,
823 pub context_name: String,
826}
827
828#[derive(Debug, Clone, Copy, PartialEq, Eq)]
829pub enum FindingKind {
830 FailedAssert,
832 SuccessfulReport,
834}
835
836#[derive(Debug, Clone, Default)]
840pub struct ValidationReport {
841 pub findings: Vec<Finding>,
842}
843
844impl ValidationReport {
845 pub fn valid(&self) -> bool {
850 !self.findings.iter().any(|f| matches!(f.kind, FindingKind::FailedAssert))
851 }
852}
853
854struct SchBindings<'a> {
858 namespaces: &'a HashMap<String, String>,
859 vars: &'a HashMap<String, Value>,
860}
861
862fn sch_static_ctx(bindings: &SchBindings<'_>) -> StaticContext {
866 StaticContext {
867 xpath_2_0: bindings.xpath_version_2_or_later(),
868 xpath_3_0: false,
869 libxml2_compatible: false, current_node: None,
870 }
871}
872
873impl XPathBindings for SchBindings<'_> {
874 fn resolve_prefix(&self, prefix: &str) -> Option<String> {
875 self.namespaces.get(prefix).cloned()
876 }
877 fn variable(&self, name: &str) -> Option<Value> {
878 self.vars.get(name).cloned()
879 }
880}
881
882impl Schematron {
883 pub fn validate_str(&self, instance_text: &str) -> Result<ValidationReport> {
887 self.validate_str_with_phase(instance_text, "#ALL")
888 }
889
890 pub fn validate_str_with_phase(
894 &self, instance_text: &str, phase: &str,
895 ) -> Result<ValidationReport> {
896 let opts = sup_xml_core::ParseOptions {
897 namespace_aware: true, ..Default::default()
898 };
899 let doc = sup_xml_core::parse_str(instance_text, &opts).map_err(XsltError::from)?;
900 self.validate_with_phase(&doc, phase)
901 }
902
903 pub fn validate(&self, instance_doc: &Document) -> Result<ValidationReport> {
910 self.validate_with_phase(instance_doc, "#ALL")
911 }
912
913 pub fn validate_with_phase(
928 &self, instance_doc: &Document, phase: &str,
929 ) -> Result<ValidationReport> {
930 let active: Option<&[String]> = match phase {
931 "#ALL" => None,
932 "#DEFAULT" => match self.default_phase.as_deref() {
933 None | Some("#ALL") => None,
934 Some(name) => Some(self.phases.get(name)
935 .ok_or_else(|| XsltError::InvalidStylesheet(format!(
936 "schema defaultPhase='{name}' but no <sch:phase id='{name}'> declared"
937 )))?
938 .as_slice()),
939 },
940 other => Some(self.phases.get(other)
941 .ok_or_else(|| XsltError::InvalidStylesheet(format!(
942 "unknown phase '{other}': no matching <sch:phase id='{other}'> in schema"
943 )))?
944 .as_slice()),
945 };
946 self.validate_inner(instance_doc, active)
947 }
948
949 fn validate_inner(
950 &self, instance_doc: &Document, active: Option<&[String]>,
951 ) -> Result<ValidationReport> {
952 let idx = DocIndex::build(instance_doc);
953 let mut report = ValidationReport::default();
954
955 let mut schema_vars: HashMap<String, Value> = HashMap::new();
961 for l in &self.lets {
962 let v = {
963 let bindings = SchBindings {
964 namespaces: &self.namespaces,
965 vars: &schema_vars,
966 };
967 let sc = sch_static_ctx(&bindings);
968 let ctx = EvalCtx { context_node: 0, pos: 1, size: 1, bindings: &bindings, static_ctx: &sc };
969 eval_expr(&l.value, &ctx, &idx).map_err(XsltError::from)?
970 };
971 schema_vars.insert(l.name.clone(), v);
972 }
973
974 for pattern in &self.patterns {
976 if let Some(list) = active {
980 let Some(pid) = pattern.id.as_deref() else { continue; };
981 if !list.iter().any(|n| n == pid) { continue; }
982 }
983 for node_id in 0..idx.nodes.len() {
984 let kind = idx.kind(node_id);
989 if !matches!(kind,
990 XPathNodeKind::Element | XPathNodeKind::Document
991 | XPathNodeKind::Attribute)
992 {
993 continue;
994 }
995 for rule in &pattern.rules {
996 if !rule_matches(&rule.context, node_id, &idx,
997 &self.namespaces, &schema_vars)?
998 {
999 continue;
1000 }
1001 let mut local_vars = schema_vars.clone();
1005 for l in &rule.lets {
1006 let v = {
1007 let bindings = SchBindings {
1008 namespaces: &self.namespaces, vars: &local_vars,
1009 };
1010 let sc = sch_static_ctx(&bindings);
1011 let ctx = EvalCtx { context_node: node_id, pos: 1, size: 1, bindings: &bindings, static_ctx: &sc };
1012 eval_expr(&l.value, &ctx, &idx).map_err(XsltError::from)?
1013 };
1014 local_vars.insert(l.name.clone(), v);
1015 }
1016 for assertion in &rule.asserts {
1017 evaluate_assertion(
1018 assertion, node_id, &idx,
1019 &self.namespaces, &local_vars,
1020 pattern, &mut report,
1021 )?;
1022 }
1023 break;
1024 }
1025 }
1026 }
1027 Ok(report)
1028 }
1029}
1030
1031fn rule_matches(
1032 context: &Expr, node: NodeId, idx: &DocIndex<'_>,
1033 namespaces: &HashMap<String, String>,
1034 vars: &HashMap<String, Value>,
1035) -> Result<bool> {
1036 let bindings = SchBindings { namespaces, vars };
1037 let sc = sch_static_ctx(&bindings);
1038 let mut cur = Some(node);
1039 while let Some(ctx_node) = cur {
1040 let ctx = EvalCtx { context_node: ctx_node, pos: 1, size: 1, bindings: &bindings, static_ctx: &sc };
1041 let v = eval_expr(context, &ctx, idx).map_err(XsltError::from)?;
1042 if let Value::NodeSet(ns) = v {
1043 if ns.contains(&node) {
1044 return Ok(true);
1045 }
1046 }
1047 cur = idx.parent(ctx_node);
1048 }
1049 Ok(false)
1050}
1051
1052fn evaluate_assertion(
1053 a: &Assertion,
1054 node: NodeId,
1055 idx: &DocIndex<'_>,
1056 namespaces: &HashMap<String, String>,
1057 vars: &HashMap<String, Value>,
1058 pattern: &Pattern,
1059 report: &mut ValidationReport,
1060) -> Result<()> {
1061 let bindings = SchBindings { namespaces, vars };
1062 let sc = sch_static_ctx(&bindings);
1063 let ctx = EvalCtx { context_node: node, pos: 1, size: 1, bindings: &bindings, static_ctx: &sc };
1064 let test_v = eval_expr(&a.test, &ctx, idx).map_err(XsltError::from)?;
1065 let truth = value_to_bool(&test_v);
1066 let fired = match a.kind {
1067 AssertKind::Assert => !truth, AssertKind::Report => truth, };
1070 if !fired { return Ok(()); }
1071 let message = render_message(&a.message, node, idx, namespaces, vars)?;
1072 report.findings.push(Finding {
1073 kind: match a.kind {
1074 AssertKind::Assert => FindingKind::FailedAssert,
1075 AssertKind::Report => FindingKind::SuccessfulReport,
1076 },
1077 message,
1078 pattern_id: pattern.id.clone(),
1079 assertion_id: a.id.clone(),
1080 role: a.role.clone(),
1081 location_id: format!("id{:x}", node),
1082 context_name: idx.local_name(node).to_string(),
1083 });
1084 Ok(())
1085}
1086
1087fn render_message(
1088 parts: &[MessagePart],
1089 node: NodeId,
1090 idx: &DocIndex<'_>,
1091 namespaces: &HashMap<String, String>,
1092 vars: &HashMap<String, Value>,
1093) -> Result<String> {
1094 let bindings = SchBindings { namespaces, vars };
1095 let sc = sch_static_ctx(&bindings);
1096 let ctx = EvalCtx { context_node: node, pos: 1, size: 1, bindings: &bindings, static_ctx: &sc };
1097 let mut s = String::new();
1098 for p in parts {
1099 match p {
1100 MessagePart::Text(t) => s.push_str(t),
1101 MessagePart::ValueOf(e) => {
1102 let v = eval_expr(e, &ctx, idx).map_err(XsltError::from)?;
1103 s.push_str(&value_to_string(&v, idx));
1104 }
1105 MessagePart::Name(e) => {
1106 let v = eval_expr(e, &ctx, idx).map_err(XsltError::from)?;
1107 let target_node = match v {
1108 Value::NodeSet(ns) if !ns.is_empty() => ns[0],
1109 _ => node,
1110 };
1111 s.push_str(idx.node_name(target_node));
1112 }
1113 }
1114 }
1115 let normalised: Vec<&str> = s.split_whitespace().collect();
1119 Ok(normalised.join(" "))
1120}
1121
1122fn value_to_bool(v: &Value) -> bool {
1123 match v {
1124 Value::Boolean(b) => *b,
1125 Value::Number(n) => n.as_f64() != 0.0 && !n.as_f64().is_nan(),
1126 Value::String(s) => !s.is_empty(),
1127 Value::NodeSet(n) => !n.is_empty(),
1128 Value::ForeignNodeSet(n) => !n.is_empty(),
1131 Value::Typed(t) => {
1132 if let Some(b) = t.boolean { return b; }
1133 if let Some(n) = t.numeric { return n != 0.0 && !n.is_nan(); }
1134 !t.lexical.is_empty()
1135 }
1136 Value::Sequence(items) => match items.first() {
1137 None => false,
1138 Some(v) => value_to_bool(v),
1139 }
1140 Value::IntRange { lo, hi } if lo == hi => *lo != 0,
1141 Value::IntRange { .. } => true,
1142 Value::Map(_) | Value::Array(_) | Value::Function(_) => true,
1143 }
1144}
1145
1146fn value_to_string<I: DocIndexLike>(v: &Value, idx: &I) -> String {
1147 use sup_xml_core::xpath::eval::value_to_string;
1148 value_to_string(v, idx)
1149}
1150
1151#[cfg(test)]
1152mod tests {
1153 use super::*;
1154
1155 fn compile(text: &str) -> Schematron {
1156 Schematron::compile_str(text).expect("schematron compile")
1157 }
1158
1159 fn validate(sch: &Schematron, xml: &str) -> ValidationReport {
1160 sch.validate_str(xml).expect("validate")
1161 }
1162
1163 #[test]
1166 fn compile_minimal_schema() {
1167 let s = compile(r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
1168 <pattern>
1169 <rule context="r">
1170 <assert test="@x">missing x</assert>
1171 </rule>
1172 </pattern>
1173 </schema>"#);
1174 assert_eq!(s.patterns.len(), 1);
1175 assert_eq!(s.patterns[0].rules.len(), 1);
1176 assert_eq!(s.patterns[0].rules[0].asserts.len(), 1);
1177 }
1178
1179 #[test]
1180 fn compile_rejects_non_schematron_root() {
1181 let err = Schematron::compile_str("<foo/>").unwrap_err();
1182 assert!(format!("{err}").contains("sch:schema"), "got: {err}");
1183 }
1184
1185 #[test]
1186 fn accepts_old_namespace() {
1187 let s = compile(r#"<schema xmlns="http://www.ascc.net/xml/schematron">
1188 <pattern><rule context="*"><assert test="true()">ok</assert></rule></pattern>
1189 </schema>"#);
1190 assert_eq!(s.patterns.len(), 1);
1191 }
1192
1193 #[test]
1196 fn assert_fails_for_missing_attribute() {
1197 let s = compile(r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
1198 <pattern>
1199 <rule context="r">
1200 <assert test="@x">missing x</assert>
1201 </rule>
1202 </pattern>
1203 </schema>"#);
1204 let r = validate(&s, "<r/>");
1205 assert!(!r.valid());
1206 assert_eq!(r.findings.len(), 1);
1207 assert_eq!(r.findings[0].kind, FindingKind::FailedAssert);
1208 assert_eq!(r.findings[0].message, "missing x");
1209 }
1210
1211 #[test]
1212 fn assert_passes_when_test_true() {
1213 let s = compile(r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
1214 <pattern>
1215 <rule context="r">
1216 <assert test="@x">missing x</assert>
1217 </rule>
1218 </pattern>
1219 </schema>"#);
1220 let r = validate(&s, r#"<r x="42"/>"#);
1221 assert!(r.valid());
1222 assert!(r.findings.is_empty());
1223 }
1224
1225 #[test]
1226 fn report_fires_when_test_true() {
1227 let s = compile(r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
1228 <pattern>
1229 <rule context="r">
1230 <report test="@deprecated">element is deprecated</report>
1231 </rule>
1232 </pattern>
1233 </schema>"#);
1234 let r = validate(&s, r#"<r deprecated="yes"/>"#);
1235 assert!(r.valid());
1237 assert_eq!(r.findings.len(), 1);
1238 assert_eq!(r.findings[0].kind, FindingKind::SuccessfulReport);
1239 }
1240
1241 #[test]
1242 fn first_matching_rule_wins_per_pattern() {
1243 let s = compile(r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
1244 <pattern>
1245 <rule context="*[@kind='a']">
1246 <assert test="false()">first rule</assert>
1247 </rule>
1248 <rule context="*">
1249 <assert test="false()">second rule</assert>
1250 </rule>
1251 </pattern>
1252 </schema>"#);
1253 let r = validate(&s, r#"<r kind="a"/>"#);
1254 let messages: Vec<_> = r.findings.iter().map(|f| f.message.clone()).collect();
1256 assert!(messages.iter().any(|m| m == "first rule"));
1257 assert!(!messages.iter().any(|m| m == "second rule"));
1258 }
1259
1260 #[test]
1261 fn namespaces_resolve_via_sch_ns_declaration() {
1262 let s = compile(r##"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
1263 <ns prefix="dc" uri="http://purl.org/dc/elements/1.1/"/>
1264 <pattern>
1265 <rule context="dc:title">
1266 <assert test="normalize-space(.) != ''">title is empty</assert>
1267 </rule>
1268 </pattern>
1269 </schema>"##);
1270 let r = validate(&s, r#"<r xmlns:dc="http://purl.org/dc/elements/1.1/">
1271 <dc:title></dc:title>
1272 </r>"#);
1273 assert!(!r.valid(), "empty title should fail assertion");
1274 }
1275
1276 #[test]
1277 fn message_inlines_value_of() {
1278 let s = compile(r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
1279 <pattern>
1280 <rule context="r">
1281 <assert test="@x">value: <value-of select="@y"/></assert>
1282 </rule>
1283 </pattern>
1284 </schema>"#);
1285 let r = validate(&s, r#"<r y="hello"/>"#);
1286 assert_eq!(r.findings[0].message, "value: hello");
1288 }
1289
1290 #[test]
1291 fn message_inlines_name() {
1292 let s = compile(r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
1293 <pattern>
1294 <rule context="*">
1295 <report test="@bad">element <name/> has bad attr</report>
1296 </rule>
1297 </pattern>
1298 </schema>"#);
1299 let r = validate(&s, r#"<foo bad="yes"/>"#);
1300 assert!(r.findings.iter().any(|f| f.message.contains("foo")));
1301 }
1302
1303 #[test]
1304 fn pattern_id_propagates_to_findings() {
1305 let s = compile(r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
1306 <pattern id="check-001">
1307 <rule context="r">
1308 <assert test="@x">missing x</assert>
1309 </rule>
1310 </pattern>
1311 </schema>"#);
1312 let r = validate(&s, "<r/>");
1313 assert_eq!(r.findings[0].pattern_id.as_deref(), Some("check-001"));
1314 }
1315
1316 #[test]
1317 fn rule_let_provides_local_binding() {
1318 let s = compile(r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
1319 <pattern>
1320 <rule context="r">
1321 <let name="count" value="count(item)"/>
1322 <assert test="$count = 3">expected 3 items</assert>
1323 </rule>
1324 </pattern>
1325 </schema>"#);
1326 let r = validate(&s, "<r><item/><item/></r>");
1328 assert!(!r.valid());
1329 let r = validate(&s, "<r><item/><item/><item/></r>");
1331 assert!(r.valid());
1332 }
1333
1334 #[test]
1337 fn rule_without_context_errors() {
1338 let r = Schematron::compile_str(r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
1339 <pattern><rule><assert test="true()">ok</assert></rule></pattern>
1340 </schema>"#);
1341 assert!(r.is_err());
1342 if let Err(XsltError::InvalidStylesheet(msg)) = r {
1343 assert!(msg.contains("context"), "got {msg}");
1344 }
1345 }
1346
1347 #[test]
1348 fn let_without_name_errors() {
1349 let r = Schematron::compile_str(r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
1350 <pattern><rule context="r">
1351 <let value="@x"/>
1352 <assert test="true()">ok</assert>
1353 </rule></pattern>
1354 </schema>"#);
1355 assert!(r.is_err());
1356 if let Err(XsltError::InvalidStylesheet(msg)) = r {
1357 assert!(msg.contains("name"), "got {msg}");
1358 }
1359 }
1360
1361 #[test]
1362 fn let_without_value_errors() {
1363 let r = Schematron::compile_str(r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
1364 <pattern><rule context="r">
1365 <let name="count"/>
1366 <assert test="true()">ok</assert>
1367 </rule></pattern>
1368 </schema>"#);
1369 assert!(r.is_err());
1370 if let Err(XsltError::InvalidStylesheet(msg)) = r {
1371 assert!(msg.contains("value"), "got {msg}");
1372 }
1373 }
1374
1375 #[test]
1376 fn assert_without_test_errors() {
1377 let r = Schematron::compile_str(r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
1378 <pattern><rule context="r"><assert>missing</assert></rule></pattern>
1379 </schema>"#);
1380 assert!(r.is_err());
1381 if let Err(XsltError::InvalidStylesheet(msg)) = r {
1382 assert!(msg.contains("test"), "got {msg}");
1383 assert!(msg.contains("assert"), "got {msg}");
1384 }
1385 }
1386
1387 #[test]
1388 fn report_without_test_errors() {
1389 let r = Schematron::compile_str(r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
1390 <pattern><rule context="r"><report>missing</report></rule></pattern>
1391 </schema>"#);
1392 assert!(r.is_err());
1393 if let Err(XsltError::InvalidStylesheet(msg)) = r {
1394 assert!(msg.contains("report"), "got {msg}");
1395 }
1396 }
1397
1398 #[test]
1399 fn value_of_without_select_errors() {
1400 let r = Schematron::compile_str(r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
1401 <pattern><rule context="r">
1402 <assert test="true()">val=<value-of/></assert>
1403 </rule></pattern>
1404 </schema>"#);
1405 assert!(r.is_err());
1406 }
1407
1408 #[test]
1411 fn message_inlines_emph_via_text_content() {
1412 let s = compile(r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
1416 <pattern>
1417 <rule context="r">
1418 <assert test="@x">missing <emph>x</emph> attribute</assert>
1419 </rule>
1420 </pattern>
1421 </schema>"#);
1422 let r = validate(&s, "<r/>");
1423 assert!(r.findings[0].message.contains("missing"));
1424 assert!(r.findings[0].message.contains("x"));
1425 assert!(r.findings[0].message.contains("attribute"));
1426 }
1427
1428 #[test]
1429 fn name_with_path_attribute() {
1430 let s = compile(r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
1432 <pattern>
1433 <rule context="r">
1434 <assert test="false()">node was <name path="."/></assert>
1435 </rule>
1436 </pattern>
1437 </schema>"#);
1438 let r = validate(&s, "<r/>");
1439 assert!(r.findings[0].message.contains("node was"));
1440 assert!(r.findings[0].message.contains("r"));
1441 }
1442
1443 #[test]
1446 fn rule_ignores_unknown_children() {
1447 let s = compile(r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
1450 <pattern>
1451 <rule context="r">
1452 <extension/>
1453 <assert test="@x">missing x</assert>
1454 </rule>
1455 </pattern>
1456 </schema>"#);
1457 let r = validate(&s, "<r/>");
1458 assert!(!r.valid());
1459 assert_eq!(r.findings[0].message, "missing x");
1460 }
1461}