From 261516d5ce6f8b0d69cf9e3da7dd2f0ef1cdc36a Mon Sep 17 00:00:00 2001 From: nsfisis Date: Tue, 4 Aug 2026 03:36:10 +0900 Subject: 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 --- scripts/plugin-stub-generator/.gitignore | 1 + scripts/plugin-stub-generator/composer.json | 14 + scripts/plugin-stub-generator/composer.lock | 78 +++++ scripts/plugin-stub-generator/generate-stubs | 97 ++++++ .../plugin-stub-generator/src/GenerationError.php | 14 + scripts/plugin-stub-generator/src/Generator.php | 383 +++++++++++++++++++++ scripts/plugin-stub-generator/src/NamePrinter.php | 92 +++++ scripts/plugin-stub-generator/src/Project.php | 59 ++++ scripts/plugin-stub-generator/src/Report.php | 37 ++ scripts/plugin-stub-generator/src/SourceFile.php | 86 +++++ scripts/plugin-stub-generator/targets.list | 12 + 11 files changed, 873 insertions(+) create mode 100644 scripts/plugin-stub-generator/.gitignore create mode 100644 scripts/plugin-stub-generator/composer.json create mode 100644 scripts/plugin-stub-generator/composer.lock create mode 100755 scripts/plugin-stub-generator/generate-stubs create mode 100644 scripts/plugin-stub-generator/src/GenerationError.php create mode 100644 scripts/plugin-stub-generator/src/Generator.php create mode 100644 scripts/plugin-stub-generator/src/NamePrinter.php create mode 100644 scripts/plugin-stub-generator/src/Project.php create mode 100644 scripts/plugin-stub-generator/src/Report.php create mode 100644 scripts/plugin-stub-generator/src/SourceFile.php create mode 100644 scripts/plugin-stub-generator/targets.list (limited to 'scripts/plugin-stub-generator') diff --git a/scripts/plugin-stub-generator/.gitignore b/scripts/plugin-stub-generator/.gitignore new file mode 100644 index 00000000..48b8bf90 --- /dev/null +++ b/scripts/plugin-stub-generator/.gitignore @@ -0,0 +1 @@ +vendor/ diff --git a/scripts/plugin-stub-generator/composer.json b/scripts/plugin-stub-generator/composer.json new file mode 100644 index 00000000..a995443b --- /dev/null +++ b/scripts/plugin-stub-generator/composer.json @@ -0,0 +1,14 @@ +{ + "name": "shirabe/plugin-stub-generator", + "description": "Deterministic emitter for the proxy stub classes loaded into the plugin PHP worker", + "license": "MIT", + "require": { + "php": ">=8.1", + "nikic/php-parser": "^5.0" + }, + "autoload": { + "psr-4": { + "Shirabe\\PluginStubGenerator\\": "src/" + } + } +} diff --git a/scripts/plugin-stub-generator/composer.lock b/scripts/plugin-stub-generator/composer.lock new file mode 100644 index 00000000..7de72585 --- /dev/null +++ b/scripts/plugin-stub-generator/composer.lock @@ -0,0 +1,78 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "This file is @generated automatically" + ], + "content-hash": "c9443c83d078e8787f1c9d4f8a693c96", + "packages": [ + { + "name": "nikic/php-parser", + "version": "v5.8.0", + "source": { + "type": "git", + "url": "https://github.com/nikic/PHP-Parser.git", + "reference": "044a6a392ff8ad0d61f14370a5fbbd0a0107152f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/044a6a392ff8ad0d61f14370a5fbbd0a0107152f", + "reference": "044a6a392ff8ad0d61f14370a5fbbd0a0107152f", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-tokenizer": "*", + "php": ">=7.4" + }, + "require-dev": { + "ircmaxell/php-yacc": "^0.0.7", + "phpunit/phpunit": "^9.0" + }, + "bin": [ + "bin/php-parse" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.x-dev" + } + }, + "autoload": { + "psr-4": { + "PhpParser\\": "lib/PhpParser" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Nikita Popov" + } + ], + "description": "A PHP parser written in PHP", + "keywords": [ + "parser", + "php" + ], + "support": { + "issues": "https://github.com/nikic/PHP-Parser/issues", + "source": "https://github.com/nikic/PHP-Parser/tree/v5.8.0" + }, + "time": "2026-07-04T14:30:18+00:00" + } + ], + "packages-dev": [], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": {}, + "prefer-stable": false, + "prefer-lowest": false, + "platform": { + "php": ">=8.1" + }, + "platform-dev": {}, + "plugin-api-version": "2.9.0" +} diff --git a/scripts/plugin-stub-generator/generate-stubs b/scripts/plugin-stub-generator/generate-stubs new file mode 100755 index 00000000..c6b03c97 --- /dev/null +++ b/scripts/plugin-stub-generator/generate-stubs @@ -0,0 +1,97 @@ +#!/usr/bin/env php +generate(); +} catch (GenerationError $e) { + foreach ($e->errors as $error) { + fwrite(STDERR, "error: $error\n"); + } + exit(1); +} + +$problems = []; + +$iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($stubsDir, FilesystemIterator::SKIP_DOTS) +); +foreach ($iterator as $entry) { + if ($entry->isFile() && str_ends_with($entry->getFilename(), '.php')) { + $relative = substr($entry->getPathname(), strlen($stubsDir) + 1); + if (!isset($files[$relative])) { + $problems[] = "stale stub not covered by targets.list: $stubsDir/$relative"; + } + } +} + +// The Rust side embeds every stub with include_str!; the two lists must not drift apart. +$libRsText = (string) file_get_contents($libRs); +foreach ($files as $relative => $_) { + if (!str_contains($libRsText, "include_str!(\"../php/stubs/$relative\")")) { + $problems[] = "STUB_FILES in $libRs does not embed $relative"; + } +} + +if ($check) { + foreach ($files as $relative => $content) { + $current = @file_get_contents("$stubsDir/$relative"); + if ($current === false) { + $problems[] = "missing stub (regenerate): $stubsDir/$relative"; + } elseif ($current !== $content) { + $problems[] = "stale stub (regenerate): $stubsDir/$relative"; + } + } +} else { + foreach ($files as $relative => $content) { + $path = "$stubsDir/$relative"; + if (!is_dir(dirname($path))) { + mkdir(dirname($path), 0777, true); + } + file_put_contents($path, $content); + } + fwrite(STDERR, count($files) . " stubs written to $stubsDir\n"); +} + +foreach ($problems as $problem) { + fwrite(STDERR, "error: $problem\n"); +} +exit($problems === [] ? 0 : 1); 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 @@ + $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 @@ +__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 */ + private array $errors = []; + + /** @var array */ + 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>> + */ + private array $surfaces = []; + + /** @param list $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 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 = "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 $publicStatics + * @param array $nonPublicStatics + * @return list + */ + 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('/(?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 */ + 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('/(?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 @@ + 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 @@ +> namespace prefix => base dirs, longest prefix first */ + private array $psr4; + + /** @var array */ + 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 @@ + $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 @@ + alias as written => FQCN, in declaration order */ + public array $aliases = []; + + public ClassLike $classLike; + + /** @var list */ + 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)); + } +} diff --git a/scripts/plugin-stub-generator/targets.list b/scripts/plugin-stub-generator/targets.list new file mode 100644 index 00000000..42794e13 --- /dev/null +++ b/scripts/plugin-stub-generator/targets.list @@ -0,0 +1,12 @@ +# Classes emitted as proxy stubs into crates/shirabe-php-rpc/php/stubs/. One FQCN per +# line; the output path is derived from the FQCN. Every entry must be classified as +# rust-proxy or contract in the classifier report, and stub base classes must precede +# their subclasses. +Composer\EventDispatcher\Event +Composer\Script\Event +Composer\PartialComposer +Composer\Composer +Composer\IO\BaseIO +Composer\IO\ConsoleIO +Composer\IO\BufferIO +Composer\IO\NullIO -- cgit v1.3.1