Fix bin/publish: copy docs.dist from project root

Fix bin/publish: use correct .env path for rspade_system
Fix bin/publish script: prevent grep exit code 1 from terminating script

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
root
2025-10-21 02:08:33 +00:00
commit f6fac6c4bc
79758 changed files with 10547827 additions and 0 deletions

View File

@@ -0,0 +1,336 @@
<?php
declare(strict_types=1);
/*
* This file is part of PHP CS Fixer.
*
* (c) Fabien Potencier <fabien@symfony.com>
* Dariusz Rumiński <dariusz.ruminski@gmail.com>
*
* This source file is subject to the MIT license that is bundled
* with this source code in the file LICENSE.
*/
namespace PhpCsFixer\DocBlock;
use PhpCsFixer\Preg;
use PhpCsFixer\Tokenizer\Analyzer\Analysis\NamespaceAnalysis;
use PhpCsFixer\Tokenizer\Analyzer\Analysis\NamespaceUseAnalysis;
/**
* This represents an entire annotation from a docblock.
*
* @author Graham Campbell <hello@gjcampbell.co.uk>
* @author Dariusz Rumiński <dariusz.ruminski@gmail.com>
*/
final class Annotation
{
/**
* All the annotation tag names with types.
*
* @var list<string>
*/
private const TAGS = [
'extends',
'implements',
'method',
'param',
'param-out',
'phpstan-type',
'phpstan-import-type',
'property',
'property-read',
'property-write',
'psalm-type',
'psalm-import-type',
'return',
'throws',
'type',
'var',
];
/**
* The lines that make up the annotation.
*
* @var non-empty-list<Line>
*/
private array $lines;
/**
* The position of the first line of the annotation in the docblock.
*/
private int $start;
/**
* The position of the last line of the annotation in the docblock.
*/
private int $end;
/**
* The associated tag.
*/
private ?Tag $tag = null;
/**
* Lazy loaded, cached types content.
*/
private ?string $typesContent = null;
/**
* The cached types.
*
* @var null|list<string>
*/
private ?array $types = null;
private ?NamespaceAnalysis $namespace = null;
/**
* @var list<NamespaceUseAnalysis>
*/
private array $namespaceUses;
/**
* Create a new line instance.
*
* @param non-empty-array<int, Line> $lines
* @param null|NamespaceAnalysis $namespace
* @param list<NamespaceUseAnalysis> $namespaceUses
*/
public function __construct(array $lines, $namespace = null, array $namespaceUses = [])
{
$this->lines = array_values($lines);
$this->namespace = $namespace;
$this->namespaceUses = $namespaceUses;
$this->start = array_key_first($lines);
$this->end = array_key_last($lines);
}
/**
* Get the string representation of object.
*/
public function __toString(): string
{
return $this->getContent();
}
/**
* Get all the annotation tag names with types.
*
* @return list<string>
*/
public static function getTagsWithTypes(): array
{
return self::TAGS;
}
/**
* Get the start position of this annotation.
*/
public function getStart(): int
{
return $this->start;
}
/**
* Get the end position of this annotation.
*/
public function getEnd(): int
{
return $this->end;
}
/**
* Get the associated tag.
*/
public function getTag(): Tag
{
if (null === $this->tag) {
$this->tag = new Tag($this->lines[0]);
}
return $this->tag;
}
/**
* @internal
*/
public function getTypeExpression(): ?TypeExpression
{
$typesContent = $this->getTypesContent();
return null === $typesContent
? null
: new TypeExpression($typesContent, $this->namespace, $this->namespaceUses);
}
/**
* @internal
*/
public function getVariableName(): ?string
{
$type = preg_quote($this->getTypesContent() ?? '', '/');
$regex = \sprintf(
'/@%s\s+(%s\s*)?(&\s*)?(\.{3}\s*)?(?<variable>\$%s)(?:.*|$)/',
$this->tag->getName(),
$type,
TypeExpression::REGEX_IDENTIFIER
);
if (Preg::match($regex, $this->getContent(), $matches)) {
\assert(isset($matches['variable']));
return $matches['variable'];
}
return null;
}
/**
* Get the types associated with this annotation.
*
* @return list<string>
*/
public function getTypes(): array
{
if (null === $this->types) {
$typeExpression = $this->getTypeExpression();
$this->types = null === $typeExpression
? []
: $typeExpression->getTypes();
}
return $this->types;
}
/**
* Set the types associated with this annotation.
*
* @param list<string> $types
*/
public function setTypes(array $types): void
{
$origTypesContent = $this->getTypesContent();
$newTypesContent = implode(
// Fallback to union type is provided for backward compatibility (previously glue was set to `|` by default even when type was not composite)
// @TODO Better handling for cases where type is fixed (original type is not composite, but was made composite during fix)
$this->getTypeExpression()->getTypesGlue() ?? '|',
$types
);
if ($origTypesContent === $newTypesContent) {
return;
}
$originalTypesLines = Preg::split('/([^\n\r]+\R*)/', $origTypesContent, -1, \PREG_SPLIT_NO_EMPTY | \PREG_SPLIT_DELIM_CAPTURE);
$newTypesLines = Preg::split('/([^\n\r]+\R*)/', $newTypesContent, -1, \PREG_SPLIT_NO_EMPTY | \PREG_SPLIT_DELIM_CAPTURE);
\assert(\count($originalTypesLines) === \count($newTypesLines));
foreach ($newTypesLines as $index => $line) {
\assert(isset($originalTypesLines[$index]));
$pattern = '/'.preg_quote($originalTypesLines[$index], '/').'/';
\assert(isset($this->lines[$index]));
$this->lines[$index]->setContent(Preg::replace($pattern, $line, $this->lines[$index]->getContent(), 1));
}
$this->clearCache();
}
/**
* Get the normalized types associated with this annotation, so they can easily be compared.
*
* @return list<string>
*/
public function getNormalizedTypes(): array
{
$typeExpression = $this->getTypeExpression();
if (null === $typeExpression) {
return [];
}
$normalizedTypeExpression = $typeExpression
->mapTypes(static fn (TypeExpression $v) => new TypeExpression(strtolower($v->toString()), null, []))
->sortTypes(static fn (TypeExpression $a, TypeExpression $b) => $a->toString() <=> $b->toString())
;
return $normalizedTypeExpression->getTypes();
}
/**
* Remove this annotation by removing all its lines.
*/
public function remove(): void
{
foreach ($this->lines as $line) {
if ($line->isTheStart() && $line->isTheEnd()) {
// Single line doc block, remove entirely
$line->remove();
} elseif ($line->isTheStart()) {
// Multi line doc block, but start is on the same line as the first annotation, keep only the start
$content = Preg::replace('#(\s*/\*\*).*#', '$1', $line->getContent());
$line->setContent($content);
} elseif ($line->isTheEnd()) {
// Multi line doc block, but end is on the same line as the last annotation, keep only the end
$content = Preg::replace('#(\s*)\S.*(\*/.*)#', '$1$2', $line->getContent());
$line->setContent($content);
} else {
// Multi line doc block, neither start nor end on this line, can be removed safely
$line->remove();
}
}
$this->clearCache();
}
/**
* Get the annotation content.
*/
public function getContent(): string
{
return implode('', $this->lines);
}
public function supportTypes(): bool
{
return \in_array($this->getTag()->getName(), self::TAGS, true);
}
/**
* Get the current types content.
*
* Be careful modifying the underlying line as that won't flush the cache.
*/
private function getTypesContent(): ?string
{
if (null === $this->typesContent) {
$name = $this->getTag()->getName();
if (!$this->supportTypes()) {
throw new \RuntimeException('This tag does not support types.');
}
if (Preg::match(
'{^(?:\h*\*|/\*\*)[\h*]*@'.$name.'\h+'.TypeExpression::REGEX_TYPES.'(?:(?:[*\h\v]|\&?[\.\$\s]).*)?\r?$}is',
$this->getContent(),
$matches
)) {
\assert(isset($matches['types']));
$this->typesContent = $matches['types'];
}
}
return $this->typesContent;
}
private function clearCache(): void
{
$this->types = null;
$this->typesContent = null;
}
}

