diff --git a/docs/reader.md b/docs/reader.md index ae72ec64..cf52421c 100644 --- a/docs/reader.md +++ b/docs/reader.md @@ -304,6 +304,66 @@ use \VeeWee\Xml\Reader\Matcher; Matcher\namespaced_element('https://some', 'item'); ``` +#### nested + +Provide nested matchers that represents parts of an XML tree. +Every provided matcher acts as a breakpoint for the next matcher, making it composable with the exact XML tree [sequence](#sequence) matcher as well. + +Given: + +```xml + + + Jos + Bos + Mos + + +``` + +This matcher will grab the `user` element with `locale="nl"` + +```php +use \VeeWee\Xml\Reader\Matcher; + +Matcher\nested( + // Breakpoint 1: + Matcher\document_element(), + // Breakpoint 2: Jos + // Searches for all elements that matches `` and attribute `locale="nl"` in the `` document. + // Note that you can skip matching on `` here : it's not an exact matcher + Matcher\all( + Matcher\element_name('user'), + Matcher\attribute_value('locale', 'nl') + ) +); +``` + +Since every match will create a breakpoint in the `NodeSequence`, you can combine it with the sequence matcher: + +```php +use \VeeWee\Xml\Reader\Matcher; + +Matcher\nested( + // Breakpoint 1: + Matcher\document_element(), + // Breakpoint 2: + // The nested matcher will provide the NodeSequence starting from the element after previous match. + // The sequence will basically receive: 'users > user' + Matcher\sequence( + // Level 0: The element inside at level 0 must exactly match + Matcher\element_name('users'), + // Level 1: The element inside at level 1 must exactly match + Matcher\element_name('user'), + ), + // Breakpoint 3: + // After matching a sequence, you can still continue matching deeper or adding even more sequences: + Matcher\element_name('email') +); +``` + +If you want every level of the XML to match exactly, you might use the [sequence](#sequence) matcher instead. + #### not Inverses a matcher's result. @@ -318,8 +378,9 @@ Matcher\not( #### sequence -Provide a sequence of matchers that represents the XML tree. -Only the items that are described by the sequence will match. +Provide a sequence of matchers that represents the exact XML tree. +Every provided matcher step must result in an exact match with the matcher on the same index. +Only the items that are described by the sequence will match: Given: @@ -352,6 +413,8 @@ Matcher\sequence( ); ``` +If you don't want every level of XML to match exactly, you might use the [nested](#nested) matcher instead. + #### Writing your own matcher diff --git a/src/Xml/Reader/Matcher/nested.php b/src/Xml/Reader/Matcher/nested.php new file mode 100644 index 00000000..a9be299b --- /dev/null +++ b/src/Xml/Reader/Matcher/nested.php @@ -0,0 +1,63 @@ + $matchers + * + * @return \Closure(NodeSequence): bool + */ +function nested(callable ... $matchers): Closure +{ + return static function (NodeSequence $sequence) use ($matchers) : bool { + $lastMatchedAtIndex = -1; + $currentMatcher = array_shift($matchers); + if (!$currentMatcher) { + return false; + } + + $stepCount = $sequence->count(); + foreach ($sequence->replay() as $index => $step) { + // Slice the step NodeSequence based on previous "match" breakpoint + // and see if it matches on current matcher: + $step = $step->slice($lastMatchedAtIndex + 1); + if (!$currentMatcher($step)) { + continue; + } + + // If there was a match, select the next matcher and store the last matched NodeSequence index. + $currentMatcher = array_shift($matchers); + $lastMatchedAtIndex = $index; + + // If the list of matchers is empty + // The function will return true if the element is the last step in the complete sequence. + // Otherwise, the nested match has an even deeper element on which we don't wish to match. + if (!$currentMatcher) { + $isLastStep = $index === $stepCount - 1; + + return $isLastStep; + } + } + + return false; + }; +} diff --git a/src/Xml/Reader/Matcher/not.php b/src/Xml/Reader/Matcher/not.php index e0673051..c268c18a 100644 --- a/src/Xml/Reader/Matcher/not.php +++ b/src/Xml/Reader/Matcher/not.php @@ -8,7 +8,7 @@ use VeeWee\Xml\Reader\Node\NodeSequence; /** - * @param callable(NodeSequence) $matcher + * @param callable(NodeSequence): bool $matcher * * @return \Closure(NodeSequence): bool */ diff --git a/src/Xml/Reader/Matcher/sequence.php b/src/Xml/Reader/Matcher/sequence.php index 4fe6e3d2..79284db0 100644 --- a/src/Xml/Reader/Matcher/sequence.php +++ b/src/Xml/Reader/Matcher/sequence.php @@ -22,15 +22,12 @@ function sequence(callable ... $matcherSequence): Closure { return static function (NodeSequence $sequence) use ($matcherSequence) : bool { - $nodeSequence = $sequence->sequence(); - if (count($matcherSequence) !== count($nodeSequence)) { + if (count($matcherSequence) !== $sequence->count()) { return false; } - $currentSequence = new NodeSequence(); - foreach ($nodeSequence as $i => $node) { - $currentSequence = $currentSequence->append($node); - $matcher = $matcherSequence[$i]; + foreach ($sequence->replay() as $index => $currentSequence) { + $matcher = $matcherSequence[$index]; if (!$matcher($currentSequence)) { return false; } diff --git a/src/Xml/Reader/Node/NodeSequence.php b/src/Xml/Reader/Node/NodeSequence.php index 3d2cb01f..924bd7ee 100644 --- a/src/Xml/Reader/Node/NodeSequence.php +++ b/src/Xml/Reader/Node/NodeSequence.php @@ -4,10 +4,13 @@ namespace VeeWee\Xml\Reader\Node; +use Countable; +use Generator; use InvalidArgumentException; use Webmozart\Assert\Assert; +use function Psl\Vec\slice; -final class NodeSequence +final class NodeSequence implements Countable { /** * @var list @@ -64,6 +67,34 @@ public function sequence(): array return $this->elementNodes; } + public function count(): int + { + return \count($this->elementNodes); + } + + /** + * @param non-negative-int $start + * @param non-negative-int|null $length + */ + public function slice(int $start, ?int $length = null): self + { + return new self(...slice($this->elementNodes, $start, $length)); + } + + /** + * Replays every step in the sequence + * + * @return Generator + */ + public function replay(): Generator + { + $step = new self(); + foreach ($this->elementNodes as $index => $node) { + $step = $step->append($node); + yield $index => $step; + } + } + /** * @throws InvalidArgumentException */ diff --git a/src/bootstrap.php b/src/bootstrap.php index 202424b7..6e9d71fd 100644 --- a/src/bootstrap.php +++ b/src/bootstrap.php @@ -136,6 +136,7 @@ require_once __DIR__.'/Xml/Reader/Matcher/namespaced_attribute.php'; require_once __DIR__.'/Xml/Reader/Matcher/namespaced_attribute_value.php'; require_once __DIR__.'/Xml/Reader/Matcher/namespaced_element.php'; +require_once __DIR__.'/Xml/Reader/Matcher/nested.php'; require_once __DIR__.'/Xml/Reader/Matcher/node_attribute.php'; require_once __DIR__.'/Xml/Reader/Matcher/node_name.php'; require_once __DIR__.'/Xml/Reader/Matcher/not.php'; diff --git a/tests/Xml/Reader/Matcher/NestedTest.php b/tests/Xml/Reader/Matcher/NestedTest.php new file mode 100644 index 00000000..5f08800f --- /dev/null +++ b/tests/Xml/Reader/Matcher/NestedTest.php @@ -0,0 +1,243 @@ + [ + nested( + document_element(), + element_name('users'), + all( + element_name('user'), + attribute_value('locale', 'nl') + ) + ), + <<<'EOXML' + + + Jos + Bos + Mos + + + EOXML, + [ + 'Jos', + ] + ]; + yield 'skipped-items' => [ + nested( + element_name('root'), + element_name('user'), + ), + <<<'EOXML' + + + Jos + Bos + Mos + + + EOXML, + [ + 'Jos', + 'Bos', + 'Mos', + ] + ]; + yield 'similar-paths' => [ + nested( + element_name('users'), + element_name('user'), + ), + <<<'EOXML' + + + Jos + Bos + + + Mos + + + EOXML, + [ + 'Jos', + 'Bos', + ] + ]; + yield 'combined-with-sequence' => [ + nested( + document_element(), + sequence( + element_name('users'), + element_name('user'), + ), + ), + <<<'EOXML' + + + Jos + Bos + + + EOXML, + [ + 'Jos', + 'Bos', + ] + ]; + yield 'multi-parent-items' => [ + nested( + element_name('root'), + element_name('user'), + ), + <<<'EOXML' + + + Jos + Bos + + + + Mos + + + EOXML, + [ + 'Jos', + 'Bos', + 'Mos', + ] + ]; + yield 'in-between-match' => [ + nested( + element_name('root'), + element_name('users'), + ), + <<<'EOXML' + + + Jos + Bos + + + Mos + + + EOXML, + [ + <<<'EOXML' + + Jos + Bos + + EOXML, + ] + ]; + yield 'deeply-nested-sequence' => [ + nested( + document_element(), + sequence(element_name('users')), + sequence(element_name('user')), + ), + <<<'EOXML' + + + Jos + Bos + + + EOXML, + [ + 'Jos', + 'Bos', + ] + ]; + } + + public static function provideMatcherCases(): Generator + { + yield 'it_returns_false_if_no_matcher' => [ + nested(), + new NodeSequence( + new ElementNode(1, 'root', 'root', '', '', []), + ), + false + ]; + + yield 'it_returns_false_if_no_sequence' => [ + nested(document_element()), + new NodeSequence(), + false + ]; + + yield 'it_returns_false_if_there_are_no_matchers_left_before_the_end_of_the_node_sequence' => [ + nested(document_element()), + new NodeSequence( + new ElementNode(1, 'root', 'root', '', '', []), + new ElementNode(1, 'users', 'users', '', '', []), + ), + false + ]; + + yield 'it_returns_false_if_there_are_still_matchers_left_at_the_end_of_the_node_sequence' => [ + nested(element_name('notfound')), + new NodeSequence( + new ElementNode(1, 'root', 'root', '', '', []), + new ElementNode(1, 'users', 'users', '', '', []), + ), + false + ]; + + yield 'it_returns_true_if_the_last_matcher_hits_the_end_of_the_node_sequence' => [ + nested(element_name('root'), element_name('users')), + new NodeSequence( + new ElementNode(1, 'root', 'root', '', '', []), + new ElementNode(1, 'users', 'users', '', '', []), + ), + true + ]; + + yield 'it_can_skip_nodes_looking_for_the_next_one' => [ + nested(element_name('root'), element_name('user')), + new NodeSequence( + new ElementNode(1, 'root', 'root', '', '', []), + new ElementNode(1, 'users', 'users', '', '', []), + new ElementNode(1, 'user', 'user', '', '', []), + ), + true + ]; + + yield 'it_uses_a_sliced_node_sequence_breakpoint_on_match' => [ + nested( + element_name('root'), + sequence( + element_name('users'), + element_name('user') + ) + ), + new NodeSequence( + new ElementNode(1, 'root', 'root', '', '', []), + new ElementNode(1, 'users', 'users', '', '', []), + new ElementNode(1, 'user', 'user', '', '', []), + ), + true + ]; + } +} diff --git a/tests/Xml/Reader/Node/NodeSequenceTest.php b/tests/Xml/Reader/Node/NodeSequenceTest.php index 463ce3a5..3b5ad38e 100644 --- a/tests/Xml/Reader/Node/NodeSequenceTest.php +++ b/tests/Xml/Reader/Node/NodeSequenceTest.php @@ -4,6 +4,7 @@ namespace VeeWee\Tests\Xml\Reader\Node; +use Countable; use InvalidArgumentException; use PHPUnit\Framework\TestCase; use VeeWee\Xml\Reader\Node\ElementNode; @@ -22,7 +23,7 @@ public function test_it_can_be_empty(): void $sequence->current(); } - + public function test_it_can_not_pop_empty_sequence(): void { $sequence = new NodeSequence(); @@ -30,7 +31,7 @@ public function test_it_can_not_pop_empty_sequence(): void $this->expectException(InvalidArgumentException::class); $sequence->pop(); } - + public function test_it_can_remember_sequences_in_an_immutable_way(): void { $sequence = new NodeSequence( @@ -61,4 +62,67 @@ public function test_it_can_remember_sequences_in_an_immutable_way(): void static::assertNull($emptySequence->parent()); static::assertSame([], $emptySequence->sequence()); } + + + public function test_it_can_count_a_sequence(): void + { + $sequence = new NodeSequence( + new ElementNode(1, 'item1', 'item1', '', '', []), + ); + + static::assertInstanceOf(Countable::class, $sequence); + static::assertCount(1, $sequence); + } + + + public function test_it_can_replay_sequence(): void + { + $sequence = new NodeSequence( + $element1 = new ElementNode(1, 'item1', 'item1', '', '', []), + $element2 = new ElementNode(1, 'item2', 'item2', '', '', []), + ); + + $replayed = [...$sequence->replay()]; + static::assertCount(2, $replayed); + static::assertEquals(new NodeSequence($element1), $replayed[0]); + static::assertEquals(new NodeSequence($element1, $element2), $replayed[1]); + } + + /** + * Added to keep both infections 'YieldValue' and psalm's non-negative-int happy. + * Yet it adds little value since it is not allowed to use the sequence like this in psalm. + * Meh ... :) + * + * + */ + public function test_it_keeps_index_during_yielding(): void + { + $sequence = new NodeSequence( + el1: $element1 = new ElementNode(1, 'item1', 'item1', '', '', []), + el2: $element2 = new ElementNode(1, 'item2', 'item2', '', '', []), + ); + + $replayed = [...$sequence->replay()]; + static::assertCount(2, $replayed); + static::assertEquals(new NodeSequence($element1), $replayed['el1']); + static::assertEquals(new NodeSequence($element1, $element2), $replayed['el2']); + } + + + public function test_it_can_slice_node_sequence(): void + { + $emptySequence = new NodeSequence(); + static::assertEquals($emptySequence, $emptySequence->slice(0, 100)); + + $sequence = new NodeSequence( + $element1 = new ElementNode(1, 'item1', 'item1', '', '', []), + $element2 = new ElementNode(1, 'item2', 'item2', '', '', []), + ); + + static::assertEquals($sequence, $sequence->slice(-1)); + static::assertEquals(new NodeSequence($element1), $sequence->slice(-1, 1)); + static::assertEquals(new NodeSequence($element1), $sequence->slice(0, 1)); + static::assertEquals(new NodeSequence($element1, $element2), $sequence->slice(0)); + static::assertEquals(new NodeSequence($element2), $sequence->slice(1, 1)); + } }