Skip to content

Commit

Permalink
fix: strengthen type tokens extraction
Browse files Browse the repository at this point in the history
This commit refactors the way tokens are extracted from a raw type
string, by using a better approach that consists in splitting first into
tokens, and then detecting texts (aka string values).

This aims to fix edge cases like the following example, where the `$bar`
annotation would previously have confused the lexer, leading to the type
of `$foo` being used for `$bar` as well.

```php
final class SomeClass
{
    /**
      * @param non-empty-string $foo Some description containing $bar
      *      which is the next parameter name
      */
     public function __construct(
         public string $foo,
         public int $bar,
     ) {}
}
```
  • Loading branch information
romm committed Mar 27, 2024
1 parent 1947061 commit c9dc975
Show file tree
Hide file tree
Showing 5 changed files with 138 additions and 86 deletions.
82 changes: 82 additions & 0 deletions src/Type/Parser/Lexer/TokensExtractor.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
<?php

namespace CuyZ\Valinor\Type\Parser\Lexer;

use function array_map;
use function array_shift;
use function implode;
use function preg_split;

/** @internal */
final class TokensExtractor
{
private const TOKEN_PATTERNS = [
'Anonymous class' => '[a-zA-Z_\x7f-\xff][\\\\a-zA-Z0-9_\x7f-\xff]*+@anonymous\x00.*?\.php(?:0x?|:[0-9]++\$)[0-9a-fA-F]++',
'Double colons' => '\:\:',
'Triple dots' => '\.\.\.',
'Dollar sign' => '\$',
'Whitespace' => '\s',
'Union' => '\|',
'Intersection' => '&',
'Opening bracket' => '\<',
'Closing bracket' => '\>',
'Opening square bracket' => '\[',
'Closing square bracket' => '\]',
'Opening curly bracket' => '\{',
'Closing curly bracket' => '\}',
'Colon' => '\:',
'Question mark' => '\?',
'Comma' => ',',
'Single quote' => "'",
'Double quote' => '"',
];

/** @var list<string> */
private array $symbols = [];

public function __construct(string $string)
{
$pattern = '/(' . implode('|', self::TOKEN_PATTERNS) . ')' . '/';
$tokens = preg_split($pattern, $string, flags: PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY);

$quote = null;
$text = null;

while (($token = array_shift($tokens)) !== null) {
if ($token === $quote) {
if ($text !== null) {
$this->symbols[] = $text;
}

$this->symbols[] = $token;

$text = null;
$quote = null;
} elseif ($quote !== null) {
$text .= $token;
} elseif ($token === '"' || $token === "'") {
$quote = $token;

$this->symbols[] = $token;
} else {
$this->symbols[] = $token;
}
}

if ($text !== null) {
$this->symbols[] = $text;
}

$this->symbols = array_map('trim', $this->symbols);
$this->symbols = array_filter($this->symbols, static fn ($value) => $value !== '');
$this->symbols = array_values($this->symbols);
}

/**
* @return list<string>
*/
public function all(): array
{
return $this->symbols;
}
}
3 changes: 2 additions & 1 deletion src/Type/Parser/LexingParser.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

namespace CuyZ\Valinor\Type\Parser;

use CuyZ\Valinor\Type\Parser\Lexer\TokensExtractor;
use CuyZ\Valinor\Type\Parser\Lexer\TokenStream;
use CuyZ\Valinor\Type\Parser\Lexer\TypeLexer;
use CuyZ\Valinor\Type\Type;
Expand All @@ -13,7 +14,7 @@ public function __construct(private TypeLexer $lexer) {}

public function parse(string $raw): Type
{
$symbols = new ParserSymbols($raw);
$symbols = new TokensExtractor($raw);

$tokens = array_map(
fn (string $symbol) => $this->lexer->tokenize($symbol),
Expand Down
82 changes: 0 additions & 82 deletions src/Type/Parser/ParserSymbols.php

This file was deleted.

25 changes: 22 additions & 3 deletions src/Utility/Reflection/DocParser.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,21 @@
namespace CuyZ\Valinor\Utility\Reflection;

use CuyZ\Valinor\Type\Parser\Exception\Template\DuplicatedTemplateName;
use CuyZ\Valinor\Type\Parser\Lexer\TokensExtractor;
use ReflectionClass;
use ReflectionFunctionAbstract;
use ReflectionParameter;
use ReflectionProperty;

use function array_key_exists;
use function array_merge;
use function array_search;
use function array_shift;
use function array_splice;
use function assert;
use function end;
use function explode;
use function in_array;
use function preg_match;
use function preg_match_all;
use function str_replace;
Expand Down Expand Up @@ -43,11 +48,25 @@ public static function parameterType(ReflectionParameter $reflection): ?string
return null;
}

if (! preg_match("/(?<type>.*)\\$$reflection->name(\s|\z)/s", $doc, $matches)) {
return null;
$parameters = [];

$tokens = (new TokensExtractor($doc))->all();

while (($token = array_shift($tokens)) !== null) {
if (! in_array($token, ['@param', '@phpstan-param', '@psalm-param'], true)) {
continue;
}

$dollarSignKey = (int)array_search('$', $tokens, true);
$name = $tokens[$dollarSignKey + 1] ?? null;

$parameters[$name][$token] = implode('', array_splice($tokens, 0, $dollarSignKey));
}

return self::annotationType($matches['type'], 'param');
return $parameters[$reflection->name]['@phpstan-param']
?? $parameters[$reflection->name]['@psalm-param']
?? $parameters[$reflection->name]['@param']
?? null;
}

public static function functionReturnType(ReflectionFunctionAbstract $reflection): ?string
Expand Down
32 changes: 32 additions & 0 deletions tests/Integration/Mapping/DocBlockParameterWithDescriptionTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<?php

declare(strict_types=1);

namespace CuyZ\Valinor\Tests\Integration\Mapping;

use CuyZ\Valinor\Tests\Integration\IntegrationTestCase;

final class DocBlockParameterWithDescriptionTest extends IntegrationTestCase
{
public function test_parameter_doc_block_description_containing_name_of_other_parameter_is_parsed_properly(): void
{
$class = new class ('foo', 42) {
/**
* @param non-empty-string $foo Some description containing $bar
* which is the next parameter name
*/
public function __construct(
public string $foo,
public int $bar,
) {}
};

$result = $this->mapperBuilder()->mapper()->map($class::class, [
'foo' => 'foo',
'bar' => 42,
]);

self::assertSame('foo', $result->foo);
self::assertSame(42, $result->bar);
}
}

0 comments on commit c9dc975

Please sign in to comment.