View File

@@ -0,0 +1,251 @@
<?php
declare(strict_types=1);
/*
* This file is part of PHP CS Fixer.
*
* (c) Fabien Potencier <fabien@symfony.com>
* Dariusz Rumiński <dariusz.ruminski@gmail.com>
*
* This source file is subject to the MIT license that is bundled
* with this source code in the file LICENSE.
*/
namespace PhpCsFixer\DocBlock;
use PhpCsFixer\Preg;
use PhpCsFixer\Tokenizer\Analyzer\Analysis\NamespaceAnalysis;
use PhpCsFixer\Tokenizer\Analyzer\Analysis\NamespaceUseAnalysis;
/**
* This class represents a docblock.
*
* It internally splits it up into "lines" that we can manipulate.
*
* @author Graham Campbell <hello@gjcampbell.co.uk>
*/
final class DocBlock
{
/**
* @var list<Line>
*/
private array $lines = [];
/**
* @var null|list<Annotation>
*/
private ?array $annotations = null;
private ?NamespaceAnalysis $namespace;
/**
* @var list<NamespaceUseAnalysis>
*/
private array $namespaceUses;
/**
* @param list<NamespaceUseAnalysis> $namespaceUses
*/
public function __construct(string $content, ?NamespaceAnalysis $namespace = null, array $namespaceUses = [])
{
foreach (Preg::split('/([^\n\r]+\R*)/', $content, -1, \PREG_SPLIT_NO_EMPTY | \PREG_SPLIT_DELIM_CAPTURE) as $line) {
$this->lines[] = new Line($line);
}
$this->namespace = $namespace;
$this->namespaceUses = $namespaceUses;
}
public function __toString(): string
{
return $this->getContent();
}
/**
* Get this docblock's lines.
*
* @return list<Line>
*/
public function getLines(): array
{
return $this->lines;
}
/**
* Get a single line.
*/
public function getLine(int $pos): ?Line
{
return $this->lines[$pos] ?? null;
}
/**
* Get this docblock's annotations.
*
* @return list<Annotation>
*/
public function getAnnotations(): array
{
if (null !== $this->annotations) {
return $this->annotations;
}
$this->annotations = [];
$total = \count($this->lines);
for ($index = 0; $index < $total; ++$index) {
if ($this->lines[$index]->containsATag()) {
// get all the lines that make up the annotation
$lines = \array_slice($this->lines, $index, $this->findAnnotationLength($index), true);
\assert([] !== $lines);
$annotation = new Annotation($lines, $this->namespace, $this->namespaceUses);
// move the index to the end of the annotation to avoid
// checking it again because we know the lines inside the
// current annotation cannot be part of another annotation
$index = $annotation->getEnd();
// add the current annotation to the list of annotations
$this->annotations[] = $annotation;
}
}
return $this->annotations;
}
public function isMultiLine(): bool
{
return 1 !== \count($this->lines);
}
/**
* Take a one line doc block, and turn it into a multi line doc block.
*/
public function makeMultiLine(string $indent, string $lineEnd): void
{
if ($this->isMultiLine()) {
return;
}
$lineContent = $this->getSingleLineDocBlockEntry($this->lines[0]);
if ('' === $lineContent) {
$this->lines = [
new Line('/**'.$lineEnd),
new Line($indent.' *'.$lineEnd),
new Line($indent.' */'),
];
return;
}
$this->lines = [
new Line('/**'.$lineEnd),
new Line($indent.' * '.$lineContent.$lineEnd),
new Line($indent.' */'),
];
}
public function makeSingleLine(): void
{
if (!$this->isMultiLine()) {
return;
}
$usefulLines = array_filter(
$this->lines,
static fn (Line $line): bool => $line->containsUsefulContent()
);
if (1 < \count($usefulLines)) {
return;
}
$lineContent = '';
if (\count($usefulLines) > 0) {
$lineContent = $this->getSingleLineDocBlockEntry(array_shift($usefulLines));
}
$this->lines = [new Line('/** '.$lineContent.' */')];
}
public function getAnnotation(int $pos): ?Annotation
{
$annotations = $this->getAnnotations();
return $annotations[$pos] ?? null;
}
/**
* Get specific types of annotations only.
*
* @param list<string>|string $types
*
* @return list<Annotation>
*/
public function getAnnotationsOfType($types): array
{
$typesToSearchFor = (array) $types;
$annotations = [];
foreach ($this->getAnnotations() as $annotation) {
$tagName = $annotation->getTag()->getName();
if (\in_array($tagName, $typesToSearchFor, true)) {
$annotations[] = $annotation;
}
}
return $annotations;
}
/**
* Get the actual content of this docblock.
*/
public function getContent(): string
{
return implode('', $this->lines);
}
private function findAnnotationLength(int $start): int
{
$index = $start;
while (($line = $this->getLine(++$index)) !== null) {
if ($line->containsATag()) {
// we've 100% reached the end of the description if we get here
break;
}
if (!$line->containsUsefulContent()) {
// if next line is also non-useful, or contains a tag, then we're done here
$next = $this->getLine($index + 1);
if (null === $next || !$next->containsUsefulContent() || $next->containsATag()) {
break;
}
// otherwise, continue, the annotation must have contained a blank line in its description
}
}
return $index - $start;
}
private function getSingleLineDocBlockEntry(Line $line): string
{
$lineString = $line->getContent();
if ('' === $lineString) {
return $lineString;
}
$lineString = str_replace('*/', '', $lineString);
$lineString = trim($lineString);
if (str_starts_with($lineString, '/**')) {
$lineString = substr($lineString, 3);
} elseif (str_starts_with($lineString, '*')) {
$lineString = substr($lineString, 1);
}
return trim($lineString);
}
}

