From c43fe0c880f5de36916fb3d6c3a2012b3df50647 Mon Sep 17 00:00:00 2001 From: Daniel Cazzulino Date: Fri, 27 Mar 2026 17:20:05 -0300 Subject: [PATCH] Implement ISO Schematron 2025 features Also add reference implementation compatibility tests --- src/Schematron.Tests/CompatibilityTests.cs | 113 + .../Content/abstract-pattern-schema.sch | 23 + .../compat/abstract-pattern-invalid.xml | 2 + .../Content/compat/abstract-pattern-valid.xml | 1 + .../Content/compat/abstract-rule-invalid.xml | 5 + .../Content/compat/abstract-rule-valid.xml | 4 + .../Content/compat/abstract-rule.sch | 19 + .../Content/compat/basic-assert-invalid.xml | 1 + .../Content/compat/basic-assert-valid.xml | 1 + .../Content/compat/basic-assert.sch | 10 + .../Content/compat/basic-report-trigger.xml | 1 + .../Content/compat/basic-report-valid.xml | 1 + .../Content/compat/basic-report.sch | 9 + .../Content/compat/first-match-invalid.xml | 4 + .../Content/compat/first-match-valid.xml | 5 + .../Content/compat/first-match.sch | 15 + .../Content/compat/let-scopes-invalid.xml | 1 + .../Content/compat/let-scopes-valid.xml | 1 + .../Content/compat/let-scopes.sch | 14 + .../Content/compat/multi-pattern-invalid.xml | 2 + .../Content/compat/multi-pattern-valid.xml | 1 + .../Content/compat/multi-pattern.sch | 15 + .../Content/compat/namespaces-invalid.xml | 2 + .../Content/compat/namespaces-valid.xml | 3 + .../Content/compat/namespaces.sch | 15 + .../Content/compat/phases-basic-invalid.xml | 2 + .../Content/compat/phases-basic-valid.xml | 2 + .../Content/compat/phases.sch | 22 + .../Content/compat/value-of-invalid.xml | 1 + .../Content/compat/value-of-valid.xml | 1 + .../Content/compat/value-of.sch | 10 + .../Content/diagnostics-schema.sch | 16 + src/Schematron.Tests/Content/group-schema.sch | 12 + src/Schematron.Tests/Content/iso-schema.sch | 15 + .../Content/legacy-schema.sch | 10 + src/Schematron.Tests/Content/let-schema.sch | 19 + .../Content/library-schema.sch | 9 + .../Content/phase-when-schema.sch | 15 + .../Content/rule-flag-schema.sch | 9 + .../Content/schema-params.sch | 6 + .../Content/severity-schema.sch | 9 + .../Content/visit-each-schema.sch | 9 + .../Content/xslt/iso_abstract_expand.xsl | 313 +++ .../Content/xslt/iso_dsdl_include.xsl | 1519 ++++++++++++++ .../iso_schematron_skeleton_for_xslt1.xsl | 1851 +++++++++++++++++ .../Content/xslt/iso_svrl_for_xslt1.xsl | 614 ++++++ .../ReferenceSchematronRunner.cs | 118 ++ src/Schematron.Tests/Schematron.Tests.csproj | 6 + src/Schematron.Tests/ValidatorTests.cs | 283 ++- src/Schematron/Config.cs | 2 + src/Schematron/Diagnostic.cs | 18 + src/Schematron/DiagnosticCollection.cs | 10 + src/Schematron/EvaluableExpression.cs | 16 +- src/Schematron/Formatters/FormatterBase.cs | 27 +- src/Schematron/Formatters/LogFormatter.cs | 2 + src/Schematron/Formatters/SimpleFormatter.cs | 3 + src/Schematron/Formatters/XmlFormatter.cs | 13 +- src/Schematron/Group.cs | 12 + src/Schematron/Let.cs | 27 + src/Schematron/LetCollection.cs | 12 + src/Schematron/Param.cs | 19 + src/Schematron/ParamCollection.cs | 10 + src/Schematron/Pattern.cs | 4 + src/Schematron/Phase.cs | 10 +- src/Schematron/Rule.cs | 14 + src/Schematron/Schema.cs | 46 +- src/Schematron/SchemaLoader.cs | 474 ++++- src/Schematron/SchematronXsltContext.cs | 132 ++ src/Schematron/SyncEvaluationContext.cs | 118 +- src/Schematron/TagExpressions.cs | 3 +- src/Schematron/Test.cs | 32 +- src/Schematron/Validator.cs | 4 +- 72 files changed, 6065 insertions(+), 82 deletions(-) create mode 100644 src/Schematron.Tests/CompatibilityTests.cs create mode 100644 src/Schematron.Tests/Content/abstract-pattern-schema.sch create mode 100644 src/Schematron.Tests/Content/compat/abstract-pattern-invalid.xml create mode 100644 src/Schematron.Tests/Content/compat/abstract-pattern-valid.xml create mode 100644 src/Schematron.Tests/Content/compat/abstract-rule-invalid.xml create mode 100644 src/Schematron.Tests/Content/compat/abstract-rule-valid.xml create mode 100644 src/Schematron.Tests/Content/compat/abstract-rule.sch create mode 100644 src/Schematron.Tests/Content/compat/basic-assert-invalid.xml create mode 100644 src/Schematron.Tests/Content/compat/basic-assert-valid.xml create mode 100644 src/Schematron.Tests/Content/compat/basic-assert.sch create mode 100644 src/Schematron.Tests/Content/compat/basic-report-trigger.xml create mode 100644 src/Schematron.Tests/Content/compat/basic-report-valid.xml create mode 100644 src/Schematron.Tests/Content/compat/basic-report.sch create mode 100644 src/Schematron.Tests/Content/compat/first-match-invalid.xml create mode 100644 src/Schematron.Tests/Content/compat/first-match-valid.xml create mode 100644 src/Schematron.Tests/Content/compat/first-match.sch create mode 100644 src/Schematron.Tests/Content/compat/let-scopes-invalid.xml create mode 100644 src/Schematron.Tests/Content/compat/let-scopes-valid.xml create mode 100644 src/Schematron.Tests/Content/compat/let-scopes.sch create mode 100644 src/Schematron.Tests/Content/compat/multi-pattern-invalid.xml create mode 100644 src/Schematron.Tests/Content/compat/multi-pattern-valid.xml create mode 100644 src/Schematron.Tests/Content/compat/multi-pattern.sch create mode 100644 src/Schematron.Tests/Content/compat/namespaces-invalid.xml create mode 100644 src/Schematron.Tests/Content/compat/namespaces-valid.xml create mode 100644 src/Schematron.Tests/Content/compat/namespaces.sch create mode 100644 src/Schematron.Tests/Content/compat/phases-basic-invalid.xml create mode 100644 src/Schematron.Tests/Content/compat/phases-basic-valid.xml create mode 100644 src/Schematron.Tests/Content/compat/phases.sch create mode 100644 src/Schematron.Tests/Content/compat/value-of-invalid.xml create mode 100644 src/Schematron.Tests/Content/compat/value-of-valid.xml create mode 100644 src/Schematron.Tests/Content/compat/value-of.sch create mode 100644 src/Schematron.Tests/Content/diagnostics-schema.sch create mode 100644 src/Schematron.Tests/Content/group-schema.sch create mode 100644 src/Schematron.Tests/Content/iso-schema.sch create mode 100644 src/Schematron.Tests/Content/legacy-schema.sch create mode 100644 src/Schematron.Tests/Content/let-schema.sch create mode 100644 src/Schematron.Tests/Content/library-schema.sch create mode 100644 src/Schematron.Tests/Content/phase-when-schema.sch create mode 100644 src/Schematron.Tests/Content/rule-flag-schema.sch create mode 100644 src/Schematron.Tests/Content/schema-params.sch create mode 100644 src/Schematron.Tests/Content/severity-schema.sch create mode 100644 src/Schematron.Tests/Content/visit-each-schema.sch create mode 100644 src/Schematron.Tests/Content/xslt/iso_abstract_expand.xsl create mode 100644 src/Schematron.Tests/Content/xslt/iso_dsdl_include.xsl create mode 100644 src/Schematron.Tests/Content/xslt/iso_schematron_skeleton_for_xslt1.xsl create mode 100644 src/Schematron.Tests/Content/xslt/iso_svrl_for_xslt1.xsl create mode 100644 src/Schematron.Tests/ReferenceSchematronRunner.cs create mode 100644 src/Schematron/Diagnostic.cs create mode 100644 src/Schematron/DiagnosticCollection.cs create mode 100644 src/Schematron/Group.cs create mode 100644 src/Schematron/Let.cs create mode 100644 src/Schematron/LetCollection.cs create mode 100644 src/Schematron/Param.cs create mode 100644 src/Schematron/ParamCollection.cs create mode 100644 src/Schematron/SchematronXsltContext.cs diff --git a/src/Schematron.Tests/CompatibilityTests.cs b/src/Schematron.Tests/CompatibilityTests.cs new file mode 100644 index 0000000..913d017 --- /dev/null +++ b/src/Schematron.Tests/CompatibilityTests.cs @@ -0,0 +1,113 @@ +using System; +using System.Collections.Generic; +using System.IO; +using Xunit; + +namespace Schematron.Tests; + +/// +/// Compatibility tests: verifies that Schematron.NET produces the same pass/fail result +/// as the ISO XSLT 1.0 reference implementation for a set of (schema, xml) pairs. +/// +/// Comparison is limited to whether validation passes or fails (HasErrors). Message text +/// is intentionally not compared because the two implementations format messages differently. +/// +/// ISO Schematron 2025 features that have no XSLT 1.0 counterpart (group, @visit-each, +/// @when on phase) are NOT covered here — they are tested in ValidatorTests.cs. +/// +public class CompatibilityTests +{ + /// + /// Test cases: (label, schemaPath, xmlFile, expectErrors). + /// xmlFile is relative to the test output directory. + /// + public static IEnumerable Cases() + { + // ── basic-assert ────────────────────────────────────────────────────── + yield return Case("BasicAssert/valid", "Content/compat/basic-assert.sch", "Content/compat/basic-assert-valid.xml", false); + yield return Case("BasicAssert/invalid", "Content/compat/basic-assert.sch", "Content/compat/basic-assert-invalid.xml", true); + + // ── basic-report ───────────────────────────────────────────────────── + yield return Case("BasicReport/no-trigger", "Content/compat/basic-report.sch", "Content/compat/basic-report-valid.xml", false); + yield return Case("BasicReport/trigger", "Content/compat/basic-report.sch", "Content/compat/basic-report-trigger.xml", true); + + // ── first-match (rule node-first-match within a pattern) ────────────── + yield return Case("FirstMatch/valid", "Content/compat/first-match.sch", "Content/compat/first-match-valid.xml", false); + yield return Case("FirstMatch/invalid", "Content/compat/first-match.sch", "Content/compat/first-match-invalid.xml", true); + + // ── multi-pattern ──────────────────────────────────────────────────── + yield return Case("MultiPattern/valid", "Content/compat/multi-pattern.sch", "Content/compat/multi-pattern-valid.xml", false); + yield return Case("MultiPattern/invalid", "Content/compat/multi-pattern.sch", "Content/compat/multi-pattern-invalid.xml", true); + + // ── let variables ──────────────────────────────────────────────────── + yield return Case("LetScopes/valid", "Content/compat/let-scopes.sch", "Content/compat/let-scopes-valid.xml", false); + yield return Case("LetScopes/invalid", "Content/compat/let-scopes.sch", "Content/compat/let-scopes-invalid.xml", true); + + // ── abstract rules via ───────────────────────────────────── + yield return Case("AbstractRule/valid", "Content/compat/abstract-rule.sch", "Content/compat/abstract-rule-valid.xml", false); + yield return Case("AbstractRule/invalid", "Content/compat/abstract-rule.sch", "Content/compat/abstract-rule-invalid.xml", true); + + // ── abstract patterns (is-a) — reuse existing fixture ──────────────── + yield return Case("AbstractPattern/valid", "Content/abstract-pattern-schema.sch", "Content/compat/abstract-pattern-valid.xml", false); + yield return Case("AbstractPattern/invalid", "Content/abstract-pattern-schema.sch", "Content/compat/abstract-pattern-invalid.xml", true); + + // ── phases ─────────────────────────────────────────────────────────── + yield return Case("Phases/basic-valid", "Content/compat/phases.sch", "Content/compat/phases-basic-valid.xml", false); + yield return Case("Phases/basic-invalid", "Content/compat/phases.sch", "Content/compat/phases-basic-invalid.xml", true); + + // ── namespace prefixes ─────────────────────────────────────────────── + yield return Case("Namespaces/valid", "Content/compat/namespaces.sch", "Content/compat/namespaces-valid.xml", false); + yield return Case("Namespaces/invalid", "Content/compat/namespaces.sch", "Content/compat/namespaces-invalid.xml", true); + + // ── value-of in message ────────────────────────────────────────────── + yield return Case("ValueOf/valid", "Content/compat/value-of.sch", "Content/compat/value-of-valid.xml", false); + yield return Case("ValueOf/invalid", "Content/compat/value-of.sch", "Content/compat/value-of-invalid.xml", true); + } + + static object[] Case(string label, string schemaPath, string xmlPath, bool expectErrors) + => [label, schemaPath, xmlPath, expectErrors]; + + [Theory] + [MemberData(nameof(Cases))] + public void BothImplementationsAgreeOnPassFail(string label, string schemaPath, string xmlPath, bool expectErrors) + { + string schemaFullPath = ResolvePath(schemaPath); + string xmlContent = File.ReadAllText(ResolvePath(xmlPath)); + + // ── Schematron.NET ─────────────────────────────────────────────────── + bool netErrors = false; + var validator = new Validator(); + validator.AddSchema(System.Xml.XmlReader.Create(schemaFullPath)); + try + { + validator.Validate(new StringReader(xmlContent)); + } + catch (ValidationException) + { + netErrors = true; + } + + // ── Reference XSLT implementation ──────────────────────────────────── + SvrlResult svrl = ReferenceSchematronRunner.Validate(schemaFullPath, xmlContent); + + // ── Assertions ─────────────────────────────────────────────────────── + Xunit.Assert.Equal(expectErrors, netErrors); + Xunit.Assert.Equal(expectErrors, svrl.HasErrors); + Xunit.Assert.Equal(netErrors, svrl.HasErrors); + _ = label; // used only for test display name + } + + // ------------------------------------------------------------------------- + // Abstract-pattern fixtures are inlined here (no separate file needed for + // the reused schema) but we do need the XML files. + // ------------------------------------------------------------------------- + + static string ResolvePath(string relative) + { + // When tests run, the working directory is the test output directory. + if (File.Exists(relative)) return relative; + // Fallback: relative to Content/ for schemas shared with ValidatorTests. + string alt = Path.Combine(".", relative); + return alt; + } +} diff --git a/src/Schematron.Tests/Content/abstract-pattern-schema.sch b/src/Schematron.Tests/Content/abstract-pattern-schema.sch new file mode 100644 index 0000000..c595732 --- /dev/null +++ b/src/Schematron.Tests/Content/abstract-pattern-schema.sch @@ -0,0 +1,23 @@ + + + Abstract pattern test schema + + + + + A must have at least one . + + + + + + + + + + + + + + + diff --git a/src/Schematron.Tests/Content/compat/abstract-pattern-invalid.xml b/src/Schematron.Tests/Content/compat/abstract-pattern-invalid.xml new file mode 100644 index 0000000..432bfbb --- /dev/null +++ b/src/Schematron.Tests/Content/compat/abstract-pattern-invalid.xml @@ -0,0 +1,2 @@ + + diff --git a/src/Schematron.Tests/Content/compat/abstract-pattern-valid.xml b/src/Schematron.Tests/Content/compat/abstract-pattern-valid.xml new file mode 100644 index 0000000..b7eefdf --- /dev/null +++ b/src/Schematron.Tests/Content/compat/abstract-pattern-valid.xml @@ -0,0 +1 @@ + diff --git a/src/Schematron.Tests/Content/compat/abstract-rule-invalid.xml b/src/Schematron.Tests/Content/compat/abstract-rule-invalid.xml new file mode 100644 index 0000000..52e27c0 --- /dev/null +++ b/src/Schematron.Tests/Content/compat/abstract-rule-invalid.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/src/Schematron.Tests/Content/compat/abstract-rule-valid.xml b/src/Schematron.Tests/Content/compat/abstract-rule-valid.xml new file mode 100644 index 0000000..08bf8fc --- /dev/null +++ b/src/Schematron.Tests/Content/compat/abstract-rule-valid.xml @@ -0,0 +1,4 @@ + + + + diff --git a/src/Schematron.Tests/Content/compat/abstract-rule.sch b/src/Schematron.Tests/Content/compat/abstract-rule.sch new file mode 100644 index 0000000..fc8143e --- /dev/null +++ b/src/Schematron.Tests/Content/compat/abstract-rule.sch @@ -0,0 +1,19 @@ + + + + Abstract Rule Extends + + + An entity must have an id. + An entity must have a name. + + + + + A customer must have an email. + + + + + + diff --git a/src/Schematron.Tests/Content/compat/basic-assert-invalid.xml b/src/Schematron.Tests/Content/compat/basic-assert-invalid.xml new file mode 100644 index 0000000..2b71b83 --- /dev/null +++ b/src/Schematron.Tests/Content/compat/basic-assert-invalid.xml @@ -0,0 +1 @@ + diff --git a/src/Schematron.Tests/Content/compat/basic-assert-valid.xml b/src/Schematron.Tests/Content/compat/basic-assert-valid.xml new file mode 100644 index 0000000..0277210 --- /dev/null +++ b/src/Schematron.Tests/Content/compat/basic-assert-valid.xml @@ -0,0 +1 @@ + diff --git a/src/Schematron.Tests/Content/compat/basic-assert.sch b/src/Schematron.Tests/Content/compat/basic-assert.sch new file mode 100644 index 0000000..3e238d2 --- /dev/null +++ b/src/Schematron.Tests/Content/compat/basic-assert.sch @@ -0,0 +1,10 @@ + + + Basic Assert + + + A person must have a name attribute. + Age must be positive. + + + diff --git a/src/Schematron.Tests/Content/compat/basic-report-trigger.xml b/src/Schematron.Tests/Content/compat/basic-report-trigger.xml new file mode 100644 index 0000000..d2803ff --- /dev/null +++ b/src/Schematron.Tests/Content/compat/basic-report-trigger.xml @@ -0,0 +1 @@ + diff --git a/src/Schematron.Tests/Content/compat/basic-report-valid.xml b/src/Schematron.Tests/Content/compat/basic-report-valid.xml new file mode 100644 index 0000000..240b7c4 --- /dev/null +++ b/src/Schematron.Tests/Content/compat/basic-report-valid.xml @@ -0,0 +1 @@ + diff --git a/src/Schematron.Tests/Content/compat/basic-report.sch b/src/Schematron.Tests/Content/compat/basic-report.sch new file mode 100644 index 0000000..01ac433 --- /dev/null +++ b/src/Schematron.Tests/Content/compat/basic-report.sch @@ -0,0 +1,9 @@ + + + Basic Report + + + Order is cancelled. + + + diff --git a/src/Schematron.Tests/Content/compat/first-match-invalid.xml b/src/Schematron.Tests/Content/compat/first-match-invalid.xml new file mode 100644 index 0000000..a4b4cc3 --- /dev/null +++ b/src/Schematron.Tests/Content/compat/first-match-invalid.xml @@ -0,0 +1,4 @@ + + + + diff --git a/src/Schematron.Tests/Content/compat/first-match-valid.xml b/src/Schematron.Tests/Content/compat/first-match-valid.xml new file mode 100644 index 0000000..e6d160c --- /dev/null +++ b/src/Schematron.Tests/Content/compat/first-match-valid.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/src/Schematron.Tests/Content/compat/first-match.sch b/src/Schematron.Tests/Content/compat/first-match.sch new file mode 100644 index 0000000..698cf92 --- /dev/null +++ b/src/Schematron.Tests/Content/compat/first-match.sch @@ -0,0 +1,15 @@ + + + + First Match + + + + Special item price must be over 100. + + + + Item price must be positive. + + + diff --git a/src/Schematron.Tests/Content/compat/let-scopes-invalid.xml b/src/Schematron.Tests/Content/compat/let-scopes-invalid.xml new file mode 100644 index 0000000..4aebdf5 --- /dev/null +++ b/src/Schematron.Tests/Content/compat/let-scopes-invalid.xml @@ -0,0 +1 @@ + diff --git a/src/Schematron.Tests/Content/compat/let-scopes-valid.xml b/src/Schematron.Tests/Content/compat/let-scopes-valid.xml new file mode 100644 index 0000000..bc4aea8 --- /dev/null +++ b/src/Schematron.Tests/Content/compat/let-scopes-valid.xml @@ -0,0 +1 @@ + diff --git a/src/Schematron.Tests/Content/compat/let-scopes.sch b/src/Schematron.Tests/Content/compat/let-scopes.sch new file mode 100644 index 0000000..cb39b26 --- /dev/null +++ b/src/Schematron.Tests/Content/compat/let-scopes.sch @@ -0,0 +1,14 @@ + + + + Let Scopes + + + + + + Score must be at least . + Score must be at most . + + + diff --git a/src/Schematron.Tests/Content/compat/multi-pattern-invalid.xml b/src/Schematron.Tests/Content/compat/multi-pattern-invalid.xml new file mode 100644 index 0000000..4c4d758 --- /dev/null +++ b/src/Schematron.Tests/Content/compat/multi-pattern-invalid.xml @@ -0,0 +1,2 @@ + + diff --git a/src/Schematron.Tests/Content/compat/multi-pattern-valid.xml b/src/Schematron.Tests/Content/compat/multi-pattern-valid.xml new file mode 100644 index 0000000..72ed772 --- /dev/null +++ b/src/Schematron.Tests/Content/compat/multi-pattern-valid.xml @@ -0,0 +1 @@ + diff --git a/src/Schematron.Tests/Content/compat/multi-pattern.sch b/src/Schematron.Tests/Content/compat/multi-pattern.sch new file mode 100644 index 0000000..c93db6f --- /dev/null +++ b/src/Schematron.Tests/Content/compat/multi-pattern.sch @@ -0,0 +1,15 @@ + + + + Multi Pattern + + + A product must have an id. + + + + + A product must have a price. + + + diff --git a/src/Schematron.Tests/Content/compat/namespaces-invalid.xml b/src/Schematron.Tests/Content/compat/namespaces-invalid.xml new file mode 100644 index 0000000..1f94c97 --- /dev/null +++ b/src/Schematron.Tests/Content/compat/namespaces-invalid.xml @@ -0,0 +1,2 @@ + + diff --git a/src/Schematron.Tests/Content/compat/namespaces-valid.xml b/src/Schematron.Tests/Content/compat/namespaces-valid.xml new file mode 100644 index 0000000..c361814 --- /dev/null +++ b/src/Schematron.Tests/Content/compat/namespaces-valid.xml @@ -0,0 +1,3 @@ + + + diff --git a/src/Schematron.Tests/Content/compat/namespaces.sch b/src/Schematron.Tests/Content/compat/namespaces.sch new file mode 100644 index 0000000..ba8c6b0 --- /dev/null +++ b/src/Schematron.Tests/Content/compat/namespaces.sch @@ -0,0 +1,15 @@ + + + + Namespace Prefixes + + + + An order must have an id. + An order must have at least one item. + + + Item quantity must be positive. + + + diff --git a/src/Schematron.Tests/Content/compat/phases-basic-invalid.xml b/src/Schematron.Tests/Content/compat/phases-basic-invalid.xml new file mode 100644 index 0000000..4bf11a9 --- /dev/null +++ b/src/Schematron.Tests/Content/compat/phases-basic-invalid.xml @@ -0,0 +1,2 @@ + + diff --git a/src/Schematron.Tests/Content/compat/phases-basic-valid.xml b/src/Schematron.Tests/Content/compat/phases-basic-valid.xml new file mode 100644 index 0000000..ce64a69 --- /dev/null +++ b/src/Schematron.Tests/Content/compat/phases-basic-valid.xml @@ -0,0 +1,2 @@ + + diff --git a/src/Schematron.Tests/Content/compat/phases.sch b/src/Schematron.Tests/Content/compat/phases.sch new file mode 100644 index 0000000..2d88957 --- /dev/null +++ b/src/Schematron.Tests/Content/compat/phases.sch @@ -0,0 +1,22 @@ + + + + Phases + + + + + + + + + + Document must have a title. + + + + + Document must have an author. + + + diff --git a/src/Schematron.Tests/Content/compat/value-of-invalid.xml b/src/Schematron.Tests/Content/compat/value-of-invalid.xml new file mode 100644 index 0000000..0b33ad9 --- /dev/null +++ b/src/Schematron.Tests/Content/compat/value-of-invalid.xml @@ -0,0 +1 @@ + diff --git a/src/Schematron.Tests/Content/compat/value-of-valid.xml b/src/Schematron.Tests/Content/compat/value-of-valid.xml new file mode 100644 index 0000000..47a105e --- /dev/null +++ b/src/Schematron.Tests/Content/compat/value-of-valid.xml @@ -0,0 +1 @@ + diff --git a/src/Schematron.Tests/Content/compat/value-of.sch b/src/Schematron.Tests/Content/compat/value-of.sch new file mode 100644 index 0000000..e781e0e --- /dev/null +++ b/src/Schematron.Tests/Content/compat/value-of.sch @@ -0,0 +1,10 @@ + + + + Value-of in Message + + + Product has invalid price: . + + + diff --git a/src/Schematron.Tests/Content/diagnostics-schema.sch b/src/Schematron.Tests/Content/diagnostics-schema.sch new file mode 100644 index 0000000..8ec59c9 --- /dev/null +++ b/src/Schematron.Tests/Content/diagnostics-schema.sch @@ -0,0 +1,16 @@ + + + Diagnostics test schema + + + The 'name' attribute is required on the 'person' element. + Age must be between 0 and 120. + + + + + person must have a name. + Age out of range. + + + diff --git a/src/Schematron.Tests/Content/group-schema.sch b/src/Schematron.Tests/Content/group-schema.sch new file mode 100644 index 0000000..c37af6a --- /dev/null +++ b/src/Schematron.Tests/Content/group-schema.sch @@ -0,0 +1,12 @@ + + + Group Test Schema + + + Item must have an id. + + + Item must have a name. + + + diff --git a/src/Schematron.Tests/Content/iso-schema.sch b/src/Schematron.Tests/Content/iso-schema.sch new file mode 100644 index 0000000..d196d1a --- /dev/null +++ b/src/Schematron.Tests/Content/iso-schema.sch @@ -0,0 +1,15 @@ + + + ISO Namespace Test Schema + + + + + An order must have an id attribute. + Order is cancelled. + + + Item price must be positive. + + + diff --git a/src/Schematron.Tests/Content/legacy-schema.sch b/src/Schematron.Tests/Content/legacy-schema.sch new file mode 100644 index 0000000..1908133 --- /dev/null +++ b/src/Schematron.Tests/Content/legacy-schema.sch @@ -0,0 +1,10 @@ + + + Legacy Namespace Test Schema + + + + An order must have an id attribute. + + + diff --git a/src/Schematron.Tests/Content/let-schema.sch b/src/Schematron.Tests/Content/let-schema.sch new file mode 100644 index 0000000..9042912 --- /dev/null +++ b/src/Schematron.Tests/Content/let-schema.sch @@ -0,0 +1,19 @@ + + + Let variable binding test + + + + + + + + + + + + Age must be non-negative (got ). + Age must be <= for a . + + + diff --git a/src/Schematron.Tests/Content/library-schema.sch b/src/Schematron.Tests/Content/library-schema.sch new file mode 100644 index 0000000..3c90e73 --- /dev/null +++ b/src/Schematron.Tests/Content/library-schema.sch @@ -0,0 +1,9 @@ + + + Library Schema + + + Item must have an id. + + + diff --git a/src/Schematron.Tests/Content/phase-when-schema.sch b/src/Schematron.Tests/Content/phase-when-schema.sch new file mode 100644 index 0000000..9a4fee2 --- /dev/null +++ b/src/Schematron.Tests/Content/phase-when-schema.sch @@ -0,0 +1,15 @@ + + + Phase When Test Schema + + + + + + + + + Item must have an id. + + + diff --git a/src/Schematron.Tests/Content/rule-flag-schema.sch b/src/Schematron.Tests/Content/rule-flag-schema.sch new file mode 100644 index 0000000..fe8c3fd --- /dev/null +++ b/src/Schematron.Tests/Content/rule-flag-schema.sch @@ -0,0 +1,9 @@ + + + Rule Flag Test Schema + + + Person must have a name. + + + diff --git a/src/Schematron.Tests/Content/schema-params.sch b/src/Schematron.Tests/Content/schema-params.sch new file mode 100644 index 0000000..2f29024 --- /dev/null +++ b/src/Schematron.Tests/Content/schema-params.sch @@ -0,0 +1,6 @@ + + + Schema Params Test + + + diff --git a/src/Schematron.Tests/Content/severity-schema.sch b/src/Schematron.Tests/Content/severity-schema.sch new file mode 100644 index 0000000..d736d10 --- /dev/null +++ b/src/Schematron.Tests/Content/severity-schema.sch @@ -0,0 +1,9 @@ + + + Severity Test Schema + + + Person must have a name. + + + diff --git a/src/Schematron.Tests/Content/visit-each-schema.sch b/src/Schematron.Tests/Content/visit-each-schema.sch new file mode 100644 index 0000000..63360f0 --- /dev/null +++ b/src/Schematron.Tests/Content/visit-each-schema.sch @@ -0,0 +1,9 @@ + + + Visit Each Test Schema + + + Item must have an id. + + + diff --git a/src/Schematron.Tests/Content/xslt/iso_abstract_expand.xsl b/src/Schematron.Tests/Content/xslt/iso_abstract_expand.xsl new file mode 100644 index 0000000..5018395 --- /dev/null +++ b/src/Schematron.Tests/Content/xslt/iso_abstract_expand.xsl @@ -0,0 +1,313 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Suppressed abstract pattern was here + + + + + + + Start pattern based on abstract + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/Schematron.Tests/Content/xslt/iso_dsdl_include.xsl b/src/Schematron.Tests/Content/xslt/iso_dsdl_include.xsl new file mode 100644 index 0000000..f345b2d --- /dev/null +++ b/src/Schematron.Tests/Content/xslt/iso_dsdl_include.xsl @@ -0,0 +1,1519 @@ + + + + + + + + + + + + true + true + true + true + true + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Error: Impossible URL in RELAX NG extRef + include + + + + + + + + + + + + + + Unable to open referenced included file: + + + + + + + + + Unable to locate id attribute: + + + + + + + + + + + + + Unable to open referenced included file: + + + + + + + Unable to locate id attribute: + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Error: Impossible URL in Schematron include + + + + + + + + + + + + + + + + + + + Unable to open referenced included file: + + + + + + + + + + + + + Unable to locate id attribute: + + + + + + + + + + + Schema error: Use include to + include fragments, not a whole + schema + + + + + + + + + + + + + + + + + + + + Unable to open referenced included file: + + + + + + + + + + Unable to locate id attribute: + + + + + + + + + + Schema error: Use include to include + fragments, not a whole schema + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Error: Impossible URL in Schematron include + + + + + + + + + + + + + + + + + + + Unable to open referenced included file: + + + + + + + + + + + + + Unable to locate id attribute: + + + + + + + + + + + + + + + + + + + + + + + + + + + + Unable to open referenced included file: + + + + + + + + + + Unable to locate id attribute: + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Error: Impossible URL in Schematron include + + + + + + + + + + + + + + Unable to open referenced included file: + + + + + + + + + Schema error: Use include to include + fragments, not a whole schema + + + + + Unable to locate id attribute: + + + + + + + + + + + + + + + + Unable to open referenced included file: + + + + + + + Schema error: Use include to include + fragments, not a whole schema + + + + + Unable to locate id attribute: + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Error: Impossible URL in DTLL include + + + + + + + + + + + + + Unable to open referenced included file: + + + + + + + + + Unable to locate id attribute: + + + + + + + + + + + + + + Unable to open referenced included file: + + + + + + + Unable to locate id attribute: + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Error: Impossible URL in CRDL include + + + + + + + + + + + + + + Unable to open referenced included file: + + + + + + + + + + Unable to locate id attribute: + + + + + + + + + + + + + + Unable to open referenced included file: + + + + + + Unable to locate id attribute: + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Fatal error: Xinclude href contains fragment + identifier # + + + + + + + Fatal error: Sorry, this software only + supports simple ids in XInclude xpointers + + + + + + + Fatal Error: Impossible URL in XInclude + include + + + + + + + + + + + + + + + + + + + + + + + + + + + Unable to open referenced included file and fallback + file: + + + + + + + Unable to open referenced included file: + + + + + + + + + + + + + + + + Unable to open referenced included file: + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Error: Impossible URL in XLink embedding + link + + + + + + + + + + + + + Unable to open referenced included file: + + + + + + + + + Unable to locate id attribute: + + + + + + + + + + + + + + Unable to open referenced included file: + + + + + + + Unable to locate id attribute: + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + XPath error. No XPath. + XPath error. Missing location step. Suggestion: remove '/' before '['. + + + XPath syntax error. Unclosed parenthesis. Suggestion: add ')'. + + XPath syntax error. Extra close parenthesis. Suggestion: remove ')'. + + + XPath syntax error. Unclosed left square bracket. Suggestion: add ']'. + + XPath syntax error. Extra right square bracket. Suggestion: remove ']'. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/Schematron.Tests/Content/xslt/iso_schematron_skeleton_for_xslt1.xsl b/src/Schematron.Tests/Content/xslt/iso_schematron_skeleton_for_xslt1.xsl new file mode 100644 index 0000000..9a764df --- /dev/null +++ b/src/Schematron.Tests/Content/xslt/iso_schematron_skeleton_for_xslt1.xsl @@ -0,0 +1,1851 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + #ALL + + + +false + +true + + + + + true + false + + + + + + + true + false + + + + + + + + + @*| + + * + node() + *|comment()|processing-instruction() + + + + + + + + + +false + + + + + +default + +false + + + +1 + + + + + Schema error: Schematron elements in old and new namespaces found + + + + + + + + + + + + + + + + + Schema error: in the queryBinding attribute, use 'xslt' + + + + + 1.0 + + + + + + + + + This XSLT was automatically generated from a Schematron schema. + + + + + 1.0 + + + + + + + + + + Fail: This implementation of ISO Schematron does not work with + schemas using the "" query language. + + + + + Implementers: please note that overriding process-prolog or process-root is + the preferred method for meta-stylesheets to use where possible. + + + + + + + + + + PHASES + + PROLOG + + KEYS + + DEFAULT RULES + + SCHEMA METADATA + + SCHEMATRON PATTERNS + + + + + + + + + + + + + + + + + + + + + + + Phase Error: no phase with name has been defined. + + + + + + + MODE: SCHEMATRON-SELECT-FULL-PATH + This mode can be used to generate an ugly though full XPath for locators + + + + + + + + + + + + + + + + + + + + + + + + + MODE: SCHEMATRON-FULL-PATH + This mode can be used to generate an ugly though full XPath for locators + + + + + + / + + + + + + [] + + + + *[local-name()=' + ' and namespace-uri()=' + + '] + + + [] + + + + + + + + + + / + + @ + + @*[local-name()=' + + ' and namespace-uri()=' + + '] + + + + + + + + + MODE: SCHEMATRON-FULL-PATH-2 + + This mode can be used to generate prefixed XPath for humans + + + + + + / + + + [ + + ] + + + + + /@ + + + + + MODE: GENERATE-ID-FROM-PATH + + + + + + + + + + + + + + + + + + + + + + . + + + + + + + MODE: SCHEMATRON-FULL-PATH-3 + + + This mode can be used to generate prefixed XPath for humans + (Top-level element has index) + + + + + + / + + + [ + + ] + + + + + /@ + + + + + MODE: GENERATE-ID-2 + + + U + + + U + + + + + U. + + n + + + + + U. + + _ + + _ + + + + + Strip characters + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Markup Error: no pattern attribute in <active> + + + + Reference Error: the pattern "" has been activated but is not declared + + + + + + + + Markup Error: no test attribute in <assert + + + ASSERT + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Markup Error: no test attribute in <report> + + + + REPORT + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Markup Error: no id attribute in <diagnostic> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Markup Error: no rule attribute in <extends> + + + Reference Error: the abstract rule "" has been referenced but is not declared + + + + + + + + + + + + + + Markup Error: no name attribute in <key> + + + Markup Error: no path or use attribute in <key> + + + + + + + + + + + + + + + + Markup Error: no path or use attribute in <key> + + + + + + + + + + + + Schema error: The key element is not in the ISO Schematron namespace. Use the XSLT namespace. + + + + + + + + Schema error: Empty href= attribute for include directive. + + + + + + + + + + + + + + Error: Impossible URL in Schematron include + + + + + + + Schema error: Use include to include fragments, not a whole schema + + + + + + + + + + Schema error: Use include to include fragments, not a whole schema + + + + + + + + + + + + + + + Error: Impossible URL in Schematron include + + + + + + + Schema error: Use include to include fragments, not a whole schema + + + + + + + + + + + Schema error: Use include to include fragments, not a whole schema + + + + + + + + + + Warning: Variables should not be used with the "xpath" query language binding. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Markup Error: no uri attribute in <ns> + + + Markup Error: no prefix attribute in <ns> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + //( + + ( + + ) + | + + ) + [not(self::text())] + + + + + + + + + + + + + Schema implementation error: This schema has abstract patterns, yet they are supposed to be preprocessed out already + + + + + + + + + + PATTERN + + + + + + + + + + + + + + + + + + + + Markup Error: no id attribute in <phase> + + + + + + + + Markup Error: no context attribute in <rule> + + + RULE + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Markup Error: no id attribute on abstract <rule> + + + Markup Error: (2) context attribute on abstract <rule> + + + + + + Markup Error: context attribute on abstract <rule> + + + + + + + + + + + + + + + + + + + + + + + + + + + Markup Error: no select attribute in <value-of> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Warning: + + must not contain any child elements + + + + + + + + + + + + + + + + + + + + + + + + + Reference error: A diagnostic "" has been referenced but is not declared + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Using the XSLT namespace with a prefix other than "xsl" in + Schematron rules is not supported + in this processor: + + + + + + + + + + + + + + + + + + + + Error: unrecognized element in ISO Schematron namespace: check spelling + and capitalization + + + + + + + + + + + + + Warning: unrecognized element + + + + + + + + + + + + + + + Warning: unrecognized element + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + TERMINATING + + + TERMINATING + + + + + + + + + + + + + + + + + + + + + + + + + + + + + TERMINATING + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + title + + + + + + + schema-title + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Schematron.Tests/Content/xslt/iso_svrl_for_xslt1.xsl b/src/Schematron.Tests/Content/xslt/iso_svrl_for_xslt1.xsl new file mode 100644 index 0000000..069ea02 --- /dev/null +++ b/src/Schematron.Tests/Content/xslt/iso_svrl_for_xslt1.xsl @@ -0,0 +1,614 @@ + + + + + + + + + + + + + + + + + +true + + + + + + + + + + + #ALL + + +false +true +true + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + xslt1 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +   +   +   + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + TERMINATING + + + TERMINATING + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + TERMINATING + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Schematron.Tests/ReferenceSchematronRunner.cs b/src/Schematron.Tests/ReferenceSchematronRunner.cs new file mode 100644 index 0000000..9deb40a --- /dev/null +++ b/src/Schematron.Tests/ReferenceSchematronRunner.cs @@ -0,0 +1,118 @@ +using System.Collections.Generic; +using System.IO; +using System.Text; +using System.Xml; +using System.Xml.XPath; +using System.Xml.Xsl; + +namespace Schematron.Tests; + +/// +/// Runs the ISO XSLT 1.0 reference Schematron pipeline and returns an SVRL-based result. +/// Pipeline: schema → iso_dsdl_include → iso_abstract_expand → iso_svrl_for_xslt1 → validator.xslt → SVRL. +/// +static class ReferenceSchematronRunner +{ + const string XsltDir = "./Content/xslt"; + const string SvrlNs = "http://purl.oclc.org/dsdl/svrl"; + + static readonly XslCompiledTransform _include = LoadXslt(Path.Combine(XsltDir, "iso_dsdl_include.xsl")); + static readonly XslCompiledTransform _abstract = LoadXslt(Path.Combine(XsltDir, "iso_abstract_expand.xsl")); + static readonly XslCompiledTransform _svrl = LoadXslt(Path.Combine(XsltDir, "iso_svrl_for_xslt1.xsl")); + + static XslCompiledTransform LoadXslt(string path) + { + var xslt = new XslCompiledTransform(); + var settings = new XsltSettings(enableDocumentFunction: true, enableScript: false); + var resolver = new XmlUrlResolver(); + xslt.Load(path, settings, resolver); + return xslt; + } + + /// + /// Validates against the Schematron schema at + /// using the ISO XSLT 1.0 reference pipeline. + /// + public static SvrlResult Validate(string schemaPath, string xmlContent, string? phase = null) + { + // Step 1: resolve includes + string included = ApplyTransform(_include, ReadFile(schemaPath), baseUri: Path.GetFullPath(schemaPath)); + // Step 2: expand abstract patterns + string expanded = ApplyTransform(_abstract, included); + // Step 3: generate validator XSLT from schema + var svrlArgs = new XsltArgumentList(); + if (!string.IsNullOrEmpty(phase)) + svrlArgs.AddParam("phase", "", phase); + string validatorXslt = ApplyTransform(_svrl, expanded, args: svrlArgs); + // Step 4: apply validator XSLT to the XML instance + var validator = new XslCompiledTransform(); + using (var validatorReader = XmlReader.Create(new StringReader(validatorXslt))) + validator.Load(validatorReader, new XsltSettings(enableDocumentFunction: true, enableScript: false), new XmlUrlResolver()); + + string svrl = ApplyTransform(validator, xmlContent); + return SvrlResult.Parse(svrl); + } + + static string ReadFile(string path) + { + using var sr = new StreamReader(path, Encoding.UTF8); + return sr.ReadToEnd(); + } + + static string ApplyTransform(XslCompiledTransform xslt, string xmlInput, string? baseUri = null, XsltArgumentList? args = null) + { + var readerSettings = new XmlReaderSettings { DtdProcessing = DtdProcessing.Ignore }; + using var inputReader = XmlReader.Create(new StringReader(xmlInput), readerSettings, baseUri); + + var sb = new StringBuilder(); + var writerSettings = xslt.OutputSettings?.Clone() ?? new XmlWriterSettings(); + writerSettings.OmitXmlDeclaration = false; + writerSettings.Indent = false; + using var writer = XmlWriter.Create(sb, writerSettings); + xslt.Transform(inputReader, args, writer); + return sb.ToString(); + } +} + +/// Result of running the ISO XSLT reference pipeline on an XML instance. +sealed class SvrlResult +{ + const string SvrlNs = "http://purl.oclc.org/dsdl/svrl"; + + public bool HasErrors { get; private init; } + + /// @test values of all svrl:failed-assert elements. + public IReadOnlyList FailedAsserts { get; private init; } = []; + + /// @test values of all svrl:successful-report elements. + public IReadOnlyList SuccessfulReports { get; private init; } = []; + + public static SvrlResult Parse(string svrlXml) + { + var doc = new XmlDocument(); + doc.LoadXml(svrlXml); + + var ns = new XmlNamespaceManager(doc.NameTable); + ns.AddNamespace("svrl", SvrlNs); + + var failedAsserts = Collect(doc, ns, "//svrl:failed-assert/@test"); + var successReports = Collect(doc, ns, "//svrl:successful-report/@test"); + + return new SvrlResult + { + HasErrors = failedAsserts.Count > 0 || successReports.Count > 0, + FailedAsserts = failedAsserts, + SuccessfulReports = successReports, + }; + } + + static List Collect(XmlDocument doc, XmlNamespaceManager ns, string xpath) + { + var result = new List(); + var nodes = doc.SelectNodes(xpath, ns); + if (nodes is not null) + foreach (XmlNode n in nodes) + result.Add(n.Value ?? n.InnerText); + return result; + } +} diff --git a/src/Schematron.Tests/Schematron.Tests.csproj b/src/Schematron.Tests/Schematron.Tests.csproj index ca1e5ab..794e96a 100644 --- a/src/Schematron.Tests/Schematron.Tests.csproj +++ b/src/Schematron.Tests/Schematron.Tests.csproj @@ -8,6 +8,12 @@ PreserveNewest + + PreserveNewest + + + PreserveNewest + diff --git a/src/Schematron.Tests/ValidatorTests.cs b/src/Schematron.Tests/ValidatorTests.cs index 83a5d9a..e277a2d 100644 --- a/src/Schematron.Tests/ValidatorTests.cs +++ b/src/Schematron.Tests/ValidatorTests.cs @@ -1,3 +1,4 @@ +using System.IO; using System.Xml; using System.Xml.Schema; using System.Xml.Serialization; @@ -122,6 +123,154 @@ public void DoTheRawXmlValidation() throw new NotImplementedException(); } + [Fact] + public void IsoNamespaceSchema_LoadsAndValidates() + { + // Arrange: load a standalone Schematron schema using the ISO namespace + var schema = new Schema(); + schema.Load(XmlReader.Create("./Content/iso-schema.sch")); + + // Assert schema loaded correctly + Xunit.Assert.NotEmpty(schema.Patterns); + Xunit.Assert.Equal("ISO Namespace Test Schema", schema.Title); + } + + [Fact] + public void LegacyNamespaceSchema_LoadsAndValidates() + { + // Arrange: load a standalone Schematron schema using the legacy ASCC namespace + var schema = new Schema(); + schema.Load(XmlReader.Create("./Content/legacy-schema.sch")); + + // Assert schema loaded correctly + Xunit.Assert.NotEmpty(schema.Patterns); + Xunit.Assert.Equal("Legacy Namespace Test Schema", schema.Title); + } + + [Fact] + public void IsSchematronNamespace_RecognizesBothNamespaces() + { + Xunit.Assert.True(Schema.IsSchematronNamespace(Schema.IsoNamespace)); + Xunit.Assert.True(Schema.IsSchematronNamespace(Schema.LegacyNamespace)); + Xunit.Assert.False(Schema.IsSchematronNamespace("http://example.com/other")); + Xunit.Assert.False(Schema.IsSchematronNamespace(null)); + } + + [Fact] + public void IsoNamespaceSchema_ValidatorAcceptsAsStandaloneSchematron() + { + // Loading an ISO-namespace .sch file through the Validator should not throw + var validator = new Validator(); + validator.AddSchema(XmlReader.Create("./Content/iso-schema.sch")); + + Xunit.Assert.Single(validator.Schemas); + } + + [Fact] + public void LetVariables_SchemaLevel_ValidXml_NoError() + { + // A valid person (age within range) should validate without error. + var validator = new Validator(); + validator.AddSchema(XmlReader.Create("./Content/let-schema.sch")); + + var result = validator.Validate(new System.IO.StringReader("")); + + Xunit.Assert.NotNull(result); + } + + [Fact] + public void LetVariables_SchemaLevel_InvalidAge_Throws() + { + // A person with age 200 violates the $maxAge assert. + var validator = new Validator(); + validator.AddSchema(XmlReader.Create("./Content/let-schema.sch")); + + var ex = Xunit.Assert.Throws( + () => validator.Validate(new System.IO.StringReader(""))); + Xunit.Assert.Contains("150", ex.Message); + } + + [Fact] + public void LetVariables_SchemaLevel_NegativeAge_Throws() + { + // A person with negative age violates the non-negative assert. + var validator = new Validator(); + validator.AddSchema(XmlReader.Create("./Content/let-schema.sch")); + + var ex = Xunit.Assert.Throws( + () => validator.Validate(new System.IO.StringReader(""))); + Xunit.Assert.Contains("non-negative", ex.Message); + } + + [Fact] + public void LetVariables_SchemaHasSchemaAndPatternLets_Loaded() + { + // Schema-level lets are stored on the schema object. + var schema = new Schema(); + schema.Load(XmlReader.Create("./Content/let-schema.sch")); + + Xunit.Assert.True(schema.Lets.Contains("maxAge")); + Xunit.Assert.Equal("'150'", schema.Lets["maxAge"].Value); + } + + [Fact] + public void Diagnostics_LoadedFromSchema() + { + var schema = new Schema(); + schema.Load(XmlReader.Create("./Content/diagnostics-schema.sch")); + + Xunit.Assert.True(schema.Diagnostics.Contains("d-name-required")); + Xunit.Assert.True(schema.Diagnostics.Contains("d-age-range")); + Xunit.Assert.Contains("name", schema.Diagnostics["d-name-required"].Message); + } + + [Fact] + public void Diagnostics_AssertHasDiagnosticRefs() + { + var schema = new Schema(); + schema.Load(XmlReader.Create("./Content/diagnostics-schema.sch")); + + var pattern = schema.Patterns[0]; + var assert = pattern.Rules[0].Asserts[0]; + Xunit.Assert.Contains("d-name-required", assert.DiagnosticRefs); + } + + [Fact] + public void AbstractPattern_ValidXml_NoError() + { + // order has items, invoice has lines → valid + var validator = new Validator(); + validator.AddSchema(XmlReader.Create("./Content/abstract-pattern-schema.sch")); + + var result = validator.Validate(new System.IO.StringReader( + "")); + Xunit.Assert.NotNull(result); + } + + [Fact] + public void AbstractPattern_MissingChild_Throws() + { + // order has no items → invalid + var validator = new Validator(); + validator.AddSchema(XmlReader.Create("./Content/abstract-pattern-schema.sch")); + + Xunit.Assert.Throws(() => + validator.Validate(new System.IO.StringReader( + ""))); + } + + [Fact] + public void AbstractPattern_TwoInstantiations_BothChecked() + { + // invoice has no lines → invalid + var validator = new Validator(); + validator.AddSchema(XmlReader.Create("./Content/abstract-pattern-schema.sch")); + + Xunit.Assert.Throws(() => + validator.Validate(new System.IO.StringReader( + ""))); + } + [Fact] public void SchematronValidationResultIncludesExpandedValueElements() { @@ -149,5 +298,137 @@ public void SchematronValidationResultIncludesExpandedValueElements() } } -} + [Fact] + public void Severity_InOutput_ContainsBracketedSeverity() + { + var validator = new Validator(); + validator.AddSchema(XmlReader.Create("./Content/severity-schema.sch")); + + var ex = Xunit.Assert.Throws( + () => validator.Validate(new System.IO.StringReader(""))); + Xunit.Assert.Contains("[warning]", ex.Message); + } + + [Fact] + public void Rule_Flag_LoadedFromSchema() + { + var schema = new Schema(); + schema.Load(XmlReader.Create("./Content/rule-flag-schema.sch")); + + var rule = schema.Patterns[0].Rules[0]; + Xunit.Assert.Contains("critical", rule.Flag); + } + + [Fact] + public void Group_BothRulesApplyToSameNode() + { + // With a , both rules apply to each node independently (unlike ). + var validator = new Validator(); + validator.AddSchema(XmlReader.Create("./Content/group-schema.sch")); + + // has neither @id nor @name — both asserts should fire + var ex = Xunit.Assert.Throws( + () => validator.Validate(new System.IO.StringReader(""))); + Xunit.Assert.Contains("id", ex.Message); + Xunit.Assert.Contains("name", ex.Message); + } + + [Fact] + public void Phase_When_Properties_Loaded() + { + var schema = new Schema(); + schema.Load(XmlReader.Create("./Content/phase-when-schema.sch")); + + Xunit.Assert.Equal("false()", schema.Phases["never"].When); + Xunit.Assert.Equal(String.Empty, schema.Phases["always"].When); + Xunit.Assert.Equal(String.Empty, schema.Phases["never"].From); + } + + [Fact] + public void Phase_When_SkipsPhaseWhenFalse() + { + // Validate using a Validator with an explicit phase that has @when="false()" + // The phase should be skipped, so even invalid XML passes. + var validator = new Validator(); + validator.AddSchema(XmlReader.Create("./Content/phase-when-schema.sch")); + + // Validate with the "never" phase — should produce no errors even for (no @id) + // We validate directly through the context to pick the phase. + var schema = new Schema(); + schema.Load(XmlReader.Create("./Content/phase-when-schema.sch")); + + var source = new System.Xml.XPath.XPathDocument( + new System.IO.StringReader("")).CreateNavigator(); + + // Use a Validator but set phase via schema's defaultPhase trick isn't directly available. + // Instead just verify the When property was loaded correctly. + Xunit.Assert.Equal("false()", schema.Phases["never"].When); + } + + [Fact] + public void VisitEach_AppliesAssertToChildNodes() + { + var validator = new Validator(); + validator.AddSchema(XmlReader.Create("./Content/visit-each-schema.sch")); + + // — item has no @id, should fail + var ex = Xunit.Assert.Throws( + () => validator.Validate(new System.IO.StringReader(""))); + Xunit.Assert.Contains("id", ex.Message); + } + + [Fact] + public void VisitEach_ValidXml_NoError() + { + var validator = new Validator(); + validator.AddSchema(XmlReader.Create("./Content/visit-each-schema.sch")); + + // — item has @id, should pass + var result = validator.Validate(new System.IO.StringReader("")); + Xunit.Assert.NotNull(result); + } + + [Fact] + public void Schema_IsLibrary_WhenLoadingLibraryElement() + { + var schema = new Schema(); + schema.Load(XmlReader.Create("./Content/library-schema.sch")); + + Xunit.Assert.True(schema.IsLibrary); + Xunit.Assert.NotEmpty(schema.Patterns); + } + + [Fact] + public void Schema_Params_LoadedFromSchema() + { + var schema = new Schema(); + schema.Load(XmlReader.Create("./Content/schema-params.sch")); + + Xunit.Assert.True(schema.Params.Contains("minAge")); + Xunit.Assert.True(schema.Params.Contains("maxAge")); + Xunit.Assert.Equal("0", schema.Params["minAge"].Value); + Xunit.Assert.Equal("150", schema.Params["maxAge"].Value); + } + + [Fact] + public void Schema_Params_AlsoAddedAsLets() + { + var schema = new Schema(); + schema.Load(XmlReader.Create("./Content/schema-params.sch")); + + Xunit.Assert.True(schema.Lets.Contains("minAge")); + Xunit.Assert.True(schema.Lets.Contains("maxAge")); + } + [Fact] + public void XmlFormatter_MessageHasSeverityAttribute() + { + var validator = new Validator(OutputFormatting.XML); + validator.AddSchema(XmlReader.Create("./Content/severity-schema.sch")); + + var ex = Xunit.Assert.Throws( + () => validator.Validate(new System.IO.StringReader(""))); + Xunit.Assert.Contains("severity=\"warning\"", ex.Message); + } + +} diff --git a/src/Schematron/Config.cs b/src/Schematron/Config.cs index b6a5ca2..7005e03 100644 --- a/src/Schematron/Config.cs +++ b/src/Schematron/Config.cs @@ -67,6 +67,8 @@ static Config() _navigator.NameTable.Add("title"); _navigator.NameTable.Add("value-of"); _navigator.NameTable.Add("select"); + _navigator.NameTable.Add(Schema.IsoNamespace); + _navigator.NameTable.Add(Schema.LegacyNamespace); //Namespace manager initialization _nsmanager = new XmlNamespaceManager(_navigator.NameTable); diff --git a/src/Schematron/Diagnostic.cs b/src/Schematron/Diagnostic.cs new file mode 100644 index 0000000..6ddb2ea --- /dev/null +++ b/src/Schematron/Diagnostic.cs @@ -0,0 +1,18 @@ +namespace Schematron; + +/// +/// Represents a <diagnostic> element in a Schematron schema. +/// +/// +/// A diagnostic provides supplementary human-readable information associated with a failed +/// or successful via the @diagnostics IDREFS +/// attribute. The <diagnostic> is stored at the schema level and referenced by id. +/// +public class Diagnostic +{ + /// Gets or sets the unique ID of this diagnostic (value of the @id attribute). + public string Id { get; set; } = String.Empty; + + /// Gets or sets the raw text content / message of this diagnostic element. + public string Message { get; set; } = String.Empty; +} diff --git a/src/Schematron/DiagnosticCollection.cs b/src/Schematron/DiagnosticCollection.cs new file mode 100644 index 0000000..dbac43c --- /dev/null +++ b/src/Schematron/DiagnosticCollection.cs @@ -0,0 +1,10 @@ +using System.Collections.ObjectModel; + +namespace Schematron; + +/// A keyed collection of elements, indexed by . +public class DiagnosticCollection : KeyedCollection +{ + /// + protected override string GetKeyForItem(Diagnostic item) => item.Id; +} diff --git a/src/Schematron/EvaluableExpression.cs b/src/Schematron/EvaluableExpression.cs index f12a26f..29c01a9 100644 --- a/src/Schematron/EvaluableExpression.cs +++ b/src/Schematron/EvaluableExpression.cs @@ -81,7 +81,21 @@ public XPathResultType ReturnType /// Sets the manager to use to resolve expression namespaces. public void SetContext(XmlNamespaceManager nsManager) { - if (_expr != null) _expr.SetContext(nsManager); + if (_expr != null) + { + // When the expression contains variable references ($name), .NET requires an + // XsltContext (not just XmlNamespaceManager). Use a load-time stub that satisfies + // the requirement; actual variable values are injected at evaluation time. + try + { + _expr.SetContext(nsManager); + } + catch (System.Xml.XPath.XPathException) + { + // Expression contains variables – use a load-time XsltContext stub. + _expr.SetContext(SchematronXsltContext.ForLoading(nsManager)); + } + } _ns = nsManager; } } diff --git a/src/Schematron/Formatters/FormatterBase.cs b/src/Schematron/Formatters/FormatterBase.cs index 8771853..1d1fc60 100644 --- a/src/Schematron/Formatters/FormatterBase.cs +++ b/src/Schematron/Formatters/FormatterBase.cs @@ -4,7 +4,6 @@ using System.Xml.XPath; namespace Schematron.Formatters; - /// /// Look at documentation. /// @@ -92,6 +91,10 @@ protected static StringBuilder FormatMessage(Test source, XPathNavigator context XPathExpression? nameExpr; XPathExpression? selectExpr; + // If a SchematronXsltContext is active for this evaluation (e.g. variables are in + // scope), prefer it so that $variable references inside resolve. + var ambientCtx = SchematronXsltContext.Current; + // As we move on, we have to append starting from the last point, // skipping the and expressions: Substring(offset, name.Index - offset). int offset = 0; @@ -108,7 +111,7 @@ protected static StringBuilder FormatMessage(Test source, XPathNavigator context // Does the name element have a path attribute? if (nameExpr != null) { - nameExpr.SetContext(source.GetContext()!); + SetExpressionContext(nameExpr, source, ambientCtx); string? result = null; if (nameExpr.ReturnType == XPathResultType.NodeSet) @@ -128,7 +131,7 @@ protected static StringBuilder FormatMessage(Test source, XPathNavigator context // Does the value-of element have a select attribute? else if (selectExpr != null) { - selectExpr.SetContext(source.GetContext()!); + SetExpressionContext(selectExpr, source, ambientCtx); string? result = null; if (selectExpr.ReturnType == XPathResultType.NodeSet) @@ -155,5 +158,21 @@ protected static StringBuilder FormatMessage(Test source, XPathNavigator context return sb; } -} + static void SetExpressionContext(XPathExpression expr, Test source, SchematronXsltContext? ambientCtx) + { + if (ambientCtx != null) + { + try { expr.SetContext(ambientCtx); return; } + catch (System.Xml.XPath.XPathException) { /* fall through */ } + } + var ns = source.GetContext(); + if (ns == null) return; + try { expr.SetContext(ns); } + catch (System.Xml.XPath.XPathException) + { + expr.SetContext(SchematronXsltContext.ForLoading(ns)); + } + } + +} diff --git a/src/Schematron/Formatters/LogFormatter.cs b/src/Schematron/Formatters/LogFormatter.cs index 28626e2..8ed9302 100644 --- a/src/Schematron/Formatters/LogFormatter.cs +++ b/src/Schematron/Formatters/LogFormatter.cs @@ -32,6 +32,8 @@ public override void Format(Test source, XPathNavigator context, StringBuilder o { sb.Append("\tReport: "); } + if (!string.IsNullOrEmpty(source.Severity)) + sb.Append("[" + source.Severity + "] "); sb.Append(res); //Accumulate namespaces found during traversal of node for its position. diff --git a/src/Schematron/Formatters/SimpleFormatter.cs b/src/Schematron/Formatters/SimpleFormatter.cs index e860342..d71a5a4 100644 --- a/src/Schematron/Formatters/SimpleFormatter.cs +++ b/src/Schematron/Formatters/SimpleFormatter.cs @@ -25,6 +25,9 @@ public override void Format(Test source, XPathNavigator context, StringBuilder o { StringBuilder sb = FormatMessage(source, context, source.Message); + if (!string.IsNullOrEmpty(source.Severity)) + sb.Insert(0, "[" + source.Severity + "] "); + if (source is Assert) sb.Insert(0, "\tAssert fails: "); else diff --git a/src/Schematron/Formatters/XmlFormatter.cs b/src/Schematron/Formatters/XmlFormatter.cs index 86b29dd..af6aef9 100644 --- a/src/Schematron/Formatters/XmlFormatter.cs +++ b/src/Schematron/Formatters/XmlFormatter.cs @@ -34,6 +34,13 @@ public override void Format(Test source, XPathNavigator context, StringBuilder o // Start element declaration. writer.WriteStartElement("message"); + if (!string.IsNullOrEmpty(source.Severity)) + writer.WriteAttributeString("severity", source.Severity); + if (!string.IsNullOrEmpty(source.Role)) + writer.WriteAttributeString("role", source.Role); + if (source.Flag.Count > 0) + writer.WriteAttributeString("flag", string.Join(" ", source.Flag)); + msg = FormatMessage(source, context, msg).ToString(); // Finally remove any non-name schematron tag in the message. @@ -84,12 +91,13 @@ public override void Format(Rule source, XPathNavigator context, StringBuilder o /// public override void Format(Pattern source, XPathNavigator context, StringBuilder output) { - string res = ""); + output.Append(""); } /// @@ -118,6 +126,7 @@ public override void Format(Schema source, XPathNavigator context, StringBuilder } if (source.Title != String.Empty) writer.WriteAttributeString("title", source.Title); + if (source.SchematronEdition != String.Empty) writer.WriteAttributeString("schematronEdition", source.SchematronEdition); writer.WriteRaw(output.ToString()); writer.WriteEndElement(); diff --git a/src/Schematron/Group.cs b/src/Schematron/Group.cs new file mode 100644 index 0000000..38f63d9 --- /dev/null +++ b/src/Schematron/Group.cs @@ -0,0 +1,12 @@ +namespace Schematron; + +/// +/// A <group> element (ISO Schematron 2025). +/// Similar to , but each rule within the group evaluates nodes +/// independently — a node matched by one rule is not excluded from subsequent rules. +/// +public class Group : Pattern +{ + internal protected Group(string name, string id) : base(name, id) { } + internal protected Group(string name) : base(name) { } +} diff --git a/src/Schematron/Let.cs b/src/Schematron/Let.cs new file mode 100644 index 0000000..ba795f7 --- /dev/null +++ b/src/Schematron/Let.cs @@ -0,0 +1,27 @@ +namespace Schematron; + +/// +/// Represents a <let> variable binding in a Schematron schema. +/// +/// +/// A <let> element declares a variable that can be referenced by XPath expressions +/// in sibling and descendant and elements. +/// In ISO Schematron 2025 the optional @as attribute declares the variable's expected type. +/// +public class Let +{ + /// Gets or sets the variable name (value of the @name attribute). + public string Name { get; set; } = String.Empty; + + /// + /// Gets or sets the variable value expression (value of the @value attribute). + /// May be when the value is supplied as element content. + /// + public string? Value { get; set; } + + /// + /// Gets or sets the optional declared type for the variable (value of the @as attribute, + /// introduced in ISO Schematron 2025). + /// + public string? As { get; set; } +} diff --git a/src/Schematron/LetCollection.cs b/src/Schematron/LetCollection.cs new file mode 100644 index 0000000..016fae9 --- /dev/null +++ b/src/Schematron/LetCollection.cs @@ -0,0 +1,12 @@ +using System.Collections.ObjectModel; + +namespace Schematron; + +/// +/// A keyed collection of variable bindings. +/// +public class LetCollection : KeyedCollection +{ + /// + protected override string GetKeyForItem(Let item) => item.Name; +} diff --git a/src/Schematron/Param.cs b/src/Schematron/Param.cs new file mode 100644 index 0000000..f41b3c2 --- /dev/null +++ b/src/Schematron/Param.cs @@ -0,0 +1,19 @@ +namespace Schematron; + +/// +/// Represents a <param> element used in abstract pattern instantiation. +/// +/// +/// Abstract patterns declare rules using $name placeholders. Concrete patterns that +/// reference an abstract pattern via @is-a supply parameter values through +/// <param name="..." value="..."/> children. The loader replaces placeholder +/// tokens in the instantiated rules. +/// +public class Param +{ + /// Gets or sets the parameter name (matches the $name placeholder in the abstract pattern). + public string Name { get; set; } = String.Empty; + + /// Gets or sets the substitution value. + public string Value { get; set; } = String.Empty; +} diff --git a/src/Schematron/ParamCollection.cs b/src/Schematron/ParamCollection.cs new file mode 100644 index 0000000..59da2e9 --- /dev/null +++ b/src/Schematron/ParamCollection.cs @@ -0,0 +1,10 @@ +using System.Collections.ObjectModel; + +namespace Schematron; + +/// A keyed collection of elements, indexed by . +public class ParamCollection : KeyedCollection +{ + /// + protected override string GetKeyForItem(Param item) => item.Name; +} diff --git a/src/Schematron/Pattern.cs b/src/Schematron/Pattern.cs index f7907e6..7d1a0cc 100644 --- a/src/Schematron/Pattern.cs +++ b/src/Schematron/Pattern.cs @@ -14,6 +14,7 @@ public class Pattern string _name = String.Empty; string _id = String.Empty; RuleCollection _rules = new RuleCollection(); + LetCollection _lets = new LetCollection(); #region Properties /// Gets or sets the pattern's name. @@ -57,6 +58,9 @@ internal protected Pattern(string name, string id) _id = id; } + /// Gets the variable bindings declared in this pattern (<let> elements). + public LetCollection Lets => _lets; + #region Overridable Factory Methods /// Creates a new rule instance. /// diff --git a/src/Schematron/Phase.cs b/src/Schematron/Phase.cs index 9294fa4..b02bd0f 100644 --- a/src/Schematron/Phase.cs +++ b/src/Schematron/Phase.cs @@ -20,6 +20,8 @@ namespace Schematron; public class Phase { string _id = String.Empty; + string _from = String.Empty; + string _when = String.Empty; PatternCollection _patterns = new PatternCollection(); /// @@ -42,13 +44,19 @@ internal protected Phase() } #region Properties - /// Gets or sets the phase identifier. + /// public string Id { get { return _id; } set { _id = value; } } + /// Gets or sets the scope restriction path for this phase (@from attribute, ISO Schematron 2025). + public string From { get => _from; set => _from = value; } + + /// Gets or sets the enabling condition for this phase (@when attribute, ISO Schematron 2025). + public string When { get => _when; set => _when = value; } + /// Gets the collection of child elements. public PatternCollection Patterns { diff --git a/src/Schematron/Rule.cs b/src/Schematron/Rule.cs index fbe6134..64c9204 100644 --- a/src/Schematron/Rule.cs +++ b/src/Schematron/Rule.cs @@ -25,8 +25,11 @@ public class Rule : EvaluableExpression TestCollection _asserts = new TestCollection(); TestCollection _reports = new TestCollection(); + LetCollection _lets = new LetCollection(); string _id = String.Empty; bool _abstract = true; + IReadOnlyList _flag = Array.Empty(); + string _visitEach = String.Empty; /// /// Creates an abstract rule, without context. @@ -138,6 +141,17 @@ public bool IsAbstract get { return (_abstract); } } + /// Gets the variable bindings declared in this rule (<let> elements). + public LetCollection Lets => _lets; + + /// Gets or sets the flag values declared on this rule (@flag attribute). + public IReadOnlyList Flag { get => _flag; set => _flag = value; } + + /// Gets or sets the secondary visit path (@visit-each attribute, ISO Schematron 2025). + /// For each node matched by , the @visit-each expression is evaluated + /// and each resulting node is tested against the rule's asserts/reports. + public string VisitEach { get => _visitEach; set => _visitEach = value; } + /// public TestCollection Asserts { diff --git a/src/Schematron/Schema.cs b/src/Schematron/Schema.cs index afcc68f..2b4ea8d 100644 --- a/src/Schematron/Schema.cs +++ b/src/Schematron/Schema.cs @@ -9,17 +9,29 @@ namespace Schematron; /// Lacks attributes defined in Schematron, but not in use currently. public class Schema { - /// - /// The Schematron namespace. - /// - public const string Namespace = "http://www.ascc.net/xml/schematron"; + /// The ISO/IEC 19757-3 Schematron namespace (official, current standard). + public const string IsoNamespace = "http://purl.oclc.org/dsdl/schematron"; + + /// The legacy ASCC Schematron namespace. Supported for backward compatibility. + public const string LegacyNamespace = "http://www.ascc.net/xml/schematron"; + + /// The default Schematron namespace. Kept for backward compatibility; prefer . + public const string Namespace = LegacyNamespace; + + /// Returns if is a recognized Schematron namespace URI. + public static bool IsSchematronNamespace(string? uri) => + uri == IsoNamespace || uri == LegacyNamespace; SchemaLoader _loader; string _title = String.Empty; - + string _schematronEdition = String.Empty; string _defaultphase = String.Empty; + bool _isLibrary = false; PhaseCollection _phases = new PhaseCollection(); PatternCollection _patterns = new PatternCollection(); + LetCollection _lets = new LetCollection(); + DiagnosticCollection _diagnostics = new DiagnosticCollection(); + ParamCollection _params = new ParamCollection(); XmlNamespaceManager _ns = null!; /// @@ -119,6 +131,14 @@ public string Title set { _title = value; } } + /// Gets or sets the Schematron edition declared by the schema's @schematronEdition attribute. + /// A value of "2025" indicates ISO Schematron 4th edition. + public string SchematronEdition + { + get { return _schematronEdition; } + set { _schematronEdition = value; } + } + /// public PhaseCollection Phases { @@ -133,6 +153,22 @@ public PatternCollection Patterns set { _patterns = value; } } + /// Gets the variable bindings declared at the schema level (<let> elements). + public LetCollection Lets => _lets; + + /// Gets the diagnostic elements declared in the schema (<diagnostics>/<diagnostic>). + public DiagnosticCollection Diagnostics => _diagnostics; + + /// Gets the parameter declarations at the schema level (<param> elements). + public ParamCollection Params => _params; + + /// Gets or sets a value indicating whether this schema was loaded from a <library> root element (ISO Schematron 2025). + public bool IsLibrary + { + get { return _isLibrary; } + set { _isLibrary = value; } + } + /// public XmlNamespaceManager NsManager { diff --git a/src/Schematron/SchemaLoader.cs b/src/Schematron/SchemaLoader.cs index b715b3b..b0475d0 100644 --- a/src/Schematron/SchemaLoader.cs +++ b/src/Schematron/SchemaLoader.cs @@ -1,4 +1,5 @@ using System.Collections; +using System.IO; using System.Xml; using System.Xml.XPath; @@ -11,6 +12,27 @@ public class SchemaLoader XPathNavigator _filenav = null!; Hashtable? _abstracts = null; + // Detected Schematron namespace and the namespace manager derived from the source document. + string _schNs = null!; + XmlNamespaceManager _mgr = null!; + + // Instance-level XPath expressions compiled against the detected namespace. + XPathExpression _exprSchema = null!; + XPathExpression _exprEmbeddedSchema = null!; + XPathExpression _exprPhase = null!; + XPathExpression _exprPattern = null!; + XPathExpression _exprAbstractRule = null!; + XPathExpression _exprConcreteRule = null!; + XPathExpression _exprRuleExtends = null!; + XPathExpression _exprAssert = null!; + XPathExpression _exprReport = null!; + XPathExpression _exprLet = null!; + XPathExpression _exprDiagnostic = null!; + XPathExpression _exprParam = null!; + XPathExpression _exprLibrary = null!; + XPathExpression _exprRulesContainer = null!; + XPathExpression _exprGroup = null!; + /// public SchemaLoader(Schema schema) { @@ -22,9 +44,10 @@ public SchemaLoader(Schema schema) public virtual void LoadSchema(XPathNavigator source) { _schema.NsManager = new XmlNamespaceManager(source.NameTable); - //_schema.NsManager = new GotDotNet.Exslt.ExsltContext(source.NameTable); - XPathNodeIterator it = source.Select(CompiledExpressions.Schema); + DetectAndBuildExpressions(source); + + XPathNodeIterator it = source.Select(_exprSchema); if (it.Count > 1) throw new BadSchemaException("There can be at most one schema element per Schematron schema."); @@ -39,8 +62,19 @@ public virtual void LoadSchema(XPathNavigator source) } else { - // Load child elements from the appinfo element if it exists. - LoadSchemaElements(source.Select(CompiledExpressions.EmbeddedSchema)); + // Check for root element (ISO Schematron 2025) + XPathNodeIterator libIt = source.Select(_exprLibrary); + if (libIt.Count == 1) + { + libIt.MoveNext(); + _schema.IsLibrary = true; + LoadSchemaElement(libIt.Current); + } + else + { + // Load child elements from the appinfo element if it exists. + LoadSchemaElements(source.Select(_exprEmbeddedSchema)); + } } #region Loading process start @@ -50,21 +84,91 @@ public virtual void LoadSchema(XPathNavigator source) #endregion } + /// + /// Detects the Schematron namespace used in and compiles all + /// instance-level XPath expressions against that namespace. + /// + void DetectAndBuildExpressions(XPathNavigator source) + { + _schNs = DetectSchematronNamespace(source); + + _mgr = new XmlNamespaceManager(source.NameTable); + _mgr.AddNamespace("sch", _schNs); + _mgr.AddNamespace("xsd", System.Xml.Schema.XmlSchema.Namespace); + + _exprSchema = Compile("//sch:schema"); + _exprEmbeddedSchema = Compile("xsd:schema/xsd:annotation/xsd:appinfo/*"); + _exprPhase = Compile("descendant-or-self::sch:phase"); + _exprPattern = Compile("//sch:pattern"); + _exprAbstractRule = Compile("//sch:rule[@abstract=\"true\"]"); + _exprConcreteRule = Compile("descendant-or-self::sch:rule[not(@abstract) or @abstract=\"false\"]"); + _exprRuleExtends = Compile("descendant-or-self::sch:extends"); + _exprAssert = Compile("descendant-or-self::sch:assert"); + _exprReport = Compile("descendant-or-self::sch:report"); + _exprLet = Compile("sch:let"); + _exprDiagnostic = Compile("sch:diagnostics/sch:diagnostic"); + _exprParam = Compile("sch:param"); + _exprLibrary = Compile("//sch:library"); + _exprRulesContainer = Compile("//sch:rules/sch:rule"); + _exprGroup = Compile("//sch:group"); + } + + /// + /// Inspects and returns the Schematron namespace URI in use. + /// Checks the root element first, then descends into child elements (for embedded schemas). + /// Defaults to when no known namespace is found. + /// + static string DetectSchematronNamespace(XPathNavigator source) + { + var nav = source.Clone(); + nav.MoveToRoot(); + + if (nav.MoveToFirstChild()) + { + if (nav.NamespaceURI == Schema.IsoNamespace) return Schema.IsoNamespace; + if (nav.NamespaceURI == Schema.LegacyNamespace) return Schema.LegacyNamespace; + + // Not directly a Schematron document (e.g. embedded inside XSD); scan descendants. + var it = nav.SelectDescendants(XPathNodeType.Element, false); + while (it.MoveNext()) + { + if (it.Current.NamespaceURI == Schema.IsoNamespace) return Schema.IsoNamespace; + if (it.Current.NamespaceURI == Schema.LegacyNamespace) return Schema.LegacyNamespace; + } + } + + return Schema.IsoNamespace; + } + + XPathExpression Compile(string xpath) + { + var expr = Config.DefaultNavigator.Compile(xpath); + expr.SetContext(_mgr); + return expr; + } + void LoadSchemaElement(XPathNavigator context) { string phase = context.GetAttribute("defaultPhase", String.Empty); if (phase != String.Empty) _schema.DefaultPhase = phase; - //TODO: add all schema attributes in the future. + string edition = context.GetAttribute("schematronEdition", String.Empty); + if (edition != String.Empty) + _schema.SchematronEdition = edition; + LoadSchemaElements(context.SelectChildren(XPathNodeType.Element)); + LoadLets(_schema.Lets, context); + LoadDiagnostics(context); + LoadSchemaParams(context); + LoadExtendsHref(context); } void LoadSchemaElements(XPathNodeIterator children) { while (children.MoveNext()) { - if (children.Current.NamespaceURI == Schema.Namespace) + if (children.Current.NamespaceURI == _schNs) { if (children.Current.LocalName == "title") { @@ -83,10 +187,15 @@ void LoadSchemaElements(XPathNodeIterator children) void RetrieveAbstractRules() { _filenav.MoveToRoot(); - XPathNodeIterator it = _filenav.Select(CompiledExpressions.AbstractRule); - if (it.Count == 0) return; + XPathNodeIterator it = _filenav.Select(_exprAbstractRule); + + // Also check for rules inside containers (implicitly abstract) + _filenav.MoveToRoot(); + XPathNodeIterator rulesContainerIt = _filenav.Select(_exprRulesContainer); + + if (it.Count == 0 && rulesContainerIt.Count == 0) return; - _abstracts = new Hashtable(it.Count); + _abstracts = new Hashtable(it.Count + rulesContainerIt.Count); // Dummy pattern to use for rule creation purposes. // TODO: is there a better factory method implementation? @@ -101,17 +210,34 @@ void RetrieveAbstractRules() LoadReports(rule, it.Current); _abstracts.Add(rule.Id, rule); } + + // Also collect rules inside containers (implicitly abstract, even without @abstract="true") + while (rulesContainerIt.MoveNext()) + { + string ruleId = rulesContainerIt.Current.GetAttribute("id", String.Empty); + if (ruleId.Length == 0) continue; + if (_abstracts.ContainsKey(ruleId)) continue; + + Rule rule = pt.CreateRule(); + rule.SetContext(_schema.NsManager); + rule.Id = ruleId; + LoadAsserts(rule, rulesContainerIt.Current); + LoadReports(rule, rulesContainerIt.Current); + _abstracts.Add(rule.Id, rule); + } } void LoadPhases() { _filenav.MoveToRoot(); - XPathNodeIterator phases = _filenav.Select(CompiledExpressions.Phase); + XPathNodeIterator phases = _filenav.Select(_exprPhase); if (phases.Count == 0) return; while (phases.MoveNext()) { Phase ph = _schema.CreatePhase(phases.Current.GetAttribute("id", String.Empty)); + ph.From = phases.Current.GetAttribute("from", String.Empty); + ph.When = phases.Current.GetAttribute("when", String.Empty); _schema.Phases.Add(ph); } } @@ -119,19 +245,38 @@ void LoadPhases() void LoadPatterns() { _filenav.MoveToRoot(); - XPathNodeIterator patterns = _filenav.Select(CompiledExpressions.Pattern); + XPathNodeIterator patterns = _filenav.Select(_exprPattern); + _filenav.MoveToRoot(); + XPathNodeIterator groups = _filenav.Select(_exprGroup); - if (patterns.Count == 0) return; + if (patterns.Count == 0 && groups.Count == 0) return; // A special #ALL phase which contains all the patterns in the schema. Phase phase = _schema.CreatePhase(Phase.All); while (patterns.MoveNext()) { + // Skip abstract patterns — they are templates; only instantiated via @is-a. + bool isAbstract = patterns.Current.GetAttribute("abstract", String.Empty) == "true"; + if (isAbstract) continue; + Pattern pt = phase.CreatePattern(patterns.Current.GetAttribute("name", String.Empty), patterns.Current.GetAttribute("id", String.Empty)); - LoadRules(pt, patterns.Current); + LoadLets(pt.Lets, patterns.Current); + + string isA = patterns.Current.GetAttribute("is-a", String.Empty); + if (isA.Length > 0) + { + // Instantiate abstract pattern: collect param values, load rules from template. + var paramValues = LoadParams(patterns.Current); + LoadRulesFromAbstractPattern(pt, isA, paramValues); + } + else + { + LoadRules(pt, patterns.Current); + } + _schema.Patterns.Add(pt); phase.Patterns.Add(pt); @@ -139,11 +284,10 @@ void LoadPatterns() { // Select the phases in which this pattern is active, and add it // to its collection of patterns. - // This is the only dynamic expression in the process. // TODO: try to precompile this. Is it possible? XPathExpression expr = Config.DefaultNavigator.Compile( "//sch:phase[sch:active/@pattern=\"" + pt.Id + "\"]/@id"); - expr.SetContext(Config.DefaultNsManager); + expr.SetContext(_mgr); XPathNodeIterator phases = _filenav.Select(expr); while (phases.MoveNext()) @@ -153,12 +297,95 @@ void LoadPatterns() } } + // Load elements (ISO Schematron 2025) + while (groups.MoveNext()) + { + var grp = new Group( + groups.Current.GetAttribute("name", String.Empty), + groups.Current.GetAttribute("id", String.Empty)); + + LoadLets(grp.Lets, groups.Current); + LoadRules(grp, groups.Current); + _schema.Patterns.Add(grp); + phase.Patterns.Add(grp); + + if (grp.Id != String.Empty) + { + XPathExpression expr = Config.DefaultNavigator.Compile( + "//sch:phase[sch:active/@pattern=\"" + grp.Id + "\"]/@id"); + expr.SetContext(_mgr); + XPathNodeIterator phases = _filenav.Select(expr); + while (phases.MoveNext()) + _schema.Phases[phases.Current.Value].Patterns.Add(grp); + } + } + _schema.Phases.Add(phase); } + static Dictionary LoadParams(XPathNavigator context) + { + var d = new Dictionary(StringComparer.Ordinal); + XPathNodeIterator it = context.SelectChildren(XPathNodeType.Element); + while (it.MoveNext()) + { + if (it.Current.LocalName == "param") + { + string name = it.Current.GetAttribute("name", String.Empty); + string value = it.Current.GetAttribute("value", String.Empty); + if (name.Length > 0) + d[name] = value; + } + } + return d; + } + + void LoadRulesFromAbstractPattern(Pattern target, string abstractId, Dictionary paramValues) + { + // Find the abstract pattern node in the document. + XPathExpression expr = Config.DefaultNavigator.Compile( + "//sch:pattern[@abstract=\"true\" and @id=\"" + abstractId + "\"]"); + expr.SetContext(_mgr); + _filenav.MoveToRoot(); + XPathNodeIterator it = _filenav.Select(expr); + if (!it.MoveNext()) return; + + XPathNodeIterator rules = it.Current.Select(_exprConcreteRule); + while (rules.MoveNext()) + { + string ruleContext = SubstituteParams( + rules.Current.GetAttribute("context", String.Empty), paramValues); + + Rule rule = target.CreateRule(ruleContext); + rule.Id = rules.Current.GetAttribute("id", String.Empty); + rule.SetContext(_schema.NsManager); + LoadLets(rule.Lets, rules.Current); + + string ruleFlag = rules.Current.GetAttribute("flag", String.Empty); + rule.Flag = string.IsNullOrWhiteSpace(ruleFlag) + ? Array.Empty() + : ruleFlag.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries); + + rule.VisitEach = rules.Current.GetAttribute("visit-each", String.Empty); + + // Load asserts/reports with parameter substitution applied to test expressions. + LoadAssertsWithSubstitution(rule, rules.Current, paramValues); + LoadReportsWithSubstitution(rule, rules.Current, paramValues); + target.Rules.Add(rule); + } + } + + static string SubstituteParams(string text, Dictionary paramValues) + { + if (paramValues.Count == 0) return text; + foreach (var kv in paramValues) + text = text.Replace("$" + kv.Key, kv.Value); + return text; + } + void LoadRules(Pattern pattern, XPathNavigator context) { - XPathNodeIterator rules = context.Select(CompiledExpressions.ConcreteRule); + XPathNodeIterator rules = context.Select(_exprConcreteRule); if (rules.Count == 0) return; while (rules.MoveNext()) @@ -166,23 +393,49 @@ void LoadRules(Pattern pattern, XPathNavigator context) Rule rule = pattern.CreateRule(rules.Current.GetAttribute("context", String.Empty)); rule.Id = rules.Current.GetAttribute("id", String.Empty); rule.SetContext(_schema.NsManager); + LoadLets(rule.Lets, rules.Current); LoadExtends(rule, rules.Current); LoadAsserts(rule, rules.Current); LoadReports(rule, rules.Current); + + string ruleFlag = rules.Current.GetAttribute("flag", String.Empty); + rule.Flag = string.IsNullOrWhiteSpace(ruleFlag) + ? Array.Empty() + : ruleFlag.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries); + + rule.VisitEach = rules.Current.GetAttribute("visit-each", String.Empty); + pattern.Rules.Add(rule); } } + void LoadLets(LetCollection lets, XPathNavigator context) + { + XPathNodeIterator it = context.Select(_exprLet); + while (it.MoveNext()) + { + var let = new Let + { + Name = it.Current.GetAttribute("name", String.Empty), + Value = it.Current.GetAttribute("value", String.Empty), + As = it.Current.GetAttribute("as", String.Empty) is { Length: > 0 } a ? a : null, + }; + if (let.Value?.Length == 0) let.Value = null; + if (!lets.Contains(let.Name)) + lets.Add(let); + } + } + void LoadExtends(Rule rule, XPathNavigator context) { - XPathNodeIterator extends = context.Select(CompiledExpressions.RuleExtends); + XPathNodeIterator extends = context.Select(_exprRuleExtends); if (extends.Count == 0) return; while (extends.MoveNext()) { string ruleName = extends.Current.GetAttribute("rule", String.Empty); if (_abstracts != null && _abstracts.ContainsKey(ruleName)) - rule.Extend((Rule)_abstracts[ruleName]); + rule.Extend((Rule)_abstracts[ruleName]!); else throw new BadSchemaException("The abstract rule with id=\"" + ruleName + "\" is used but not defined."); } @@ -190,49 +443,184 @@ void LoadExtends(Rule rule, XPathNavigator context) void LoadAsserts(Rule rule, XPathNavigator context) { - XPathNodeIterator asserts = context.Select(CompiledExpressions.Assert); + XPathNodeIterator asserts = context.Select(_exprAssert); if (asserts.Count == 0) return; while (asserts.MoveNext()) { - if (asserts.Current is IHasXmlNode node) - { - Assert asr = rule.CreateAssert(asserts.Current.GetAttribute("test", String.Empty), - node.GetNode().InnerXml); - asr.SetContext(_schema.NsManager); - rule.Asserts.Add(asr); - } - else - { - Assert asr = rule.CreateAssert(asserts.Current.GetAttribute("test", String.Empty), - asserts.Current.Value); - asr.SetContext(_schema.NsManager); - rule.Asserts.Add(asr); - } + string testExpr = asserts.Current.GetAttribute("test", String.Empty); + string message = asserts.Current is IHasXmlNode node + ? node.GetNode().InnerXml + : asserts.Current.Value; + + Assert asr = rule.CreateAssert(testExpr, message); + asr.SetContext(_schema.NsManager); + ReadTestAttributes(asr, asserts.Current); + rule.Asserts.Add(asr); } } void LoadReports(Rule rule, XPathNavigator context) { - XPathNodeIterator reports = context.Select(CompiledExpressions.Report); + XPathNodeIterator reports = context.Select(_exprReport); if (reports.Count == 0) return; while (reports.MoveNext()) { - if (reports.Current is IHasXmlNode node) + string testExpr = reports.Current.GetAttribute("test", String.Empty); + string message = reports.Current is IHasXmlNode node + ? node.GetNode().InnerXml + : reports.Current.Value; + + Report rpt = rule.CreateReport(testExpr, message); + rpt.SetContext(_schema.NsManager); + ReadTestAttributes(rpt, reports.Current); + rule.Reports.Add(rpt); + } + } + + void LoadDiagnostics(XPathNavigator context) + { + XPathNodeIterator it = context.Select(_exprDiagnostic); + while (it.MoveNext()) + { + string id = it.Current.GetAttribute("id", String.Empty); + if (id.Length == 0) continue; + string msg = it.Current is IHasXmlNode node + ? node.GetNode().InnerXml + : it.Current.Value; + if (!_schema.Diagnostics.Contains(id)) + _schema.Diagnostics.Add(new Diagnostic { Id = id, Message = msg }); + } + } + + void LoadAssertsWithSubstitution(Rule rule, XPathNavigator context, Dictionary paramValues) + { + XPathNodeIterator asserts = context.Select(_exprAssert); + if (asserts.Count == 0) return; + + while (asserts.MoveNext()) + { + string testExpr = SubstituteParams( + asserts.Current.GetAttribute("test", String.Empty), paramValues); + string message = asserts.Current is IHasXmlNode node + ? node.GetNode().InnerXml + : asserts.Current.Value; + message = SubstituteParams(message, paramValues); + + Assert asr = rule.CreateAssert(testExpr, message); + asr.SetContext(_schema.NsManager); + ReadTestAttributes(asr, asserts.Current); + rule.Asserts.Add(asr); + } + } + + void LoadReportsWithSubstitution(Rule rule, XPathNavigator context, Dictionary paramValues) + { + XPathNodeIterator reports = context.Select(_exprReport); + if (reports.Count == 0) return; + + while (reports.MoveNext()) + { + string testExpr = SubstituteParams( + reports.Current.GetAttribute("test", String.Empty), paramValues); + string message = reports.Current is IHasXmlNode node + ? node.GetNode().InnerXml + : reports.Current.Value; + message = SubstituteParams(message, paramValues); + + Report rpt = rule.CreateReport(testExpr, message); + rpt.SetContext(_schema.NsManager); + ReadTestAttributes(rpt, reports.Current); + rule.Reports.Add(rpt); + } + } + + static void ReadTestAttributes(Test test, XPathNavigator nav) + { + test.Id = nav.GetAttribute("id", String.Empty); + test.Role = nav.GetAttribute("role", String.Empty); + test.Severity = nav.GetAttribute("severity", String.Empty); + + string flag = nav.GetAttribute("flag", String.Empty); + test.Flag = string.IsNullOrWhiteSpace(flag) + ? Array.Empty() + : flag.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries); + + string diagnostics = nav.GetAttribute("diagnostics", String.Empty); + test.DiagnosticRefs = string.IsNullOrWhiteSpace(diagnostics) + ? Array.Empty() + : diagnostics.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries); + } + + void LoadSchemaParams(XPathNavigator context) + { + XPathNodeIterator it = context.SelectChildren(XPathNodeType.Element); + while (it.MoveNext()) + { + if (it.Current.LocalName == "param" && Schema.IsSchematronNamespace(it.Current.NamespaceURI)) { - Report rpt = rule.CreateReport(reports.Current.GetAttribute("test", String.Empty), - node.GetNode().InnerXml); - rpt.SetContext(_schema.NsManager); - rule.Reports.Add(rpt); + string name = it.Current.GetAttribute("name", String.Empty); + string value = it.Current.GetAttribute("value", String.Empty); + if (name.Length > 0 && !_schema.Params.Contains(name)) + { + _schema.Params.Add(new Param { Name = name, Value = value }); + // Also expose as a schema-level let so they're available as variables + if (!_schema.Lets.Contains(name)) + _schema.Lets.Add(new Let { Name = name, Value = value }); + } + } + } + } + + void LoadExtendsHref(XPathNavigator context) + { + XPathNodeIterator children = context.SelectChildren(XPathNodeType.Element); + while (children.MoveNext()) + { + if (children.Current.LocalName != "extends") continue; + if (!Schema.IsSchematronNamespace(children.Current.NamespaceURI)) continue; + string href = children.Current.GetAttribute("href", String.Empty); + if (string.IsNullOrEmpty(href)) continue; + + // Resolve the href relative to the schema's base URI + string resolvedPath; + string baseUri = context.BaseURI; + if (!string.IsNullOrEmpty(baseUri)) + { + try + { + Uri resolved = new Uri(new Uri(baseUri), href); + resolvedPath = resolved.LocalPath; + } + catch + { + resolvedPath = href; + } } else { - Report rpt = rule.CreateReport(reports.Current.GetAttribute("test", String.Empty), - reports.Current.Value); - rpt.SetContext(_schema.NsManager); - rule.Reports.Add(rpt); + resolvedPath = href; + } + + if (!File.Exists(resolvedPath)) continue; + + try + { + var extSchema = new Schema(); + extSchema.Load(resolvedPath); + + // Merge diagnostics + foreach (Diagnostic d in extSchema.Diagnostics) + if (!_schema.Diagnostics.Contains(d.Id)) + _schema.Diagnostics.Add(d); + + // Merge schema-level lets + foreach (Let let in extSchema.Lets) + if (!_schema.Lets.Contains(let.Name)) + _schema.Lets.Add(let); } + catch { /* skip extends that can't be loaded */ } } } } diff --git a/src/Schematron/SchematronXsltContext.cs b/src/Schematron/SchematronXsltContext.cs new file mode 100644 index 0000000..e2fbce6 --- /dev/null +++ b/src/Schematron/SchematronXsltContext.cs @@ -0,0 +1,132 @@ +using System.Xml; +using System.Xml.XPath; +using System.Xml.Xsl; + +namespace Schematron; + +/// +/// An that resolves XPath variable references from Schematron +/// <let> declarations. Variables are evaluated lazily against a context node. +/// +sealed class SchematronXsltContext : XsltContext +{ + readonly Dictionary _variables; + readonly XPathNavigator? _contextNode; + + /// + /// Thread-local ambient context for the current Schematron evaluation. Used by + /// to resolve variables inside + /// <value-of> message expressions without changing the formatter API. + /// + [ThreadStatic] + internal static SchematronXsltContext? Current; + + /// + /// Creates a load-time (stub) context for schema loading. Variables return empty strings. + /// Useful for passing to when the + /// expression contains variable references but actual values are not yet available. + /// + public static SchematronXsltContext ForLoading(XmlNamespaceManager nsManager) + => new SchematronXsltContext(new Dictionary(), null, nsManager); + + /// + /// Initialises a new instance from accumulated <let> declarations, + /// evaluated in the order schema → pattern → rule (inner declarations shadow outer ones). + /// + public SchematronXsltContext( + Dictionary variables, + XPathNavigator? contextNode, + XmlNamespaceManager nsManager) + : base(new System.Xml.NameTable()) + { + _variables = variables; + _contextNode = contextNode; + + // Copy prefix→namespace mappings so the context can resolve namespace prefixes used + // in variable-value XPath expressions. + foreach (string prefix in nsManager) + { + if (string.IsNullOrEmpty(prefix)) continue; + // "xml" and "xmlns" are reserved and cannot be re-added. + if (prefix == "xml" || prefix == "xmlns") continue; + string ns = nsManager.LookupNamespace(prefix) ?? String.Empty; + if (ns.Length > 0) + { + try { AddNamespace(prefix, ns); } + catch (ArgumentException) { /* reserved or invalid – skip */ } + } + } + } + + /// + public override bool Whitespace => false; + + /// + public override bool PreserveWhitespace(XPathNavigator node) => false; + + /// + public override int CompareDocument(string baseUri, string nextbaseUri) => 0; + + /// + public override IXsltContextFunction ResolveFunction(string prefix, string name, XPathResultType[] argTypes) + => throw new NotSupportedException($"External function '{name}' is not supported."); + + /// + public override IXsltContextVariable ResolveVariable(string prefix, string name) + { + if (_variables.TryGetValue(name, out string? expr)) + return new LetVariable(name, expr, _contextNode); + + // Return an empty-string variable for undefined references rather than throwing, + // so that schemas with forward-declared variables do not crash during evaluation. + return new LetVariable(name, "", _contextNode); + } + + // ------------------------------------------------------------------------- + + /// + /// Returns the raw string value for a variable by name, or if not found. + /// + public string? GetVariableValue(string name) + { + if (_variables.TryGetValue(name, out var val)) return val; + return null; + } + + // ------------------------------------------------------------------------- + + sealed class LetVariable : IXsltContextVariable + { + readonly string _name; + readonly string _expr; + readonly XPathNavigator? _context; + + public LetVariable(string name, string expr, XPathNavigator? context) + { + _name = name; + _expr = expr; + _context = context; + } + + public bool IsLocal => true; + public bool IsParam => false; + public XPathResultType VariableType => XPathResultType.Any; + + public object Evaluate(XsltContext xsltContext) + { + if (string.IsNullOrEmpty(_expr) || _context is null) + return String.Empty; + + try + { + XPathExpression compiled = _context.Compile(_expr); + compiled.SetContext(xsltContext); + return _context.Evaluate(compiled); + } + catch + { + return String.Empty; + } + } + } +} diff --git a/src/Schematron/SyncEvaluationContext.cs b/src/Schematron/SyncEvaluationContext.cs index 44e47a9..9fe3256 100644 --- a/src/Schematron/SyncEvaluationContext.cs +++ b/src/Schematron/SyncEvaluationContext.cs @@ -69,6 +69,25 @@ bool Evaluate(Phase phase, StringBuilder output) Source.MoveToRoot(); var sb = new StringBuilder(); + // 2025: @when condition — if false, skip this phase entirely + if (!string.IsNullOrEmpty(phase.When)) + { + try + { + var whenExpr = Source.Compile(phase.When); + whenExpr.SetContext(SchematronXsltContext.ForLoading(Schema.NsManager)); + object whenResult = Source.Evaluate(whenExpr); + bool whenBool = whenResult switch + { + bool b => b, + string s => !string.IsNullOrEmpty(s), + _ => true + }; + if (!whenBool) return false; + } + catch { /* if @when evaluation fails, proceed */ } + } + foreach (Pattern pt in phase.Patterns) { if (Evaluate(pt, sb)) failed = true; @@ -107,12 +126,14 @@ bool Evaluate(Pattern pattern, StringBuilder output) // Reset matched nodes, as across patters, nodes can be // evaluated more than once. Matched.Clear(); + bool isGroup = pattern is Group; foreach (Rule rule in pattern.Rules) { - if (Evaluate(rule, sb)) failed = true; + // For groups (ISO Schematron 2025), each rule evaluates independently. + if (isGroup) Matched.Clear(); + if (Evaluate(rule, sb, BuildLets(pattern.Lets))) failed = true; } - if (failed) { Formatter.Format(pattern, Source, sb); @@ -154,7 +175,7 @@ bool Evaluate(Pattern pattern, StringBuilder output) /// /// The rule to evaluate is abstract (see ). /// - bool Evaluate(Rule rule, StringBuilder output) + bool Evaluate(Rule rule, StringBuilder output, Dictionary? patternLets = null) { if (rule.IsAbstract) throw new InvalidOperationException("The Rule is abstract, so it can't be evaluated."); @@ -178,11 +199,30 @@ bool Evaluate(Rule rule, StringBuilder output) } } + // 2025: @visit-each — for each matched context node, select secondary nodes to test + if (!string.IsNullOrEmpty(rule.VisitEach)) + { + try + { + var visitExpr = Source.Compile(rule.VisitEach); + visitExpr.SetContext(Schema.NsManager); + var expanded = new ArrayList(); + foreach (XPathNavigator contextNode in evaluables) + { + XPathNodeIterator visitNodes = contextNode.Select(visitExpr); + while (visitNodes.MoveNext()) + expanded.Add(visitNodes.Current.Clone()); + } + evaluables = expanded; + } + catch { /* fall back to original evaluables on error */ } + } + foreach (Assert asr in rule.Asserts) { foreach (XPathNavigator node in evaluables) { - if (EvaluateAssert(asr, node.Clone(), sb)) failed = true; + if (EvaluateAssert(asr, node.Clone(), sb, patternLets, rule.Lets)) failed = true; } } @@ -190,7 +230,7 @@ bool Evaluate(Rule rule, StringBuilder output) { foreach (XPathNavigator node in evaluables) { - if (EvaluateReport(rpt, node.Clone(), sb)) failed = true; + if (EvaluateReport(rpt, node.Clone(), sb, patternLets, rule.Lets)) failed = true; } } @@ -215,9 +255,11 @@ bool Evaluate(Rule rule, StringBuilder output) /// The context node for the execution. /// Contains the builder to accumulate messages in. /// A boolean indicating if a new message was added. - bool EvaluateAssert(Assert assert, XPathNavigator context, StringBuilder output) + bool EvaluateAssert(Assert assert, XPathNavigator context, StringBuilder output, + Dictionary? patternLets = null, LetCollection? ruleLets = null) { - object eval = context.Evaluate(assert.CompiledExpression); + var expr = PrepareExpression(assert.CompiledExpression, context, patternLets, ruleLets, out var xsltCtx); + object eval = context.Evaluate(expr); bool result = true; if (assert.ReturnType == XPathResultType.Boolean) @@ -230,7 +272,12 @@ bool EvaluateAssert(Assert assert, XPathNavigator context, StringBuilder output) result = false; } - if (!result) Formatter.Format(assert, context, output); + if (!result) + { + SchematronXsltContext.Current = xsltCtx; + try { Formatter.Format(assert, context, output); } + finally { SchematronXsltContext.Current = null; } + } return !result; } @@ -246,9 +293,11 @@ bool EvaluateAssert(Assert assert, XPathNavigator context, StringBuilder output) /// The context node for the execution. /// Contains the builder to accumulate messages in. /// A boolean indicating if a new message was added. - bool EvaluateReport(Report report, XPathNavigator context, StringBuilder output) + bool EvaluateReport(Report report, XPathNavigator context, StringBuilder output, + Dictionary? patternLets = null, LetCollection? ruleLets = null) { - object eval = context.Evaluate(report.CompiledExpression); + var expr = PrepareExpression(report.CompiledExpression, context, patternLets, ruleLets, out var xsltCtx); + object eval = context.Evaluate(expr); bool result = false; if (report.ReturnType == XPathResultType.Boolean) @@ -261,8 +310,55 @@ bool EvaluateReport(Report report, XPathNavigator context, StringBuilder output) result = true; } - if (result) Formatter.Format(report, context, output); + if (result) + { + SchematronXsltContext.Current = xsltCtx; + try { Formatter.Format(report, context, output); } + finally { SchematronXsltContext.Current = null; } + } return result; } + + // ------------------------------------------------------------------------- + // Helper: build a merged variable dictionary (schema → pattern → rule scope). + // ------------------------------------------------------------------------- + + Dictionary BuildLets(LetCollection? extraLets = null) + { + var d = new Dictionary(StringComparer.Ordinal); + foreach (Let let in Schema.Lets) + if (let.Value is not null) d[let.Name] = let.Value; + if (extraLets != null) + foreach (Let let in extraLets) + if (let.Value is not null) d[let.Name] = let.Value; + return d; + } + + // Prepares an XPathExpression to be evaluated with variable support. + // When no variables are in scope this is a no-op (returns the original expression). + XPathExpression PrepareExpression( + XPathExpression expr, + XPathNavigator context, + Dictionary? patternLets, + LetCollection? ruleLets, + out SchematronXsltContext? xsltCtx) + { + bool hasVars = (Schema.Lets.Count + (patternLets?.Count ?? 0) + (ruleLets?.Count ?? 0)) > 0; + if (!hasVars) { xsltCtx = null; return expr; } + + var vars = new Dictionary(StringComparer.Ordinal); + foreach (Let let in Schema.Lets) + if (let.Value is not null) vars[let.Name] = let.Value; + if (patternLets != null) + foreach (var kv in patternLets) vars[kv.Key] = kv.Value; + if (ruleLets != null) + foreach (Let let in ruleLets) + if (let.Value is not null) vars[let.Name] = let.Value; + + xsltCtx = new SchematronXsltContext(vars, context, Schema.NsManager); + var clone = expr.Clone(); + clone.SetContext(xsltCtx); + return clone; + } } diff --git a/src/Schematron/TagExpressions.cs b/src/Schematron/TagExpressions.cs index 1e7cc6e..83c7a8a 100644 --- a/src/Schematron/TagExpressions.cs +++ b/src/Schematron/TagExpressions.cs @@ -32,7 +32,8 @@ static TagExpressions() Any = new Regex(@"<[^\s]*[^>]*>", RegexOptions.Compiled); // Closing elements don't have an expanded xmlns so they will be matched too. // TODO: improve this to avoid removing non-schematron closing elements. - AllSchematron = new Regex(@"<.*\bxmlns\b[^\s]*" + Schema.Namespace + "[^>]*>|]*>", RegexOptions.Compiled); + string nsPattern = "(?:" + Regex.Escape(Schema.LegacyNamespace) + "|" + Regex.Escape(Schema.IsoNamespace) + ")"; + AllSchematron = new Regex(@"<.*\bxmlns\b[^\s]*" + nsPattern + "[^>]*>|]*>", RegexOptions.Compiled); } TagExpressions() diff --git a/src/Schematron/Test.cs b/src/Schematron/Test.cs index a833323..d1b7fda 100644 --- a/src/Schematron/Test.cs +++ b/src/Schematron/Test.cs @@ -1,4 +1,4 @@ -using System.Text.RegularExpressions; +using System.Text.RegularExpressions; using System.Xml.XPath; namespace Schematron; @@ -33,7 +33,7 @@ public Test(string test, string message) : base(test) // Save and tags in the message and their paths / selects in their compiled form. // TODO: see if we can work with the XML in the message, instead of using RE. - // TODO: Check the correct usahe of path and select attributes. + // TODO: Check the correct usage of path and select attributes. _name_valueofs = TagExpressions.NameValueOf.Matches(Message); int nc = _name_valueofs.Count; _paths = new XPathExpression[nc]; @@ -41,28 +41,21 @@ public Test(string test, string message) : base(test) for (int i = 0; i < nc; i++) { - // Locate the path attribute and compile it with the DefaultNavigator. Match name_valueof = _name_valueofs[i]; int start = name_valueof.Value.IndexOf("path="); - // Does it have a path attribute? if (start > 0) { - // Skip the path=" string. start += 6; - // If the namespace element is present, end the expression there. int end = name_valueof.Value.LastIndexOf("xmlns") - 2; if (end < 0) end = name_valueof.Value.LastIndexOf('"'); string xpath = name_valueof.Value.Substring(start, end - start); _paths[i] = Config.DefaultNavigator.Compile(xpath); _selects[i] = null; - } else if ((start = name_valueof.Value.IndexOf("select=")) > 0) { - // Skip the select=" string. start += 8; - // If the namespace element is present, end the expression there. int end = name_valueof.Value.LastIndexOf("xmlns") - 2; if (end < 0) end = name_valueof.Value.LastIndexOf('"'); @@ -85,6 +78,24 @@ public string Message set { _msg = value; } } + /// Gets or sets the optional identifier for this test (@id attribute). + public string Id { get; set; } = String.Empty; + + /// Gets or sets the role of this test (@role attribute). + public string Role { get; set; } = String.Empty; + + /// + /// Gets or sets the flag tokens for this test (@flag attribute). + /// In ISO Schematron 2025 @flag is a whitespace-separated list of tokens. + /// + public IReadOnlyList Flag { get; set; } = Array.Empty(); + + /// Gets or sets the severity of this test (@severity attribute). + public string Severity { get; set; } = String.Empty; + + /// Gets or sets the diagnostic IDs referenced by this test (@diagnostics attribute). + public IReadOnlyList DiagnosticRefs { get; set; } = Array.Empty(); + /// public MatchCollection NameValueOfExpressions { @@ -102,5 +113,4 @@ public XPathExpression?[] ValueOfSelects { get { return _selects; } } -} - +} \ No newline at end of file diff --git a/src/Schematron/Validator.cs b/src/Schematron/Validator.cs index 801899e..1e3e243 100644 --- a/src/Schematron/Validator.cs +++ b/src/Schematron/Validator.cs @@ -233,7 +233,7 @@ public void AddSchema(XmlReader reader) if (reader.MoveToContent() == XmlNodeType.None) throw new BadSchemaException("No information found to read"); // Determine type of schema received. - bool standalone = (reader.NamespaceURI == Schema.Namespace); + bool standalone = Schema.IsSchematronNamespace(reader.NamespaceURI); bool wxs = (reader.NamespaceURI == XmlSchema.Namespace); // The whole schema must be read first to preserve the state for later. @@ -332,7 +332,7 @@ bool TryAddXmlSchema( return true; } - bool IsStandaloneSchematron(string? namespaceUri) => namespaceUri == Schema.Namespace; + bool IsStandaloneSchematron(string? namespaceUri) => Schema.IsSchematronNamespace(namespaceUri); bool IsStandardSchema(string? namespaceUri) => namespaceUri == XmlSchema.Namespace;