diff --git a/Makefile b/Makefile index 5ad2bbdc..fba69401 100644 --- a/Makefile +++ b/Makefile @@ -5,3 +5,7 @@ test/unit: .PHONY: test/mutation test/mutation: php vendor/bin/infection --show-mutations=max --threads=max --min-covered-msi=93 + +.PHONY: playground +playground: + playground/bin/run diff --git a/bin/xphp b/bin/xphp index d7912821..cdf9b111 100755 --- a/bin/xphp +++ b/bin/xphp @@ -1,4 +1,4 @@ -#!/usr/local/bin/php +#!/usr/bin/env php +│ │ ├── 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) +``` + +Each demo reflects on the compiled class to prove that the generic parameter +became a real native type — `ReflectionProperty::getType()->getName()` returns +the concrete class, not `mixed` or `T`. The reflection output is the load-bearing +assertion: it's what DI containers and serializers actually look at. + +## Iterating + +Edit any `.xphp` file under `src/`, then re-run `playground/bin/run`. The +compile step is idempotent and only rewrites changed paths' output. To start +fresh, `rm -rf playground/var/` and re-run. diff --git a/playground/_site_src/.gitignore b/playground/_site_src/.gitignore new file mode 100644 index 00000000..8b5ae8e6 --- /dev/null +++ b/playground/_site_src/.gitignore @@ -0,0 +1 @@ +_config.yml \ No newline at end of file diff --git a/playground/_site_src/index.md b/playground/_site_src/index.md new file mode 100644 index 00000000..0a3c5dfe --- /dev/null +++ b/playground/_site_src/index.md @@ -0,0 +1,65 @@ +--- +title: "xphp playground — unknown" +layout: default +--- + +# xphp playground + +Browse the `.xphp` sources and the compiled artifacts from a recent run. + +| | | +|---|---| +| Branch | `unknown` | +| Commit | [`unknown`](https://github.com/xphp-lang/xphp-parser) | +| Built | `2026-05-21T01:52:58Z` | +| Source | [https://github.com/xphp-lang/xphp-parser](https://github.com/xphp-lang/xphp-parser) | + +## Source `.xphp` files (12) +{: id="source"} + +Authored input that the xphp compiler consumes. Generic templates live under `Containers/`; the demo scripts under `Demos/` instantiate them. + +- [`Containers/Box.xphp`](src/Containers/Box.xphp.md) +- [`Containers/Collection.xphp`](src/Containers/Collection.xphp.md) +- [`Containers/Map.xphp`](src/Containers/Map.xphp.md) +- [`Containers/Pair.xphp`](src/Containers/Pair.xphp.md) +- [`Containers/Wrapper.xphp`](src/Containers/Wrapper.xphp.md) +- [`Demos/ArraySugar.xphp`](src/Demos/ArraySugar.xphp.md) +- [`Demos/MultiType.xphp`](src/Demos/MultiType.xphp.md) +- [`Demos/NestedTransitive.xphp`](src/Demos/NestedTransitive.xphp.md) +- [`Demos/SingleType.xphp`](src/Demos/SingleType.xphp.md) +- [`Models/Food.xphp`](src/Models/Food.xphp.md) +- [`Models/Plastic.xphp`](src/Models/Plastic.xphp.md) +- [`Models/User.xphp`](src/Models/User.xphp.md) + +## Rewritten user code (`var/dist/`) (12) +{: id="dist"} + +Each `.xphp` source rewritten to native PHP — generic call sites get pointed at the matching specialized class. + +- [`Containers/Box.php`](var/dist/Containers/Box.php.md) +- [`Containers/Collection.php`](var/dist/Containers/Collection.php.md) +- [`Containers/Map.php`](var/dist/Containers/Map.php.md) +- [`Containers/Pair.php`](var/dist/Containers/Pair.php.md) +- [`Containers/Wrapper.php`](var/dist/Containers/Wrapper.php.md) +- [`Demos/ArraySugar.php`](var/dist/Demos/ArraySugar.php.md) +- [`Demos/MultiType.php`](var/dist/Demos/MultiType.php.md) +- [`Demos/NestedTransitive.php`](var/dist/Demos/NestedTransitive.php.md) +- [`Demos/SingleType.php`](var/dist/Demos/SingleType.php.md) +- [`Models/Food.php`](var/dist/Models/Food.php.md) +- [`Models/Plastic.php`](var/dist/Models/Plastic.php.md) +- [`Models/User.php`](var/dist/Models/User.php.md) + +## Specialized classes (`var/cache/`) (7) +{: id="cache"} + +Monomorphized class per unique generic instantiation. Loaded via the `XPHP\Generated\` PSR-4 prefix. Class shortnames are SHA-256-derived hashes of the canonical argument list. + +- [`Generated/App/Containers/Box/T_3036c16466fa04e2ed024d6b75086b6a023199482af4f6a278a33d8bd9ebe44f.php`](var/cache/Generated/App/Containers/Box/T_3036c16466fa04e2ed024d6b75086b6a023199482af4f6a278a33d8bd9ebe44f.php.md) +- [`Generated/App/Containers/Box/T_de1e0eaabedbafa176d971782f59025438ff880765b54fb07b0698700f21a0cd.php`](var/cache/Generated/App/Containers/Box/T_de1e0eaabedbafa176d971782f59025438ff880765b54fb07b0698700f21a0cd.php.md) +- [`Generated/App/Containers/Collection/T_d59a159d1113876f898c70f94a3971da6bb3165b1a8f0c07236c92d911df85ed.php`](var/cache/Generated/App/Containers/Collection/T_d59a159d1113876f898c70f94a3971da6bb3165b1a8f0c07236c92d911df85ed.php.md) +- [`Generated/App/Containers/Map/T_ddc02d7f9d24d965819939d5b07227efa95da520d87d6728a4c41906ecce5bca.php`](var/cache/Generated/App/Containers/Map/T_ddc02d7f9d24d965819939d5b07227efa95da520d87d6728a4c41906ecce5bca.php.md) +- [`Generated/App/Containers/Pair/T_307894382bcf10563dfe258ab0eef6cf6d925ef67d412e2b8743703540f18bdd.php`](var/cache/Generated/App/Containers/Pair/T_307894382bcf10563dfe258ab0eef6cf6d925ef67d412e2b8743703540f18bdd.php.md) +- [`Generated/App/Containers/Pair/T_3ca1518773f5e15bb180bc027c8394e76250619a79189c7cb33652fd9815b2ed.php`](var/cache/Generated/App/Containers/Pair/T_3ca1518773f5e15bb180bc027c8394e76250619a79189c7cb33652fd9815b2ed.php.md) +- [`Generated/App/Containers/Wrapper/T_de1e0eaabedbafa176d971782f59025438ff880765b54fb07b0698700f21a0cd.php`](var/cache/Generated/App/Containers/Wrapper/T_de1e0eaabedbafa176d971782f59025438ff880765b54fb07b0698700f21a0cd.php.md) + diff --git a/playground/_site_src/src/Containers/Box.xphp.md b/playground/_site_src/src/Containers/Box.xphp.md new file mode 100644 index 00000000..1b1a77dc --- /dev/null +++ b/playground/_site_src/src/Containers/Box.xphp.md @@ -0,0 +1,29 @@ +--- +title: "src/Containers/Box.xphp" +layout: default +--- + +[← index](../../index.md) · [Source `.xphp` files](../../index.md#source) + +# `src/Containers/Box.xphp` + +```php + +{ + public function __construct(public T $item) + { + } + + public function get(): T + { + return $this->item; + } +} + +``` diff --git a/playground/_site_src/src/Containers/Collection.xphp.md b/playground/_site_src/src/Containers/Collection.xphp.md new file mode 100644 index 00000000..4d403693 --- /dev/null +++ b/playground/_site_src/src/Containers/Collection.xphp.md @@ -0,0 +1,42 @@ +--- +title: "src/Containers/Collection.xphp" +layout: default +--- + +[← index](../../index.md) · [Source `.xphp` files](../../index.md#source) + +# `src/Containers/Collection.xphp` + +```php + +{ + private T[] $items; + + public function __construct(T ...$items) + { + $this->items = $items; + } + + public function first(): ?T + { + return $this->items[0] ?? null; + } + + public function all(): T[] + { + return $this->items; + } + + public function count(): int + { + return count($this->items); + } +} + +``` diff --git a/playground/_site_src/src/Containers/Map.xphp.md b/playground/_site_src/src/Containers/Map.xphp.md new file mode 100644 index 00000000..bfddbcc7 --- /dev/null +++ b/playground/_site_src/src/Containers/Map.xphp.md @@ -0,0 +1,41 @@ +--- +title: "src/Containers/Map.xphp" +layout: default +--- + +[← index](../../index.md) · [Source `.xphp` files](../../index.md#source) + +# `src/Containers/Map.xphp` + +```php + +{ + /** @var array */ + private array $keys = []; + /** @var array */ + private array $values = []; + + public function set(K $key, V $value): void + { + $this->keys[] = $key; + $this->values[] = $value; + } + + public function firstKey(): ?K + { + return $this->keys[0] ?? null; + } + + public function firstValue(): ?V + { + return $this->values[0] ?? null; + } +} + +``` diff --git a/playground/_site_src/src/Containers/Pair.xphp.md b/playground/_site_src/src/Containers/Pair.xphp.md new file mode 100644 index 00000000..d69da988 --- /dev/null +++ b/playground/_site_src/src/Containers/Pair.xphp.md @@ -0,0 +1,26 @@ +--- +title: "src/Containers/Pair.xphp" +layout: default +--- + +[← index](../../index.md) · [Source `.xphp` files](../../index.md#source) + +# `src/Containers/Pair.xphp` + +```php + +{ + public function __construct( + public K $first, + public V $second, + ) { + } +} + +``` diff --git a/playground/_site_src/src/Containers/Wrapper.xphp.md b/playground/_site_src/src/Containers/Wrapper.xphp.md new file mode 100644 index 00000000..4d40b80b --- /dev/null +++ b/playground/_site_src/src/Containers/Wrapper.xphp.md @@ -0,0 +1,31 @@ +--- +title: "src/Containers/Wrapper.xphp" +layout: default +--- + +[← index](../../index.md) · [Source `.xphp` files](../../index.md#source) + +# `src/Containers/Wrapper.xphp` + +```php + +{ + public function __construct(public Box $box) + { + } + + public function unwrap(): T + { + return $this->box->get(); + } +} + +``` diff --git a/playground/_site_src/src/Demos/ArraySugar.xphp.md b/playground/_site_src/src/Demos/ArraySugar.xphp.md new file mode 100644 index 00000000..ea5f6d4f --- /dev/null +++ b/playground/_site_src/src/Demos/ArraySugar.xphp.md @@ -0,0 +1,45 @@ +--- +title: "src/Demos/ArraySugar.xphp" +layout: default +--- + +[← index](../../index.md) · [Source `.xphp` files](../../index.md#source) + +# `src/Demos/ArraySugar.xphp` + +```php +: T[] property + ?T return type', PHP_EOL; + +$users = new Collection(new User('alice'), new User('bob'), new User('carol')); + +echo ' collection->count() = ', $users->count(), PHP_EOL; +echo ' collection->first()->name = ', $users->first()?->name ?? '', PHP_EOL; + +$first = (new ReflectionMethod($users::class, 'first'))->getReturnType(); +echo ' reflected first(): type = ', $first instanceof \ReflectionNamedType ? $first->getName() : '', PHP_EOL; +echo ' reflected first(): nullable = ', $first instanceof \ReflectionNamedType && $first->allowsNull() ? 'yes' : 'no', PHP_EOL; + +$empty = new Collection(); +echo ' empty collection first = ', $empty->first() === null ? 'null (good)' : 'unexpected', PHP_EOL; + +// `T ...$items` is what actually enforces element type at runtime. +try { + $bad = new Collection(new User('alice'), 'not-a-user'); + echo ' WRONG_TYPE: variadic accepted bad arg', PHP_EOL; +} catch (\TypeError $e) { + echo ' variadic TypeError fires for non-User arg (good)', PHP_EOL; +} + +echo PHP_EOL; + +``` diff --git a/playground/_site_src/src/Demos/MultiType.xphp.md b/playground/_site_src/src/Demos/MultiType.xphp.md new file mode 100644 index 00000000..f55f4dec --- /dev/null +++ b/playground/_site_src/src/Demos/MultiType.xphp.md @@ -0,0 +1,42 @@ +--- +title: "src/Demos/MultiType.xphp" +layout: default +--- + +[← index](../../index.md) · [Source `.xphp` files](../../index.md#source) + +# `src/Demos/MultiType.xphp` + +```php +, Map', PHP_EOL; + +$pair = new Pair(new User('alice'), new Plastic('red')); +echo ' pair->first->name = ', $pair->first->name, PHP_EOL; +echo ' pair->second->color = ', $pair->second->color, PHP_EOL; +echo ' reflected first type = ', (new ReflectionProperty($pair::class, 'first'))->getType()?->getName() ?? '', PHP_EOL; + +$map = new Map(); +$map->set('alpha', 1); +$map->set('beta', 2); +echo ' map firstKey = ', $map->firstKey(), PHP_EOL; +echo ' map firstValue = ', $map->firstValue(), PHP_EOL; + +// Slot-order matters: Pair and Pair are *different* generated classes. +$reversed = new Pair(new Plastic('blue'), new User('bob')); +echo ' pair vs reversed eq = ', $pair::class === $reversed::class ? 'SAME (wrong!)' : 'different (good)', PHP_EOL; + +echo PHP_EOL; + +``` diff --git a/playground/_site_src/src/Demos/NestedTransitive.xphp.md b/playground/_site_src/src/Demos/NestedTransitive.xphp.md new file mode 100644 index 00000000..ad3080af --- /dev/null +++ b/playground/_site_src/src/Demos/NestedTransitive.xphp.md @@ -0,0 +1,33 @@ +--- +title: "src/Demos/NestedTransitive.xphp" +layout: default +--- + +[← index](../../index.md) · [Source `.xphp` files](../../index.md#source) + +# `src/Demos/NestedTransitive.xphp` + +```php + -> transitive Box', PHP_EOL; + +$wrapper = new Wrapper(new Box(new Plastic('green'))); + +echo ' unwrap()->color = ', $wrapper->unwrap()->color, PHP_EOL; +echo ' reflected wrapper->box = ', (new ReflectionProperty($wrapper::class, 'box'))->getType()?->getName() ?? '', PHP_EOL; +echo ' reflected box->item = ', (new ReflectionProperty($wrapper->box::class, 'item'))->getType()?->getName() ?? '', PHP_EOL; +echo ' Box was transitively specialized via Wrapper\'s constructor signature', PHP_EOL; + +echo PHP_EOL; + +``` diff --git a/playground/_site_src/src/Demos/SingleType.xphp.md b/playground/_site_src/src/Demos/SingleType.xphp.md new file mode 100644 index 00000000..8db29007 --- /dev/null +++ b/playground/_site_src/src/Demos/SingleType.xphp.md @@ -0,0 +1,37 @@ +--- +title: "src/Demos/SingleType.xphp" +layout: default +--- + +[← index](../../index.md) · [Source `.xphp` files](../../index.md#source) + +# `src/Demos/SingleType.xphp` + +```php +", PHP_EOL; + +$boxOfFood = new Box(new Food('pizza')); + +echo ' Box->get()->label = ', $boxOfFood->get()->label, PHP_EOL; +echo ' reflected item type = ', (new ReflectionProperty($boxOfFood::class, 'item'))->getType()?->getName() ?? '', PHP_EOL; + +try { + $bad = new Box('not-a-food'); + echo ' WRONG_TYPE: no error fired', PHP_EOL; +} catch (\TypeError $e) { + echo ' TypeError fires for non-Food arg (good)', PHP_EOL; +} + +echo PHP_EOL; + +``` diff --git a/playground/_site_src/src/Models/Food.xphp.md b/playground/_site_src/src/Models/Food.xphp.md new file mode 100644 index 00000000..498d14e9 --- /dev/null +++ b/playground/_site_src/src/Models/Food.xphp.md @@ -0,0 +1,24 @@ +--- +title: "src/Models/Food.xphp" +layout: default +--- + +[← index](../../index.md) · [Source `.xphp` files](../../index.md#source) + +# `src/Models/Food.xphp` + +```php +item; + } +} +``` diff --git a/playground/_site_src/var/cache/Generated/App/Containers/Box/T_de1e0eaabedbafa176d971782f59025438ff880765b54fb07b0698700f21a0cd.php.md b/playground/_site_src/var/cache/Generated/App/Containers/Box/T_de1e0eaabedbafa176d971782f59025438ff880765b54fb07b0698700f21a0cd.php.md new file mode 100644 index 00000000..b0804657 --- /dev/null +++ b/playground/_site_src/var/cache/Generated/App/Containers/Box/T_de1e0eaabedbafa176d971782f59025438ff880765b54fb07b0698700f21a0cd.php.md @@ -0,0 +1,26 @@ +--- +title: "var/cache/Generated/App/Containers/Box/T_de1e0eaabedbafa176d971782f59025438ff880765b54fb07b0698700f21a0cd.php" +layout: default +--- + +[← index](../../../../../../index.md) · [Specialized classes (`var/cache/`)](../../../../../../index.md#cache) + +# `var/cache/Generated/App/Containers/Box/T_de1e0eaabedbafa176d971782f59025438ff880765b54fb07b0698700f21a0cd.php` + +```php +item; + } +} +``` diff --git a/playground/_site_src/var/cache/Generated/App/Containers/Collection/T_d59a159d1113876f898c70f94a3971da6bb3165b1a8f0c07236c92d911df85ed.php.md b/playground/_site_src/var/cache/Generated/App/Containers/Collection/T_d59a159d1113876f898c70f94a3971da6bb3165b1a8f0c07236c92d911df85ed.php.md new file mode 100644 index 00000000..f7593d38 --- /dev/null +++ b/playground/_site_src/var/cache/Generated/App/Containers/Collection/T_d59a159d1113876f898c70f94a3971da6bb3165b1a8f0c07236c92d911df85ed.php.md @@ -0,0 +1,36 @@ +--- +title: "var/cache/Generated/App/Containers/Collection/T_d59a159d1113876f898c70f94a3971da6bb3165b1a8f0c07236c92d911df85ed.php" +layout: default +--- + +[← index](../../../../../../index.md) · [Specialized classes (`var/cache/`)](../../../../../../index.md#cache) + +# `var/cache/Generated/App/Containers/Collection/T_d59a159d1113876f898c70f94a3971da6bb3165b1a8f0c07236c92d911df85ed.php` + +```php +items = $items; + } + public function first(): ?\App\Models\User + { + return $this->items[0] ?? null; + } + public function all(): array + { + return $this->items; + } + public function count(): int + { + return count($this->items); + } +} +``` diff --git a/playground/_site_src/var/cache/Generated/App/Containers/Map/T_ddc02d7f9d24d965819939d5b07227efa95da520d87d6728a4c41906ecce5bca.php.md b/playground/_site_src/var/cache/Generated/App/Containers/Map/T_ddc02d7f9d24d965819939d5b07227efa95da520d87d6728a4c41906ecce5bca.php.md new file mode 100644 index 00000000..6246e612 --- /dev/null +++ b/playground/_site_src/var/cache/Generated/App/Containers/Map/T_ddc02d7f9d24d965819939d5b07227efa95da520d87d6728a4c41906ecce5bca.php.md @@ -0,0 +1,36 @@ +--- +title: "var/cache/Generated/App/Containers/Map/T_ddc02d7f9d24d965819939d5b07227efa95da520d87d6728a4c41906ecce5bca.php" +layout: default +--- + +[← index](../../../../../../index.md) · [Specialized classes (`var/cache/`)](../../../../../../index.md#cache) + +# `var/cache/Generated/App/Containers/Map/T_ddc02d7f9d24d965819939d5b07227efa95da520d87d6728a4c41906ecce5bca.php` + +```php + */ + private array $keys = []; + /** @var array */ + private array $values = []; + public function set(string $key, int $value): void + { + $this->keys[] = $key; + $this->values[] = $value; + } + public function firstKey(): ?string + { + return $this->keys[0] ?? null; + } + public function firstValue(): ?int + { + return $this->values[0] ?? null; + } +} +``` diff --git a/playground/_site_src/var/cache/Generated/App/Containers/Pair/T_307894382bcf10563dfe258ab0eef6cf6d925ef67d412e2b8743703540f18bdd.php.md b/playground/_site_src/var/cache/Generated/App/Containers/Pair/T_307894382bcf10563dfe258ab0eef6cf6d925ef67d412e2b8743703540f18bdd.php.md new file mode 100644 index 00000000..d444391b --- /dev/null +++ b/playground/_site_src/var/cache/Generated/App/Containers/Pair/T_307894382bcf10563dfe258ab0eef6cf6d925ef67d412e2b8743703540f18bdd.php.md @@ -0,0 +1,22 @@ +--- +title: "var/cache/Generated/App/Containers/Pair/T_307894382bcf10563dfe258ab0eef6cf6d925ef67d412e2b8743703540f18bdd.php" +layout: default +--- + +[← index](../../../../../../index.md) · [Specialized classes (`var/cache/`)](../../../../../../index.md#cache) + +# `var/cache/Generated/App/Containers/Pair/T_307894382bcf10563dfe258ab0eef6cf6d925ef67d412e2b8743703540f18bdd.php` + +```php +box->get(); + } +} +``` diff --git a/playground/_site_src/var/dist/Containers/Box.php.md b/playground/_site_src/var/dist/Containers/Box.php.md new file mode 100644 index 00000000..330a2f34 --- /dev/null +++ b/playground/_site_src/var/dist/Containers/Box.php.md @@ -0,0 +1,16 @@ +--- +title: "var/dist/Containers/Box.php" +layout: default +--- + +[← index](../../../index.md) · [Rewritten user code (`var/dist/`)](../../../index.md#dist) + +# `var/dist/Containers/Box.php` + +```php +: T[] property + ?T return type', PHP_EOL; +$users = new \XPHP\Generated\App\Containers\Collection\T_d59a159d1113876f898c70f94a3971da6bb3165b1a8f0c07236c92d911df85ed(new User('alice'), new User('bob'), new User('carol')); +echo ' collection->count() = ', $users->count(), PHP_EOL; +echo ' collection->first()->name = ', $users->first()?->name ?? '', PHP_EOL; +$first = (new ReflectionMethod($users::class, 'first'))->getReturnType(); +echo ' reflected first(): type = ', $first instanceof \ReflectionNamedType ? $first->getName() : '', PHP_EOL; +echo ' reflected first(): nullable = ', $first instanceof \ReflectionNamedType && $first->allowsNull() ? 'yes' : 'no', PHP_EOL; +$empty = new \XPHP\Generated\App\Containers\Collection\T_d59a159d1113876f898c70f94a3971da6bb3165b1a8f0c07236c92d911df85ed(); +echo ' empty collection first = ', $empty->first() === null ? 'null (good)' : 'unexpected', PHP_EOL; +// `T ...$items` is what actually enforces element type at runtime. +try { + $bad = new \XPHP\Generated\App\Containers\Collection\T_d59a159d1113876f898c70f94a3971da6bb3165b1a8f0c07236c92d911df85ed(new User('alice'), 'not-a-user'); + echo ' WRONG_TYPE: variadic accepted bad arg', PHP_EOL; +} catch (\TypeError $e) { + echo ' variadic TypeError fires for non-User arg (good)', PHP_EOL; +} +echo PHP_EOL; +``` diff --git a/playground/_site_src/var/dist/Demos/MultiType.php.md b/playground/_site_src/var/dist/Demos/MultiType.php.md new file mode 100644 index 00000000..844d88b1 --- /dev/null +++ b/playground/_site_src/var/dist/Demos/MultiType.php.md @@ -0,0 +1,35 @@ +--- +title: "var/dist/Demos/MultiType.php" +layout: default +--- + +[← index](../../../index.md) · [Rewritten user code (`var/dist/`)](../../../index.md#dist) + +# `var/dist/Demos/MultiType.php` + +```php +, Map', PHP_EOL; +$pair = new \XPHP\Generated\App\Containers\Pair\T_3ca1518773f5e15bb180bc027c8394e76250619a79189c7cb33652fd9815b2ed(new User('alice'), new Plastic('red')); +echo ' pair->first->name = ', $pair->first->name, PHP_EOL; +echo ' pair->second->color = ', $pair->second->color, PHP_EOL; +echo ' reflected first type = ', (new ReflectionProperty($pair::class, 'first'))->getType()?->getName() ?? '', PHP_EOL; +$map = new \XPHP\Generated\App\Containers\Map\T_ddc02d7f9d24d965819939d5b07227efa95da520d87d6728a4c41906ecce5bca(); +$map->set('alpha', 1); +$map->set('beta', 2); +echo ' map firstKey = ', $map->firstKey(), PHP_EOL; +echo ' map firstValue = ', $map->firstValue(), PHP_EOL; +// Slot-order matters: Pair and Pair are *different* generated classes. +$reversed = new \XPHP\Generated\App\Containers\Pair\T_307894382bcf10563dfe258ab0eef6cf6d925ef67d412e2b8743703540f18bdd(new Plastic('blue'), new User('bob')); +echo ' pair vs reversed eq = ', $pair::class === $reversed::class ? 'SAME (wrong!)' : 'different (good)', PHP_EOL; +echo PHP_EOL; +``` diff --git a/playground/_site_src/var/dist/Demos/NestedTransitive.php.md b/playground/_site_src/var/dist/Demos/NestedTransitive.php.md new file mode 100644 index 00000000..d2703182 --- /dev/null +++ b/playground/_site_src/var/dist/Demos/NestedTransitive.php.md @@ -0,0 +1,27 @@ +--- +title: "var/dist/Demos/NestedTransitive.php" +layout: default +--- + +[← index](../../../index.md) · [Rewritten user code (`var/dist/`)](../../../index.md#dist) + +# `var/dist/Demos/NestedTransitive.php` + +```php + -> transitive Box', PHP_EOL; +$wrapper = new \XPHP\Generated\App\Containers\Wrapper\T_de1e0eaabedbafa176d971782f59025438ff880765b54fb07b0698700f21a0cd(new \XPHP\Generated\App\Containers\Box\T_de1e0eaabedbafa176d971782f59025438ff880765b54fb07b0698700f21a0cd(new Plastic('green'))); +echo ' unwrap()->color = ', $wrapper->unwrap()->color, PHP_EOL; +echo ' reflected wrapper->box = ', (new ReflectionProperty($wrapper::class, 'box'))->getType()?->getName() ?? '', PHP_EOL; +echo ' reflected box->item = ', (new ReflectionProperty($wrapper->box::class, 'item'))->getType()?->getName() ?? '', PHP_EOL; +echo ' Box was transitively specialized via Wrapper\'s constructor signature', PHP_EOL; +echo PHP_EOL; +``` diff --git a/playground/_site_src/var/dist/Demos/SingleType.php.md b/playground/_site_src/var/dist/Demos/SingleType.php.md new file mode 100644 index 00000000..3b5fcce0 --- /dev/null +++ b/playground/_site_src/var/dist/Demos/SingleType.php.md @@ -0,0 +1,30 @@ +--- +title: "var/dist/Demos/SingleType.php" +layout: default +--- + +[← index](../../../index.md) · [Rewritten user code (`var/dist/`)](../../../index.md#dist) + +# `var/dist/Demos/SingleType.php` + +```php +", PHP_EOL; +$boxOfFood = new \XPHP\Generated\App\Containers\Box\T_3036c16466fa04e2ed024d6b75086b6a023199482af4f6a278a33d8bd9ebe44f(new Food('pizza')); +echo ' Box->get()->label = ', $boxOfFood->get()->label, PHP_EOL; +echo ' reflected item type = ', (new ReflectionProperty($boxOfFood::class, 'item'))->getType()?->getName() ?? '', PHP_EOL; +try { + $bad = new \XPHP\Generated\App\Containers\Box\T_3036c16466fa04e2ed024d6b75086b6a023199482af4f6a278a33d8bd9ebe44f('not-a-food'); + echo ' WRONG_TYPE: no error fired', PHP_EOL; +} catch (\TypeError $e) { + echo ' TypeError fires for non-Food arg (good)', PHP_EOL; +} +echo PHP_EOL; +``` diff --git a/playground/_site_src/var/dist/Models/Food.php.md b/playground/_site_src/var/dist/Models/Food.php.md new file mode 100644 index 00000000..1b7c17d2 --- /dev/null +++ b/playground/_site_src/var/dist/Models/Food.php.md @@ -0,0 +1,22 @@ +--- +title: "var/dist/Models/Food.php" +layout: default +--- + +[← index](../../../index.md) · [Rewritten user code (`var/dist/`)](../../../index.md#dist) + +# `var/dist/Models/Food.php` + +```php +/dev/null') ?? ''); +$sha = getenv('GITHUB_SHA') ?: trim(@shell_exec('git -C ' . escapeshellarg($root) . ' rev-parse HEAD 2>/dev/null') ?? ''); +$shaShort = $sha !== '' ? substr($sha, 0, 7) : 'unknown'; +$buildIso = gmdate('Y-m-d\TH:i:s\Z'); + +$repository = getenv('GITHUB_REPOSITORY') ?: 'xphp-lang/xphp-parser'; +$server = getenv('GITHUB_SERVER_URL') ?: 'https://github.com'; +$repoUrl = $server . '/' . $repository; +$commitUrl = $sha !== '' ? $repoUrl . '/commit/' . $sha : $repoUrl; + +$sections = [ + [ + 'id' => 'source', + 'heading' => 'Source `.xphp` files', + 'lead' => 'Authored input that the xphp compiler consumes. Generic templates live under `Containers/`; the demo scripts under `Demos/` instantiate them.', + 'baseAbs' => $root . '/src', + 'urlPrefix' => 'src', + 'glob' => '*.xphp', + ], + [ + 'id' => 'dist', + 'heading' => 'Rewritten user code (`var/dist/`)', + 'lead' => 'Each `.xphp` source rewritten to native PHP — generic call sites get pointed at the matching specialized class.', + 'baseAbs' => $root . '/var/dist', + 'urlPrefix' => 'var/dist', + 'glob' => '*.php', + ], + [ + 'id' => 'cache', + 'heading' => 'Specialized classes (`var/cache/`)', + 'lead' => 'Monomorphized class per unique generic instantiation. Loaded via the `XPHP\\Generated\\` PSR-4 prefix. Class shortnames are SHA-256-derived hashes of the canonical argument list.', + 'baseAbs' => $root . '/var/cache', + 'urlPrefix' => 'var/cache', + 'glob' => '*.php', + ], +]; + +resetSiteDir($siteSrc); +writeJekyllConfig($siteSrc, $shaShort); + +$counts = []; +foreach ($sections as $section) { + $files = collectFiles($section['baseAbs'], $section['glob']); + $counts[$section['id']] = count($files); + foreach ($files as $absPath) { + $rel = substr($absPath, strlen($section['baseAbs']) + 1); + $mdPath = $section['urlPrefix'] . '/' . $rel . '.md'; + emitFilePage($siteSrc, $mdPath, $rel, $section, $absPath); + } +} + +emitIndex($siteSrc, $sections, $counts, [ + 'ref' => $ref !== '' ? $ref : 'unknown', + 'shaShort' => $shaShort, + 'commitUrl' => $commitUrl, + 'repoUrl' => $repoUrl, + 'buildIso' => $buildIso, +]); + +fwrite(STDOUT, sprintf( + "Built %d source / %d rewritten / %d specialized markdown page(s) under %s\n", + $counts['source'] ?? 0, + $counts['dist'] ?? 0, + $counts['cache'] ?? 0, + $siteSrc, +)); + +function resetSiteDir(string $siteSrc): void +{ + if (is_dir($siteSrc)) { + rrmdir($siteSrc); + } + if (!mkdir($siteSrc, 0o755, true) && !is_dir($siteSrc)) { + fwrite(STDERR, "could not create {$siteSrc}\n"); + exit(1); + } +} + +function writeJekyllConfig(string $siteSrc, string $shaShort): void +{ + // jekyll-theme-cayman gives a clean header + readable typography out of + // the box. permalink: /:path/:basename:output_ext keeps Box.xphp.md -> + // Box.xphp.md instead of Jekyll's default basename-stripping behavior. + $title = 'xphp playground — ' . $shaShort; + $config = << + */ +function collectFiles(string $base, string $pattern): array +{ + if (!is_dir($base)) { + return []; + } + $out = []; + $it = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($base, FilesystemIterator::SKIP_DOTS), + ); + foreach ($it as $entry) { + if (!$entry->isFile()) { + continue; + } + if (fnmatch($pattern, $entry->getFilename())) { + $out[] = $entry->getPathname(); + } + } + sort($out); + return $out; +} + +/** + * @param array{id:string,heading:string,lead:string,baseAbs:string,urlPrefix:string,glob:string} $section + */ +function emitFilePage(string $siteSrc, string $mdPath, string $rel, array $section, string $absPath): void +{ + $target = $siteSrc . '/' . $mdPath; + $targetDir = dirname($target); + if (!is_dir($targetDir) && !mkdir($targetDir, 0o755, true) && !is_dir($targetDir)) { + fwrite(STDERR, "could not create {$targetDir}\n"); + exit(1); + } + + $depth = substr_count($mdPath, '/'); + $rootRel = $depth > 0 ? str_repeat('../', $depth) : './'; + + $contents = (string) file_get_contents($absPath); + $title = $section['urlPrefix'] . '/' . $rel; + $sectionHref = $rootRel . 'index.md#' . $section['id']; + + // Use ```php for both .xphp and .php — xphp is a superset, GitHub Pages' + // Rouge highlighter doesn't know "xphp" as a language. Pick a fence longer + // than any consecutive backtick run in the file contents to keep the fence + // unambiguous even if the source has its own code-block markup. + $fence = pickFence($contents); + + $body = << $sections + * @param array $counts + * @param array{ref:string,shaShort:string,commitUrl:string,repoUrl:string,buildIso:string} $meta + */ +function emitIndex(string $siteSrc, array $sections, array $counts, array $meta): void +{ + $body = "---\n"; + $body .= "title: \"xphp playground — {$meta['shaShort']}\"\n"; + $body .= "layout: default\n"; + $body .= "---\n\n"; + $body .= "# xphp playground\n\n"; + $body .= "Browse the `.xphp` sources and the compiled artifacts from a recent run.\n\n"; + $body .= "| | |\n"; + $body .= "|---|---|\n"; + $body .= "| Branch | `{$meta['ref']}` |\n"; + $body .= "| Commit | [`{$meta['shaShort']}`]({$meta['commitUrl']}) |\n"; + $body .= "| Built | `{$meta['buildIso']}` |\n"; + $body .= "| Source | [{$meta['repoUrl']}]({$meta['repoUrl']}) |\n\n"; + + foreach ($sections as $section) { + $count = $counts[$section['id']] ?? 0; + $body .= "## {$section['heading']} ({$count})\n"; + $body .= '{: id="' . $section['id'] . '"}' . "\n\n"; + $body .= $section['lead'] . "\n\n"; + + $files = collectFiles($section['baseAbs'], $section['glob']); + if ($files === []) { + $body .= "_no files_\n\n"; + continue; + } + + foreach ($files as $absPath) { + $rel = substr($absPath, strlen($section['baseAbs']) + 1); + $href = $section['urlPrefix'] . '/' . $rel . '.md'; + $body .= "- [`{$rel}`]({$href})\n"; + } + $body .= "\n"; + } + + file_put_contents($siteSrc . '/index.md', $body); +} + +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) ? rrmdir($path) : unlink($path); + } + rmdir($dir); +} diff --git a/playground/bin/run b/playground/bin/run new file mode 100755 index 00000000..8d672bf2 --- /dev/null +++ b/playground/bin/run @@ -0,0 +1,45 @@ +#!/usr/bin/env php + Installing playground dependencies\n"); + passthru('composer install --no-interaction --quiet', $exit); + if ($exit !== 0) { + fwrite(STDERR, "composer install failed\n"); + exit($exit); + } +} + +fwrite(STDOUT, "==> Compiling .xphp -> .php (src -> var/dist, specialized -> var/cache)\n"); +$cmd = sprintf('%s compile %s %s %s', escapeshellarg($parserBin), escapeshellarg('src'), escapeshellarg('var/dist'), escapeshellarg('var/cache')); +passthru($cmd, $exit); +if ($exit !== 0) { + exit($exit); +} + +require $root . '/vendor/autoload.php'; + +$demos = [ + 'var/dist/Demos/SingleType.php', + 'var/dist/Demos/MultiType.php', + 'var/dist/Demos/ArraySugar.php', + 'var/dist/Demos/NestedTransitive.php', +]; + +fwrite(STDOUT, "==> Running demos\n\n"); + +foreach ($demos as $demo) { + $path = $root . '/' . $demo; + if (!is_file($path)) { + fwrite(STDERR, "missing compiled demo: {$path}\n"); + exit(1); + } + require $path; +} diff --git a/playground/composer.json b/playground/composer.json new file mode 100644 index 00000000..407cf18e --- /dev/null +++ b/playground/composer.json @@ -0,0 +1,36 @@ +{ + "name": "xphp-lang/playground", + "description": "Sandbox app to explore xphp features. Compiles its own .xphp sources via a local path-repo dependency on xphp-lang/xphp-parser.", + "type": "project", + "license": "MIT", + "require": { + "php": "^8.4.0", + "xphp-lang/xphp-parser": "@dev" + }, + "repositories": [ + { + "type": "path", + "url": "../", + "options": { + "symlink": true + } + } + ], + "autoload": { + "psr-4": { + "XPHP\\Generated\\": "var/cache/Generated/", + "App\\": [ + "src/", + "var/dist/" + ] + } + }, + "config": { + "platform": { + "php": "8.4.21" + }, + "sort-packages": true + }, + "minimum-stability": "dev", + "prefer-stable": true +} diff --git a/playground/src/Containers/Box.xphp b/playground/src/Containers/Box.xphp new file mode 100644 index 00000000..67e02496 --- /dev/null +++ b/playground/src/Containers/Box.xphp @@ -0,0 +1,17 @@ + +{ + public function __construct(public T $item) + { + } + + public function get(): T + { + return $this->item; + } +} diff --git a/playground/src/Containers/Collection.xphp b/playground/src/Containers/Collection.xphp new file mode 100644 index 00000000..5fc554e6 --- /dev/null +++ b/playground/src/Containers/Collection.xphp @@ -0,0 +1,30 @@ + +{ + private T[] $items; + + public function __construct(T ...$items) + { + $this->items = $items; + } + + public function first(): ?T + { + return $this->items[0] ?? null; + } + + public function all(): T[] + { + return $this->items; + } + + public function count(): int + { + return count($this->items); + } +} diff --git a/playground/src/Containers/Map.xphp b/playground/src/Containers/Map.xphp new file mode 100644 index 00000000..f801e548 --- /dev/null +++ b/playground/src/Containers/Map.xphp @@ -0,0 +1,29 @@ + +{ + /** @var array */ + private array $keys = []; + /** @var array */ + private array $values = []; + + public function set(K $key, V $value): void + { + $this->keys[] = $key; + $this->values[] = $value; + } + + public function firstKey(): ?K + { + return $this->keys[0] ?? null; + } + + public function firstValue(): ?V + { + return $this->values[0] ?? null; + } +} diff --git a/playground/src/Containers/Pair.xphp b/playground/src/Containers/Pair.xphp new file mode 100644 index 00000000..2cf3d633 --- /dev/null +++ b/playground/src/Containers/Pair.xphp @@ -0,0 +1,14 @@ + +{ + public function __construct( + public K $first, + public V $second, + ) { + } +} diff --git a/playground/src/Containers/Wrapper.xphp b/playground/src/Containers/Wrapper.xphp new file mode 100644 index 00000000..d961172c --- /dev/null +++ b/playground/src/Containers/Wrapper.xphp @@ -0,0 +1,19 @@ + +{ + public function __construct(public Box $box) + { + } + + public function unwrap(): T + { + return $this->box->get(); + } +} diff --git a/playground/src/Demos/ArraySugar.xphp b/playground/src/Demos/ArraySugar.xphp new file mode 100644 index 00000000..9c7af8ae --- /dev/null +++ b/playground/src/Demos/ArraySugar.xphp @@ -0,0 +1,33 @@ +: T[] property + ?T return type', PHP_EOL; + +$users = new Collection(new User('alice'), new User('bob'), new User('carol')); + +echo ' collection->count() = ', $users->count(), PHP_EOL; +echo ' collection->first()->name = ', $users->first()?->name ?? '', PHP_EOL; + +$first = (new ReflectionMethod($users::class, 'first'))->getReturnType(); +echo ' reflected first(): type = ', $first instanceof \ReflectionNamedType ? $first->getName() : '', PHP_EOL; +echo ' reflected first(): nullable = ', $first instanceof \ReflectionNamedType && $first->allowsNull() ? 'yes' : 'no', PHP_EOL; + +$empty = new Collection(); +echo ' empty collection first = ', $empty->first() === null ? 'null (good)' : 'unexpected', PHP_EOL; + +// `T ...$items` is what actually enforces element type at runtime. +try { + $bad = new Collection(new User('alice'), 'not-a-user'); + echo ' WRONG_TYPE: variadic accepted bad arg', PHP_EOL; +} catch (\TypeError $e) { + echo ' variadic TypeError fires for non-User arg (good)', PHP_EOL; +} + +echo PHP_EOL; diff --git a/playground/src/Demos/MultiType.xphp b/playground/src/Demos/MultiType.xphp new file mode 100644 index 00000000..3e8efbf8 --- /dev/null +++ b/playground/src/Demos/MultiType.xphp @@ -0,0 +1,30 @@ +, Map', PHP_EOL; + +$pair = new Pair(new User('alice'), new Plastic('red')); +echo ' pair->first->name = ', $pair->first->name, PHP_EOL; +echo ' pair->second->color = ', $pair->second->color, PHP_EOL; +echo ' reflected first type = ', (new ReflectionProperty($pair::class, 'first'))->getType()?->getName() ?? '', PHP_EOL; + +$map = new Map(); +$map->set('alpha', 1); +$map->set('beta', 2); +echo ' map firstKey = ', $map->firstKey(), PHP_EOL; +echo ' map firstValue = ', $map->firstValue(), PHP_EOL; + +// Slot-order matters: Pair and Pair are *different* generated classes. +$reversed = new Pair(new Plastic('blue'), new User('bob')); +echo ' pair vs reversed eq = ', $pair::class === $reversed::class ? 'SAME (wrong!)' : 'different (good)', PHP_EOL; + +echo PHP_EOL; diff --git a/playground/src/Demos/NestedTransitive.xphp b/playground/src/Demos/NestedTransitive.xphp new file mode 100644 index 00000000..682b1281 --- /dev/null +++ b/playground/src/Demos/NestedTransitive.xphp @@ -0,0 +1,21 @@ + -> transitive Box', PHP_EOL; + +$wrapper = new Wrapper(new Box(new Plastic('green'))); + +echo ' unwrap()->color = ', $wrapper->unwrap()->color, PHP_EOL; +echo ' reflected wrapper->box = ', (new ReflectionProperty($wrapper::class, 'box'))->getType()?->getName() ?? '', PHP_EOL; +echo ' reflected box->item = ', (new ReflectionProperty($wrapper->box::class, 'item'))->getType()?->getName() ?? '', PHP_EOL; +echo ' Box was transitively specialized via Wrapper\'s constructor signature', PHP_EOL; + +echo PHP_EOL; diff --git a/playground/src/Demos/SingleType.xphp b/playground/src/Demos/SingleType.xphp new file mode 100644 index 00000000..dd978f8a --- /dev/null +++ b/playground/src/Demos/SingleType.xphp @@ -0,0 +1,25 @@ +", PHP_EOL; + +$boxOfFood = new Box(new Food('pizza')); + +echo ' Box->get()->label = ', $boxOfFood->get()->label, PHP_EOL; +echo ' reflected item type = ', (new ReflectionProperty($boxOfFood::class, 'item'))->getType()?->getName() ?? '', PHP_EOL; + +try { + $bad = new Box('not-a-food'); + echo ' WRONG_TYPE: no error fired', PHP_EOL; +} catch (\TypeError $e) { + echo ' TypeError fires for non-Food arg (good)', PHP_EOL; +} + +echo PHP_EOL; diff --git a/playground/src/Models/Food.xphp b/playground/src/Models/Food.xphp new file mode 100644 index 00000000..ac5de9cf --- /dev/null +++ b/playground/src/Models/Food.xphp @@ -0,0 +1,12 @@ +