View File

@@ -0,0 +1,128 @@
<?php
declare(strict_types=1);
/*
* This file is part of PHP CS Fixer.
*
* (c) Fabien Potencier <fabien@symfony.com>
* Dariusz Rumiński <dariusz.ruminski@gmail.com>
*
* This source file is subject to the MIT license that is bundled
* with this source code in the file LICENSE.
*/
namespace PhpCsFixer\DocBlock;
use PhpCsFixer\Preg;
/**
* This represents a line of a docblock.
*
* @author Graham Campbell <hello@gjcampbell.co.uk>
*/
final class Line
{
/**
* The content of this line.
*/
private string $content;
/**
* Create a new line instance.
*/
public function __construct(string $content)
{
$this->content = $content;
}
/**
* Get the string representation of object.
*/
public function __toString(): string
{
return $this->content;
}
/**
* Get the content of this line.
*/
public function getContent(): string
{
return $this->content;
}
/**
* Does this line contain useful content?
*
* If the line contains text or tags, then this is true.
*/
public function containsUsefulContent(): bool
{
return Preg::match('/\*\s*\S+/', $this->content) && '' !== trim(str_replace(['/', '*'], ' ', $this->content));
}
/**
* Does the line contain a tag?
*
* If this is true, then it must be the first line of an annotation.
*/
public function containsATag(): bool
{
return Preg::match('/\*\s*@/', $this->content);
}
/**
* Is the line the start of a docblock?
*/
public function isTheStart(): bool
{
return str_contains($this->content, '/**');
}
/**
* Is the line the end of a docblock?
*/
public function isTheEnd(): bool
{
return str_contains($this->content, '*/');
}
/**
* Set the content of this line.
*/
public function setContent(string $content): void
{
$this->content = $content;
}
/**
* Remove this line by clearing its contents.
*
* Note that this method technically brakes the internal state of the
* docblock, but is useful when we need to retain the indices of lines
* during the execution of an algorithm.
*/
public function remove(): void
{
$this->content = '';
}
/**
* Append a blank docblock line to this line's contents.
*
* Note that this method technically brakes the internal state of the
* docblock, but is useful when we need to retain the indices of lines
* during the execution of an algorithm.
*/
public function addBlank(): void
{
$matched = Preg::match('/^(\h*\*)[^\r\n]*(\r?\n)$/', $this->content, $matches);
if (!$matched) {
return;
}
$this->content .= $matches[1].$matches[2];
}
}

View File

@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
/*
* This file is part of PHP CS Fixer.
*
* (c) Fabien Potencier <fabien@symfony.com>
* Dariusz Rumiński <dariusz.ruminski@gmail.com>
*
* This source file is subject to the MIT license that is bundled
* with this source code in the file LICENSE.
*/
namespace PhpCsFixer\DocBlock;
/**
* This class represents a short description (aka summary) of a docblock.
*
* @readonly
*
* @internal
*/
final class ShortDescription
{
/**
* The docblock containing the short description.
*/
private DocBlock $doc;
public function __construct(DocBlock $doc)
{
$this->doc = $doc;
}
/**
* Get the line index of the line containing the end of the short
* description, if present.
*/
public function getEnd(): ?int
{
$reachedContent = false;
foreach ($this->doc->getLines() as $index => $line) {
// we went past a description, then hit a tag or blank line, so
// the last line of the description must be the one before this one
if ($reachedContent && ($line->containsATag() || !$line->containsUsefulContent())) {
return $index - 1;
}
// no short description was found
if ($line->containsATag()) {
return null;
}
// we've reached content, but need to check the next lines too
// in case the short description is multi-line
if ($line->containsUsefulContent()) {
$reachedContent = true;
}
}
return null;
}
}

