aboutsummaryrefslogtreecommitdiffhomepage
path: root/scripts/plugin-stub-generator/src
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2026-08-04 03:36:10 +0900
committernsfisis <nsfisis@gmail.com>2026-08-04 05:43:22 +0900
commit261516d5ce6f8b0d69cf9e3da7dd2f0ef1cdc36a (patch)
treef635a416745065b52ed0bb0ebb18154b9ef89f3b /scripts/plugin-stub-generator/src
parent6cb1849473792bd73dbfb6265d363f149f687572 (diff)
downloadphp-shirabe-261516d5ce6f8b0d69cf9e3da7dd2f0ef1cdc36a.tar.gz
php-shirabe-261516d5ce6f8b0d69cf9e3da7dd2f0ef1cdc36a.tar.zst
php-shirabe-261516d5ce6f8b0d69cf9e3da7dd2f0ef1cdc36a.zip
feat(plugin): generate the worker proxy stubs from the Composer sources
Replace the hand-written proxy stubs under crates/shirabe-php-rpc/php/stubs with output of scripts/plugin-stub-generator, a deterministic emitter that derives every stub from the Composer checkout and the classifier report. Anything it cannot faithfully proxy (by-ref/variadic parameters, magic methods, public properties, diverging omitted overrides, stale stub files, a STUB_FILES entry missing in lib.rs) fails generation instead of degrading silently, so future Composer releases surface new members as explicit errors rather than silent gaps. Regenerating the stubs also normalizes the hand-written inconsistencies (uniform guarded constructors, import-based type spellings) and fixes real gaps the review of the generated diff uncovered: the BaseIO authentication methods now carry the real class's untyped signatures, and the previously missing ConsoleIO::sanitize is materialized together with its private static helper. A cargo test runs generate-stubs --check to keep the committed stubs, the generator and the embedded list from drifting apart. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Diffstat (limited to 'scripts/plugin-stub-generator/src')
-rw-r--r--scripts/plugin-stub-generator/src/GenerationError.php14
-rw-r--r--scripts/plugin-stub-generator/src/Generator.php383
-rw-r--r--scripts/plugin-stub-generator/src/NamePrinter.php92
-rw-r--r--scripts/plugin-stub-generator/src/Project.php59
-rw-r--r--scripts/plugin-stub-generator/src/Report.php37
-rw-r--r--scripts/plugin-stub-generator/src/SourceFile.php86
6 files changed, 671 insertions, 0 deletions
diff --git a/scripts/plugin-stub-generator/src/GenerationError.php b/scripts/plugin-stub-generator/src/GenerationError.php
new file mode 100644
index 00000000..c85bede4
--- /dev/null
+++ b/scripts/plugin-stub-generator/src/GenerationError.php
@@ -0,0 +1,14 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shirabe\PluginStubGenerator;
+
+final class GenerationError extends \RuntimeException
+{
+ /** @param list<string> $errors */
+ public function __construct(public readonly array $errors)
+ {
+ parent::__construct(implode("\n", $errors));
+ }
+}
diff --git a/scripts/plugin-stub-generator/src/Generator.php b/scripts/plugin-stub-generator/src/Generator.php
new file mode 100644
index 00000000..9ab16ed8
--- /dev/null
+++ b/scripts/plugin-stub-generator/src/Generator.php
@@ -0,0 +1,383 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shirabe\PluginStubGenerator;
+
+use PhpParser\Node\Name;
+use PhpParser\Node\Stmt\Class_;
+use PhpParser\Node\Stmt\ClassMethod;
+use PhpParser\Node\Stmt\Interface_;
+
+/**
+ * Emits the proxy stub files deterministically from the Composer sources and the classifier
+ * report. Anything the emitter cannot faithfully proxy (by-ref or variadic parameters,
+ * magic methods, public properties, non-public constants) fails generation instead of
+ * degrading silently.
+ */
+final class Generator
+{
+ private const BOILERPLATE = <<<'PHP'
+ /** @var int */
+ protected $__rhandle;
+ /** @var int */
+ protected $__epoch;
+
+ public function __construct(int $rhandle = 0, int $epoch = 0)
+ {
+ if (func_num_args() < 2) {
+ // Constructing the class from plugin code (a common idiom for e.g. `new BufferIO()`)
+ // is an open question of the plugin design; only proxy instantiation passes a
+ // Rust handle. Fail with a diagnosable message instead of an ArgumentCountError.
+ throw new \RuntimeException(
+ 'Shirabe does not support constructing ' . static::class . ' inside the plugin process yet'
+ );
+ }
+ $this->__rhandle = $rhandle;
+ $this->__epoch = $epoch;
+ }
+
+ public function __destruct()
+ {
+ \ShirabeRustObjectRegistry::release($this->__rhandle);
+ }
+
+ public function __shirabeRustHandleDescriptor(): array
+ {
+ return [
+ '__rhandle' => $this->__rhandle,
+ '__class' => static::class,
+ '__epoch' => $this->__epoch,
+ ];
+ }
+ PHP;
+
+ private Project $project;
+
+ private NamePrinter $printer;
+
+ /** @var list<string> */
+ private array $errors = [];
+
+ /** @var array<string, true> */
+ private array $targetSet = [];
+
+ /**
+ * Emitted method records per stub class: fqcn => name => fingerprint. A subclass stub
+ * omits overrides whose parameter list matches the record; the record therefore doubles
+ * as the divergence detector for omitted overrides.
+ *
+ * @var array<string, array<string, list<array{string, bool, string, bool, bool}>>>
+ */
+ private array $surfaces = [];
+
+ /** @param list<string> $targets */
+ public function __construct(
+ string $composerRoot,
+ private readonly Report $report,
+ private readonly array $targets,
+ ) {
+ $this->project = new Project($composerRoot);
+ $this->printer = new NamePrinter();
+ foreach ($targets as $fqcn) {
+ $this->targetSet[$fqcn] = true;
+ }
+ }
+
+ /** @return array<string, string> relative stub path => file content */
+ public function generate(): array
+ {
+ $files = [];
+ foreach ($this->targets as $fqcn) {
+ $files[str_replace('\\', '/', $fqcn) . '.php'] = $this->emitClass($fqcn);
+ }
+ if ($this->errors !== []) {
+ throw new GenerationError($this->errors);
+ }
+ return $files;
+ }
+
+ private function emitClass(string $fqcn): string
+ {
+ $file = $this->project->sourceFor($fqcn);
+ $class = $file->classLike;
+ if (!$class instanceof Class_) {
+ $this->errors[] = "$fqcn is not a class";
+ return '';
+ }
+ $category = $this->report->category($fqcn);
+ if (!in_array($category, ['rust-proxy', 'contract'], true)) {
+ $this->errors[] = "$fqcn is classified as " . ($category ?? 'nothing')
+ . '; only rust-proxy and contract classes can become proxy stubs';
+ }
+
+ $parentFqcn = $class->extends === null ? null : $this->resolvedName($class->extends);
+ $isRoot = $parentFqcn === null;
+ if ($parentFqcn !== null && !isset($this->targetSet[$parentFqcn])) {
+ $this->errors[] = "$fqcn extends $parentFqcn, which is not a stub target";
+ $isRoot = true;
+ }
+ if (!$isRoot && !isset($this->surfaces[$parentFqcn])) {
+ $this->errors[] = "$fqcn must come after its base class $parentFqcn in targets.list";
+ $isRoot = true;
+ }
+
+ foreach ($class->getProperties() as $property) {
+ if ($property->isPublic()) {
+ $this->errors[] = "$fqcn declares a public property; a proxy stub cannot forward property access";
+ }
+ }
+
+ $constants = [];
+ foreach ($class->getConstants() as $constant) {
+ if (!$constant->isPublic()) {
+ $this->errors[] = "$fqcn declares a non-public constant; materializing it is not supported";
+ continue;
+ }
+ $constants[] = $file->verbatim($constant->getStartLine(), $constant->getEndLine());
+ }
+
+ $publicStatics = [];
+ $nonPublicStatics = [];
+ $ownInstanceMethods = [];
+ foreach ($class->getMethods() as $method) {
+ $name = $method->name->toString();
+ if ($name === '__construct') {
+ continue;
+ }
+ if (str_starts_with($name, '__')) {
+ if ($method->isPublic()) {
+ $this->errors[] = "$fqcn::$name: magic methods cannot be proxied";
+ }
+ continue;
+ }
+ if ($method->isStatic()) {
+ if ($method->isPublic()) {
+ $publicStatics[$name] = $method;
+ } else {
+ $nonPublicStatics[$name] = $method;
+ }
+ } elseif ($method->isPublic()) {
+ $ownInstanceMethods[$name] = $method;
+ }
+ }
+ $staticMethods = $this->materializeStatics($file, $publicStatics, $nonPublicStatics);
+
+ $surface = [];
+ $emitted = [];
+ if ($isRoot) {
+ foreach ($this->interfaceClosure($class) as $interface) {
+ foreach ($interface->classLike->getMethods() as $method) {
+ $name = $method->name->toString();
+ if ($method->isStatic()) {
+ $this->errors[] = "$fqcn: static interface method $name is not supported";
+ continue;
+ }
+ $emitted[$name] ??= $method;
+ }
+ }
+ // A concrete redeclaration wins over the interface signature (it may widen
+ // defaults); it keeps the interface's position in the emission order.
+ foreach ($ownInstanceMethods as $name => $method) {
+ $emitted[$name] = $method;
+ }
+ } else {
+ $surface = $this->surfaces[$parentFqcn];
+ if ($class->implements !== []) {
+ $this->errors[] = "$fqcn adds interfaces to a stub base class; this is not supported yet";
+ }
+ foreach ($ownInstanceMethods as $name => $method) {
+ if (isset($surface[$name])) {
+ $this->checkOmittedOverride($fqcn, $name, $method, $file, $surface[$name]);
+ } else {
+ $emitted[$name] = $method;
+ }
+ }
+ }
+
+ $methodTexts = [];
+ foreach ($emitted as $name => $method) {
+ $methodTexts[] = $this->renderProxyMethod($fqcn, $name, $method, $file);
+ $surface[$name] = $this->fingerprint($method, $file);
+ }
+ $this->surfaces[$fqcn] = $surface;
+
+ $decl = ($class->isAbstract() ? 'abstract ' : '') . 'class ' . $class->name?->toString();
+ if ($class->extends !== null) {
+ $decl .= ' extends ' . $this->printer->renderName($class->extends, $file);
+ }
+ if ($isRoot) {
+ $interfaces = array_map(fn (Name $n): string => $this->printer->renderName($n, $file), $class->implements);
+ $interfaces[] = '\ShirabeRustStub';
+ $decl .= ' implements ' . implode(', ', $interfaces);
+ }
+
+ $members = [];
+ if ($isRoot) {
+ $members[] = self::BOILERPLATE;
+ }
+ if ($constants !== []) {
+ $members[] = implode("\n", $constants);
+ }
+ $members = array_merge($members, $staticMethods, $methodTexts);
+ $body = implode("\n\n", $members);
+
+ $header = "// Generated by scripts/plugin-stub-generator; do not edit by hand.\n"
+ . "// Proxy stub for $fqcn: the public surface forwards to the Rust-side entity over RPC.";
+ $text = "<?php\n\n$header\n\nnamespace {$file->namespace};\n\n";
+ $uses = $this->usedImports($file, $decl . "\n" . $body);
+ if ($uses !== '') {
+ $text .= $uses . "\n\n";
+ }
+ return $text . $decl . "\n{\n" . ($body === '' ? '' : $body . "\n") . "}\n";
+ }
+
+ /**
+ * Static methods read no instance state; their real implementation is materialized
+ * verbatim so they run locally in the worker. Non-public static helpers they call
+ * (through `self::`, `static::` or the class name) are materialized along with them.
+ *
+ * @param array<string, ClassMethod> $publicStatics
+ * @param array<string, ClassMethod> $nonPublicStatics
+ * @return list<string>
+ */
+ private function materializeStatics(SourceFile $file, array $publicStatics, array $nonPublicStatics): array
+ {
+ $texts = [];
+ foreach ($publicStatics as $name => $method) {
+ $texts[$name] = $file->verbatim($method->getStartLine(), $method->getEndLine());
+ }
+ $scan = array_values($texts);
+ while ($scan !== []) {
+ $text = array_shift($scan);
+ foreach ($nonPublicStatics as $name => $method) {
+ if (isset($texts[$name])) {
+ continue;
+ }
+ $receiver = '(?:self|static|' . preg_quote($file->classLike->name?->toString() ?? '', '/') . ')';
+ if (preg_match('/(?<![\w$])' . $receiver . '::' . preg_quote($name, '/') . '\s*\(/', $text) === 1) {
+ $scan[] = $texts[$name] = $file->verbatim($method->getStartLine(), $method->getEndLine());
+ }
+ }
+ }
+ return array_values($texts);
+ }
+
+ /** Interfaces implemented by the class, each interface preceding the ones it extends. */
+ private function interfaceClosure(Class_ $class): array
+ {
+ $out = [];
+ $seen = [];
+ $visit = function (string $fqcn) use (&$visit, &$out, &$seen): void {
+ if (isset($seen[$fqcn])) {
+ return;
+ }
+ $seen[$fqcn] = true;
+ $file = $this->project->sourceFor($fqcn);
+ if (!$file->classLike instanceof Interface_) {
+ $this->errors[] = "$fqcn is implemented as an interface but is not one";
+ return;
+ }
+ $out[] = $file;
+ foreach ($file->classLike->extends as $parent) {
+ $visit($this->resolvedName($parent));
+ }
+ };
+ foreach ($class->implements as $interface) {
+ $visit($this->resolvedName($interface));
+ }
+ return $out;
+ }
+
+ private function renderProxyMethod(string $fqcn, string $name, ClassMethod $method, SourceFile $target): string
+ {
+ $params = [];
+ $args = [];
+ foreach ($method->params as $param) {
+ $paramName = $param->var->name;
+ if ($param->byRef) {
+ $this->errors[] = "$fqcn::$name: by-ref parameter \$$paramName cannot be proxied yet";
+ }
+ if ($param->variadic) {
+ $this->errors[] = "$fqcn::$name: variadic parameter \$$paramName cannot be proxied yet";
+ }
+ $rendered = '';
+ if ($param->type !== null) {
+ $rendered = $this->printer->renderType($param->type, $target) . ' ';
+ }
+ $rendered .= '$' . $paramName;
+ if ($param->default !== null) {
+ $rendered .= ' = ' . $this->printer->renderExpr($param->default, $target);
+ }
+ $params[] = $rendered;
+ $args[] = '$' . $paramName;
+ }
+
+ $returnType = $this->printer->renderType($method->returnType, $target);
+ $signature = "public function $name(" . implode(', ', $params) . ')'
+ . ($returnType === '' ? '' : ": $returnType");
+ $call = "\\ShirabeRpcRuntime::callRust(\$this->__rhandle, '$name', [" . implode(', ', $args) . '])';
+ // `self`/`static` returns are fluent interfaces; the local stub itself is returned to
+ // preserve identity instead of round-tripping the handle.
+ $body = match ($returnType) {
+ 'void' => " $call;",
+ 'self', 'static' => " $call;\n return \$this;",
+ default => " return $call;",
+ };
+ return " $signature\n {\n$body\n }";
+ }
+
+ /**
+ * An override that is omitted from a subclass stub must take exactly the parameters the
+ * inherited stub method declares: same names, arity, defaults and passing modes. Type
+ * declarations are allowed to differ (overrides may widen them; the forwarding body is
+ * type-agnostic either way).
+ */
+ private function checkOmittedOverride(
+ string $fqcn,
+ string $name,
+ ClassMethod $method,
+ SourceFile $file,
+ array $inherited,
+ ): void {
+ if ($this->fingerprint($method, $file) !== $inherited) {
+ $this->errors[] = "$fqcn::$name: override diverges from the inherited stub signature"
+ . ' (names, arity, defaults or passing modes); it can no longer be omitted';
+ }
+ }
+
+ /** @return list<array{string, bool, string, bool, bool}> */
+ private function fingerprint(ClassMethod $method, SourceFile $file): array
+ {
+ $fingerprint = [];
+ foreach ($method->params as $param) {
+ $fingerprint[] = [
+ $param->var->name,
+ $param->default !== null,
+ $param->default === null ? '' : $this->printer->renderExpr($param->default, $file),
+ $param->byRef,
+ $param->variadic,
+ ];
+ }
+ return $fingerprint;
+ }
+
+ /** The original file's imports, restricted to names the emitted stub actually uses. */
+ private function usedImports(SourceFile $file, string $emittedText): string
+ {
+ $kept = [];
+ foreach ($file->aliases as $alias => $fqcn) {
+ if (preg_match('/(?<![\\\\$\w])' . preg_quote($alias, '/') . '\b/', $emittedText) === 1) {
+ $kept[] = 'use ' . $fqcn
+ . (str_ends_with($fqcn, '\\' . $alias) || $fqcn === $alias ? '' : " as $alias") . ';';
+ }
+ }
+ return implode("\n", $kept);
+ }
+
+ private function resolvedName(Name $name): string
+ {
+ $resolved = $name->getAttribute('resolvedName');
+ return $resolved instanceof Name ? $resolved->toString() : $name->toString();
+ }
+}
diff --git a/scripts/plugin-stub-generator/src/NamePrinter.php b/scripts/plugin-stub-generator/src/NamePrinter.php
new file mode 100644
index 00000000..1675fee1
--- /dev/null
+++ b/scripts/plugin-stub-generator/src/NamePrinter.php
@@ -0,0 +1,92 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shirabe\PluginStubGenerator;
+
+use PhpParser\Node;
+use PhpParser\Node\Identifier;
+use PhpParser\Node\IntersectionType;
+use PhpParser\Node\Name;
+use PhpParser\Node\NullableType;
+use PhpParser\Node\UnionType;
+use PhpParser\PrettyPrinter\Standard;
+
+/**
+ * Renders type and default-value nodes into a target file context: class names resolve to
+ * their FQCN and are re-spelled through the target's import table (alias if imported, bare
+ * name if in the stub's namespace, `\FQCN` otherwise). This lets signatures declared in one
+ * file (an interface) be emitted into a stub that carries another file's `use` block.
+ */
+final class NamePrinter extends Standard
+{
+ private SourceFile $context;
+
+ public function __construct()
+ {
+ parent::__construct(['shortArraySyntax' => true]);
+ }
+
+ public function renderType(?Node $type, SourceFile $context): string
+ {
+ if ($type === null) {
+ return '';
+ }
+ if ($type instanceof Identifier) {
+ return $type->toString();
+ }
+ if ($type instanceof NullableType) {
+ return '?' . $this->renderType($type->type, $context);
+ }
+ if ($type instanceof UnionType) {
+ return implode('|', array_map(fn (Node $t): string => $this->renderType($t, $context), $type->types));
+ }
+ if ($type instanceof IntersectionType) {
+ return implode('&', array_map(fn (Node $t): string => $this->renderType($t, $context), $type->types));
+ }
+ if ($type instanceof Name) {
+ return $this->renderName($type, $context);
+ }
+ throw new GenerationError(['unsupported type node ' . get_class($type)]);
+ }
+
+ public function renderName(Name $name, SourceFile $context): string
+ {
+ if ($name->isSpecialClassName()) {
+ return $name->toString();
+ }
+ $resolved = $name->getAttribute('resolvedName');
+ $fqcn = $resolved instanceof Name ? $resolved->toString() : $name->toString();
+ if ($name->isUnqualified() && !str_contains($fqcn, '\\')) {
+ // Names that resolved into the global namespace only occur in constant space
+ // (true, false, null, ...); they keep their source spelling.
+ return $name->toString();
+ }
+ return $this->renderFqcn($fqcn, $context);
+ }
+
+ public function renderFqcn(string $fqcn, SourceFile $context): string
+ {
+ foreach ($context->aliases as $alias => $target) {
+ if ($target === $fqcn) {
+ return $alias;
+ }
+ }
+ $prefix = $context->namespace === null ? '' : $context->namespace . '\\';
+ if ($prefix !== '' && str_starts_with($fqcn, $prefix) && !str_contains(substr($fqcn, strlen($prefix)), '\\')) {
+ return substr($fqcn, strlen($prefix));
+ }
+ return '\\' . $fqcn;
+ }
+
+ public function renderExpr(Node\Expr $expr, SourceFile $context): string
+ {
+ $this->context = $context;
+ return $this->prettyPrintExpr($expr);
+ }
+
+ protected function pName(Name $node): string
+ {
+ return $this->renderName($node, $this->context);
+ }
+}
diff --git a/scripts/plugin-stub-generator/src/Project.php b/scripts/plugin-stub-generator/src/Project.php
new file mode 100644
index 00000000..13f1067d
--- /dev/null
+++ b/scripts/plugin-stub-generator/src/Project.php
@@ -0,0 +1,59 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shirabe\PluginStubGenerator;
+
+use PhpParser\Parser;
+use PhpParser\ParserFactory;
+
+/**
+ * Locates and parses classes of the Composer checkout (composer/src and composer/vendor),
+ * using the checkout's own PSR-4 autoload map.
+ */
+final class Project
+{
+ private Parser $parser;
+
+ /** @var array<string, list<string>> namespace prefix => base dirs, longest prefix first */
+ private array $psr4;
+
+ /** @var array<string, SourceFile> */
+ private array $cache = [];
+
+ public function __construct(string $composerRoot)
+ {
+ $this->parser = (new ParserFactory())->createForNewestSupportedVersion();
+
+ $mapFile = $composerRoot . '/vendor/composer/autoload_psr4.php';
+ if (!is_file($mapFile)) {
+ throw new GenerationError(["missing $mapFile (run composer install in the checkout)"]);
+ }
+ $map = require $mapFile;
+ $map['Composer\\'] ??= [$composerRoot . '/src/Composer'];
+ uksort($map, static fn (string $a, string $b): int => strlen($b) <=> strlen($a));
+ $this->psr4 = $map;
+ }
+
+ public function sourceFor(string $fqcn): SourceFile
+ {
+ return $this->cache[$fqcn] ??= new SourceFile($this->fileFor($fqcn), $fqcn, $this->parser);
+ }
+
+ private function fileFor(string $fqcn): string
+ {
+ foreach ($this->psr4 as $prefix => $dirs) {
+ if (!str_starts_with($fqcn, $prefix)) {
+ continue;
+ }
+ $relative = str_replace('\\', '/', substr($fqcn, strlen($prefix))) . '.php';
+ foreach ($dirs as $dir) {
+ $path = $dir . '/' . $relative;
+ if (is_file($path)) {
+ return $path;
+ }
+ }
+ }
+ throw new GenerationError(["cannot locate a source file for $fqcn"]);
+ }
+}
diff --git a/scripts/plugin-stub-generator/src/Report.php b/scripts/plugin-stub-generator/src/Report.php
new file mode 100644
index 00000000..92e0565e
--- /dev/null
+++ b/scripts/plugin-stub-generator/src/Report.php
@@ -0,0 +1,37 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shirabe\PluginStubGenerator;
+
+/** The classifier report (scripts/plugin-class-classifier/report.json). */
+final class Report
+{
+ /** @param array<string, string> $categories fqcn => category */
+ private function __construct(private readonly array $categories)
+ {
+ }
+
+ public static function load(string $path): self
+ {
+ if (!is_file($path)) {
+ throw new GenerationError([
+ "missing classifier report $path (run scripts/plugin-class-classifier/classify first)",
+ ]);
+ }
+ $data = json_decode((string) file_get_contents($path), true, 512, JSON_THROW_ON_ERROR);
+ if (($data['violations'] ?? null) !== []) {
+ throw new GenerationError(["$path records classification violations; fix the classifier lists first"]);
+ }
+ $categories = [];
+ foreach ($data['classes'] as $class) {
+ $categories[$class['fqcn']] = $class['category'];
+ }
+ return new self($categories);
+ }
+
+ public function category(string $fqcn): ?string
+ {
+ return $this->categories[$fqcn] ?? null;
+ }
+}
diff --git a/scripts/plugin-stub-generator/src/SourceFile.php b/scripts/plugin-stub-generator/src/SourceFile.php
new file mode 100644
index 00000000..771e83a8
--- /dev/null
+++ b/scripts/plugin-stub-generator/src/SourceFile.php
@@ -0,0 +1,86 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shirabe\PluginStubGenerator;
+
+use PhpParser\Node\Stmt\ClassLike;
+use PhpParser\Node\Stmt\GroupUse;
+use PhpParser\Node\Stmt\Namespace_;
+use PhpParser\Node\Stmt\Use_;
+use PhpParser\NodeTraverser;
+use PhpParser\NodeVisitor\NameResolver;
+use PhpParser\Parser;
+
+/**
+ * A parsed source file: the class-like it declares, its namespace and its import table.
+ * All Name nodes in the AST carry a `resolvedName` attribute (NameResolver with
+ * replaceNodes disabled), so signatures can be re-rendered under another file's context.
+ */
+final class SourceFile
+{
+ public ?string $namespace = null;
+
+ /** @var array<string, string> alias as written => FQCN, in declaration order */
+ public array $aliases = [];
+
+ public ClassLike $classLike;
+
+ /** @var list<string> */
+ public array $lines;
+
+ public function __construct(
+ public readonly string $path,
+ public readonly string $expectedFqcn,
+ Parser $parser,
+ ) {
+ $code = file_get_contents($path);
+ if ($code === false) {
+ throw new GenerationError(["cannot read $path"]);
+ }
+ $this->lines = explode("\n", $code);
+
+ $ast = $parser->parse($code);
+ if ($ast === null) {
+ throw new GenerationError(["cannot parse $path"]);
+ }
+ $traverser = new NodeTraverser();
+ $traverser->addVisitor(new NameResolver(null, ['replaceNodes' => false]));
+ $ast = $traverser->traverse($ast);
+
+ $classLike = null;
+ foreach ($ast as $stmt) {
+ if (!$stmt instanceof Namespace_) {
+ continue;
+ }
+ $this->namespace = $stmt->name?->toString();
+ foreach ($stmt->stmts as $inner) {
+ if ($inner instanceof Use_) {
+ if ($inner->type !== Use_::TYPE_NORMAL) {
+ throw new GenerationError(["$path: function/const use statements are not supported"]);
+ }
+ foreach ($inner->uses as $use) {
+ $this->aliases[$use->getAlias()->toString()] = $use->name->toString();
+ }
+ } elseif ($inner instanceof GroupUse) {
+ throw new GenerationError(["$path: group use statements are not supported"]);
+ } elseif ($inner instanceof ClassLike) {
+ $fqcn = ($this->namespace === null ? '' : $this->namespace . '\\') . $inner->name?->toString();
+ if ($fqcn === $expectedFqcn) {
+ $classLike = $inner;
+ }
+ }
+ }
+ }
+ if ($classLike === null) {
+ throw new GenerationError(["$path does not declare $expectedFqcn"]);
+ }
+ $this->classLike = $classLike;
+ }
+
+ /** The declaration's verbatim source text, without any leading doc comment. */
+ public function verbatim(int $startLine, int $endLine): string
+ {
+ return implode("\n", array_slice($this->lines, $startLine - 1, $endLine - $startLine + 1));
+ }
+}