diff options
| author | nsfisis <nsfisis@gmail.com> | 2026-08-04 03:36:10 +0900 |
|---|---|---|
| committer | nsfisis <nsfisis@gmail.com> | 2026-08-04 05:43:22 +0900 |
| commit | 261516d5ce6f8b0d69cf9e3da7dd2f0ef1cdc36a (patch) | |
| tree | f635a416745065b52ed0bb0ebb18154b9ef89f3b | |
| parent | 6cb1849473792bd73dbfb6265d363f149f687572 (diff) | |
| download | php-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>
24 files changed, 1099 insertions, 47 deletions
diff --git a/crates/shirabe-php-rpc/php/stubs/Composer/Composer.php b/crates/shirabe-php-rpc/php/stubs/Composer/Composer.php index 1bb9f64c..de5a6d1b 100644 --- a/crates/shirabe-php-rpc/php/stubs/Composer/Composer.php +++ b/crates/shirabe-php-rpc/php/stubs/Composer/Composer.php @@ -1,17 +1,16 @@ <?php -// Hand-written proxy stub for Composer\Composer, kept in the shape the future stub generator -// will output. Class constants and static methods are materialized (they read no instance -// state); instance methods forward to the Rust-side entity. +// Generated by scripts/plugin-stub-generator; do not edit by hand. +// Proxy stub for Composer\Composer: the public surface forwards to the Rust-side entity over RPC. namespace Composer; -use Composer\Autoload\AutoloadGenerator; -use Composer\Package\Archiver\ArchiveManager; use Composer\Package\Locker; -use Composer\Downloader\DownloadManager; use Composer\Pcre\Preg; use Composer\Plugin\PluginManager; +use Composer\Downloader\DownloadManager; +use Composer\Autoload\AutoloadGenerator; +use Composer\Package\Archiver\ArchiveManager; class Composer extends PartialComposer { diff --git a/crates/shirabe-php-rpc/php/stubs/Composer/EventDispatcher/Event.php b/crates/shirabe-php-rpc/php/stubs/Composer/EventDispatcher/Event.php index 9ce9c12f..fea84c75 100644 --- a/crates/shirabe-php-rpc/php/stubs/Composer/EventDispatcher/Event.php +++ b/crates/shirabe-php-rpc/php/stubs/Composer/EventDispatcher/Event.php @@ -1,7 +1,7 @@ <?php -// Hand-written proxy stub for Composer\EventDispatcher\Event, kept in the shape the future stub -// generator will output: the real public methods, each forwarding to the Rust-side entity. +// Generated by scripts/plugin-stub-generator; do not edit by hand. +// Proxy stub for Composer\EventDispatcher\Event: the public surface forwards to the Rust-side entity over RPC. namespace Composer\EventDispatcher; @@ -12,8 +12,16 @@ class Event implements \ShirabeRustStub /** @var int */ protected $__epoch; - public function __construct(int $rhandle, int $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; } diff --git a/crates/shirabe-php-rpc/php/stubs/Composer/IO/BaseIO.php b/crates/shirabe-php-rpc/php/stubs/Composer/IO/BaseIO.php index 51cdbe08..55da6415 100644 --- a/crates/shirabe-php-rpc/php/stubs/Composer/IO/BaseIO.php +++ b/crates/shirabe-php-rpc/php/stubs/Composer/IO/BaseIO.php @@ -1,9 +1,7 @@ <?php -// Hand-written proxy stub for Composer\IO\BaseIO, kept in the shape the future stub generator -// will output: every public method of the real class (its own and the IOInterface / -// LoggerInterface surface) forwards to the Rust-side entity. Loading this stub pulls the real -// IOInterface contract from the Composer PHP runtime, which must already be required. +// Generated by scripts/plugin-stub-generator; do not edit by hand. +// Proxy stub for Composer\IO\BaseIO: the public surface forwards to the Rust-side entity over RPC. namespace Composer\IO; @@ -129,22 +127,17 @@ abstract class BaseIO implements IOInterface, \ShirabeRustStub return \ShirabeRpcRuntime::callRust($this->__rhandle, 'getAuthentications', []); } - public function resetAuthentications() - { - return \ShirabeRpcRuntime::callRust($this->__rhandle, 'resetAuthentications', []); - } - - public function hasAuthentication(string $repositoryName) + public function hasAuthentication($repositoryName) { return \ShirabeRpcRuntime::callRust($this->__rhandle, 'hasAuthentication', [$repositoryName]); } - public function getAuthentication(string $repositoryName) + public function getAuthentication($repositoryName) { return \ShirabeRpcRuntime::callRust($this->__rhandle, 'getAuthentication', [$repositoryName]); } - public function setAuthentication(string $repositoryName, string $username, ?string $password = null) + public function setAuthentication($repositoryName, $username, $password = null) { return \ShirabeRpcRuntime::callRust($this->__rhandle, 'setAuthentication', [$repositoryName, $username, $password]); } @@ -198,4 +191,9 @@ abstract class BaseIO implements IOInterface, \ShirabeRustStub { \ShirabeRpcRuntime::callRust($this->__rhandle, 'log', [$level, $message, $context]); } + + public function resetAuthentications() + { + return \ShirabeRpcRuntime::callRust($this->__rhandle, 'resetAuthentications', []); + } } diff --git a/crates/shirabe-php-rpc/php/stubs/Composer/IO/BufferIO.php b/crates/shirabe-php-rpc/php/stubs/Composer/IO/BufferIO.php index e95fb2da..d5e59e90 100644 --- a/crates/shirabe-php-rpc/php/stubs/Composer/IO/BufferIO.php +++ b/crates/shirabe-php-rpc/php/stubs/Composer/IO/BufferIO.php @@ -1,7 +1,7 @@ <?php -// Hand-written proxy stub for Composer\IO\BufferIO, kept in the shape the future stub -// generator will output. +// Generated by scripts/plugin-stub-generator; do not edit by hand. +// Proxy stub for Composer\IO\BufferIO: the public surface forwards to the Rust-side entity over RPC. namespace Composer\IO; diff --git a/crates/shirabe-php-rpc/php/stubs/Composer/IO/ConsoleIO.php b/crates/shirabe-php-rpc/php/stubs/Composer/IO/ConsoleIO.php index 6a4c2ac4..0041363a 100644 --- a/crates/shirabe-php-rpc/php/stubs/Composer/IO/ConsoleIO.php +++ b/crates/shirabe-php-rpc/php/stubs/Composer/IO/ConsoleIO.php @@ -1,15 +1,62 @@ <?php -// Hand-written proxy stub for Composer\IO\ConsoleIO, kept in the shape the future stub -// generator will output. The IOInterface surface is inherited from the BaseIO stub; only the -// public methods ConsoleIO adds are declared here. +// Generated by scripts/plugin-stub-generator; do not edit by hand. +// Proxy stub for Composer\IO\ConsoleIO: the public surface forwards to the Rust-side entity over RPC. namespace Composer\IO; +use Composer\Pcre\Preg; use Symfony\Component\Console\Helper\Table; class ConsoleIO extends BaseIO { + public static function sanitize($messages, bool $allowNewlines = true) + { + // Match ANSI escape sequences: + // - CSI (Control Sequence Introducer): ESC [ params intermediate final + // - OSC (Operating System Command): ESC ] ... ESC \ or BEL + // - Other ESC sequences: ESC followed by any character + $escapePattern = '\x1B\[[\x30-\x3F]*[\x20-\x2F]*[\x40-\x7E]|\x1B\].*?(?:\x1B\\\\|\x07)|\x1B.'; + $pattern = $allowNewlines ? "{{$escapePattern}|[\x01-\x09\x0B\x0C\x0E-\x1A]|\r(?!\n)}u" : "{{$escapePattern}|[\x01-\x1A]}u"; + if (is_string($messages)) { + $messages = self::ensureValidUtf8($messages); + return Preg::replace($pattern, '', $messages); + } + + $sanitized = []; + foreach ($messages as $key => $message) { + $message = self::ensureValidUtf8($message); + $sanitized[$key] = Preg::replace($pattern, '', $message); + } + + return $sanitized; + } + + private static function ensureValidUtf8(string $string): string + { + // Quick check: if string is already valid UTF-8, return as-is + if (function_exists('mb_check_encoding') && mb_check_encoding($string, 'UTF-8')) { + return $string; + } + + // Use mb_convert_encoding to replace invalid sequences with '?' + // This makes it visible when data quality issues occur + if (function_exists('mb_convert_encoding')) { + return (string) mb_convert_encoding($string, 'UTF-8', 'UTF-8'); + } + + // Fallback to iconv if mbstring unavailable + if (function_exists('iconv')) { + $cleaned = @iconv('UTF-8', 'UTF-8//TRANSLIT', $string); + if ($cleaned !== false) { + return $cleaned; + } + } + + // Last resort: return as-is (should never happen - Composer requires mbstring OR iconv) + return $string; + } + public function enableDebugging(float $startTime) { return \ShirabeRpcRuntime::callRust($this->__rhandle, 'enableDebugging', [$startTime]); diff --git a/crates/shirabe-php-rpc/php/stubs/Composer/IO/NullIO.php b/crates/shirabe-php-rpc/php/stubs/Composer/IO/NullIO.php index 92e01700..1234ad4a 100644 --- a/crates/shirabe-php-rpc/php/stubs/Composer/IO/NullIO.php +++ b/crates/shirabe-php-rpc/php/stubs/Composer/IO/NullIO.php @@ -1,7 +1,7 @@ <?php -// Hand-written proxy stub for Composer\IO\NullIO, kept in the shape the future stub generator -// will output. The whole surface is inherited from the BaseIO stub. +// Generated by scripts/plugin-stub-generator; do not edit by hand. +// Proxy stub for Composer\IO\NullIO: the public surface forwards to the Rust-side entity over RPC. namespace Composer\IO; diff --git a/crates/shirabe-php-rpc/php/stubs/Composer/PartialComposer.php b/crates/shirabe-php-rpc/php/stubs/Composer/PartialComposer.php index 2641b267..588800dc 100644 --- a/crates/shirabe-php-rpc/php/stubs/Composer/PartialComposer.php +++ b/crates/shirabe-php-rpc/php/stubs/Composer/PartialComposer.php @@ -1,18 +1,15 @@ <?php -// Hand-written proxy stub for Composer\PartialComposer, kept in the shape the future stub -// generator will output: the real public methods, each forwarding to the Rust-side entity. -// Methods the Rust dispatcher does not support yet surface as explicit RPC errors. +// Generated by scripts/plugin-stub-generator; do not edit by hand. +// Proxy stub for Composer\PartialComposer: the public surface forwards to the Rust-side entity over RPC. namespace Composer; -use Composer\Autoload\AutoloadGenerator; -use Composer\Config; -use Composer\EventDispatcher\EventDispatcher; -use Composer\Installer\InstallationManager; use Composer\Package\RootPackageInterface; -use Composer\Repository\RepositoryManager; use Composer\Util\Loop; +use Composer\Repository\RepositoryManager; +use Composer\Installer\InstallationManager; +use Composer\EventDispatcher\EventDispatcher; class PartialComposer implements \ShirabeRustStub { diff --git a/crates/shirabe-php-rpc/php/stubs/Composer/Script/Event.php b/crates/shirabe-php-rpc/php/stubs/Composer/Script/Event.php index 80afe1b7..a93d0dfc 100644 --- a/crates/shirabe-php-rpc/php/stubs/Composer/Script/Event.php +++ b/crates/shirabe-php-rpc/php/stubs/Composer/Script/Event.php @@ -1,20 +1,22 @@ <?php -// Hand-written proxy stub for Composer\Script\Event, kept in the shape the future stub -// generator will output. See Composer/EventDispatcher/Event.php. +// Generated by scripts/plugin-stub-generator; do not edit by hand. +// Proxy stub for Composer\Script\Event: the public surface forwards to the Rust-side entity over RPC. namespace Composer\Script; +use Composer\Composer; +use Composer\IO\IOInterface; use Composer\EventDispatcher\Event as BaseEvent; class Event extends BaseEvent { - public function getComposer(): \Composer\Composer + public function getComposer(): Composer { return \ShirabeRpcRuntime::callRust($this->__rhandle, 'getComposer', []); } - public function getIO(): \Composer\IO\IOInterface + public function getIO(): IOInterface { return \ShirabeRpcRuntime::callRust($this->__rhandle, 'getIO', []); } diff --git a/crates/shirabe-php-rpc/src/lib.rs b/crates/shirabe-php-rpc/src/lib.rs index 9c5dae55..23996a95 100644 --- a/crates/shirabe-php-rpc/src/lib.rs +++ b/crates/shirabe-php-rpc/src/lib.rs @@ -539,8 +539,9 @@ fn call(name: &str, arg: &str) -> PhpMixed { const GLUE_SCRIPT: &str = include_str!("../php/worker.php"); -/// Hand-written proxy stub classes made autoloadable inside the worker, written in the shape the -/// future stub generator will output. +/// Proxy stub classes made autoloadable inside the worker. Generated by +/// `scripts/plugin-stub-generator/generate-stubs`; this list must cover its targets.list (the +/// generator's `--check` mode verifies both the file contents and this list). const STUB_FILES: &[(&str, &str)] = &[ ( "Composer/EventDispatcher/Event.php", diff --git a/crates/shirabe-php-rpc/tests/generated_stubs.rs b/crates/shirabe-php-rpc/tests/generated_stubs.rs new file mode 100644 index 00000000..b4ff8197 --- /dev/null +++ b/crates/shirabe-php-rpc/tests/generated_stubs.rs @@ -0,0 +1,40 @@ +//! Freshness check for the generated proxy stubs: `generate-stubs --check` verifies that the +//! committed stub files and the `STUB_FILES` list in `lib.rs` match what the generator emits +//! from the current Composer checkout and classifier report. +//! +//! The generator needs a PHP interpreter, its composer vendor directory and the classifier +//! report; when any of those is missing the test returns early, following the non-mock test +//! convention of this crate. + +use shirabe_external_packages::symfony::process::PhpExecutableFinder; +use std::path::Path; + +#[test] +fn generated_stubs_are_fresh() { + let Some(php) = PhpExecutableFinder::new().find(false) else { + return; + }; + let tool_dir = + Path::new(env!("CARGO_MANIFEST_DIR")).join("../../scripts/plugin-stub-generator"); + if !tool_dir.join("vendor/autoload.php").is_file() { + return; + } + if !tool_dir + .join("../plugin-class-classifier/report.json") + .is_file() + { + return; + } + + let output = std::process::Command::new(php) + .arg(tool_dir.join("generate-stubs")) + .arg("--check") + .output() + .expect("failed to spawn the stub generator"); + assert!( + output.status.success(), + "generate-stubs --check failed; regenerate the stubs with \ + scripts/plugin-stub-generator/generate-stubs:\n{}", + String::from_utf8_lossy(&output.stderr), + ); +} diff --git a/crates/shirabe/src/plugin/php_plugin_proxy.rs b/crates/shirabe/src/plugin/php_plugin_proxy.rs index 09867079..2b823fa5 100644 --- a/crates/shirabe/src/plugin/php_plugin_proxy.rs +++ b/crates/shirabe/src/plugin/php_plugin_proxy.rs @@ -90,8 +90,8 @@ pub(crate) fn io_stub_class( } else if any.downcast_ref::<crate::io::null_io::NullIO>().is_some() { Ok("Composer\\IO\\NullIO") } else { - // TODO(plugin): only the IO classes with hand-written proxy stubs can cross the - // boundary until a stub generator exists. + // TODO(plugin): only IO implementations with a generated proxy stub can cross the + // boundary; the rest are an explicit error until stubs of their own are generated. Err(anyhow::anyhow!( "no proxy stub class is available for this IO implementation" )) diff --git a/docs/dev/php-rpc.md b/docs/dev/php-rpc.md index f8f42e90..a1093ec5 100644 --- a/docs/dev/php-rpc.md +++ b/docs/dev/php-rpc.md @@ -131,10 +131,11 @@ function; an unknown name is an explicit error. Notable internal helpers: ## Proxy stubs -`php/stubs/` holds hand-written proxy stub classes (`Composer\EventDispatcher\Event`, +`php/stubs/` holds the proxy stub classes (`Composer\EventDispatcher\Event`, `Composer\Script\Event`, `Composer\PartialComposer`, `Composer\Composer`, and the -`Composer\IO\{BaseIO,ConsoleIO,BufferIO,NullIO}` hierarchy), written in the shape the future -stub generator will output. They are autoloaded with highest priority so a proxied FQCN can +`Composer\IO\{BaseIO,ConsoleIO,BufferIO,NullIO}` hierarchy). They are generated by +`scripts/plugin-stub-generator/generate-stubs` and must not be edited by hand; see +`docs/dev/plugin-stub-generation.md`. They are autoloaded with highest priority so a proxied FQCN can never be shadowed by the real implementation; `__shirabe_require` restores that priority after loading code that prepends its own autoloader. Stubs are interned per rhandle (`WeakReference`-based registry) so identity (`===`) holds, and their destructors send diff --git a/docs/dev/plugin-stub-generation.md b/docs/dev/plugin-stub-generation.md new file mode 100644 index 00000000..1fb77ff4 --- /dev/null +++ b/docs/dev/plugin-stub-generation.md @@ -0,0 +1,86 @@ +# Plugin proxy stub generation + +## Purpose + +The plugin PHP worker resolves proxied Composer FQCNs to thin stub classes whose +public methods forward to the Rust-side entity over RPC (see +`docs/dev/php-rpc.md`, "Proxy stubs"). These stub files, committed under +`crates/shirabe-php-rpc/php/stubs/` and embedded into the binary by the +`STUB_FILES` list in `crates/shirabe-php-rpc/src/lib.rs`, are *generated* by +`scripts/plugin-stub-generator` — never edited by hand. Generating them from the +Composer sources keeps every stub signature mechanically faithful to the real +class and turns "the stub is missing something the real class has" into a +generation failure instead of silent breakage. + +## Running + +``` +cd scripts/plugin-stub-generator +composer install # once; vendor/ is git-ignored +./generate-stubs # rewrites crates/shirabe-php-rpc/php/stubs/ +./generate-stubs --check # verifies committed stubs are fresh; exit 1 otherwise +``` + +Inputs: + +* `targets.list` — the FQCNs to emit, stub base classes before their subclasses. + Growing the stub set means adding a line here and regenerating. +* the Composer checkout (`composer/`, override with `--composer-root=`); the + generator locates sources through the checkout's own PSR-4 autoload map, so + interfaces from vendor packages (e.g. `Psr\Log\LoggerInterface`) resolve too. +* the classifier report (`scripts/plugin-class-classifier/report.json`, override + with `--report=`). Run `scripts/plugin-class-classifier/classify` first; the + report is git-ignored. Every target must be classified `rust-proxy` or + `contract`, and a report with classification violations is rejected. + +The freshness check runs in `cargo test` as +`crates/shirabe-php-rpc/tests/generated_stubs.rs`; it returns early when PHP, +the generator's vendor directory or the classifier report is unavailable. + +## What the generator emits + +* **Root stubs** (targets whose parent class is not itself a target) carry the + proxy boilerplate: `__rhandle`/`__epoch` properties, a constructor that + accepts `(rhandle, epoch)` from proxy instantiation and throws a diagnosable + `RuntimeException` when plugin code tries to `new` the class directly, a + destructor releasing the Rust handle, and the wire descriptor helper. The + real `extends`/`implements` hierarchy is preserved and `\ShirabeRustStub` is + appended to the interface list. +* **Instance methods** forward via `\ShirabeRpcRuntime::callRust`. For a root + stub the emitted surface is the interface closure (each interface before the + ones it extends, methods in declaration order; a concrete redeclaration in + the class wins over the interface signature) followed by the class's own + remaining public methods. A subclass stub declares only the methods whose + *name* is new relative to the inherited stub surface; an omitted override + must match the inherited parameter list (names, arity, defaults, passing + modes — type declarations may differ), otherwise generation fails. +* Methods returning `self`/`static` perform the RPC and then `return $this;` + to preserve identity instead of round-tripping the handle. +* **Class constants and static methods** are materialized verbatim from the + real source (they read no instance state and run locally in the worker), + together with any non-public static helpers they call. +* **Imports**: the original file's `use` statements are kept in their original + order, restricted to names the emitted stub references; signatures declared + elsewhere (interface files) are re-spelled through that import table. + +## Coverage assertions + +Generation fails — instead of emitting something quietly wrong — on: + +* a target missing from the classifier report, classified other than + `rust-proxy`/`contract`, or a report carrying violations, +* public properties (a stub cannot forward property access), +* by-ref or variadic parameters, magic methods, static interface methods, +* an omitted override diverging from the inherited stub signature, +* a subclass target listed before its base class, or extending a class that is + not a target, +* non-public class constants (materializing them is unsupported so far). + +`generate-stubs` (in both modes) additionally fails when a `.php` file exists +under the stubs directory that no target produces, or when `STUB_FILES` in +`crates/shirabe-php-rpc/src/lib.rs` does not embed every generated file. + +When a future Composer release adds a public member the emitter cannot handle, +these assertions surface it at generation time; extending the emitter (or +deciding the porting policy) is then an explicit step, mirroring the +completeness stance of `docs/dev/plugin-class-classification.md`. 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 +<?php + +declare(strict_types=1); + +require __DIR__ . '/vendor/autoload.php'; + +use Shirabe\PluginStubGenerator\GenerationError; +use Shirabe\PluginStubGenerator\Generator; +use Shirabe\PluginStubGenerator\Report; + +$repoRoot = dirname(__DIR__, 2); +$composerRoot = $repoRoot . '/composer'; +$reportPath = $repoRoot . '/scripts/plugin-class-classifier/report.json'; +$stubsDir = $repoRoot . '/crates/shirabe-php-rpc/php/stubs'; +$libRs = $repoRoot . '/crates/shirabe-php-rpc/src/lib.rs'; +$check = false; + +foreach (array_slice($argv, 1) as $arg) { + if (str_starts_with($arg, '--composer-root=')) { + $composerRoot = substr($arg, strlen('--composer-root=')); + } elseif (str_starts_with($arg, '--report=')) { + $reportPath = substr($arg, strlen('--report=')); + } elseif (str_starts_with($arg, '--stubs-dir=')) { + $stubsDir = substr($arg, strlen('--stubs-dir=')); + } elseif ($arg === '--check') { + $check = true; + } else { + fwrite(STDERR, "usage: generate-stubs [--composer-root=DIR] [--report=FILE] [--stubs-dir=DIR] [--check]\n"); + exit(2); + } +} + +$targets = []; +foreach (file(__DIR__ . '/targets.list', FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) { + $line = trim($line); + if ($line !== '' && !str_starts_with($line, '#')) { + $targets[] = $line; + } +} + +try { + $generator = new Generator($composerRoot, Report::load($reportPath), $targets); + $files = $generator->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 @@ +<?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)); + } +} 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 |
