From d0f561e321211f4678b6202ead44f3fcf664a2b6 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Sun, 14 Jun 2026 17:23:30 +0000 Subject: [PATCH 1/8] test(catch): lock in catching exceptions by generic specialization Each HttpError monomorphizes into a distinct concrete class (extending the native exception, implementing the original name as a marker interface), so a `catch (HttpError $e)` clause is just a type-hint position that CallSiteRewriter rewrites to the specialized FQN and PHP's native catch discriminates by concrete class. This worked as emergent behavior with no dedicated production code; add a fixture + integration test so a future narrowing of CallSiteRewriter or the scanner's type-hint detection cannot regress it silently. Covers specific-specialization discrimination (the textually-first arm is skipped), bare-marker catch-all, and a union of two specializations, at runtime plus a rewrite snapshot. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../GenericExceptionCatchIntegrationTest.php | 177 ++++++++++++++++++ .../source/Client.xphp | 69 +++++++ .../source/Errors/HttpError.xphp | 13 ++ .../source/Models/Forbidden.xphp | 9 + .../source/Models/NotFound.xphp | 9 + .../verify/catch_runtime.php | 50 +++++ .../Client.expected.php | 60 ++++++ 7 files changed, 387 insertions(+) create mode 100644 test/Transpiler/Monomorphize/GenericExceptionCatchIntegrationTest.php create mode 100644 test/fixture/compile/generic_exception_catch/source/Client.xphp create mode 100644 test/fixture/compile/generic_exception_catch/source/Errors/HttpError.xphp create mode 100644 test/fixture/compile/generic_exception_catch/source/Models/Forbidden.xphp create mode 100644 test/fixture/compile/generic_exception_catch/source/Models/NotFound.xphp create mode 100644 test/fixture/compile/generic_exception_catch/verify/catch_runtime.php create mode 100644 test/fixture/compile/generic_exception_catch/verify/testCatchClausesRewriteToDistinctSpecializedFqns/Client.expected.php diff --git a/test/Transpiler/Monomorphize/GenericExceptionCatchIntegrationTest.php b/test/Transpiler/Monomorphize/GenericExceptionCatchIntegrationTest.php new file mode 100644 index 0000000..a93e805 --- /dev/null +++ b/test/Transpiler/Monomorphize/GenericExceptionCatchIntegrationTest.php @@ -0,0 +1,177 @@ +` monomorphizes into a distinct concrete class (extending the + * native exception, implementing the original name as a marker interface), a + * `catch (HttpError $e)` clause is just another type-hint position that + * CallSiteRewriter rewrites to the specialized FQN. PHP's native catch then + * discriminates by concrete class. + * + * These tests lock that behavior in: it works today as emergent behavior, with + * no production code dedicated to it, so a future narrowing of CallSiteRewriter + * or the scanner's type-hint detection could regress it silently. + */ +final class GenericExceptionCatchIntegrationTest extends TestCase +{ + private string $sourceDir; + private string $workDir; + private string $targetDir; + private string $cacheDir; + + protected function setUp(): void + { + $this->sourceDir = realpath(__DIR__ . '/../../fixture/compile/generic_exception_catch/source') + ?: throw new RuntimeException('Fixture missing'); + $this->workDir = sys_get_temp_dir() . '/xphp-generic-catch-' . uniqid('', true); + $this->targetDir = $this->workDir . '/dist'; + $this->cacheDir = $this->workDir . '/.xphp-cache'; + mkdir($this->workDir, 0o755, true); + } + + protected function tearDown(): void + { + if (is_dir($this->workDir)) { + self::rrmdir($this->workDir); + } + } + + #[RunInSeparateProcess] + public function testGenericCatchDiscriminatesBySpecializationAtRuntime(): void + { + $fixture = CompiledFixture::compile($this->sourceDir, 'generic-catch-runtime'); + $fixture->registerAutoload('App\\GenericExceptionCatch\\'); + try { + require __DIR__ . '/../../fixture/compile/generic_exception_catch/verify/catch_runtime.php'; + } finally { + $fixture->cleanup(); + } + } + + public function testCatchClausesRewriteToDistinctSpecializedFqns(): void + { + $this->compile(); + + $notFoundFqn = Registry::generatedFqn( + 'App\\GenericExceptionCatch\\Errors\\HttpError', + [new TypeRef('App\\GenericExceptionCatch\\Models\\NotFound')], + ); + $forbiddenFqn = Registry::generatedFqn( + 'App\\GenericExceptionCatch\\Errors\\HttpError', + [new TypeRef('App\\GenericExceptionCatch\\Models\\Forbidden')], + ); + + // The two specializations MUST be distinct FQNs — that distinctness is + // what lets the two catch arms discriminate at all. This (plus the + // snapshot) pins the rewrite shape, but it is position-insensitive: a + // regression that swapped the two arm *bodies* would slip past here + // because both FQNs would still appear and keep their first-seen order + // (SnapshotHash's documented blind spot). The runtime test + // (`classify('forbidden')` must skip the NotFound arm) is what actually + // guards the arm/body pairing. + self::assertNotSame($notFoundFqn, $forbiddenFqn); + + $clientPath = $this->targetDir . '/Client.php'; + self::assertFileExists($clientPath); + $content = file_get_contents($clientPath); + + // Both specialized FQNs appear as rewritten catch targets, and the bare + // catch-all arm stays on the unspecialized marker name. + self::assertStringContainsString('catch (\\' . $notFoundFqn . ' $e)', $content); + self::assertStringContainsString('catch (\\' . $forbiddenFqn . ' $e)', $content); + self::assertStringContainsString('catch (HttpError $e)', $content); + + SnapshotHash::assertMatches( + __DIR__ . '/../../fixture/compile/generic_exception_catch/verify/testCatchClausesRewriteToDistinctSpecializedFqns/Client.expected.php', + $content, + ); + } + + public function testAllOutputFilesAreSyntacticallyValid(): void + { + $this->compile(); + + $files = array_merge( + self::globRecursive($this->targetDir, '*.php'), + self::globRecursive($this->cacheDir . '/Generated', '*.php'), + ); + self::assertNotEmpty($files); + + foreach ($files as $file) { + $output = []; + $exit = 0; + exec('php -l ' . escapeshellarg($file) . ' 2>&1', $output, $exit); + self::assertSame(0, $exit, "Syntax error in {$file}:\n" . implode("\n", $output)); + } + } + + private function compile(): void + { + $compiler = $this->buildCompiler(); + $sources = (new NativeFileFinder()) + ->find($this->sourceDir) + ->filter(static fn (string $f): bool => str_ends_with($f, '.xphp')); + $compiler->compile($sources, $this->sourceDir, $this->targetDir, $this->cacheDir); + } + + /** + * @return list + */ + private static function globRecursive(string $dir, string $pattern): array + { + if (!is_dir($dir)) { + return []; + } + $found = glob(rtrim($dir, '/') . '/' . $pattern) ?: []; + foreach (glob(rtrim($dir, '/') . '/*', GLOB_ONLYDIR) ?: [] as $subdir) { + $found = array_merge($found, self::globRecursive($subdir, $pattern)); + } + return $found; + } + + private function buildCompiler(): Compiler + { + $phpParser = (new ParserFactory())->createForHostVersion(); + $printer = new StandardPrinter(); + $writer = new NativeFileWriter(); + + return new Compiler( + new NativeFileReader(), + $writer, + new XphpSourceParser($phpParser), + new Specializer(), + new SpecializedClassGenerator($printer, $writer), + $printer, + ); + } + + private static function rrmdir(string $dir): void + { + if (!is_dir($dir)) { + return; + } + foreach (scandir($dir) as $entry) { + if ($entry === '.' || $entry === '..') { + continue; + } + $path = $dir . '/' . $entry; + is_dir($path) ? self::rrmdir($path) : unlink($path); + } + rmdir($dir); + } +} diff --git a/test/fixture/compile/generic_exception_catch/source/Client.xphp b/test/fixture/compile/generic_exception_catch/source/Client.xphp new file mode 100644 index 0000000..ba2aca6 --- /dev/null +++ b/test/fixture/compile/generic_exception_catch/source/Client.xphp @@ -0,0 +1,69 @@ +('api key revoked'); + } + + throw new HttpError::('missing'); + } + + /** + * The generic type discriminates: a thrown `HttpError` must + * fall through the textually-first `HttpError` arm and land on + * the `HttpError` arm. Catching by generic specialization is + * the whole point of the fixture. + */ + public function classify(string $which): string + { + try { + $this->raise($which); + } catch (HttpError $e) { + return 'not-found:' . $e->detail; + } catch (HttpError $e) { + return 'forbidden:' . $e->detail; + } + + return 'none'; + } + + /** + * Bare `catch (HttpError $e)` (no type args) catches any `HttpError<*>` + * specialization via the marker interface every specialization implements. + */ + public function catchAny(string $which): string + { + try { + $this->raise($which); + } catch (HttpError $e) { + return $e::class; + } + + return 'none'; + } + + /** + * A union of two specializations matches when the thrown error is either. + */ + public function catchUnion(string $which): string + { + try { + $this->raise($which); + } catch (HttpError | HttpError $e) { + return 'union:' . $e->detail; + } + + return 'none'; + } +} diff --git a/test/fixture/compile/generic_exception_catch/source/Errors/HttpError.xphp b/test/fixture/compile/generic_exception_catch/source/Errors/HttpError.xphp new file mode 100644 index 0000000..039ee3a --- /dev/null +++ b/test/fixture/compile/generic_exception_catch/source/Errors/HttpError.xphp @@ -0,0 +1,13 @@ + extends \RuntimeException +{ + public function __construct(public string $detail) + { + parent::__construct($detail); + } +} diff --git a/test/fixture/compile/generic_exception_catch/source/Models/Forbidden.xphp b/test/fixture/compile/generic_exception_catch/source/Models/Forbidden.xphp new file mode 100644 index 0000000..07c5336 --- /dev/null +++ b/test/fixture/compile/generic_exception_catch/source/Models/Forbidden.xphp @@ -0,0 +1,9 @@ +` vs `HttpError`) + * discriminates on the concrete monomorphized class, so the right arm fires. + * + * Driver contract: `$fixture` (CompiledFixture) in scope, autoload registered + * for `App\GenericExceptionCatch\` + the generated namespace. + */ + +use App\GenericExceptionCatch\Client; +use PHPUnit\Framework\Assert; +use XPHP\Transpiler\Monomorphize\Registry; +use XPHP\Transpiler\Monomorphize\TypeRef; + +$client = new Client(); + +// Generic type discriminates: the Forbidden throw must skip the textually +// FIRST `HttpError` arm and land on `HttpError`. This is +// the property that would silently break if catch types stopped being +// rewritten to their distinct specialized FQNs. +Assert::assertSame('forbidden:api key revoked', $client->classify('forbidden')); +Assert::assertSame('not-found:missing', $client->classify('notfound')); + +// Bare `catch (HttpError $e)` catches any specialization via the marker +// interface. The caught object is the concrete Forbidden specialization. +$forbiddenFqn = Registry::generatedFqn( + 'App\\GenericExceptionCatch\\Errors\\HttpError', + [new TypeRef('App\\GenericExceptionCatch\\Models\\Forbidden')], +); +Assert::assertSame($forbiddenFqn, $client->catchAny('forbidden')); + +// A union of two specializations matches either thrown error. +Assert::assertSame('union:missing', $client->catchUnion('notfound')); +Assert::assertSame('union:api key revoked', $client->catchUnion('forbidden')); + +// The specialization is a genuine Throwable subtype (so it is catchable at all) +// AND implements the original generic name as a marker interface (so the bare +// catch-all arm above can match it). +Assert::assertTrue( + is_subclass_of($forbiddenFqn, \RuntimeException::class), + 'specialized exception must remain a RuntimeException subtype', +); +Assert::assertTrue( + is_subclass_of($forbiddenFqn, 'App\\GenericExceptionCatch\\Errors\\HttpError'), + 'specialized exception must implement the HttpError marker interface', +); diff --git a/test/fixture/compile/generic_exception_catch/verify/testCatchClausesRewriteToDistinctSpecializedFqns/Client.expected.php b/test/fixture/compile/generic_exception_catch/verify/testCatchClausesRewriteToDistinctSpecializedFqns/Client.expected.php new file mode 100644 index 0000000..f8c75f2 --- /dev/null +++ b/test/fixture/compile/generic_exception_catch/verify/testCatchClausesRewriteToDistinctSpecializedFqns/Client.expected.php @@ -0,0 +1,60 @@ +` must + * fall through the textually-first `HttpError` arm and land on + * the `HttpError` arm. Catching by generic specialization is + * the whole point of the fixture. + */ + public function classify(string $which): string + { + try { + $this->raise($which); + } catch (\XPHP\Generated\App\GenericExceptionCatch\Errors\HttpError\T_ecf2ea0572da7e267540a6732b45a57cd872d497de0ce6e93c5606d12dc408b8 $e) { + return 'not-found:' . $e->detail; + } catch (\XPHP\Generated\App\GenericExceptionCatch\Errors\HttpError\T_06f918bacae3fd07cbcf951547a0b2cd134791ea546f4e217ed92646c23312fb $e) { + return 'forbidden:' . $e->detail; + } + return 'none'; + } + /** + * Bare `catch (HttpError $e)` (no type args) catches any `HttpError<*>` + * specialization via the marker interface every specialization implements. + */ + public function catchAny(string $which): string + { + try { + $this->raise($which); + } catch (HttpError $e) { + return $e::class; + } + return 'none'; + } + /** + * A union of two specializations matches when the thrown error is either. + */ + public function catchUnion(string $which): string + { + try { + $this->raise($which); + } catch (\XPHP\Generated\App\GenericExceptionCatch\Errors\HttpError\T_ecf2ea0572da7e267540a6732b45a57cd872d497de0ce6e93c5606d12dc408b8|\XPHP\Generated\App\GenericExceptionCatch\Errors\HttpError\T_06f918bacae3fd07cbcf951547a0b2cd134791ea546f4e217ed92646c23312fb $e) { + return 'union:' . $e->detail; + } + return 'none'; + } +} From 794176cfd70f43b013191c4c4a03e8ae365bf3c5 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Sun, 14 Jun 2026 23:35:42 +0200 Subject: [PATCH 2/8] docs(roadmap): roadmap cleanup Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/roadmap.md | 31 ++++++++----------------------- 1 file changed, 8 insertions(+), 23 deletions(-) diff --git a/docs/roadmap.md b/docs/roadmap.md index 629a1b5..519d195 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -45,24 +45,18 @@ timeline : runtime instanceof T : marker interface per template Developer experience - : PSR-4 fixtures : RFC-aligned call-site syntax : empty turbofish for all-defaults templates section Next Editor and tooling - : PhpStorm syntax highlighting - : Language Server Protocol - : Composer plugin for autoload : Live transpilation via stream wrapper Compiler ergonomics - : Migration hint for bare call sites - : PHPDoc substitution in generated bodies : Source maps back to xphp lines + section Discovery Generic surface : Generic type aliases : Variance edges on trait-owned templates : Branching narrowing precision - section Discovery Module surface : internal visibility modifier : composer-package boundary @@ -182,7 +176,6 @@ upcoming one. ### Developer experience -- PSR-4 fixtures. - RFC-aligned call-site syntax (`Name::<...>` turbofish). - Empty turbofish (`Name::<>`) for all-defaults templates. @@ -192,27 +185,12 @@ upcoming one. ### Editor and tooling -- PhpStorm syntax highlighting. -- Language Server Protocol implementation (diagnostics, hover types, - goto-definition). -- Composer plugin for autoload registration. - Live transpilation via stream wrapper (no build step in dev). ### Compiler ergonomics -- Compile-time migration hint for bare `Name<...>(...)` call sites - that point users at the `::<...>` turbofish. -- PHPDoc substitution in generated bodies so generated `.php` reads - naturally. - Source maps (stack traces back to `.xphp` lines). -### Generic surface - -- Generic type aliases (e.g. `type Pair = ...`). -- Variance edges on trait-owned templates. -- Branching narrowing precision: today conservatively de-specializes - when arms disagree; will track unions with runtime dispatch instead. - --- ## Discovery @@ -223,6 +201,13 @@ implementation knobs are still being settled. Treat each Discovery entry as a starting point for community discussion, not a guarantee to ship. +### Generic surface + +- Generic type aliases (e.g. `type Pair = ...`). +- Variance edges on trait-owned templates. +- Branching narrowing precision: today conservatively de-specializes + when arms disagree; will track unions with runtime dispatch instead. + ### Module surface - **`internal` visibility modifier**: replace PHPDoc `@internal` hints From 2f4cdb8ddde8ffc9503ab30d40584bbe0232610c Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Mon, 15 Jun 2026 23:34:55 +0200 Subject: [PATCH 3/8] test(php85): cover PHP 8.5 pipe operator on a dedicated 8.5 runtime The transpiler passes plain PHP through unchanged, so `|>` support hinges purely on the parser's target version. Keep ApplicationConsole on createForHostVersion() (supported runtime is ^8.4): the host accepts 8.4 syntax on 8.4 and 8.5 syntax on 8.5, with no need to force a newer grammar. Lock the pipe operator with an integration test that mirrors the production wiring and round-trips `|>` (alone and beside a generic specialization). Tag it `@group php85` + `#[RequiresPhp('>= 8.5.0')]` so it skips on 8.4 and runs only under an 8.5 runtime. - Makefile: test/unit excludes php85; new test/unit/php85 runs only it. - CI: phpunit job is now a matrix (8.4 -> full suite minus php85, 8.5 -> only php85). - CONTRIBUTING: document the split and the new target. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/ci-core.yml | 20 ++- CONTRIBUTING.md | 12 +- Makefile | 12 +- src/Console/ApplicationConsole.php | 5 + .../PipeOperatorIntegrationTest.php | 159 ++++++++++++++++++ 5 files changed, 200 insertions(+), 8 deletions(-) create mode 100644 test/Transpiler/Monomorphize/PipeOperatorIntegrationTest.php diff --git a/.github/workflows/ci-core.yml b/.github/workflows/ci-core.yml index 94e6fde..5115e79 100644 --- a/.github/workflows/ci-core.yml +++ b/.github/workflows/ci-core.yml @@ -15,15 +15,27 @@ concurrency: jobs: phpunit: - name: PHPUnit + # 8.4 is the supported/default runtime and runs the full suite minus the + # `php85` group; a dedicated 8.5 container runs only that group, which + # covers syntax (e.g. the pipe operator `|>`) that the host-version + # parser can only tokenize on PHP 8.5. + name: PHPUnit (PHP ${{ matrix.php }}) runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - php: '8.4' + target: test/unit + - php: '8.5' + target: test/unit/php85 steps: - uses: actions/checkout@v4 - - name: Setup PHP 8.4 + - name: Setup PHP ${{ matrix.php }} uses: shivammathur/setup-php@v2 with: - php-version: '8.4' + php-version: ${{ matrix.php }} extensions: dom, json, mbstring, tokenizer coverage: none tools: composer:v2 @@ -32,7 +44,7 @@ jobs: uses: ramsey/composer-install@v3 - name: Run PHPUnit - run: make test/unit + run: make ${{ matrix.target }} phpstan: name: PHPStan diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0ca9e17..8c24a55 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -3,10 +3,16 @@ ## Test ```bash -make test/unit # PHPUnit -make test/mutation # Infection, MSI under a 95 % gate +make test/unit # PHPUnit on the default PHP 8.4 runtime +make test/unit/php85 # only tests tagged `@group php85`; needs a PHP 8.5 runtime +make test/mutation # Infection, MSI under a 95 % gate ``` -CI gates every PR on both targets. `infection.json5` carries a curated set +The supported runtime is PHP `^8.4`. Tests that exercise newer-PHP syntax +(e.g. the 8.5 pipe operator) are tagged `@group php85`; `make test/unit` +excludes them and they self-skip via `#[RequiresPhp]` off an 8.5 host. CI +runs them in a dedicated PHP 8.5 job. + +CI gates every PR on these targets. `infection.json5` carries a curated set of per-mutator `ignore` rules for genuinely-equivalent / defensive mutations so the report only surfaces real test gaps. \ No newline at end of file diff --git a/Makefile b/Makefile index e323a9a..3288b02 100644 --- a/Makefile +++ b/Makefile @@ -7,8 +7,18 @@ # targets here. .PHONY: test/unit +# Default runtime is PHP 8.4 (composer requires ^8.4). Tests exercising +# newer-PHP syntax are tagged `@group php85` and excluded here; they run +# on an 8.5 runtime via `make test/unit/php85`. test/unit: - php vendor/bin/phpunit + php vendor/bin/phpunit --exclude-group php85 + +.PHONY: test/unit/php85 +# Runs only the PHP 8.5-specific syntax tests (e.g. the pipe operator). +# Requires a PHP 8.5 runtime -- CI uses a dedicated 8.5 container; on 8.4 +# these self-skip via #[RequiresPhp]. +test/unit/php85: + php vendor/bin/phpunit --group php85 .PHONY: lint/phpstan # Static analysis at level 7. Memory limit lifted because deep generic diff --git a/src/Console/ApplicationConsole.php b/src/Console/ApplicationConsole.php index a395802..bed4708 100644 --- a/src/Console/ApplicationConsole.php +++ b/src/Console/ApplicationConsole.php @@ -27,6 +27,11 @@ public function __construct( ) { parent::__construct('xphp'); + // Parse against the host PHP version: xphp's supported runtime is + // ^8.4 (see composer.json), so on 8.4 the transpiler accepts 8.4 + // syntax and on an 8.5 host it transparently accepts 8.5 syntax such + // as the pipe operator `|>`. Newer-syntax behaviour is locked by + // tests tagged `@group php85`, which run only under an 8.5 runtime. $phpParser = (new ParserFactory())->createForHostVersion(); $printer = new StandardPrinter(); diff --git a/test/Transpiler/Monomorphize/PipeOperatorIntegrationTest.php b/test/Transpiler/Monomorphize/PipeOperatorIntegrationTest.php new file mode 100644 index 0000000..af95adf --- /dev/null +++ b/test/Transpiler/Monomorphize/PipeOperatorIntegrationTest.php @@ -0,0 +1,159 @@ +`). + * + * xphp only owns generic syntax; everything else is plain PHP that must + * survive the parse -> rewrite -> pretty-print round-trip untouched. The + * production parser uses `createForHostVersion()` (xphp's runtime is ^8.4), + * so the `|>` token only lexes when the transpiler itself runs on PHP 8.5+. + * + * This case therefore requires an 8.5 runtime: it is tagged `@group php85` + * so the default 8.4 CI job excludes it, and `#[RequiresPhp]` makes it skip + * rather than error if ever run on an older PHP. The dedicated 8.5 CI + * container runs exactly this group. + */ +#[Group('php85')] +#[RequiresPhp('>= 8.5.0')] +final class PipeOperatorIntegrationTest extends TestCase +{ + private string $sourceDir; + private string $targetDir; + private string $cacheDir; + + protected function setUp(): void + { + $workDir = sys_get_temp_dir() . '/xphp-pipe-' . uniqid('', true); + $this->sourceDir = $workDir . '/src'; + $this->targetDir = $workDir . '/dist'; + $this->cacheDir = $workDir . '/.xphp-cache'; + mkdir($this->sourceDir, 0o755, true); + } + + protected function tearDown(): void + { + $workDir = \dirname($this->sourceDir); + if (is_dir($workDir)) { + self::rrmdir($workDir); + } + } + + public function testPipeOperatorRoundTripsThroughTranspiler(): void + { + $this->writeSource('pipe.xphp', <<<'PHP' + trim(...) |> strtolower(...); + PHP); + + $this->compile(); + + $out = $this->readOutput('pipe.php'); + self::assertStringContainsString('|>', $out); + self::assertStringContainsString('$slug = $title |> trim(...) |> strtolower(...);', $out); + $this->assertValidPhp($out); + } + + public function testPipeOperatorCoexistsWithGenericSpecialization(): void + { + // The pipe lives right beside a turbofish call site, so this also proves + // xphp's generic byte-offset rewriting does not disturb the `|>` tokens. + $this->writeSource('box.xphp', <<<'PHP' + + { + public function __construct(public T $value) {} + } + + $box = new Box::(' HELLO '); + $slug = $box->value |> trim(...) |> strtolower(...); + PHP); + + $this->compile(); + + $out = $this->readOutput('box.php'); + // Pipe survives untouched. + self::assertStringContainsString('$slug = $box->value |> trim(...) |> strtolower(...);', $out); + // Generic was actually specialized: the turbofish call site is rewritten + // to a generated, monomorphized FQN and the template syntax is gone. + self::assertStringContainsString('XPHP\Generated\App\Box\T_', $out); + self::assertStringNotContainsString('Box::<', $out); + $this->assertValidPhp($out); + } + + private function writeSource(string $name, string $code): void + { + file_put_contents($this->sourceDir . '/' . $name, $code); + } + + private function readOutput(string $name): string + { + $path = $this->targetDir . '/' . $name; + self::assertFileExists($path); + + return file_get_contents($path) ?: ''; + } + + private function assertValidPhp(string $code): void + { + // The emitted PHP must itself re-parse on this (8.5) runtime. + $parser = (new ParserFactory())->createForHostVersion(); + self::assertNotNull( + $parser->parse($code), + 'Transpiled output is not valid PHP', + ); + } + + private function compile(): void + { + // Mirror production wiring (ApplicationConsole): host-version parser. + // On the 8.5 runtime this group runs under, that tokenizes `|>`. + $phpParser = (new ParserFactory())->createForHostVersion(); + $printer = new StandardPrinter(); + $writer = new NativeFileWriter(); + + $compiler = new Compiler( + new NativeFileReader(), + $writer, + new XphpSourceParser($phpParser), + new Specializer(), + new SpecializedClassGenerator($printer, $writer), + $printer, + ); + + $sources = (new NativeFileFinder())->find($this->sourceDir) + ->filter(static fn (string $f): bool => str_ends_with($f, '.xphp')); + + $compiler->compile($sources, $this->sourceDir, $this->targetDir, $this->cacheDir); + } + + private static function rrmdir(string $dir): void + { + if (!is_dir($dir)) { + return; + } + foreach (scandir($dir) ?: [] as $entry) { + if ($entry === '.' || $entry === '..') { + continue; + } + $path = $dir . '/' . $entry; + is_dir($path) ? self::rrmdir($path) : unlink($path); + } + rmdir($dir); + } +} From c4f1d123d48acd0a40eb3b9ff1337840a42e6a82 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Mon, 15 Jun 2026 23:38:06 +0200 Subject: [PATCH 4/8] build(docker): add php85 container for the 8.5 test group Lets contributors without a local PHP 8.5 run the `@group php85` tests: `docker compose run --rm php85 make test/unit/php85`. The service builds a lean php:8.5-cli-alpine image (composer + git/unzip/make, no coverage drivers since those tests run coverage-off). Verified green on PHP 8.5.7. Co-Authored-By: Claude Opus 4.8 (1M context) --- .docker/php85.Dockerfile | 12 ++++++++++++ CONTRIBUTING.md | 6 ++++++ docker-compose.yml | 9 +++++++++ 3 files changed, 27 insertions(+) create mode 100644 .docker/php85.Dockerfile diff --git a/.docker/php85.Dockerfile b/.docker/php85.Dockerfile new file mode 100644 index 0000000..766b38b --- /dev/null +++ b/.docker/php85.Dockerfile @@ -0,0 +1,12 @@ +FROM php:8.5-cli-alpine + +# Dedicated runtime for the `@group php85` tests -- syntax (e.g. the 8.5 +# pipe operator `|>`) that the host-version parser can only tokenize on +# PHP 8.5. These run with coverage disabled, so no xdebug/pcov here: +# just composer plus the tools composer + `make test/unit/php85` need. +COPY --from=composer:2 /usr/bin/composer /usr/local/bin/composer + +RUN apk add --no-cache \ + git \ + unzip \ + make diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8c24a55..84b9757 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -13,6 +13,12 @@ The supported runtime is PHP `^8.4`. Tests that exercise newer-PHP syntax excludes them and they self-skip via `#[RequiresPhp]` off an 8.5 host. CI runs them in a dedicated PHP 8.5 job. +No PHP 8.5 locally? Run them in the bundled 8.5 container: + +```bash +docker compose run --rm php85 make test/unit/php85 +``` + CI gates every PR on these targets. `infection.json5` carries a curated set of per-mutator `ignore` rules for genuinely-equivalent / defensive mutations so the report only surfaces real test gaps. \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index ab29a5d..31851af 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,6 +8,15 @@ services: environment: XDEBUG_MODE: coverage,develop + # PHP 8.5 runtime for the `@group php85` tests. Run them with: + # docker compose run --rm php85 make test/unit/php85 + php85: + build: + dockerfile: .docker/php85.Dockerfile + working_dir: /opt/app + volumes: + - ./:/opt/app + xphp: extends: service: php From ede6fc9e7e934e756082705ef68d495f01530e34 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Mon, 15 Jun 2026 23:48:20 +0200 Subject: [PATCH 5/8] test(php85): convert pipe-operator test to the fixture+snapshot convention Replace the inline-source pipe test with a tracked `pipe_operator` fixture (Box.xphp generic + Use.xphp exercising a standalone `|>` chain and one beside a turbofish call site), asserted via SnapshotHash like the other compile fixtures. The whole-file snapshot locks the emitted bytes -- pipe operator preserved verbatim, turbofish rewritten to the monomorphized FQN. Still `@group php85` + `#[RequiresPhp('>= 8.5.0')]`: skips on 8.4, runs on 8.5. Verified green in the php85 container on PHP 8.5.7 (real comparison, not update mode) and skipping cleanly on the 8.4 host. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../PipeOperatorIntegrationTest.php | 113 +++++++----------- .../compile/pipe_operator/source/Box.xphp | 12 ++ .../compile/pipe_operator/source/Use.xphp | 15 +++ .../Use.expected.php | 13 ++ 4 files changed, 82 insertions(+), 71 deletions(-) create mode 100644 test/fixture/compile/pipe_operator/source/Box.xphp create mode 100644 test/fixture/compile/pipe_operator/source/Use.xphp create mode 100644 test/fixture/compile/pipe_operator/verify/testPipeOperatorFixtureCompiles/Use.expected.php diff --git a/test/Transpiler/Monomorphize/PipeOperatorIntegrationTest.php b/test/Transpiler/Monomorphize/PipeOperatorIntegrationTest.php index af95adf..64bf328 100644 --- a/test/Transpiler/Monomorphize/PipeOperatorIntegrationTest.php +++ b/test/Transpiler/Monomorphize/PipeOperatorIntegrationTest.php @@ -9,12 +9,15 @@ use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\Attributes\RequiresPhp; use PHPUnit\Framework\TestCase; +use RuntimeException; use XPHP\FileSystem\FileFinder\NativeFileFinder; use XPHP\FileSystem\FileReader\NativeFileReader; use XPHP\FileSystem\FileWriter\NativeFileWriter; +use XPHP\TestSupport\SnapshotHash; /** - * Locks in pass-through support for the PHP 8.5 pipe operator (`|>`). + * Locks in pass-through support for the PHP 8.5 pipe operator (`|>`) via the + * tracked `pipe_operator` fixture. * * xphp only owns generic syntax; everything else is plain PHP that must * survive the parse -> rewrite -> pretty-print round-trip untouched. The @@ -24,102 +27,75 @@ * This case therefore requires an 8.5 runtime: it is tagged `@group php85` * so the default 8.4 CI job excludes it, and `#[RequiresPhp]` makes it skip * rather than error if ever run on an older PHP. The dedicated 8.5 CI - * container runs exactly this group. + * container runs exactly this group. Locally: + * docker compose run --rm php85 make test/unit/php85 */ #[Group('php85')] #[RequiresPhp('>= 8.5.0')] final class PipeOperatorIntegrationTest extends TestCase { private string $sourceDir; + private string $workDir; private string $targetDir; private string $cacheDir; protected function setUp(): void { - $workDir = sys_get_temp_dir() . '/xphp-pipe-' . uniqid('', true); - $this->sourceDir = $workDir . '/src'; - $this->targetDir = $workDir . '/dist'; - $this->cacheDir = $workDir . '/.xphp-cache'; - mkdir($this->sourceDir, 0o755, true); + $this->sourceDir = realpath(__DIR__ . '/../../fixture/compile/pipe_operator/source') + ?: throw new RuntimeException('Fixture not found'); + $this->workDir = sys_get_temp_dir() . '/xphp-pipe-' . uniqid('', true); + $this->targetDir = $this->workDir . '/dist'; + $this->cacheDir = $this->workDir . '/.xphp-cache'; + mkdir($this->workDir, 0o755, true); } protected function tearDown(): void { - $workDir = \dirname($this->sourceDir); - if (is_dir($workDir)) { - self::rrmdir($workDir); + if (is_dir($this->workDir)) { + self::rrmdir($this->workDir); } } - public function testPipeOperatorRoundTripsThroughTranspiler(): void + public function testPipeOperatorFixtureCompiles(): void { - $this->writeSource('pipe.xphp', <<<'PHP' - trim(...) |> strtolower(...); - PHP); - - $this->compile(); - - $out = $this->readOutput('pipe.php'); - self::assertStringContainsString('|>', $out); - self::assertStringContainsString('$slug = $title |> trim(...) |> strtolower(...);', $out); - $this->assertValidPhp($out); - } + $compiler = $this->buildCompiler(); - public function testPipeOperatorCoexistsWithGenericSpecialization(): void - { - // The pipe lives right beside a turbofish call site, so this also proves - // xphp's generic byte-offset rewriting does not disturb the `|>` tokens. - $this->writeSource('box.xphp', <<<'PHP' - - { - public function __construct(public T $value) {} - } + $sources = (new NativeFileFinder()) + ->find($this->sourceDir) + ->filter(static fn (string $f): bool => str_ends_with($f, '.xphp')); - $box = new Box::(' HELLO '); - $slug = $box->value |> trim(...) |> strtolower(...); - PHP); + $result = $compiler->compile($sources, $this->sourceDir, $this->targetDir, $this->cacheDir); - $this->compile(); + self::assertSame(2, $result->sourceCount, 'expected Box.xphp + Use.xphp'); + self::assertSame(1, $result->generatedCount, 'expected one specialization: Box'); - $out = $this->readOutput('box.php'); - // Pipe survives untouched. - self::assertStringContainsString('$slug = $box->value |> trim(...) |> strtolower(...);', $out); - // Generic was actually specialized: the turbofish call site is rewritten - // to a generated, monomorphized FQN and the template syntax is gone. - self::assertStringContainsString('XPHP\Generated\App\Box\T_', $out); - self::assertStringNotContainsString('Box::<', $out); - $this->assertValidPhp($out); - } + $useFile = $this->targetDir . '/Use.php'; + self::assertFileExists($useFile); + $useContent = file_get_contents($useFile); - private function writeSource(string $name, string $code): void - { - file_put_contents($this->sourceDir . '/' . $name, $code); - } + // Pipe survives verbatim -- both the standalone chain and the one + // sitting right next to the rewritten turbofish call site. + self::assertStringContainsString('$slug = $title |> trim(...) |> strtolower(...);', $useContent); + self::assertStringContainsString('$shout = $box->value |> trim(...) |> strtoupper(...);', $useContent); - private function readOutput(string $name): string - { - $path = $this->targetDir . '/' . $name; - self::assertFileExists($path); + // Generic was actually specialized: turbofish rewritten to the + // monomorphized FQN, template syntax gone. + self::assertStringContainsString('XPHP\Generated\App\PipeOperator\Box\T_', $useContent); + self::assertStringNotContainsString('Box::<', $useContent); - return file_get_contents($path) ?: ''; - } - - private function assertValidPhp(string $code): void - { // The emitted PHP must itself re-parse on this (8.5) runtime. - $parser = (new ParserFactory())->createForHostVersion(); self::assertNotNull( - $parser->parse($code), + (new ParserFactory())->createForHostVersion()->parse($useContent), 'Transpiled output is not valid PHP', ); + + // Whole-file snapshot (hash segments normalized) locks the exact + // emitted bytes, including pipe-operator formatting. + $snapshotDir = __DIR__ . '/../../fixture/compile/pipe_operator/verify/testPipeOperatorFixtureCompiles'; + SnapshotHash::assertMatches($snapshotDir . '/Use.expected.php', $useContent); } - private function compile(): void + private function buildCompiler(): Compiler { // Mirror production wiring (ApplicationConsole): host-version parser. // On the 8.5 runtime this group runs under, that tokenizes `|>`. @@ -127,7 +103,7 @@ private function compile(): void $printer = new StandardPrinter(); $writer = new NativeFileWriter(); - $compiler = new Compiler( + return new Compiler( new NativeFileReader(), $writer, new XphpSourceParser($phpParser), @@ -135,11 +111,6 @@ private function compile(): void new SpecializedClassGenerator($printer, $writer), $printer, ); - - $sources = (new NativeFileFinder())->find($this->sourceDir) - ->filter(static fn (string $f): bool => str_ends_with($f, '.xphp')); - - $compiler->compile($sources, $this->sourceDir, $this->targetDir, $this->cacheDir); } private static function rrmdir(string $dir): void diff --git a/test/fixture/compile/pipe_operator/source/Box.xphp b/test/fixture/compile/pipe_operator/source/Box.xphp new file mode 100644 index 0000000..7237915 --- /dev/null +++ b/test/fixture/compile/pipe_operator/source/Box.xphp @@ -0,0 +1,12 @@ + +{ + public function __construct(public T $value) + { + } +} diff --git a/test/fixture/compile/pipe_operator/source/Use.xphp b/test/fixture/compile/pipe_operator/source/Use.xphp new file mode 100644 index 0000000..ec46284 --- /dev/null +++ b/test/fixture/compile/pipe_operator/source/Use.xphp @@ -0,0 +1,15 @@ + trim(...) |> strtolower(...); + +// Pipe coexists with a generic specialization: the turbofish call site gets +// rewritten to the monomorphized FQN while the |> tokens beside it are left +// intact (proves the generic byte-offset rewriting doesn't disturb them). +$box = new Box::(' HELLO '); +$shout = $box->value |> trim(...) |> strtoupper(...); diff --git a/test/fixture/compile/pipe_operator/verify/testPipeOperatorFixtureCompiles/Use.expected.php b/test/fixture/compile/pipe_operator/verify/testPipeOperatorFixtureCompiles/Use.expected.php new file mode 100644 index 0000000..a4f8a21 --- /dev/null +++ b/test/fixture/compile/pipe_operator/verify/testPipeOperatorFixtureCompiles/Use.expected.php @@ -0,0 +1,13 @@ + trim(...) |> strtolower(...); +// Pipe coexists with a generic specialization: the turbofish call site gets +// rewritten to the monomorphized FQN while the |> tokens beside it are left +// intact (proves the generic byte-offset rewriting doesn't disturb them). +$box = new \XPHP\Generated\App\PipeOperator\Box\T_473287f8298dba7163a897908958f7c0eae733e25d2e027992ea2edc9bed2fa8(' HELLO '); +$shout = $box->value |> trim(...) |> strtoupper(...); From b2ef32678513013cf73b363722207b54b57d3345 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Tue, 16 Jun 2026 00:06:43 +0200 Subject: [PATCH 6/8] docs: fix broken README example, wrong cache path, and document generic catch + 8.5 pass-through Correctness fixes (all verified by compiling/running the snippets): - README: the flagship Collection example used a variadic promoted property (`public T ...$items`), which PHP rejects ("Cannot declare variadic promoted property") -- the headline snippet never ran. Replace with an add()/first() shape that compiles and prints "Alice". - how-it-works: the on-disk cache path is `/Generated/...`, not `XPHP/Generated/...` (XPHP\Generated\ is only the namespace). Fix the prose and the mermaid node; they contradicted the file's own Stage 5. - pseudo-types: "See also" cited box_generic as using `self` (it uses `T`); point at the real self/static fixtures instead. Coverage gaps: - New docs/syntax/exceptions.md: generic exceptions and catch by specialization (turbofish throw, per-specialization arms, bare marker catch, union catch), with a tested fixture reference. Linked from the syntax index table and quick-reference card. - caveats: document that newer-PHP syntax (e.g. the 8.5 pipe operator) needs a host runtime that can parse it, since the parser uses the host PHP version. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 8 ++- docs/caveats.md | 32 ++++++++++++ docs/guides/how-it-works.md | 7 +-- docs/syntax/exceptions.md | 98 +++++++++++++++++++++++++++++++++++++ docs/syntax/index.md | 8 +++ docs/syntax/pseudo-types.md | 5 +- 6 files changed, 151 insertions(+), 7 deletions(-) create mode 100644 docs/syntax/exceptions.md diff --git a/README.md b/README.md index f318ffe..1d4ee90 100644 --- a/README.md +++ b/README.md @@ -117,14 +117,18 @@ Write a generic class and use it: namespace App; class Collection { - public function __construct(public T ...$items) {} + private array $items = []; + + public function add(T $item): void { $this->items[] = $item; } public function first(): ?T { return $this->items[0] ?? null; } } // src/Use.xphp namespace App; -$users = new Collection::(new User('Alice'), new User('Bob')); +$users = new Collection::(); +$users->add(new User('Alice')); +$users->add(new User('Bob')); echo $users->first()->name; ``` diff --git a/docs/caveats.md b/docs/caveats.md index ce37261..4799252 100644 --- a/docs/caveats.md +++ b/docs/caveats.md @@ -493,3 +493,35 @@ class TempContainer { } $x = new TempContainer::(); ``` + +## Newer PHP syntax needs a matching host runtime + +### ❌ What doesn't work + +Running the transpiler on PHP 8.4 over a source file that uses PHP 8.5 +syntax — for example the pipe operator: + +```php +$slug = $title |> trim(...) |> strtolower(...); +``` + +``` +Syntax error, unexpected '>' +``` + +### Why + +xphp owns only the generic syntax; everything else is plain PHP. It +parses your source with `nikic/php-parser` configured for the **host** +PHP version (the one running the transpiler). On PHP 8.4 the lexer +can't tokenize 8.5-only syntax like `|>`, so the parse fails before +specialization even begins. There's nothing generic about the line — +it just never reaches the host parser's grammar. + +### ✅ Workaround + +Run the transpiler on a PHP version that can parse your syntax — e.g. +PHP 8.5 for the pipe operator. Plain (non-generic) code, including +newer-PHP syntax, passes straight through untouched. Note the emitted +PHP still requires a runtime that supports those features to *execute*; +the supported floor for xphp itself is PHP 8.4 (`composer.json`). diff --git a/docs/guides/how-it-works.md b/docs/guides/how-it-works.md index d9ad767..3452aee 100644 --- a/docs/guides/how-it-works.md +++ b/docs/guides/how-it-works.md @@ -22,8 +22,9 @@ defaults to `dist` and `[cache-dir]` defaults to `.xphp-cache`. The data flows t turn into AST, the AST populates a Registry and a TypeHierarchy, a fixed-point loop expands every concrete instantiation into a specialized class file, and finally the rewritten user code lands in the target -directory while specialized classes land in a cache directory under -`XPHP\Generated\