diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml new file mode 100644 index 0000000..0e12268 --- /dev/null +++ b/.github/workflows/php.yml @@ -0,0 +1,43 @@ +name: validate and test + +on: + push: + branches: [ "master" ] + pull_request: + branches: [ "master" ] + +permissions: + contents: read + +jobs: + build: + runs-on: ubuntu-latest + + strategy: + matrix: + php-versions: ['8.0', '8.1' , '8.2' , '8.3'] + + steps: + - uses: actions/checkout@v3 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php-versions }} + extensions: libxml, xml, dom + + - name: Cache Composer packages + id: composer-cache + uses: actions/cache@v3 + with: + path: vendor + key: ${{ runner.os }}-php-${{ hashFiles('**/composer.lock') }} + restore-keys: | + ${{ runner.os }}-php- + + - name: Install dependencies + run: composer install --no-interaction --dev + + # -C to make sure dom extension is loaded + - name: Run test suite + run: php vendor/bin/tester -C -s tests diff --git a/composer.json b/composer.json index deaa659..835de80 100644 --- a/composer.json +++ b/composer.json @@ -14,9 +14,12 @@ "ext-dom": "*" }, "require-dev": { - "nette/tester": "~1.1.0" + "nette/tester": "^2.5" }, "autoload": { "classmap": ["src/Schematron.php"] - } + }, + "scripts": { + "test": "vendor/bin/tester -s tests" + } } diff --git a/src/Schematron.php b/src/Schematron.php index d4a725c..56c358d 100644 --- a/src/Schematron.php +++ b/src/Schematron.php @@ -7,7 +7,6 @@ use DOMDocument, DOMElement, DOMNode, - DOMNodeList, DOMXPath; use ErrorException, @@ -164,6 +163,9 @@ class Schematron /** @var array[id => value] {@see self::findPhases()} */ protected $phases = array(); + /* var array list of opened external DOMDOCUMENT and Xpath (to support document() in xpath ) */ + protected $externals = []; + @@ -280,9 +282,47 @@ public function validate(DOMDocument $doc, $result = self::RESULT_SIMPLE, $phase $pattern = $this->patterns[$patternKey]; foreach ($pattern->rules as $ruleKey => $rule) { foreach ($xpath->queryContext($rule->context, $doc) as $currentNode) { + $lets = []; + if ($rule->lets) { + foreach ($rule->lets as $name => $value) { + $let = $xpath->evaluate("string($value)", $currentNode); + // Adding quotes is necessary to be able search strings + // TODO : maybe escape? + $lets[$name] = is_numeric($let) ? $let : "'$let'"; + } + } foreach ($rule->statements as $statement) { - if ($statement->isAssert ^ $xpath->evaluate("boolean($statement->test)", $currentNode)) { - $message = $this->statementToMessage($statement->node, $xpath, $currentNode); + $testStatement = $statement->test ; + $nodeToEval = $currentNode; + $xpathToEval = $xpath; + if ($lets) { + $testStatement = call_user_func($this->getReplaceCb(), $testStatement, $lets); + } + // Added support to evaluate document() + // Maybe it should move to SchematronXPath, but we would have to deal with paths + // as neither DOMDocument or XPATH holds information about the file, so is it really worth the trouble? + $parts = explode('//', $testStatement); + if (count($parts) == 2) { + if ($parts[0]) { + if (strpos($parts[0], 'document(') == 0) { + $file = substr($parts[0], 10, -2); + if (!isset($this->externals[$file])) { + $external = new DOMDocument(); + $external->load($this->directory.DIRECTORY_SEPARATOR.$file); + $this->externals[$file] = [ + 'node' => $external, + 'xpath' => new DOMXPath($external) + ]; + } + $nodeToEval = $this->externals[$file]['node']; + $xpathToEval = $this->externals[$file]['xpath']; + $testStatement = "//".$parts[1]; + } + } + + } + if ($statement->isAssert ^ $xpathToEval->evaluate("boolean($testStatement)", $nodeToEval)) { + $message = $this->statementToMessage($statement->node, $xpath, $currentNode, $lets); switch ($result) { case self::RESULT_EXCEPTION: @@ -740,6 +780,7 @@ protected function findRules(DOMElement $pattern) $rules[] = (object) array( 'context' => $context, + 'lets' => $this->findLets($element), 'statements' => $statements = $this->findStatements($element, $abstracts), ); @@ -849,13 +890,28 @@ protected function findActives(DOMElement $phase) return $actives; } + /** + * Search for all . + * @return array + */ + protected function findLets(DOMElement $rule) + { + $variables = array(); + foreach ($this->xPath->query('sch:let', $rule) as $node) { + $name = Helpers::getAttribute($node, 'name'); + $value = Helpers::getAttribute($node, 'value'); + $variables[$name] = $value; + } + return $variables; + } + /** * Expands and in assertion/report message. * @return string */ - protected function statementToMessage(DOMElement $stmt, SchematronXPath $xPath, DOMNode $current) + protected function statementToMessage(DOMElement $stmt, SchematronXPath $xPath, DOMNode $current, $lets = array()) { $message = ''; foreach ($stmt->childNodes as $node) { @@ -864,7 +920,11 @@ protected function statementToMessage(DOMElement $stmt, SchematronXPath $xPath, $message .= $xPath->evaluate('name(' . Helpers::getAttribute($node, 'path', '') . ')', $current); } elseif ($node->localName === 'value-of') { - $message .= $xPath->evaluate('string(' . Helpers::getAttribute($node, 'select') . ')', $current); + $selected = Helpers::getAttribute($node, 'select'); + if ($lets){ + $selected = call_user_func($this->getReplaceCb(),$selected, $lets) ; + } + $message .= $xPath->evaluate('string(' . $selected . ')', $current); } else { /** @todo warning? */ @@ -999,7 +1059,7 @@ class SchematronXPath extends DOMXPath /** * ($registerNodeNS is FALSE in opposition to DOMXPath default value) */ - public function query($expression, DOMNode $context = NULL, $registerNodeNS = FALSE) + public function query($expression, DOMNode $context = NULL, $registerNodeNS = FALSE): mixed { return parent::query($expression, $context, $registerNodeNS); } @@ -1009,14 +1069,14 @@ public function query($expression, DOMNode $context = NULL, $registerNodeNS = FA /** * ($registerNodeNS is FALSE in opposition to DOMXPath default value) */ - public function evaluate($expression, DOMNode $context = NULL, $registerNodeNS = FALSE) + public function evaluate($expression, DOMNode $context = NULL, $registerNodeNS = FALSE): mixed { return parent::evaluate($expression, $context, $registerNodeNS); } - public function queryContext($expression, DOMNode $context = NULL, $registerNodeNS = FALSE) + public function queryContext($expression, DOMNode $context = NULL, $registerNodeNS = FALSE): mixed { if (isset($expression[0]) && $expression[0] !== '.' && $expression[0] !== '/') { $expression = "//$expression"; diff --git a/tests/Schematron.validate.phpt b/tests/Schematron.validate.phpt index e1a4a1f..e28c67c 100644 --- a/tests/Schematron.validate.phpt +++ b/tests/Schematron.validate.phpt @@ -40,16 +40,19 @@ $simple = array( 'S13 - fail - milo', 'S14 - fail - name', 'S15 - fail', + 'S17 - fail', + 'S19 - fail', ); Assert::same($simple, $sch->validate($doc)); # RESULT_COMPLEX $complex = $sch->validate($doc, $sch::RESULT_COMPLEX); -Assert::same(count($complex), 3); +Assert::same(count($complex), 4); Assert::true(isset($complex['#p1']->rules[0]->errors[0]->message)); Assert::same($complex['#p1']->rules[0]->errors[0]->message, 'S15 - fail'); Assert::same(reset($complex)->title, 'Pattern 1'); +Assert::true(isset($complex['#let']->rules[0]->errors[0]->message)); # RESULT_EXCEPTION diff --git a/tests/resources/external-document.xml b/tests/resources/external-document.xml new file mode 100644 index 0000000..83e0a5c --- /dev/null +++ b/tests/resources/external-document.xml @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/tests/resources/validate-schema.xml b/tests/resources/validate-schema.xml index e4217e2..4d4a854 100644 --- a/tests/resources/validate-schema.xml +++ b/tests/resources/validate-schema.xml @@ -67,4 +67,15 @@ + + + + S16 - pass + + S17 - fail + S18 - pass + S19 - fail + + +