aboutsummaryrefslogtreecommitdiffhomepage
path: root/scripts/plugin-class-classifier/src/SourceParser.php
diff options
context:
space:
mode:
Diffstat (limited to 'scripts/plugin-class-classifier/src/SourceParser.php')
-rw-r--r--scripts/plugin-class-classifier/src/SourceParser.php416
1 files changed, 416 insertions, 0 deletions
diff --git a/scripts/plugin-class-classifier/src/SourceParser.php b/scripts/plugin-class-classifier/src/SourceParser.php
new file mode 100644
index 00000000..591c54b1
--- /dev/null
+++ b/scripts/plugin-class-classifier/src/SourceParser.php
@@ -0,0 +1,416 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shirabe\PluginClassifier;
+
+use PhpParser\Node;
+use PhpParser\Node\Name;
+use PhpParser\Node\Stmt;
+use PhpParser\NodeTraverser;
+use PhpParser\NodeVisitor\NameResolver;
+use PhpParser\NodeVisitorAbstract;
+use PhpParser\Parser;
+use PhpParser\ParserFactory;
+
+/**
+ * Parses a source tree into ClassInfo records. Names inside declarations
+ * and bodies are resolved by PhpParser's NameResolver; docblock types are
+ * resolved separately against the recorded per-file use map.
+ */
+final class SourceParser
+{
+ private Parser $parser;
+
+ private DocblockTypeExtractor $docblocks;
+
+ /** @var array<string, ClassInfo> FQCN (case-preserved) => info */
+ public array $classes = [];
+
+ /** @var array<string, string> lowercase FQCN => case-preserved FQCN */
+ public array $lowercaseIndex = [];
+
+ /** @var list<string> docblock tokens that resolved to nothing known; reported, not fatal */
+ public array $unresolvedDocblockTypes = [];
+
+ private BuiltinSignatures $builtins;
+
+ public function __construct()
+ {
+ $this->parser = (new ParserFactory())->createForNewestSupportedVersion();
+ $this->docblocks = new DocblockTypeExtractor();
+ $this->builtins = new BuiltinSignatures();
+ }
+
+ public function parseTree(string $root): void
+ {
+ $files = [];
+ $it = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($root, \FilesystemIterator::SKIP_DOTS));
+ foreach ($it as $file) {
+ if (!$file->isFile() || $file->getExtension() !== 'php') {
+ continue;
+ }
+ // PHPStan extensions are dev-only tooling, never shipped at
+ // runtime; they reference phpstan types and only add noise.
+ if (str_contains($file->getPathname(), '/PHPStan/')) {
+ continue;
+ }
+ $files[] = $file->getPathname();
+ }
+ sort($files);
+
+ // Pre-scan for @template / @phpstan-type alias names so the
+ // docblock type extractor can ignore them tree-wide.
+ foreach ($files as $path) {
+ $code = file_get_contents($path);
+ if ($code !== false) {
+ $this->docblocks->collectAliases($code);
+ }
+ }
+
+ foreach ($files as $path) {
+ $this->parseFile($path);
+ }
+ }
+
+ public function finalize(): void
+ {
+ $this->mergeTraits();
+ }
+
+ private function parseFile(string $path): void
+ {
+ $code = file_get_contents($path);
+ if ($code === false) {
+ throw new \RuntimeException("cannot read $path");
+ }
+
+ $stmts = $this->parser->parse($code);
+ if ($stmts === null) {
+ throw new \RuntimeException("cannot parse $path");
+ }
+
+ $traverser = new NodeTraverser();
+ $traverser->addVisitor(new NameResolver());
+ $useCollector = new class extends NodeVisitorAbstract {
+ public string $namespace = '';
+
+ /** @var array<string, string> lowercase alias => FQCN */
+ public array $useMap = [];
+
+ public function enterNode(Node $node): null
+ {
+ if ($node instanceof Stmt\Namespace_) {
+ $this->namespace = $node->name?->toString() ?? '';
+ } elseif ($node instanceof Stmt\Use_ && $node->type === Stmt\Use_::TYPE_NORMAL) {
+ foreach ($node->uses as $use) {
+ $alias = $use->alias?->toString() ?? $use->name->getLast();
+ $this->useMap[strtolower($alias)] = $use->name->toString();
+ }
+ } elseif ($node instanceof Stmt\GroupUse) {
+ foreach ($node->uses as $use) {
+ if ($use->type !== Stmt\Use_::TYPE_NORMAL && $node->type !== Stmt\Use_::TYPE_NORMAL) {
+ continue;
+ }
+ $alias = $use->alias?->toString() ?? $use->name->getLast();
+ $this->useMap[strtolower($alias)] = $node->prefix->toString() . '\\' . $use->name->toString();
+ }
+ }
+
+ return null;
+ }
+ };
+ $traverser->addVisitor($useCollector);
+
+ $classCollector = new class extends NodeVisitorAbstract {
+ /** @var list<Stmt\ClassLike> */
+ public array $classLikes = [];
+
+ public function enterNode(Node $node): null
+ {
+ if ($node instanceof Stmt\ClassLike && $node->name !== null) {
+ $this->classLikes[] = $node;
+ }
+
+ return null;
+ }
+ };
+ $traverser->addVisitor($classCollector);
+
+ $traverser->traverse($stmts);
+
+ foreach ($classCollector->classLikes as $node) {
+ $this->collectClass($node, $path, $useCollector->namespace, $useCollector->useMap);
+ }
+ }
+
+ /** @param array<string, string> $useMap */
+ private function collectClass(Stmt\ClassLike $node, string $file, string $namespace, array $useMap): void
+ {
+ $fqcn = $node->namespacedName?->toString() ?? $node->name->toString();
+
+ $kind = match (true) {
+ $node instanceof Stmt\Interface_ => 'interface',
+ $node instanceof Stmt\Trait_ => 'trait',
+ $node instanceof Stmt\Enum_ => 'enum',
+ default => 'class',
+ };
+
+ $parent = null;
+ $interfaces = [];
+ if ($node instanceof Stmt\Class_) {
+ $parent = $node->extends?->toString();
+ foreach ($node->implements as $iface) {
+ $interfaces[] = $iface->toString();
+ }
+ } elseif ($node instanceof Stmt\Interface_) {
+ foreach ($node->extends as $iface) {
+ $interfaces[] = $iface->toString();
+ }
+ } elseif ($node instanceof Stmt\Enum_) {
+ foreach ($node->implements as $iface) {
+ $interfaces[] = $iface->toString();
+ }
+ }
+
+ $info = new ClassInfo(
+ $fqcn,
+ $kind,
+ $node instanceof Stmt\Class_ && $node->isAbstract(),
+ $node instanceof Stmt\Class_ && $node->isFinal(),
+ $parent,
+ $interfaces,
+ $file,
+ );
+
+ foreach ($node->getTraitUses() as $traitUse) {
+ foreach ($traitUse->traits as $trait) {
+ $info->traitUses[] = $trait->toString();
+ }
+ }
+
+ foreach ($node->getProperties() as $propNode) {
+ $doc = $this->docblocks->extract($propNode->getDocComment()?->getText(), $namespace, $useMap);
+ [$classTypes, $expandable] = $this->typeClassNames($propNode->type, $fqcn, $parent);
+ foreach ($propNode->props as $prop) {
+ $info->properties[] = new PropertyInfo(
+ $prop->name->toString(),
+ $propNode->isPrivate() ? 'private' : ($propNode->isProtected() ? 'protected' : 'public'),
+ $propNode->isStatic(),
+ $classTypes,
+ $expandable,
+ $doc['var'],
+ );
+ }
+ }
+
+ foreach ($node->getMethods() as $methodNode) {
+ $this->collectMethod($info, $methodNode, $namespace, $useMap);
+ }
+
+ $this->classes[$fqcn] = $info;
+ $this->lowercaseIndex[strtolower($fqcn)] = $fqcn;
+ }
+
+ /** @param array<string, string> $useMap */
+ private function collectMethod(ClassInfo $info, Stmt\ClassMethod $node, string $namespace, array $useMap): void
+ {
+ $doc = $this->docblocks->extract($node->getDocComment()?->getText(), $namespace, $useMap);
+
+ $params = [];
+ foreach ($node->params as $paramNode) {
+ $name = $paramNode->var instanceof Node\Expr\Variable && is_string($paramNode->var->name)
+ ? $paramNode->var->name
+ : '';
+ [$classTypes, $expandable] = $this->typeClassNames($paramNode->type, $info->fqcn, $info->parent);
+ $params[] = new ParamInfo(
+ $name,
+ $paramNode->byRef,
+ $paramNode->variadic,
+ $classTypes,
+ $expandable,
+ $doc['params'][$name] ?? [],
+ $this->typeIsCallable($paramNode->type),
+ );
+
+ // Constructor property promotion.
+ if ($paramNode->flags !== 0) {
+ $info->properties[] = new PropertyInfo(
+ $name,
+ ($paramNode->flags & \PhpParser\Modifiers::PRIVATE) !== 0 ? 'private'
+ : (($paramNode->flags & \PhpParser\Modifiers::PROTECTED) !== 0 ? 'protected' : 'public'),
+ false,
+ $classTypes,
+ $expandable,
+ $doc['params'][$name] ?? [],
+ );
+ }
+ }
+
+ [$returnClassTypes, $returnExpandable] = $this->typeClassNames($node->returnType, $info->fqcn, $info->parent);
+
+ $method = new MethodInfo(
+ $node->name->toString(),
+ $node->isPrivate() ? 'private' : ($node->isProtected() ? 'protected' : 'public'),
+ $node->isStatic(),
+ $node->isAbstract() || $node->stmts === null,
+ $params,
+ $returnClassTypes,
+ $returnExpandable,
+ $doc['return'],
+ $doc['throws'],
+ );
+
+ $propertyTypes = [];
+ foreach ($info->properties as $prop) {
+ $single = $this->singleClassType($prop->classTypes, $prop->docblockTypes);
+ if ($single !== null) {
+ $propertyTypes[$prop->name] = $single;
+ }
+ }
+ $paramTypes = [];
+ foreach ($params as $param) {
+ $single = $this->singleClassType($param->classTypes, $param->docblockTypes);
+ if ($single !== null) {
+ $paramTypes[$param->name] = $single;
+ }
+ }
+
+ $analyzer = BodyAnalyzer::analyze($node, $method, $info->fqcn, $propertyTypes, $paramTypes, $this->builtins);
+ foreach ($analyzer->newRefs as $ref) {
+ $info->newRefs[] = ltrim($ref, '\\');
+ }
+ foreach ($analyzer->staticRefs as $ref) {
+ $info->staticRefs[] = ltrim($ref, '\\');
+ }
+ foreach ($analyzer->benignRefs as $ref) {
+ $info->benignBodyRefs[] = ltrim($ref, '\\');
+ }
+ if ($analyzer->writesOwnStaticProps) {
+ $info->writesOwnStaticProps = true;
+ }
+
+ $info->methods[strtolower($method->name)] = $method;
+ }
+
+ /**
+ * @param list<string> $native
+ * @param list<string> $docblock
+ */
+ private function singleClassType(array $native, array $docblock): ?string
+ {
+ if (count($native) === 1) {
+ return $native[0];
+ }
+ if ($native === [] && count($docblock) === 1) {
+ return $docblock[0];
+ }
+
+ return null;
+ }
+
+ /**
+ * Extracts class-like FQCNs from a native type node and reports whether
+ * the type invites docblock refinement (array/iterable/mixed/object or
+ * no type at all).
+ *
+ * @return array{0: list<string>, 1: bool}
+ */
+ private function typeClassNames(?Node $type, string $currentClass, ?string $parentClass): array
+ {
+ if ($type === null) {
+ return [[], true];
+ }
+
+ $classes = [];
+ $expandable = false;
+
+ $walk = function (Node $t) use (&$walk, &$classes, &$expandable, $currentClass, $parentClass): void {
+ if ($t instanceof Node\NullableType) {
+ $walk($t->type);
+ } elseif ($t instanceof Node\UnionType || $t instanceof Node\IntersectionType) {
+ foreach ($t->types as $sub) {
+ $walk($sub);
+ }
+ } elseif ($t instanceof Node\Identifier) {
+ if (in_array($t->toLowerString(), ['array', 'iterable', 'mixed', 'object'], true)) {
+ $expandable = true;
+ }
+ } elseif ($t instanceof Name) {
+ $name = $t->toString();
+ $lower = strtolower($name);
+ if ($lower === 'self' || $lower === 'static') {
+ $classes[] = $currentClass;
+ } elseif ($lower === 'parent') {
+ if ($parentClass !== null) {
+ $classes[] = $parentClass;
+ }
+ } else {
+ $classes[] = ltrim($name, '\\');
+ }
+ }
+ };
+ $walk($type);
+
+ return [array_values(array_unique($classes)), $expandable];
+ }
+
+ private function typeIsCallable(?Node $type): bool
+ {
+ if ($type === null) {
+ return false;
+ }
+ if ($type instanceof Node\NullableType) {
+ return $this->typeIsCallable($type->type);
+ }
+ if ($type instanceof Node\UnionType || $type instanceof Node\IntersectionType) {
+ foreach ($type->types as $sub) {
+ if ($this->typeIsCallable($sub)) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+ if ($type instanceof Node\Identifier) {
+ return $type->toLowerString() === 'callable';
+ }
+ if ($type instanceof Name) {
+ return strtolower($type->toString()) === 'closure';
+ }
+
+ return false;
+ }
+
+ private function mergeTraits(): void
+ {
+ foreach ($this->classes as $info) {
+ foreach ($info->traitUses as $traitName) {
+ $trait = $this->classes[$traitName] ?? null;
+ if ($trait === null) {
+ continue;
+ }
+ foreach ($trait->methods as $lname => $method) {
+ if (!isset($info->methods[$lname])) {
+ $info->methods[$lname] = $method;
+ }
+ }
+ foreach ($trait->properties as $prop) {
+ $info->properties[] = $prop;
+ }
+ foreach ($trait->newRefs as $ref) {
+ $info->newRefs[] = $ref;
+ }
+ foreach ($trait->staticRefs as $ref) {
+ $info->staticRefs[] = $ref;
+ }
+ foreach ($trait->benignBodyRefs as $ref) {
+ $info->benignBodyRefs[] = $ref;
+ }
+ if ($trait->writesOwnStaticProps) {
+ $info->writesOwnStaticProps = true;
+ }
+ }
+ }
+ }
+}