From 8fbc2b381112ba4cea0ef187ef240b2849f8b970 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Wed, 3 Jun 2026 19:07:16 +0000 Subject: [PATCH 1/5] Add XPHP Symfony bundle and runnable demo app Introduce the xphp-lang/xphp-symfony-bundle: write .xphp generics in a Symfony app, compile them to vanilla PHP during the normal build, and autoload the generated classes. Bundle: - XphpBundle (AbstractBundle) with source/target/cache, hash_length, compile_on_warmup and register_runtime_autoloader config. - XphpCompiler drives xphp's public ApplicationConsole `compile` facade in-process. - xphp:compile console command for on-demand compilation. - XphpCacheWarmer hooks compilation into cache:warmup; mandatory and fail-loud so a compile error aborts the build instead of shipping missing/stale generated PHP. - GeneratedClassLoader: runtime SPL autoloader for XPHP\Generated\*. Demo (demo/): console-only Symfony app consuming the bundle via a path repository. .xphp and plain .php coexist in src/; an app:demo command calls the compiled Catalog and prints the monomorphized Box class. Verified end to end: composer install, xphp:compile, app:demo. Co-Authored-By: Claude Opus 4.8 (1M context) --- .docker/php.Dockerfile | 29 ++++ .gitignore | 10 ++ Makefile | 10 ++ README.md | 183 ++++++++++++++++++++ composer.json | 59 +++++++ config/services.php | 34 ++++ demo/.gitignore | 4 + demo/README.md | 61 +++++++ demo/bin/console | 18 ++ demo/composer.json | 29 ++++ demo/config/bundles.php | 8 + demo/config/packages/framework.yaml | 5 + demo/config/packages/xphp.yaml | 7 + demo/config/services.yaml | 10 ++ demo/src/Command/DemoCommand.php | 48 +++++ demo/src/Containers/Box.xphp | 26 +++ demo/src/Demo/Catalog.xphp | 43 +++++ demo/src/Kernel.php | 13 ++ demo/src/Models/Plastic.php | 17 ++ docker-compose.yml | 12 ++ infection.json5 | 14 ++ phpunit.xml.dist | 19 ++ src/Autoload/GeneratedClassLoader.php | 55 ++++++ src/CacheWarmer/XphpCacheWarmer.php | 54 ++++++ src/Command/CompileCommand.php | 52 ++++++ src/Compiler/CompilationFailedException.php | 11 ++ src/Compiler/XphpCompiler.php | 81 +++++++++ src/XphpBundle.php | 76 ++++++++ tests/Autoload/GeneratedClassLoaderTest.php | 49 ++++++ 29 files changed, 1037 insertions(+) create mode 100644 .docker/php.Dockerfile create mode 100644 .gitignore create mode 100644 Makefile create mode 100644 README.md create mode 100644 composer.json create mode 100644 config/services.php create mode 100644 demo/.gitignore create mode 100644 demo/README.md create mode 100755 demo/bin/console create mode 100644 demo/composer.json create mode 100644 demo/config/bundles.php create mode 100644 demo/config/packages/framework.yaml create mode 100644 demo/config/packages/xphp.yaml create mode 100644 demo/config/services.yaml create mode 100644 demo/src/Command/DemoCommand.php create mode 100644 demo/src/Containers/Box.xphp create mode 100644 demo/src/Demo/Catalog.xphp create mode 100644 demo/src/Kernel.php create mode 100644 demo/src/Models/Plastic.php create mode 100644 docker-compose.yml create mode 100644 infection.json5 create mode 100644 phpunit.xml.dist create mode 100644 src/Autoload/GeneratedClassLoader.php create mode 100644 src/CacheWarmer/XphpCacheWarmer.php create mode 100644 src/Command/CompileCommand.php create mode 100644 src/Compiler/CompilationFailedException.php create mode 100644 src/Compiler/XphpCompiler.php create mode 100644 src/XphpBundle.php create mode 100644 tests/Autoload/GeneratedClassLoaderTest.php diff --git a/.docker/php.Dockerfile b/.docker/php.Dockerfile new file mode 100644 index 0000000..6df3aae --- /dev/null +++ b/.docker/php.Dockerfile @@ -0,0 +1,29 @@ +FROM php:8.4-cli-alpine + +# Composer: copied from the official multi-arch image instead of curl- +# piped install. Reproducible across rebuilds and gets composer 2.x +# without an interactive installer. Both Makefile entry points +# (`make test` and `make test/mutation`) shell out to `composer +# install`, so this needs to be in PATH. +COPY --from=composer:2 /usr/bin/composer /usr/local/bin/composer + +RUN apk add --update --no-cache linux-headers +RUN apk add --no-cache \ + php84-dev \ + build-base \ + git \ + unzip + +# Both coverage drivers are installed. Xdebug runs as the dev-time +# debugger; PCOV is used exclusively for Infection's coverage pass +# (orders of magnitude less memory than Xdebug, which avoids the +# host OOM-killer firing during the initial test run on tight +# containers). Infection auto-detects PCOV when it's loaded and +# Xdebug is disabled via XDEBUG_MODE=off; the mutation Makefile +# target does both. +RUN pecl install \ + xdebug \ + pcov \ + && docker-php-ext-enable \ + xdebug \ + pcov diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..07aa9ea --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +/docker-compose.override.yml +/app + +/vendor/ +/composer.lock +/var/ +/.phpunit.cache/ +/.phpunit.result.cache +/phpunit.xml +/infection.log diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..a890f8b --- /dev/null +++ b/Makefile @@ -0,0 +1,10 @@ +.PHONY: install test test/mutation + +install: + composer install --no-interaction --prefer-dist + +test: install + XDEBUG_MODE=coverage vendor/bin/phpunit + +test/mutation: install + XDEBUG_MODE=off vendor/bin/infection --threads=max --min-msi=80 --min-covered-msi=80 diff --git a/README.md b/README.md new file mode 100644 index 0000000..69172e4 --- /dev/null +++ b/README.md @@ -0,0 +1,183 @@ +# XPHP Symfony Bundle + +Write [XPHP](https://github.com/xphp-lang/xphp) — PHP 8.4 with native generics via +compile-time monomorphization — inside a Symfony application, and have the +auto-generated vanilla PHP produced as part of your normal build/deploy. + +The bundle wires xphp's transpiler into the Symfony lifecycle: + +- a **`xphp:compile`** console command to compile on demand during development; +- a **cache warmer** so `bin/console cache:warmup` (run by every production build) + emits the generated PHP as a deterministic deploy artifact; +- a **runtime autoloader** for the generated `XPHP\Generated\*` classes, so the app + boots against fresh output without a `composer dump-autoload`. + +## Requirements + +- PHP `^8.4` +- Symfony `^8.0` +- [`xphp-lang/xphp`](https://packagist.org/packages/xphp-lang/xphp) `^0.1` (pulled in automatically) + +## Installation + +```bash +composer require xphp-lang/xphp-symfony-bundle +``` + +Register the bundle (Symfony Flex does this for you): + +```php +// config/bundles.php +return [ + // ... + Xphp\SymfonyBundle\XphpBundle::class => ['all' => true], +]; +``` + +## Configuration + +Defaults (override only what you need): + +```yaml +# config/packages/xphp.yaml +xphp: + # Where your .xphp sources live. + source: '%kernel.project_dir%/xphp' + # Rewritten, vanilla .php files (generic call sites replaced). + target: '%kernel.project_dir%/var/xphp/dist' + # Generated specialized classes (XPHP\Generated\*). + cache: '%kernel.project_dir%/var/xphp/generated' + # Hex length (16-64) of the hash in specialized class names. null = xphp default. + hash_length: null + # Compile automatically during cache:warmup (recommended for deploys). + compile_on_warmup: true + # Register an SPL autoloader for XPHP\Generated\ at boot. + register_runtime_autoloader: true +``` + +These map directly to the upstream CLI: `xphp compile `. + +## Layout & autoloading + +A `.xphp` source uses standard PSR-4 namespace-to-directory mapping. Compilation +produces two outputs: + +| Output | Namespace | Goes to | +| --- | --- | --- | +| Rewritten user code | original (e.g. `App\…`) | `target` dir | +| Specialized classes | `XPHP\Generated\…` | `/Generated/` | + +The bundle autoloads `XPHP\Generated\*` at runtime out of the box. For the +**rewritten** code you choose where the application loads `App\` from. For a +production deploy, point composer at the compiled output and dump an optimized +classmap: + +```jsonc +// composer.json +"autoload": { + "psr-4": { + "App\\": ["src/", "var/xphp/dist/App/"], + "XPHP\\Generated\\": "var/xphp/generated/Generated/" + } +} +``` + +```bash +composer dump-autoload --optimize --classmap-authoritative +``` + +When composer owns the classmap, you can set `register_runtime_autoloader: false`. + +## Usage + +1. Put your generics under the `source` directory, e.g.: + + ``` + xphp/ + └── App/ + └── Collections/ + └── Collection.xphp # final class Collection { ... } + ``` + +2. Compile during development: + + ```bash + bin/console xphp:compile + ``` + + …or just warm the cache (also runs the compiler): + + ```bash + bin/console cache:warmup + ``` + +3. Reference the generated classes from your app as usual; instantiations like + `Collection` are monomorphized to concrete classes with native type + hints and **zero runtime penalty**. + +## Deploy + +A typical build step needs nothing xphp-specific beyond what you already run: + +```bash +composer install --no-dev --optimize-autoloader +bin/console cache:warmup --env=prod # compiles .xphp -> var/xphp/* +``` + +Commit `.xphp` sources; treat `var/xphp/` as generated (gitignored), regenerated +on each build. If you prefer to ship precompiled PHP without xphp on the target, +run `xphp:compile` in CI and include the `target`/`cache` dirs in the artifact. + +The cache warmer is **mandatory and fail-loud**: if compilation fails during +`cache:warmup`, the command exits non-zero and the build aborts, so a broken +artifact (missing or stale `XPHP\Generated\*` classes) never ships. + +### Compiling in a separate step (`compile_on_warmup: false`) + +If you'd rather drive compilation explicitly — e.g. a dedicated CI stage, or to +keep `cache:warmup` free of transpilation — turn the warmer off: + +```yaml +# config/packages/xphp.yaml +xphp: + compile_on_warmup: false +``` + +The bundle then never compiles on its own; **you** own the step and must run it +before the app is deployed (and before `composer dump-autoload`, since the +optimized classmap is built from the generated output): + +```bash +bin/console xphp:compile --env=prod # explicit, fails the pipeline on error +composer dump-autoload --optimize --classmap-authoritative +``` + +`xphp:compile` is itself fail-loud — it exits non-zero on a compilation error — +so a CI stage running it will stop the pipeline just like the warmer would. + +With the warmer disabled, `cache:warmup` no longer regenerates `var/xphp/`, so +make sure your build runs `xphp:compile` on every deploy; otherwise the app +boots against whatever output happens to be on disk. + +## Try the demo + +A runnable, console-only example app lives in [`demo/`](demo/). It writes `.xphp` +generics alongside plain PHP in one `src/`, compiles them through this bundle, and +calls the generated code from a Symfony command: + +```bash +cd demo && composer install +bin/console xphp:compile +bin/console app:demo +``` + +## Development + +```bash +make test # PHPUnit +make test/mutation # Infection +``` + +## License + +MIT diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..51081de --- /dev/null +++ b/composer.json @@ -0,0 +1,59 @@ +{ + "name": "xphp-lang/xphp-symfony-bundle", + "description": "Symfony bundle to write XPHP (PHP with native generics) inside a Symfony app and deploy the auto-generated PHP.", + "type": "symfony-bundle", + "license": "MIT", + "keywords": [ + "symfony", + "bundle", + "xphp", + "generics", + "transpiler", + "monomorphization" + ], + "homepage": "https://github.com/xphp-lang/xphp-symfony-bundle", + "authors": [ + { + "name": "Matheus Martins", + "email": "math3usmartins@github.com" + } + ], + "support": { + "issues": "https://github.com/xphp-lang/xphp-symfony-bundle/issues", + "source": "https://github.com/xphp-lang/xphp-symfony-bundle" + }, + "require": { + "php": "^8.4.0", + "xphp-lang/xphp": "^0.1", + "symfony/config": "^8.0", + "symfony/console": "^8.0", + "symfony/dependency-injection": "^8.0", + "symfony/http-kernel": "^8.0" + }, + "require-dev": { + "symfony/framework-bundle": "^8.0", + "phpunit/phpunit": "^13.0", + "infection/infection": "^0.33" + }, + "autoload": { + "psr-4": { + "Xphp\\SymfonyBundle\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "Xphp\\SymfonyBundle\\Tests\\": "tests/" + } + }, + "config": { + "sort-packages": true, + "allow-plugins": { + "infection/extension-installer": true + } + }, + "extra": { + "branch-alias": { + "dev-main": "0.1-dev" + } + } +} diff --git a/config/services.php b/config/services.php new file mode 100644 index 0000000..cd4224f --- /dev/null +++ b/config/services.php @@ -0,0 +1,34 @@ +services(); + + $services->set(XphpCompiler::class) + ->args([ + '%xphp.source%', + '%xphp.target%', + '%xphp.cache%', + '%xphp.hash_length%', + ]); + + $services->set(CompileCommand::class) + ->args([service(XphpCompiler::class)]) + ->tag('console.command'); + + $services->set(XphpCacheWarmer::class) + ->args([ + service(XphpCompiler::class), + '%xphp.compile_on_warmup%', + '%xphp.source%', + ]) + ->tag('kernel.cache_warmer'); +}; diff --git a/demo/.gitignore b/demo/.gitignore new file mode 100644 index 0000000..6fe1119 --- /dev/null +++ b/demo/.gitignore @@ -0,0 +1,4 @@ +/vendor/ +/var/ +/composer.lock +/config/reference.php diff --git a/demo/README.md b/demo/README.md new file mode 100644 index 0000000..a0caa62 --- /dev/null +++ b/demo/README.md @@ -0,0 +1,61 @@ +# XPHP Symfony bundle — demo app + +A minimal, console-only Symfony app that uses the +[`xphp-lang/xphp-symfony-bundle`](../) (consumed locally via a Composer path +repository). It shows the whole loop: write `.xphp` generics → compile them with +the bundle → call the generated PHP from a handwritten Symfony command. + +## Layout: one `src/`, two kinds of file + +`.xphp` and plain `.php` files live **side by side** under `src/`: + +``` +src/ +├── Command/DemoCommand.php # handwritten PHP — calls into the compiled Catalog +├── Models/Plastic.php # handwritten PHP — left untouched by the build +├── Containers/Box.xphp # generic template class Box +├── Demo/Catalog.xphp # uses new Box() (so it must be .xphp) +└── Kernel.php +``` + +The bundle's compiler is pointed at `src/` (see `config/packages/xphp.yaml`) but +only ever processes `*.xphp`; your `*.php` files are ignored and stay byte-for-byte +the same. Compiled output goes to `var/xphp/` and `App\` is mapped (in +`composer.json`) to both `src/` and `var/xphp/dist/`, so each class resolves to +exactly one real `.php`: + +| Class | Loaded from | Kind | +| --- | --- | --- | +| `App\Command\DemoCommand` | `src/Command/DemoCommand.php` | handwritten | +| `App\Models\Plastic` | `src/Models/Plastic.php` | handwritten, untouched | +| `App\Containers\Box` | `var/xphp/dist/Containers/Box.php` | compiled (supertype) | +| `App\Demo\Catalog` | `var/xphp/dist/Demo/Catalog.php` | compiled | +| `XPHP\Generated\…\T_` | `var/xphp/generated/Generated/…` | generated (specialized) | + +The generated `XPHP\Generated\*` classes are autoloaded by the bundle's runtime +autoloader — no extra composer config needed. + +## Run it + +Requires PHP 8.4+. + +```bash +cd demo +composer install +bin/console xphp:compile # src/*.xphp -> var/xphp/dist + var/xphp/generated +bin/console app:demo +``` + +Expected output: the colors `red, blue` produced by the compiled `Catalog`, plus +the concrete `XPHP\Generated\App\Containers\Box\T_` class that `Box` +was monomorphized into — proving it's the generated PHP that actually runs. + +In a real deploy you'd skip the explicit `xphp:compile`: `bin/console cache:warmup` +runs the compiler as part of the standard build (see the bundle README). + +## What to look at + +- `src/Demo/Catalog.xphp` — the only place that writes `Box`. +- `var/xphp/dist/Demo/Catalog.php` (after compiling) — the same code with the + generic call sites rewritten to concrete classes. +- `src/Models/Plastic.php` — compare before/after a compile; it never changes. diff --git a/demo/bin/console b/demo/bin/console new file mode 100755 index 0000000..03a8af7 --- /dev/null +++ b/demo/bin/console @@ -0,0 +1,18 @@ +#!/usr/bin/env php +run()); diff --git a/demo/composer.json b/demo/composer.json new file mode 100644 index 0000000..1176c56 --- /dev/null +++ b/demo/composer.json @@ -0,0 +1,29 @@ +{ + "name": "xphp-lang/xphp-symfony-bundle-demo", + "description": "Runnable demo of the XPHP Symfony bundle: write .xphp generics in src/, compile them, and call the generated PHP from a console command.", + "type": "project", + "license": "MIT", + "repositories": [ + { + "type": "path", + "url": "../" + } + ], + "require": { + "php": "^8.4.0", + "xphp-lang/xphp-symfony-bundle": "@dev", + "symfony/console": "^8.0", + "symfony/framework-bundle": "^8.0", + "symfony/yaml": "^8.0" + }, + "autoload": { + "psr-4": { + "App\\": ["src/", "var/xphp/dist/"] + } + }, + "minimum-stability": "dev", + "prefer-stable": true, + "config": { + "sort-packages": true + } +} diff --git a/demo/config/bundles.php b/demo/config/bundles.php new file mode 100644 index 0000000..6d1ca3f --- /dev/null +++ b/demo/config/bundles.php @@ -0,0 +1,8 @@ + ['all' => true], + Xphp\SymfonyBundle\XphpBundle::class => ['all' => true], +]; diff --git a/demo/config/packages/framework.yaml b/demo/config/packages/framework.yaml new file mode 100644 index 0000000..f46f5d8 --- /dev/null +++ b/demo/config/packages/framework.yaml @@ -0,0 +1,5 @@ +framework: + # Console-only demo: no session, router, or test config needed. + secret: 'xphp-demo-not-secret' + http_method_override: false + handle_all_throwables: true diff --git a/demo/config/packages/xphp.yaml b/demo/config/packages/xphp.yaml new file mode 100644 index 0000000..3aa70b4 --- /dev/null +++ b/demo/config/packages/xphp.yaml @@ -0,0 +1,7 @@ +xphp: + # Compile .xphp files that live alongside handwritten .php in src/. + # The compiler only touches *.xphp; plain *.php files are left as-is. + source: '%kernel.project_dir%/src' + # target/cache keep their defaults: + # target: '%kernel.project_dir%/var/xphp/dist' (rewritten vanilla PHP) + # cache: '%kernel.project_dir%/var/xphp/generated' (XPHP\Generated\* classes) diff --git a/demo/config/services.yaml b/demo/config/services.yaml new file mode 100644 index 0000000..5a408ee --- /dev/null +++ b/demo/config/services.yaml @@ -0,0 +1,10 @@ +services: + _defaults: + autowire: true + autoconfigure: true + + # Register ONLY the command directory as services. We deliberately do not glob + # the whole of src/, because src/ also holds .xphp sources and the compiled + # domain classes (Box, Catalog) — none of which should be containerized. + App\Command\: + resource: '../src/Command/' diff --git a/demo/src/Command/DemoCommand.php b/demo/src/Command/DemoCommand.php new file mode 100644 index 0000000..c243c32 --- /dev/null +++ b/demo/src/Command/DemoCommand.php @@ -0,0 +1,48 @@ +title('XPHP demo: Catalog (compiled from src/Demo/Catalog.xphp)'); + $io->listing($catalog->colors()); + + // Backslash-escape the angle brackets so the console formatter treats + // them as literals rather than markup tags. + $io->writeln(sprintf( + 'Box\ was monomorphized to: %s', + $catalog->boxClass(), + )); + $io->newLine(); + $io->success('Ran generated PHP — no generics left at runtime.'); + + return Command::SUCCESS; + } +} diff --git a/demo/src/Containers/Box.xphp b/demo/src/Containers/Box.xphp new file mode 100644 index 0000000..0973f49 --- /dev/null +++ b/demo/src/Containers/Box.xphp @@ -0,0 +1,26 @@ +` type parameter and the `T` type hints are XPHP + * syntax — only legal inside a .xphp file. The compiler monomorphizes each + * concrete usage (e.g. Box) into a dedicated class under + * XPHP\Generated\, and emits App\Containers\Box itself as the common supertype. + */ +class Box +{ + public T $item; + + public function set(T $val): void + { + $this->item = $val; + } + + public function get(): T + { + return $this->item; + } +} diff --git a/demo/src/Demo/Catalog.xphp b/demo/src/Demo/Catalog.xphp new file mode 100644 index 0000000..136f8e0 --- /dev/null +++ b/demo/src/Demo/Catalog.xphp @@ -0,0 +1,43 @@ +()`, this file + * must be .xphp — the compiler rewrites those call sites to the concrete + * XPHP\Generated\App\Containers\Box\T_ classes. The public API + * (`colors()`, `boxClass()`) is plain PHP, so handwritten code can call it + * without knowing anything about generics. + */ +final class Catalog +{ + /** + * @return list + */ + public function colors(): array + { + $red = new Box(); + $red->set(new Plastic('red')); + + $blue = new Box(); + $blue->set(new Plastic('blue')); + + return [$red->get()->color, $blue->get()->color]; + } + + /** + * Returns the runtime class name a `Box` was monomorphized into, so + * the demo can show the generated class that actually ran. + */ + public function boxClass(): string + { + $box = new Box(); + + return $box::class; + } +} diff --git a/demo/src/Kernel.php b/demo/src/Kernel.php new file mode 100644 index 0000000..4cf4eed --- /dev/null +++ b/demo/src/Kernel.php @@ -0,0 +1,13 @@ + + + + + tests + + + + + src + + + diff --git a/src/Autoload/GeneratedClassLoader.php b/src/Autoload/GeneratedClassLoader.php new file mode 100644 index 0000000..d073d8e --- /dev/null +++ b/src/Autoload/GeneratedClassLoader.php @@ -0,0 +1,55 @@ +/Generated/` mirroring its namespace on disk. + * + * Registering this lets the application boot against freshly compiled output + * without a `composer dump-autoload`. In a production build you can instead add + * the equivalent PSR-4 entry to composer.json and disable this loader. + */ +final class GeneratedClassLoader +{ + private const NAMESPACE_PREFIX = 'XPHP\\Generated\\'; + + /** @var array Guards against registering the same root twice. */ + private static array $registered = []; + + public static function register(string $cacheDir): void + { + $root = rtrim($cacheDir, '/\\') . \DIRECTORY_SEPARATOR . 'Generated'; + + if (isset(self::$registered[$root])) { + return; + } + + self::$registered[$root] = true; + + spl_autoload_register(static function (string $class) use ($root): void { + if (!str_starts_with($class, self::NAMESPACE_PREFIX)) { + return; + } + + $relative = substr($class, \strlen(self::NAMESPACE_PREFIX)); + $file = $root . \DIRECTORY_SEPARATOR + . str_replace('\\', \DIRECTORY_SEPARATOR, $relative) . '.php'; + + if (is_file($file)) { + require $file; + } + }); + } + + /** @internal Exposed for tests so each case starts from a clean slate. */ + public static function reset(): void + { + self::$registered = []; + } +} diff --git a/src/CacheWarmer/XphpCacheWarmer.php b/src/CacheWarmer/XphpCacheWarmer.php new file mode 100644 index 0000000..9693309 --- /dev/null +++ b/src/CacheWarmer/XphpCacheWarmer.php @@ -0,0 +1,54 @@ +enabled || !is_dir($this->sourceDir)) { + return []; + } + + // Let CompilationFailedException propagate: a compile error must fail + // the build loudly rather than ship missing/stale generated PHP. + $this->compiler->compile(); + + return []; + } +} diff --git a/src/Command/CompileCommand.php b/src/Command/CompileCommand.php new file mode 100644 index 0000000..1de772b --- /dev/null +++ b/src/Command/CompileCommand.php @@ -0,0 +1,52 @@ +compiler->compile($output); + } catch (CompilationFailedException $e) { + $io->error($e->getMessage()); + + return Command::FAILURE; + } + + if ($summary !== '') { + $io->success($summary); + } else { + $io->success('XPHP compilation complete.'); + } + + return Command::SUCCESS; + } +} diff --git a/src/Compiler/CompilationFailedException.php b/src/Compiler/CompilationFailedException.php new file mode 100644 index 0000000..de8cbab --- /dev/null +++ b/src/Compiler/CompilationFailedException.php @@ -0,0 +1,11 @@ + ` command rather + * than re-wiring the transpiler internals, so it stays stable across xphp + * patch releases while avoiding the cost of shelling out to `vendor/bin/xphp`. + */ +final class XphpCompiler +{ + public function __construct( + private readonly string $sourceDir, + private readonly string $targetDir, + private readonly string $cacheDir, + private readonly ?int $hashLength = null, + ) { + } + + /** + * Compile every `.xphp` file under the configured source directory. + * + * @return string The compiler's human-readable summary line. + * + * @throws CompilationFailedException When the source dir is missing or the compiler reports a failure. + */ + public function compile(?OutputInterface $output = null): string + { + if (!is_dir($this->sourceDir)) { + throw new CompilationFailedException(sprintf('XPHP source directory not found: %s', $this->sourceDir)); + } + + @mkdir($this->targetDir, 0o775, true); + @mkdir($this->cacheDir, 0o775, true); + + $application = new ApplicationConsole( + new NativeFileFinder(), + new NativeFileReader(), + new NativeFileWriter(), + $this->hashLength ?? Registry::DEFAULT_HASH_HEX_LENGTH, + ); + $application->setAutoExit(false); + $application->setCatchExceptions(false); + + $buffer = new BufferedOutput(); + $exitCode = $application->run( + new ArrayInput([ + 'command' => 'compile', + 'source' => $this->sourceDir, + 'target' => $this->targetDir, + 'cache' => $this->cacheDir, + ]), + $output ?? $buffer, + ); + + $summary = trim($output instanceof BufferedOutput ? $output->fetch() : $buffer->fetch()); + + if ($exitCode !== 0) { + throw new CompilationFailedException(sprintf( + 'XPHP compilation failed (exit code %d). %s', + $exitCode, + $summary, + )); + } + + return $summary; + } +} diff --git a/src/XphpBundle.php b/src/XphpBundle.php new file mode 100644 index 0000000..d9505a6 --- /dev/null +++ b/src/XphpBundle.php @@ -0,0 +1,76 @@ +rootNode() + ->children() + ->scalarNode('source') + ->info('Directory that holds your .xphp source files.') + ->defaultValue('%kernel.project_dir%/xphp') + ->end() + ->scalarNode('target') + ->info('Directory the rewritten, vanilla .php files are emitted to.') + ->defaultValue('%kernel.project_dir%/var/xphp/dist') + ->end() + ->scalarNode('cache') + ->info('Directory the generated specialized classes (XPHP\Generated\*) are emitted to.') + ->defaultValue('%kernel.project_dir%/var/xphp/generated') + ->end() + ->integerNode('hash_length') + ->info('Hex length of the hash in specialized class names (16-64). Null leaves the xphp default / XPHP_HASH_LENGTH env untouched.') + ->min(16)->max(64) + ->defaultNull() + ->end() + ->booleanNode('compile_on_warmup') + ->info('Run the compiler automatically during cache:warmup (so a deploy build produces the generated PHP).') + ->defaultTrue() + ->end() + ->booleanNode('register_runtime_autoloader') + ->info('Register an SPL autoloader for the XPHP\Generated\ namespace at boot, so generated classes load without `composer dump-autoload`.') + ->defaultTrue() + ->end() + ->end(); + } + + public function loadExtension(array $config, ContainerConfigurator $container, ContainerBuilder $builder): void + { + $builder->setParameter('xphp.source', $config['source']); + $builder->setParameter('xphp.target', $config['target']); + $builder->setParameter('xphp.cache', $config['cache']); + $builder->setParameter('xphp.hash_length', $config['hash_length']); + $builder->setParameter('xphp.compile_on_warmup', $config['compile_on_warmup']); + $builder->setParameter('xphp.register_runtime_autoloader', $config['register_runtime_autoloader']); + + $container->import(\dirname(__DIR__) . '/config/services.php'); + } + + public function boot(): void + { + if (!$this->container->getParameter('xphp.register_runtime_autoloader')) { + return; + } + + GeneratedClassLoader::register((string) $this->container->getParameter('xphp.cache')); + } +} diff --git a/tests/Autoload/GeneratedClassLoaderTest.php b/tests/Autoload/GeneratedClassLoaderTest.php new file mode 100644 index 0000000..7ac3fae --- /dev/null +++ b/tests/Autoload/GeneratedClassLoaderTest.php @@ -0,0 +1,49 @@ +cacheDir = sys_get_temp_dir() . '/xphp-loader-' . bin2hex(random_bytes(6)); + } + + protected function tearDown(): void + { + GeneratedClassLoader::reset(); + } + + public function testLoadsAGeneratedClassFromTheMirroredNamespacePath(): void + { + // XPHP\Generated\App\Box\T_abc => /Generated/App/Box/T_abc.php + $dir = $this->cacheDir . '/Generated/App/Box'; + mkdir($dir, 0o775, true); + file_put_contents( + $dir . '/T_loadertest.php', + "cacheDir); + + $fqcn = 'XPHP\\Generated\\App\\Box\\T_loadertest'; + self::assertTrue(class_exists($fqcn), 'Generated class should autoload from the cache dir.'); + self::assertSame(42, (new $fqcn())->v); + } + + public function testIgnoresClassesOutsideTheGeneratedNamespace(): void + { + GeneratedClassLoader::register($this->cacheDir); + + // Must not throw or attempt to require anything for unrelated classes. + self::assertFalse(class_exists('App\\SomeRandom\\Service', true)); + } +} From 17ac30572241d0489b18a78cddad069c5c0e80c3 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Wed, 3 Jun 2026 20:59:30 +0000 Subject: [PATCH 2/5] Add opt-in DI registration of generic specializations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Let service-like generics participate in Symfony's container. A new `xphp.services.bindings` config declares which instantiations become services; the bundle resolves each to the transpiler's monomorphized class (via the public Registry::generatedFqn) and registers it, with per-binding autowire/autoconfigure/public/shared/tags over inheritable _defaults. - GenericServiceRegistrar builds TypeRefs from explicit template/args (nested supported) and creates the Definition + optional alias. - Registration happens in XphpBundle::loadExtension so autoconfigure flows through the normal passes; gated on non-empty bindings. - XphpCompiler gains specialization "roots": monomorphization is usage-driven, so a config-only generic (never `new`-ed in source) is bridged with a synthetic, cleaned-up instantiation. Stopgap until xphp can accept roots directly. - Resolve %kernel.project_dir% etc. against the parameter bag before touching the filesystem at build time. Demo: a cache-aside CachedFinder (injecting CacheInterface + a source service) consumed by FindCommand.xphp — an .xphp command that takes CachedFinder as a constructor argument and injects the specialization directly. The generic implements no interface (a T-generic can't honestly implement a User-specific one); only .xphp can name a generic, so the consumer is .xphp. Verified: #[AsCommand] and use statements survive compilation, the ctor type rewrites to the hash, and autowiring resolves it with its real deps. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 68 ++++++++ config/services.php | 1 + demo/README.md | 46 +++++- demo/config/packages/framework.yaml | 5 + demo/config/packages/xphp.yaml | 12 ++ demo/config/services.yaml | 19 ++- demo/src/Command/FindCommand.xphp | 56 +++++++ demo/src/Entity/User.php | 18 +++ demo/src/Finder/SourceInterface.php | 15 ++ demo/src/Finder/UserSource.php | 35 ++++ demo/src/Generic/CachedFinder.xphp | 38 +++++ src/Compiler/XphpCompiler.php | 68 ++++++++ .../GenericServiceRegistrar.php | 128 +++++++++++++++ src/XphpBundle.php | 116 ++++++++++++++ .../GenericServiceRegistrarTest.php | 149 ++++++++++++++++++ 15 files changed, 766 insertions(+), 8 deletions(-) create mode 100644 demo/src/Command/FindCommand.xphp create mode 100644 demo/src/Entity/User.php create mode 100644 demo/src/Finder/SourceInterface.php create mode 100644 demo/src/Finder/UserSource.php create mode 100644 demo/src/Generic/CachedFinder.xphp create mode 100644 src/DependencyInjection/GenericServiceRegistrar.php create mode 100644 tests/DependencyInjection/GenericServiceRegistrarTest.php diff --git a/README.md b/README.md index 69172e4..f9c5ada 100644 --- a/README.md +++ b/README.md @@ -115,6 +115,74 @@ When composer owns the classmap, you can set `register_runtime_autoloader: false `Collection` are monomorphized to concrete classes with native type hints and **zero runtime penalty**. +## Generic services (dependency injection) + +Service-like generics — `Repository`, `CachedFinder`, `Handler` — +can be registered in the container and **autowired**. This is opt-in: declare each +instantiation under `xphp.services.bindings`. Value-type generics (`Box`, +`Collection`) are not meant for DI — keep `new`-ing those. + +```yaml +# config/packages/xphp.yaml +xphp: + services: + _defaults: # inherited by every binding below + autowire: true + autoconfigure: true + public: false + shared: true + bindings: + - template: 'App\Generic\CachedFinder' + args: ['App\Entity\User'] # FQN string, or nested { template, args } + # any of autowire/autoconfigure/public/shared/tags/alias may be set per binding +``` + +The bundle resolves each binding to the class the transpiler monomorphizes it into +(`XPHP\Generated\…\T_`, via the transpiler's own `Registry::generatedFqn`) and +registers it as a service under that class name. + +**The class that consumes the generic must itself be `.xphp`** — only `.xphp` can +name `CachedFinder` as a type. Write that consumer (a service, a command, a +controller) in `.xphp`, register it as a service, and inject the generic directly; +its compiled constructor type *is* the hash class, so autowiring matches with no +seam needed: + +```php +// src/Command/FindCommand.xphp (a .xphp class — register it as a service) +#[AsCommand('app:find')] +final class FindCommand extends Command +{ + public function __construct(private readonly CachedFinder $finder) { // -> the specialization + parent::__construct(); + } +} +``` + +(If a specialization *does* implement a stable interface, set `alias:` on the binding +to that interface so handwritten `.php` can inject it — but a generic implementing a +type-specific interface is usually a smell.) + +Declaring a binding makes compilation run at **container-build time** (so the +generated classes exist for autowiring/autoconfigure), and the container is rebuilt +when any `.xphp` source changes. No separate `xphp:compile` step is needed. + +### Two rules for DI-bound generics + +- **Write collaborator types fully-qualified in the template** (no `use`): + monomorphization fully-qualifies type *parameters* but copies other type + references verbatim, so a short `use`-imported name would resolve into the + generated `XPHP\Generated\…` namespace. Write `\App\Finder\SourceInterface`, not a + `use`d `SourceInterface`. +- **You don't need a call site.** A generic that's only injected is never written as + `new Foo()` anywhere, yet monomorphization is usage-driven. The bundle bridges + this by synthesizing a throwaway instantiation for each declared binding at compile + time (transparent; cleaned up afterwards). *(Stopgap: a future xphp release may + accept specialization roots directly.)* + +> Autowire the specialization (or its alias), not the bare template name — +> `App\Generic\CachedFinder` compiles to an empty marker interface that every +> specialization implements, so it is ambiguous across type arguments. + ## Deploy A typical build step needs nothing xphp-specific beyond what you already run: diff --git a/config/services.php b/config/services.php index cd4224f..95447e2 100644 --- a/config/services.php +++ b/config/services.php @@ -18,6 +18,7 @@ '%xphp.target%', '%xphp.cache%', '%xphp.hash_length%', + '%xphp.di_roots%', ]); $services->set(CompileCommand::class) diff --git a/demo/README.md b/demo/README.md index a0caa62..68698c5 100644 --- a/demo/README.md +++ b/demo/README.md @@ -53,9 +53,49 @@ was monomorphized into — proving it's the generated PHP that actually runs. In a real deploy you'd skip the explicit `xphp:compile`: `bin/console cache:warmup` runs the compiler as part of the standard build (see the bundle README). +## Two patterns, side by side + +The demo shows both ways to use a generic: + +**1. Value type — `app:demo`** (`Box` / `Catalog`). You `new` the generic +inside `.xphp`; handwritten code calls the compiled class. No DI involved. + +**2. Service via DI — `app:find`** (`CachedFinder`). The generic is registered +as a service and **constructor-autowired**, with real service dependencies: + +```bash +bin/console app:find +``` +``` + lookup #1: Ada + lookup #1 again: Ada + source queried: 1 time(s) for 2 lookups (cache-aside works) + [OK] Autowired concrete type: XPHP\Generated\App\Generic\CachedFinder\T_ +``` + +`FindCommand` is **itself an `.xphp` file** — that's what lets it name +`CachedFinder` as a constructor type. Its compiled form injects the +monomorphized class directly (no interface, no alias), and that class's own +`CacheInterface` + `SourceInterface` deps were autowired. No `xphp:compile` needed +first — declaring the binding in `config/packages/xphp.yaml` makes compilation run at +container build. + +Inspect the wiring: +```bash +bin/console debug:container 'App\Command\FindCommand' +# -> ctor arg autowired to XPHP\Generated\App\Generic\CachedFinder\T_ +``` + ## What to look at -- `src/Demo/Catalog.xphp` — the only place that writes `Box`. -- `var/xphp/dist/Demo/Catalog.php` (after compiling) — the same code with the - generic call sites rewritten to concrete classes. +- `src/Demo/Catalog.xphp` — the only place that writes `Box` (value-type path). +- `src/Generic/CachedFinder.xphp` — the generic service. It implements **no** interface + (a `T`-generic class can't honestly implement a `User`-specific one); note its + collaborator types are written fully-qualified (`\App\Finder\SourceInterface`), not `use`d. +- `src/Command/FindCommand.xphp` — a **`.xphp` command** whose constructor takes + `CachedFinder`. Compare it with `var/xphp/dist/Command/FindCommand.php`: the + `#[AsCommand]` attribute and `use` statements survive; only the generic type-hint is + rewritten to the hash class. +- `config/packages/xphp.yaml` — the `services.bindings` entry that turns + `CachedFinder` into an autowireable service. - `src/Models/Plastic.php` — compare before/after a compile; it never changes. diff --git a/demo/config/packages/framework.yaml b/demo/config/packages/framework.yaml index f46f5d8..ece79cb 100644 --- a/demo/config/packages/framework.yaml +++ b/demo/config/packages/framework.yaml @@ -3,3 +3,8 @@ framework: secret: 'xphp-demo-not-secret' http_method_override: false handle_all_throwables: true + # Use the in-memory cache pool so each `app:find` run starts cold and the + # cache-aside demonstration is deterministic. cache.app is what CacheInterface + # autowires to. + cache: + app: cache.adapter.array diff --git a/demo/config/packages/xphp.yaml b/demo/config/packages/xphp.yaml index 3aa70b4..345f915 100644 --- a/demo/config/packages/xphp.yaml +++ b/demo/config/packages/xphp.yaml @@ -5,3 +5,15 @@ xphp: # target/cache keep their defaults: # target: '%kernel.project_dir%/var/xphp/dist' (rewritten vanilla PHP) # cache: '%kernel.project_dir%/var/xphp/generated' (XPHP\Generated\* classes) + + # Opt-in: register a generic specialization as a Symfony service. Declaring a + # binding makes the bundle compile + register at container-build time, so the + # service is available for autowiring (no separate xphp:compile step needed). + services: + bindings: + # CachedFinder -> XPHP\Generated\App\Generic\CachedFinder\T_ + # Registered under its class hash; the .xphp FindCommand injects + # CachedFinder directly, whose compiled type IS that hash — so it + # autowires with no alias needed. + - template: 'App\Generic\CachedFinder' + args: ['App\Entity\User'] diff --git a/demo/config/services.yaml b/demo/config/services.yaml index 5a408ee..9ad8070 100644 --- a/demo/config/services.yaml +++ b/demo/config/services.yaml @@ -3,8 +3,17 @@ services: autowire: true autoconfigure: true - # Register ONLY the command directory as services. We deliberately do not glob - # the whole of src/, because src/ also holds .xphp sources and the compiled - # domain classes (Box, Catalog) — none of which should be containerized. - App\Command\: - resource: '../src/Command/' + # Commands are registered explicitly (not via a src/Command/ glob): that dir now + # mixes DemoCommand.php (handwritten) with FindCommand.xphp (compiled), and the + # autodiscovery glob must not touch .xphp sources. autoconfigure tags each as a + # console.command (via #[AsCommand] / Command subclass). + App\Command\DemoCommand: ~ + App\Command\FindCommand: ~ + + # Handwritten collaborators (.php-only dir — safe to glob). + App\Finder\: + resource: '../src/Finder/' + + # The cached finder's backing source: bind the interface to its single impl so + # the generic CachedFinder specialization can autowire SourceInterface. + App\Finder\SourceInterface: '@App\Finder\UserSource' diff --git a/demo/src/Command/FindCommand.xphp b/demo/src/Command/FindCommand.xphp new file mode 100644 index 0000000..d55a841 --- /dev/null +++ b/demo/src/Command/FindCommand.xphp @@ -0,0 +1,56 @@ +` + * as a constructor type (illegal in plain PHP). It's a non-generic class, so the + * compiler leaves it almost untouched: `use` statements and the #[AsCommand] + * attribute are preserved; only the `CachedFinder` type-hint is rewritten to + * the monomorphized class. The DI container autowires that hash type to the service + * the bundle registered for the `CachedFinder` binding. + */ +#[AsCommand( + name: 'app:find', + description: 'Inject a generic CachedFinder specialization via DI.', +)] +final class FindCommand extends Command +{ + public function __construct( + private readonly CachedFinder $finder, + private readonly UserSource $source, + ) { + parent::__construct(); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $io->title('Cache-aside finder (compiled from src/Generic/CachedFinder.xphp)'); + + $first = $this->finder->get(1); + $again = $this->finder->get(1); + + $io->writeln(' lookup #1: ' . ($first?->name ?? 'null')); + $io->writeln(' lookup #1 again: ' . ($again?->name ?? 'null')); + $io->writeln(sprintf( + ' source queried: %d time(s) for 2 lookups (cache-aside works)', + $this->source->calls, + )); + $io->newLine(); + $io->success('Autowired concrete type: ' . $this->finder::class); + + return Command::SUCCESS; + } +} diff --git a/demo/src/Entity/User.php b/demo/src/Entity/User.php new file mode 100644 index 0000000..8dcf634 --- /dev/null +++ b/demo/src/Entity/User.php @@ -0,0 +1,18 @@ + is bound as a service (CachedFinder). + */ +final class User +{ + public function __construct( + public int $id, + public string $name, + ) { + } +} diff --git a/demo/src/Finder/SourceInterface.php b/demo/src/Finder/SourceInterface.php new file mode 100644 index 0000000..e15ab51 --- /dev/null +++ b/demo/src/Finder/SourceInterface.php @@ -0,0 +1,15 @@ + depends on it without binding + * to any one entity type. + */ +interface SourceInterface +{ + public function load(int $id): ?object; +} diff --git a/demo/src/Finder/UserSource.php b/demo/src/Finder/UserSource.php new file mode 100644 index 0000000..9efa017 --- /dev/null +++ b/demo/src/Finder/UserSource.php @@ -0,0 +1,35 @@ + + * specialization. It counts its calls so the demo can prove the cache-aside path: + * two finder lookups of the same id query the source only once. + */ +final class UserSource implements SourceInterface +{ + public int $calls = 0; + + /** @var array */ + private array $users; + + public function __construct() + { + $this->users = [ + 1 => new User(1, 'Ada'), + 2 => new User(2, 'Linus'), + ]; + } + + public function load(int $id): ?object + { + ++$this->calls; + + return $this->users[$id] ?? null; + } +} diff --git a/demo/src/Generic/CachedFinder.xphp b/demo/src/Generic/CachedFinder.xphp new file mode 100644 index 0000000..bde81cd --- /dev/null +++ b/demo/src/Generic/CachedFinder.xphp @@ -0,0 +1,38 @@ +` flows through the public + * `get(): ?T` return type, giving each specialization a precise return type. + * + * It implements no interface: a generic class can't honestly implement a + * type-specific one. Code that wants a `CachedFinder` injected is itself + * `.xphp` (only `.xphp` can name a generic) and receives the specialization + * directly — see src/Command/FindCommand.xphp. + * + * Collaborator types are written fully-qualified (no `use`): monomorphization + * fully-qualifies type parameters but copies other type references verbatim, so a + * short `use`-imported name would resolve into the generated XPHP\Generated\… + * namespace instead of here. + */ +final class CachedFinder +{ + public function __construct( + private \Symfony\Contracts\Cache\CacheInterface $cache, + private \App\Finder\SourceInterface $source, + ) { + } + + public function get(int $id): ?T + { + return $this->cache->get( + 'find_' . $id, + fn () => $this->source->load($id), + ); + } +} diff --git a/src/Compiler/XphpCompiler.php b/src/Compiler/XphpCompiler.php index e9c43c5..cb68aa5 100644 --- a/src/Compiler/XphpCompiler.php +++ b/src/Compiler/XphpCompiler.php @@ -22,11 +22,24 @@ */ final class XphpCompiler { + /** + * Directory (under the source tree) the synthetic "specialization roots" file is + * written to. Named with a leading marker so it's obvious it is bundle-generated. + */ + private const ROOTS_DIR = '__xphp_di_roots__'; + + /** + * @param list $roots Generic-instantiation expressions (e.g. + * "App\Generic\CachedFinder") that must be specialized even + * though no source code instantiates them — they back config-declared DI + * services. See {@see writeRootsFile()} for why this is needed. + */ public function __construct( private readonly string $sourceDir, private readonly string $targetDir, private readonly string $cacheDir, private readonly ?int $hashLength = null, + private readonly array $roots = [], ) { } @@ -46,6 +59,17 @@ public function compile(?OutputInterface $output = null): string @mkdir($this->targetDir, 0o775, true); @mkdir($this->cacheDir, 0o775, true); + $this->writeRootsFile(); + + try { + return $this->runCompiler($output); + } finally { + $this->removeRootsArtifacts(); + } + } + + private function runCompiler(?OutputInterface $output): string + { $application = new ApplicationConsole( new NativeFileFinder(), new NativeFileReader(), @@ -78,4 +102,48 @@ public function compile(?OutputInterface $output = null): string return $summary; } + + /** + * Monomorphization is usage-driven: the transpiler only emits a `T_` + * specialization where source actually instantiates `Foo`. A generic that is + * only declared as a DI service (and injected via an interface) is never written + * as `new Foo()` anywhere, so its specialization would never be generated. + * + * As a stopgap we synthesize a throwaway `.xphp` file containing one + * `new Template();` per declared root, dropped into the source set so the + * transpiler treats it as a usage. The rewritten copy that lands in the target dir + * is dead code (it is never autoloaded — the file declares no class) and is removed + * by {@see removeRootsArtifacts()} along with the source file. + */ + private function writeRootsFile(): void + { + if ($this->roots === []) { + return; + } + + $dir = $this->sourceDir . \DIRECTORY_SEPARATOR . self::ROOTS_DIR; + @mkdir($dir, 0o775, true); + + $lines = ['roots as $root) { + $lines[] = sprintf('new %s();', $root); + } + + file_put_contents($dir . \DIRECTORY_SEPARATOR . 'roots.xphp', implode("\n", $lines) . "\n"); + } + + private function removeRootsArtifacts(): void + { + if ($this->roots === []) { + return; + } + + foreach ([$this->sourceDir, $this->targetDir] as $base) { + $dir = $base . \DIRECTORY_SEPARATOR . self::ROOTS_DIR; + foreach (['roots.xphp', 'roots.php'] as $file) { + @unlink($dir . \DIRECTORY_SEPARATOR . $file); + } + @rmdir($dir); + } + } } diff --git a/src/DependencyInjection/GenericServiceRegistrar.php b/src/DependencyInjection/GenericServiceRegistrar.php new file mode 100644 index 0000000..1e14b32 --- /dev/null +++ b/src/DependencyInjection/GenericServiceRegistrar.php @@ -0,0 +1,128 @@ +`) is resolved to the concrete + * class the transpiler monomorphizes it into — `XPHP\Generated\…\T_` — via + * the transpiler's own public {@see Registry::generatedFqn()}, so the service id + * always matches what compilation actually emits. A binding may also expose a + * stable alias (typically a handwritten interface the specialization implements) + * so consumers can autowire it without ever naming the hash. + * + * Per-binding `autowire`/`autoconfigure`/`public`/`shared`/`tags` are honored, + * falling back to the `_defaults` block — nothing is hard-coded here. + */ +final class GenericServiceRegistrar +{ + /** + * @param int $hashLength must equal the hash length used at compile time, or the + * resolved service ids won't match the emitted class names + */ + public function __construct(private readonly int $hashLength) + { + } + + /** + * @param array{ + * _defaults: array{autowire: bool, autoconfigure: bool, public: bool, shared: bool}, + * bindings: list>, + * alias: string|null, + * autowire: bool|null, + * autoconfigure: bool|null, + * public: bool|null, + * shared: bool|null, + * tags: list, + * }> + * } $config + */ + public function register(ContainerBuilder $container, array $config): void + { + $defaults = $config['_defaults']; + + foreach ($config['bindings'] as $binding) { + $this->registerBinding($container, $binding, $defaults); + } + } + + /** + * Canonical generic-instantiation expressions for the declared bindings, e.g. + * `App\Generic\CachedFinder`. These feed the compiler's synthetic + * "specialization roots" so a config-only generic (never instantiated in source) + * still gets its specialized class emitted. See {@see XphpCompiler::writeRootsFile()}. + * + * @param list> $bindings + * + * @return list + */ + public static function rootExpressions(array $bindings): array + { + return array_map( + static fn (array $binding): string => self::toTypeRef([ + 'template' => $binding['template'], + 'args' => $binding['args'], + ])->canonical(), + $bindings, + ); + } + + /** + * @param array $binding + * @param array{autowire: bool, autoconfigure: bool, public: bool, shared: bool} $defaults + */ + private function registerBinding(ContainerBuilder $container, array $binding, array $defaults): void + { + $fqcn = Registry::generatedFqn( + ltrim((string) $binding['template'], '\\'), + array_map(self::toTypeRef(...), $binding['args']), + $this->hashLength, + ); + + $public = $binding['public'] ?? $defaults['public']; + + $definition = new Definition($fqcn); + $definition->setAutowired($binding['autowire'] ?? $defaults['autowire']); + $definition->setAutoconfigured($binding['autoconfigure'] ?? $defaults['autoconfigure']); + $definition->setPublic($public); + $definition->setShared($binding['shared'] ?? $defaults['shared']); + + foreach ($binding['tags'] as $tag) { + $definition->addTag($tag); + } + + $container->setDefinition($fqcn, $definition); + + if ($binding['alias'] !== null) { + $container->setAlias($binding['alias'], $fqcn)->setPublic($public); + } + } + + /** + * Turn an explicit arg — an FQN string, or a nested `{template, args}` map for a + * generic argument — into the transpiler's recursive {@see TypeRef} shape. + * + * @param string|array $arg + */ + private static function toTypeRef(string|array $arg): TypeRef + { + if (\is_string($arg)) { + return new TypeRef(ltrim($arg, '\\')); + } + + return new TypeRef( + ltrim((string) $arg['template'], '\\'), + array_map(self::toTypeRef(...), $arg['args'] ?? []), + ); + } +} diff --git a/src/XphpBundle.php b/src/XphpBundle.php index d9505a6..3f77568 100644 --- a/src/XphpBundle.php +++ b/src/XphpBundle.php @@ -4,11 +4,18 @@ namespace Xphp\SymfonyBundle; +use Symfony\Component\Config\Definition\Builder\ArrayNodeDefinition; +use Symfony\Component\Config\Definition\Builder\NodeDefinition; +use Symfony\Component\Config\Definition\Builder\TreeBuilder; use Symfony\Component\Config\Definition\Configurator\DefinitionConfigurator; +use Symfony\Component\Config\Resource\DirectoryResource; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator; use Symfony\Component\HttpKernel\Bundle\AbstractBundle; use Xphp\SymfonyBundle\Autoload\GeneratedClassLoader; +use Xphp\SymfonyBundle\Compiler\XphpCompiler; +use Xphp\SymfonyBundle\DependencyInjection\GenericServiceRegistrar; +use XPHP\Transpiler\Monomorphize\Registry; /** * Lets a Symfony application keep `.xphp` sources in the repo, compile them to @@ -50,9 +57,69 @@ public function configure(DefinitionConfigurator $definition): void ->info('Register an SPL autoloader for the XPHP\Generated\ namespace at boot, so generated classes load without `composer dump-autoload`.') ->defaultTrue() ->end() + ->append($this->servicesNode()) ->end(); } + /** + * The `services` subtree: opt-in registration of generic specializations as DI + * services. `_defaults` are inherited by every binding; a binding may override + * any flag (null = inherit). + */ + private function servicesNode(): NodeDefinition + { + $tree = new TreeBuilder('services'); + /** @var ArrayNodeDefinition $root */ + $root = $tree->getRootNode(); + + // @phpstan-ignore-next-line — the fluent node builder is dynamically typed. + $root + ->info('Register xphp generic specializations (e.g. CachedFinder) as Symfony services.') + ->addDefaultsIfNotSet() + ->children() + ->arrayNode('_defaults') + ->addDefaultsIfNotSet() + ->children() + ->booleanNode('autowire')->defaultTrue()->end() + ->booleanNode('autoconfigure')->defaultTrue()->end() + ->booleanNode('public')->defaultFalse()->end() + ->booleanNode('shared')->defaultTrue()->end() + ->end() + ->end() + ->arrayNode('bindings') + ->info('Each entry maps a generic instantiation to a DI service (and optional alias).') + ->arrayPrototype() + ->children() + ->scalarNode('template') + ->info('FQN of the generic template, e.g. App\Generic\CachedFinder.') + ->isRequired()->cannotBeEmpty() + ->end() + ->arrayNode('args') + ->info('Type arguments: each is an FQN string, or a nested { template, args } map for a generic argument.') + ->variablePrototype()->end() + ->defaultValue([]) + ->end() + ->scalarNode('alias') + ->info('Optional service id/interface to alias to the specialization (the autowiring seam).') + ->defaultNull() + ->end() + ->booleanNode('autowire')->defaultNull()->end() + ->booleanNode('autoconfigure')->defaultNull()->end() + ->booleanNode('public')->defaultNull()->end() + ->booleanNode('shared')->defaultNull()->end() + ->arrayNode('tags') + ->scalarPrototype()->end() + ->defaultValue([]) + ->end() + ->end() + ->end() + ->defaultValue([]) + ->end() + ->end(); + + return $root; + } + public function loadExtension(array $config, ContainerConfigurator $container, ContainerBuilder $builder): void { $builder->setParameter('xphp.source', $config['source']); @@ -62,7 +129,56 @@ public function loadExtension(array $config, ContainerConfigurator $container, C $builder->setParameter('xphp.compile_on_warmup', $config['compile_on_warmup']); $builder->setParameter('xphp.register_runtime_autoloader', $config['register_runtime_autoloader']); + // Specialization roots for the declared DI bindings (empty when none). The + // compiler uses these to emit specializations for generics that are only + // referenced from config, never instantiated in source. + $roots = GenericServiceRegistrar::rootExpressions($config['services']['bindings']); + $builder->setParameter('xphp.di_roots', $roots); + $container->import(\dirname(__DIR__) . '/config/services.php'); + + if ($config['services']['bindings'] !== []) { + $this->registerGenericServices($config, $builder, $roots); + } + } + + /** + * Opt-in path: turn declared generic instantiations into DI services. + * + * This runs at extension-load time (before any compiler pass) on purpose: the + * `autoconfigure` flag is consumed by ResolveInstanceofConditionalsPass, which runs + * early, so a definition added by a late pass would have its autoconfigure silently + * dropped. Registering here makes the definitions behave exactly like services.yaml + * entries. It is gated on a non-empty `bindings` list, so apps that don't use DI for + * generics pay nothing. + * + * @param array $config + * @param list $roots + */ + private function registerGenericServices(array $config, ContainerBuilder $builder, array $roots): void + { + $hashLength = $config['hash_length'] ?? Registry::DEFAULT_HASH_HEX_LENGTH; + + // Config values still contain %kernel.project_dir% etc. — the container resolves + // those later, but we touch the filesystem NOW, so resolve them by hand against the + // parameter bag (kernel.* params are already set before extensions load). + $bag = $builder->getParameterBag(); + $source = (string) $bag->resolveValue($config['source']); + $target = (string) $bag->resolveValue($config['target']); + $cache = (string) $bag->resolveValue($config['cache']); + + // Compile now so the generated classes exist on disk and are reflectable while the + // container is being built (autowiring + autoconfigure both need to load them). + (new XphpCompiler($source, $target, $cache, $config['hash_length'], $roots))->compile(); + GeneratedClassLoader::register($cache); + + (new GenericServiceRegistrar($hashLength))->register($builder, $config['services']); + + // Rebuild the container whenever a .xphp source changes, so the registered + // specializations stay in sync with the code. + if (is_dir($source)) { + $builder->addResource(new DirectoryResource($source, '/\.xphp$/')); + } } public function boot(): void diff --git a/tests/DependencyInjection/GenericServiceRegistrarTest.php b/tests/DependencyInjection/GenericServiceRegistrarTest.php new file mode 100644 index 0000000..3d58b49 --- /dev/null +++ b/tests/DependencyInjection/GenericServiceRegistrarTest.php @@ -0,0 +1,149 @@ + $binding + * @param array{autowire?: bool, autoconfigure?: bool, public?: bool, shared?: bool} $defaults + */ + private function register(array $binding, array $defaults = []): ContainerBuilder + { + $container = new ContainerBuilder(); + + (new GenericServiceRegistrar(self::HASH_LENGTH))->register($container, [ + '_defaults' => $defaults + [ + 'autowire' => true, + 'autoconfigure' => true, + 'public' => false, + 'shared' => true, + ], + 'bindings' => [$binding + [ + 'args' => [], + 'alias' => null, + 'autowire' => null, + 'autoconfigure' => null, + 'public' => null, + 'shared' => null, + 'tags' => [], + ]], + ]); + + return $container; + } + + public function testServiceIdMatchesTheTranspilersGeneratedFqn(): void + { + $container = $this->register([ + 'template' => 'App\\Generic\\CachedFinder', + 'args' => ['App\\Entity\\User'], + ]); + + $expected = Registry::generatedFqn( + 'App\\Generic\\CachedFinder', + [new TypeRef('App\\Entity\\User')], + self::HASH_LENGTH, + ); + + self::assertTrue($container->hasDefinition($expected)); + } + + public function testNestedGenericArgResolvesToTheSameFqnAsNestedTypeRefs(): void + { + $container = $this->register([ + 'template' => 'App\\Box', + 'args' => [ + ['template' => 'App\\Lst', 'args' => ['App\\Plastic']], + ], + ]); + + $expected = Registry::generatedFqn( + 'App\\Box', + [new TypeRef('App\\Lst', [new TypeRef('App\\Plastic')])], + self::HASH_LENGTH, + ); + + self::assertTrue($container->hasDefinition($expected)); + } + + public function testLeadingBackslashesDoNotChangeTheResolvedId(): void + { + $container = $this->register([ + 'template' => '\\App\\Generic\\CachedFinder', + 'args' => ['\\App\\Entity\\User'], + ]); + + $expected = Registry::generatedFqn( + 'App\\Generic\\CachedFinder', + [new TypeRef('App\\Entity\\User')], + self::HASH_LENGTH, + ); + + self::assertTrue($container->hasDefinition($expected)); + } + + public function testFlagsAndTagsAreAppliedFromDefaults(): void + { + $container = $this->register([ + 'template' => 'App\\Generic\\CachedFinder', + 'args' => ['App\\Entity\\User'], + 'tags' => ['app.finder'], + ]); + + $id = Registry::generatedFqn('App\\Generic\\CachedFinder', [new TypeRef('App\\Entity\\User')], self::HASH_LENGTH); + $definition = $container->getDefinition($id); + + self::assertTrue($definition->isAutowired()); + self::assertTrue($definition->isAutoconfigured()); + self::assertFalse($definition->isPublic()); + self::assertTrue($definition->isShared()); + self::assertArrayHasKey('app.finder', $definition->getTags()); + } + + public function testPerBindingFlagsOverrideDefaults(): void + { + $container = $this->register([ + 'template' => 'App\\Generic\\CachedFinder', + 'args' => ['App\\Entity\\User'], + 'autoconfigure' => false, + 'shared' => false, + 'public' => true, + ]); + + $id = Registry::generatedFqn('App\\Generic\\CachedFinder', [new TypeRef('App\\Entity\\User')], self::HASH_LENGTH); + $definition = $container->getDefinition($id); + + self::assertTrue($definition->isAutowired()); // inherited from _defaults + self::assertFalse($definition->isAutoconfigured()); // overridden + self::assertFalse($definition->isShared()); // overridden + self::assertTrue($definition->isPublic()); // overridden + } + + public function testAliasPointsAtTheSpecialization(): void + { + $container = $this->register([ + 'template' => 'App\\Generic\\CachedFinder', + 'args' => ['App\\Entity\\User'], + 'alias' => 'App\\Finder\\UserFinderInterface', + 'public' => true, + ]); + + $id = Registry::generatedFqn('App\\Generic\\CachedFinder', [new TypeRef('App\\Entity\\User')], self::HASH_LENGTH); + + self::assertTrue($container->hasAlias('App\\Finder\\UserFinderInterface')); + $alias = $container->getAlias('App\\Finder\\UserFinderInterface'); + self::assertSame($id, (string) $alias); + self::assertTrue($alias->isPublic()); + } +} From 3ceb93adf2b7622fd3436e24a280809d0ff3da8f Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Wed, 3 Jun 2026 21:28:50 +0000 Subject: [PATCH 3/5] Reach 100% covered MSI across the bundle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously only the two unit-tested files were in Infection's scope (84% covered MSI over a small slice). Bring the whole bundle under test and mutation coverage. Tests added: - XphpBundleTest: configure() defaults + bounds, loadExtension param/ service wiring, the DI binding registration (service id, di_roots, resolved-path compile, source resource), build-time autoloader registration, and boot(). - XphpCompilerTest: compile output, synthetic-roots generation + cleanup, missing-source and transpiler-error paths. - XphpCacheWarmerTest, CompileCommandTest: enabled/disabled/missing-source and success/failure paths. - TempProjectTrait: throwaway .xphp project fixtures. Source refinements (remove behavior-invisible mutants instead of masking): - GeneratedClassLoader: track registered roots as a list (the boolean marker value was never read). - GenericServiceRegistrar: drop redundant ltrim() — Registry::generatedFqn and TypeRef::canonical already strip leading backslashes. - XphpCompiler: always buffer; setCatchExceptions(true) so any transpiler error becomes a uniform CompilationFailedException (also testable); drop the unused output-streaming param. - CompileCommand: drop the unreachable empty-summary branch. infection.json5 documents the remaining ignores — all provably invisible: mkdir() permission bits, the redundant explicit setCatchExceptions(true), the synthetic file's exact byte layout, the (string) casts on resolveValue(), and the empty-roots cleanup guard. Covered MSI 100% (was 84%); Makefile gate raised to --min-msi=100. Co-Authored-By: Claude Opus 4.8 (1M context) --- Makefile | 2 +- infection.json5 | 32 ++- src/Autoload/GeneratedClassLoader.php | 6 +- src/Command/CompileCommand.php | 8 +- src/Compiler/XphpCompiler.php | 15 +- .../GenericServiceRegistrar.php | 10 +- tests/Autoload/GeneratedClassLoaderTest.php | 23 +- tests/CacheWarmer/XphpCacheWarmerTest.php | 61 +++++ tests/Command/CompileCommandTest.php | 47 ++++ tests/Compiler/XphpCompilerTest.php | 104 +++++++++ .../GenericServiceRegistrarTest.php | 11 +- tests/Support/TempProjectTrait.php | 74 ++++++ tests/XphpBundleTest.php | 214 ++++++++++++++++++ 13 files changed, 581 insertions(+), 26 deletions(-) create mode 100644 tests/CacheWarmer/XphpCacheWarmerTest.php create mode 100644 tests/Command/CompileCommandTest.php create mode 100644 tests/Compiler/XphpCompilerTest.php create mode 100644 tests/Support/TempProjectTrait.php create mode 100644 tests/XphpBundleTest.php diff --git a/Makefile b/Makefile index a890f8b..262387d 100644 --- a/Makefile +++ b/Makefile @@ -7,4 +7,4 @@ test: install XDEBUG_MODE=coverage vendor/bin/phpunit test/mutation: install - XDEBUG_MODE=off vendor/bin/infection --threads=max --min-msi=80 --min-covered-msi=80 + XDEBUG_MODE=off vendor/bin/infection --threads=max --min-msi=100 --min-covered-msi=100 diff --git a/infection.json5 b/infection.json5 index e7287df..0af7164 100644 --- a/infection.json5 +++ b/infection.json5 @@ -6,7 +6,37 @@ ] }, "mutators": { - "@default": true + "@default": true, + + // --- Documented ignores: mutants that are provably behavior-invisible. --- + + // The 0o775 mode passed to mkdir() only affects directory permission bits, which + // nothing observes or asserts. (Targets the mkdir() calls only; the min(16)/max(64) + // bounds in the config tree are still mutated and killed.) + // (Regexes are anchored by Infection to a whole removed diff line: ^-\s*$, + // hence the surrounding .* — they target one specific line each.) + "IncrementInteger": { "ignoreSourceCodeByRegex": [".*0o775.*"] }, + "DecrementInteger": { "ignoreSourceCodeByRegex": [".*0o775.*"] }, + + // Symfony\Component\Console\Application already defaults catchExceptions to true; the + // explicit call documents intent, so removing it is a no-op. (Other MethodCallRemoval + // mutants — the services.php import, addResource, the autoloader register — are killed.) + "MethodCallRemoval": { "ignoreSourceCodeByRegex": [".*setCatchExceptions.*"] }, + + // The synthetic roots file's exact byte layout (trailing newline / line glue) is + // irrelevant: it only has to be valid PHP that triggers specialization, which + // XphpCompilerTest asserts via the generated class plus full artifact cleanup. + "Concat": { "ignoreSourceCodeByRegex": [".*implode.*"] }, + "ConcatOperandRemoval": { "ignoreSourceCodeByRegex": [".*implode.*"] }, + + // These (string) casts satisfy the type system only: ParameterBag::resolveValue() + // returns a string for the source/target/cache path params at runtime, so dropping + // the cast changes no behavior. + "CastString": { "ignoreSourceCodeByRegex": [".*resolveValue.*"] }, + + // Early-return guard in removeRootsArtifacts(): with no roots there is nothing to + // clean, and the @unlink/@rmdir it guards are already no-ops on absent paths. + "ReturnRemoval": { "ignore": ["Xphp\\SymfonyBundle\\Compiler\\XphpCompiler::removeRootsArtifacts"] } }, "logs": { "text": "var/infection.log" diff --git a/src/Autoload/GeneratedClassLoader.php b/src/Autoload/GeneratedClassLoader.php index d073d8e..f9bd6ce 100644 --- a/src/Autoload/GeneratedClassLoader.php +++ b/src/Autoload/GeneratedClassLoader.php @@ -19,18 +19,18 @@ final class GeneratedClassLoader { private const NAMESPACE_PREFIX = 'XPHP\\Generated\\'; - /** @var array Guards against registering the same root twice. */ + /** @var list Roots already registered, so we never register the same one twice. */ private static array $registered = []; public static function register(string $cacheDir): void { $root = rtrim($cacheDir, '/\\') . \DIRECTORY_SEPARATOR . 'Generated'; - if (isset(self::$registered[$root])) { + if (\in_array($root, self::$registered, true)) { return; } - self::$registered[$root] = true; + self::$registered[] = $root; spl_autoload_register(static function (string $class) use ($root): void { if (!str_starts_with($class, self::NAMESPACE_PREFIX)) { diff --git a/src/Command/CompileCommand.php b/src/Command/CompileCommand.php index 1de772b..aa796fc 100644 --- a/src/Command/CompileCommand.php +++ b/src/Command/CompileCommand.php @@ -34,18 +34,14 @@ protected function execute(InputInterface $input, OutputInterface $output): int $io = new SymfonyStyle($input, $output); try { - $summary = $this->compiler->compile($output); + $summary = $this->compiler->compile(); } catch (CompilationFailedException $e) { $io->error($e->getMessage()); return Command::FAILURE; } - if ($summary !== '') { - $io->success($summary); - } else { - $io->success('XPHP compilation complete.'); - } + $io->success($summary); return Command::SUCCESS; } diff --git a/src/Compiler/XphpCompiler.php b/src/Compiler/XphpCompiler.php index cb68aa5..b133fc6 100644 --- a/src/Compiler/XphpCompiler.php +++ b/src/Compiler/XphpCompiler.php @@ -6,7 +6,6 @@ use Symfony\Component\Console\Input\ArrayInput; use Symfony\Component\Console\Output\BufferedOutput; -use Symfony\Component\Console\Output\OutputInterface; use XPHP\Console\ApplicationConsole; use XPHP\FileSystem\FileFinder\NativeFileFinder; use XPHP\FileSystem\FileReader\NativeFileReader; @@ -50,7 +49,7 @@ public function __construct( * * @throws CompilationFailedException When the source dir is missing or the compiler reports a failure. */ - public function compile(?OutputInterface $output = null): string + public function compile(): string { if (!is_dir($this->sourceDir)) { throw new CompilationFailedException(sprintf('XPHP source directory not found: %s', $this->sourceDir)); @@ -62,13 +61,13 @@ public function compile(?OutputInterface $output = null): string $this->writeRootsFile(); try { - return $this->runCompiler($output); + return $this->runCompiler(); } finally { $this->removeRootsArtifacts(); } } - private function runCompiler(?OutputInterface $output): string + private function runCompiler(): string { $application = new ApplicationConsole( new NativeFileFinder(), @@ -77,7 +76,9 @@ private function runCompiler(?OutputInterface $output): string $this->hashLength ?? Registry::DEFAULT_HASH_HEX_LENGTH, ); $application->setAutoExit(false); - $application->setCatchExceptions(false); + // Catch exceptions so any transpiler error surfaces as a non-zero exit code that we + // wrap below — callers get a uniform CompilationFailedException, never a raw throwable. + $application->setCatchExceptions(true); $buffer = new BufferedOutput(); $exitCode = $application->run( @@ -87,10 +88,10 @@ private function runCompiler(?OutputInterface $output): string 'target' => $this->targetDir, 'cache' => $this->cacheDir, ]), - $output ?? $buffer, + $buffer, ); - $summary = trim($output instanceof BufferedOutput ? $output->fetch() : $buffer->fetch()); + $summary = trim($buffer->fetch()); if ($exitCode !== 0) { throw new CompilationFailedException(sprintf( diff --git a/src/DependencyInjection/GenericServiceRegistrar.php b/src/DependencyInjection/GenericServiceRegistrar.php index 1e14b32..43b41a2 100644 --- a/src/DependencyInjection/GenericServiceRegistrar.php +++ b/src/DependencyInjection/GenericServiceRegistrar.php @@ -83,8 +83,10 @@ public static function rootExpressions(array $bindings): array */ private function registerBinding(ContainerBuilder $container, array $binding, array $defaults): void { + // No need to strip a leading "\": Registry::generatedFqn() ltrims the template + // and TypeRef::canonical() ltrims each arg, so "\App\X" and "App\X" hash alike. $fqcn = Registry::generatedFqn( - ltrim((string) $binding['template'], '\\'), + (string) $binding['template'], array_map(self::toTypeRef(...), $binding['args']), $this->hashLength, ); @@ -116,12 +118,14 @@ private function registerBinding(ContainerBuilder $container, array $binding, ar */ private static function toTypeRef(string|array $arg): TypeRef { + // Leading backslashes are normalized downstream by TypeRef::canonical(), so we + // don't strip them here (see registerBinding()). if (\is_string($arg)) { - return new TypeRef(ltrim($arg, '\\')); + return new TypeRef($arg); } return new TypeRef( - ltrim((string) $arg['template'], '\\'), + (string) $arg['template'], array_map(self::toTypeRef(...), $arg['args'] ?? []), ); } diff --git a/tests/Autoload/GeneratedClassLoaderTest.php b/tests/Autoload/GeneratedClassLoaderTest.php index 7ac3fae..29a4c07 100644 --- a/tests/Autoload/GeneratedClassLoaderTest.php +++ b/tests/Autoload/GeneratedClassLoaderTest.php @@ -41,9 +41,30 @@ public function testLoadsAGeneratedClassFromTheMirroredNamespacePath(): void public function testIgnoresClassesOutsideTheGeneratedNamespace(): void { + // Place a file exactly where the loader WOULD look if it stripped the prefix + // off a non-matching class (`XPHP\GeneratedX\App\Box\T_x` -> .../App/Box/T_x.php). + // The namespace guard must prevent that file from ever being required. + $dir = $this->cacheDir . '/Generated/App/Box'; + mkdir($dir, 0o775, true); + file_put_contents( + $dir . '/T_outside.php', + "cacheDir); - // Must not throw or attempt to require anything for unrelated classes. + // Same length/path shape as a real generated class, but the prefix doesn't match. + self::assertFalse(class_exists('XPHP\\GeneratedX\\App\\Box\\T_outside', true)); self::assertFalse(class_exists('App\\SomeRandom\\Service', true)); } + + public function testTrailingSlashIsNormalizedSoTheSameRootIsRegisteredOnce(): void + { + $before = \count(spl_autoload_functions() ?: []); + + GeneratedClassLoader::register($this->cacheDir); + GeneratedClassLoader::register($this->cacheDir . '/'); // same root after rtrim + + self::assertCount($before + 1, spl_autoload_functions() ?: [], 'duplicate root must not re-register'); + } } diff --git a/tests/CacheWarmer/XphpCacheWarmerTest.php b/tests/CacheWarmer/XphpCacheWarmerTest.php new file mode 100644 index 0000000..ae92677 --- /dev/null +++ b/tests/CacheWarmer/XphpCacheWarmerTest.php @@ -0,0 +1,61 @@ +removeTempProjects(); + } + + public function testIsMandatorySoCacheWarmupCannotSkipIt(): void + { + $warmer = new XphpCacheWarmer($this->compilerFor('/x', '/x'), true, '/x'); + + self::assertFalse($warmer->isOptional()); + } + + public function testWarmUpCompilesWhenEnabledAndSourceExists(): void + { + $root = $this->makeProject(); + $warmer = new XphpCacheWarmer($this->compilerFor($root . '/xphp', $root), true, $root . '/xphp'); + + $preloaded = $warmer->warmUp($root . '/cache'); + + self::assertSame([], $preloaded); + self::assertFileExists($root . '/dist/Containers/Box.php', 'warmup ran the compiler'); + } + + public function testWarmUpDoesNothingWhenDisabled(): void + { + $root = $this->makeProject(); + $warmer = new XphpCacheWarmer($this->compilerFor($root . '/xphp', $root), false, $root . '/xphp'); + + self::assertSame([], $warmer->warmUp($root . '/cache')); + self::assertFileDoesNotExist($root . '/dist/Containers/Box.php', 'disabled warmer must not compile'); + } + + public function testWarmUpDoesNothingWhenSourceDirIsMissing(): void + { + $root = $this->makeProject(); + $warmer = new XphpCacheWarmer($this->compilerFor($root . '/absent', $root), true, $root . '/absent'); + + self::assertSame([], $warmer->warmUp($root . '/cache')); + self::assertFileDoesNotExist($root . '/dist/Containers/Box.php'); + } + + private function compilerFor(string $source, string $root): XphpCompiler + { + return new XphpCompiler($source, $root . '/dist', $root . '/cache', 16); + } +} diff --git a/tests/Command/CompileCommandTest.php b/tests/Command/CompileCommandTest.php new file mode 100644 index 0000000..3c78e91 --- /dev/null +++ b/tests/Command/CompileCommandTest.php @@ -0,0 +1,47 @@ +removeTempProjects(); + } + + public function testReturnsSuccessAndReportsTheSummary(): void + { + $root = $this->makeProject(); + $tester = new CommandTester(new CompileCommand( + new XphpCompiler($root . '/xphp', $root . '/dist', $root . '/cache', 16), + )); + + $exit = $tester->execute([]); + + self::assertSame(0, $exit); + self::assertStringContainsString('Compiled', $tester->getDisplay()); + self::assertFileExists($root . '/dist/Containers/Box.php'); + } + + public function testReturnsFailureAndPrintsErrorWhenCompilationFails(): void + { + $tester = new CommandTester(new CompileCommand( + new XphpCompiler('/no/such/source', '/tmp/t', '/tmp/c', 16), + )); + + $exit = $tester->execute([]); + + self::assertSame(1, $exit); + self::assertStringContainsString('source directory not found', $tester->getDisplay()); + } +} diff --git a/tests/Compiler/XphpCompilerTest.php b/tests/Compiler/XphpCompilerTest.php new file mode 100644 index 0000000..e190cae --- /dev/null +++ b/tests/Compiler/XphpCompilerTest.php @@ -0,0 +1,104 @@ +removeTempProjects(); + } + + public function testCompileRewritesSourcesIntoTargetAndReturnsASummary(): void + { + $root = $this->makeProject(); + + $summary = (new XphpCompiler($root . '/xphp', $root . '/dist', $root . '/cache', 16))->compile(); + + self::assertNotSame('', $summary, 'compile() returns the transpiler summary line'); + self::assertSame($summary, trim($summary), 'the summary is trimmed of surrounding whitespace'); + self::assertFileExists($root . '/dist/Containers/Box.php', 'rewritten template lands in the target dir'); + } + + public function testRootsGenerateASpecializationAndAreCleanedUp(): void + { + $root = $this->makeProject(); + $roots = ['App\\Containers\\Box']; + + (new XphpCompiler($root . '/xphp', $root . '/dist', $root . '/cache', 16, $roots))->compile(); + + $fqcn = Registry::generatedFqn('App\\Containers\\Box', [new TypeRef('App\\Models\\Plastic')], 16); + $relative = str_replace('\\', '/', substr($fqcn, \strlen('XPHP\\Generated\\'))); + self::assertFileExists( + $root . '/cache/Generated/' . $relative . '.php', + 'a config-only root is specialized even with no source call site', + ); + + // The synthetic roots artifact must be written into __xphp_di_roots__ and then + // fully removed — no "roots.*" file may linger anywhere under the source or target + // (catches a misplaced or un-cleaned synthetic file). + self::assertDirectoryDoesNotExist($root . '/xphp/__xphp_di_roots__'); + self::assertDirectoryDoesNotExist($root . '/dist/__xphp_di_roots__'); + self::assertSame([], $this->findFiles($root . '/xphp', '/^roots\./'), 'no synthetic roots file left in source'); + self::assertSame([], $this->findFiles($root . '/dist', '/^roots\./'), 'no synthetic roots file left in target'); + } + + /** + * @return list paths of files under $dir whose basename matches $pattern + */ + private function findFiles(string $dir, string $pattern): array + { + if (!is_dir($dir)) { + return []; + } + $found = []; + $it = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS)); + foreach ($it as $file) { + if (preg_match($pattern, $file->getFilename())) { + $found[] = (string) $file; + } + } + + return $found; + } + + public function testNoRootsMeansNoSyntheticDirIsEverCreated(): void + { + $root = $this->makeProject(); + + (new XphpCompiler($root . '/xphp', $root . '/dist', $root . '/cache', 16))->compile(); + + self::assertDirectoryDoesNotExist($root . '/xphp/__xphp_di_roots__'); + } + + public function testMissingSourceDirThrows(): void + { + $compiler = new XphpCompiler('/no/such/source', '/tmp/t', '/tmp/c', 16); + + $this->expectException(CompilationFailedException::class); + $this->expectExceptionMessage('source directory not found'); + $compiler->compile(); + } + + public function testTranspilerErrorBecomesCompilationFailedException(): void + { + $root = $this->makeProject(); + // A syntactically broken source makes the transpiler exit non-zero; the compiler + // must surface that as a CompilationFailedException, never a raw throwable. + $this->writeFile($root . '/xphp/Broken.xphp', "expectException(CompilationFailedException::class); + (new XphpCompiler($root . '/xphp', $root . '/dist', $root . '/cache', 16))->compile(); + } +} diff --git a/tests/DependencyInjection/GenericServiceRegistrarTest.php b/tests/DependencyInjection/GenericServiceRegistrarTest.php index 3d58b49..3176c45 100644 --- a/tests/DependencyInjection/GenericServiceRegistrarTest.php +++ b/tests/DependencyInjection/GenericServiceRegistrarTest.php @@ -113,9 +113,12 @@ public function testFlagsAndTagsAreAppliedFromDefaults(): void public function testPerBindingFlagsOverrideDefaults(): void { + // Every flag is overridden to the OPPOSITE of its _default, so each `?? default` + // is pinned (a swapped coalesce would flip the asserted value). $container = $this->register([ 'template' => 'App\\Generic\\CachedFinder', 'args' => ['App\\Entity\\User'], + 'autowire' => false, 'autoconfigure' => false, 'shared' => false, 'public' => true, @@ -124,10 +127,10 @@ public function testPerBindingFlagsOverrideDefaults(): void $id = Registry::generatedFqn('App\\Generic\\CachedFinder', [new TypeRef('App\\Entity\\User')], self::HASH_LENGTH); $definition = $container->getDefinition($id); - self::assertTrue($definition->isAutowired()); // inherited from _defaults - self::assertFalse($definition->isAutoconfigured()); // overridden - self::assertFalse($definition->isShared()); // overridden - self::assertTrue($definition->isPublic()); // overridden + self::assertFalse($definition->isAutowired()); // overridden (default true) + self::assertFalse($definition->isAutoconfigured()); // overridden (default true) + self::assertFalse($definition->isShared()); // overridden (default true) + self::assertTrue($definition->isPublic()); // overridden (default false) } public function testAliasPointsAtTheSpecialization(): void diff --git a/tests/Support/TempProjectTrait.php b/tests/Support/TempProjectTrait.php new file mode 100644 index 0000000..894bfe3 --- /dev/null +++ b/tests/Support/TempProjectTrait.php @@ -0,0 +1,74 @@ + dirs to clean up in tearDown */ + private array $tempDirs = []; + + /** + * Create a temp project root containing `xphp/Containers/Box.xphp` — a simple + * generic template with no bounds, instantiable as `Box`. + */ + private function makeProject(): string + { + $root = sys_get_temp_dir() . '/xphp-test-' . bin2hex(random_bytes(8)); + $this->tempDirs[] = $root; + + $this->writeFile($root . '/xphp/Containers/Box.xphp', <<<'XPHP' + + { + public ?T $item = null; + + public function set(T $value): void + { + $this->item = $value; + } + } + XPHP); + + return $root; + } + + private function writeFile(string $path, string $contents): void + { + @mkdir(\dirname($path), 0o775, true); + file_put_contents($path, $contents); + } + + private function removeTempProjects(): void + { + foreach ($this->tempDirs as $dir) { + $this->rrmdir($dir); + } + $this->tempDirs = []; + } + + private function rrmdir(string $dir): void + { + if (!is_dir($dir)) { + return; + } + $items = scandir($dir) ?: []; + foreach ($items as $item) { + if ($item === '.' || $item === '..') { + continue; + } + $path = $dir . \DIRECTORY_SEPARATOR . $item; + is_dir($path) ? $this->rrmdir($path) : @unlink($path); + } + @rmdir($dir); + } +} diff --git a/tests/XphpBundleTest.php b/tests/XphpBundleTest.php new file mode 100644 index 0000000..9f826de --- /dev/null +++ b/tests/XphpBundleTest.php @@ -0,0 +1,214 @@ +removeTempProjects(); + GeneratedClassLoader::reset(); + } + + /** + * Load the bundle extension into a fresh ContainerBuilder, the way the kernel does. + * + * @param array $config + */ + private function load(string $projectDir, array $config): ContainerBuilder + { + $builder = new ContainerBuilder(new ParameterBag(['kernel.project_dir' => $projectDir])); + $extension = (new XphpBundle())->getContainerExtension(); + self::assertNotNull($extension); + $extension->load([$config], $builder); + + return $builder; + } + + public function testConfigDefaultsBecomeParameters(): void + { + $builder = $this->load($this->makeProject(), []); + + self::assertSame('%kernel.project_dir%/xphp', $builder->getParameter('xphp.source')); + self::assertSame('%kernel.project_dir%/var/xphp/dist', $builder->getParameter('xphp.target')); + self::assertSame('%kernel.project_dir%/var/xphp/generated', $builder->getParameter('xphp.cache')); + self::assertNull($builder->getParameter('xphp.hash_length')); + self::assertTrue($builder->getParameter('xphp.compile_on_warmup')); + self::assertTrue($builder->getParameter('xphp.register_runtime_autoloader')); + self::assertSame([], $builder->getParameter('xphp.di_roots')); + } + + public function testTheBundlesOwnServicesAreImported(): void + { + $builder = $this->load($this->makeProject(), []); + + self::assertTrue($builder->hasDefinition(XphpCompiler::class)); + self::assertTrue($builder->hasDefinition(CompileCommand::class)); + self::assertTrue($builder->hasDefinition(XphpCacheWarmer::class)); + } + + public function testNoBindingsCompilesNothingAndRegistersNoGenericService(): void + { + $root = $this->makeProject(); + + $builder = $this->load($root, []); + + self::assertSame([], $builder->getParameter('xphp.di_roots')); + self::assertDirectoryDoesNotExist($root . '/var/xphp/generated', 'no bindings => no build-time compile'); + } + + public function testDeclaredBindingRegistersTheSpecializationServiceAndCompilesIt(): void + { + $root = $this->makeProject(); + + $builder = $this->load($root, [ + 'services' => [ + 'bindings' => [ + ['template' => 'App\\Containers\\Box', 'args' => ['App\\Models\\Plastic']], + ], + ], + ]); + + $id = Registry::generatedFqn('App\\Containers\\Box', [new TypeRef('App\\Models\\Plastic')], Registry::DEFAULT_HASH_HEX_LENGTH); + + self::assertTrue($builder->hasDefinition($id), 'binding is registered as a service'); + self::assertSame(['App\\Containers\\Box'], $builder->getParameter('xphp.di_roots')); + + $relative = str_replace('\\', '/', substr($id, \strlen('XPHP\\Generated\\'))); + self::assertFileExists( + $root . '/var/xphp/generated/Generated/' . $relative . '.php', + 'the specialization was compiled to the RESOLVED cache path (not a literal %kernel.project_dir%)', + ); + + // Watching the source dir for .xphp changes is what makes the container rebuild. + $watchesSource = array_filter( + $builder->getResources(), + static fn ($r): bool => $r instanceof DirectoryResource && $r->getResource() === $root . '/xphp', + ); + self::assertNotEmpty($watchesSource, 'the .xphp source dir is tracked as a container resource'); + } + + public function testBuildTimeRegistersTheGeneratedAutoloaderSoSpecializationsAreReflectable(): void + { + $root = $this->makeProject(); + $id = Registry::generatedFqn('App\\Containers\\Box', [new TypeRef('App\\Models\\Plastic')], Registry::DEFAULT_HASH_HEX_LENGTH); + + // The specialization `implements \App\Containers\Box` (the compiled marker), so make + // the temp app's App\ namespace loadable from the target dir for this assertion. + $appLoader = static function (string $class) use ($root): void { + if (str_starts_with($class, 'App\\')) { + $file = $root . '/var/xphp/dist/' . str_replace('\\', '/', substr($class, 4)) . '.php'; + if (is_file($file)) { + require $file; + } + } + }; + spl_autoload_register($appLoader); + + try { + $this->load($root, [ + 'services' => ['bindings' => [['template' => 'App\\Containers\\Box', 'args' => ['App\\Models\\Plastic']]]], + ]); + + // Only loads if loadExtension registered the XPHP\Generated\ autoloader for the + // cache dir during the build — which is what autowiring/autoconfigure rely on. + self::assertTrue(class_exists($id), 'the freshly compiled specialization autoloads after the build'); + } finally { + spl_autoload_unregister($appLoader); + } + } + + public function testHashLengthDrivesTheResolvedServiceId(): void + { + $root = $this->makeProject(); + + $builder = $this->load($root, [ + 'hash_length' => 16, + 'services' => ['bindings' => [['template' => 'App\\Containers\\Box', 'args' => ['App\\Models\\Plastic']]]], + ]); + + // If the hash_length weren't honored (e.g. fell back to the 64 default), this id + // wouldn't exist. + $id = Registry::generatedFqn('App\\Containers\\Box', [new TypeRef('App\\Models\\Plastic')], 16); + self::assertTrue($builder->hasDefinition($id)); + } + + /** + * @return iterable + */ + public static function hashLengthBoundaries(): iterable + { + yield 'min accepted' => [16, true]; + yield 'below min rejected' => [15, false]; + yield 'max accepted' => [64, true]; + yield 'above max rejected' => [65, false]; + } + + #[\PHPUnit\Framework\Attributes\DataProvider('hashLengthBoundaries')] + public function testHashLengthBoundsAreEnforced(int $hashLength, bool $accepted): void + { + if (!$accepted) { + $this->expectException(InvalidConfigurationException::class); + } + + $builder = $this->load($this->makeProject(), ['hash_length' => $hashLength]); + + if ($accepted) { + self::assertSame($hashLength, $builder->getParameter('xphp.hash_length')); + } + } + + public function testBootRegistersTheGeneratedAutoloaderWhenEnabled(): void + { + $root = $this->makeProject(); + $cache = $root . '/cache'; + $this->writeFile( + $cache . '/Generated/App/Demo/T_bootenabled.php', + "bootBundle(['xphp.register_runtime_autoloader' => true, 'xphp.cache' => $cache]); + + self::assertTrue(class_exists('XPHP\\Generated\\App\\Demo\\T_bootenabled')); + } + + public function testBootSkipsTheAutoloaderWhenDisabled(): void + { + $root = $this->makeProject(); + $cache = $root . '/cache'; + $this->writeFile( + $cache . '/Generated/App/Demo/T_bootdisabled.php', + "bootBundle(['xphp.register_runtime_autoloader' => false, 'xphp.cache' => $cache]); + + self::assertFalse(class_exists('XPHP\\Generated\\App\\Demo\\T_bootdisabled')); + } + + /** @param array $params */ + private function bootBundle(array $params): void + { + $bundle = new XphpBundle(); + $bundle->setContainer(new ContainerBuilder(new ParameterBag($params))); + $bundle->boot(); + } +} From 1174f6f834ab60ec24c1e771e2de4ace15b632f5 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Wed, 3 Jun 2026 21:34:20 +0000 Subject: [PATCH 4/5] Add CI: run unit + mutation tests on PRs and main GitHub Actions workflow runs `make test` (PHPUnit) and `make test/mutation` (Infection, --min-msi=100) on PHP 8.4 with PCOV, triggered on pull requests targeting main and on pushes to main (PR merges). Calling the Makefile targets keeps a single source of truth for how the suite runs locally and in CI. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/ci.yml | 52 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..58ff8b3 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,52 @@ +name: CI + +# Run the full test suite (unit + mutation) on every PR targeting main and +# whenever those changes land on main (e.g. a PR merge). +on: + pull_request: + push: + branches: [main] + +# Cancel superseded runs on the same ref (e.g. new pushes to an open PR). +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + tests: + name: Unit + Mutation (PHP ${{ matrix.php }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + php: ['8.4'] + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + coverage: pcov # Infection's coverage driver (PCOV; Xdebug off) + tools: composer:v2 + ini-values: memory_limit=-1 + + - name: Cache Composer packages + uses: actions/cache@v4 + with: + path: ~/.cache/composer/files + key: composer-${{ runner.os }}-php${{ matrix.php }}-${{ hashFiles('composer.json') }} + restore-keys: composer-${{ runner.os }}-php${{ matrix.php }}- + + # `make test` / `make test/mutation` each run `composer install` first, so the + # Makefile stays the single source of truth for how the suite runs locally and in CI. + - name: Unit tests (PHPUnit) + run: make test + + - name: Mutation tests (Infection, min-msi=100) + run: make test/mutation From 762d6792fe59971809ef507ce85aa87dbd92e109 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Wed, 3 Jun 2026 21:39:52 +0000 Subject: [PATCH 5/5] CI: run the demo app (app:find + app:demo) in a job Add a parallel `demo` job that installs the demo (which consumes the bundle via a path repository) and runs both console commands, asserting their output: app:find proves the generic-service DI path (autowired CachedFinder, cache-aside), app:demo proves the value-type path (monomorphized Box). A non-zero exit or missing output fails the build. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/ci.yml | 51 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 58ff8b3..1307144 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -50,3 +50,54 @@ jobs: - name: Mutation tests (Infection, min-msi=100) run: make test/mutation + + demo: + name: Demo app (PHP ${{ matrix.php }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + php: ['8.4'] + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + coverage: none + tools: composer:v2 + ini-values: memory_limit=-1 + + - name: Cache Composer packages + uses: actions/cache@v4 + with: + path: ~/.cache/composer/files + key: composer-demo-${{ runner.os }}-php${{ matrix.php }}-${{ hashFiles('demo/composer.json') }} + restore-keys: composer-demo-${{ runner.os }}-php${{ matrix.php }}- + + # The demo consumes the bundle from the checkout via a Composer path repository. + - name: Install demo dependencies + working-directory: demo + run: composer install --prefer-dist --no-interaction --no-progress + + # app:find exercises the opt-in DI path: an .xphp command autowiring a + # CachedFinder specialization (compiled at container build). + - name: Run app:find (generic service via DI) + working-directory: demo + run: | + php bin/console app:find | tee find.out + grep -q "Ada" find.out + grep -q "Autowired" find.out + grep -q "cache-aside" find.out + + # app:demo exercises the value-type path: a generic Box monomorphized + # and called from compiled code. + - name: Run app:demo (value-type generic) + working-directory: demo + run: | + php bin/console app:demo | tee demo.out + grep -q "blue" demo.out + grep -q "monomorphized" demo.out