From 45eddeaef4cc8a08ea1537c0fea5ab2cfd8f3432 Mon Sep 17 00:00:00 2001 From: thelovekesh Date: Sun, 28 Jun 2026 22:25:49 +0530 Subject: [PATCH 1/9] Add `phpstan/phpdoc-parser` --- composer.json | 1 + 1 file changed, 1 insertion(+) diff --git a/composer.json b/composer.json index dac50aa..14962bb 100644 --- a/composer.json +++ b/composer.json @@ -25,6 +25,7 @@ "require": { "php": "^7.3 || ^8.0", "nikic/php-parser": "^4.18 || ^5.5", + "phpstan/phpdoc-parser": "^2.3", "symfony/console": "^5.1 || ^6.0 || ^7.0", "symfony/filesystem": "^5.0 || ^6.0 || ^7.0", "symfony/finder": "^5.0 || ^6.0 || ^7.0" From 3fd8fe7e03fcee22a44d8979edc6cd09b3bd0cc6 Mon Sep 17 00:00:00 2001 From: thelovekesh Date: Sun, 28 Jun 2026 22:38:27 +0530 Subject: [PATCH 2/9] Add `PhpDocFqcnRewriter` --- src/PhpDocFqcnRewriter.php | 70 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 src/PhpDocFqcnRewriter.php diff --git a/src/PhpDocFqcnRewriter.php b/src/PhpDocFqcnRewriter.php new file mode 100644 index 0000000..319fca1 --- /dev/null +++ b/src/PhpDocFqcnRewriter.php @@ -0,0 +1,70 @@ + true, 'indexes' => true, 'comments' => true]); + $constExprParser = new ConstExprParser($config); + + $this->lexer = new Lexer($config); + $this->printer = new Printer(); + $this->docParser = new PhpDocParser($config, new TypeParser($config, $constExprParser), $constExprParser); + } + + /** + * @param array $imports + */ + public function rewrite(string $docComment, array $imports): string + { + if ($imports === []) { + return $docComment; + } + + $aliases = []; + foreach ($imports as $alias => $fqcn) { + $aliases[strtolower($alias)] = $fqcn; + } + + try { + $tokens = new TokenIterator($this->lexer->tokenize($docComment)); + $original = $this->docParser->parse($tokens); + } catch (\Throwable $e) { + return $docComment; + } + + $rewritten = $this->cloningTraverser()->traverse([$original])[0]; + (new NodeTraverser([new PhpDocTypeNameResolver($aliases)]))->traverse([$rewritten]); + + if (! $rewritten instanceof PhpDocNode) { + return $docComment; + } + + return $this->printer->printFormatPreserving($rewritten, $original, $tokens); + } + + private function cloningTraverser(): NodeTraverser + { + return new NodeTraverser([new CloningVisitor()]); + } +} From 610fe3d52879a5becb24d5904c836805ac2291ed Mon Sep 17 00:00:00 2001 From: thelovekesh Date: Sun, 28 Jun 2026 22:38:36 +0530 Subject: [PATCH 3/9] Add `PhpDocTypeNameResolver` --- src/PhpDocTypeNameResolver.php | 148 +++++++++++++++++++++++++++++++++ 1 file changed, 148 insertions(+) create mode 100644 src/PhpDocTypeNameResolver.php diff --git a/src/PhpDocTypeNameResolver.php b/src/PhpDocTypeNameResolver.php new file mode 100644 index 0000000..e649b76 --- /dev/null +++ b/src/PhpDocTypeNameResolver.php @@ -0,0 +1,148 @@ + */ + private array $aliases; + + /** @var array */ + private array $skip = []; + + /** @var array */ + private array $templateNames = []; + + /** + * @param array $aliases + */ + public function __construct(array $aliases) + { + $this->aliases = $aliases; + } + + /** + * @return null + */ + public function enterNode(Node $node): ?Node + { + if ($node instanceof TemplateTagValueNode) { + $this->templateNames[strtolower($node->name)] = true; + + return null; + } + + if ( + ($node instanceof ArrayShapeItemNode || $node instanceof ObjectShapeItemNode) + && $node->keyName !== null + ) { + $this->skip[spl_object_id($node->keyName)] = true; + } + + if ($node instanceof GenericTypeNode && strtolower($node->type->name) === 'int') { + foreach ($node->genericTypes as $bound) { + if (! ($bound instanceof IdentifierTypeNode) || ! in_array(strtolower($bound->name), ['min', 'max'], true)) { + continue; + } + + $this->skip[spl_object_id($bound)] = true; + } + } + + if ($node instanceof CallableTypeNode) { + $this->skip[spl_object_id($node->identifier)] = true; + } + + if ($node instanceof ConstFetchNode && $node->className !== '') { + if (! isset($this->skip[spl_object_id($node)])) { + $resolved = $this->resolveName($node->className); + if ($resolved !== null) { + $node->className = $resolved; + } + } + + return null; + } + + if (! ($node instanceof IdentifierTypeNode)) { + return null; + } + + if (isset($this->skip[spl_object_id($node)]) || isset($this->templateNames[strtolower($node->name)])) { + return null; + } + + $resolved = $this->resolveName($node->name); + if ($resolved !== null) { + $node->name = $resolved; + } + + return null; + } + + private function resolveName(string $name): ?string + { + if (strncmp($name, '\\', 1) === 0) { + return null; // already fully qualified + } + + $separatorPos = strpos($name, '\\'); + $firstSegment = $separatorPos === false ? $name : substr($name, 0, $separatorPos); + + if (in_array(strtolower($firstSegment), self::RESERVED, true)) { + return null; + } + + $alias = strtolower($firstSegment); + if (! isset($this->aliases[$alias])) { + return null; + } + + $remainder = $separatorPos === false ? '' : substr($name, $separatorPos); + + return sprintf('%s%s', $this->aliases[$alias], $remainder); + } +} From b6cb6b25a97635517f74853a69b73ef01b2dec32 Mon Sep 17 00:00:00 2001 From: thelovekesh Date: Sun, 28 Jun 2026 22:43:49 +0530 Subject: [PATCH 4/9] Add phpdoc fqcn rewriter in node visitor --- src/NodeVisitor.php | 109 ++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 99 insertions(+), 10 deletions(-) diff --git a/src/NodeVisitor.php b/src/NodeVisitor.php index 61f8176..eb0a871 100644 --- a/src/NodeVisitor.php +++ b/src/NodeVisitor.php @@ -1,6 +1,11 @@ */ + private $useAliases = []; + /** @var PhpDocFqcnRewriter */ + private $fqcnRewriter; /** * @var array @@ -118,11 +128,13 @@ public function init(int $symbols = StubsGenerator::DEFAULT, array $config = []) $this->includeInaccessibleClassNodes = ($config['include_inaccessible_class_nodes'] ?? false) === true; $this->globalNamespace = new Namespace_(); + $this->fqcnRewriter = new PhpDocFqcnRewriter(); } public function beforeTraverse(array $nodes) { $this->stack = []; + $this->useAliases = []; return null; } @@ -130,6 +142,9 @@ public function enterNode(Node $node) { $this->stack[] = $node; + $this->trackUseStatements($node); + $this->rewriteImportedNames($node); + if ($node instanceof Namespace_) { // We always need to parse the children of namespaces. return null; @@ -156,7 +171,8 @@ public function enterNode(Node $node) // signatures are fully qualified by the `NameResolver` visitor. // (This will already be `true` if it's a ClassMethod.) $this->isInDeclaration = true; - } elseif ($node instanceof Expression + } elseif ( + $node instanceof Expression && $node->expr instanceof Assign ) { // Since we don't parse any the bodies of any statements which can @@ -164,7 +180,8 @@ public function enterNode(Node $node) // assigns are for globals. Check if we are assigning to `$GLOBALS` // with a simple string that's a valid variable identifier. If so, // convert it to a normal variable assignment. - if (count($this->stack) === 1 + if ( + count($this->stack) === 1 && $node->expr->var instanceof ArrayDimFetch && $node->expr->var->var instanceof Variable && $node->expr->var->var->name === 'GLOBALS' @@ -203,6 +220,77 @@ public function enterNode(Node $node) return null; } + private function trackUseStatements(Node $node): void + { + if ($node instanceof Namespace_) { + $this->useAliases = []; + return; + } + + if ($node instanceof Use_) { + foreach ($node->uses as $use) { + $this->addAlias($use, $node->type, ''); + } + return; + } + + if (!($node instanceof GroupUse)) { + return; + } + + $prefix = sprintf('%s\\', $node->prefix->toString()); + foreach ($node->uses as $use) { + $this->addAlias($use, $node->type, $prefix); + } + } + + /** + * @param \PhpParser\Node\UseItem $useItem + */ + private function addAlias(Node $useItem, int $type, string $prefix): void + { + if ($useItem->type !== Use_::TYPE_UNKNOWN) { + $type = $useItem->type; + } + + if ($type !== Use_::TYPE_NORMAL) { + return; + } + + $alias = strtolower($useItem->getAlias()->toString()); + $fullyQualifiedName = ltrim(sprintf('%s%s', $prefix, $useItem->name->toString()), '\\'); + + $this->useAliases[$alias] = sprintf('\\%s', $fullyQualifiedName); + } + + private function rewriteImportedNames(Node $node): void + { + if ( + !($node instanceof Function_) + && !($node instanceof ClassMethod) + && !($node instanceof Property) + && !($node instanceof ClassLike) + ) { + return; + } + + $docComment = $node->getDocComment(); + if (!($docComment instanceof Doc)) { + return; + } + + if ($this->useAliases === []) { + return; + } + + $newText = $this->fqcnRewriter->rewrite($docComment->getText(), $this->useAliases); + if ($newText === $docComment->getText()) { + return; + } + + $node->setDocComment(new Doc($newText, $docComment->getStartLine(), $docComment->getStartFilePos())); + } + public function leaveNode(Node $node, bool $preserveStack = false) { if (!$preserveStack) { @@ -258,8 +346,9 @@ public function leaveNode(Node $node, bool $preserveStack = false) // either a method, property, or constant, or enum case, or its part // of the declaration itself (e.g., `extends`). - if (!$this->includeInaccessibleClassNodes && ($parent instanceof Class_ || $parent instanceof Enum_) && ($node instanceof ClassMethod || $node instanceof ClassConst || $node instanceof Property)) { - if ($node->isPrivate() + if (!$this->includeInaccessibleClassNodes && ($parent instanceof Class_ || $parent instanceof Enum_) && ($node instanceof ClassMethod || $node instanceof ClassConst || $node instanceof Property)) { + if ( + $node->isPrivate() || ($parent instanceof Class_ && $parent->isFinal() && $node->isProtected()) || ($parent instanceof Enum_ && $node->isProtected()) ) { @@ -406,7 +495,7 @@ function (\PhpParser\Node\Const_ $const) { !isset($this->counts['constants'][$fullyQualifiedName]) ) { return $this->count('constants', $fullyQualifiedName) - && !defined($fullyQualifiedName); + && !defined($fullyQualifiedName); } } ); @@ -429,7 +518,7 @@ function (\PhpParser\Node\Const_ $const) { !isset($this->counts['constants'][$fullyQualifiedName]) ) { return $this->count('constants', $fullyQualifiedName) - && !defined($fullyQualifiedName); + && !defined($fullyQualifiedName); } } } From cd31ddc6056954c040ccf9bdab435ab05c5893fd Mon Sep 17 00:00:00 2001 From: thelovekesh Date: Sun, 28 Jun 2026 22:48:36 +0530 Subject: [PATCH 5/9] Add tests for PhpDocFqcnRewriterTest --- test/PhpDocFqcnRewriterTest.php | 253 ++++++++++++++++++++++++++++++++ 1 file changed, 253 insertions(+) create mode 100644 test/PhpDocFqcnRewriterTest.php diff --git a/test/PhpDocFqcnRewriterTest.php b/test/PhpDocFqcnRewriterTest.php new file mode 100644 index 0000000..38cfce8 --- /dev/null +++ b/test/PhpDocFqcnRewriterTest.php @@ -0,0 +1,253 @@ + $aliases + */ + public function testRewrite(array $aliases, string $input, string $expected): void + { + $rewriter = new PhpDocFqcnRewriter(); + self::assertSame($expected, $rewriter->rewrite($input, $aliases)); + } + + /** + * @return iterable, string, string}> + */ + public static function provideDocBlocks(): iterable + { + $std = [ + 'Foo' => '\Acme\Foo', + 'Bar' => '\Acme\Bar', + 'Baz' => '\Acme\Baz', + 'Message' => '\Acme\Messages\Message', + 'Coll' => '\Acme\Collection', + 'Sub' => '\Acme\Sub', + 'Qux' => '\Acme\Aliased', + 'Ex' => '\Acme\Exceptions\MyException', + ]; + + // Type annotations in various tags. + yield '@param' => [$std, '/** @param Foo $x */', '/** @param \Acme\Foo $x */']; + yield '@return' => [$std, '/** @return Foo */', '/** @return \Acme\Foo */']; + yield '@var with element name' => [$std, '/** @var Foo $bar */', '/** @var \Acme\Foo $bar */']; + yield '@var without element name' => [$std, '/** @var Foo */', '/** @var \Acme\Foo */']; + yield '@throws' => [$std, '/** @throws Ex */', '/** @throws \Acme\Exceptions\MyException */']; + yield '@property' => [$std, '/** @property Foo $x */', '/** @property \Acme\Foo $x */']; + yield '@property-read' => [$std, '/** @property-read Foo $x */', '/** @property-read \Acme\Foo $x */']; + yield '@property-write' => [$std, '/** @property-write Foo $x */', '/** @property-write \Acme\Foo $x */']; + yield '@method return and param' => [ + $std, + '/** @method Foo doThing(Baz $b) */', + '/** @method \Acme\Foo doThing(\Acme\Baz $b) */', + ]; + yield '@method static return type' => [ + $std, + '/** @method static Foo make() */', + '/** @method static \Acme\Foo make() */', + ]; + yield '@method multiple params' => [ + $std, + '/** @method Bar handle(Foo $a, Baz $b, int $c) */', + '/** @method \Acme\Bar handle(\Acme\Foo $a, \Acme\Baz $b, int $c) */', + ]; + yield '@mixin' => [$std, '/** @mixin Foo */', '/** @mixin \Acme\Foo */']; + + // PHPStan-specific tags. + yield '@phpstan-param' => [$std, '/** @phpstan-param Foo $x */', '/** @phpstan-param \Acme\Foo $x */']; + yield '@phpstan-return' => [$std, '/** @phpstan-return Foo */', '/** @phpstan-return \Acme\Foo */']; + yield '@phpstan-var' => [$std, '/** @phpstan-var Foo $x */', '/** @phpstan-var \Acme\Foo $x */']; + yield '@phpstan-type right-hand side' => [ + $std, + '/** @phpstan-type Prompt Foo|Message|int */', + '/** @phpstan-type Prompt \Acme\Foo|\Acme\Messages\Message|int */', + ]; + yield '@phpstan-import-type from target' => [ + $std, + '/** @phpstan-import-type Shape from Message */', + '/** @phpstan-import-type Shape from \Acme\Messages\Message */', + ]; + yield '@phpstan-import-type with as' => [ + $std, + '/** @phpstan-import-type Shape from Message as Renamed */', + '/** @phpstan-import-type Shape from \Acme\Messages\Message as Renamed */', + ]; + + // Type expressions in various forms. + yield 'union' => [$std, '/** @param Foo|Bar $x */', '/** @param \Acme\Foo|\Acme\Bar $x */']; + yield 'union with builtin' => [$std, '/** @param Foo|null $x */', '/** @param \Acme\Foo|null $x */']; + yield 'nullable shorthand' => [$std, '/** @param ?Foo $x */', '/** @param ?\Acme\Foo $x */']; + yield 'intersection' => [$std, '/** @param Foo&Bar $x */', '/** @param \Acme\Foo&\Acme\Bar $x */']; + yield 'generic list' => [$std, '/** @param list $x */', '/** @param list<\Acme\Foo> $x */']; + yield 'generic array with key' => [ + $std, + '/** @param array $x */', + '/** @param array $x */', + ]; + yield 'generic custom collection' => [ + $std, + '/** @param Coll $x */', + '/** @param \Acme\Collection<\Acme\Foo> $x */', + ]; + yield 'array shape' => [ + $std, + '/** @param array{a: Foo, b?: Bar} $x */', + '/** @param array{a: \Acme\Foo, b?: \Acme\Bar} $x */', + ]; + yield 'nested generics' => [ + $std, + '/** @param array> $x */', + '/** @param array> $x */', + ]; + yield 'callable' => [ + $std, + '/** @param callable(Foo): Bar $x */', + '/** @param callable(\Acme\Foo): \Acme\Bar $x */', + ]; + yield 'variadic' => [$std, '/** @param Foo ...$x */', '/** @param \Acme\Foo ...$x */']; + yield 'by reference' => [$std, '/** @param Foo &$x */', '/** @param \Acme\Foo &$x */']; + yield 'class-string generic' => [ + $std, + '/** @param class-string $x */', + '/** @param class-string<\Acme\Foo> $x */', + ]; + + // Types that are imported via an alias. + yield 'aliased import' => [$std, '/** @param Qux $x */', '/** @param \Acme\Aliased $x */']; + yield 'qualified name, imported first segment' => [ + $std, + '/** @param Sub\Deep $x */', + '/** @param \Acme\Sub\Deep $x */', + ]; + yield 'case-insensitive alias match' => [$std, '/** @param foo $x */', '/** @param \Acme\Foo $x */']; + + // Reserved words and built-in types that should not be rewritten. + yield 'builtin scalar untouched' => [$std, '/** @param string $x */', '/** @param string $x */']; + yield 'builtin array untouched' => [$std, '/** @param array $x */', '/** @param array $x */']; + yield 'reserved static untouched' => [$std, '/** @return static */', '/** @return static */']; + yield 'reserved self untouched' => [$std, '/** @return self */', '/** @return self */']; + yield 'pseudo-type list untouched' => [$std, '/** @return list */', '/** @return list */']; + yield 'already fully qualified untouched' => [ + $std, + '/** @param \Already\Qualified $x */', + '/** @param \Already\Qualified $x */', + ]; + yield 'unimported same-namespace class untouched' => [ + $std, + '/** @param NotImported $x */', + '/** @param NotImported $x */', + ]; + yield 'local type alias untouched' => [$std, '/** @param Prompt $x */', '/** @param Prompt $x */']; + yield 'class name in description untouched' => [ + $std, + '/** @param Foo $x A Foo instance to use. */', + '/** @param \Acme\Foo $x A Foo instance to use. */', + ]; + + // Formatting and layout preservation. + yield 'multi-line layout preserved' => [ + $std, + <<<'DOC' + /** + * Does a thing with a Foo. + * + * @since 1.2.3 + * + * @param Foo $foo The foo to use. + * @param int $count How many. + * @return Bar The result. + * @throws Ex When it breaks. + */ + DOC, + <<<'DOC' + /** + * Does a thing with a Foo. + * + * @since 1.2.3 + * + * @param \Acme\Foo $foo The foo to use. + * @param int $count How many. + * @return \Acme\Bar The result. + * @throws \Acme\Exceptions\MyException When it breaks. + */ + DOC, + ]; + yield 'multiple tags in one block' => [ + $std, + <<<'DOC' + /** + * @param Foo $a + * @param Bar $b + * @return Baz + */ + DOC, + <<<'DOC' + /** + * @param \Acme\Foo $a + * @param \Acme\Bar $b + * @return \Acme\Baz + */ + DOC, + ]; + yield 'empty alias map leaves everything untouched' => [ + [], + '/** @param Foo $x */', + '/** @param Foo $x */', + ]; + yield 'unparsable input returned unchanged' => [ + $std, + 'this is not a doc comment', + 'this is not a doc comment', + ]; + + // Types in array/object shapes. + yield 'shape key matching import left alone' => [ + $std, + '/** @param array{message: string, body: Bar} $x */', + '/** @param array{message: string, body: \Acme\Bar} $x */', + ]; + yield 'object shape key left alone' => [ + $std, + '/** @return object{message: int} */', + '/** @return object{message: int} */', + ]; + yield 'class constant type qualified' => [ + $std, + '/** @return Foo::TYPE_X */', + '/** @return \Acme\Foo::TYPE_X */', + ]; + yield 'enum case wildcard qualified' => [ + $std, + '/** @param Foo::* $x */', + '/** @param \Acme\Foo::* $x */', + ]; + yield 'old-style array suffix' => [ + $std, + '/** @param Foo[] $x */', + '/** @param \Acme\Foo[] $x */', + ]; + + // Template annotations. + yield 'template shadows import' => [ + $std, + <<<'DOC' + /** + * @template Foo + * @param Foo $x + * @return Bar + */ + DOC, + <<<'DOC' + /** + * @template Foo + * @param Foo $x + * @return \Acme\Bar + */ + DOC, + ]; + } +} From 1216c15580a780f12472cfa4973ba776cc47cc8a Mon Sep 17 00:00:00 2001 From: thelovekesh Date: Sun, 28 Jun 2026 22:52:02 +0530 Subject: [PATCH 6/9] Add `mikey179/vfsstream` --- composer.json | 1 + 1 file changed, 1 insertion(+) diff --git a/composer.json b/composer.json index 14962bb..c83f156 100644 --- a/composer.json +++ b/composer.json @@ -32,6 +32,7 @@ }, "require-dev": { "friendsofphp/php-cs-fixer": "3.4.0 || ^3.12", + "mikey179/vfsstream": "^1.6", "phpstan/extension-installer": "^1.4", "phpstan/phpstan": "^1.0 || ^2.0", "phpstan/phpstan-symfony": "^1.0 || ^2.0", From dadb54820a4c0248b05405244677a14ec9434906 Mon Sep 17 00:00:00 2001 From: thelovekesh Date: Sun, 28 Jun 2026 22:55:51 +0530 Subject: [PATCH 7/9] Add integration tests for stub generator --- test/NodeVisitorFqcnRewriteTest.php | 127 ++++++++++++++++++++++++++++ 1 file changed, 127 insertions(+) create mode 100644 test/NodeVisitorFqcnRewriteTest.php diff --git a/test/NodeVisitorFqcnRewriteTest.php b/test/NodeVisitorFqcnRewriteTest.php new file mode 100644 index 0000000..9388b4c --- /dev/null +++ b/test/NodeVisitorFqcnRewriteTest.php @@ -0,0 +1,127 @@ + The collected messages. + */ + protected array $messages = []; + + /** + * @param Message $message The message to add. + * @param Registry $registry The registry (aliased import). + * @return Message + * @throws InvalidArgumentException When invalid. + */ + public function add(Message $message, Registry $registry): Message + { + return $message; + } + + /** + * @param Message ...$parts The parts. + * @return self + */ + public function withParts(Message ...$parts): self + { + return $this; + } + } + PHP; + + $output = $this->generateStubs($source); + + // Tags are rewritten to fully qualified names. + self::assertStringContainsString('@var list<\Acme\Models\Message>', $output); + self::assertStringContainsString('@param \Acme\Models\Message $message', $output); + self::assertStringContainsString('@param \Acme\Models\ProviderRegistry $registry', $output); + self::assertStringContainsString('@return \Acme\Models\Message', $output); + self::assertStringContainsString('@throws \Acme\Exceptions\InvalidArgumentException When invalid.', $output); + self::assertStringContainsString('@param \Acme\Models\Message ...$parts', $output); + + // Unqualified names no longer appear in the output. + self::assertStringNotContainsString('@param Message ', $output); + self::assertStringNotContainsString('@param Registry ', $output); + self::assertStringNotContainsString('@throws InvalidArgumentException', $output); + self::assertStringNotContainsString('@var list', $output); + + // Type declarations are rewritten to fully qualified names. + self::assertStringContainsString('public function add(\Acme\Models\Message $message', $output); + + // Imports no longer appear in the output. + self::assertStringNotContainsString('use Acme\Models\Message', $output); + + // Descriptions are preserved verbatim. + self::assertStringContainsString('The registry (aliased import).', $output); + } + + public function testShapeKeysConstantsAndTemplatesAreHandled(): void + { + $source = <<<'PHP' + generateStubs($source); + + // Template names are preserved verbatim, even if they match an import. + self::assertStringContainsString('@param Reply $x', $output); + self::assertStringNotContainsString('@param \Acme\Models\Reply $x', $output); + + // Shape keys are preserved verbatim, even if they match an import. + self::assertStringContainsString('status: int', $output); + self::assertStringContainsString('extra: \Acme\Models\Status', $output); + self::assertStringNotContainsString('\Acme\Models\Status: int', $output); + + // Return types that reference constants are rewritten to fully qualified names. + self::assertStringContainsString('@return \Acme\Models\Status::ACTIVE', $output); + } + + private function generateStubs(string $source): string + { + $root = vfsStream::setup('stubs'); + vfsStream::newFile('fixture.php')->at($root)->setContent($source); + + $finder = Finder::create()->in(vfsStream::url('stubs'))->name('*.php'); + return (new StubsGenerator())->generate($finder)->prettyPrint(); + } +} From cab824ecdc591e18ac9806b94efe41eb1104aa82 Mon Sep 17 00:00:00 2001 From: thelovekesh Date: Sun, 28 Jun 2026 23:00:30 +0530 Subject: [PATCH 8/9] Bump php to 7.4 --- .github/workflows/test.yml | 1 - composer.json | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index caa3745..cd8774a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -16,7 +16,6 @@ jobs: strategy: matrix: php-version: - - "7.3" - "7.4" - "8.0" - "8.1" diff --git a/composer.json b/composer.json index c83f156..5dd63c2 100644 --- a/composer.json +++ b/composer.json @@ -23,7 +23,7 @@ "bin/generate-stubs" ], "require": { - "php": "^7.3 || ^8.0", + "php": "^7.4 || ^8.0", "nikic/php-parser": "^4.18 || ^5.5", "phpstan/phpdoc-parser": "^2.3", "symfony/console": "^5.1 || ^6.0 || ^7.0", From faab4fc112cc361494ccff066a9fca62d137d670 Mon Sep 17 00:00:00 2001 From: thelovekesh Date: Sun, 28 Jun 2026 23:02:21 +0530 Subject: [PATCH 9/9] Add changelog --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d9fe9c..d644591 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## [Unreleased] +### Added +- PHPDoc type names that depend on `use` imports (e.g. in `@param`, `@return`, `@var`, `@throws`, `@property`, `@method`, `@mixin`, and `@phpstan-*` tags) are now resolved to their fully qualified form in generated stubs, while template names, array/object shape keys, and reserved/built-in types are left untouched. +### Changed +- Minimum supported PHP version raised to 7.4 (required by `phpstan/phpdoc-parser` 2.x). + ## [0.5] - 2018-04-13 ### Added - `--nullify-globals` option: converts all global variable assignments to `null`.