diff --git a/core/README.md b/core/README.md index 4dd61bba..cafa77d1 100644 --- a/core/README.md +++ b/core/README.md @@ -94,7 +94,7 @@ core/ ## Test ```bash -make -C core test/unit # PHPUnit: 184 tests / 495 assertions +make -C core test/unit # PHPUnit: 192 tests / 545 assertions make -C core test/mutation # Infection: 100% MSI, gate 95% ``` diff --git a/core/test/Transpiler/Monomorphize/ReifiedTypeParameterIntegrationTest.php b/core/test/Transpiler/Monomorphize/ReifiedTypeParameterIntegrationTest.php new file mode 100644 index 00000000..fcb1f6e2 --- /dev/null +++ b/core/test/Transpiler/Monomorphize/ReifiedTypeParameterIntegrationTest.php @@ -0,0 +1,276 @@ +` body, every bare + * single-segment `T` reference -- `new T()`, `T::class`, `T::method()`, + * `instanceof T`, `is_a($x, T::class)` -- is substituted at codegen with the + * concrete class. Specializer's substituting visitor matches any bare Name node + * whose text equals a type-param key, so all five patterns trip the same code + * path and produce a uniform substitution. + */ +final class ReifiedTypeParameterIntegrationTest extends TestCase +{ + private string $sourceDir; + private string $workDir; + private string $targetDir; + private string $cacheDir; + + protected function setUp(): void + { + $this->sourceDir = realpath(__DIR__ . '/../../fixture/compile/reified_t/source') + ?: throw new RuntimeException('Fixture missing'); + $this->workDir = sys_get_temp_dir() . '/xphp-reified-t-' . 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); + } + } + + public function testNewTSubstitutesToConcreteClass(): void + { + $this->compile(); + + $alphaContent = file_get_contents($this->specializedFile('App\\Models\\Alpha')); + $betaContent = file_get_contents($this->specializedFile('App\\Models\\Beta')); + + self::assertStringContainsString('new \\App\\Models\\Alpha()', $alphaContent); + self::assertStringContainsString('new \\App\\Models\\Beta()', $betaContent); + + self::assertStringNotContainsString('new T(', $alphaContent); + self::assertStringNotContainsString('new T(', $betaContent); + } + + public function testTClassSubstitutesToConcreteClass(): void + { + $this->compile(); + + $alphaContent = file_get_contents($this->specializedFile('App\\Models\\Alpha')); + $betaContent = file_get_contents($this->specializedFile('App\\Models\\Beta')); + + self::assertStringContainsString('\\App\\Models\\Alpha::class', $alphaContent); + self::assertStringContainsString('\\App\\Models\\Beta::class', $betaContent); + + self::assertStringNotContainsString('T::class', $alphaContent); + self::assertStringNotContainsString('T::class', $betaContent); + } + + public function testStaticMethodCallOnTSubstitutes(): void + { + $this->compile(); + + $alphaContent = file_get_contents($this->specializedFile('App\\Models\\Alpha')); + $betaContent = file_get_contents($this->specializedFile('App\\Models\\Beta')); + + self::assertStringContainsString('\\App\\Models\\Alpha::describe()', $alphaContent); + self::assertStringContainsString('\\App\\Models\\Beta::describe()', $betaContent); + + self::assertStringNotContainsString('T::describe', $alphaContent); + self::assertStringNotContainsString('T::describe', $betaContent); + } + + public function testInstanceofTSubstitutes(): void + { + $this->compile(); + + $alphaContent = file_get_contents($this->specializedFile('App\\Models\\Alpha')); + $betaContent = file_get_contents($this->specializedFile('App\\Models\\Beta')); + + self::assertStringContainsString('instanceof \\App\\Models\\Alpha', $alphaContent); + self::assertStringContainsString('instanceof \\App\\Models\\Beta', $betaContent); + + self::assertStringNotContainsString('instanceof T', $alphaContent); + self::assertStringNotContainsString('instanceof T', $betaContent); + } + + public function testIsAComposesWithSubstitutedTClass(): void + { + $this->compile(); + + $alphaContent = file_get_contents($this->specializedFile('App\\Models\\Alpha')); + $betaContent = file_get_contents($this->specializedFile('App\\Models\\Beta')); + + self::assertMatchesRegularExpression( + '/is_a\(\$x,\s*\\\\App\\\\Models\\\\Alpha::class\)/', + $alphaContent, + ); + self::assertMatchesRegularExpression( + '/is_a\(\$x,\s*\\\\App\\\\Models\\\\Beta::class\)/', + $betaContent, + ); + } + + public function testSpecializationsAreIndependent(): void + { + $this->compile(); + + $alphaContent = file_get_contents($this->specializedFile('App\\Models\\Alpha')); + $betaContent = file_get_contents($this->specializedFile('App\\Models\\Beta')); + + // Each specialization references only its own concrete type, never the other. + self::assertStringNotContainsString('App\\Models\\Beta', $alphaContent); + self::assertStringNotContainsString('App\\Models\\Alpha', $betaContent); + } + + public function testReifiedSubstitutionRoundTripsAtRuntime(): void + { + $this->compile(); + + $alphaFqn = Registry::generatedFqn('App\\Containers\\Reified', [new TypeRef('App\\Models\\Alpha')]); + $betaFqn = Registry::generatedFqn('App\\Containers\\Reified', [new TypeRef('App\\Models\\Beta')]); + + $alphaFile = $this->specializedFile('App\\Models\\Alpha'); + $betaFile = $this->specializedFile('App\\Models\\Beta'); + $alphaModel = $this->targetDir . '/Models/Alpha.php'; + $betaModel = $this->targetDir . '/Models/Beta.php'; + $markerInterface = $this->targetDir . '/Containers/Reified.php'; + + $runScript = $this->workDir . '/run.php'; + $script = <<phpExport($alphaModel)}; + require {$this->phpExport($betaModel)}; + require {$this->phpExport($markerInterface)}; + require {$this->phpExport($alphaFile)}; + require {$this->phpExport($betaFile)}; + + \$alphaFqn = {$this->phpExport($alphaFqn)}; + \$betaFqn = {$this->phpExport($betaFqn)}; + \$forAlpha = new \$alphaFqn(); + \$forBeta = new \$betaFqn(); + + \$alphaInstance = \$forAlpha->make(); + echo get_class(\$alphaInstance) === 'App\\\\Models\\\\Alpha' ? 'MAKE_ALPHA_OK' : 'MAKE_ALPHA_BAD', "\\n"; + echo \$forAlpha->className() === 'App\\\\Models\\\\Alpha' ? 'CLASS_ALPHA_OK' : 'CLASS_ALPHA_BAD', "\\n"; + echo \$forAlpha->describeStatic() === 'alpha' ? 'DESCRIBE_ALPHA_OK' : 'DESCRIBE_ALPHA_BAD', "\\n"; + echo \$forAlpha->isInstance(\$alphaInstance) ? 'INSTANCEOF_ALPHA_HIT_OK' : 'INSTANCEOF_ALPHA_HIT_BAD', "\\n"; + echo \$forAlpha->isInstance(\$forBeta->make()) ? 'INSTANCEOF_ALPHA_MISS_BAD' : 'INSTANCEOF_ALPHA_MISS_OK', "\\n"; + echo \$forAlpha->isInstanceViaIsA(\$alphaInstance) ? 'ISA_ALPHA_OK' : 'ISA_ALPHA_BAD', "\\n"; + + echo \$forBeta->className() === 'App\\\\Models\\\\Beta' ? 'CLASS_BETA_OK' : 'CLASS_BETA_BAD', "\\n"; + echo \$forBeta->describeStatic() === 'beta' ? 'DESCRIBE_BETA_OK' : 'DESCRIBE_BETA_BAD', "\\n"; + PHP; + file_put_contents($runScript, $script); + + $output = []; + $exit = 0; + exec('php ' . escapeshellarg($runScript) . ' 2>&1', $output, $exit); + self::assertSame(0, $exit, "runtime failed:\n" . implode("\n", $output)); + foreach ([ + 'MAKE_ALPHA_OK', + 'CLASS_ALPHA_OK', + 'DESCRIBE_ALPHA_OK', + 'INSTANCEOF_ALPHA_HIT_OK', + 'INSTANCEOF_ALPHA_MISS_OK', + 'ISA_ALPHA_OK', + 'CLASS_BETA_OK', + 'DESCRIBE_BETA_OK', + ] as $marker) { + self::assertContains($marker, $output, "missing runtime marker {$marker}"); + } + } + + public function testEmittedFilesAreSyntacticallyValid(): 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 specializedFile(string $concreteFqn): string + { + $fqn = Registry::generatedFqn('App\\Containers\\Reified', [new TypeRef($concreteFqn)]); + $prefix = Registry::GENERATED_NAMESPACE_PREFIX . '\\'; + $rel = str_starts_with($fqn, $prefix) ? substr($fqn, strlen($prefix)) : $fqn; + $path = $this->cacheDir . '/Generated/' . str_replace('\\', '/', $rel) . '.php'; + self::assertFileExists($path, "specialized class for {$concreteFqn} must exist"); + return $path; + } + + private function phpExport(string $value): string + { + return var_export($value, true); + } + + 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/core/test/fixture/compile/reified_t/source/Containers/Reified.xphp b/core/test/fixture/compile/reified_t/source/Containers/Reified.xphp new file mode 100644 index 00000000..ea54c60a --- /dev/null +++ b/core/test/fixture/compile/reified_t/source/Containers/Reified.xphp @@ -0,0 +1,33 @@ + +{ + public function make(): T + { + return new T(); + } + + public function className(): string + { + return T::class; + } + + public function describeStatic(): string + { + return T::describe(); + } + + public function isInstance(mixed $x): bool + { + return $x instanceof T; + } + + public function isInstanceViaIsA(mixed $x): bool + { + return is_a($x, T::class); + } +} diff --git a/core/test/fixture/compile/reified_t/source/Models/Alpha.xphp b/core/test/fixture/compile/reified_t/source/Models/Alpha.xphp new file mode 100644 index 00000000..95043e86 --- /dev/null +++ b/core/test/fixture/compile/reified_t/source/Models/Alpha.xphp @@ -0,0 +1,17 @@ +(); +$forBeta = new Reified(); diff --git a/docs/generics-comparison.md b/docs/generics-comparison.md index 92ed3280..556a76da 100644 --- a/docs/generics-comparison.md +++ b/docs/generics-comparison.md @@ -1,8 +1,31 @@ # Generic features xphp doesn't have yet -Baseline: xphp today (post `feat/generics-expansion`) has generic classes/interfaces/traits, free + method-scoped generic functions, single upper bounds, nested generics, and a marker-interface trick for `instanceof`. The compilation model is **monomorphization** — same as Rust, opposite of Java/Kotlin's erasure. That last point matters a lot for what's easy vs hard to add. +Baseline: xphp today ships generic classes / interfaces / traits (any arity, arbitrarily nested), free + method-scoped generic functions (static-call only), single upper bounds enforced at the class **and** method/free-function level with source-level error messages, transitive fixed-point specialization, collision-safe FQCN naming, and a marker-interface trick that makes `instanceof OriginalTemplate` work for every specialization. The compilation model is **monomorphization** — same as Rust, opposite of Java/Kotlin's erasure. That last point matters a lot for what's easy vs hard to add. -Below: gaps grouped by tier, with the language(s) that have each feature. Filtered for things that make sense in a PHP-targeted language — skipping Rust lifetimes, const generics over `usize`, TS template-literal types, etc. +Below: a quick cross-language reference, then the gaps grouped by tier with the language(s) that have each feature. Filtered for things that make sense in a PHP-targeted language — skipping Rust lifetimes, const generics over `usize`, TS template-literal types, etc. + +--- + +## Cross-language summary + +| Feature | TS | Kotlin | Rust | xphp | +|---|---|---|---|---| +| Generic classes/ifaces | ✅ | ✅ | ✅ | ✅ | +| Generic functions/methods | ✅ | ✅ | ✅ | ✅ method-scope = static-call only; free functions full | +| Upper bounds (class + method + free-function level) | ✅ | ✅ | ✅ | ✅ (single, enforced everywhere) | +| Multiple bounds | ✅ | ✅ | ✅ | ❌ | +| Default type params | ✅ | ✅ | ✅ | ❌ | +| Declaration-site variance | ✅ | ✅ | (via PhantomData) | ❌ | +| Use-site variance | ❌ | ✅ | n/a | ❌ | +| Reified via marker interface (`instanceof OriginalFqn`) | n/a | n/a | n/a | ✅ | +| Reified placeholder in generic body (`instanceof T`, `T::class`, `new T(...)`, `T::method(...)`, `is_a($x, T::class)`) | ❌ | ✅ (inline) | ✅ (monomorphic) | ✅ | +| Generic type aliases | ✅ | ✅ | ✅ | ❌ | +| F-bounded recursion | ✅ | ✅ | ✅ | ❌ (bound is bare string) | +| Wildcard / `*` | ✅ (`unknown`) | ✅ | n/a | ⚠ via marker | +| Variadic generics | ✅ | ❌ | ⚠ tuples | ❌ | +| Generic enums / sums | ✅ | ✅ | ✅ | ❌ | +| Per-arg specialization | ❌ | ❌ | ⚠ nightly | ❌ | +| Associated types | ❌ | ❌ | ✅ | ❌ | --- @@ -16,7 +39,7 @@ class Producer { public function get(): T; } // covariant class Consumer { public function set(T $x); } // contravariant ``` -Today every `Box` and `Box` are unrelated even if `Plastic extends Animal`. With marker interfaces, the only commonality is the erased `Box`. Adding `out T` would let the compiler emit `Box_ implements Box_` when `Plastic <: Animal` — a real subtype relationship at the specialized FQN level. +Today every `Box` and `Box` are unrelated even when `Dog extends Animal`. With marker interfaces, the only commonality is the erased `Box`. Adding `out T` would let the compiler emit `Box_ implements Box_` whenever `Dog <: Animal` — a real subtype relationship at the specialized FQN level. **Why high-value**: monomorphization already produces per-specialization classes; wiring up the right `implements` chains is mostly a hierarchy lookup at specialization time. @@ -43,21 +66,9 @@ class Sortable where T: \Stringable, T: \Countable { ... } Bound validation (`Registry::validateBounds`) already loops per param; trivially extends to loop per (param, bound) pair. Parser is the only real change. ### 4. Instance-method generic calls -Already on the MVP-gaps list. `$obj->method(...)` requires knowing the static type of `$obj`. Without inference, the workaround in monomorphized worlds is **declaration-site annotation**: if `$obj` is typed as `Util` in the signature, the compiler knows the receiver class. With strict typing, this covers the majority of practical cases. - -### 5. Reified type parameters -A headline Kotlin feature. Rust gets the same effect "for free" via monomorphization — the type IS known at codegen time. - -```php -function decode(string $json): T { - $data = json_decode($json, true); - return new T(...$data); // T is concrete in the specialized body -} -``` - -Today the `Specializer` already substitutes `new T()` (the `New_` class field is a `Name`) — so this works **incidentally**. The gap is that user code can't write `if ($x instanceof T)` and reason about it as part of the documented contract. Worth promoting from "accidentally works" to "documented capability" with `T::class` / `instanceof T` / `is_a($x, T::class)` all guaranteed. +Method-scoped generics ship today for static call sites (`Util::identity(...)`); the gap is instance calls (`$obj->method(...)`), which require knowing the static type of `$obj`. Without inference, the workaround in monomorphized worlds is **declaration-site annotation**: if `$obj` is typed as `Util` in the signature, the compiler knows the receiver class. With strict typing, this covers the majority of practical cases. -### 6. Generic type aliases +### 5. Generic type aliases TypeScript (`type Result = ...`), Rust (`type Result = ...`), Kotlin (`typealias`). ```php @@ -156,36 +167,14 @@ The `static` part should "just work" in specializations — worth a fixture to l --- -## Cross-language summary - -| Feature | TS | Kotlin | Rust | xphp | -|---|---|---|---|---| -| Generic classes/ifaces | ✅ | ✅ | ✅ | ✅ | -| Generic functions/methods | ✅ | ✅ | ✅ | ✅ (static-call only) | -| Upper bounds | ✅ | ✅ | ✅ | ✅ (single) | -| Multiple bounds | ✅ | ✅ | ✅ | ❌ | -| Default type params | ✅ | ✅ | ✅ | ❌ | -| Declaration-site variance | ✅ | ✅ | (via PhantomData) | ❌ | -| Use-site variance | ❌ | ✅ | n/a | ❌ | -| Reified at runtime | ❌ | ✅ (inline) | ✅ (monomorphic) | ⚠ accidental | -| Generic type aliases | ✅ | ✅ | ✅ | ❌ | -| F-bounded recursion | ✅ | ✅ | ✅ | ❌ (bound is bare string) | -| Wildcard / `*` | ✅ (`unknown`) | ✅ | n/a | ⚠ via marker | -| Variadic generics | ✅ | ❌ | ⚠ tuples | ❌ | -| Generic enums / sums | ✅ | ✅ | ✅ | ❌ | -| Per-arg specialization | ❌ | ❌ | ⚠ nightly | ❌ | -| Associated types | ❌ | ❌ | ✅ | ❌ | -| `instanceof OriginalFqn` | n/a | n/a | n/a | ✅ (clever) | - ---- - ## Next opportunities -The following features seem to offer a better ROI: +Aligned with `roadmap.md`'s **Next** horizon (Type system depth + Generic surface). In rough priority order: 1. **Default type params + multiple bounds** — both are mostly parser changes, low risk, high quality-of-life. 2. **Variance annotations (`in`/`out`)** — leverages the monomorphization model uniquely; emit the right `implements` chains between specializations. -3. **Generic type aliases** — unlocks reuse, and since xphp doesn't have nominal types yet the design is unconstrained. -4. **Reified-T as a documented contract** — `T::class`, `instanceof T`, `is_a($x, T::class)` all guaranteed. xphp already pays for monomorphization; this is the user-facing payoff over Java/Kotlin. +3. **F-bounded recursion** (`T: Comparable`) — promote `boundFqn` from bare string to `TypeRef` so the bound can itself be generic. +4. **Instance-method generic calls** on a typed receiver — declaration-site annotation gives the compiler the receiver class without inference. +5. **Generic type aliases** — unlocks reuse, and since xphp doesn't have nominal types yet the design is unconstrained. -The combination unique to xphp is **(2) + (4) together**: PHP would become the only mainstream PHP-shaped language with Rust-style reified-and-monomorphized generics _plus_ variance — a differentiator the community can point to. +The combination unique to xphp is **(2) plus the already-shipped reified-T (Tier 1 #5)**: PHP would become the only mainstream PHP-shaped language with Rust-style reified-and-monomorphized generics _plus_ variance — a differentiator the community can point to. diff --git a/docs/generics.md b/docs/generics.md index 1a3b0e2d..240178d4 100644 --- a/docs/generics.md +++ b/docs/generics.md @@ -7,6 +7,37 @@ For the broader project context, see the [README](/README.md). --- +## Monomorphization + +> "monomorphization is a compile-time process where polymorphic functions are replaced by many monomorphic functions for +> each unique instantiation [...]" +> +> from [Wikipedia](https://en.wikipedia.org/wiki/Monomorphization) + +It means a code instantiating `Map` with `50` distinct `(K, V)` combinations produces `50` generated files. + +That's the price `xphp` pays to have the following: + +- **Zero runtime overhead.** OpCache compiles each specialized class once; subsequent instantiations are normal `new` + calls. +- **Honest reflection.** `ReflectionParameter::getType()->getName()` returns the real concrete type -- what DI + containers and serializers actually need. +- **Native `TypeError` enforcement** at every boundary, without writing one line of reflection-aware glue. + +Type erasure, on the other hand, generates fewer files at the price of re-introducing the exact problem `xphp` exists +to solve. The trade is intentional. + +--- + +## Comparison VS other languages + +For a side-by-side comparison against TypeScript, Kotlin, and Rust — including the features xphp doesn't have yet, +ordered by tier and tied to the monomorphization model — see [`generics-comparison.md`](generics-comparison.md). It's +the strategic counterpart to this reference: this file documents _what works_; the comparison documents _what's next, +and why_. + +--- + ## How it works The `xphp compile` command goes through the following phases @@ -132,13 +163,16 @@ Util::identity(42); // -> Util::identity_T_(42) Util::identity('hi'); // -> Util::identity_T_('hi') ``` -MVP limits: static-call sites only (`Util::method<…>`); method must be on a non-generic enclosing class; bound checks on -method-level type-params aren't enforced yet. +Bound checks on method-level type-params are enforced at compile time the same way class-level bounds are -- the +call-site walk routes through `Registry::checkBounds`. + +MVP limits: static-call sites only (`Util::method<...>`); method must be on a non-generic enclosing class. ### Free generic functions Same shape as method generics but at namespace scope. `function foo(...)` becomes one mangled function per unique -arg-list, appended to the enclosing namespace; call sites rewrite to fully-qualified mangled refs. +arg-list, appended to the enclosing namespace; call sites rewrite to fully-qualified mangled refs. Bound checks run +at compile time, same as method-level and class-level type-params. MVP limit: the function must live inside a `namespace { ... }` block; bare top-level functions aren't supported yet. @@ -183,28 +217,3 @@ The namespace mirrors the template's original `FQCN`, so two `Box` classes in di regardless of how their args are spelled. Hash-collision detection at recording time will fail loudly with both colliding instantiations, the current hash length, and a re-run command using a longer hash. - -### Why monomorphization (and what it costs) - -One class file per unique instantiation. A codebase instantiating `Map` with `50` distinct `(K, V)` combinations -produces `50` generated files. That's the cost. - -In exchange: - -- **Zero runtime overhead.** OpCache compiles each specialized class once; subsequent instantiations are normal `new` - calls. -- **Honest reflection.** `ReflectionParameter::getType()->getName()` returns the real concrete type -- what DI - containers and serializers actually need. -- **Native `TypeError` enforcement** at every boundary, without writing one line of reflection-aware glue. - -Type erasure (the phpdoc / attribute path) generates fewer files at the price of re-introducing the exact problem `xphp` -exists to solve. The trade is intentional. - ---- - -## Where xphp's generics stand vs other languages - -For a side-by-side comparison against TypeScript, Kotlin, and Rust — including the features xphp doesn't have yet, -ordered by tier and tied to the monomorphization model — see [`generics-comparison.md`](generics-comparison.md). It's -the strategic counterpart to this reference: this file documents _what works_; the comparison documents _what's next, -and why_. diff --git a/docs/roadmap.md b/docs/roadmap.md index da9cd12d..107dbef3 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -19,12 +19,14 @@ timeline Function-level generics : method-scoped generics — static calls only : free generic functions at namespace scope + : bound validation for method-level and free-function type-params Type-parameter bounds : single upper bound (e.g. T must extend Stringable) validated at compile time - : built-in interface whitelist (Stringable / Countable / Iterator / …) + : built-in interface whitelist (Stringable / Countable / Iterator / ...) : error messages reference the source-level instantiation, not the hash Runtime semantics : instanceof Template via generated marker interfaces + : reified T inside a generic body Naming and collisions : sha256-based generated FQCN, namespace mirrors template : build-time hash-collision detection with copy-pasteable widen command @@ -52,11 +54,9 @@ timeline : default type parameters : multiple bounds (T must satisfy A and B) : variance annotations (covariant / contravariant) — leverages monomorphization for real subtype edges - : reified T as documented contract (T-class / instanceof T / is_a) : F-bounded recursion (T bounded by a generic of itself) Generic surface : instance-method generic calls on a typed receiver - : bound validation on method-level type-params : generic type aliases Developer experience : Composer plugin for autoload registration diff --git a/playground/README.md b/playground/README.md index 041cb599..731227a9 100644 --- a/playground/README.md +++ b/playground/README.md @@ -10,35 +10,48 @@ one level up — the same shape any downstream consumer would use. playground/bin/run ``` -The first invocation runs `composer install` (which symlinks the parent -project into `playground/vendor/` via the path repo). After that it compiles -every `.xphp` under `src/`, then runs all the demos in turn. - -The Makefile at the repo root has a `playground` target wrapping the same -command: - -```bash -make playground -``` +The first invocation runs `composer install` (which symlinks the +`core/` package into `playground/vendor/` via the path repo). After +that it compiles every `.xphp` under `src/`, then runs all the demos +in turn. ## What's in here ``` playground/ -├── composer.json # own package, path-repo'd at ../ -├── bin/run # compile + run all demos -├── src/ -│ ├── Models/ # plain final classes used as type args -│ ├── Containers/ # the generic templates -│ │ ├── Box.xphp # Box -│ │ ├── Pair.xphp # Pair -│ │ ├── Map.xphp # Map -│ │ ├── Collection.xphp # Collection — uses T[] + ?T sugar -│ │ └── Wrapper.xphp # Wrapper { Box $box; } — transitive -│ └── Demos/ # top-level scripts that exercise each feature bucket -└── var/ - ├── cache/ # generated specialized classes (gitignored) - └── dist/ # rewritten .xphp → .php (gitignored) +|-- composer.json # own package, path-repo'd at ../core/ +|-- bin/run # compile + run all demos +|-- src/ +| |-- Models/ # plain final classes used as type args +| | | # (Animal, Cat, Dog, Food, Plastic, Stats, Tag, +| | | # User, Wolf -- Wolf is the subclass-protected +| | | # LSP completion fixture) +| |-- Containers/ # the generic templates +| | |-- Box.xphp # Box +| | |-- Pair.xphp # Pair +| | |-- Map.xphp # Map +| | |-- Collection.xphp # Collection -- uses T[] + ?T sugar +| | |-- Wrapper.xphp # Wrapper { Box $box; } -- transitive +| | |-- Reified.xphp # Reified -- new T(), T::class, instanceof T +| | |-- Repository.xphp # Repository -- interface +| | |-- InMemoryRepository.xphp # in-memory Repository impl +| | |-- StringableBox.xphp # Box -- bound demo +| | `-- Util.xphp # Util::identity -- method/free-function generics +| |-- Demos/ # top-level scripts that exercise each feature +| | # bucket -- compiled + executed by bin/run. +| | # (SingleType, MultiType, NestedTransitive, +| | # ArraySugar, Bounds, GenericInterface, +| | # GenericMethod, GenericFunction, Inheritance, +| | # InstanceofTemplate, Reified) +| `-- LspFixtures/ # LSP-only fixtures -- opened in the IDE to verify +| # editor behaviour (completion, hover, GTD). +| # Compiled by bin/run but NOT in its execute list. +| # (Phase3BoundAware, Phase3ClosedFile, +| # Phase3ScopeAwareVars, Phase3StaticProp, +| # Phase3TieBreak, Phase3Utf16) +`-- var/ + |-- cache/ # generated specialized classes (gitignored) + `-- dist/ # rewritten .xphp -> .php (gitignored) ``` Each demo reflects on the compiled class to prove that the generic parameter diff --git a/playground/bin/run b/playground/bin/run index be47caf8..9c73ba3a 100755 --- a/playground/bin/run +++ b/playground/bin/run @@ -36,6 +36,7 @@ $demos = [ 'var/dist/Demos/GenericMethod.php', 'var/dist/Demos/GenericFunction.php', 'var/dist/Demos/InstanceofTemplate.php', + 'var/dist/Demos/Reified.php', ]; fwrite(STDOUT, "==> Running demos\n\n"); diff --git a/playground/src/Containers/InMemoryRepository.xphp b/playground/src/Containers/InMemoryRepository.xphp index 092ecbb5..550db093 100644 --- a/playground/src/Containers/InMemoryRepository.xphp +++ b/playground/src/Containers/InMemoryRepository.xphp @@ -25,6 +25,6 @@ class InMemoryRepository implements Repository public function items(): Collection { - return new Collection($this->items); + return new Collection(...$this->items); } } diff --git a/playground/src/Containers/Reified.xphp b/playground/src/Containers/Reified.xphp new file mode 100644 index 00000000..2cb0f615 --- /dev/null +++ b/playground/src/Containers/Reified.xphp @@ -0,0 +1,35 @@ + -- every bare `T` inside the body is substituted at codegen with + * the concrete class. `new T(...)`, `T::class`, `instanceof T`, + * `is_a($x, T::class)` all produce specialized PHP that references the + * concrete type directly, so the monomorphized output behaves the same way + * Rust does for reified generics. No reflection / runtime type token needed. + */ +class Reified +{ + public function make(string $arg): T + { + return new T($arg); + } + + public function className(): string + { + return T::class; + } + + public function isInstance(mixed $x): bool + { + return $x instanceof T; + } + + public function isInstanceViaIsA(mixed $x): bool + { + return is_a($x, T::class); + } +} diff --git a/playground/src/Demos/Reified.xphp b/playground/src/Demos/Reified.xphp new file mode 100644 index 00000000..64a79e8b --- /dev/null +++ b/playground/src/Demos/Reified.xphp @@ -0,0 +1,35 @@ +(); +$foodOps = new Reified(); + +// `T::class` -> the substituted FQN. No reflection needed. +echo ' Reified::className() = ', $tagOps->className(), PHP_EOL; +echo ' Reified::className() = ', $foodOps->className(), PHP_EOL; + +// `new T($arg)` -> `new \App\Models\Tag($arg)` after specialization. +$tag = $tagOps->make('blue'); +$food = $foodOps->make('pizza'); +echo ' make("blue")->name = ', $tag->name, ' (', $tag::class, ')', PHP_EOL; +echo ' make("pizza")->label = ', $food->label, ' (', $food::class, ')', PHP_EOL; + +// `instanceof T` -> `instanceof \App\Models\Tag`. Crosses real subtype edges, +// not just the marker interface. +echo ' $tag instanceof Tag = ', $tagOps->isInstance($tag) ? 'yes' : 'NO', PHP_EOL; +echo ' $food instanceof Tag = ', $tagOps->isInstance($food) ? 'yes' : 'NO', PHP_EOL; + +// `is_a($x, T::class)` composes with the substituted T::class above. +echo ' is_a($tag, T::class) for Tag = ', $tagOps->isInstanceViaIsA($tag) ? 'yes' : 'NO', PHP_EOL; +echo ' is_a($food, T::class) for Tag = ', $tagOps->isInstanceViaIsA($food) ? 'yes' : 'NO', PHP_EOL; + +echo PHP_EOL; diff --git a/playground/src/Demos/Phase3BoundAware.xphp b/playground/src/LspFixtures/Phase3BoundAware.xphp similarity index 90% rename from playground/src/Demos/Phase3BoundAware.xphp rename to playground/src/LspFixtures/Phase3BoundAware.xphp index ac7b8722..614a8a7d 100644 --- a/playground/src/Demos/Phase3BoundAware.xphp +++ b/playground/src/LspFixtures/Phase3BoundAware.xphp @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\Demos; +namespace App\LspFixtures; use App\Containers\StringableBox; use App\Models\Tag; @@ -22,7 +22,7 @@ use App\Models\User; // - everything shows (classes + scalars). // CURSOR HERE -- delete the inside of the `<>` and trigger completion. -$bounded = new StringableBox(new Tag('phase3')); +$bounded = new StringableBox(new Tag('phase3')); // Reference uses so the file parses + classes resolve: echo $bounded->describe(); diff --git a/playground/src/Demos/Phase3ClosedFile.xphp b/playground/src/LspFixtures/Phase3ClosedFile.xphp similarity index 96% rename from playground/src/Demos/Phase3ClosedFile.xphp rename to playground/src/LspFixtures/Phase3ClosedFile.xphp index 77ac834f..c7128fef 100644 --- a/playground/src/Demos/Phase3ClosedFile.xphp +++ b/playground/src/LspFixtures/Phase3ClosedFile.xphp @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\Demos; +namespace App\LspFixtures; use App\Models\Tag; diff --git a/playground/src/Demos/Phase3ScopeAwareVars.xphp b/playground/src/LspFixtures/Phase3ScopeAwareVars.xphp similarity index 98% rename from playground/src/Demos/Phase3ScopeAwareVars.xphp rename to playground/src/LspFixtures/Phase3ScopeAwareVars.xphp index d1ced294..a482dca6 100644 --- a/playground/src/Demos/Phase3ScopeAwareVars.xphp +++ b/playground/src/LspFixtures/Phase3ScopeAwareVars.xphp @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\Demos; +namespace App\LspFixtures; // Phase 3 fixture: scope-aware variable completion. // diff --git a/playground/src/Demos/Phase3StaticProp.xphp b/playground/src/LspFixtures/Phase3StaticProp.xphp similarity index 95% rename from playground/src/Demos/Phase3StaticProp.xphp rename to playground/src/LspFixtures/Phase3StaticProp.xphp index 41669505..cd51f9d1 100644 --- a/playground/src/Demos/Phase3StaticProp.xphp +++ b/playground/src/LspFixtures/Phase3StaticProp.xphp @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\Demos; +namespace App\LspFixtures; use App\Models\Stats; diff --git a/playground/src/Demos/Phase3TieBreak.xphp b/playground/src/LspFixtures/Phase3TieBreak.xphp similarity index 97% rename from playground/src/Demos/Phase3TieBreak.xphp rename to playground/src/LspFixtures/Phase3TieBreak.xphp index c9ca1650..7387b36d 100644 --- a/playground/src/Demos/Phase3TieBreak.xphp +++ b/playground/src/LspFixtures/Phase3TieBreak.xphp @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\Demos; +namespace App\LspFixtures; // Phase 3 fixture: short-name tie-break in findClassByName. // diff --git a/playground/src/Demos/Phase3Utf16.xphp b/playground/src/LspFixtures/Phase3Utf16.xphp similarity index 96% rename from playground/src/Demos/Phase3Utf16.xphp rename to playground/src/LspFixtures/Phase3Utf16.xphp index debf88a6..019eeb79 100644 --- a/playground/src/Demos/Phase3Utf16.xphp +++ b/playground/src/LspFixtures/Phase3Utf16.xphp @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\Demos; +namespace App\LspFixtures; use App\Models\User; diff --git a/playground/src/Demos/Phase3SubclassProtected.xphp b/playground/src/Models/Wolf.xphp similarity index 100% rename from playground/src/Demos/Phase3SubclassProtected.xphp rename to playground/src/Models/Wolf.xphp diff --git a/tools/lsp/README.md b/tools/lsp/README.md index 5a443377..52134fef 100644 --- a/tools/lsp/README.md +++ b/tools/lsp/README.md @@ -59,22 +59,41 @@ tools/lsp/ │ │ └── DiagnosticTranslator framework-neutral → wire-format │ ├── Handler/ │ │ ├── AstPositionResolver find smallest Name at byte offset +│ │ ├── XphpTextDocumentHandler didOpen / didChange / didClose (full-sync overlay) │ │ ├── XphpHoverHandler textDocument/hover (xphp + PHP fall-through) │ │ ├── XphpDefinitionHandler textDocument/definition (xphp + PHP fall-through) │ │ ├── XphpCompletionHandler textDocument/completion (xphp + PHP fall-through) -│ │ ├── TypeArgPositionDetector backwards-scanner for cursor-in-<…> +│ │ ├── XphpReferencesHandler textDocument/references (with subclass-inherited walks) +│ │ ├── XphpRenameHandler textDocument/rename (alias-aware, optional file rename) +│ │ ├── XphpDocumentSymbolHandler textDocument/documentSymbol (hierarchical outline) +│ │ ├── XphpWorkspaceSymbolHandler workspace/symbol (cross-file, FqnIndex-backed) +│ │ ├── XphpFileWatcherHandler workspace/didChangeWatchedFiles (invalidates FqnIndex) +│ │ ├── TypeArgPositionDetector backwards-scanner for cursor-in-<...> │ │ └── WorkspaceSymbols collect ClassLike FQNs across open docs │ ├── Reflection/ │ │ ├── ReflectorFactory builds worse-reflection Reflector for the session │ │ ├── WorkspaceSourceLocator serves open documents (stripped to PHP) to worse-reflection -│ │ └── FilesystemSourceLocator serves on-disk .xphp / .php files (stripped to PHP) +│ │ ├── FilesystemSourceLocator serves on-disk .xphp / .php files (stripped to PHP) +│ │ └── FqnIndex unified open-doc + on-disk FQN -> declaration index │ ├── Resolver/ │ │ ├── PhpDefinitionResolver PHP-semantic GTD via worse-reflection (classes / funcs / methods / props / native stubs) -│ │ ├── PhpHoverResolver signature + docblock hover via worse-reflection -│ │ ├── PhpCompletionResolver member / static-member completion via worse-reflection -│ │ └── PhpCompletionContext source-level detector for `$obj->` / `Cls::` cursor positions -│ └── (phpactor's own Workspace handles document open/change/close; no -│ local DocumentStore wrapper needed) +│ │ ├── PhpHoverResolver signature + docblock hover, param + return-type substitution +│ │ ├── PhpCompletionResolver member / static / type-arg / variable / `Cls::$prop` completion +│ │ ├── PhpCompletionContext source-level detector for `$obj->` / `Cls::` cursor positions +│ │ ├── CompletionIndex candidate enumeration backed by FqnIndex +│ │ ├── CompositeClassLikeLookup open-doc overlay over filesystem ClassLike lookup +│ │ ├── WorkspaceClassLikeLookup open-doc ClassLike resolver +│ │ ├── FilesystemClassLikeLookup filesystem ClassLike resolver +│ │ ├── GenericResolver expands generic-param maps for substitution +│ │ ├── GenericParamRegistry prettify pass for placeholder pairs +│ │ ├── ReferenceFinder cross-file reference collector w/ inheritance walks +│ │ ├── RenameProvider alias-aware rename + RenameFile ops gating +│ │ ├── VarBinding scope-aware variable typing (top-level / function / closure) +│ │ ├── MethodCallSubstitution receiver-type-aware param + return substitution +│ │ ├── ResolvedType value object for resolved type expressions +│ │ └── StubsIndex worse-reflection's PhpStorm-stubs index, file-cached +│ └── (phpactor's own Workspace handles document open/change/close transport; +│ XphpTextDocumentHandler is our overlay on top of it.) └── test/ PHPUnit suite ``` diff --git a/tools/phpstorm-plugin/README.md b/tools/phpstorm-plugin/README.md index a4a931bd..d6561404 100644 --- a/tools/phpstorm-plugin/README.md +++ b/tools/phpstorm-plugin/README.md @@ -1,18 +1,23 @@ # xphp PhpStorm plugin Editing intelligence for `.xphp` files inside PhpStorm -- diagnostics, hover, -go-to-definition, completion -- driven by the same Language Server Protocol -implementation that backs the VS Code extension at +go-to-definition, completion, references, rename, document and workspace +symbols -- driven by the same Language Server Protocol implementation that +backs the VS Code extension at [`tools/vscode-extension/`](/tools/vscode-extension/). One server, one TextMate grammar, two editor integrations. | Feature | How | |---|---| | Diagnostics (parse errors, generic-bound violations, duplicate templates) | LSP `textDocument/publishDiagnostics` | -| Hover (specialized FQN + bound info) | LSP `textDocument/hover` | -| Go-to-definition (across files, into specialized classes) | LSP `textDocument/definition` | -| Completion (class names inside `<...>` type-arg positions) | LSP `textDocument/completion` | -| File-type recognition (`.xphp`) | IntelliJ `com.intellij.fileType` extension | +| Hover (specialized FQN + bound info, parameter & return-type substitution) | LSP `textDocument/hover` | +| Go-to-definition (across files, into specialized classes, filesystem-only targets) | LSP `textDocument/definition` | +| Completion (member / static access, type-arg positions, scope-aware variables, `Cls::$prop`) | LSP `textDocument/completion` | +| Find references (classes, functions, methods, properties, subclass-inherited walks) | LSP `textDocument/references` | +| Rename symbol (alias-aware; file rename gated on client `resourceOperations`) | LSP `textDocument/rename` | +| Document outline (Cmd+O / Structure panel) | LSP `textDocument/documentSymbol` | +| Workspace symbol search | LSP `workspace/symbol` | +| Syntax highlighting (`.xphp`) | Bundled TextMate grammar registered at project open via `XphpBundleRegistrar` | | Zero-config server install | Bundled PHAR auto-extracted on first plugin load | ## Requirements @@ -90,20 +95,29 @@ make run-ide Open file.xphp in PhpStorm | v +XphpBundleRegistrar (postStartupActivity) + | registers tools/vscode-extension/syntaxes/xphp.tmLanguage.json + | as a TextMate user-bundle the first time a project opens + v +TextMate plugin claims .xphp via the bundle's `fileTypes: ["xphp"]` + | (the plugin deliberately registers NO `` extension -- + | see plugin.xml for the rationale) + v XphpLspServerSupportProvider.fileOpened() - | (filters on XphpFileType) + | (filters by file extension == "xphp") v XphpLspServerDescriptor.createCommandLine() | (1) XphpSettings.lspPath if set, else | (2) PharExtractor.extract() -> system-dir/xphp/xphp-lsp.phar - | (3) else error pointing at settings + | (3) else a balloon notification with an "Open Settings..." action v IntelliJ Platform LSP API spawns `php ` over stdio | v xphp Language Server (tools/lsp/) replies with diagnostics, hover, -definition, completion, semantic tokens -- the platform threads -them into the standard editor UI. +definition, completion, references, rename, documentSymbol, +workspace/symbol -- the platform threads them into the standard +editor UI via LspCustomization (all the above features enabled). ``` ## Why this lives under `tools/`