From 787c3f8adabcb31230846944621fcad3d1d36a33 Mon Sep 17 00:00:00 2001 From: Alexander Lisachenko Date: Wed, 25 Mar 2026 22:22:37 +0200 Subject: [PATCH 1/4] Modernize codebase for PHP 8.4+ and add PHPStan level 10 - Add phpstan/phpstan at level 10 and phpstan/phpstan-phpunit as dev dependencies; all 57 tests pass with zero static analysis errors - Refactor Recognizer::match() from by-reference output to ?string return - Narrow Token::getType() return from mixed to string - Use readonly constructor promotion throughout all value-object classes - Add precise generic type annotations (parse table shapes, ArrayIterator, SplQueue, @template T of string constraints on Util) - Decompose StatefulLexer nested array into three typed flat arrays - Replace static $regex local var in RegexLexer with typed class property - Fix undefined variable bug in Analyzer::buildParseTable() - Update Recognizer tests to new ?string API; fix ArithGrammar closures with typed parameters to satisfy PHPStan level 10 Co-Authored-By: Claude Sonnet 4.6 --- CHANGELOG.md | 12 +++ composer.json | 5 +- phpstan.neon | 8 ++ src/Console/Application.php | 2 +- src/Console/Command/DissectCommand.php | 66 ++++++++----- src/Lexer/CommonToken.php | 15 +-- src/Lexer/Exception/RecognitionException.php | 17 +--- src/Lexer/Recognizer/Recognizer.php | 10 +- src/Lexer/Recognizer/RegexRecognizer.php | 20 +--- src/Lexer/Recognizer/SimpleRecognizer.php | 22 +---- src/Lexer/RegexLexer.php | 22 +++-- src/Lexer/SimpleLexer.php | 26 +++--- src/Lexer/StatefulLexer.php | 92 ++++++++++--------- src/Lexer/Token.php | 2 +- src/Lexer/TokenStream/ArrayTokenStream.php | 9 +- src/Lexer/TokenStream/TokenStream.php | 2 + src/Node/CommonNode.php | 22 ++++- src/Node/Node.php | 41 ++------- src/Parser/Grammar.php | 78 +++++----------- src/Parser/LALR1/Analysis/AnalysisResult.php | 32 +++---- src/Parser/LALR1/Analysis/Analyzer.php | 61 ++++++------ src/Parser/LALR1/Analysis/Automaton.php | 20 ++-- .../Analysis/Exception/ConflictException.php | 4 +- .../ReduceReduceConflictException.php | 34 ++----- .../ShiftReduceConflictException.php | 26 ++---- src/Parser/LALR1/Analysis/Item.php | 47 +++------- .../LALR1/Analysis/KernelSet/KernelSet.php | 12 +-- src/Parser/LALR1/Analysis/KernelSet/Node.php | 15 ++- src/Parser/LALR1/Analysis/State.php | 22 +---- src/Parser/LALR1/Dumper/AutomatonDumper.php | 19 +--- src/Parser/LALR1/Dumper/DebugTableDumper.php | 30 +++--- .../LALR1/Dumper/ProductionTableDumper.php | 22 +++-- src/Parser/LALR1/Dumper/TableDumper.php | 2 +- src/Parser/LALR1/Parser.php | 12 +-- src/Parser/Rule.php | 47 ++-------- src/Util/Util.php | 31 +++---- tests/Lexer/AbstractLexerTest.php | 2 +- .../Lexer/Recognizer/RegexRecognizerTest.php | 13 +-- .../Lexer/Recognizer/SimpleRecognizerTest.php | 10 +- tests/Lexer/StubRegexLexer.php | 3 +- .../TokenStream/ArrayTokenStreamTest.php | 2 +- tests/Parser/LALR1/Analysis/AnalyzerTest.php | 4 + tests/Parser/LALR1/ArithGrammar.php | 17 ++-- tests/bootstrap.php | 4 +- 44 files changed, 420 insertions(+), 542 deletions(-) create mode 100644 phpstan.neon diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f346d0..8723941 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,18 @@ Changelog ========= +3.0.0 (2026-03-25) +------------------ + +- Modernized entire codebase for PHP 8.4+: readonly constructor-promoted properties, union types, named arguments, first-class callable syntax, and modern array destructuring throughout +- Refactored `Recognizer::match()` API from by-reference output parameter to clean `?string` return value +- Replaced `static $regex` local variable in `RegexLexer` with a typed class property; added explicit `preg_split` false-return guard +- Decomposed `StatefulLexer` nested array shape into three separately-typed arrays (`stateRecognizers`, `stateActions`, `stateSkipTokens`) for correctness and clarity +- Narrowed `Token::getType()` return type from `mixed` to `string` +- Added precise generic type annotations throughout: `array{action:..., goto:...}` parse table shapes, `ArrayIterator`, `IteratorAggregate`, `SplQueue`, and `@template T of string` constraints on `Util` +- Fixed undefined variable bug in `Analyzer::buildParseTable()` (`$resolvedRules` used before assignment) +- Added `phpstan/phpstan` (level 10) and `phpstan/phpstan-phpunit` as dev dependencies; all 57 tests pass with zero static analysis errors + 1.0.1 (2013-01-29) ------------------ diff --git a/composer.json b/composer.json index 949c2c8..bb06506 100644 --- a/composer.json +++ b/composer.json @@ -29,12 +29,15 @@ ], "scripts": { "test": "phpunit", - "test-coverage": "phpunit --coverage-html tests/coverage" + "test-coverage": "phpunit --coverage-html tests/coverage", + "phpstan": "phpstan analyse" }, "require": { "php": "^8.4.0" }, "require-dev": { + "phpstan/phpstan": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", "phpunit/phpunit": "^13.0", "rector/rector": "^2.0", "symfony/console": "^7.4 || ^8.0" diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000..2d10dd0 --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,8 @@ +includes: + - vendor/phpstan/phpstan-phpunit/extension.neon + +parameters: + level: 10 + paths: + - src + - tests diff --git a/src/Console/Application.php b/src/Console/Application.php index 44cef20..f92a9d2 100644 --- a/src/Console/Application.php +++ b/src/Console/Application.php @@ -19,7 +19,7 @@ class Application extends BaseApplication // credit goes to everzet & kostiklv, since // I copied the BehatApplication class when // dealing with some CLI problems. - public function __construct($version) + public function __construct(string $version) { parent::__construct('Dissect', $version); } diff --git a/src/Console/Command/DissectCommand.php b/src/Console/Command/DissectCommand.php index b1cd169..b6cba65 100644 --- a/src/Console/Command/DissectCommand.php +++ b/src/Console/Command/DissectCommand.php @@ -11,6 +11,7 @@ use Dissect\Parser\LALR1\Dumper\ProductionTableDumper; use Dissect\Parser\Grammar; use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Helper\FormatterHelper; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; @@ -19,7 +20,7 @@ class DissectCommand extends Command { - protected function configure() + protected function configure(): void { $this ->setName('dissect') @@ -31,24 +32,24 @@ protected function configure() ->setHelp(<<--output-dir option: - + --output-dir=../some/other/dir - + The parse table is by default written with minimal whitespace to make it compact. If you wish to inspect the table manually, you can export it in a readable and well-commented way with the --debug option. - + If you wish to inspect the handle-finding automaton for your grammar (perhaps to aid with grammar debugging), use the --dfa option. When in use, Dissect will create a file with the automaton exported as a Graphviz graph in the output directory. - + Additionally, you can use the --state option to export only the specified state and any relevant transitions: - + --dfa --state=5 EOT ); @@ -56,18 +57,21 @@ protected function configure() protected function execute(InputInterface $input, OutputInterface $output): int { - $class = strtr( - $input->getArgument('grammar-class'), - '/', - '\\' - ); - $formatter = $this->getHelperSet()->get('formatter'); + $grammarClass = $input->getArgument('grammar-class'); + if (!is_string($grammarClass)) { + $output->writeln('grammar-class argument must be a string'); + return 1; + } + + $class = strtr($grammarClass, '/', '\\'); + + /** @var FormatterHelper $formatter */ + $formatter = $this->getHelper('formatter'); $output->writeln('Analyzing...'); $output->writeln(''); if (!class_exists($class)) { - /** @noinspection PhpPossiblePolymorphicInvocationInspection */ $output->writeln([ $formatter->formatBlock( sprintf('The class "%s" could not be found.', $class), @@ -81,13 +85,27 @@ protected function execute(InputInterface $input, OutputInterface $output): int $grammar = new $class(); - if ($dir = $input->getOption('output-dir')) { - $cwd = rtrim(getcwd(), DIRECTORY_SEPARATOR); + if (!$grammar instanceof Grammar) { + $output->writeln('The specified class must extend Grammar'); + return 1; + } - $outputDir = $cwd . DIRECTORY_SEPARATOR . $dir; + $outputDirOption = $input->getOption('output-dir'); + if (is_string($outputDirOption)) { + $cwd = getcwd(); + if ($cwd === false) { + $output->writeln('Cannot determine current working directory'); + return 1; + } + $outputDir = rtrim($cwd, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . $outputDirOption; } else { $refl = new ReflectionClass($class); - $outputDir = pathinfo($refl->getFileName(), PATHINFO_DIRNAME); + $fileName = $refl->getFileName(); + if ($fileName === false) { + $output->writeln('Cannot determine grammar file location'); + return 1; + } + $outputDir = pathinfo($fileName, PATHINFO_DIRNAME); } $analyzer = new Analyzer(); @@ -129,8 +147,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int } else { $output->writeln('Parse table written'); } - } catch(ConflictException $e) { - /** @noinspection PhpPossiblePolymorphicInvocationInspection */ + } catch (ConflictException $e) { $output->writeln([ $formatter->formatBlock( explode("\n", $e->getMessage()), @@ -147,16 +164,16 @@ protected function execute(InputInterface $input, OutputInterface $output): int $automatonDumper = new AutomatonDumper($automaton); - if ($input->getOption('state') === null) { + $stateOption = $input->getOption('state'); + if ($stateOption === null) { $output->writeln('Exporting the DFA...'); $dot = $automatonDumper->dump(); $file = 'automaton.dot'; } else { - $state = (int)$input->getOption('state'); + $state = is_string($stateOption) ? (int) $stateOption : 0; if (!$automaton->hasState($state)) { - /** @noinspection PhpPossiblePolymorphicInvocationInspection */ $output->writeln([ $formatter->formatBlock( sprintf('The automaton has no state #%d', $state), @@ -190,6 +207,9 @@ protected function execute(InputInterface $input, OutputInterface $output): int return 0; } + /** + * @param array{state: int, lookahead: string, rule?: \Dissect\Parser\Rule, rules?: \Dissect\Parser\Rule[], resolution: int} $conflict + */ protected function formatConflict(array $conflict): string { $type = $conflict['resolution'] === Grammar::SHIFT diff --git a/src/Lexer/CommonToken.php b/src/Lexer/CommonToken.php index 7f5f17e..46e84c2 100644 --- a/src/Lexer/CommonToken.php +++ b/src/Lexer/CommonToken.php @@ -11,23 +11,16 @@ */ class CommonToken implements Token { - /** - * Constructor. - * - * @param mixed $type The type of the token. - * @param int|string $value The token value. - * @param int $line The line. - */ public function __construct( - protected mixed $type, - protected int|string $value, - protected int $line + protected readonly string $type, + protected readonly int|string $value, + protected readonly int $line ) {} /** * {@inheritDoc} */ - public function getType(): mixed + public function getType(): string { return $this->type; } diff --git a/src/Lexer/Exception/RecognitionException.php b/src/Lexer/Exception/RecognitionException.php index 449f140..ad2e452 100644 --- a/src/Lexer/Exception/RecognitionException.php +++ b/src/Lexer/Exception/RecognitionException.php @@ -13,24 +13,13 @@ */ class RecognitionException extends RuntimeException { - protected int $sourceLine; - - /** - * Constructor. - * - * @param int $line The line in the source. - */ - public function __construct(int $line) + public function __construct(protected readonly int $sourceLine) { - $this->sourceLine = $line; - - parent::__construct(sprintf("Cannot extract another token at line %d.", $line)); + parent::__construct(sprintf("Cannot extract another token at line %d.", $sourceLine)); } /** - * Returns the source line number where the exception occured. - * - * @return int The source line number. + * Returns the source line number where the exception occurred. */ public function getSourceLine(): int { diff --git a/src/Lexer/Recognizer/Recognizer.php b/src/Lexer/Recognizer/Recognizer.php index 2b6464d..32ef722 100644 --- a/src/Lexer/Recognizer/Recognizer.php +++ b/src/Lexer/Recognizer/Recognizer.php @@ -13,14 +13,12 @@ interface Recognizer { /** - * Returns a boolean value specifying whether - * the string matches or not and if it does, - * returns the match in the second variable. + * Attempts to match the beginning of the string. + * Returns the matched value on success, or null on failure. * * @param string $string The string to match. - * @param ?string $result The variable that gets set to the value of the match. * - * @return boolean Whether the match was successful or not. + * @return string|null The matched value, or null if no match. */ - public function match(string $string, ?string &$result = null): bool; + public function match(string $string): ?string; } diff --git a/src/Lexer/Recognizer/RegexRecognizer.php b/src/Lexer/Recognizer/RegexRecognizer.php index 7956de2..1dea5aa 100644 --- a/src/Lexer/Recognizer/RegexRecognizer.php +++ b/src/Lexer/Recognizer/RegexRecognizer.php @@ -13,31 +13,19 @@ */ class RegexRecognizer implements Recognizer { - protected string $regex; - - /** - * Constructor. - * - * @param string $regex The regex to use in the match. - */ - public function __construct(string $regex) - { - $this->regex = $regex; - } + public function __construct(protected readonly string $regex) {} /** * {@inheritDoc} */ - public function match(string $string, ?string &$result = null): bool + public function match(string $string): ?string { $r = preg_match($this->regex, $string, $match, PREG_OFFSET_CAPTURE); if ($r === 1 && $match[0][1] === 0) { - $result = $match[0][0]; - - return true; + return $match[0][0]; } - return false; + return null; } } diff --git a/src/Lexer/Recognizer/SimpleRecognizer.php b/src/Lexer/Recognizer/SimpleRecognizer.php index a03a5a7..605aef3 100644 --- a/src/Lexer/Recognizer/SimpleRecognizer.php +++ b/src/Lexer/Recognizer/SimpleRecognizer.php @@ -6,36 +6,24 @@ /** * SimpleRecognizer matches a string by a simple - * strpos match. + * strncmp match. * * @author Jakub Lรฉdl * @see \Dissect\Lexer\Recognizer\SimpleRecognizerTest */ class SimpleRecognizer implements Recognizer { - protected string $string; - - /** - * Constructor. - * - * @param string $string The string to match by. - */ - public function __construct(string $string) - { - $this->string = $string; - } + public function __construct(protected readonly string $string) {} /** * {@inheritDoc} */ - public function match(string $string, ?string &$result = null): bool + public function match(string $string): ?string { if (strncmp($string, $this->string, strlen($this->string)) === 0) { - $result = $this->string; - - return true; + return $this->string; } - return false; + return null; } } diff --git a/src/Lexer/RegexLexer.php b/src/Lexer/RegexLexer.php index 673aacc..0e7999f 100644 --- a/src/Lexer/RegexLexer.php +++ b/src/Lexer/RegexLexer.php @@ -7,6 +7,7 @@ use Dissect\Lexer\TokenStream\ArrayTokenStream; use Dissect\Lexer\TokenStream\TokenStream; use Dissect\Parser\Parser; +use RuntimeException; /** * Fast regex lexer, adapted from Doctrine. @@ -19,28 +20,33 @@ */ abstract class RegexLexer implements Lexer { + private ?string $regex = null; + /** * {@inheritDoc} */ public function lex(string $string): TokenStream { - static $regex; - - if (!isset($regex)) { - $regex = '/(' . implode(')|(', $this->getCatchablePatterns()) . ')|' + if ($this->regex === null) { + $this->regex = '/(' . implode(')|(', $this->getCatchablePatterns()) . ')|' . implode('|', $this->getNonCatchablePatterns()) . '/i'; } $string = strtr($string, ["\r\n" => "\n", "\r" => "\n"]); $flags = PREG_SPLIT_NO_EMPTY | PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_OFFSET_CAPTURE; - $matches = preg_split($regex, $string, -1, $flags); + $matches = preg_split($this->regex, $string, -1, $flags); + + if ($matches === false) { + throw new RuntimeException(sprintf('preg_split failed for regex: %s', $this->regex)); + } + $tokens = []; $line = 1; $oldPosition = 0; foreach ($matches as $match) { - list ($value, $position) = $match; + [$value, $position] = $match; $type = $this->getType($value); @@ -60,11 +66,15 @@ public function lex(string $string): TokenStream /** * The patterns corresponding to tokens. + * + * @return string[] */ abstract protected function getCatchablePatterns(): array; /** * The patterns corresponding to tokens to be skipped. + * + * @return string[] */ abstract protected function getNonCatchablePatterns(): array; diff --git a/src/Lexer/SimpleLexer.php b/src/Lexer/SimpleLexer.php index f993365..e8fe88f 100644 --- a/src/Lexer/SimpleLexer.php +++ b/src/Lexer/SimpleLexer.php @@ -4,6 +4,7 @@ namespace Dissect\Lexer; +use Dissect\Lexer\Recognizer\Recognizer; use Dissect\Lexer\Recognizer\RegexRecognizer; use Dissect\Lexer\Recognizer\SimpleRecognizer; use Dissect\Util\Util; @@ -17,10 +18,13 @@ */ class SimpleLexer extends AbstractLexer { + /** + * @var list + */ protected array $skipTokens = []; /** - * @var SimpleRecognizer[] + * @var array */ protected array $recognizers = []; @@ -28,13 +32,10 @@ class SimpleLexer extends AbstractLexer * Adds a new token definition. If given only one argument, * it assumes that the token type and recognized value are * identical. - * - * @param string $type The token type. - * @param string|null $value The value to be recognized. */ - public function token(string $type, ?string $value = null): self + public function token(string $type, ?string $value = null): static { - if ($value) { + if ($value !== null) { $this->recognizers[$type] = new SimpleRecognizer($value); } else { $this->recognizers[$type] = new SimpleRecognizer($type); @@ -45,9 +46,6 @@ public function token(string $type, ?string $value = null): self /** * Adds a new regex token definition. - * - * @param string $type The token type. - * @param string $regex The regular expression used to match the token. */ public function regex(string $type, string $regex): static { @@ -63,7 +61,7 @@ public function regex(string $type, string $regex): static */ public function skip(mixed ...$types): static { - $this->skipTokens = $types; + $this->skipTokens = array_values($types); return $this; } @@ -81,10 +79,12 @@ protected function shouldSkipToken(Token $token): bool */ protected function extractToken(string $string): ?Token { - $value = $type = null; + $value = null; + $type = null; foreach ($this->recognizers as $t => $recognizer) { - if ($recognizer->match($string, $v)) { + $v = $recognizer->match($string); + if ($v !== null) { if ($value === null || Util::stringLength($v) > Util::stringLength($value)) { $value = $v; $type = $t; @@ -92,7 +92,7 @@ protected function extractToken(string $string): ?Token } } - if ($type !== null) { + if ($type !== null && $value !== null) { return new CommonToken($type, $value, $this->getCurrentLine()); } diff --git a/src/Lexer/StatefulLexer.php b/src/Lexer/StatefulLexer.php index 1190c35..9dd2f11 100644 --- a/src/Lexer/StatefulLexer.php +++ b/src/Lexer/StatefulLexer.php @@ -4,6 +4,7 @@ namespace Dissect\Lexer; +use Dissect\Lexer\Recognizer\Recognizer; use Dissect\Lexer\Recognizer\RegexRecognizer; use Dissect\Lexer\Recognizer\SimpleRecognizer; use Dissect\Util\Util; @@ -18,9 +19,28 @@ */ class StatefulLexer extends AbstractLexer { - protected array $states = []; + /** + * @var array> + */ + protected array $stateRecognizers = []; + + /** + * @var array> + */ + protected array $stateActions = []; + + /** + * @var array> + */ + protected array $stateSkipTokens = []; + + /** + * @var list + */ protected array $stateStack = []; + protected ?string $stateBeingBuilt = null; + protected ?string $typeBeingBuilt = null; /** @@ -38,11 +58,8 @@ class StatefulLexer extends AbstractLexer * Adds a new token definition. If given only one argument, * it assumes that the token type and recognized value are * identical. - * - * @param string $type The token type. - * @param string|null $value The value to be recognized. */ - public function token(string $type, ?string $value = null): StatefulLexer + public function token(string $type, ?string $value = null): static { if ($this->stateBeingBuilt === null) { throw new LogicException("Define a lexer state first."); @@ -52,10 +69,8 @@ public function token(string $type, ?string $value = null): StatefulLexer $value = $type; } - $this->states[$this->stateBeingBuilt]['recognizers'][$type] = - new SimpleRecognizer($value); - - $this->states[$this->stateBeingBuilt]['actions'][$type] = self::NO_ACTION; + $this->stateRecognizers[$this->stateBeingBuilt][$type] = new SimpleRecognizer($value); + $this->stateActions[$this->stateBeingBuilt][$type] = self::NO_ACTION; $this->typeBeingBuilt = $type; @@ -64,20 +79,15 @@ public function token(string $type, ?string $value = null): StatefulLexer /** * Adds a new regex token definition. - * - * @param string $type The token type. - * @param string $regex The regular expression used to match the token. */ - public function regex(string $type, string $regex): AbstractLexer + public function regex(string $type, string $regex): static { if ($this->stateBeingBuilt === null) { throw new LogicException("Define a lexer state first."); } - $this->states[$this->stateBeingBuilt]['recognizers'][$type] = - new RegexRecognizer($regex); - - $this->states[$this->stateBeingBuilt]['actions'][$type] = self::NO_ACTION; + $this->stateRecognizers[$this->stateBeingBuilt][$type] = new RegexRecognizer($regex); + $this->stateActions[$this->stateBeingBuilt][$type] = self::NO_ACTION; $this->typeBeingBuilt = $type; @@ -89,41 +99,35 @@ public function regex(string $type, string $regex): AbstractLexer * * @param mixed $types Unlimited number of token types. */ - public function skip(mixed ...$types): StatefulLexer + public function skip(mixed ...$types): static { if ($this->stateBeingBuilt === null) { throw new LogicException("Define a lexer state first."); } - $this->states[$this->stateBeingBuilt]['skip_tokens'] = $types; + $this->stateSkipTokens[$this->stateBeingBuilt] = array_values($types); return $this; } /** * Registers a new lexer state. - * - * @param string $state The new state name. */ - public function state(string $state): StatefulLexer + public function state(string $state): static { $this->stateBeingBuilt = $state; - $this->states[$state] = [ - 'recognizers' => [], - 'actions' => [], - 'skip_tokens' => [], - ]; + $this->stateRecognizers[$state] = []; + $this->stateActions[$state] = []; + $this->stateSkipTokens[$state] = []; return $this; } /** * Sets the starting state for the lexer. - * - * @param string $state The name of the starting state. */ - public function start(string $state): StatefulLexer + public function start(string $state): static { $this->stateStack[] = $state; @@ -135,13 +139,13 @@ public function start(string $state): StatefulLexer * * @param mixed $action The action to take. */ - public function action(mixed $action): StatefulLexer + public function action(mixed $action): static { if ($this->stateBeingBuilt === null || $this->typeBeingBuilt === null) { throw new LogicException("Define a lexer state and type first."); } - $this->states[$this->stateBeingBuilt]['actions'][$this->typeBeingBuilt] = $action; + $this->stateActions[$this->stateBeingBuilt][$this->typeBeingBuilt] = $action; return $this; } @@ -151,9 +155,9 @@ public function action(mixed $action): StatefulLexer */ protected function shouldSkipToken(Token $token): bool { - $state = $this->states[$this->stateStack[count($this->stateStack) - 1]]; + $currentState = $this->stateStack[count($this->stateStack) - 1]; - return in_array($token->getType(), $state['skip_tokens']); + return in_array($token->getType(), $this->stateSkipTokens[$currentState]); } /** @@ -165,22 +169,26 @@ protected function extractToken(string $string): ?Token throw new LogicException("You must set a starting state before lexing."); } - $value = $type = $action = null; - $state = $this->states[$this->stateStack[count($this->stateStack) - 1]]; + $currentState = $this->stateStack[count($this->stateStack) - 1]; + $recognizers = $this->stateRecognizers[$currentState]; + $actions = $this->stateActions[$currentState]; + + $value = null; + $type = null; + $action = null; - foreach ($state['recognizers'] as $t => $recognizer) { - /** @var string $t */ - /** @var SimpleRecognizer|RegexRecognizer $recognizer */ - if ($recognizer->match($string, $v)) { + foreach ($recognizers as $t => $recognizer) { + $v = $recognizer->match($string); + if ($v !== null) { if ($value === null || Util::stringLength($v) > Util::stringLength($value)) { $value = $v; $type = $t; - $action = $state['actions'][$type]; + $action = $actions[$type]; } } } - if ($type !== null) { + if ($type !== null && $value !== null) { if (is_string($action)) { // enter new state $this->stateStack[] = $action; } elseif ($action === self::POP_STATE) { diff --git a/src/Lexer/Token.php b/src/Lexer/Token.php index 21c04da..b980454 100644 --- a/src/Lexer/Token.php +++ b/src/Lexer/Token.php @@ -14,7 +14,7 @@ interface Token /** * Returns the token type. */ - public function getType(): mixed; + public function getType(): string; /** * Returns the token value. diff --git a/src/Lexer/TokenStream/ArrayTokenStream.php b/src/Lexer/TokenStream/ArrayTokenStream.php index c31b631..aad1bf3 100644 --- a/src/Lexer/TokenStream/ArrayTokenStream.php +++ b/src/Lexer/TokenStream/ArrayTokenStream.php @@ -62,7 +62,7 @@ public function lookAhead(int $n): Token /** * {@inheritDoc} */ - public function get($n): Token + public function get(int $n): Token { if (isset($this->tokens[$n])) { return $this->tokens[$n]; @@ -74,7 +74,7 @@ public function get($n): Token /** * {@inheritDoc} */ - public function move($n): void + public function move(int $n): void { if (!isset($this->tokens[$n])) { throw new OutOfBoundsException('Invalid index to move to.'); @@ -86,7 +86,7 @@ public function move($n): void /** * {@inheritDoc} */ - public function seek($n): void + public function seek(int $n): void { if (!isset($this->tokens[$this->position + $n])) { throw new OutOfBoundsException('Invalid seek.'); @@ -112,6 +112,9 @@ public function count(): int return count($this->tokens); } + /** + * @return ArrayIterator + */ public function getIterator(): ArrayIterator { return new ArrayIterator($this->tokens); diff --git a/src/Lexer/TokenStream/TokenStream.php b/src/Lexer/TokenStream/TokenStream.php index da47d2b..f194a8a 100644 --- a/src/Lexer/TokenStream/TokenStream.php +++ b/src/Lexer/TokenStream/TokenStream.php @@ -13,6 +13,8 @@ * A common contract for all token stream classes. * * @author Jakub Lรฉdl + * + * @extends IteratorAggregate */ interface TokenStream extends Countable, IteratorAggregate { diff --git a/src/Node/CommonNode.php b/src/Node/CommonNode.php index 851d2bf..711e366 100644 --- a/src/Node/CommonNode.php +++ b/src/Node/CommonNode.php @@ -14,15 +14,24 @@ */ class CommonNode implements Node { + /** + * @var array + */ protected array $nodes; + + /** + * @var array + */ protected array $attributes; + + /** + * @var array + */ protected array $children = []; /** - * Constructor. - * - * @param array $attributes The attributes of this node. - * @param array $nodes The children of this node. + * @param array $attributes The attributes of this node. + * @param array $nodes The children of this node. */ public function __construct(array $attributes = [], array $nodes = []) { @@ -55,7 +64,7 @@ public function getNode(int|string $name): Node throw new RuntimeException(sprintf('No child node "%s" exists.', $name)); } - return $this->nodes[$name]; + return $this->children[$name]; } /** @@ -123,6 +132,9 @@ public function count(): int return count($this->children); } + /** + * @return ArrayIterator + */ public function getIterator(): ArrayIterator { return new ArrayIterator($this->children); diff --git a/src/Node/Node.php b/src/Node/Node.php index dd22e48..a3d980b 100644 --- a/src/Node/Node.php +++ b/src/Node/Node.php @@ -12,89 +12,66 @@ * A basic contract for a node in an AST. * * @author Jakub Lรฉdl + * + * @template-extends IteratorAggregate */ interface Node extends Countable, IteratorAggregate { /** * Returns the children of this node. * - * @return array The children belonging to this node. + * @return array */ public function getNodes(): array; /** * Checks for existence of child node named $name. - * - * @param string $name The name of the child node. - * - * @return boolean If the node exists. */ public function hasNode(string $name): bool; /** * Returns a child node specified by $name. * - * @param int|string $name The name of the node. - * - * @return Node The child node specified by $name. - * * @throws RuntimeException When no child node named $name exists. */ public function getNode(int|string $name): Node; /** * Sets a child node. - * - * @param string $name The name. - * @param Node $child The new child node. */ - public function setNode(string $name, Node $child); + public function setNode(string $name, Node $child): void; /** * Removes a child node by name. - * - * @param string $name The name. */ - public function removeNode(string $name); + public function removeNode(string $name): void; /** * Returns all attributes of this node. * - * @return array The attributes. + * @return array */ public function getAttributes(): array; /** - * Determines whether this node has an attribute - * under $key. - * - * @param string $key The key. - * @return boolean Whether there's an attribute under $key. + * Determines whether this node has an attribute under $key. */ public function hasAttribute(string $key): bool; /** * Gets an attribute by key. * - * @param string $key The key. - * @return mixed The attribute value. - * * @throws RuntimeException When no attribute exists under $key. */ public function getAttribute(string $key): mixed; /** * Sets an attribute by key. - * - * @param string $key The key. - * @param mixed $value The new value. */ - public function setAttribute(string $key, mixed $value); + public function setAttribute(string $key, mixed $value): void; /** * Removes an attribute by key. - * - * @param string $key The key. */ - public function removeAttribute(string $key); + public function removeAttribute(string $key): void; } diff --git a/src/Parser/Grammar.php b/src/Parser/Grammar.php index e795b36..88421ee 100644 --- a/src/Parser/Grammar.php +++ b/src/Parser/Grammar.php @@ -26,10 +26,13 @@ class Grammar public const EPSILON = '$epsilon'; /** - * @var Rule[] + * @var array */ protected array $rules = []; + /** + * @var array> + */ protected array $groupedRules = []; protected int $nextRuleNumber = 1; @@ -38,15 +41,16 @@ class Grammar protected ?string $currentNonterminal = null; - /** - * @var string[] - */ - private array $nonterminals = []; - protected ?Rule $currentRule = null; + /** + * @var array + */ protected array $operators = []; + /** + * @var list|null + */ protected ?array $currentOperators = null; /** @@ -78,7 +82,7 @@ class Grammar /** * Signifies that the conflicts should be - * resolved by taking operator precendence + * resolved by taking operator precedence * into account. */ public const OPERATORS = 8; @@ -115,8 +119,6 @@ public function __invoke(string $nonterminal): static * Defines an alternative for a grammar rule. * * @param string ...$components The components of the rule. - * - * @return Grammar This instance. */ public function is(string ...$components): static { @@ -132,10 +134,8 @@ public function is(string ...$components): static $rule = new Rule($num, $this->currentNonterminal, $components); - $this->rules[$num] = - $this->currentRule = - $this->groupedRules[$this->currentNonterminal][] = - $rule; + $this->rules[$num] = $this->currentRule = $rule; + $this->groupedRules[$this->currentNonterminal][] = $rule; return $this; } @@ -144,8 +144,6 @@ public function is(string ...$components): static * Sets the callback for the current rule. * * @param callable $callback The callback. - * - * @return Grammar This instance. */ public function call(callable $callback): static { @@ -163,32 +161,22 @@ public function call(callable $callback): static /** * Returns the set of rules of this grammar. * - * @return Rule[] The rules. + * @return array */ public function getRules(): array { return $this->rules; } - public function getRule($number): Rule + public function getRule(int $number): Rule { return $this->rules[$number]; } - /** - * Returns the nonterminal symbols of this grammar. - * - * @return string[] The nonterminals. - */ - public function getNonterminals(): array - { - return $this->nonterminals; - } - /** * Returns rules grouped by nonterminal name. * - * @return array The rules grouped by nonterminal name. + * @return array> */ public function getGroupedRules(): array { @@ -207,8 +195,6 @@ public function start(string $name): void /** * Returns the augmented start rule. For internal use only. - * - * @return Rule The start rule. */ public function getStartRule(): Rule { @@ -241,8 +227,6 @@ public function getConflictsMode(): int /** * Does a nonterminal $name exist in the grammar? - * - * @param string $name The name of the nonterminal. */ public function hasNonterminal(string $name): bool { @@ -253,14 +237,11 @@ public function hasNonterminal(string $name): bool * Defines a group of operators. * * @param string ...$ops Any number of tokens that serve as the operators. - * - * @return Grammar This instance for fluent interface. */ public function operators(string ...$ops): static { $this->currentRule = null; - - $this->currentOperators = $ops; + $this->currentOperators = array_values($ops); foreach ($ops as $op) { $this->operators[$op] = [ @@ -274,8 +255,6 @@ public function operators(string ...$ops): static /** * Marks the current group of operators as left-associative. - * - * @return Grammar This instance for fluent interface. */ public function left(): static { @@ -284,8 +263,6 @@ public function left(): static /** * Marks the current group of operators as right-associative. - * - * @return Grammar This instance for fluent interface. */ public function right(): static { @@ -294,8 +271,6 @@ public function right(): static /** * Marks the current group of operators as nonassociative. - * - * @return Grammar This instance for fluent interface. */ public function nonassoc(): static { @@ -303,15 +278,13 @@ public function nonassoc(): static } /** - * Explicitly sets the associatity of the current group of operators. + * Explicitly sets the associativity of the current group of operators. * * @param int $a One of Grammar::LEFT, Grammar::RIGHT, Grammar::NONASSOC - * - * @return Grammar This instance for fluent interface. */ public function assoc(int $a): static { - if (!$this->currentOperators) { + if ($this->currentOperators === null) { throw new LogicException('Define a group of operators first.'); } @@ -328,13 +301,11 @@ public function assoc(int $a): static * of the currently described rule. * * @param int $i The precedence as an integer. - * - * @return Grammar This instance for fluent interface. */ public function prec(int $i): static { - if (!$this->currentOperators) { - if (!$this->currentRule) { + if ($this->currentOperators === null) { + if ($this->currentRule === null) { throw new LogicException('Define a group of operators or a rule first.'); } else { $this->currentRule->setPrecedence($i); @@ -350,15 +321,16 @@ public function prec(int $i): static /** * Is the passed token an operator? - * - * @param string $token The token type. */ public function hasOperator(string $token): bool { return array_key_exists($token, $this->operators); } - public function getOperatorInfo($token) + /** + * @return array{prec: int, assoc: int} + */ + public function getOperatorInfo(string $token): array { return $this->operators[$token]; } diff --git a/src/Parser/LALR1/Analysis/AnalysisResult.php b/src/Parser/LALR1/Analysis/AnalysisResult.php index a215abc..f9d8284 100644 --- a/src/Parser/LALR1/Analysis/AnalysisResult.php +++ b/src/Parser/LALR1/Analysis/AnalysisResult.php @@ -4,32 +4,26 @@ namespace Dissect\Parser\LALR1\Analysis; +use Dissect\Parser\Rule; + /** * The result of a grammar analysis. * * @author Jakub Lรฉdl + * + * @phpstan-type ResolvedConflict array{state: int, lookahead: string, rule?: Rule, rules?: array, resolution: int} */ class AnalysisResult { - protected Automaton $automaton; - - protected array $parseTable; - - protected array $resolvedConflicts; - /** - * Constructor. - * - * @param array $parseTable The parse table. - * @param array $conflicts An array of conflicts resolved during parse table - * construction. + * @param array{action: array>, goto: array>} $parseTable The parse table. + * @param list $resolvedConflicts An array of conflicts resolved during parse table construction. */ - public function __construct(array $parseTable, Automaton $automaton, array $conflicts) - { - $this->parseTable = $parseTable; - $this->automaton = $automaton; - $this->resolvedConflicts = $conflicts; - } + public function __construct( + protected readonly array $parseTable, + protected readonly Automaton $automaton, + protected readonly array $resolvedConflicts + ) {} /** * Returns the handle-finding FSA. @@ -42,7 +36,7 @@ public function getAutomaton(): Automaton /** * Returns the resulting parse table. * - * @return array The parse table. + * @return array{action: array>, goto: array>} */ public function getParseTable(): array { @@ -52,7 +46,7 @@ public function getParseTable(): array /** * Returns an array of resolved parse table conflicts. * - * @return array The conflicts. + * @return list, resolution: int}> */ public function getResolvedConflicts(): array { diff --git a/src/Parser/LALR1/Analysis/Analyzer.php b/src/Parser/LALR1/Analysis/Analyzer.php index a6fefe4..f21f761 100644 --- a/src/Parser/LALR1/Analysis/Analyzer.php +++ b/src/Parser/LALR1/Analysis/Analyzer.php @@ -9,6 +9,7 @@ use Dissect\Parser\LALR1\Analysis\KernelSet\KernelSet; use Dissect\Parser\Grammar; use Dissect\Parser\Parser; +use Dissect\Parser\Rule; use Dissect\Util\Util; use SplQueue; @@ -24,24 +25,18 @@ class Analyzer /** * Performs a grammar analysis. * - * @param Grammar $grammar The grammar to analyse. - * * @return AnalysisResult The result of the analysis. */ public function analyze(Grammar $grammar): AnalysisResult { $automaton = $this->buildAutomaton($grammar); - list($parseTable, $conflicts) = $this->buildParseTable($automaton, $grammar); + [$parseTable, $conflicts] = $this->buildParseTable($automaton, $grammar); return new AnalysisResult($parseTable, $automaton, $conflicts); } /** * Builds the handle-finding FSA from the grammar. - * - * @param Grammar $grammar The grammar. - * - * @return Automaton The resulting automaton. */ protected function buildAutomaton(Grammar $grammar): Automaton { @@ -49,6 +44,7 @@ protected function buildAutomaton(Grammar $grammar): Automaton $automaton = new Automaton(); // the queue of states that need processing + /** @var SplQueue $queue */ $queue = new SplQueue(); // the BST for state kernels @@ -60,12 +56,11 @@ protected function buildAutomaton(Grammar $grammar): Automaton // FIRST sets of nonterminals $firstSets = $this->calculateFirstSets($groupedRules); - // keeps a list of tokens that need to be pumped - // through the automaton + // keeps a list of tokens that need to be pumped through the automaton + // @var list $pumpings = []; - // the item from which the whole automaton - // is derived + // the item from which the whole automaton is derived $initialItem = new Item($grammar->getStartRule(), 0); // construct the initial state @@ -73,8 +68,7 @@ protected function buildAutomaton(Grammar $grammar): Automaton [$initialItem->getRule()->getNumber(), $initialItem->getDotIndex()], ]), [$initialItem]); - // the initial item automatically has EOF - // as its lookahead + // the initial item automatically has EOF as its lookahead $pumpings[] = [$initialItem, [Parser::EOF_TOKEN_TYPE]]; $queue->enqueue($state); @@ -84,12 +78,14 @@ protected function buildAutomaton(Grammar $grammar): Automaton $state = $queue->dequeue(); // items of this state are grouped by - // the active component to calculate - // transitions easily + // the active component to calculate transitions easily + /** @var array> $groupedItems */ $groupedItems = []; // calculate closure + /** @var list $added */ $added = []; + /** @var list $currentItems */ $currentItems = $state->getItems(); for ($x = 0; $x < count($currentItems); $x++) { $item = $currentItems[$x]; @@ -102,6 +98,7 @@ protected function buildAutomaton(Grammar $grammar): Automaton if ($grammar->hasNonterminal($component)) { // calculate lookahead + /** @var string[] $lookahead */ $lookahead = []; $cs = $item->getUnrecognizedComponents(); @@ -123,7 +120,6 @@ protected function buildAutomaton(Grammar $grammar): Automaton break; } else { // if it does - if ($i < (count($cs) - 1)) { // if more components ahead, remove epsilon unset($new[array_search(Grammar::EPSILON, $new)]); @@ -156,7 +152,7 @@ protected function buildAutomaton(Grammar $grammar): Automaton foreach ($groupedRules[$component] as $rule) { if (!in_array($component, $added)) { - // if $component hasn't yet been expaned, + // if $component hasn't yet been expanded, // create new items for it $newItem = new Item($rule, 0); @@ -187,6 +183,7 @@ protected function buildAutomaton(Grammar $grammar): Automaton // calculate transitions foreach ($groupedItems as $thisComponent => $theseItems) { + /** @var list $newKernel */ $newKernel = []; foreach ($theseItems as $thisItem) { @@ -215,7 +212,7 @@ protected function buildAutomaton(Grammar $grammar): Automaton } } else { // new state needs to be created - $newState = new State($num, array_map(function (Item $i) { + $newState = new State($num, array_map(function (Item $i): Item { $new = new Item($i->getRule(), $i->getDotIndex() + 1); // connect the two items @@ -233,8 +230,8 @@ protected function buildAutomaton(Grammar $grammar): Automaton } // pump all the lookahead tokens - foreach ($pumpings as $pumping) { - $pumping[0]->pumpAll($pumping[1]); + foreach ($pumpings as [$pumpItem, $pumpLookahead]) { + $pumpItem->pumpAll($pumpLookahead); } return $automaton; @@ -243,16 +240,23 @@ protected function buildAutomaton(Grammar $grammar): Automaton /** * Encodes the handle-finding FSA as a LR parse table. * - * - * @return array The parse table. + * @return array{ + * array{action: array>, goto: array>}, + * list + * } */ protected function buildParseTable(Automaton $automaton, Grammar $grammar): array { $conflictsMode = $grammar->getConflictsMode(); + + /** @var list $conflicts */ $conflicts = []; + + /** @var array> $errors */ $errors = []; // initialize the table + /** @var array{action: array>, goto: array>} $table */ $table = [ 'action' => [], 'goto' => [], @@ -283,7 +287,6 @@ protected function buildParseTable(Automaton $automaton, Grammar $grammar): arra if (isset($errors[$num]) && isset($errors[$num][$token])) { // there was a previous conflict resolved as an error // entry for this token. - continue; } @@ -336,8 +339,7 @@ protected function buildParseTable(Automaton $automaton, Grammar $grammar): arra // the token is nonassociative. // this actually means an input error, so // remove the shift entry from the table - // and mark this as an explicit error - // entry + // and mark this as an explicit error entry unset($table['action'][$num][$token]); $errors[$num][$token] = true; } @@ -423,6 +425,7 @@ protected function buildParseTable(Automaton $automaton, Grammar $grammar): arra } else { // new rule was earlier $table['action'][$num][$token] = -$ruleNumber; + $resolvedRules = [$newRule, $originalRule]; $conflicts[] = [ 'state' => $num, @@ -430,8 +433,6 @@ protected function buildParseTable(Automaton $automaton, Grammar $grammar): arra 'rules' => $resolvedRules, 'resolution' => Grammar::EARLIER_REDUCE, ]; - $resolvedRules = [$newRule, $originalRule]; - } continue; } @@ -459,13 +460,14 @@ protected function buildParseTable(Automaton $automaton, Grammar $grammar): arra /** * Calculates the FIRST sets of all nonterminals. * - * @param array $rules The rules grouped by the LHS. + * @param array> $rules The rules grouped by the LHS. * - * @return array Calculated FIRST sets. + * @return array Calculated FIRST sets. */ protected function calculateFirstSets(array $rules): array { // initialize + /** @var array $firstSets */ $firstSets = []; foreach (array_keys($rules) as $lhs) { @@ -478,6 +480,7 @@ protected function calculateFirstSets(array $rules): array foreach ($rules as $lhs => $ruleArray) { foreach ($ruleArray as $rule) { $components = $rule->getComponents(); + /** @var string[] $new */ $new = []; if (empty($components)) { diff --git a/src/Parser/LALR1/Analysis/Automaton.php b/src/Parser/LALR1/Analysis/Automaton.php index 03c4eb3..a52dc7d 100644 --- a/src/Parser/LALR1/Analysis/Automaton.php +++ b/src/Parser/LALR1/Analysis/Automaton.php @@ -13,14 +13,18 @@ */ class Automaton { + /** + * @var array + */ protected array $states = []; + /** + * @var array> + */ protected array $transitionTable = []; /** * Adds a new automaton state. - * - * @param State $state The new state. */ public function addState(State $state): void { @@ -41,10 +45,6 @@ public function addTransition(int $origin, string $label, int $dest): void /** * Returns a state by its number. - * - * @param int $number The state number. - * - * @return State The requested state. */ public function getState(int $number): State { @@ -53,10 +53,8 @@ public function getState(int $number): State /** * Does this automaton have a state identified by $number? - * - * @param $number */ - public function hasState($number): bool + public function hasState(int $number): bool { return isset($this->states[$number]); } @@ -64,7 +62,7 @@ public function hasState($number): bool /** * Returns all states in this FSA. * - * @return array The states of this FSA. + * @return array */ public function getStates(): array { @@ -74,7 +72,7 @@ public function getStates(): array /** * Returns the transition table for this automaton. * - * @return array The transition table. + * @return array> */ public function getTransitionTable(): array { diff --git a/src/Parser/LALR1/Analysis/Exception/ConflictException.php b/src/Parser/LALR1/Analysis/Exception/ConflictException.php index 9ad3e8f..ddcb5e6 100644 --- a/src/Parser/LALR1/Analysis/Exception/ConflictException.php +++ b/src/Parser/LALR1/Analysis/Exception/ConflictException.php @@ -17,8 +17,8 @@ class ConflictException extends LogicException { public function __construct( string $message, - protected int $state, - protected Automaton $automaton + protected readonly int $state, + protected readonly Automaton $automaton ) { parent::__construct($message); } diff --git a/src/Parser/LALR1/Analysis/Exception/ReduceReduceConflictException.php b/src/Parser/LALR1/Analysis/Exception/ReduceReduceConflictException.php index 2907dde..893f74a 100644 --- a/src/Parser/LALR1/Analysis/Exception/ReduceReduceConflictException.php +++ b/src/Parser/LALR1/Analysis/Exception/ReduceReduceConflictException.php @@ -30,23 +30,13 @@ class ReduceReduceConflictException extends ConflictException (on lookahead "%s" in state %d). Restructure your grammar or choose a conflict resolution mode. EOT; - protected Rule $firstRule; - - protected Rule $secondRule; - - protected string $lookahead; - - /** - * Constructor. - * - * @param int $state The number of the inadequate state. - * @param Rule $firstRule The first conflicting grammar rule. - * @param Rule $secondRule The second conflicting grammar rule. - * @param string $lookahead The conflicting lookahead. - * @param Automaton $automaton The faulty automaton. - */ - public function __construct(int $state, Rule $firstRule, Rule $secondRule, string $lookahead, Automaton $automaton) - { + public function __construct( + int $state, + protected readonly Rule $firstRule, + protected readonly Rule $secondRule, + protected readonly string $lookahead, + Automaton $automaton + ) { $components1 = $firstRule->getComponents(); $components2 = $secondRule->getComponents(); @@ -65,16 +55,10 @@ public function __construct(int $state, Rule $firstRule, Rule $secondRule, strin $state, $automaton ); - - $this->firstRule = $firstRule; - $this->secondRule = $secondRule; - $this->lookahead = $lookahead; } /** * Returns the first conflicting rule. - * - * @return Rule The first conflicting rule. */ public function getFirstRule(): Rule { @@ -83,8 +67,6 @@ public function getFirstRule(): Rule /** * Returns the second conflicting rule. - * - * @return Rule The second conflicting rule. */ public function getSecondRule(): Rule { @@ -93,8 +75,6 @@ public function getSecondRule(): Rule /** * Returns the conflicting lookahead. - * - * @return string The conflicting lookahead. */ public function getLookahead(): string { diff --git a/src/Parser/LALR1/Analysis/Exception/ShiftReduceConflictException.php b/src/Parser/LALR1/Analysis/Exception/ShiftReduceConflictException.php index a9e93c6..b3c022b 100644 --- a/src/Parser/LALR1/Analysis/Exception/ShiftReduceConflictException.php +++ b/src/Parser/LALR1/Analysis/Exception/ShiftReduceConflictException.php @@ -26,19 +26,12 @@ class ShiftReduceConflictException extends ConflictException (on lookahead "%s" in state %d). Restructure your grammar or choose a conflict resolution mode. EOT; - protected Rule $rule; - - protected string $lookahead; - - /** - * Constructor. - * - * @param Rule $rule The conflicting grammar rule. - * @param string $lookahead The conflicting lookahead to shift. - * @param Automaton $automaton The faulty automaton. - */ - public function __construct($state, Rule $rule, $lookahead, Automaton $automaton) - { + public function __construct( + int $state, + protected readonly Rule $rule, + protected readonly string $lookahead, + Automaton $automaton + ) { $components = $rule->getComponents(); parent::__construct( @@ -53,15 +46,10 @@ public function __construct($state, Rule $rule, $lookahead, Automaton $automaton $state, $automaton ); - - $this->rule = $rule; - $this->lookahead = $lookahead; } /** * Returns the conflicting rule. - * - * @return Rule The conflicting rule. */ public function getRule(): Rule { @@ -70,8 +58,6 @@ public function getRule(): Rule /** * Returns the conflicting lookahead. - * - * @return string The conflicting lookahead. */ public function getLookahead(): string { diff --git a/src/Parser/LALR1/Analysis/Item.php b/src/Parser/LALR1/Analysis/Item.php index c517731..30bb437 100644 --- a/src/Parser/LALR1/Analysis/Item.php +++ b/src/Parser/LALR1/Analysis/Item.php @@ -33,30 +33,19 @@ */ class Item { - protected Rule $rule; - - protected int $dotIndex; - + /** @var string[] */ protected array $lookahead = []; + /** @var Item[] */ protected array $connected = []; - /** - * Constructor. - * - * @param Rule $rule The rule of this item. - * @param int $dotIndex The index of the dot in this item. - */ - public function __construct(Rule $rule, int $dotIndex) - { - $this->rule = $rule; - $this->dotIndex = $dotIndex; - } + public function __construct( + protected readonly Rule $rule, + protected readonly int $dotIndex + ) {} /** * Returns the dot index of this item. - * - * @return int The dot index. */ public function getDotIndex(): int { @@ -73,18 +62,16 @@ public function getDotIndex(): int * * * then this method returns the component "b". - * - * @return string The component. + * Only valid when !isReduceItem(). */ public function getActiveComponent(): string { - return $this->rule->getComponent($this->dotIndex); + return $this->rule->getComponent($this->dotIndex) + ?? throw new \LogicException('No active component: item is a reduce item.'); } /** * Returns the rule of this item. - * - * @return Rule The rule. */ public function getRule(): Rule { @@ -99,8 +86,6 @@ public function getRule(): Rule *
      * A -> a b c .
      * 
- * - * @return boolean Whether this item is a reduce item. */ public function isReduceItem(): bool { @@ -109,8 +94,6 @@ public function isReduceItem(): bool /** * Connects two items with a lookahead pumping channel. - * - * @param Item $i The item. */ public function connect(Item $i): void { @@ -118,10 +101,7 @@ public function connect(Item $i): void } /** - * Pumps a lookahead token to this item and all items connected - * to it. - * - * @param string $lookahead The lookahead token name. + * Pumps a lookahead token to this item and all items connected to it. */ public function pump(string $lookahead): void { @@ -137,7 +117,7 @@ public function pump(string $lookahead): void /** * Pumps several lookahead tokens. * - * @param array $lookahead The lookahead tokens. + * @param string[] $lookahead The lookahead tokens. */ public function pumpAll(array $lookahead): void { @@ -157,10 +137,9 @@ public function getLookahead(): array } /** - * Returns all components that haven't been recognized - * so far. + * Returns all components that haven't been recognized so far. * - * @return array The unrecognized components. + * @return string[] The unrecognized components. */ public function getUnrecognizedComponents(): array { diff --git a/src/Parser/LALR1/Analysis/KernelSet/KernelSet.php b/src/Parser/LALR1/Analysis/KernelSet/KernelSet.php index 230f448..02272ad 100644 --- a/src/Parser/LALR1/Analysis/KernelSet/KernelSet.php +++ b/src/Parser/LALR1/Analysis/KernelSet/KernelSet.php @@ -22,7 +22,7 @@ class KernelSet * exists. Otherwise, returns the number of the * existing state. * - * @param array $kernel The state kernel. + * @param array $kernel The state kernel. * * @return int The state number. */ @@ -64,16 +64,16 @@ public function insert(array $kernel): int /** * Hashes a state kernel using a pairing function. * - * @param array $kernel The kernel. + * @param array $kernel The kernel. * - * @return array The hashed kernel. + * @return int[] The hashed kernel. */ public static function hashKernel(array $kernel): array { - $kernel = array_map(function ($tuple) { - list ($car, $cdr) = $tuple; + $kernel = array_map(static function (array $tuple): int { + [$car, $cdr] = $tuple; - return ($car + $cdr) * ($car + $cdr + 1) / 2 + $cdr; + return (int) (($car + $cdr) * ($car + $cdr + 1) / 2 + $cdr); }, $kernel); sort($kernel); diff --git a/src/Parser/LALR1/Analysis/KernelSet/Node.php b/src/Parser/LALR1/Analysis/KernelSet/Node.php index 1fa02c7..7c34873 100644 --- a/src/Parser/LALR1/Analysis/KernelSet/Node.php +++ b/src/Parser/LALR1/Analysis/KernelSet/Node.php @@ -6,15 +6,14 @@ class Node { - public array $kernel; - public int $number; - public ?Node $left = null; public ?Node $right = null; - public function __construct(array $hashedKernel, int $number) - { - $this->kernel = $hashedKernel; - $this->number = $number; - } + /** + * @param int[] $kernel + */ + public function __construct( + public readonly array $kernel, + public readonly int $number + ) {} } diff --git a/src/Parser/LALR1/Analysis/State.php b/src/Parser/LALR1/Analysis/State.php index 62e92b8..81eaa1d 100644 --- a/src/Parser/LALR1/Analysis/State.php +++ b/src/Parser/LALR1/Analysis/State.php @@ -12,22 +12,17 @@ */ class State { + /** @var Item[] */ protected array $items = []; + /** @var array> */ protected array $itemMap = []; - protected int $number; - /** - * Constructor. - * - * @param int $number The number identifying this state. - * @param array $items The initial items of this state. + * @param Item[] $items */ - public function __construct(int $number, array $items) + public function __construct(protected readonly int $number, array $items) { - $this->number = $number; - foreach ($items as $item) { $this->add($item); } @@ -35,8 +30,6 @@ public function __construct(int $number, array $items) /** * Adds a new item to this state. - * - * @param Item $item The new item. */ public function add(Item $item): void { @@ -47,11 +40,6 @@ public function add(Item $item): void /** * Returns an item by its rule number and dot index. - * - * @param int $ruleNumber The number of the rule of the desired item. - * @param int $dotIndex The dot index of the desired item. - * - * @return Item The item. */ public function get(int $ruleNumber, int $dotIndex): Item { @@ -69,7 +57,7 @@ public function getNumber(): int /** * Returns an array of items constituting this state. * - * @return array The items. + * @return Item[] */ public function getItems(): array { diff --git a/src/Parser/LALR1/Dumper/AutomatonDumper.php b/src/Parser/LALR1/Dumper/AutomatonDumper.php index 2662a00..ba3a9ea 100644 --- a/src/Parser/LALR1/Dumper/AutomatonDumper.php +++ b/src/Parser/LALR1/Dumper/AutomatonDumper.php @@ -17,15 +17,7 @@ */ class AutomatonDumper { - protected Automaton $automaton; - - /** - * Constructor. - */ - public function __construct(Automaton $automaton) - { - $this->automaton = $automaton; - } + public function __construct(protected readonly Automaton $automaton) {} /** * Dumps the entire automaton. @@ -63,8 +55,7 @@ public function dump(): string } /** - * Dumps only the specified state + any relevant - * transitions. + * Dumps only the specified state + any relevant transitions. * * @param int $n The number of the state. * @@ -105,18 +96,18 @@ public function dumpState(int $n): string return $writer->get(); } - protected function writeHeader(StringWriter $writer, $stateNumber = null): void + protected function writeHeader(StringWriter $writer, ?int $stateNumber = null): void { $writer->writeLine(sprintf( 'digraph %s {', - $stateNumber ? 'State' . $stateNumber : 'Automaton' + $stateNumber !== null ? 'State' . $stateNumber : 'Automaton' )); $writer->indent(); $writer->writeLine('rankdir="LR";'); } - protected function writeState(StringWriter $writer, State $state, $full = true): void + protected function writeState(StringWriter $writer, State $state, bool $full = true): void { $n = $state->getNumber(); diff --git a/src/Parser/LALR1/Dumper/DebugTableDumper.php b/src/Parser/LALR1/Dumper/DebugTableDumper.php index 7e49497..7b189dc 100644 --- a/src/Parser/LALR1/Dumper/DebugTableDumper.php +++ b/src/Parser/LALR1/Dumper/DebugTableDumper.php @@ -16,25 +16,19 @@ */ class DebugTableDumper implements TableDumper { - protected Grammar $grammar; - protected StringWriter $writer; protected bool $written = false; - /** - * Constructor. - * - * @param Grammar $grammar The grammar of this parse table. - */ - public function __construct(Grammar $grammar) + public function __construct(protected readonly Grammar $grammar) { - $this->grammar = $grammar; $this->writer = new StringWriter(); } /** * {@inheritDoc} + * + * @param array{action: array>, goto: array>} $table */ public function dump(array $table): string { @@ -71,7 +65,7 @@ public function dump(array $table): string return $this->writer->get(); } - protected function writeHeader() + protected function writeHeader(): void { $this->writer->writeLine('writer->writeLine(); @@ -80,7 +74,10 @@ protected function writeHeader() $this->writer->writeLine("'action' => ["); } - protected function writeState($n, array $state) + /** + * @param array $state + */ + protected function writeState(int $n, array $state): void { $this->writer->writeLine($n . ' => ['); $this->writer->indent(); @@ -94,7 +91,7 @@ protected function writeState($n, array $state) $this->writer->writeLine('],'); } - protected function writeAction($trigger, $action) + protected function writeAction(string $trigger, int $action): void { if ($action > 0) { $this->writer->writeLine(sprintf( @@ -132,14 +129,17 @@ protected function writeAction($trigger, $action) )); } - protected function writeMiddle() + protected function writeMiddle(): void { $this->writer->writeLine('],'); $this->writer->writeLine(); $this->writer->writeLine("'goto' => ["); } - protected function writeGoto($n, array $map) + /** + * @param array $map + */ + protected function writeGoto(int $n, array $map): void { $this->writer->writeLine($n . ' => ['); $this->writer->indent(); @@ -164,7 +164,7 @@ protected function writeGoto($n, array $map) $this->writer->writeLine('],'); } - protected function writeFooter() + protected function writeFooter(): void { $this->writer->writeLine('],'); $this->writer->outdent(); diff --git a/src/Parser/LALR1/Dumper/ProductionTableDumper.php b/src/Parser/LALR1/Dumper/ProductionTableDumper.php index c4d288f..f72d924 100644 --- a/src/Parser/LALR1/Dumper/ProductionTableDumper.php +++ b/src/Parser/LALR1/Dumper/ProductionTableDumper.php @@ -17,6 +17,8 @@ class ProductionTableDumper implements TableDumper { /** * {@inheritDoc} + * + * @param array{action: array>, goto: array>} $table */ public function dump(array $table): string { @@ -31,7 +33,7 @@ public function dump(array $table): string $this->writeMiddle($writer); - foreach($table['goto'] as $num => $map) { + foreach ($table['goto'] as $num => $map) { $this->writeGoto($writer, $num, $map); $writer->write(','); } @@ -43,12 +45,15 @@ public function dump(array $table): string return $writer->get(); } - protected function writeIntro(StringWriter $writer) + protected function writeIntro(StringWriter $writer): void { $writer->write("["); } - protected function writeState(StringWriter $writer, $num, $state) + /** + * @param array $state + */ + protected function writeState(StringWriter $writer, int $num, array $state): void { $writer->write($num . '=>['); @@ -60,7 +65,7 @@ protected function writeState(StringWriter $writer, $num, $state) $writer->write(']'); } - protected function writeAction(StringWriter $writer, $trigger, $action) + protected function writeAction(StringWriter $writer, string $trigger, int $action): void { $writer->write(sprintf( "'%s'=>%d", @@ -69,12 +74,15 @@ protected function writeAction(StringWriter $writer, $trigger, $action) )); } - protected function writeMiddle(StringWriter $writer) + protected function writeMiddle(StringWriter $writer): void { $writer->write("],'goto'=>["); } - protected function writeGoto(StringWriter $writer, $num, $map) + /** + * @param array $map + */ + protected function writeGoto(StringWriter $writer, int $num, array $map): void { $writer->write($num . '=>['); @@ -91,7 +99,7 @@ protected function writeGoto(StringWriter $writer, $num, $map) $writer->write(']'); } - protected function writeOutro(StringWriter $writer) + protected function writeOutro(StringWriter $writer): void { $writer->write(']];'); } diff --git a/src/Parser/LALR1/Dumper/TableDumper.php b/src/Parser/LALR1/Dumper/TableDumper.php index a567710..f29faa7 100644 --- a/src/Parser/LALR1/Dumper/TableDumper.php +++ b/src/Parser/LALR1/Dumper/TableDumper.php @@ -14,7 +14,7 @@ interface TableDumper /** * Dumps the parse table. * - * @param array $table The parse table. + * @param array{action: array>, goto: array>} $table The parse table. * * @return string The resulting string representation of the table. */ diff --git a/src/Parser/LALR1/Parser.php b/src/Parser/LALR1/Parser.php index ca4ccb6..09472ce 100644 --- a/src/Parser/LALR1/Parser.php +++ b/src/Parser/LALR1/Parser.php @@ -20,19 +20,22 @@ class Parser implements P\Parser { protected Grammar $grammar; + /** + * @var array{action: array>, goto: array>} + */ protected array $parseTable; /** * Constructor. * * @param Grammar $grammar The grammar. - * @param array|null $parseTable If given, the parser doesn't have to analyze the grammar. + * @param array{action: array>, goto: array>}|null $parseTable If given, the parser doesn't have to analyze the grammar. */ public function __construct(Grammar $grammar, ?array $parseTable = null) { $this->grammar = $grammar; - if ($parseTable) { + if ($parseTable !== null) { $this->parseTable = $parseTable; } else { $analyzer = new Analyzer(); @@ -54,7 +57,6 @@ public function parse(TokenStream $stream): mixed if (!isset($this->parseTable['action'][$currentState][$type])) { // unexpected token - throw new UnexpectedTokenException( $token, array_keys($this->parseTable['action'][$currentState]) @@ -65,7 +67,6 @@ public function parse(TokenStream $stream): mixed if ($action > 0) { // shift - $args[] = $token; $stateStack[] = $currentState = $action; @@ -79,7 +80,7 @@ public function parse(TokenStream $stream): mixed $newArgs = array_splice($args, -$popCount); if ($callback = $rule->getCallback()) { - $args[] = call_user_func_array($callback, $newArgs); + $args[] = $callback(...$newArgs); } else { $args[] = $newArgs[0]; } @@ -89,7 +90,6 @@ public function parse(TokenStream $stream): mixed [$state][$rule->getName()]; } else { // accept - return $args[0]; } } diff --git a/src/Parser/Rule.php b/src/Parser/Rule.php index 6a7fde8..0a7426e 100644 --- a/src/Parser/Rule.php +++ b/src/Parser/Rule.php @@ -12,40 +12,24 @@ */ class Rule { - protected int $number; - - protected string $name; - /** - * @var string[] - */ - protected array $components; - - /** - * @var callable + * @var callable|null */ protected $callback = null; protected ?int $precedence = null; /** - * Constructor. - * - * @param int $number The number of the rule in the grammar. - * @param string $name The name (lhs) of the rule ("A" in "A -> a b c") * @param string[] $components The components of this rule. */ - public function __construct(int $number, string $name, array $components) - { - $this->number = $number; - $this->name = $name; - $this->components = $components; - } + public function __construct( + protected readonly int $number, + protected readonly string $name, + protected readonly array $components + ) {} /** * Returns the number of this rule. - * - * @return int The number of this rule. */ public function getNumber(): int { @@ -54,8 +38,6 @@ public function getNumber(): int /** * Returns the name of this rule. - * - * @return string The name of this rule. */ public function getName(): string { @@ -65,7 +47,7 @@ public function getName(): string /** * Returns the components of this rule. * - * @return string[] The components of this rule. + * @return string[] */ public function getComponents(): array { @@ -73,20 +55,11 @@ public function getComponents(): array } /** - * Returns a component at index $index or null - * if index is out of range. - * - * @param int $index The index. - * - * @return ?string The component at index $index. + * Returns a component at index $index or null if index is out of range. */ public function getComponent(int $index): ?string { - if (!isset($this->components[$index])) { - return null; - } - - return $this->components[$index]; + return $this->components[$index] ?? null; } /** @@ -109,7 +82,7 @@ public function getPrecedence(): ?int return $this->precedence; } - public function setPrecedence($i): void + public function setPrecedence(int $i): void { $this->precedence = $i; } diff --git a/src/Util/Util.php b/src/Util/Util.php index 4b4ee1c..61b3a16 100644 --- a/src/Util/Util.php +++ b/src/Util/Util.php @@ -16,20 +16,23 @@ abstract class Util * * {a, b} union {b, c} = {a, b, c} * - * @return array The union of given sets. + * @template T of string + * @param array ...$arrays + * @return array The union of given sets. */ public static function union(array ...$arrays): array { - return array_unique(call_user_func_array('array_merge', $arrays)); + return array_unique(array_merge(...$arrays)); } /** * Determines whether two sets have a difference. * - * @param array $first The first set. - * @param array $second The second set. + * @template T of string + * @param array $first The first set. + * @param array $second The second set. * - * @return boolean Whether there is a difference. + * @return bool Whether there is a difference. */ public static function different(array $first, array $second): bool { @@ -37,15 +40,13 @@ public static function different(array $first, array $second): bool } /** - * Determines length of a UTF-8 string. + * Determines the byte length of a string value (for position tracking). * - * @param string $str The string in UTF-8 encoding. - * - * @return int The length. + * @param int|string $str The value. */ - public static function stringLength(string $str): int + public static function stringLength(int|string $str): int { - return mb_strlen(mb_convert_encoding($str, 'ISO-8859-1')); + return strlen((string) $str); } /** @@ -59,16 +60,10 @@ public static function stringLength(string $str): int */ public static function substring(string $str, int $position, ?int $length = null): string { - static $lengthFunc = null; - - if ($lengthFunc === null) { - $lengthFunc = function_exists('mb_substr') ? 'mb_substr' : 'iconv_substr'; - } - if ($length === null) { $length = self::stringLength($str); } - return $lengthFunc($str, $position, $length, 'UTF-8'); + return mb_substr($str, $position, $length, 'UTF-8'); } } diff --git a/tests/Lexer/AbstractLexerTest.php b/tests/Lexer/AbstractLexerTest.php index eb33970..8471910 100644 --- a/tests/Lexer/AbstractLexerTest.php +++ b/tests/Lexer/AbstractLexerTest.php @@ -10,7 +10,7 @@ class AbstractLexerTest extends TestCase { - protected ?StubLexer $lexer = null; + protected StubLexer $lexer; public function setUp(): void { diff --git a/tests/Lexer/Recognizer/RegexRecognizerTest.php b/tests/Lexer/Recognizer/RegexRecognizerTest.php index 26072d8..b13a4a2 100644 --- a/tests/Lexer/Recognizer/RegexRecognizerTest.php +++ b/tests/Lexer/Recognizer/RegexRecognizerTest.php @@ -9,23 +9,21 @@ class RegexRecognizerTest extends TestCase { #[\PHPUnit\Framework\Attributes\Test] - public function recognizerShouldMatchAndPassTheValueByReference(): void + public function recognizerShouldMatchAndReturnTheMatchedValue(): void { $recognizer = new RegexRecognizer('/[a-z]+/'); - $result = $recognizer->match('lorem ipsum', $value); + $value = $recognizer->match('lorem ipsum'); - $this->assertTrue($result); $this->assertNotNull($value); $this->assertSame('lorem', $value); } #[\PHPUnit\Framework\Attributes\Test] - public function recognizerShouldFailAndTheValueShouldStayNull(): void + public function recognizerShouldFailAndReturnNull(): void { $recognizer = new RegexRecognizer('/[a-z]+/'); - $result = $recognizer->match('123 456', $value); + $value = $recognizer->match('123 456'); - $this->assertFalse($result); $this->assertNull($value); } @@ -33,9 +31,8 @@ public function recognizerShouldFailAndTheValueShouldStayNull(): void public function recognizerShouldFailIfTheMatchIsNotAtTheBeginningOfTheString(): void { $recognizer = new RegexRecognizer('/[a-z]+/'); - $result = $recognizer->match('234 class', $value); + $value = $recognizer->match('234 class'); - $this->assertFalse($result); $this->assertNull($value); } } diff --git a/tests/Lexer/Recognizer/SimpleRecognizerTest.php b/tests/Lexer/Recognizer/SimpleRecognizerTest.php index da0212b..db0101b 100644 --- a/tests/Lexer/Recognizer/SimpleRecognizerTest.php +++ b/tests/Lexer/Recognizer/SimpleRecognizerTest.php @@ -9,23 +9,21 @@ class SimpleRecognizerTest extends TestCase { #[\PHPUnit\Framework\Attributes\Test] - public function recognizerShouldMatchAndPassTheValueByReference(): void + public function recognizerShouldMatchAndReturnTheMatchedValue(): void { $recognizer = new SimpleRecognizer('class'); - $result = $recognizer->match('class lorem ipsum', $value); + $value = $recognizer->match('class lorem ipsum'); - $this->assertTrue($result); $this->assertNotNull($value); $this->assertSame('class', $value); } #[\PHPUnit\Framework\Attributes\Test] - public function recognizerShouldFailAndTheValueShouldStayNull(): void + public function recognizerShouldFailAndReturnNull(): void { $recognizer = new SimpleRecognizer('class'); - $result = $recognizer->match('lorem ipsum', $value); + $value = $recognizer->match('lorem ipsum'); - $this->assertFalse($result); $this->assertNull($value); } } diff --git a/tests/Lexer/StubRegexLexer.php b/tests/Lexer/StubRegexLexer.php index c06d3fb..ab5374f 100644 --- a/tests/Lexer/StubRegexLexer.php +++ b/tests/Lexer/StubRegexLexer.php @@ -8,6 +8,7 @@ class StubRegexLexer extends RegexLexer { + /** @var string[] */ protected array $operators = ['+', '-']; protected function getCatchablePatterns(): array @@ -23,8 +24,6 @@ protected function getNonCatchablePatterns(): array protected function getType(string &$value): string { if (is_numeric($value)) { - $value = (int)$value; - return 'INT'; } elseif (in_array($value, $this->operators)) { return $value; diff --git a/tests/Lexer/TokenStream/ArrayTokenStreamTest.php b/tests/Lexer/TokenStream/ArrayTokenStreamTest.php index 64c2ba7..16e9447 100644 --- a/tests/Lexer/TokenStream/ArrayTokenStreamTest.php +++ b/tests/Lexer/TokenStream/ArrayTokenStreamTest.php @@ -10,7 +10,7 @@ class ArrayTokenStreamTest extends TestCase { - protected ?ArrayTokenStream $stream = null; + protected ArrayTokenStream $stream; protected function setUp(): void { diff --git a/tests/Parser/LALR1/Analysis/AnalyzerTest.php b/tests/Parser/LALR1/Analysis/AnalyzerTest.php index c48d997..959ee19 100644 --- a/tests/Parser/LALR1/Analysis/AnalyzerTest.php +++ b/tests/Parser/LALR1/Analysis/AnalyzerTest.php @@ -177,6 +177,7 @@ public function expectedConflictsShouldBeRecorded(): void $this->assertSame(3, $conflict['state']); $this->assertSame('b', $conflict['lookahead']); + $this->assertArrayHasKey('rule', $conflict); $this->assertSame(2, $conflict['rule']->getNumber()); $this->assertSame(Grammar::SHIFT, $conflict['resolution']); @@ -184,6 +185,7 @@ public function expectedConflictsShouldBeRecorded(): void $this->assertSame(4, $conflict['state']); $this->assertSame('b', $conflict['lookahead']); + $this->assertArrayHasKey('rule', $conflict); $this->assertSame(1, $conflict['rule']->getNumber()); $this->assertSame(Grammar::SHIFT, $conflict['resolution']); @@ -191,6 +193,7 @@ public function expectedConflictsShouldBeRecorded(): void $this->assertSame(4, $conflict['state']); $this->assertSame(Parser::EOF_TOKEN_TYPE, $conflict['lookahead']); + $this->assertArrayHasKey('rules', $conflict); $this->assertSame(1, $conflict['rules'][0]->getNumber()); $this->assertSame(2, $conflict['rules'][1]->getNumber()); $this->assertSame(Grammar::LONGER_REDUCE, $conflict['resolution']); @@ -199,6 +202,7 @@ public function expectedConflictsShouldBeRecorded(): void $this->assertSame(4, $conflict['state']); $this->assertSame('b', $conflict['lookahead']); + $this->assertArrayHasKey('rule', $conflict); $this->assertSame(2, $conflict['rule']->getNumber()); $this->assertSame(Grammar::SHIFT, $conflict['resolution']); } diff --git a/tests/Parser/LALR1/ArithGrammar.php b/tests/Parser/LALR1/ArithGrammar.php index 95ea1f1..0ed2f4c 100644 --- a/tests/Parser/LALR1/ArithGrammar.php +++ b/tests/Parser/LALR1/ArithGrammar.php @@ -4,6 +4,7 @@ namespace Dissect\Parser\LALR1; +use Dissect\Lexer\Token; use Dissect\Parser\Grammar; class ArithGrammar extends Grammar @@ -13,28 +14,28 @@ public function __construct() { $this('Expr') ->is('Expr', '+', 'Expr') - ->call(fn($l, $_, $r) => $l + $r) + ->call(function(int|float $l, mixed $_, int|float $r): int|float { return $l + $r; }) ->is('Expr', '-', 'Expr') - ->call(fn($l, $_, $r) => $l - $r) + ->call(function(int|float $l, mixed $_, int|float $r): int|float { return $l - $r; }) ->is('Expr', '*', 'Expr') - ->call(fn($l, $_, $r) => $l * $r) + ->call(function(int|float $l, mixed $_, int|float $r): int|float { return $l * $r; }) ->is('Expr', '/', 'Expr') - ->call(fn($l, $_, $r) => $l / $r) + ->call(function(int|float $l, mixed $_, int|float $r): int|float { return $l / $r; }) ->is('Expr', '**', 'Expr') - ->call(fn($l, $_, $r) => pow($l, $r)) + ->call(function(int|float $l, mixed $_, int|float $r): int|float { return pow($l, $r); }) ->is('(', 'Expr', ')') - ->call(fn($r, $e, $_) => $e) + ->call(function(mixed $r, int|float $e, mixed $_): int|float { return $e; }) ->is('-', 'Expr')->prec(4) - ->call(fn($_, $e) => -$e) + ->call(function(mixed $_, int|float $e): int|float { return -$e; }) ->is('INT') - ->call(fn($i) => (int)$i->getValue()); + ->call(function(Token $i): int { return (int) $i->getValue(); }); $this->operators('+', '-')->left()->prec(1); $this->operators('*', '/')->left()->prec(2); diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 63d8726..2d69b4e 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -8,4 +8,6 @@ $loader = require __DIR__ . '/../vendor/autoload.php'; -$loader->add('Dissect', __DIR__); +if ($loader instanceof \Composer\Autoload\ClassLoader) { + $loader->add('Dissect', __DIR__); +} From 0dfb668ee85736b7aa253cff144833f8e92d15ec Mon Sep 17 00:00:00 2001 From: Alexander Lisachenko Date: Wed, 25 Mar 2026 22:28:29 +0200 Subject: [PATCH 2/4] Update README with modern style, key features, and quick example Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/phpstan.yml | 36 +++++++++ LICENSE | 2 +- README.md | 134 +++++++++++++++++++++++++++++----- phpunit.xml | 2 +- 4 files changed, 154 insertions(+), 20 deletions(-) create mode 100644 .github/workflows/phpstan.yml diff --git a/.github/workflows/phpstan.yml b/.github/workflows/phpstan.yml new file mode 100644 index 0000000..7f11637 --- /dev/null +++ b/.github/workflows/phpstan.yml @@ -0,0 +1,36 @@ +name: "PHPStan analysis" + +permissions: + contents: read + +on: + pull_request: + push: + branches: + - master + +jobs: + build: + name: "PHPStan analysis - PHP8.4" + runs-on: ubuntu-latest + steps: + - name: "Checkout" + uses: actions/checkout@v4 + - name: "Install PHP" + uses: shivammathur/setup-php@v2 + with: + php-version: "8.4" + ini-values: memory_limit=-1 + tools: composer:v2 + - name: "Cache dependencies" + uses: actions/cache@v4 + with: + path: | + ~/.composer/cache + vendor + key: "php-8.4" + restore-keys: "php-8.4" + - name: "Install dependencies" + run: "composer install --no-interaction --no-progress" + - name: "Static analysis" + run: "vendor/bin/phpstan analyze --memory-limit=1G" diff --git a/LICENSE b/LICENSE index 965cc61..0cb9d7a 100644 --- a/LICENSE +++ b/LICENSE @@ -1,4 +1,4 @@ -Copyright (c) 2024 Lisachenko Alexander +Copyright (c) 2026 Lisachenko Alexander Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index fc662ff..a561a04 100644 --- a/README.md +++ b/README.md @@ -1,44 +1,142 @@ -Dissect ------------------ -This library is based on @jakubledl and @WalterWoshid work. +# ๐Ÿ”ฌ Dissect -Dissect library provides a set of tools to create and use lexical parsers. Read more at /docs folder. -For the goaop/framework it is responsible for parsing pointcut DSL expressions into AST tree, which might be then -processed. +> A pure-PHP toolkit for building custom **lexers** and **LALR(1) parsers** โ€” fast, type-safe, and dependency-free. ![GitHub Actions Workflow Status](https://img.shields.io/github/actions/workflow/status/goaop/dissect/php-tests.yml?branch=master) +![PHPStan Badge](https://img.shields.io/badge/PHPStan-level%2010-brightgreen.svg?style=flat&link=https%3A%2F%2Fphpstan.org%2Fuser-guide%2Frule-levels) [![Total Downloads](https://img.shields.io/packagist/dt/goaop/dissect.svg)](https://packagist.org/packages/goaop/dissect) [![Daily Downloads](https://img.shields.io/packagist/dd/goaop/dissect.svg)](https://packagist.org/packages/goaop/dissect) -[![PHP Version](https://img.shields.io/badge/php-%3E%3D%208.2-8892BF.svg)](https://php.net/) +[![PHP Version](https://img.shields.io/badge/php-%3E%3D%208.4-8892BF.svg)](https://php.net/) ![GitHub License](https://img.shields.io/github/license/goaop/dissect) +[![Sponsor](https://img.shields.io/badge/Sponsor-โค๏ธ-lightgray?style=flat&logo=github)](https://github.com/sponsors/lisachenko) +--- -## Installation +## โœจ What is Dissect? + +Dissect is a pure-PHP library for **lexical and syntactical analysis** โ€” the foundational building blocks for any language tooling: expression evaluators, template engines, DSL interpreters, query parsers, and more. + +It powers the [GoAOP framework](https://github.com/goaop/framework), where it parses pointcut DSL expressions into an AST for aspect-oriented programming. + +### Data flow + +``` +Input String + โ”‚ + โ–ผ +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ Lexer โ”‚ โ”€โ”€โ”€โ–ถ โ”‚ TokenStream โ”‚ โ”€โ”€โ”€โ–ถ โ”‚ LALR(1) Parser โ”‚ โ”€โ”€โ”€โ–ถ Result / AST +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + โ–ฒ + Grammar (rules + + callbacks) +``` + +--- + +## ๐Ÿš€ Key Features + +### ๐Ÿ”ค Flexible Lexers +| Lexer | Description | +|---------------------|--------------------------------------------------------------------------------------------| +| **`SimpleLexer`** | Fluent builder API โ€” define tokens with strings or regex, mark skippable tokens | +| **`StatefulLexer`** | Context-aware tokenization with explicit state transitions (e.g. for string interpolation) | +| **`RegexLexer`** | Abstract base class adapted from Doctrine โ€” ultra-fast single-pass regex lexing | + +### ๐Ÿ“ LALR(1) Parser + +- **Full LALR(1) grammar support** โ€” handles the vast majority of real-world grammars +- **Fluent grammar API** โ€” define productions and semantic actions with readable PHP closures +- **Operator precedence & associativity** โ€” built-in `left()`, `right()`, `nonassoc()` declarations +- **Conflict resolution** โ€” configurable strategies: shift-wins, longer-reduce, earlier-reduce +- **Precomputed parse tables** โ€” analyze once, serialize to PHP file, load instantly in production + +### ๐ŸŒณ AST Construction + +- **`CommonNode`** โ€” ready-to-use tree node with named children and arbitrary attributes +- **Countable & iterable** โ€” traverse subtrees with standard PHP constructs + +### ๐Ÿ›  Developer Experience + +- **Zero runtime dependencies** โ€” only Symfony Console as an optional CLI dep +- **PHPStan level 10** โ€” fully typed with generics, array shapes, and readonly properties +- **CLI tool** โ€” dump parse tables and visualize automaton states as Graphviz graphs + +--- + +## ๐Ÿ“ฆ Installation ```bash composer require goaop/dissect ``` +--- + +## โšก Quick Example -# Documentation? +```php +use Dissect\Lexer\SimpleLexer; +use Dissect\Parser\Grammar; +use Dissect\Parser\LALR1\Parser; -[Here](docs/index.md). +// 1. Define a lexer +$lexer = new SimpleLexer(); +$lexer->token('INT', '/[0-9]+/') + ->token('PLUS', '+') + ->token('MINUS', '-') + ->skip('WS', '/\s+/'); +// 2. Define a grammar +$grammar = new Grammar(); +$grammar('Expr') + ->is('Expr', 'PLUS', 'Expr') + ->call(fn($l, $_, $r) => $l + $r) + ->is('Expr', 'MINUS', 'Expr') + ->call(fn($l, $_, $r) => $l - $r) -## Testing + ->is('INT') + ->call(fn($t) => (int) $t->getValue()); + +$grammar->operators('PLUS', 'MINUS')->left()->prec(1); +$grammar->start('Expr'); + +// 3. Parse! +$parser = new Parser($grammar); +$result = $parser->parse($lexer->lex('3 + 5 - 2')); // โ†’ 6 +``` -- Run `composer run-script test`
- or -- Run `composer run-script test-coverage` +--- +## ๐Ÿ“– Documentation +| Topic | Description | +|--------------------------------------|------------------------------------------------------------------| +| [Lexical analysis](docs/lexing.md) | `SimpleLexer`, `StatefulLexer`, `RegexLexer`, performance tips | +| [Writing a grammar](docs/parsing.md) | Productions, callbacks, operator precedence, conflict resolution | +| [Building an AST](docs/ast.md) | `CommonNode`, tree traversal | +| [Common patterns](docs/common.md) | Lists, comma-separated sequences, expression grammars | +| [CLI tool](docs/cli.md) | Precomputing parse tables, exporting automaton graphs | -## Show your support +--- + +## ๐Ÿงช Testing & Quality + +```bash +# Run tests +composer test + +# Run tests with coverage +composer test-coverage + +# Static analysis (PHPStan level 10) +composer phpstan +``` -Give a โญ if this project helped you! +--- +## ๐Ÿ™ Credits -## ๐Ÿ“ License +Originally created by [@jakubledl](https://github.com/jakubledl), extended by [@WalterWoshid](https://github.com/WalterWoshid), maintained by the [GoAOP](https://github.com/goaop) team. -This project is [MIT](https://opensource.org/licenses/MIT) licensed. +Give a โญ if Dissect saved you from writing a parser by hand! diff --git a/phpunit.xml b/phpunit.xml index 33dcfdb..ac6b76e 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -1,5 +1,5 @@ - + tests/ From 06536d130df6de6ad7dccd5db28137dba7935dc5 Mon Sep 17 00:00:00 2001 From: Alexander Lisachenko Date: Wed, 25 Mar 2026 22:36:10 +0200 Subject: [PATCH 3/4] Fix changelog: move PHP 8.4 modernization to 4.0.0, document 3.0.0 fork changes Co-Authored-By: Claude Sonnet 4.6 --- CHANGELOG.md | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8723941..db3483a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,10 +1,10 @@ Changelog ========= -3.0.0 (2026-03-25) +4.0.0 (2026-03-25) ------------------ -- Modernized entire codebase for PHP 8.4+: readonly constructor-promoted properties, union types, named arguments, first-class callable syntax, and modern array destructuring throughout +- Modernized entire codebase for PHP 8.4+: readonly constructor-promoted properties, union types, first-class callable syntax, and modern array destructuring throughout - Refactored `Recognizer::match()` API from by-reference output parameter to clean `?string` return value - Replaced `static $regex` local variable in `RegexLexer` with a typed class property; added explicit `preg_split` false-return guard - Decomposed `StatefulLexer` nested array shape into three separately-typed arrays (`stateRecognizers`, `stateActions`, `stateSkipTokens`) for correctness and clarity @@ -13,6 +13,19 @@ Changelog - Fixed undefined variable bug in `Analyzer::buildParseTable()` (`$resolvedRules` used before assignment) - Added `phpstan/phpstan` (level 10) and `phpstan/phpstan-phpunit` as dev dependencies; all 57 tests pass with zero static analysis errors +3.0.0 (2024-02-18) +------------------ + +- Forked package to the `goaop` organization to keep all dependencies under control +- Migrated PHPUnit configuration to the newer XML scheme +- Removed deprecated `utf8_decode()` call, replaced with `mb_convert_encoding()` +- Applied Rector ruleset: `DeclareStrictTypesRector`, PSR-4 autoloading, `AddVoidReturnTypeWhereNoReturnRector`, `ClosureToArrowFunctionRector`, `PublicConstantVisibilityRector`, removed useless PHPDoc tags +- Modernized test suite via `PHPUnitSetList` and `PHPUnitSetList::PHPUNIT_CODE_QUALITY` Rector rules +- Used variadic syntax for `ArrayTokenStream` constructor to enforce type checks +- Dropped PHP 8.1 and 8.2 from build matrix; added PHP 8.3 +- Added Dependabot for automated dependency updates +- Updated license to MIT + 1.0.1 (2013-01-29) ------------------ From ca8918ec35a88023049f2cbbcb06213e8296b024 Mon Sep 17 00:00:00 2001 From: Alexander Lisachenko Date: Wed, 25 Mar 2026 22:45:58 +0200 Subject: [PATCH 4/4] Fix three bugs flagged in PR review - Util::substring() was using mb_substr() (char offsets) while stringLength() uses strlen() (byte offsets), causing wrong slice positions for multibyte input in AbstractLexer; switch to substr() - CommonNode had split backing arrays ($nodes vs $children): constructor populated $nodes but getNode/setNode/count/getIterator all used $children, making constructor-passed nodes invisible to all accessors; consolidate everything to $children - README Quick Example used token() for regex patterns and wrong skip() signature; fix to use regex() for patterns and skip('WS') for type name Co-Authored-By: Claude Sonnet 4.6 --- README.md | 5 +++-- src/Node/CommonNode.php | 19 +++++++------------ src/Util/Util.php | 12 ++++-------- 3 files changed, 14 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index a561a04..2fbe84a 100644 --- a/README.md +++ b/README.md @@ -81,10 +81,11 @@ use Dissect\Parser\LALR1\Parser; // 1. Define a lexer $lexer = new SimpleLexer(); -$lexer->token('INT', '/[0-9]+/') +$lexer->regex('INT', '/[0-9]+/') ->token('PLUS', '+') ->token('MINUS', '-') - ->skip('WS', '/\s+/'); + ->regex('WS', '/\s+/') + ->skip('WS'); // 2. Define a grammar $grammar = new Grammar(); diff --git a/src/Node/CommonNode.php b/src/Node/CommonNode.php index 711e366..7c8e86b 100644 --- a/src/Node/CommonNode.php +++ b/src/Node/CommonNode.php @@ -17,26 +17,21 @@ class CommonNode implements Node /** * @var array */ - protected array $nodes; + protected array $children; /** * @var array */ protected array $attributes; - /** - * @var array - */ - protected array $children = []; - /** * @param array $attributes The attributes of this node. - * @param array $nodes The children of this node. + * @param array $children The children of this node. */ - public function __construct(array $attributes = [], array $nodes = []) + public function __construct(array $attributes = [], array $children = []) { $this->attributes = $attributes; - $this->nodes = $nodes; + $this->children = $children; } /** @@ -44,7 +39,7 @@ public function __construct(array $attributes = [], array $nodes = []) */ public function getNodes(): array { - return $this->nodes; + return $this->children; } /** @@ -52,7 +47,7 @@ public function getNodes(): array */ public function hasNode(string $name): bool { - return isset($this->nodes[$name]); + return isset($this->children[$name]); } /** @@ -133,7 +128,7 @@ public function count(): int } /** - * @return ArrayIterator + * @return ArrayIterator */ public function getIterator(): ArrayIterator { diff --git a/src/Util/Util.php b/src/Util/Util.php index 61b3a16..3431c40 100644 --- a/src/Util/Util.php +++ b/src/Util/Util.php @@ -50,20 +50,16 @@ public static function stringLength(int|string $str): int } /** - * Extracts a substring of a UTF-8 string. + * Extracts a substring using byte offsets, consistent with stringLength(). * * @param string $str The string to extract the substring from. - * @param int $position The position from which to start extracting. - * @param int|null $length The length of the substring. + * @param int $position The byte position from which to start extracting. + * @param int|null $length The byte length of the substring. * * @return string The substring. */ public static function substring(string $str, int $position, ?int $length = null): string { - if ($length === null) { - $length = self::stringLength($str); - } - - return mb_substr($str, $position, $length, 'UTF-8'); + return substr($str, $position, $length); } }