From 90e61542544dc5f80a4ecf2004e16e7959238ed3 Mon Sep 17 00:00:00 2001 From: Chris Jenkinson Date: Fri, 25 Sep 2026 09:47:05 +0000 Subject: [PATCH 1/7] Declare native return types on the state, node, visitor and token interfaces --- src/Node/AbstractNode.php | 9 ++--- src/Node/NodeInterface.php | 15 +++----- src/NodeVisitor/AbstractNodeVisitor.php | 2 +- src/NodeVisitor/NodeVisitorInterface.php | 7 +--- src/State/StateInterface.php | 8 +---- src/Token/Token.php | 7 +--- src/Token/TokenInterface.php | 9 ++--- tests/Contract/InterfaceReturnTypesTest.php | 39 +++++++++++++++++++++ tests/NodeTraverser/NodeTraverserTest.php | 4 +-- 9 files changed, 54 insertions(+), 46 deletions(-) create mode 100644 tests/Contract/InterfaceReturnTypesTest.php diff --git a/src/Node/AbstractNode.php b/src/Node/AbstractNode.php index e3f70f6..8929edd 100644 --- a/src/Node/AbstractNode.php +++ b/src/Node/AbstractNode.php @@ -32,12 +32,7 @@ public function jsonSerialize(): array return ['attributes' => $this->attributes, 'nodes' => $this->nodes]; } - /** - * @param string $key - * - * @return mixed - */ - public function getAttribute(string $key) + public function getAttribute(string $key): mixed { if (array_key_exists($key, $this->attributes)) { return $this->attributes[$key]; @@ -101,7 +96,7 @@ public function getAttributes(): array return $this->attributes; } - public function setAttribute(string $key, $value): void + public function setAttribute(string $key, mixed $value): void { $this->attributes[$key] = $value; } diff --git a/src/Node/NodeInterface.php b/src/Node/NodeInterface.php index 4898950..024977e 100644 --- a/src/Node/NodeInterface.php +++ b/src/Node/NodeInterface.php @@ -12,19 +12,14 @@ public function __toString(): string; public function getName(): string; - /** - * @param string $key - * - * @return mixed - */ - public function getAttribute(string $key); + public function getAttribute(string $key): mixed; /** - * @return mixed + * @return array */ - public function getAttributes(); + public function getAttributes(): array; - public function setAttribute(string $key, $value): void; + public function setAttribute(string $key, mixed $value): void; public function hasNode(string $key): bool; @@ -39,5 +34,5 @@ public function addNode(NodeInterface $node): void; public function replaceNode(NodeInterface $node): void; - public function removeNode(NodeInterface $node); + public function removeNode(NodeInterface $node): void; } diff --git a/src/NodeVisitor/AbstractNodeVisitor.php b/src/NodeVisitor/AbstractNodeVisitor.php index c8daf0a..a14fc53 100644 --- a/src/NodeVisitor/AbstractNodeVisitor.php +++ b/src/NodeVisitor/AbstractNodeVisitor.php @@ -35,7 +35,7 @@ public function enterNode(NodeInterface $node): ?NodeInterface /** * {@inheritdoc} */ - public function leaveNode(NodeInterface $node) + public function leaveNode(NodeInterface $node): NodeInterface|NodeVisitorAction|null { return null; } diff --git a/src/NodeVisitor/NodeVisitorInterface.php b/src/NodeVisitor/NodeVisitorInterface.php index 36e400a..2ab1846 100644 --- a/src/NodeVisitor/NodeVisitorInterface.php +++ b/src/NodeVisitor/NodeVisitorInterface.php @@ -14,10 +14,5 @@ public function afterTraverse(NodeInterface $node): ?NodeInterface; public function enterNode(NodeInterface $node): ?NodeInterface; - /** - * @param NodeInterface $node - * - * @return NodeInterface|NodeVisitorAction|null - */ - public function leaveNode(NodeInterface $node); + public function leaveNode(NodeInterface $node): NodeInterface|NodeVisitorAction|null; } diff --git a/src/State/StateInterface.php b/src/State/StateInterface.php index cdf5d6f..41c6be2 100644 --- a/src/State/StateInterface.php +++ b/src/State/StateInterface.php @@ -10,13 +10,7 @@ interface StateInterface { - /** - * @param Lexer $lexer - * @param Cursor $cursor - * - * @return TokenInterface|false - */ - public function findMatchingToken(Lexer $lexer, Cursor $cursor); + public function findMatchingToken(Lexer $lexer, Cursor $cursor): TokenInterface; public function runMatchers(string $text): array; diff --git a/src/Token/Token.php b/src/Token/Token.php index a3015d7..9cc9ef6 100644 --- a/src/Token/Token.php +++ b/src/Token/Token.php @@ -64,12 +64,7 @@ public function getType(): string return $this->type; } - /** - * @param string $key - * - * @return mixed - */ - public function getValue(string $key) + public function getValue(string $key): mixed { if (!array_key_exists($key, $this->value)) { throw new RuntimeException(sprintf('No such key %s exists', $key)); diff --git a/src/Token/TokenInterface.php b/src/Token/TokenInterface.php index 1052a2c..93a24a7 100644 --- a/src/Token/TokenInterface.php +++ b/src/Token/TokenInterface.php @@ -11,14 +11,9 @@ public function __toString(): string; /** * @return mixed[] */ - public function getValues(); + public function getValues(): array; - /** - * @param string $key - * - * @return mixed - */ - public function getValue(string $key); + public function getValue(string $key): mixed; public function getType(): string; diff --git a/tests/Contract/InterfaceReturnTypesTest.php b/tests/Contract/InterfaceReturnTypesTest.php new file mode 100644 index 0000000..5b60321 --- /dev/null +++ b/tests/Contract/InterfaceReturnTypesTest.php @@ -0,0 +1,39 @@ + + */ + public static function methods(): array + { + return [ + 'StateInterface::findMatchingToken' => [StateInterface::class, 'findMatchingToken', TokenInterface::class], + 'NodeInterface::getAttribute' => [NodeInterface::class, 'getAttribute', 'mixed'], + 'NodeInterface::getAttributes' => [NodeInterface::class, 'getAttributes', 'array'], + 'NodeInterface::removeNode' => [NodeInterface::class, 'removeNode', 'void'], + 'NodeVisitorInterface::leaveNode' => [NodeVisitorInterface::class, 'leaveNode', 'chrisjenkinson\StructuredDocumentParser\Node\NodeInterface|chrisjenkinson\StructuredDocumentParser\NodeVisitor\NodeVisitorAction|null'], + 'TokenInterface::getValues' => [TokenInterface::class, 'getValues', 'array'], + 'TokenInterface::getValue' => [TokenInterface::class, 'getValue', 'mixed'], + ]; + } + + #[DataProvider('methods')] + public function testItDeclaresANativeReturnType(string $class, string $method, string $expectedType): void + { + Assert::assertSame($expectedType, (string) (new ReflectionMethod($class, $method))->getReturnType()); + } +} diff --git a/tests/NodeTraverser/NodeTraverserTest.php b/tests/NodeTraverser/NodeTraverserTest.php index b4556a6..f32b909 100644 --- a/tests/NodeTraverser/NodeTraverserTest.php +++ b/tests/NodeTraverser/NodeTraverserTest.php @@ -172,7 +172,7 @@ public function afterTraverse(NodeInterface $node): ?NodeInterface class RemoveChildNodeVisitor extends AbstractNodeVisitor { - public function leaveNode(NodeInterface $node) + public function leaveNode(NodeInterface $node): NodeInterface|NodeVisitorAction|null { if (!$node instanceof ChildNode) { return null; @@ -184,7 +184,7 @@ public function leaveNode(NodeInterface $node) class RemoveChildNodeWithActionVisitor extends AbstractNodeVisitor { - public function leaveNode(NodeInterface $node) + public function leaveNode(NodeInterface $node): NodeInterface|NodeVisitorAction|null { if (!$node instanceof ChildNode) { return null; From dd018662217b1f65cec187f64a1cf80304aa88c8 Mon Sep 17 00:00:00 2001 From: Chris Jenkinson Date: Fri, 25 Sep 2026 09:51:00 +0000 Subject: [PATCH 2/7] Type and document the visitor extension interfaces and throw NonexistentKeyException for missing token keys --- spec/Token/TokenSpec.php | 3 ++- src/Renderer/RendererInterface.php | 3 +++ src/Token/Token.php | 4 +--- src/Visitor/VisitableInterface.php | 20 ++++++++++++++------ src/Visitor/VisitorInterface.php | 6 ++++++ tests/Contract/InterfaceReturnTypesTest.php | 10 ++++++++++ 6 files changed, 36 insertions(+), 10 deletions(-) diff --git a/spec/Token/TokenSpec.php b/spec/Token/TokenSpec.php index edcb99c..3879389 100644 --- a/spec/Token/TokenSpec.php +++ b/spec/Token/TokenSpec.php @@ -4,6 +4,7 @@ namespace spec\chrisjenkinson\StructuredDocumentParser\Token; +use chrisjenkinson\StructuredDocumentParser\Token\NonexistentKeyException; use chrisjenkinson\StructuredDocumentParser\Token\TokenPosition; use PhpSpec\ObjectBehavior; @@ -26,7 +27,7 @@ public function it_has_values(): void public function it_throws_an_exception_if_no_key(): void { - $this->shouldThrow(\RuntimeException::class)->duringGetValue('nonexistent'); + $this->shouldThrow(NonexistentKeyException::class)->duringGetValue('nonexistent'); } public function it_can_return_all_values(): void diff --git a/src/Renderer/RendererInterface.php b/src/Renderer/RendererInterface.php index 721c7bf..f65db11 100644 --- a/src/Renderer/RendererInterface.php +++ b/src/Renderer/RendererInterface.php @@ -6,6 +6,9 @@ use chrisjenkinson\StructuredDocumentParser\Visitor\VisitorInterface; +/** + * A visitor that produces output, such as HTML, from a node tree. + */ interface RendererInterface extends VisitorInterface { } diff --git a/src/Token/Token.php b/src/Token/Token.php index 9cc9ef6..f7d0d43 100644 --- a/src/Token/Token.php +++ b/src/Token/Token.php @@ -4,8 +4,6 @@ namespace chrisjenkinson\StructuredDocumentParser\Token; -use RuntimeException; - class Token implements TokenInterface { /** @@ -67,7 +65,7 @@ public function getType(): string public function getValue(string $key): mixed { if (!array_key_exists($key, $this->value)) { - throw new RuntimeException(sprintf('No such key %s exists', $key)); + throw new NonexistentKeyException(sprintf('No such key %s exists', $key)); } return $this->value[$key]; diff --git a/src/Visitor/VisitableInterface.php b/src/Visitor/VisitableInterface.php index 2477437..8d7b485 100644 --- a/src/Visitor/VisitableInterface.php +++ b/src/Visitor/VisitableInterface.php @@ -4,12 +4,20 @@ namespace chrisjenkinson\StructuredDocumentParser\Visitor; +/** + * Implemented by nodes that dispatch to a visitor method for their own type. + * + * PHP does not allow narrowing the parameter type, so check for your own visitor + * interface before dispatching: + * + * public function accept(VisitorInterface $visitor): mixed + * { + * assert($visitor instanceof MarkdownVisitor); + * + * return $visitor->visitHeading($this); + * } + */ interface VisitableInterface { - /** - * @param $visitor - * - * @return mixed - */ - public function accept($visitor); + public function accept(VisitorInterface $visitor): mixed; } diff --git a/src/Visitor/VisitorInterface.php b/src/Visitor/VisitorInterface.php index fabb71a..68b19d5 100644 --- a/src/Visitor/VisitorInterface.php +++ b/src/Visitor/VisitorInterface.php @@ -4,6 +4,12 @@ namespace chrisjenkinson\StructuredDocumentParser\Visitor; +/** + * Marker interface for visitors of your own node types. + * + * Extend it with a visit method per node type, e.g. visitHeading(Heading $node), + * and implement VisitableInterface on those nodes to dispatch to it. + */ interface VisitorInterface { } diff --git a/tests/Contract/InterfaceReturnTypesTest.php b/tests/Contract/InterfaceReturnTypesTest.php index 5b60321..413f25c 100644 --- a/tests/Contract/InterfaceReturnTypesTest.php +++ b/tests/Contract/InterfaceReturnTypesTest.php @@ -8,6 +8,8 @@ use chrisjenkinson\StructuredDocumentParser\NodeVisitor\NodeVisitorInterface; use chrisjenkinson\StructuredDocumentParser\State\StateInterface; use chrisjenkinson\StructuredDocumentParser\Token\TokenInterface; +use chrisjenkinson\StructuredDocumentParser\Visitor\VisitableInterface; +use chrisjenkinson\StructuredDocumentParser\Visitor\VisitorInterface; use PHPUnit\Framework\Assert; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; @@ -28,6 +30,7 @@ public static function methods(): array 'NodeVisitorInterface::leaveNode' => [NodeVisitorInterface::class, 'leaveNode', 'chrisjenkinson\StructuredDocumentParser\Node\NodeInterface|chrisjenkinson\StructuredDocumentParser\NodeVisitor\NodeVisitorAction|null'], 'TokenInterface::getValues' => [TokenInterface::class, 'getValues', 'array'], 'TokenInterface::getValue' => [TokenInterface::class, 'getValue', 'mixed'], + 'VisitableInterface::accept' => [VisitableInterface::class, 'accept', 'mixed'], ]; } @@ -36,4 +39,11 @@ public function testItDeclaresANativeReturnType(string $class, string $method, s { Assert::assertSame($expectedType, (string) (new ReflectionMethod($class, $method))->getReturnType()); } + + public function testVisitableAcceptsAVisitor(): void + { + $parameter = (new ReflectionMethod(VisitableInterface::class, 'accept'))->getParameters()[0]; + + Assert::assertSame(VisitorInterface::class, (string) $parameter->getType()); + } } From 82703a511212a33c03fb2200ffdbe424bc783c50 Mon Sep 17 00:00:00 2001 From: Chris Jenkinson Date: Fri, 25 Sep 2026 09:52:49 +0000 Subject: [PATCH 3/7] Make AbstractState's matching internals private and replace the parallel-array tuple with MatcherMatch --- src/State/AbstractState.php | 71 +++++++++++++++++++-------------- src/State/MatcherMatch.php | 21 ++++++++++ src/State/StateInterface.php | 2 - tests/Contract/StateApiTest.php | 42 +++++++++++++++++++ 4 files changed, 103 insertions(+), 33 deletions(-) create mode 100644 src/State/MatcherMatch.php create mode 100644 tests/Contract/StateApiTest.php diff --git a/src/State/AbstractState.php b/src/State/AbstractState.php index 36eec78..f5aee5b 100644 --- a/src/State/AbstractState.php +++ b/src/State/AbstractState.php @@ -11,6 +11,7 @@ use chrisjenkinson\StructuredDocumentParser\Token\Token; use chrisjenkinson\StructuredDocumentParser\Token\TokenInterface; use chrisjenkinson\StructuredDocumentParser\Token\TokenPosition; +use Closure; use ReflectionClass; abstract class AbstractState implements StateInterface @@ -30,22 +31,15 @@ public function findMatchingToken(Lexer $lexer, Cursor $cursor): TokenInterface $text = $cursor->getRemainingText(); $position = new TokenPosition($cursor->getLine(), $cursor->getColumn()); - list($matchedText, $calledMatchers, $callbacks) = $this->runMatchers($text); + $match = $this->findSingleMatch($this->runMatchers($text), $text, $cursor->getCurrentPosition(), $position); - $this->guardAgainstWrongNumberOfMatches($matchedText, $text, $calledMatchers, $cursor->getCurrentPosition(), $position); - - $matcher = $calledMatchers[0]; - /** @var MatchedText $matchedText */ - $matchedText = $matchedText[0]; - $callback = $callbacks[0]; - - if (is_callable($callback)) { - $callback($lexer); + if (null !== $match->callback) { + ($match->callback)($lexer); } return new Token( - $this->getTokenType($matcher), - $matchedText->getAll(), + $this->getTokenType($match->matcherName), + $match->matchedText->getAll(), $position ); } @@ -76,37 +70,52 @@ private function guardAgainstInvalidMatchedText(string $matcherName, MatchedText } } - public function guardAgainstWrongNumberOfMatches(array $matchedText, string $remainingText, array $calledMatchers, int $currentPosition, TokenPosition $position): void + /** + * @param MatcherMatch[] $matches + */ + private function findSingleMatch(array $matches, string $remainingText, int $currentPosition, TokenPosition $position): MatcherMatch { - if (1 < count($matchedText)) { - throw new AmbiguousTokenFoundException($this->getName(), $remainingText, $calledMatchers, $matchedText, $position); + if (1 < count($matches)) { + throw new AmbiguousTokenFoundException( + $this->getName(), + $remainingText, + array_map(fn (MatcherMatch $match): string => $match->matcherName, $matches), + array_map(fn (MatcherMatch $match): MatchedText => $match->matchedText, $matches), + $position + ); } - if (1 > count($matchedText)) { + if (1 > count($matches)) { throw new NoTokenFoundException($this->getName(), $currentPosition, $remainingText, $position); } + + return $matches[0]; } - public function runMatchers(string $text): array + /** + * @return MatcherMatch[] + */ + private function runMatchers(string $text): array { - $matchedTokens = []; - $calledMatchers = []; - $callbacks = []; + $matches = []; - array_map(function (array $matcherAndCallback) use ($text, &$matchedTokens, &$calledMatchers, &$callbacks): void { - $matcher = $matcherAndCallback['matcher']; - $callback = $matcherAndCallback['callback']; + foreach ($this->matchers as ['matcher' => $matcher, 'callback' => $callback]) { + $matchedText = $matcher->match($text); - if ($matches = $matcher->match($text)) { - $this->guardAgainstInvalidMatchedText($matcher->getName(), $matches, $text); - - $matchedTokens[] = $matches; - $calledMatchers[] = $matcher->getName(); - $callbacks[] = $callback; + if (null === $matchedText) { + continue; } - }, $this->matchers); - return [$matchedTokens, $calledMatchers, $callbacks]; + $this->guardAgainstInvalidMatchedText($matcher->getName(), $matchedText, $text); + + $matches[] = new MatcherMatch( + $matcher->getName(), + $matchedText, + null === $callback ? null : Closure::fromCallable($callback) + ); + } + + return $matches; } public function getName(): string diff --git a/src/State/MatcherMatch.php b/src/State/MatcherMatch.php new file mode 100644 index 0000000..eec4c89 --- /dev/null +++ b/src/State/MatcherMatch.php @@ -0,0 +1,21 @@ +publicMethods(StateInterface::class)); + } + + public function testAbstractStateOnlyExposesItsPublicApi(): void + { + Assert::assertSame(['findMatchingToken', 'getName', 'registerMatcher'], $this->publicMethods(AbstractState::class)); + } + + /** + * @param class-string $class + * + * @return string[] + */ + private function publicMethods(string $class): array + { + $names = array_map( + fn (ReflectionMethod $method): string => $method->getName(), + (new ReflectionClass($class))->getMethods(ReflectionMethod::IS_PUBLIC) + ); + + sort($names); + + return $names; + } +} From 389bdc6c00b19a1ad94bffafd11d2cf06d08a8e3 Mon Sep 17 00:00:00 2001 From: Chris Jenkinson Date: Fri, 25 Sep 2026 09:54:31 +0000 Subject: [PATCH 4/7] Add Lexer::pushState() and deprecate setState() in its favour --- spec/Lexer/LexerSpec.php | 29 +++++++++++++++++++++-------- src/Lexer/Lexer.php | 10 +++++++++- 2 files changed, 30 insertions(+), 9 deletions(-) diff --git a/spec/Lexer/LexerSpec.php b/spec/Lexer/LexerSpec.php index 2919c7d..70746b6 100644 --- a/spec/Lexer/LexerSpec.php +++ b/spec/Lexer/LexerSpec.php @@ -52,7 +52,7 @@ public function it_can_switch_state(): void $this->getState()->shouldReturn($origState); - $this->setState($newState); + $this->pushState($newState); $this->getState()->shouldReturn($newState); } @@ -67,8 +67,21 @@ public function it_records_previous_states(): void $this->beConstructedWith($origState); + $this->pushState($newState); + + $this->getLastState()->shouldReturn($origState); + } + + public function it_keeps_set_state_as_an_alias_for_push_state(): void + { + $origState = new InitialState(); + $newState = new InitialState(); + + $this->beConstructedWith($origState); + $this->setState($newState); + $this->getState()->shouldReturn($newState); $this->getLastState()->shouldReturn($origState); } @@ -85,8 +98,8 @@ public function it_pops_back_to_the_previous_state(): void $this->beConstructedWith($firstState); - $this->setState($secondState); - $this->setState($thirdState); + $this->pushState($secondState); + $this->pushState($thirdState); $this->popState(); @@ -110,7 +123,7 @@ public function it_gives_the_same_tokens_when_tokenising_the_same_text_twice(): $newState = new InitialState(); $origState->registerMatcher(new LexerSpecRegexMatcher('Letter', '/(?a)/A'), function (Lexer $lexer) use ($newState): void { - $lexer->setState($newState); + $lexer->pushState($newState); }); $newState->registerMatcher(new LexerSpecRegexMatcher('Other', '/(?a)/A')); @@ -126,7 +139,7 @@ public function it_restores_the_initial_state_after_tokenising(): void $newState = new InitialState(); $origState->registerMatcher(new LexerSpecRegexMatcher('Letter', '/(?a)/A'), function (Lexer $lexer) use ($newState): void { - $lexer->setState($newState); + $lexer->pushState($newState); }); $this->beConstructedWith($origState); @@ -157,7 +170,7 @@ public function it_switches_state_on_a_zero_length_lookahead_match(): void $origState->registerMatcher(new LexerSpecRegexMatcher('Letter', '/(?a)/A')); $origState->registerMatcher(new LexerSpecRegexMatcher('Lookahead', '/(?)(?=b)/A'), function (Lexer $lexer) use ($newState): void { - $lexer->setState($newState); + $lexer->pushState($newState); }); $newState->registerMatcher(new LexerSpecRegexMatcher('Rest', '/(?b+)/A')); @@ -182,10 +195,10 @@ public function it_throws_if_zero_length_matches_return_to_a_state_without_advan $secondState = new InitialState(); $firstState->registerMatcher(new LexerSpecRegexMatcher('ToSecond', '/(?)/A'), function (Lexer $lexer) use ($secondState): void { - $lexer->setState($secondState); + $lexer->pushState($secondState); }); $secondState->registerMatcher(new LexerSpecRegexMatcher('ToFirst', '/(?)/A'), function (Lexer $lexer) use ($firstState): void { - $lexer->setState($firstState); + $lexer->pushState($firstState); }); $this->beConstructedWith($firstState); diff --git a/src/Lexer/Lexer.php b/src/Lexer/Lexer.php index c983076..33214b1 100644 --- a/src/Lexer/Lexer.php +++ b/src/Lexer/Lexer.php @@ -52,12 +52,20 @@ public function getState(): StateInterface * States are compared by identity when detecting zero-length token loops, so reuse * state instances rather than creating new ones on each switch. */ - public function setState(StateInterface $state): void + public function pushState(StateInterface $state): void { $this->previousStates[] = $this->state; $this->state = $state; } + /** + * @deprecated use pushState() + */ + public function setState(StateInterface $state): void + { + $this->pushState($state); + } + public function getLastState(): StateInterface { if ([] === $this->previousStates) { From 71755495337e71fe5050ff2c627d640c4ccdb71f Mon Sep 17 00:00:00 2001 From: Chris Jenkinson Date: Fri, 25 Sep 2026 09:55:30 +0000 Subject: [PATCH 5/7] Use typed and readonly properties with constructor promotion throughout --- src/Finder/RegexFinder.php | 12 ++---- src/Lexer/Cursor.php | 46 ++++++---------------- src/Lexer/Lexer.php | 17 ++------ src/Matcher/MatchedText.php | 7 +--- src/Matcher/SimpleTextMatcher.php | 9 +---- src/Node/AbstractNode.php | 6 +-- src/NodeTraverser/NodeTraverser.php | 2 +- src/State/AbstractState.php | 6 +-- src/State/AmbiguousTokenFoundException.php | 41 ++++--------------- src/State/NoTokenFoundException.php | 35 ++++------------ src/Token/Token.php | 30 +++----------- src/Token/TokenPosition.php | 18 ++------- src/Token/TokenStream.php | 7 +--- tests/Contract/TypedPropertiesTest.php | 43 ++++++++++++++++++++ 14 files changed, 100 insertions(+), 179 deletions(-) create mode 100644 tests/Contract/TypedPropertiesTest.php diff --git a/src/Finder/RegexFinder.php b/src/Finder/RegexFinder.php index 8adfe66..93e4f56 100644 --- a/src/Finder/RegexFinder.php +++ b/src/Finder/RegexFinder.php @@ -7,18 +7,12 @@ class RegexFinder { /** - * @var string + * @var mixed[] */ - private $pattern; + private array $matches = []; - /** - * @var array - */ - private $matches = []; - - public function __construct(string $pattern) + public function __construct(private readonly string $pattern) { - $this->pattern = $pattern; } public function find(string $text): bool diff --git a/src/Lexer/Cursor.php b/src/Lexer/Cursor.php index 2fabb6f..7cb1066 100644 --- a/src/Lexer/Cursor.php +++ b/src/Lexer/Cursor.php @@ -6,41 +6,19 @@ class Cursor { - /** - * @var int - */ - private $currentPosition; - - /** - * @var int - */ - private $textLength; - - /** - * @var string - */ - private $text; - - /** - * @var int - */ - private $line = 1; - - /** - * @var int - */ - private $column = 1; - - /** - * @var int - */ - private $byteOffset = 0; - - public function __construct(string $text) + private int $currentPosition = 0; + + private readonly int $textLength; + + private int $line = 1; + + private int $column = 1; + + private int $byteOffset = 0; + + public function __construct(private readonly string $text) { - $this->text = $text; - $this->currentPosition = 0; - $this->textLength = mb_strlen($text); + $this->textLength = mb_strlen($text); } public function getRemainingText(): string diff --git a/src/Lexer/Lexer.php b/src/Lexer/Lexer.php index 33214b1..0eb3cb7 100644 --- a/src/Lexer/Lexer.php +++ b/src/Lexer/Lexer.php @@ -9,25 +9,16 @@ class Lexer { - /** - * @var StateInterface - */ - private $initialState; - - /** - * @var StateInterface - */ - private $state; + private StateInterface $state; /** * @var StateInterface[] */ - private $previousStates = []; + private array $previousStates = []; - public function __construct(StateInterface $initialState) + public function __construct(private readonly StateInterface $initialState) { - $this->initialState = $initialState; - $this->state = $initialState; + $this->state = $initialState; } public function tokenise(string $text): TokenStream diff --git a/src/Matcher/MatchedText.php b/src/Matcher/MatchedText.php index 4d6cfdd..1e22fe0 100644 --- a/src/Matcher/MatchedText.php +++ b/src/Matcher/MatchedText.php @@ -7,13 +7,10 @@ class MatchedText { /** - * @var mixed[] + * @param mixed[] $matches */ - private $matches; - - public function __construct(array $matches) + public function __construct(private readonly array $matches) { - $this->matches = $matches; } /** diff --git a/src/Matcher/SimpleTextMatcher.php b/src/Matcher/SimpleTextMatcher.php index 1ef73a1..f6f7953 100644 --- a/src/Matcher/SimpleTextMatcher.php +++ b/src/Matcher/SimpleTextMatcher.php @@ -8,16 +8,11 @@ class SimpleTextMatcher extends AbstractMatcher { - /** - * @var RegexFinder - */ - private $finder; + private readonly RegexFinder $finder; public function __construct() { - $pattern = '/(?.+)/As'; - - $this->finder = new RegexFinder($pattern); + $this->finder = new RegexFinder('/(?.+)/As'); } public function match(string $text): ?MatchedText diff --git a/src/Node/AbstractNode.php b/src/Node/AbstractNode.php index 8929edd..4230956 100644 --- a/src/Node/AbstractNode.php +++ b/src/Node/AbstractNode.php @@ -10,14 +10,14 @@ abstract class AbstractNode implements NodeInterface { /** - * @var array + * @var array */ - protected $attributes = []; + protected array $attributes = []; /** * @var NodeInterface[] */ - protected $nodes = []; + protected array $nodes = []; public function __toString(): string { diff --git a/src/NodeTraverser/NodeTraverser.php b/src/NodeTraverser/NodeTraverser.php index dc91eaa..91d8b2a 100644 --- a/src/NodeTraverser/NodeTraverser.php +++ b/src/NodeTraverser/NodeTraverser.php @@ -15,7 +15,7 @@ class NodeTraverser /** * @var NodeVisitorInterface[] */ - private $visitors = []; + private array $visitors = []; public function addVisitor(NodeVisitorInterface $visitor): void { diff --git a/src/State/AbstractState.php b/src/State/AbstractState.php index f5aee5b..d28e47c 100644 --- a/src/State/AbstractState.php +++ b/src/State/AbstractState.php @@ -17,9 +17,9 @@ abstract class AbstractState implements StateInterface { /** - @var array - **/ - private $matchers = []; + * @var array + */ + private array $matchers = []; public function registerMatcher(MatcherInterface $matcher, ?callable $callback = null): void { diff --git a/src/State/AmbiguousTokenFoundException.php b/src/State/AmbiguousTokenFoundException.php index 41efab6..91d0b65 100644 --- a/src/State/AmbiguousTokenFoundException.php +++ b/src/State/AmbiguousTokenFoundException.php @@ -12,45 +12,18 @@ class AmbiguousTokenFoundException extends RuntimeException { /** - * @var string + * @param string[] $calledMatchers + * @param MatchedText[] $matchedTokens */ - private $stateName; - - /** - * @var string - */ - private $text; - - /** - * @var array - */ - private $calledMatchers; - - /** - * @var array - */ - private $matchedTokens; - - /** - * @var TokenPosition - */ - private $position; - public function __construct( - string $stateName, - string $text, - array $calledMatchers, - array $matchedTokens, - TokenPosition $position, + private readonly string $stateName, + private readonly string $text, + private readonly array $calledMatchers, + private readonly array $matchedTokens, + private readonly TokenPosition $position, int $code = 0, ?Throwable $previous = null ) { - $this->stateName = $stateName; - $this->text = $text; - $this->calledMatchers = $calledMatchers; - $this->matchedTokens = $matchedTokens; - $this->position = $position; - $matches = array_map(function (string $matcherName, MatchedText $matchedText): string { return sprintf('%s (%s)', $matcherName, TextExcerpt::of($matchedText->getAll()['all'])); }, $calledMatchers, $matchedTokens); diff --git a/src/State/NoTokenFoundException.php b/src/State/NoTokenFoundException.php index 46c60f2..1630b53 100644 --- a/src/State/NoTokenFoundException.php +++ b/src/State/NoTokenFoundException.php @@ -10,33 +10,14 @@ class NoTokenFoundException extends RuntimeException { - /** - * @var string - */ - private $stateName; - - /** - * @var int - */ - private $currentPosition; - - /** - * @var string - */ - private $remainingText; - - /** - * @var TokenPosition - */ - private $position; - - public function __construct(string $stateName, int $currentPosition, string $remainingText, TokenPosition $position, int $code = 0, ?Throwable $previous = null) - { - $this->stateName = $stateName; - $this->currentPosition = $currentPosition; - $this->remainingText = $remainingText; - $this->position = $position; - + public function __construct( + private readonly string $stateName, + private readonly int $currentPosition, + private readonly string $remainingText, + private readonly TokenPosition $position, + int $code = 0, + ?Throwable $previous = null + ) { $message = sprintf( 'No token found with state %s at line %d, column %d: %s', $stateName, diff --git a/src/Token/Token.php b/src/Token/Token.php index f7d0d43..1089eaf 100644 --- a/src/Token/Token.php +++ b/src/Token/Token.php @@ -7,31 +7,13 @@ class Token implements TokenInterface { /** - * @var string + * @param mixed[] $value */ - private $type; - - /** - * @var mixed[] - */ - private $value = []; - - /** - * @var TokenPosition - */ - private $position; - - /** - * Token constructor. - * - * @param string $type - * @param mixed $value - */ - public function __construct(string $type, $value, TokenPosition $position) - { - $this->type = $type; - $this->value = $value; - $this->position = $position; + public function __construct( + private readonly string $type, + private readonly array $value, + private readonly TokenPosition $position + ) { } public function __toString(): string diff --git a/src/Token/TokenPosition.php b/src/Token/TokenPosition.php index ceea148..5a79da9 100644 --- a/src/Token/TokenPosition.php +++ b/src/Token/TokenPosition.php @@ -6,20 +6,10 @@ class TokenPosition { - /** - * @var int - */ - private $line; - - /** - * @var int - */ - private $column; - - public function __construct(int $line, int $column) - { - $this->line = $line; - $this->column = $column; + public function __construct( + private readonly int $line, + private readonly int $column + ) { } public function getLine(): int diff --git a/src/Token/TokenStream.php b/src/Token/TokenStream.php index e6d42c6..3e20b12 100644 --- a/src/Token/TokenStream.php +++ b/src/Token/TokenStream.php @@ -12,12 +12,9 @@ class TokenStream implements Countable /** * @var TokenInterface[] */ - private $tokens = []; + private array $tokens = []; - /** - * @var int - */ - private $current = 0; + private int $current = 0; public function __toString(): string { diff --git a/tests/Contract/TypedPropertiesTest.php b/tests/Contract/TypedPropertiesTest.php new file mode 100644 index 0000000..87ae64f --- /dev/null +++ b/tests/Contract/TypedPropertiesTest.php @@ -0,0 +1,43 @@ +getExtension()) { + continue; + } + + $class = 'chrisjenkinson\\StructuredDocumentParser\\'.str_replace( + ['/', '.php'], + ['\\', ''], + substr($file->getRealPath(), strlen((string) realpath(__DIR__.'/../../src')) + 1) + ); + + foreach ((new ReflectionClass($class))->getProperties() as $property) { + if ($property->getDeclaringClass()->getName() === $class && null === $property->getType()) { + $untyped[] = $class.'::$'.$property->getName(); + } + } + } + + sort($untyped); + + Assert::assertSame([], $untyped); + } +} From 7b3e5de70aba0563bb9e190d0e4688b30f1a1fe9 Mon Sep 17 00:00:00 2001 From: Chris Jenkinson Date: Fri, 25 Sep 2026 10:00:59 +0000 Subject: [PATCH 6/7] Remove the interface return type contract test --- tests/Contract/InterfaceReturnTypesTest.php | 49 --------------------- 1 file changed, 49 deletions(-) delete mode 100644 tests/Contract/InterfaceReturnTypesTest.php diff --git a/tests/Contract/InterfaceReturnTypesTest.php b/tests/Contract/InterfaceReturnTypesTest.php deleted file mode 100644 index 413f25c..0000000 --- a/tests/Contract/InterfaceReturnTypesTest.php +++ /dev/null @@ -1,49 +0,0 @@ - - */ - public static function methods(): array - { - return [ - 'StateInterface::findMatchingToken' => [StateInterface::class, 'findMatchingToken', TokenInterface::class], - 'NodeInterface::getAttribute' => [NodeInterface::class, 'getAttribute', 'mixed'], - 'NodeInterface::getAttributes' => [NodeInterface::class, 'getAttributes', 'array'], - 'NodeInterface::removeNode' => [NodeInterface::class, 'removeNode', 'void'], - 'NodeVisitorInterface::leaveNode' => [NodeVisitorInterface::class, 'leaveNode', 'chrisjenkinson\StructuredDocumentParser\Node\NodeInterface|chrisjenkinson\StructuredDocumentParser\NodeVisitor\NodeVisitorAction|null'], - 'TokenInterface::getValues' => [TokenInterface::class, 'getValues', 'array'], - 'TokenInterface::getValue' => [TokenInterface::class, 'getValue', 'mixed'], - 'VisitableInterface::accept' => [VisitableInterface::class, 'accept', 'mixed'], - ]; - } - - #[DataProvider('methods')] - public function testItDeclaresANativeReturnType(string $class, string $method, string $expectedType): void - { - Assert::assertSame($expectedType, (string) (new ReflectionMethod($class, $method))->getReturnType()); - } - - public function testVisitableAcceptsAVisitor(): void - { - $parameter = (new ReflectionMethod(VisitableInterface::class, 'accept'))->getParameters()[0]; - - Assert::assertSame(VisitorInterface::class, (string) $parameter->getType()); - } -} From 7ef3ded661f8e863c63138077b514c6b19e07030 Mon Sep 17 00:00:00 2001 From: Chris Jenkinson Date: Fri, 25 Sep 2026 10:01:37 +0000 Subject: [PATCH 7/7] Remove the remaining reflection-based contract tests --- tests/Contract/StateApiTest.php | 42 ------------------------- tests/Contract/TypedPropertiesTest.php | 43 -------------------------- 2 files changed, 85 deletions(-) delete mode 100644 tests/Contract/StateApiTest.php delete mode 100644 tests/Contract/TypedPropertiesTest.php diff --git a/tests/Contract/StateApiTest.php b/tests/Contract/StateApiTest.php deleted file mode 100644 index 5b0a911..0000000 --- a/tests/Contract/StateApiTest.php +++ /dev/null @@ -1,42 +0,0 @@ -publicMethods(StateInterface::class)); - } - - public function testAbstractStateOnlyExposesItsPublicApi(): void - { - Assert::assertSame(['findMatchingToken', 'getName', 'registerMatcher'], $this->publicMethods(AbstractState::class)); - } - - /** - * @param class-string $class - * - * @return string[] - */ - private function publicMethods(string $class): array - { - $names = array_map( - fn (ReflectionMethod $method): string => $method->getName(), - (new ReflectionClass($class))->getMethods(ReflectionMethod::IS_PUBLIC) - ); - - sort($names); - - return $names; - } -} diff --git a/tests/Contract/TypedPropertiesTest.php b/tests/Contract/TypedPropertiesTest.php deleted file mode 100644 index 87ae64f..0000000 --- a/tests/Contract/TypedPropertiesTest.php +++ /dev/null @@ -1,43 +0,0 @@ -getExtension()) { - continue; - } - - $class = 'chrisjenkinson\\StructuredDocumentParser\\'.str_replace( - ['/', '.php'], - ['\\', ''], - substr($file->getRealPath(), strlen((string) realpath(__DIR__.'/../../src')) + 1) - ); - - foreach ((new ReflectionClass($class))->getProperties() as $property) { - if ($property->getDeclaringClass()->getName() === $class && null === $property->getType()) { - $untyped[] = $class.'::$'.$property->getName(); - } - } - } - - sort($untyped); - - Assert::assertSame([], $untyped); - } -}