View File

@@ -0,0 +1,102 @@
<?php
declare(strict_types=1);
/*
* This file is part of PHP CS Fixer.
*
* (c) Fabien Potencier <fabien@symfony.com>
* Dariusz Rumiński <dariusz.ruminski@gmail.com>
*
* This source file is subject to the MIT license that is bundled
* with this source code in the file LICENSE.
*/
namespace PhpCsFixer\DocBlock;
use PhpCsFixer\Preg;
/**
* This represents a tag, as defined by the proposed PSR PHPDoc standard.
*
* @author Graham Campbell <hello@gjcampbell.co.uk>
* @author Jakub Kwaśniewski <jakub@zero-85.pl>
*/
final class Tag
{
/**
* All the tags defined by the proposed PSR PHPDoc standard.
*/
public const PSR_STANDARD_TAGS = [
'api', 'author', 'category', 'copyright', 'deprecated', 'example',
'global', 'internal', 'license', 'link', 'method', 'package', 'param',
'property', 'property-read', 'property-write', 'return', 'see',
'since', 'subpackage', 'throws', 'todo', 'uses', 'var', 'version',
];
/**
* The line containing the tag.
*/
private Line $line;
/**
* The cached tag name.
*/
private ?string $name = null;
/**
* Create a new tag instance.
*/
public function __construct(Line $line)
{
$this->line = $line;
}
/**
* Get the tag name.
*
* This may be "param", or "return", etc.
*/
public function getName(): string
{
if (null === $this->name) {
Preg::matchAll('/@[a-zA-Z0-9_-]+(?=\s|$)/', $this->line->getContent(), $matches);
if (isset($matches[0][0])) {
$this->name = ltrim($matches[0][0], '@');
} else {
$this->name = 'other';
}
}
return $this->name;
}
/**
* Set the tag name.
*
* This will also be persisted to the upstream line and annotation.
*/
public function setName(string $name): void
{
$current = $this->getName();
if ('other' === $current) {
throw new \RuntimeException('Cannot set name on unknown tag.');
}
$this->line->setContent(Preg::replace("/@{$current}/", "@{$name}", $this->line->getContent(), 1));
$this->name = $name;
}
/**
* Is the tag a known tag?
*
* This is defined by if it exists in the proposed PSR PHPDoc standard.
*/
public function valid(): bool
{
return \in_array($this->getName(), self::PSR_STANDARD_TAGS, true);
}
}

View File

@@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
/*
* This file is part of PHP CS Fixer.
*
* (c) Fabien Potencier <fabien@symfony.com>
* Dariusz Rumiński <dariusz.ruminski@gmail.com>
*
* This source file is subject to the MIT license that is bundled
* with this source code in the file LICENSE.
*/
namespace PhpCsFixer\DocBlock;
/**
* This class is responsible for comparing tags to see if they should be kept
* together, or kept apart.
*
* @author Graham Campbell <hello@gjcampbell.co.uk>
* @author Jakub Kwaśniewski <jakub@zero-85.pl>
*
* @deprecated
*/
final class TagComparator
{
/**
* Groups of tags that should be allowed to immediately follow each other.
*
* @var list<list<string>>
*
* @internal
*/
public const DEFAULT_GROUPS = [
['deprecated', 'link', 'see', 'since'],
['author', 'copyright', 'license'],
['category', 'package', 'subpackage'],
['property', 'property-read', 'property-write'],
];
/**
* Should the given tags be kept together, or kept apart?
*
* @param list<list<string>> $groups
*/
public static function shouldBeTogether(Tag $first, Tag $second, array $groups = self::DEFAULT_GROUPS): bool
{
@trigger_error('Method '.__METHOD__.' is deprecated and will be removed in version 4.0.', \E_USER_DEPRECATED);
$firstName = $first->getName();
$secondName = $second->getName();
if ($firstName === $secondName) {
return true;
}
foreach ($groups as $group) {
if (\in_array($firstName, $group, true) && \in_array($secondName, $group, true)) {
return true;
}
}
return false;
}
}

View File

