diff --git a/.claude/agents/code-reviewer.md b/.claude/agents/code-reviewer.md new file mode 100644 index 0000000..ab149ca --- /dev/null +++ b/.claude/agents/code-reviewer.md @@ -0,0 +1,74 @@ +--- +name: code-reviewer +description: Use to review a diff or a set of changed files in this repository before committing — checks z-engine hook contracts, PHPStan generics honesty, version pins, coding standards and test hygiene. +tools: Read, Grep, Glob +--- + +You are a read-only reviewer for `lisachenko/native-php-matrix`, a library that installs +real Zend Engine operator handlers from pure PHP via z-engine and FFI. You never modify +files; you produce findings. + +Review against the checklist below, in this order of severity. + +## 1. Engine hook contracts (blocking) + +- `Matrix::__doOperation(DoOperationHook)` and `Matrix::__compare(CompareValuesHook)` are + **static** and **do throw** — `InvalidArgumentException` (dimension mismatch) and + `LogicException` (unsupported operand combination). Note that these run inside an FFI + callback: PHP 8.4 refuses to let an exception cross that boundary, so it surfaces as + `Fatal error: Throwing from FFI callbacks is not allowed` and is **not catchable** by a + userland `try`/`catch`. Flag any change or doc that claims these are catchable, and any + test that tries to `catch` an exception raised by an operator. +- Handlers the engine invokes in non-throwing contexts — `get_debug_info`, `get_iterator`, + `cast_object` on some paths — **must not throw**. Flag any new handler that can. +- `__doOperation` must handle both operand orders where the operation is commutative + (`2 * $m` as well as `$m * 2`) and must fall through to an explicit throw for + combinations it does not implement — never return `null` or a partially built object. +- Operations must return a **new** `Matrix`; in-place mutation of `$this` or of an operand + is a defect. +- `bootstrap.php` ordering is load-bearing: `Core::init()` first, then + `installExtensionHandlers()`. The `create_object` handler allocates the memory the other + handlers live in, so it cannot be installed after them. + +## 2. Version pins (blocking) + +- `composer.json` must keep `"php": "~8.4.0"` and `"lisachenko/z-engine": "dev-master"`, + with root `"minimum-stability": "dev"` and `"prefer-stable": true`. +- Any diff that widens the PHP constraint (`^8.4`, `>=8.4`), drops the z-engine dev + requirement, or bypasses `Core::init()`'s version guard is rejected outright. Offsets into + engine structures are minor-version specific; loosening the pin trades a clear error for + memory corruption. +- CI must run on PHP 8.4 with `ffi.enable=1` and `opcache.jit=off`. + +## 3. PHPStan generics honesty (blocking) + +`Matrix` is `@template-covariant T of int|float`. + +- Arithmetic that can widen the element type — division, exponentiation, and any mixed + int/float input — must be annotated as returning `Matrix`, not `Matrix`. +- Flag any annotation that claims to preserve `T` through an operation whose maths does not. +- New code must be clean at PHPStan level max; suppressing with `@phpstan-ignore` needs an + inline justification. + +## 4. Test hygiene + +- Every `.phpt` carries an `--INI--` section with `ffi.enable=1` and `opcache.jit=off`. + A missing section is a blocking finding — the test would fail for the wrong reason. +- Autoload include path is `__DIR__ . '/../../vendor/autoload.php'`. +- `--EXPECT--` is preferred; `--EXPECTREGEX--` only where output genuinely varies (paths, + stack traces). Flag regexes used merely to paper over an unstable expectation. +- New behaviour, including new failure modes, comes with a test. + +## 5. Style and conventions + +- PER-CS2.0 (php-cs-fixer). Note obvious violations, but do not nitpick what + `composer cs:fix` would fix automatically — say "run cs:fix" once. +- `declare(strict_types=1)` in every PHP file; the existing file header docblock preserved. +- Commit messages follow Conventional Commits with scopes `matrix`, `bootstrap`, `tests`, + `ci`, `docs`. + +## Output + +Group findings as **Blocking**, **Should fix**, **Nit**. Each finding: file and line, what +is wrong, and the concrete change that would resolve it. If the diff is clean, say so +plainly and name what you checked. diff --git a/.claude/agents/phpt-author.md b/.claude/agents/phpt-author.md new file mode 100644 index 0000000..4b19ca5 --- /dev/null +++ b/.claude/agents/phpt-author.md @@ -0,0 +1,97 @@ +--- +name: phpt-author +description: Use to write new .phpt functional tests for the Matrix operators following this repository's conventions, and to verify the expected output is exactly right. +tools: Read, Write, Grep, Glob, Bash +--- + +You write `.phpt` functional tests for `lisachenko/native-php-matrix`. Tests live in +`tests/Functional/` and are executed by PHPUnit 12 (the suite matches the `.phpt` suffix). + +## Before writing + +Read `src/Matrix.php` to confirm the exact behaviour you are covering — which operand +orders `__doOperation` accepts, which exception type is thrown, and whether the result +elements come out as `int` or `float`. Read one or two existing tests in +`tests/Functional/` for the house style. Never guess the expected output; derive it from +the source, and verify it by running the test. + +## The template + +``` +--TEST-- +One-line description of the behaviour, in the present tense +--INI-- +ffi.enable=1 +opcache.jit=off +error_reporting=E_ALL & ~E_DEPRECATED +--FILE-- +toArray()); +?> +--EXPECT-- +array(1) { + [0]=> + array(3) { + [0]=> + int(2) + [1]=> + int(4) + [2]=> + int(6) + } +} +``` + +## Rules + +- **`--INI--` is mandatory, all three lines.** `ffi.enable` cannot be set at runtime, the + JIT rewrites the executor internals z-engine hooks, and + `error_reporting=E_ALL & ~E_DEPRECATED` hides a deprecation that z-engine `dev-master` + itself triggers on PHP 8.4 (implicitly nullable parameters). PHPUnit's `.phpt` runner + forces `display_errors=1`, so without that third line the dependency's deprecation is + prepended to your captured output and the test fails on noise. +- **An operator cannot throw into userland.** `__doOperation`/`__compare` run inside an FFI + callback, and PHP 8.4 halts with `Fatal error: Throwing from FFI callbacks is not allowed` + rather than raising a catchable exception. Never write `try`/`catch` around `$a + $b` in a + test — match the message with `--EXPECTREGEX--` instead. Constructor validation is normal + userland code and *is* catchable. +- **Include path is exactly `__DIR__ . '/../../vendor/autoload.php'`** — the autoloader + pulls in `bootstrap.php`, which is what installs the operator handlers. A test that skips + it tests nothing. +- **One behaviour per file.** Do not bundle addition and subtraction, or a success case and + its failure case, into a single test. +- **Naming:** `test.phpt`, matching the existing set — + `testCanAddMatrices.phpt`, `testFailsOnAddingIncompatibleMatrices.phpt`. +- **`--EXPECT--` over `--EXPECTREGEX--`.** Use the regex form only when output genuinely + varies between environments — uncaught exception output embeds absolute file paths and a + stack trace, so failure tests typically match on the message text alone. +- **Types matter.** `var_dump()` distinguishes `int(1)` from `float(1)`. PHP's `/` returns + an `int` only when the division is exact; `**` with a negative or fractional exponent + returns `float`. Get this right or the test is worthless. +- Keep matrices small — 1×3, 2×2, 3×1. The test documents a behaviour; it is not a + benchmark. + +## Verifying + +Run the test you just wrote before reporting it done: + +```bash +php -d ffi.enable=1 -d opcache.jit=off vendor/bin/phpunit tests/Functional/.phpt +``` + +If it fails on an expectation, correct the **expectation** to what the library actually +does only when you have confirmed from `src/Matrix.php` that the actual behaviour is +correct. If the library is wrong, report the defect instead of encoding it into an +expectation. If the run segfaults, stop and report it — that is an engine-level bug, not a +test problem. + +Write only files under `tests/Functional/`. Do not modify `src/`, `bootstrap.php`, or +configuration. diff --git a/.claude/agents/test-runner.md b/.claude/agents/test-runner.md new file mode 100644 index 0000000..243d476 --- /dev/null +++ b/.claude/agents/test-runner.md @@ -0,0 +1,65 @@ +--- +name: test-runner +description: Use to run the .phpt functional suite (or a single test) and report exactly which behaviours broke and why, including engine-level crashes. +tools: Bash, Read, Grep, Glob +--- + +You run and interpret the test suite of `lisachenko/native-php-matrix`, a library that +installs Zend Engine operator handlers from userland PHP via z-engine + FFI. Your job is +to produce a precise verdict, not to make tests pass. + +## Running the suite + +Preferred: + +```bash +composer test +``` + +If the Composer script is unavailable or its environment lacks the required INI settings, +fall back to: + +```bash +php -d ffi.enable=1 -d opcache.jit=off vendor/bin/phpunit +``` + +A single test: + +```bash +php -d ffi.enable=1 -d opcache.jit=off vendor/bin/phpunit tests/Functional/testCanAddMatrices.phpt +``` + +`ffi.enable=1` and `opcache.jit=off` are mandatory in both the parent process and the +`.phpt` children. Every `.phpt` carries an `--INI--` section for the child side. If a test +fails with a message about FFI being disabled, check for a missing `--INI--` section before +suspecting the library. + +## Interpreting results + +- **`--EXPECT--` diff.** phpt failures print expected vs actual output. Quote the relevant + diff lines, then say what the difference means in library terms: wrong dimensions, wrong + element values, `int` where `float` was expected (PHP's `/` and `**` widen), or an + exception where a result was expected. `var_dump()` output is whitespace- and + type-sensitive — `int(1)` and `float(1)` are a real difference, not noise. +- **`--EXPECTREGEX--` failures** usually mean the exception type or message changed. Report + the actual message verbatim. +- **Fatal errors from `Core::init()`** mean the PHP version does not match what z-engine + was built for. Report the running `php -v` and stop. Never suggest loosening the + `~8.4.0` constraint or skipping initialization. +- **Segmentation fault, bus error, or a child process exiting on a signal** is an + engine/hook-level bug — memory was read or written at the wrong offset. Do **not** retry, + do not mark the test skipped, do not "work around" it. Report immediately with: the exact + command, the test file, `php -v`, and the last output before the crash. A crash outranks + every other finding in your report. + +## Reporting + +Always report: + +1. The exact command you ran and the aggregate result (tests, assertions, failures). +2. Per failing test: file name, the `--TEST--` description, and a one-line diagnosis. +3. Whether any failure looks environmental (missing `vendor/`, FFI off, wrong PHP minor) + rather than a code defect. + +Do not edit source files, tests, or configuration. If a fix is obvious, describe it; leave +the change to whoever asked. diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..633d837 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,15 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +indent_style = space +indent_size = 4 + +[*.{yml,yaml,json,neon}] +indent_size = 2 + +[*.md] +trim_trailing_whitespace = false diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 5a98fda..3e529aa 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -6,3 +6,8 @@ updates: interval: daily time: "04:00" open-pull-requests-limit: 10 +- package-ecosystem: github-actions + directory: "/" + schedule: + interval: weekly + open-pull-requests-limit: 5 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..67ae333 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,63 @@ +name: CI + +on: + push: + branches: + - master + pull_request: + +permissions: + contents: read + +# This library targets PHP 8.4 only - it rides z-engine's engine bindings. +env: + PHP_MINOR: '8.4' + +jobs: + tests: + name: Tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ env.PHP_MINOR }} + extensions: ffi + ini-values: ffi.enable=1, zend.assertions=1, opcache.jit=off + coverage: none + + - name: Install dependencies + uses: ramsey/composer-install@v3 + + - name: Run test suite + run: composer test + + static-analysis: + name: PHPStan (level max) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: shivammathur/setup-php@v2 + with: + php-version: ${{ env.PHP_MINOR }} + extensions: ffi + ini-values: ffi.enable=1, zend.assertions=1, opcache.jit=off + coverage: none + - uses: ramsey/composer-install@v3 + - run: composer phpstan + + coding-standards: + name: Coding standards + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: shivammathur/setup-php@v2 + with: + php-version: ${{ env.PHP_MINOR }} + extensions: ffi + ini-values: ffi.enable=1, zend.assertions=1, opcache.jit=off + coverage: none + - uses: ramsey/composer-install@v3 + - run: composer cs:check diff --git a/.github/workflows/phpunit.yml b/.github/workflows/phpunit.yml deleted file mode 100644 index 2cc6b30..0000000 --- a/.github/workflows/phpunit.yml +++ /dev/null @@ -1,57 +0,0 @@ -name: "PHPUnit tests" - -on: - push: - -jobs: - phpunit: - name: "PHPUnit tests" - - runs-on: ${{ matrix.operating-system }} - - strategy: - matrix: - dependencies: - - "lowest" - - "highest" - php-version: - - "7.4" - - "8.0" - operating-system: - - "ubuntu-latest" - - steps: - - name: "Checkout" - uses: "actions/checkout@v2" - - - name: "Install PHP" - uses: "shivammathur/setup-php@v2" - with: - coverage: "xdebug" - php-version: "${{ matrix.php-version }}" - ini-values: memory_limit=-1 - tools: composer:v2, cs2pr - - - name: "Cache dependencies" - uses: "actions/cache@v2" - with: - path: | - ~/.composer/cache - vendor - key: "php-${{ matrix.php-version }}-${{ matrix.dependencies }}" - restore-keys: "php-${{ matrix.php-version }}-${{ matrix.dependencies }}" - - - name: "Install lowest dependencies" - if: ${{ matrix.dependencies == 'lowest' }} - run: "composer update --prefer-lowest --no-interaction --no-progress --no-suggest" - - - name: "Install highest dependencies" - if: ${{ matrix.dependencies == 'highest' }} - run: "composer update --no-interaction --no-progress --no-suggest" - - - name: "Install locked dependencies" - if: ${{ matrix.dependencies == 'locked' }} - run: "composer install --no-interaction --no-progress --no-suggest" - - - name: "Tests" - run: "XDEBUG_MODE=coverage vendor/bin/phpunit" diff --git a/.gitignore b/.gitignore index ac20931..3b2d104 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,5 @@ composer.lock /build/ /vendor/ +/.phpunit.cache/ +/.php-cs-fixer.cache diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php new file mode 100644 index 0000000..aa86055 --- /dev/null +++ b/.php-cs-fixer.dist.php @@ -0,0 +1,30 @@ + + +This source file is subject to the license that is bundled +with this source code in the file LICENSE. +HEADER; + +$finder = PhpCsFixer\Finder::create() + ->in([__DIR__ . '/src']) + ->name('*.php') + ->append([__FILE__, __DIR__ . '/bootstrap.php']); + +return (new PhpCsFixer\Config()) + ->setRiskyAllowed(true) + ->setRules([ + '@PER-CS2.0' => true, + 'declare_strict_types' => true, + 'ordered_imports' => ['sort_algorithm' => 'alpha'], + 'no_unused_imports' => true, + 'single_quote' => true, + 'array_syntax' => ['syntax' => 'short'], + 'binary_operator_spaces' => ['default' => 'align_single_space_minimal'], + ]) + ->setFinder($finder); diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..8101b23 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,198 @@ +# Working on native-php-matrix + +This library gives PHP a `Matrix` class with real operator overloading — `+`, `-`, +`*`, `/`, `**` and `==` are dispatched by the Zend Engine itself, not emulated in +userland. It does that by using [z-engine](https://github.com/lisachenko/z-engine) +to install `create_object`, `do_operation` and `compare` handlers directly onto the +`Matrix` class entry through PHP FFI. The matrix arithmetic underneath is ordinary, +readable PHP; the only unusual part is how the engine is convinced to call it. + +## The one rule that is non-negotiable: the PHP version is pinned + +`composer.json` requires **`php: ~8.4.0`** — an exact minor, not a floor. z-engine +reads engine structures (`zend_class_entry`, `zval`, `zend_object_handlers`) by byte +offset, and those offsets change on every PHP minor release. Running against the +wrong minor does not throw a nice exception; it reads and writes the wrong memory. + +`ZEngine\Core::init()` (called from `bootstrap.php`) enforces the match and aborts +with a clear message. **Never "fix" an initialization failure by loosening the PHP +constraint, skipping `Core::init()`, or defeating the guard.** If the environment's +PHP does not satisfy `~8.4.0`, the environment is wrong — say so and stop. + +The same applies in reverse: z-engine's own FFI definitions live on per-minor +branches, so this package must consume the z-engine line built for PHP 8.4. + +## Running tests + +```bash +composer test +``` + +The suite is PHPUnit 12 driving `.phpt` files in `tests/Functional/`. Three INI +settings must hold in **both** the parent PHPUnit process and every `.phpt` child +process it spawns: + +- `ffi.enable=1` — FFI cannot be turned on at runtime. +- `opcache.jit=off` — the JIT rewrites the very executor internals z-engine hooks. +- `error_reporting=E_ALL & ~E_DEPRECATED` — z-engine `dev-master` still declares + implicitly nullable parameters (e.g. `ZEngine\Type\OpLine::__construct()`), which + PHP 8.4 reports as a deprecation. PHPUnit's `.phpt` runner forces + `display_errors=1`, so without this the dependency's deprecation is prepended to + the captured output of **every** test and each `--EXPECT--` block fails on noise + that has nothing to do with this library. Drop the suppression once z-engine + declares those parameters `?Type`. + +CI supplies the FFI and JIT pair as `ini-values` on the PHP setup step, and **every +`.phpt` file carries its own `--INI--` section** — all three lines — so the child +processes inherit nothing by luck. The deprecation suppression only ever matters in +the children, because those are the processes whose output is compared. + +For a local one-off run without touching `php.ini`: + +```bash +php -d ffi.enable=1 -d opcache.jit=off vendor/bin/phpunit +``` + +Single test: + +```bash +php -d ffi.enable=1 -d opcache.jit=off vendor/bin/phpunit tests/Functional/testCanAddMatrices.phpt +``` + +> **A segfault or bus error is not a normal test failure.** It means a hook or an +> engine structure is being used incorrectly — an engine-level bug. Do not retry +> the run hoping it passes, do not mark the test skipped: report the crash with the +> exact command, PHP version, and the test that triggered it. + +## Quality gates (all enforced in CI) + +```bash +composer phpstan # PHPStan at level max +composer cs:check # coding standards, PER-CS2.0 +composer cs:fix # apply the fixes +``` + +`Matrix` is declared `@template-covariant T of int|float`. **Keep the generics +honest.** Arithmetic widens: dividing or exponentiating a `Matrix` can produce +floats, so those results are `Matrix`, never `Matrix`. Do not annotate +a method as preserving `T` to make PHPStan quiet — if the maths does not preserve +the type, the signature must not claim it does. + +## Anatomy of a `.phpt` test + +Each file covers exactly one behaviour and is named `test.phpt`: + +``` +--TEST-- +Matrices can be added with "+" operator +--INI-- +ffi.enable=1 +opcache.jit=off +error_reporting=E_ALL & ~E_DEPRECATED +--FILE-- +toArray()); +?> +--EXPECT-- +array(1) { + [0]=> + array(3) { + [0]=> + int(5) + [1]=> + int(7) + [2]=> + int(9) + } +} +``` + +Rules for a new test: + +- `--INI--` is **mandatory, all three lines** — without them the child process has no + FFI, runs the JIT over hooked internals, or drowns the expected output in a + z-engine deprecation, and the test fails in a way that looks like a library bug. +- Include the autoloader with the relative path `__DIR__ . '/../../vendor/autoload.php'`; + that is what triggers `bootstrap.php` and installs the handlers. +- Prefer `--EXPECT--` (exact match). Use `--EXPECTREGEX--` only when the output + genuinely varies — uncaught-exception output, for example, contains file paths and + stack traces. +- One behaviour per file. Failure cases (incompatible dimensions, unsupported + operator combinations) get their own files. + +## Repository map + +``` +src/Matrix.php the entire library: the maths plus the __doOperation/__compare hooks +bootstrap.php Core::init() then installExtensionHandlers() on Matrix — order matters; + runs automatically via Composer's "files" autoload +tests/Functional/*.phpt the functional suite, one behaviour per file +phpunit.xml.dist PHPUnit 12 config (suite points at tests/, suffix .phpt) +phpstan.dist.neon static analysis config, level max +.php-cs-fixer.dist.php coding standards config (PER-CS2.0) +.github/workflows/ci.yml jobs: tests, static-analysis, coding-standards — PHP 8.4 +``` + +`src/Matrix.php` is the whole library. There is no framework here to hide behind: a +change to a hook signature or to `bootstrap.php`'s ordering affects every operator +at once. + +## Hook contracts + +- `__doOperation(DoOperationHook $hook)` and `__compare(CompareValuesHook $hook)` + are **static**. They do throw — `InvalidArgumentException` for a dimension + mismatch, `LogicException` for an operand combination the class does not + implement — but be precise about what that means for a caller: these hooks run + inside an **FFI callback**, and PHP 8.4 does not let an exception cross that + boundary. The engine reports the exception and then aborts with + `Fatal error: Throwing from FFI callbacks is not allowed`. The script dies with + exit code 255; a userland `try`/`catch` around `$a + $b` **will not catch it**. + This is why the incompatible-dimension tests use `--EXPECTREGEX--` and match the + message inside fatal-error output rather than catching anything. +- Validation performed in the **constructor** is ordinary userland code and its + `InvalidArgumentException` *is* catchable — that is the difference between + `testFailsOnEmptyMatrix.phpt` (uses `try`/`catch`) and + `testFailsOnAddingIncompatibleMatrices.phpt` (cannot). Prefer validating in the + constructor where a failure mode can be detected there. +- Handlers that the engine calls in non-throwing contexts — `get_debug_info`, + `get_iterator`, `cast_object` in some paths — **must not throw**. If a future + feature adds one of those, it has to degrade gracefully instead. +- Operations return a **new** `Matrix`. Nothing mutates in place; `$a += $b` works + because the engine rebinds the variable to the returned object. + +## Conventions + +[Conventional Commits](https://www.conventionalcommits.org/): + +``` +feat(matrix): support element-wise exponentiation by scalar +fix(bootstrap): install create_object handler before do_operation +test(tests): cover division by zero +ci: run the suite on PHP 8.4 with ffi.enable=1 +docs: rewrite the README in the z-engine style +``` + +Scopes in use: `matrix`, `bootstrap`, `tests`, `ci`, `docs`. + +Code style is **PER-CS2.0**, applied by php-cs-fixer. Run `composer cs:fix` before +proposing a change rather than hand-formatting. + +## Dependency policy + +- `lisachenko/z-engine` is required as **`dev-master`**. There is no stable tag with + PHP 8.4 support yet, so the root `composer.json` also carries + `"minimum-stability": "dev"` with `"prefer-stable": true` — Composer resolves + development stability only at the root level, so consumers need the same pair. +- PHP stays pinned at `~8.4.0`, in lockstep with the z-engine line this package + tracks. Bumping one without the other is never correct. +- When z-engine ships a stable PHP 8.4 release, both the constraint and the + root stability flags should be tightened in a single change. diff --git a/README.md b/README.md index 5718295..e5d7d6a 100644 --- a/README.md +++ b/README.md @@ -1,30 +1,87 @@ -Native matrix library ------------------ +
-For a long time, PHP developers dreamed of being able to overload operators. And now, finally, this moment has come. -Thanks to the PHP7.4 and the [lisachenko/z-engine](https://github.com/lisachenko/z-engine) package, we can overload the -operators of comparison, addition, multiplication, casting and much more! +# 🧮 Native PHP Matrix -This library is first ever userland PHP extension, that implements operator overloading for the `Matrix` class. +### Real operator overloading for PHP — in pure PHP. +**Native PHP Matrix** gives PHP a `Matrix` type with genuine arithmetic: `+`, `-`, `*`, `/`, `**` and `==` all work on matrices the way they work on numbers. There is no C code, no PECL package, no compiler — just [Z-Engine](https://github.com/lisachenko/z-engine) hooking the Zend Engine's own `do_operation` and `compare` handlers through FFI. + +[![CI](https://img.shields.io/github/actions/workflow/status/lisachenko/native-php-matrix/ci.yml?branch=master&label=CI)](https://github.com/lisachenko/native-php-matrix/actions/workflows/ci.yml) [![GitHub release](https://img.shields.io/github/release/lisachenko/native-php-matrix.svg)](https://github.com/lisachenko/native-php-matrix/releases/latest) -[![Minimum PHP Version](http://img.shields.io/badge/php-%3E%3D%207.4-8892BF.svg)](https://php.net/) -[![License](https://img.shields.io/github/license/lisachenko/native-php-matrix.svg)](https://packagist.org/packages/lisachenko/native-php-matrix) -[![GitHub Workflow Status](https://img.shields.io/github/workflow/status/lisachenko/native-php-matrix/PHPUnit%20tests)](https://github.com/lisachenko/native-php-matrix/actions/workflows/phpunit.yml) +[![PHP Version](https://img.shields.io/badge/php-8.4-8892BF.svg)](https://php.net/) +[![License](https://img.shields.io/packagist/l/lisachenko/native-php-matrix.svg)](https://packagist.org/packages/lisachenko/native-php-matrix) +[![PHPStan](https://img.shields.io/badge/PHPStan-level%20max-brightgreen.svg)](https://phpstan.org/) + +
+ +--- + +> **⚠️ Experimental — educational first, production second.** This library installs custom object handlers into a running Zend Engine. It is a demonstration of what userland PHP can reach, and it inherits every constraint of the machinery underneath it. Pin your PHP version, keep it out of production until there are stable tags on both this package and Z-Engine, and treat a crash as an engine-level bug report rather than a flaky test. + +## The first PHP "extension" written without C + +For a long time operator overloading was the exclusive privilege of compiled extensions: write C, learn the `zend_object_handlers` struct, build a `.so`, ship a PECL release. This library is the first ever *userland* PHP extension that implements true operator overloading — and it does it entirely in PHP. + +The trick is [lisachenko/z-engine](https://github.com/lisachenko/z-engine), which loads version-exact FFI definitions of the engine's C structures and lets PHP write into them. `Matrix` installs its own `do_operation` and `compare` handlers on its own class entry, so when the VM evaluates `$a * $b` and sees an object operand, it calls straight back into PHP code. The operators are not simulated, not parsed, not wrapped in a fluent API — the engine really dispatches them. + +## What you get + +| | Operation | Example | +|---|---|---| +| ➕ | **Addition** — element-wise, dimensions must match | `$a + $b` | +| ➖ | **Subtraction** — element-wise, dimensions must match | `$a - $b` | +| ✖️ | **Matrix multiplication** — columns of the left must match rows of the right | `$a * $b` | +| 🔢 | **Scalar multiplication** — in both directions | `$a * 2` and `2 * $a` | +| ➗ | **Scalar division** | `$a / 2` | +| 🔺 | **Scalar exponentiation** — element-wise power | `$a ** 2` | +| 🟰 | **Equality** — strict element-wise comparison | `$a == $b`, `$a != $b` | +| 🛡️ | **Type-safe by design** — PHPStan generics `Matrix` / `Matrix`, checked at level max | `Matrix` | + +Every operation returns a **new** `Matrix`; the class is `final` and its state is `readonly`, so nothing is ever mutated in place. The constructor validates that you passed a rectangular list of rows holding only `int`/`float` cells and raises a catchable `InvalidArgumentException` when it does not. + +Operator-level failures are a different story, and it is worth being blunt about it: a dimension mismatch (`InvalidArgumentException`) or an unimplemented operand combination such as `$a - 2` (`LogicException`) is raised *inside an FFI callback*, and PHP 8.4 does not allow an exception to cross that boundary. The engine prints the exception and then halts with `Fatal error: Throwing from FFI callbacks is not allowed`. You cannot `try`/`catch` it — check your dimensions before you multiply. + +Generics are honest about arithmetic: `Matrix` divided by `2` is a `Matrix`, because PHP's `/` widens. Nothing pretends to preserve `T` where the maths does not. + +## How it works + +Three moving parts, in order: +1. **`bootstrap.php`** is registered in Composer's `files` autoload, so it runs the moment you `require vendor/autoload.php`. It calls `ZEngine\Core::init()`, which validates that the FFI struct definitions match the exact PHP build you are running. +2. It then calls `installExtensionHandlers()` on `ZEngine\Reflection\ReflectionClass` for `Matrix`, wiring the class's `create_object`, `do_operation` and `compare` slots to the engine trampolines. Order matters — `create_object` allocates the memory the other handlers live in. +3. **`Matrix::__doOperation()`** and **`Matrix::__compare()`** are static hooks. The engine hands them a `DoOperationHook` / `CompareValuesHook` describing the opcode and both operands; they dispatch to ordinary, boring, pure-PHP methods (`sum()`, `subtract()`, `multiply()`, `multiplyByScalar()`, `divideByScalar()`, `powByScalar()`, `equals()`). -Pre-requisites and initialization --------------- +The maths is plain PHP. The magic is only in getting the engine to call it. -As this library depends on `FFI`, it requires PHP>=7.4 and `FFI` extension to be enabled. Also, current limitations of -[lisachenko/z-engine](https://github.com/lisachenko/z-engine) are also applied (x64, NTS) +## Requirements + +- **PHP `~8.4.0`** — an exact minor, not a floor. Z-Engine reads engine structures by byte offset and those offsets change on every PHP minor release; `Core::init()` refuses to boot on a mismatch rather than corrupting memory. +- **`ext-ffi` enabled**, with `ffi.enable=1` for CLI usage. +- **x64, non-thread-safe (NTS)** build — the same platform limitations as [Z-Engine](https://github.com/lisachenko/z-engine#requirements--support-matrix). +- The **matching Z-Engine minor branch**. This package tracks the PHP 8.4 line; mixing a Z-Engine built for another minor is not a configuration choice, it is undefined behaviour. + +## Installation -To install this library, simply add it via `composer`: ```bash -composer require lisachenko/native-php-matrix +composer require lisachenko/z-engine:dev-master lisachenko/native-php-matrix:dev-master ``` -Now you can test it with following example: +Z-Engine does not yet have a stable tag with PHP 8.4 support, so both packages are consumed from `dev-master`. Composer only resolves development stability at the **root** level, which is why the requirement has to be written into your own project rather than inherited — your `composer.json` needs: + +```json +{ + "minimum-stability": "dev", + "prefer-stable": true +} +``` + +Once a stable 8.4-capable Z-Engine release exists, both constraints collapse back to ordinary version ranges. + +No initialization call is needed: `bootstrap.php` ships in the package's `files` autoload and sets everything up behind `require vendor/autoload.php`. + +## Usage + +### The classic ```php toArray()); // [[32]] — a 1×3 times a 3×1 gives a 1×1 +``` + +### Powers and division + +```php +$m = new Matrix([[1, 2, 3]]); + +var_dump(($m ** 2)->toArray()); // [[1, 4, 9]] — element-wise exponentiation + +$even = new Matrix([[2, 4, 6]]); +var_dump(($even / 2)->toArray()); // [[1, 2, 3]] +``` + +### Comparison + +```php +$a = new Matrix([[1, 2], [3, 4]]); +$b = new Matrix([[1, 2], [3, 4]]); +$c = new Matrix([[1, 2], [3, 5]]); + +var_dump($a == $b); // bool(true) +var_dump($a == $c); // bool(false) +var_dump($a != $c); // bool(true) +``` + +Equality is element-wise and strict. Ordering operators (`<`, `>`) are deliberately not meaningful for matrices — the `compare` hook reports "unordered" rather than inventing a total order. + +### Beyond operators + +The class is a normal PHP object too: + +```php +$m = new Matrix([[1, 2], [3, 4]]); + +$m->getRows(); // 2 +$m->getColumns(); // 2 +$m->isSquare(); // true +$m->toArray(); // [[1, 2], [3, 4]] +``` + +## Roadmap + +Tracked as [GitHub issues](https://github.com/lisachenko/native-php-matrix/issues) — contributions welcome: + +- **Array-style row access** — `$matrix[0]` and `$matrix[0][1]` via the `read_dimension` handler +- **`count($matrix)`** — row count through `Countable`, installed at the engine level +- **`foreach` iteration** — row-by-row traversal via the `get_iterator` handler +- **Scalar casts** — `(string)` and `(float)` behaviour through `cast_object` +- **Friendly `var_dump()`** — a `get_debug_info` handler that prints the matrix instead of its internals +- **FFI BLAS backend** — hand multiplication off to a real BLAS library for performance, keeping the pure-PHP path as the fallback + +## Contributing + +The repository ships an agent/contributor contract in **[CLAUDE.md](CLAUDE.md)** — read it before changing anything, especially the section on why the PHP version is pinned. + +```bash +composer test # PHPUnit 12, .phpt functional suite +composer phpstan # static analysis at level max +composer cs:check # coding standards (PER-CS2.0); composer cs:fix to apply ``` -Supported features: - - [x] Matrices addition (`$matrixA + $matrixB`) - - [x] Matrices subtraction (`$matrixA - $matrixB`) - - [x] Matrix multiplication by number (`$matrixA * 2`) - - [x] Matrices multiplication (`$matrixA * $matrixB`) - - [x] Dividing a matrix by a number (`$matrixA / 2`) - - [x] Matrices equality check (`$matrixA == $matrixB`) - -For the future versions, I would like to implement native SSE/AVX assembly methods to improve the performance of calculation. +## License - +Released under the [MIT License](LICENSE). diff --git a/bootstrap.php b/bootstrap.php index 0532ec3..88f8601 100644 --- a/bootstrap.php +++ b/bootstrap.php @@ -1,4 +1,5 @@ - - - ./src/ - - - - - - - + bootstrap="./vendor/autoload.php" + failOnWarning="true" + displayDetailsOnTestsThatTriggerWarnings="true" + displayDetailsOnTestsThatTriggerNotices="true" + displayDetailsOnTestsThatTriggerDeprecations="true"> ./tests/ - - - performance - - - - - + + + ./src/ + + diff --git a/src/Matrix.php b/src/Matrix.php index 044e80d..8146084 100644 --- a/src/Matrix.php +++ b/src/Matrix.php @@ -1,4 +1,5 @@ ` a division + * or an exponentiation may produce floats, therefore every operation returns a `Matrix` instead of + * pretending that the original `T` is preserved. + * + * @template-covariant T of int|float */ -class Matrix implements ObjectCreateInterface, ObjectDoOperationInterface, ObjectCompareValuesInterface +final class Matrix implements ObjectCreateInterface, ObjectDoOperationInterface, ObjectCompareValuesInterface { use ObjectCreateTrait; - private array $matrix = []; - private int $rows = 0; - private int $columns = 0; + /** + * Matrix cells, stored as a list of rows + * + * @var non-empty-list> + */ + private readonly array $matrix; + + /** + * Total number of rows in this matrix + */ + private readonly int $rows; + + /** + * Total number of columns in this matrix + */ + private readonly int $columns; /** - * Matrix constructor. + * Matrix constructor * - * @param array $matrix + * @param non-empty-list> $matrix Rectangular list of rows, each one holding numeric cells */ public function __construct(array $matrix) { + if ($matrix === []) { + throw new InvalidArgumentException('Matrix should contain at least one row'); + } + if (!array_is_list($matrix)) { + throw new InvalidArgumentException('Matrix should be a list of rows with sequential keys, starting from 0'); + } + + $columns = null; + foreach ($matrix as $rowIndex => $row) { + if (!is_array($row) || !array_is_list($row)) { + throw new InvalidArgumentException( + sprintf('Matrix row %d should be a list of values with sequential keys, starting from 0', $rowIndex), + ); + } + if ($row === []) { + throw new InvalidArgumentException('Matrix should contain at least one column'); + } + if ($columns === null) { + $columns = count($row); + } elseif (count($row) !== $columns) { + throw new InvalidArgumentException('All matrix rows should have the same number of columns'); + } + foreach ($row as $columnIndex => $value) { + if (!is_int($value) && !is_float($value)) { + throw new InvalidArgumentException( + sprintf('Matrix value at [%d][%d] should be either an int or a float', $rowIndex, $columnIndex), + ); + } + } + } + $this->matrix = $matrix; $this->rows = count($matrix); $this->columns = count($matrix[0]); @@ -61,6 +125,11 @@ public function isSquare(): bool return $this->columns === $this->rows; } + /** + * Returns an underlying representation of this matrix + * + * @return non-empty-list> + */ public function toArray(): array { return $this->matrix; @@ -69,11 +138,9 @@ public function toArray(): array /** * Performs multiplication of two matrices * - * @param Matrix $multiplier + * @param self $multiplier Right operand * - * @todo: Implement SSE/AVX support for multiplication - * - * @return $this Product of two matrices + * @return self Product of two matrices */ public function multiply(self $multiplier): self { @@ -81,21 +148,27 @@ public function multiply(self $multiplier): self throw new InvalidArgumentException('Inconsistent matrix supplied'); } - $totalColumns = $multiplier->columns; - $result = []; - foreach ($this->matrix as $row => $rowItems) { - for ($column = 0; $column < $totalColumns; ++$column) { - $columnItems = array_column($multiplier->matrix, $column); - $cellValue = 0; + // Columns of the multiplier are extracted only once, they are reused for every row of the left operand + $multiplierColumns = []; + foreach (array_keys($multiplier->matrix[0]) as $column) { + $multiplierColumns[] = array_column($multiplier->matrix, $column); + } + + $result = []; + foreach ($this->matrix as $rowItems) { + $resultRow = []; + foreach ($multiplierColumns as $columnItems) { + $cellValue = 0; foreach ($rowItems as $key => $value) { $cellValue += $value * $columnItems[$key]; } - $result[$row][$column] = $cellValue; + $resultRow[] = $cellValue; } + $result[] = $resultRow; } - return new static($result); + return new self($result); } /** @@ -103,21 +176,20 @@ public function multiply(self $multiplier): self * * @param int|float $value Divider * - * @return $this + * @return self */ - public function divideByScalar($value): self + public function divideByScalar(int|float $value): self { - if (!is_numeric($value)) { - throw new \InvalidArgumentException("Divide accepts only numeric values"); - } $result = []; - for ($i = 0; $i < $this->rows; ++$i) { - for ($j = 0; $j < $this->columns; ++$j) { - $result[$i][$j] = $this->matrix[$i][$j] / $value; + foreach ($this->matrix as $row) { + $resultRow = []; + foreach ($row as $cellValue) { + $resultRow[] = $cellValue / $value; } + $result[] = $resultRow; } - return new static($result); + return new self($result); } /** @@ -125,21 +197,20 @@ public function divideByScalar($value): self * * @param int|float $value Multiplier * - * @return $this + * @return self */ - public function multiplyByScalar($value): self + public function multiplyByScalar(int|float $value): self { - if (!is_numeric($value)) { - throw new \InvalidArgumentException("Multiply accepts only numeric values"); - } $result = []; - for ($i = 0; $i < $this->rows; ++$i) { - for ($j = 0; $j < $this->columns; ++$j) { - $result[$i][$j] = $this->matrix[$i][$j] * $value; + foreach ($this->matrix as $row) { + $resultRow = []; + foreach ($row as $cellValue) { + $resultRow[] = $cellValue * $value; } + $result[] = $resultRow; } - return new static($result); + return new self($result); } /** @@ -147,50 +218,54 @@ public function multiplyByScalar($value): self * * @param int|float $value Exponent * - * @return $this + * @return self */ - public function powByScalar($value): self + public function powByScalar(int|float $value): self { - if (!is_numeric($value)) { - throw new \InvalidArgumentException("Exponent accepts only numeric values"); - } $result = []; - for ($i = 0; $i < $this->rows; ++$i) { - for ($j = 0; $j < $this->columns; ++$j) { - $result[$i][$j] = $this->matrix[$i][$j] ** $value; + foreach ($this->matrix as $row) { + $resultRow = []; + foreach ($row as $cellValue) { + $resultRow[] = $cellValue ** $value; } + $result[] = $resultRow; } - return new static($result); + return new self($result); } /** * Performs addition of two matrices * - * @return self Sum of two matrices - * @todo: Implement SSE/AVX support for addition + * @param self $value Right operand + * + * @return self Sum of two matrices */ public function sum(self $value): self { if (($this->columns !== $value->columns) || ($this->rows !== $value->rows)) { throw new InvalidArgumentException('Inconsistent matrix supplied'); } + $result = []; - for ($i = 0; $i < $this->rows; ++$i) { - for ($k = 0; $k < $this->columns; ++$k) { - $result[$i][$k] = $this->matrix[$i][$k] + $value->matrix[$i][$k]; + foreach ($this->matrix as $rowIndex => $row) { + $anotherRow = $value->matrix[$rowIndex]; + $resultRow = []; + foreach ($row as $columnIndex => $cellValue) { + $resultRow[] = $cellValue + $anotherRow[$columnIndex]; } + $result[] = $resultRow; } - return new static($result); + return new self($result); } /** * Performs subtraction of two matrices * - * @todo: Implement SSE/AVX support for addition + * @param self $value Right operand * - * @return self Subtraction of two matrices + * @return self Difference of two matrices */ public function subtract(self $value): self { @@ -199,31 +274,32 @@ public function subtract(self $value): self } $result = []; - for ($i = 0; $i < $this->rows; ++$i) { - for ($k = 0; $k < $this->columns; ++$k) { - $result[$i][$k] = $this->matrix[$i][$k] - $value->matrix[$i][$k]; + foreach ($this->matrix as $rowIndex => $row) { + $anotherRow = $value->matrix[$rowIndex]; + $resultRow = []; + foreach ($row as $columnIndex => $cellValue) { + $resultRow[] = $cellValue - $anotherRow[$columnIndex]; } + $result[] = $resultRow; } - return new static($result); + return new self($result); } /** * Checks if the given matrix equals to another one * - * @param Matrix $another Another matrix - * - * @return bool + * @param self $another Another matrix */ - public function equals(Matrix $another): bool + public function equals(self $another): bool { if ($another->rows !== $this->rows || $another->columns !== $this->columns) { return false; } - for ($i = 0; $i < $this->rows; ++$i) { - for ($k = 0; $k < $this->columns; ++$k) { - $equals = $this->matrix[$i][$k] === $another->matrix[$i][$k]; - if (!$equals) { + foreach ($this->matrix as $rowIndex => $row) { + $anotherRow = $another->matrix[$rowIndex]; + foreach ($row as $columnIndex => $cellValue) { + if ($cellValue !== $anotherRow[$columnIndex]) { return false; } } @@ -237,52 +313,54 @@ public function equals(Matrix $another): bool * * @param DoOperationHook $hook Instance of current hook * - * @return mixed Result of operation value + * @return self Result of operation */ - public static function __doOperation(DoOperationHook $hook): Matrix + public static function __doOperation(DoOperationHook $hook): self { $left = $hook->getFirst(); $right = $hook->getSecond(); $opCode = $hook->getOpcode(); - $isLeftMatrix = $left instanceof Matrix; - $isRightMatrix = $right instanceof Matrix; - $isLeftNumeric = is_numeric($left); - $isRightNumeric = is_numeric($right); + $leftMatrix = $left instanceof self ? $left : null; + $rightMatrix = $right instanceof self ? $right : null; + $leftScalar = self::asScalar($left); + $rightScalar = self::asScalar($right); switch ($opCode) { case OpCode::ADD: - if ($isLeftMatrix && $isRightMatrix) { - return $left->sum($right); + if ($leftMatrix !== null && $rightMatrix !== null) { + return $leftMatrix->sum($rightMatrix); } break; case OpCode::SUB: - if ($isLeftMatrix && $isRightMatrix) { - return $left->subtract($right); + if ($leftMatrix !== null && $rightMatrix !== null) { + return $leftMatrix->subtract($rightMatrix); } break; case OpCode::MUL: - if ($isLeftMatrix && $isRightMatrix) { - return $left->multiply($right); - } elseif ($isLeftMatrix && $isRightNumeric) { - return $left->multiplyByScalar($right); - } elseif ($isLeftNumeric && $isRightMatrix) { - return $right->multiplyByScalar($left); + if ($leftMatrix !== null && $rightMatrix !== null) { + return $leftMatrix->multiply($rightMatrix); + } + if ($leftMatrix !== null && $rightScalar !== null) { + return $leftMatrix->multiplyByScalar($rightScalar); + } + if ($leftScalar !== null && $rightMatrix !== null) { + return $rightMatrix->multiplyByScalar($leftScalar); } break; case OpCode::DIV: - if ($isLeftMatrix && $isRightNumeric) { - return $left->divideByScalar($right); + if ($leftMatrix !== null && $rightScalar !== null) { + return $leftMatrix->divideByScalar($rightScalar); } break; case OpCode::POW: - if ($isLeftMatrix && $isRightNumeric) { - return $left->powByScalar($right); + if ($leftMatrix !== null && $rightScalar !== null) { + return $leftMatrix->powByScalar($rightScalar); } break; } - throw new \LogicException('Unsupported ' . OpCode::name($opCode). ' operation or invalid arguments'); + throw new LogicException('Unsupported ' . OpCode::name($opCode) . ' operation or invalid arguments'); } /** @@ -290,14 +368,14 @@ public static function __doOperation(DoOperationHook $hook): Matrix * * @param CompareValuesHook $hook Instance of current hook * - * @return int Result of comparison: 1 is greater, -1 is less, 0 is equal + * @return int Result of comparison: 0 is equal, -2 is uncomparable */ public static function __compare(CompareValuesHook $hook): int { $one = $hook->getFirst(); $another = $hook->getSecond(); - if (!($one instanceof Matrix) || !($another instanceof Matrix)) { - throw new \InvalidArgumentException('Matrix can be compared only with another matrix'); + if (!($one instanceof self) || !($another instanceof self)) { + throw new InvalidArgumentException('Matrix can be compared only with another matrix'); } if ($one->equals($another)) { @@ -306,4 +384,26 @@ public static function __compare(CompareValuesHook $hook): int return -2; } + + /** + * Casts an operand received from the engine into a native scalar value, if possible + * + * Numeric strings are explicitly coerced with the unary plus, otherwise `$matrix * '2'` would fail against the + * natively typed `int|float` parameters under `strict_types=1`. + * + * @param mixed $operand Raw operand, received from the operation hook + * + * @return int|float|null Coerced value or null if the operand is not numeric at all + */ + private static function asScalar(mixed $operand): int|float|null + { + if (is_int($operand) || is_float($operand)) { + return $operand; + } + if (is_string($operand) && is_numeric($operand)) { + return +$operand; + } + + return null; + } } diff --git a/tests/Functional/testCanAddMatrices.phpt b/tests/Functional/testCanAddMatrices.phpt index e4f1c4b..5148f64 100644 --- a/tests/Functional/testCanAddMatrices.phpt +++ b/tests/Functional/testCanAddMatrices.phpt @@ -1,5 +1,9 @@ --TEST-- Matrices can be added with "+" operator +--INI-- +ffi.enable=1 +opcache.jit=off +error_reporting=E_ALL & ~E_DEPRECATED --FILE-- getMessage(), PHP_EOL; +} +?> +--EXPECT-- +InvalidArgumentException: Matrix should contain at least one row diff --git a/tests/Functional/testFailsOnRaggedMatrix.phpt b/tests/Functional/testFailsOnRaggedMatrix.phpt new file mode 100644 index 0000000..ecdc869 --- /dev/null +++ b/tests/Functional/testFailsOnRaggedMatrix.phpt @@ -0,0 +1,22 @@ +--TEST-- +Matrix can not be created from rows with a different number of columns +--INI-- +ffi.enable=1 +opcache.jit=off +error_reporting=E_ALL & ~E_DEPRECATED +--FILE-- +getMessage(), PHP_EOL; +} +?> +--EXPECT-- +InvalidArgumentException: All matrix rows should have the same number of columns diff --git a/tests/Functional/testFailsOnSubtractingIncompatibleMatrices.phpt b/tests/Functional/testFailsOnSubtractingIncompatibleMatrices.phpt index ecd3930..ba5717a 100644 --- a/tests/Functional/testFailsOnSubtractingIncompatibleMatrices.phpt +++ b/tests/Functional/testFailsOnSubtractingIncompatibleMatrices.phpt @@ -1,5 +1,9 @@ --TEST-- Matrices with incompatible dimensions can not be subtracted +--INI-- +ffi.enable=1 +opcache.jit=off +error_reporting=E_ALL & ~E_DEPRECATED --FILE-- --EXPECTREGEX--