use std::collections::HashMap;
use sup_xml_core::xpath::eval::{
eval_expr, EvalCtx, StaticContext, Value, XPathBindings,
};
use sup_xml_core::xpath::{parse_xpath, DocIndex, DocIndexLike, Expr, NodeId, XPathNodeKind};
use sup_xml_tree::dom::{Document, Node, NodeKind};
use crate::error::XsltError;
type Result<T> = std::result::Result<T, XsltError>;
pub const SCH_NS_ISO: &str = "http://purl.oclc.org/dsdl/schematron";
pub const SCH_NS_1_5: &str = "http://www.ascc.net/xml/schematron";
fn is_schematron_element(node: &Node) -> bool {
if !node.is_element() { return false; }
let uri = node.namespace.get().map(|ns| ns.href()).unwrap_or("");
uri == SCH_NS_ISO || uri == SCH_NS_1_5
}
#[derive(Debug)]
pub struct Schematron {
namespaces: HashMap<String, String>,
lets: Vec<Let>,
patterns: Vec<Pattern>,
phases: HashMap<String, Vec<String>>,
default_phase: Option<String>,
}
#[derive(Debug)]
struct Pattern {
id: Option<String>,
#[allow(dead_code)]
name: Option<String>,
rules: Vec<Rule>,
}
#[derive(Debug)]
struct Rule {
context: Expr,
lets: Vec<Let>,
asserts: Vec<Assertion>,
}
#[derive(Debug)]
struct Let {
name: String,
value: Expr,
}
#[derive(Debug)]
struct Assertion {
kind: AssertKind,
test: Expr,
message: Vec<MessagePart>,
id: Option<String>,
role: Option<String>,
}
#[derive(Debug, Clone, Copy)]
enum AssertKind { Assert, Report }
#[derive(Debug)]
enum MessagePart {
Text(String),
ValueOf(Expr),
Name(Expr),
}
impl Schematron {
pub fn compile(schema_doc: &Document) -> Result<Self> {
Self::compile_with_loader(schema_doc, &crate::loader::NullLoader, None)
}
pub fn compile_with_loader(
schema_doc: &Document,
loader: &dyn crate::loader::Loader,
base: Option<&str>,
) -> Result<Self> {
let root = schema_doc.root();
if !is_schematron_element(root) || root.local_name() != "schema" {
return Err(XsltError::InvalidStylesheet(
"Schematron root element must be sch:schema".into(),
));
}
let mut s = Schematron {
namespaces: HashMap::new(),
lets: Vec::new(),
patterns: Vec::new(),
phases: HashMap::new(),
default_phase: attr(root, "defaultPhase").map(|s| s.to_string()),
};
let mut abs_patterns: HashMap<String, &Node> = HashMap::new();
let mut abs_rules: HashMap<String, &Node> = HashMap::new();
collect_abstract_patterns(root, &mut abs_patterns);
collect_abstract_rules(root, &mut abs_rules);
let abstracts = Abstracts { patterns: abs_patterns, rules: abs_rules };
process_schema_children(root, loader, base, &abstracts, &mut s)?;
let has_concrete_rule = s.patterns.iter().any(|p| !p.rules.is_empty());
let has_abstract = !abstracts.patterns.is_empty() || !abstracts.rules.is_empty();
if !has_concrete_rule && !has_abstract {
return Err(XsltError::InvalidStylesheet(
"Schematron schema defines no rules".into(),
));
}
Ok(s)
}
pub fn compile_str(text: &str) -> Result<Self> {
let opts = sup_xml_core::ParseOptions {
namespace_aware: true, ..Default::default()
};
let doc = sup_xml_core::parse_str(text, &opts).map_err(XsltError::from)?;
Self::compile(&doc)
}
pub fn compile_str_with_loader(
text: &str,
loader: &dyn crate::loader::Loader,
base: Option<&str>,
) -> Result<Self> {
let opts = sup_xml_core::ParseOptions {
namespace_aware: true, ..Default::default()
};
let doc = sup_xml_core::parse_str(text, &opts).map_err(XsltError::from)?;
Self::compile_with_loader(&doc, loader, base)
}
pub fn compile_iso(
schema_text: &str,
base_dir: &str,
loader: &dyn crate::loader::Loader,
) -> Result<IsoSchematronValidator> {
let svrl_path = format!("{base_dir}/iso_svrl_for_xslt1.xsl");
let svrl_text = loader.load(&svrl_path, None)?;
let svrl = crate::Stylesheet::compile_str_with_loader(
&svrl_text, loader, Some(&svrl_path))?;
let opts = sup_xml_core::ParseOptions {
namespace_aware: true, ..Default::default()
};
let schema_doc = sup_xml_core::parse_str(schema_text, &opts)
.map_err(XsltError::from)?;
let validator_xsl = svrl.apply(&schema_doc)?.to_string()?;
let validator = crate::Stylesheet::compile_str_with_loader(
&validator_xsl, loader, Some(&svrl_path))?;
Ok(IsoSchematronValidator { validator })
}
}
pub struct IsoSchematronValidator {
validator: crate::Stylesheet,
}
impl IsoSchematronValidator {
pub fn validate_to_svrl(
&self,
instance_doc: &sup_xml_tree::dom::Document,
) -> Result<String> {
let result = self.validator.apply(instance_doc)?;
Ok(result.to_string()?)
}
pub fn validate_str(&self, instance_text: &str) -> Result<String> {
let opts = sup_xml_core::ParseOptions {
namespace_aware: true, ..Default::default()
};
let doc = sup_xml_core::parse_str(instance_text, &opts)
.map_err(XsltError::from)?;
self.validate_to_svrl(&doc)
}
}
struct Abstracts<'a> {
patterns: HashMap<String, &'a Node<'a>>,
rules: HashMap<String, &'a Node<'a>>,
}
type Params<'a> = Option<&'a HashMap<String, String>>;
fn collect_abstract_patterns<'a>(parent: &'a Node<'a>, out: &mut HashMap<String, &'a Node<'a>>) {
walk_abstracts(parent, out, "pattern");
}
fn collect_abstract_rules<'a>(parent: &'a Node<'a>, out: &mut HashMap<String, &'a Node<'a>>) {
walk_abstracts(parent, out, "rule");
}
fn walk_abstracts<'a>(
parent: &'a Node<'a>,
out: &mut HashMap<String, &'a Node<'a>>,
want: &str,
) {
for child in parent.children() {
if !is_schematron_element(child) { continue; }
let local = child.local_name();
if local == want && attr(child, "abstract") == Some("true") {
if let Some(id) = attr(child, "id") {
out.insert(id.to_string(), child);
}
}
if local == "pattern" && attr(child, "abstract") != Some("true") {
walk_abstracts(child, out, want);
}
}
}
fn substitute_params(s: &str, params: Params) -> String {
let Some(params) = params else { return s.to_string(); };
if params.is_empty() { return s.to_string(); }
let mut out = String::with_capacity(s.len());
let mut iter = s.char_indices().peekable();
while let Some((_, c)) = iter.next() {
if c == '$' {
if let Some(&(name_start, first)) = iter.peek() {
if is_ncname_start(first) {
iter.next();
let mut name_end = name_start + first.len_utf8();
while let Some(&(j, c)) = iter.peek() {
if is_ncname_cont(c) {
iter.next();
name_end = j + c.len_utf8();
} else {
break;
}
}
let name = &s[name_start..name_end];
if let Some(v) = params.get(name) {
out.push_str(v);
} else {
out.push('$');
out.push_str(name);
}
continue;
}
}
out.push('$');
} else {
out.push(c);
}
}
out
}
fn is_ncname_start(c: char) -> bool {
matches!(c, 'a'..='z' | 'A'..='Z' | '_')
}
fn is_ncname_cont(c: char) -> bool {
is_ncname_start(c) || c.is_ascii_digit() || c == '-' || c == '.'
}
fn parse_xpath_with_params(s: &str, params: Params) -> Result<Expr> {
parse_xpath(&substitute_params(s, params)).map_err(XsltError::from)
}
fn process_schema_children<'a, 'b>(
parent: &'b Node<'b>,
loader: &dyn crate::loader::Loader,
base: Option<&str>,
abstracts: &Abstracts<'a>,
out: &mut Schematron,
) -> Result<()> {
for child in parent.children() {
if !is_schematron_element(child) { continue; }
match child.local_name() {
"ns" => {
if let (Some(p), Some(u)) = (
attr(child, "prefix"), attr(child, "uri"),
) {
out.namespaces.insert(p.to_string(), u.to_string());
}
}
"let" => out.lets.push(compile_let(child, None)?),
"pattern" => {
if attr(child, "abstract") == Some("true") { continue; }
if let Some(abs_id) = attr(child, "is-a") {
out.patterns.push(instantiate_abstract_pattern(
child, abs_id, loader, base, abstracts,
)?);
} else {
out.patterns.push(compile_pattern(child, loader, base, abstracts, None)?);
}
}
"phase" => {
if let Some(id) = attr(child, "id") {
let mut active = Vec::new();
for c in child.children() {
if is_schematron_element(c)
&& c.local_name() == "active"
{
if let Some(p) = attr(c, "pattern") {
active.push(p.to_string());
}
}
}
out.phases.insert(id.to_string(), active);
}
}
"include" => {
let (doc, target_lookup, sub_base) = load_include(child, loader, base)?;
let target = locate_include_target(doc.root(), target_lookup.as_deref())?;
if target.local_name() == "schema" && is_schematron_element(target) {
process_schema_children(target, loader, sub_base.as_deref(), abstracts, out)?;
} else {
process_schema_one(target, loader, sub_base.as_deref(), abstracts, out)?;
}
}
_ => {}
}
}
Ok(())
}
fn process_schema_one<'a, 'b>(
node: &'b Node<'b>,
loader: &dyn crate::loader::Loader,
base: Option<&str>,
abstracts: &Abstracts<'a>,
out: &mut Schematron,
) -> Result<()> {
if !is_schematron_element(node) { return Ok(()); }
match node.local_name() {
"ns" => {
if let (Some(p), Some(u)) = (attr(node, "prefix"), attr(node, "uri")) {
out.namespaces.insert(p.to_string(), u.to_string());
}
}
"let" => out.lets.push(compile_let(node, None)?),
"pattern" => {
if attr(node, "abstract") == Some("true") { return Ok(()); }
if let Some(abs_id) = attr(node, "is-a") {
out.patterns.push(instantiate_abstract_pattern(
node, abs_id, loader, base, abstracts,
)?);
} else {
out.patterns.push(compile_pattern(node, loader, base, abstracts, None)?);
}
}
_ => {} }
Ok(())
}
fn instantiate_abstract_pattern<'a, 'b>(
instance: &'b Node<'b>,
abs_id: &str,
loader: &dyn crate::loader::Loader,
base: Option<&str>,
abstracts: &Abstracts<'a>,
) -> Result<Pattern> {
let abstract_node = abstracts.patterns.get(abs_id).ok_or_else(|| {
XsltError::UnresolvedReference(format!(
"<pattern is-a='{abs_id}'> references an abstract pattern that wasn't declared"
))
})?;
let mut params: HashMap<String, String> = HashMap::new();
for c in instance.children() {
if !is_schematron_element(c) || c.local_name() != "param" { continue; }
let n = attr(c, "name").ok_or_else(|| XsltError::InvalidStylesheet(
"sch:param requires a name= attribute".into(),
))?;
let v = attr(c, "value").ok_or_else(|| XsltError::InvalidStylesheet(
"sch:param requires a value= attribute".into(),
))?;
params.insert(n.to_string(), v.to_string());
}
let id = attr(instance, "id").map(|s| s.to_string());
let name = attr(instance, "name").map(|s| s.to_string());
let mut rules = Vec::new();
process_pattern_children(abstract_node, loader, base, abstracts, Some(¶ms), &mut rules)?;
Ok(Pattern { id, name, rules })
}
fn compile_pattern<'a, 'b>(
node: &'b Node<'b>,
loader: &dyn crate::loader::Loader,
base: Option<&str>,
abstracts: &Abstracts<'a>,
params: Params,
) -> Result<Pattern> {
let id = attr(node, "id").map(|s| s.to_string());
let name = attr(node, "name").map(|s| s.to_string());
let mut rules = Vec::new();
process_pattern_children(node, loader, base, abstracts, params, &mut rules)?;
Ok(Pattern { id, name, rules })
}
fn process_pattern_children<'a, 'b>(
parent: &'b Node<'b>,
loader: &dyn crate::loader::Loader,
base: Option<&str>,
abstracts: &Abstracts<'a>,
params: Params,
rules: &mut Vec<Rule>,
) -> Result<()> {
for child in parent.children() {
if !is_schematron_element(child) { continue; }
match child.local_name() {
"rule" => {
if attr(child, "abstract") == Some("true") { continue; }
rules.push(compile_rule(child, loader, base, abstracts, params)?);
}
"include" => {
let (doc, target_lookup, sub_base) = load_include(child, loader, base)?;
let target = locate_include_target(doc.root(), target_lookup.as_deref())?;
match (is_schematron_element(target), target.local_name()) {
(true, "pattern") => process_pattern_children(target, loader, sub_base.as_deref(), abstracts, params, rules)?,
(true, "rule") => rules.push(compile_rule(target, loader, sub_base.as_deref(), abstracts, params)?),
_ => return Err(XsltError::InvalidStylesheet(format!(
"sch:include inside <pattern> must point to a <pattern> or <rule>; got <{}>",
target.name(),
))),
}
}
_ => {}
}
}
Ok(())
}
fn compile_rule<'a, 'b>(
node: &'b Node<'b>,
loader: &dyn crate::loader::Loader,
base: Option<&str>,
abstracts: &Abstracts<'a>,
params: Params,
) -> Result<Rule> {
let ctx = attr(node, "context").ok_or_else(|| XsltError::InvalidStylesheet(
"sch:rule requires a context= attribute".into(),
))?;
let context = parse_xpath_with_params(ctx, params)?;
let mut lets = Vec::new();
let mut asserts = Vec::new();
process_rule_children(node, loader, base, abstracts, params, &mut lets, &mut asserts)?;
Ok(Rule { context, lets, asserts })
}
fn process_rule_children<'a, 'b>(
parent: &'b Node<'b>,
loader: &dyn crate::loader::Loader,
base: Option<&str>,
abstracts: &Abstracts<'a>,
params: Params,
lets: &mut Vec<Let>,
asserts: &mut Vec<Assertion>,
) -> Result<()> {
for child in parent.children() {
if !is_schematron_element(child) { continue; }
match child.local_name() {
"let" => lets.push(compile_let(child, params)?),
"assert" => asserts.push(compile_assertion(child, AssertKind::Assert, params)?),
"report" => asserts.push(compile_assertion(child, AssertKind::Report, params)?),
"extends" => {
let rule_id = attr(child, "rule").ok_or_else(|| XsltError::InvalidStylesheet(
"sch:extends requires a rule= attribute".into(),
))?;
let abstract_rule = abstracts.rules.get(rule_id).ok_or_else(||
XsltError::UnresolvedReference(format!(
"<extends rule='{rule_id}'> references an abstract rule that wasn't declared"
))
)?;
process_rule_children(abstract_rule, loader, base, abstracts, params, lets, asserts)?;
}
"include" => {
let (doc, target_lookup, sub_base) = load_include(child, loader, base)?;
let target = locate_include_target(doc.root(), target_lookup.as_deref())?;
match (is_schematron_element(target), target.local_name()) {
(true, "rule") => process_rule_children(target, loader, sub_base.as_deref(), abstracts, params, lets, asserts)?,
(true, "assert") => asserts.push(compile_assertion(target, AssertKind::Assert, params)?),
(true, "report") => asserts.push(compile_assertion(target, AssertKind::Report, params)?),
(true, "let") => lets.push(compile_let(target, params)?),
_ => return Err(XsltError::InvalidStylesheet(format!(
"sch:include inside <rule> must point to <rule>/<assert>/<report>/<let>; got <{}>",
target.name(),
))),
}
}
_ => {}
}
}
Ok(())
}
fn load_include(
node: &Node,
loader: &dyn crate::loader::Loader,
base: Option<&str>,
) -> Result<(Document, Option<String>, Option<String>)> {
let href = attr(node, "href").ok_or_else(|| XsltError::InvalidStylesheet(
"sch:include requires an href= attribute".into(),
))?;
let (path, fragment) = match href.find('#') {
Some(i) => (&href[..i], Some(href[i+1..].to_string())),
None => (href, None),
};
let text = loader.load(path, base)?;
let opts = sup_xml_core::ParseOptions {
namespace_aware: true, ..Default::default()
};
let doc = sup_xml_core::parse_str(&text, &opts).map_err(XsltError::from)?;
let resolved_base = loader.resolve(path, base).ok();
Ok((doc, fragment, resolved_base))
}
fn locate_include_target<'a>(
root: &'a Node<'a>,
fragment: Option<&str>,
) -> Result<&'a Node<'a>> {
let Some(id) = fragment else { return Ok(root); };
find_by_id(root, id).ok_or_else(|| XsltError::InvalidStylesheet(format!(
"sch:include fragment '#{id}' not found in loaded document"
)))
}
fn find_by_id<'a>(node: &'a Node<'a>, id: &str) -> Option<&'a Node<'a>> {
if node.is_element() {
if attr(node, "id") == Some(id) {
return Some(node);
}
for a in node.attributes() {
if a.local_name() == "id"
&& a.namespace.get().and_then(|n| n.prefix()) == Some("xml") && a.value() == id {
return Some(node);
}
}
for c in node.children() {
if let Some(hit) = find_by_id(c, id) { return Some(hit); }
}
}
None
}
fn compile_let(node: &Node, params: Params) -> Result<Let> {
let name = attr(node, "name").ok_or_else(|| XsltError::InvalidStylesheet(
"sch:let requires a name= attribute".into(),
))?.to_string();
let value = attr(node, "value").ok_or_else(|| XsltError::InvalidStylesheet(
"sch:let requires a value= attribute".into(),
))?;
Ok(Let { name, value: parse_xpath_with_params(value, params)? })
}
fn compile_assertion(node: &Node, kind: AssertKind, params: Params) -> Result<Assertion> {
let test = attr(node, "test").ok_or_else(|| XsltError::InvalidStylesheet(
format!("sch:{} requires a test= attribute",
if matches!(kind, AssertKind::Assert) { "assert" } else { "report" }),
))?;
let test = parse_xpath_with_params(test, params)?;
Ok(Assertion {
kind,
test,
message: compile_message(node, params)?,
id: attr(node, "id").map(str::to_string),
role: attr(node, "role").map(str::to_string),
})
}
fn compile_message(node: &Node, params: Params) -> Result<Vec<MessagePart>> {
let mut parts = Vec::new();
for child in node.children() {
match child.kind {
NodeKind::Text | NodeKind::CData => {
parts.push(MessagePart::Text(child.content().to_string()));
}
NodeKind::Element if is_schematron_element(child) => {
match child.local_name() {
"value-of" => {
let sel = attr(child, "select").ok_or_else(||
XsltError::InvalidStylesheet(
"sch:value-of requires select=".into()))?;
parts.push(MessagePart::ValueOf(
parse_xpath_with_params(sel, params)?));
}
"name" => {
let path = attr(child, "path").unwrap_or(".");
parts.push(MessagePart::Name(
parse_xpath_with_params(path, params)?));
}
_ => {
parts.push(MessagePart::Text(child_text(child)));
}
}
}
_ => {}
}
}
Ok(parts)
}
fn child_text(node: &Node) -> String {
let mut s = String::new();
for c in node.children() {
if matches!(c.kind, NodeKind::Text | NodeKind::CData) {
s.push_str(c.content());
}
}
s
}
fn attr<'a>(node: &'a Node, name: &str) -> Option<&'a str> {
for a in node.attributes() {
if a.name() == name && !a.name().contains(':') {
return Some(a.value());
}
}
None
}
#[derive(Debug, Clone)]
pub struct Finding {
pub kind: FindingKind,
pub message: String,
pub pattern_id: Option<String>,
pub assertion_id: Option<String>,
pub role: Option<String>,
pub location_id: String,
pub context_name: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FindingKind {
FailedAssert,
SuccessfulReport,
}
#[derive(Debug, Clone, Default)]
pub struct ValidationReport {
pub findings: Vec<Finding>,
}
impl ValidationReport {
pub fn valid(&self) -> bool {
!self.findings.iter().any(|f| matches!(f.kind, FindingKind::FailedAssert))
}
}
struct SchBindings<'a> {
namespaces: &'a HashMap<String, String>,
vars: &'a HashMap<String, Value>,
}
fn sch_static_ctx(bindings: &SchBindings<'_>) -> StaticContext {
StaticContext {
xpath_2_0: bindings.xpath_version_2_or_later(),
xpath_3_0: false,
libxml2_compatible: false, current_node: None,
}
}
impl XPathBindings for SchBindings<'_> {
fn resolve_prefix(&self, prefix: &str) -> Option<String> {
self.namespaces.get(prefix).cloned()
}
fn variable(&self, name: &str) -> Option<Value> {
self.vars.get(name).cloned()
}
}
impl Schematron {
pub fn validate_str(&self, instance_text: &str) -> Result<ValidationReport> {
self.validate_str_with_phase(instance_text, "#ALL")
}
pub fn validate_str_with_phase(
&self, instance_text: &str, phase: &str,
) -> Result<ValidationReport> {
let opts = sup_xml_core::ParseOptions {
namespace_aware: true, ..Default::default()
};
let doc = sup_xml_core::parse_str(instance_text, &opts).map_err(XsltError::from)?;
self.validate_with_phase(&doc, phase)
}
pub fn validate(&self, instance_doc: &Document) -> Result<ValidationReport> {
self.validate_with_phase(instance_doc, "#ALL")
}
pub fn validate_with_phase(
&self, instance_doc: &Document, phase: &str,
) -> Result<ValidationReport> {
let active: Option<&[String]> = match phase {
"#ALL" => None,
"#DEFAULT" => match self.default_phase.as_deref() {
None | Some("#ALL") => None,
Some(name) => Some(self.phases.get(name)
.ok_or_else(|| XsltError::InvalidStylesheet(format!(
"schema defaultPhase='{name}' but no <sch:phase id='{name}'> declared"
)))?
.as_slice()),
},
other => Some(self.phases.get(other)
.ok_or_else(|| XsltError::InvalidStylesheet(format!(
"unknown phase '{other}': no matching <sch:phase id='{other}'> in schema"
)))?
.as_slice()),
};
self.validate_inner(instance_doc, active)
}
fn validate_inner(
&self, instance_doc: &Document, active: Option<&[String]>,
) -> Result<ValidationReport> {
let idx = DocIndex::build(instance_doc);
let mut report = ValidationReport::default();
let mut schema_vars: HashMap<String, Value> = HashMap::new();
for l in &self.lets {
let v = {
let bindings = SchBindings {
namespaces: &self.namespaces,
vars: &schema_vars,
};
let sc = sch_static_ctx(&bindings);
let ctx = EvalCtx { context_node: 0, pos: 1, size: 1, bindings: &bindings, static_ctx: &sc };
eval_expr(&l.value, &ctx, &idx).map_err(XsltError::from)?
};
schema_vars.insert(l.name.clone(), v);
}
for pattern in &self.patterns {
if let Some(list) = active {
let Some(pid) = pattern.id.as_deref() else { continue; };
if !list.iter().any(|n| n == pid) { continue; }
}
for node_id in 0..idx.nodes.len() {
let kind = idx.kind(node_id);
if !matches!(kind,
XPathNodeKind::Element | XPathNodeKind::Document
| XPathNodeKind::Attribute)
{
continue;
}
for rule in &pattern.rules {
if !rule_matches(&rule.context, node_id, &idx,
&self.namespaces, &schema_vars)?
{
continue;
}
let mut local_vars = schema_vars.clone();
for l in &rule.lets {
let v = {
let bindings = SchBindings {
namespaces: &self.namespaces, vars: &local_vars,
};
let sc = sch_static_ctx(&bindings);
let ctx = EvalCtx { context_node: node_id, pos: 1, size: 1, bindings: &bindings, static_ctx: &sc };
eval_expr(&l.value, &ctx, &idx).map_err(XsltError::from)?
};
local_vars.insert(l.name.clone(), v);
}
for assertion in &rule.asserts {
evaluate_assertion(
assertion, node_id, &idx,
&self.namespaces, &local_vars,
pattern, &mut report,
)?;
}
break;
}
}
}
Ok(report)
}
}
fn rule_matches(
context: &Expr, node: NodeId, idx: &DocIndex<'_>,
namespaces: &HashMap<String, String>,
vars: &HashMap<String, Value>,
) -> Result<bool> {
let bindings = SchBindings { namespaces, vars };
let sc = sch_static_ctx(&bindings);
let mut cur = Some(node);
while let Some(ctx_node) = cur {
let ctx = EvalCtx { context_node: ctx_node, pos: 1, size: 1, bindings: &bindings, static_ctx: &sc };
let v = eval_expr(context, &ctx, idx).map_err(XsltError::from)?;
if let Value::NodeSet(ns) = v {
if ns.contains(&node) {
return Ok(true);
}
}
cur = idx.parent(ctx_node);
}
Ok(false)
}
fn evaluate_assertion(
a: &Assertion,
node: NodeId,
idx: &DocIndex<'_>,
namespaces: &HashMap<String, String>,
vars: &HashMap<String, Value>,
pattern: &Pattern,
report: &mut ValidationReport,
) -> Result<()> {
let bindings = SchBindings { namespaces, vars };
let sc = sch_static_ctx(&bindings);
let ctx = EvalCtx { context_node: node, pos: 1, size: 1, bindings: &bindings, static_ctx: &sc };
let test_v = eval_expr(&a.test, &ctx, idx).map_err(XsltError::from)?;
let truth = value_to_bool(&test_v);
let fired = match a.kind {
AssertKind::Assert => !truth, AssertKind::Report => truth, };
if !fired { return Ok(()); }
let message = render_message(&a.message, node, idx, namespaces, vars)?;
report.findings.push(Finding {
kind: match a.kind {
AssertKind::Assert => FindingKind::FailedAssert,
AssertKind::Report => FindingKind::SuccessfulReport,
},
message,
pattern_id: pattern.id.clone(),
assertion_id: a.id.clone(),
role: a.role.clone(),
location_id: format!("id{:x}", node),
context_name: idx.local_name(node).to_string(),
});
Ok(())
}
fn render_message(
parts: &[MessagePart],
node: NodeId,
idx: &DocIndex<'_>,
namespaces: &HashMap<String, String>,
vars: &HashMap<String, Value>,
) -> Result<String> {
let bindings = SchBindings { namespaces, vars };
let sc = sch_static_ctx(&bindings);
let ctx = EvalCtx { context_node: node, pos: 1, size: 1, bindings: &bindings, static_ctx: &sc };
let mut s = String::new();
for p in parts {
match p {
MessagePart::Text(t) => s.push_str(t),
MessagePart::ValueOf(e) => {
let v = eval_expr(e, &ctx, idx).map_err(XsltError::from)?;
s.push_str(&value_to_string(&v, idx));
}
MessagePart::Name(e) => {
let v = eval_expr(e, &ctx, idx).map_err(XsltError::from)?;
let target_node = match v {
Value::NodeSet(ns) if !ns.is_empty() => ns[0],
_ => node,
};
s.push_str(idx.node_name(target_node));
}
}
}
let normalised: Vec<&str> = s.split_whitespace().collect();
Ok(normalised.join(" "))
}
fn value_to_bool(v: &Value) -> bool {
match v {
Value::Boolean(b) => *b,
Value::Number(n) => n.as_f64() != 0.0 && !n.as_f64().is_nan(),
Value::String(s) => !s.is_empty(),
Value::NodeSet(n) => !n.is_empty(),
Value::ForeignNodeSet(n) => !n.is_empty(),
Value::Typed(t) => {
if let Some(b) = t.boolean { return b; }
if let Some(n) = t.numeric { return n != 0.0 && !n.is_nan(); }
!t.lexical.is_empty()
}
Value::Sequence(items) => match items.first() {
None => false,
Some(v) => value_to_bool(v),
}
Value::IntRange { lo, hi } if lo == hi => *lo != 0,
Value::IntRange { .. } => true,
Value::Map(_) | Value::Array(_) | Value::Function(_) => true,
}
}
fn value_to_string<I: DocIndexLike>(v: &Value, idx: &I) -> String {
use sup_xml_core::xpath::eval::value_to_string;
value_to_string(v, idx)
}
#[cfg(test)]
mod tests {
use super::*;
fn compile(text: &str) -> Schematron {
Schematron::compile_str(text).expect("schematron compile")
}
fn validate(sch: &Schematron, xml: &str) -> ValidationReport {
sch.validate_str(xml).expect("validate")
}
#[test]
fn compile_minimal_schema() {
let s = compile(r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
<pattern>
<rule context="r">
<assert test="@x">missing x</assert>
</rule>
</pattern>
</schema>"#);
assert_eq!(s.patterns.len(), 1);
assert_eq!(s.patterns[0].rules.len(), 1);
assert_eq!(s.patterns[0].rules[0].asserts.len(), 1);
}
#[test]
fn compile_rejects_non_schematron_root() {
let err = Schematron::compile_str("<foo/>").unwrap_err();
assert!(format!("{err}").contains("sch:schema"), "got: {err}");
}
#[test]
fn accepts_old_namespace() {
let s = compile(r#"<schema xmlns="http://www.ascc.net/xml/schematron">
<pattern><rule context="*"><assert test="true()">ok</assert></rule></pattern>
</schema>"#);
assert_eq!(s.patterns.len(), 1);
}
#[test]
fn assert_fails_for_missing_attribute() {
let s = compile(r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
<pattern>
<rule context="r">
<assert test="@x">missing x</assert>
</rule>
</pattern>
</schema>"#);
let r = validate(&s, "<r/>");
assert!(!r.valid());
assert_eq!(r.findings.len(), 1);
assert_eq!(r.findings[0].kind, FindingKind::FailedAssert);
assert_eq!(r.findings[0].message, "missing x");
}
#[test]
fn assert_passes_when_test_true() {
let s = compile(r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
<pattern>
<rule context="r">
<assert test="@x">missing x</assert>
</rule>
</pattern>
</schema>"#);
let r = validate(&s, r#"<r x="42"/>"#);
assert!(r.valid());
assert!(r.findings.is_empty());
}
#[test]
fn report_fires_when_test_true() {
let s = compile(r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
<pattern>
<rule context="r">
<report test="@deprecated">element is deprecated</report>
</rule>
</pattern>
</schema>"#);
let r = validate(&s, r#"<r deprecated="yes"/>"#);
assert!(r.valid());
assert_eq!(r.findings.len(), 1);
assert_eq!(r.findings[0].kind, FindingKind::SuccessfulReport);
}
#[test]
fn first_matching_rule_wins_per_pattern() {
let s = compile(r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
<pattern>
<rule context="*[@kind='a']">
<assert test="false()">first rule</assert>
</rule>
<rule context="*">
<assert test="false()">second rule</assert>
</rule>
</pattern>
</schema>"#);
let r = validate(&s, r#"<r kind="a"/>"#);
let messages: Vec<_> = r.findings.iter().map(|f| f.message.clone()).collect();
assert!(messages.iter().any(|m| m == "first rule"));
assert!(!messages.iter().any(|m| m == "second rule"));
}
#[test]
fn namespaces_resolve_via_sch_ns_declaration() {
let s = compile(r##"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
<ns prefix="dc" uri="http://purl.org/dc/elements/1.1/"/>
<pattern>
<rule context="dc:title">
<assert test="normalize-space(.) != ''">title is empty</assert>
</rule>
</pattern>
</schema>"##);
let r = validate(&s, r#"<r xmlns:dc="http://purl.org/dc/elements/1.1/">
<dc:title></dc:title>
</r>"#);
assert!(!r.valid(), "empty title should fail assertion");
}
#[test]
fn message_inlines_value_of() {
let s = compile(r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
<pattern>
<rule context="r">
<assert test="@x">value: <value-of select="@y"/></assert>
</rule>
</pattern>
</schema>"#);
let r = validate(&s, r#"<r y="hello"/>"#);
assert_eq!(r.findings[0].message, "value: hello");
}
#[test]
fn message_inlines_name() {
let s = compile(r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
<pattern>
<rule context="*">
<report test="@bad">element <name/> has bad attr</report>
</rule>
</pattern>
</schema>"#);
let r = validate(&s, r#"<foo bad="yes"/>"#);
assert!(r.findings.iter().any(|f| f.message.contains("foo")));
}
#[test]
fn pattern_id_propagates_to_findings() {
let s = compile(r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
<pattern id="check-001">
<rule context="r">
<assert test="@x">missing x</assert>
</rule>
</pattern>
</schema>"#);
let r = validate(&s, "<r/>");
assert_eq!(r.findings[0].pattern_id.as_deref(), Some("check-001"));
}
#[test]
fn rule_let_provides_local_binding() {
let s = compile(r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
<pattern>
<rule context="r">
<let name="count" value="count(item)"/>
<assert test="$count = 3">expected 3 items</assert>
</rule>
</pattern>
</schema>"#);
let r = validate(&s, "<r><item/><item/></r>");
assert!(!r.valid());
let r = validate(&s, "<r><item/><item/><item/></r>");
assert!(r.valid());
}
#[test]
fn rule_without_context_errors() {
let r = Schematron::compile_str(r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
<pattern><rule><assert test="true()">ok</assert></rule></pattern>
</schema>"#);
assert!(r.is_err());
if let Err(XsltError::InvalidStylesheet(msg)) = r {
assert!(msg.contains("context"), "got {msg}");
}
}
#[test]
fn let_without_name_errors() {
let r = Schematron::compile_str(r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
<pattern><rule context="r">
<let value="@x"/>
<assert test="true()">ok</assert>
</rule></pattern>
</schema>"#);
assert!(r.is_err());
if let Err(XsltError::InvalidStylesheet(msg)) = r {
assert!(msg.contains("name"), "got {msg}");
}
}
#[test]
fn let_without_value_errors() {
let r = Schematron::compile_str(r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
<pattern><rule context="r">
<let name="count"/>
<assert test="true()">ok</assert>
</rule></pattern>
</schema>"#);
assert!(r.is_err());
if let Err(XsltError::InvalidStylesheet(msg)) = r {
assert!(msg.contains("value"), "got {msg}");
}
}
#[test]
fn assert_without_test_errors() {
let r = Schematron::compile_str(r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
<pattern><rule context="r"><assert>missing</assert></rule></pattern>
</schema>"#);
assert!(r.is_err());
if let Err(XsltError::InvalidStylesheet(msg)) = r {
assert!(msg.contains("test"), "got {msg}");
assert!(msg.contains("assert"), "got {msg}");
}
}
#[test]
fn report_without_test_errors() {
let r = Schematron::compile_str(r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
<pattern><rule context="r"><report>missing</report></rule></pattern>
</schema>"#);
assert!(r.is_err());
if let Err(XsltError::InvalidStylesheet(msg)) = r {
assert!(msg.contains("report"), "got {msg}");
}
}
#[test]
fn value_of_without_select_errors() {
let r = Schematron::compile_str(r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
<pattern><rule context="r">
<assert test="true()">val=<value-of/></assert>
</rule></pattern>
</schema>"#);
assert!(r.is_err());
}
#[test]
fn message_inlines_emph_via_text_content() {
let s = compile(r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
<pattern>
<rule context="r">
<assert test="@x">missing <emph>x</emph> attribute</assert>
</rule>
</pattern>
</schema>"#);
let r = validate(&s, "<r/>");
assert!(r.findings[0].message.contains("missing"));
assert!(r.findings[0].message.contains("x"));
assert!(r.findings[0].message.contains("attribute"));
}
#[test]
fn name_with_path_attribute() {
let s = compile(r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
<pattern>
<rule context="r">
<assert test="false()">node was <name path="."/></assert>
</rule>
</pattern>
</schema>"#);
let r = validate(&s, "<r/>");
assert!(r.findings[0].message.contains("node was"));
assert!(r.findings[0].message.contains("r"));
}
#[test]
fn rule_ignores_unknown_children() {
let s = compile(r#"<schema xmlns="http://purl.oclc.org/dsdl/schematron">
<pattern>
<rule context="r">
<extension/>
<assert test="@x">missing x</assert>
</rule>
</pattern>
</schema>"#);
let r = validate(&s, "<r/>");
assert!(!r.valid());
assert_eq!(r.findings[0].message, "missing x");
}
}