@@ -0,0 +1,857 @@
<?php
declare(strict_types=1);
/*
* This file is part of PHP CS Fixer.
*
* (c) Fabien Potencier <fabien@symfony.com>
* Dariusz Rumiński <dariusz.ruminski@gmail.com>
*
* This source file is subject to the MIT license that is bundled
* with this source code in the file LICENSE.
*/
namespace PhpCsFixer\DocBlock;
use PhpCsFixer\Preg;
use PhpCsFixer\Tokenizer\Analyzer\Analysis\NamespaceAnalysis;
use PhpCsFixer\Tokenizer\Analyzer\Analysis\NamespaceUseAnalysis;
use PhpCsFixer\Utils;
/**
* @author Michael Vorisek <https://github.com/mvorisek>
*
* @internal
*/
final class TypeExpression
{
/**
* Regex to match any PHP identifier.
*
* @internal
*/
public const REGEX_IDENTIFIER = '(?:(?!(?<!\*)\d)[^\x00-\x2f\x3a-\x40\x5b-\x5e\x60\x7b-\x7f]++)';
/**
* Regex to match any PHPDoc type.
*
* @internal
*/
public const REGEX_TYPES = '(?<types>(?x) # one or several types separated by `|` or `&`
'.self::REGEX_TYPE.'
(?:
\h*(?<glue>[|&])\h*
(?&type)
)*+
)';
/**
* Based on:
* - https://github.com/phpstan/phpdoc-parser/blob/1.26.0/doc/grammars/type.abnf fuzzing grammar
* - and https://github.com/phpstan/phpdoc-parser/blob/1.26.0/src/Parser/PhpDocParser.php parser impl.
*/
private const REGEX_TYPE = '(?<type>(?x) # single type
(?<nullable>\??\h*)
(?:
(?<array_shape>
(?<array_shape_name>(?i)(?:array|list|object)(?-i))
(?<array_shape_start>\h*\{[\h\v*]*)
(?<array_shape_inners>
(?<array_shape_inner>
(?<array_shape_inner_key>(?:(?&constant)|(?&identifier)|(?&name))\h*\??\h*:\h*|)
(?<array_shape_inner_value>(?&types_inner))
)
(?:
\h*,[\h\v*]*
(?&array_shape_inner)
)*+
(?:\h*,|(?!(?&array_shape_unsealed_variadic)))
|)
(?<array_shape_unsealed> # unsealed array shape, e.g. `...`. `...<string>`
(?<array_shape_unsealed_variadic>\h*\.\.\.)
(?<array_shape_unsealed_type>
(?<array_shape_unsealed_type_start>\h*<\h*)
(?<array_shape_unsealed_type_a>(?&types_inner))
(?:
(?<array_shape_unsealed_type_comma>\h*,\h*)
(?<array_shape_unsealed_type_b>(?&array_shape_unsealed_type_a))
|)
\h*>
|)
|)
[\h\v*]*\}
)
|
(?<callable> # callable syntax, e.g. `callable(string, int...): bool`, `\Closure<T>(T, int): T`
(?<callable_name>(?&name))
(?<callable_template>
(?<callable_template_start>\h*<\h*)
(?<callable_template_inners>
(?<callable_template_inner>
(?<callable_template_inner_name>
(?&identifier)
)
(?<callable_template_inner_b> # template bound
\h+(?i)(?<callable_template_inner_b_kw>of|as)(?-i)\h+
(?<callable_template_inner_b_types>(?&types_inner))
|)
(?<callable_template_inner_d> # template default
\h*=\h*
(?<callable_template_inner_d_types>(?&types_inner))
|)
)
(?:
\h*,\h*
(?&callable_template_inner)
)*+
)
\h*>
(?=\h*\()
|)
(?<callable_start>\h*\(\h*)
(?<callable_arguments>
(?<callable_argument>
(?<callable_argument_type>(?&types_inner))
(?<callable_argument_is_reference>\h*&|)
(?<callable_argument_is_variadic>\h*\.\.\.|)
(?<callable_argument_name>\h*\$(?&identifier)|)
(?<callable_argument_is_optional>\h*=|)
)
(?:
\h*,\h*
(?&callable_argument)
)*+
(?:\h*,)?
|)
\h*\)
(?:
\h*\:\h*
(?<callable_return>(?&type))
)?
)
|
(?<generic> # generic syntax, e.g.: `array<int, \Foo\Bar>`
(?<generic_name>(?&name))
(?<generic_start>\h*<[\h\v*]*)
(?<generic_types>
(?&types_inner)
(?:
\h*,[\h\v*]*
(?&types_inner)
)*+
(?:\h*,)?
)
[\h\v*]*>
)
|
(?<class_constant> # class constants with optional wildcard, e.g.: `Foo::*`, `Foo::CONST_A`, `FOO::CONST_*`
(?<class_constant_name>(?&name))
::\*?(?:(?&identifier)\*?)*
)
|
(?<constant> # single constant value (case insensitive), e.g.: 1, -1.8E+6, `\'a\'`
(?i)
# all sorts of numbers: with or without sign, supports literal separator and several numeric systems,
# e.g.: 1, +1.1, 1., .1, -1, 123E+8, 123_456_789, 0x7Fb4, 0b0110, 0o777
[+-]?(?:
(?:0b[01]++(?:_[01]++)*+)
| (?:0o[0-7]++(?:_[0-7]++)*+)
| (?:0x[\da-f]++(?:_[\da-f]++)*+)
| (?:(?<constant_digits>\d++(?:_\d++)*+)|(?=\.\d))
(?:\.(?&constant_digits)|(?<=\d)\.)?+
(?:e[+-]?(?&constant_digits))?+
)
| \'(?:[^\'\\\]|\\\.)*+\'
| "(?:[^"\\\]|\\\.)*+"
(?-i)
)
|
(?<this> # self reference, e.g.: $this, $self, @static
(?i)
[@$](?:this | self | static)
(?-i)
)
|
(?<name> # full name, e.g.: `int`, `\DateTime`, `\Foo\Bar`, `positive-int`
\\\?+
(?<identifier>'.self::REGEX_IDENTIFIER.')
(?:[\\\\\-](?&identifier))*+
)
|
(?<parenthesized> # parenthesized type, e.g.: `(int)`, `(int|\stdClass)`
(?<parenthesized_start>
\(\h*
)
(?:
(?<parenthesized_types>
(?&types_inner)
)
|
(?<conditional> # conditional type, e.g.: `$foo is \Throwable ? false : $foo`
(?<conditional_cond_left>
(?:\$(?&identifier))
|
(?<conditional_cond_left_types>(?&types_inner))
)
(?<conditional_cond_middle>
\h+(?i)is(?:\h+not)?(?-i)\h+
)
(?<conditional_cond_right_types>(?&types_inner))
(?<conditional_true_start>\h*\?\h*)
(?<conditional_true_types>(?&types_inner))
(?<conditional_false_start>\h*:\h*)
(?<conditional_false_types>(?&types_inner))
)
)
\h*\)
)
)
(?<array> # array, e.g.: `string[]`, `array<int, string>[][]`
(\h*\[\h*\])*
)
(?:(?=1)0
(?<types_inner>(?>
(?&type)
(?:
\h*[|&]\h*
(?&type)
)*+
))
|)
)';
private string $value;
private bool $isCompositeType;
/** @var null|'&'|'|' */
private ?string $typesGlue = null;
/** @var list<array{start_index: int, expression: self}> */
private array $innerTypeExpressions = [];
private ?NamespaceAnalysis $namespace;
/** @var list<NamespaceUseAnalysis> */
private array $namespaceUses;
/**
* @param list<NamespaceUseAnalysis> $namespaceUses
*/
public function __construct(string $value, ?NamespaceAnalysis $namespace, array $namespaceUses)
{
$this->value = $value;
$this->namespace = $namespace;
$this->namespaceUses = $namespaceUses;
$this->parse();
}
public function toString(): string
{
return $this->value;
}
/**
* @return list<string>
*/
public function getTypes(): array
{
if ($this->isCompositeType) {
return array_map(
static fn (array $type) => $type['expression']->toString(),
$this->innerTypeExpressions,
);
}
return [$this->value];
}
/**
* Determines if type expression is a composite type (union or intersection).
*/
public function isCompositeType(): bool
{
return $this->isCompositeType;
}
public function isUnionType(): bool
{
return $this->isCompositeType && '|' === $this->typesGlue;
}
public function isIntersectionType(): bool
{
return $this->isCompositeType && '&' === $this->typesGlue;
}
/**
* @return null|'&'|'|'
*/
public function getTypesGlue(): ?string
{
return $this->typesGlue;
}
/**
* @param \Closure(self): self $callback
*/
public function mapTypes(\Closure $callback): self
{
$value = $this->value;
$startIndexOffset = 0;
foreach ($this->innerTypeExpressions as [
'start_index' => $startIndexOrig,
'expression' => $inner,
]) {
$innerValueOrig = $inner->value;
$inner = $inner->mapTypes($callback);
if ($inner->value !== $innerValueOrig) {
$value = substr_replace(
$value,
$inner->value,
$startIndexOrig + $startIndexOffset,
\strlen($innerValueOrig)
);
$startIndexOffset += \strlen($inner->value) - \strlen($innerValueOrig);
}
}
$type = $value === $this->value
? $this
: $this->inner($value);
return $callback($type);
}
/**
* @param \Closure(self): void $callback
*/
public function walkTypes(\Closure $callback): void
{
$this->mapTypes(static function (self $type) use ($callback) {
$valueOrig = $type->value;
$callback($type);
\assert($type->value === $valueOrig);
return $type;
});
}
/**
* @param \Closure(self, self): (-1|0|1) $compareCallback
*/
public function sortTypes(\Closure $compareCallback): self
{
return $this->mapTypes(function (self $type) use ($compareCallback): self {
if ($type->isCompositeType) {
$innerTypeExpressions = Utils::stableSort(
$type->innerTypeExpressions,
static fn (array $v): self => $v['expression'],
$compareCallback,
);
if ($innerTypeExpressions !== $type->innerTypeExpressions) {
$value = implode(
$type->getTypesGlue(),
array_map(static fn (array $v): string => $v['expression']->toString(), $innerTypeExpressions)
);
return $this->inner($value);
}
}
return $type;
});
}
public function getCommonType(): ?string
{
$aliases = $this->getAliases();
$mainType = null;
foreach ($this->getTypes() as $type) {
if ('null' === $type) {
continue;
}
if (str_starts_with($type, '?')) {
$type = substr($type, 1);
}
if (Preg::match('/\[\h*\]$/', $type)) {
$type = 'array';
} elseif (Preg::match('/^(.+?)\h*[<{(]/', $type, $matches)) {
$type = $matches[1];
}
if (isset($aliases[$type])) {
$type = $aliases[$type];
}
if (null === $mainType || $type === $mainType) {
$mainType = $type;
continue;
}
$mainType = $this->getParentType($type, $mainType);
if (null === $mainType) {
return null;
}
}
return $mainType;
}
public function allowsNull(): bool
{
foreach ($this->getTypes() as $type) {
if (\in_array($type, ['null', 'mixed'], true) || str_starts_with($type, '?')) {
return true;
}
}
return false;
}
private function parse(): void
{
$seenGlues = null;
$innerValues = [];
$index = 0;
while (true) {
Preg::match(
'{\G'.self::REGEX_TYPE.'(?<glue_raw>\h*(?<glue>[|&])\h*(?!$)|$)}',
$this->value,
$matches,
\PREG_OFFSET_CAPTURE,
$index
);
if ([] === $matches) {
throw new \Exception('Unable to parse phpdoc type '.var_export($this->value, true));
}
if (null === $seenGlues) {
if (($matches['glue'][0] ?? '') === '') {
break;
}
$seenGlues = ['|' => false, '&' => false];
}
if (($matches['glue'][0] ?? '') !== '') {
\assert(isset($seenGlues[$matches['glue'][0]]));
$seenGlues[$matches['glue'][0]] = true;
}
$innerValues[] = [
'start_index' => $index,
'value' => $matches['type'][0],
'next_glue' => $matches['glue'][0] ?? null,
'next_glue_raw' => $matches['glue_raw'][0] ?? null,
];
$consumedValueLength = \strlen($matches[0][0]);
$index += $consumedValueLength;
if (\strlen($this->value) <= $index) {
\assert(\strlen($this->value) === $index);
$seenGlues = array_filter($seenGlues);
\assert([] !== $seenGlues);
$this->isCompositeType = true;
$this->typesGlue = array_key_first($seenGlues);
if (1 === \count($seenGlues)) {
foreach ($innerValues as $innerValue) {
$this->innerTypeExpressions[] = [
'start_index' => $innerValue['start_index'],
'expression' => $this->inner($innerValue['value']),
];
}
} else {
for ($i = 0; $i < \count($innerValues); ++$i) {
$innerStartIndex = $innerValues[$i]['start_index'];
$innerValue = '';
while (true) {
$innerValue .= $innerValues[$i]['value'];
if (($innerValues[$i]['next_glue'] ?? $this->typesGlue) === $this->typesGlue) {
break;
}
$innerValue .= $innerValues[$i]['next_glue_raw'];
++$i;
}
$this->innerTypeExpressions[] = [
'start_index' => $innerStartIndex,
'expression' => $this->inner($innerValue),
];
}
}
return;
}
}
$this->isCompositeType = false;
if ('' !== $matches['nullable'][0]) {
$this->innerTypeExpressions[] = [
'start_index' => \strlen($matches['nullable'][0]),
'expression' => $this->inner(substr($matches['type'][0], \strlen($matches['nullable'][0]))),
];
} elseif ('' !== $matches['array'][0]) {
$this->innerTypeExpressions[] = [
'start_index' => 0,
'expression' => $this->inner(substr($matches['type'][0], 0, -\strlen($matches['array'][0]))),
];
} elseif ('' !== ($matches['generic'][0] ?? '') && 0 === $matches['generic'][1]) {
$this->innerTypeExpressions[] = [
'start_index' => 0,
'expression' => $this->inner($matches['generic_name'][0]),
];
$this->parseCommaSeparatedInnerTypes(
\strlen($matches['generic_name'][0]) + \strlen($matches['generic_start'][0]),
$matches['generic_types'][0]
);
} elseif ('' !== ($matches['callable'][0] ?? '') && 0 === $matches['callable'][1]) {
$this->innerTypeExpressions[] = [
'start_index' => 0,
'expression' => $this->inner($matches['callable_name'][0]),
];
$this->parseCallableTemplateInnerTypes(
\strlen($matches['callable_name'][0])
+ \strlen($matches['callable_template_start'][0]),
$matches['callable_template_inners'][0]
);
$this->parseCallableArgumentTypes(
\strlen($matches['callable_name'][0])
+ \strlen($matches['callable_template'][0])
+ \strlen($matches['callable_start'][0]),
$matches['callable_arguments'][0]
);
if ('' !== ($matches['callable_return'][0] ?? '')) {
$this->innerTypeExpressions[] = [
'start_index' => \strlen($this->value) - \strlen($matches['callable_return'][0]),
'expression' => $this->inner($matches['callable_return'][0]),
];
}
} elseif ('' !== ($matches['array_shape'][0] ?? '') && 0 === $matches['array_shape'][1]) {
$this->innerTypeExpressions[] = [
'start_index' => 0,
'expression' => $this->inner($matches['array_shape_name'][0]),
];
$nextIndex = \strlen($matches['array_shape_name'][0]) + \strlen($matches['array_shape_start'][0]);
$this->parseArrayShapeInnerTypes(
$nextIndex,
$matches['array_shape_inners'][0]
);
if ('' !== ($matches['array_shape_unsealed_type'][0] ?? '')) {
$nextIndex += \strlen($matches['array_shape_inners'][0])
+ \strlen($matches['array_shape_unsealed_variadic'][0])
+ \strlen($matches['array_shape_unsealed_type_start'][0]);
$this->innerTypeExpressions[] = [
'start_index' => $nextIndex,
'expression' => $this->inner($matches['array_shape_unsealed_type_a'][0]),
];
if ('' !== ($matches['array_shape_unsealed_type_b'][0] ?? '')) {
$nextIndex += \strlen($matches['array_shape_unsealed_type_a'][0])
+ \strlen($matches['array_shape_unsealed_type_comma'][0]);
$this->innerTypeExpressions[] = [
'start_index' => $nextIndex,
'expression' => $this->inner($matches['array_shape_unsealed_type_b'][0]),
];
}
}
} elseif ('' !== ($matches['parenthesized'][0] ?? '') && 0 === $matches['parenthesized'][1]) {
$index = \strlen($matches['parenthesized_start'][0]);
if ('' !== ($matches['conditional'][0] ?? '')) {
if ('' !== ($matches['conditional_cond_left_types'][0] ?? '')) {
$this->innerTypeExpressions[] = [
'start_index' => $index,
'expression' => $this->inner($matches['conditional_cond_left_types'][0]),
];
}
$index += \strlen($matches['conditional_cond_left'][0]) + \strlen($matches['conditional_cond_middle'][0]);
$this->innerTypeExpressions[] = [
'start_index' => $index,
'expression' => $this->inner($matches['conditional_cond_right_types'][0]),
];
$index += \strlen($matches['conditional_cond_right_types'][0]) + \strlen($matches['conditional_true_start'][0]);
$this->innerTypeExpressions[] = [
'start_index' => $index,
'expression' => $this->inner($matches['conditional_true_types'][0]),
];
$index += \strlen($matches['conditional_true_types'][0]) + \strlen($matches['conditional_false_start'][0]);
$this->innerTypeExpressions[] = [
'start_index' => $index,
'expression' => $this->inner($matches['conditional_false_types'][0]),
];
} else {
$this->innerTypeExpressions[] = [
'start_index' => $index,
'expression' => $this->inner($matches['parenthesized_types'][0]),
];
}
} elseif ('' !== $matches['class_constant'][0]) {
$this->innerTypeExpressions[] = [
'start_index' => 0,
'expression' => $this->inner($matches['class_constant_name'][0]),
];
}
}
private function parseCommaSeparatedInnerTypes(int $startIndex, string $value): void
{
$index = 0;
while (\strlen($value) !== $index) {
Preg::match(
'{\G'.self::REGEX_TYPES.'(?:\h*,[\h\v*]*|$)}',
$value,
$matches,
0,
$index
);
$this->innerTypeExpressions[] = [
'start_index' => $startIndex + $index,
'expression' => $this->inner($matches['types']),
];
$index += \strlen($matches[0]);
}
}
private function parseCallableTemplateInnerTypes(int $startIndex, string $value): void
{
$index = 0;
while (\strlen($value) !== $index) {
Preg::match(
'{\G(?:(?=1)0'.self::REGEX_TYPES.'|(?<_callable_template_inner>(?&callable_template_inner))(?:\h*,\h*|$))}',
$value,
$prematches,
0,
$index
);
$consumedValue = $prematches['_callable_template_inner'];
$consumedValueLength = \strlen($consumedValue);
$consumedCommaLength = \strlen($prematches[0]) - $consumedValueLength;
$addedPrefix = 'Closure<';
Preg::match(
'{^'.self::REGEX_TYPES.'$}',
$addedPrefix.$consumedValue.'>(): void',
$matches,
\PREG_OFFSET_CAPTURE
);
if ('' !== $matches['callable_template_inner_b'][0]) {
$this->innerTypeExpressions[] = [
'start_index' => $startIndex + $index + $matches['callable_template_inner_b_types'][1]
- \strlen($addedPrefix),
'expression' => $this->inner($matches['callable_template_inner_b_types'][0]),
];
}
if ('' !== $matches['callable_template_inner_d'][0]) {
$this->innerTypeExpressions[] = [
'start_index' => $startIndex + $index + $matches['callable_template_inner_d_types'][1]
- \strlen($addedPrefix),
'expression' => $this->inner($matches['callable_template_inner_d_types'][0]),
];
}
$index += $consumedValueLength + $consumedCommaLength;
}
}
private function parseCallableArgumentTypes(int $startIndex, string $value): void
{
$index = 0;
while (\strlen($value) !== $index) {
Preg::match(
'{\G(?:(?=1)0'.self::REGEX_TYPES.'|(?<_callable_argument>(?&callable_argument))(?:\h*,\h*|$))}',
$value,
$prematches,
0,
$index
);
$consumedValue = $prematches['_callable_argument'];
$consumedValueLength = \strlen($consumedValue);
$consumedCommaLength = \strlen($prematches[0]) - $consumedValueLength;
$addedPrefix = 'Closure(';
Preg::match(
'{^'.self::REGEX_TYPES.'$}',
$addedPrefix.$consumedValue.'): void',
$matches,
\PREG_OFFSET_CAPTURE
);
$this->innerTypeExpressions[] = [
'start_index' => $startIndex + $index,
'expression' => $this->inner($matches['callable_argument_type'][0]),
];
$index += $consumedValueLength + $consumedCommaLength;
}
}
private function parseArrayShapeInnerTypes(int $startIndex, string $value): void
{
$index = 0;
while (\strlen($value) !== $index) {
Preg::match(
'{\G(?:(?=1)0'.self::REGEX_TYPES.'|(?<_array_shape_inner>(?&array_shape_inner))(?:\h*,[\h\v*]*|$))}',
$value,
$prematches,
0,
$index
);
$consumedValue = $prematches['_array_shape_inner'];
$consumedValueLength = \strlen($consumedValue);
$consumedCommaLength = \strlen($prematches[0]) - $consumedValueLength;
$addedPrefix = 'array{';
Preg::match(
'{^'.self::REGEX_TYPES.'$}',
$addedPrefix.$consumedValue.'}',
$matches,
\PREG_OFFSET_CAPTURE
);
$this->innerTypeExpressions[] = [
'start_index' => $startIndex + $index + $matches['array_shape_inner_value'][1]
- \strlen($addedPrefix),
'expression' => $this->inner($matches['array_shape_inner_value'][0]),
];
$index += $consumedValueLength + $consumedCommaLength;
}
}
private function inner(string $value): self
{
return new self($value, $this->namespace, $this->namespaceUses);
}
private function getParentType(string $type1, string $type2): ?string
{
$types = [
$this->normalize($type1),
$this->normalize($type2),
];
natcasesort($types);
$types = implode('|', $types);
$parents = [
'array|Traversable' => 'iterable',
'array|iterable' => 'iterable',
'iterable|Traversable' => 'iterable',
'self|static' => 'self',
];
return $parents[$types] ?? null;
}
private function normalize(string $type): string
{
$aliases = $this->getAliases();
if (isset($aliases[$type])) {
return $aliases[$type];
}
if (\in_array($type, [
'array',
'bool',
'callable',
'false',
'float',
'int',
'iterable',
'mixed',
'never',
'null',
'object',
'resource',
'string',
'true',
'void',
], true)) {
return $type;
}
if (Preg::match('/\[\]$/', $type)) {
return 'array';
}
if (Preg::match('/^(.+?)</', $type, $matches)) {
return $matches[1];
}
if (str_starts_with($type, '\\')) {
return substr($type, 1);
}
foreach ($this->namespaceUses as $namespaceUse) {
if ($namespaceUse->getShortName() === $type) {
return $namespaceUse->getFullName();
}
}
if (null === $this->namespace || $this->namespace->isGlobalNamespace()) {
return $type;
}
return "{$this->namespace->getFullName()}\\{$type}";
}
/**
* @return array<string, string>
*/
private function getAliases(): array
{
return [
'boolean' => 'bool',
'callback' => 'callable',
'double' => 'float',
'false' => 'bool',
'integer' => 'int',
'list' => 'array',
'real' => 'float',
'true' => 'bool',
];
}
}