diff --git a/README.md b/README.md index 3aa7450..359638f 100644 --- a/README.md +++ b/README.md @@ -134,6 +134,25 @@ 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. +### Casting + +Casts are dispatched by the engine too — the `cast_object` and `get_properties_for` handlers: + +```php +$m = new Matrix([[1, 2], [3, 4]]); + +var_dump((array) $m); // [[1, 2], [3, 4]] — the rows, not the object's internals + +echo (string) $m; // [1, 2] + // [3, 4] + +var_dump((bool) $m); // bool(true) — a valid matrix is never empty by construction +``` + +Numeric casts keep the engine's default behaviour (`(int) $m` gives `1`), and everything that is +not a cast — `var_dump()`, `serialize()`, `var_export()`, `json_encode()`, `get_object_vars()` — +still sees the object exactly as before. + ### Beyond operators The class is a normal PHP object too: @@ -154,7 +173,6 @@ Tracked as [GitHub issues](https://github.com/lisachenko/native-php-matrix/issue - **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 diff --git a/phpunit.xml.dist b/phpunit.xml.dist index bdde63c..a208f97 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -13,6 +13,8 @@ colors="true" bootstrap="./vendor/autoload.php" failOnWarning="true" + failOnSkipped="true" + failOnIncomplete="true" displayDetailsOnTestsThatTriggerWarnings="true" displayDetailsOnTestsThatTriggerNotices="true" displayDetailsOnTestsThatTriggerDeprecations="true"> diff --git a/src/Matrix.php b/src/Matrix.php index 8146084..a83779f 100644 --- a/src/Matrix.php +++ b/src/Matrix.php @@ -13,9 +13,15 @@ namespace Lisachenko\NativePhpMatrix; use function array_column; +use function array_filter; + +use const ARRAY_FILTER_USE_KEY; + use function array_is_list; use function array_keys; use function count; +use function get_mangled_object_vars; +use function implode; use InvalidArgumentException; @@ -28,13 +34,20 @@ use LogicException; use function sprintf; +use function str_starts_with; +use ZEngine\ClassExtension\Hook\CastObjectHook; +use ZEngine\ClassExtension\Hook\CastType; use ZEngine\ClassExtension\Hook\CompareValuesHook; use ZEngine\ClassExtension\Hook\DoOperationHook; +use ZEngine\ClassExtension\Hook\GetPropertiesForHook; +use ZEngine\ClassExtension\Hook\PropertyPurpose; +use ZEngine\ClassExtension\ObjectCastInterface; use ZEngine\ClassExtension\ObjectCompareValuesInterface; use ZEngine\ClassExtension\ObjectCreateInterface; use ZEngine\ClassExtension\ObjectCreateTrait; use ZEngine\ClassExtension\ObjectDoOperationInterface; +use ZEngine\ClassExtension\ObjectGetPropertiesForInterface; use ZEngine\System\OpCode; /** @@ -46,7 +59,12 @@ * * @template-covariant T of int|float */ -final class Matrix implements ObjectCreateInterface, ObjectDoOperationInterface, ObjectCompareValuesInterface +final class Matrix implements + ObjectCastInterface, + ObjectCompareValuesInterface, + ObjectCreateInterface, + ObjectDoOperationInterface, + ObjectGetPropertiesForInterface { use ObjectCreateTrait; @@ -385,6 +403,101 @@ public static function __compare(CompareValuesHook $hook): int return -2; } + /** + * Performs casting of this object to another type, requested by the engine + * + * Unlike the operation and comparison hooks this handler never throws: it runs inside an FFI + * callback, and PHP escalates any exception crossing that boundary into an engine-level + * fatal error. Cast types that are not implemented here defer to the default engine behaviour + * via {@see CastObjectHook::proceed()} — on the z-engine dev lines this package tracks that + * fall-through behaves exactly like an uninstalled handler, so failed numeric casts propagate + * to the engine caller, which emits its own warning and substitutes the value 1. + * + * @param CastObjectHook $hook Instance of current hook + * + * @return mixed Casted value + */ + public static function __cast(CastObjectHook $hook): mixed + { + $object = $hook->getObject(); + + if ($object instanceof self) { + switch ($hook->getCastTypeEnum()) { + case CastType::Array: + // PHP 8.4 routes `(array)` casts through the get_properties_for handler (see + // __getFields), the branch stays for engine paths that pass IS_ARRAY directly + return $object->toArray(); + case CastType::String: + return $object->toString(); + case CastType::Bool: + // A valid matrix holds at least one cell by construction, so it is never "empty" + return true; + default: + break; + } + } + + $hook->proceed(); + + return $hook->getResult(); + } + + /** + * Returns an array representation of this object for the purpose requested by the engine + * + * PHP 8.4 does not route `(array)` casts through the cast_object handler: they arrive here, + * at the get_properties_for handler, with the ARRAY_CAST purpose. Every other purpose + * (debugging, serialization, var_export, JSON encoding, get_object_vars) keeps the default + * property table, reproduced with get_mangled_object_vars() because the raw hashtable + * returned by the original engine handler cannot cross the hook boundary as a PHP array. + * This handler runs in non-throwing engine contexts and therefore never throws. + * + * One deliberate deviation: get_object_vars() is scope-sensitive by default (a closure bound + * to Matrix would see the private properties), but the calling scope is not recoverable from + * inside this FFI callback, so every caller receives the public view — an empty array. + * + * @param GetPropertiesForHook $hook Instance of current hook + * + * @return array Key-value pairs for the requested purpose + */ + public static function __getFields(GetPropertiesForHook $hook): array + { + $object = $hook->getObject(); + $purpose = $hook->getPurposeEnum(); + + if ($object instanceof self && $purpose === PropertyPurpose::ArrayCast) { + return $object->toArray(); + } + + if ($purpose === PropertyPurpose::GetObjectVars) { + // The engine hands the returned table to get_object_vars() callers without applying + // any visibility filtering once a custom handler is installed, so only the publicly + // visible entries may be exposed here: every property of Matrix is private, exactly + // like the default handlers would show to an outside caller. Class-scoped callers + // lose their privileged view — see the deviation note in the method docblock + return array_filter( + get_mangled_object_vars($object), + static fn(int|string $key): bool => !is_string($key) || !str_starts_with($key, "\0"), + ARRAY_FILTER_USE_KEY, + ); + } + + return get_mangled_object_vars($object); + } + + /** + * Returns a human-readable representation of this matrix, one row per line + */ + private function toString(): string + { + $rows = []; + foreach ($this->matrix as $row) { + $rows[] = '[' . implode(', ', $row) . ']'; + } + + return implode("\n", $rows); + } + /** * Casts an operand received from the engine into a native scalar value, if possible * diff --git a/tests/Functional/testCanCastMatrixToArray.phpt b/tests/Functional/testCanCastMatrixToArray.phpt new file mode 100644 index 0000000..5f7ee05 --- /dev/null +++ b/tests/Functional/testCanCastMatrixToArray.phpt @@ -0,0 +1,38 @@ +--TEST-- +Matrix can be cast to array with "(array)" operator +--INI-- +ffi.enable=1 +opcache.jit=off +error_reporting=E_ALL & ~E_DEPRECATED +--FILE-- + +--EXPECT-- +array(2) { + [0]=> + array(3) { + [0]=> + int(1) + [1]=> + int(2) + [2]=> + int(3) + } + [1]=> + array(3) { + [0]=> + int(4) + [1]=> + int(5) + [2]=> + int(6) + } +} diff --git a/tests/Functional/testCanCastMatrixToBool.phpt b/tests/Functional/testCanCastMatrixToBool.phpt new file mode 100644 index 0000000..b0a931b --- /dev/null +++ b/tests/Functional/testCanCastMatrixToBool.phpt @@ -0,0 +1,23 @@ +--TEST-- +Matrix can be cast to bool with "(bool)" operator and is always truthy +--INI-- +ffi.enable=1 +opcache.jit=off +error_reporting=E_ALL & ~E_DEPRECATED +--FILE-- + +--EXPECT-- +bool(true) +truthy diff --git a/tests/Functional/testCanCastMatrixToNumberUsingEngineFallback.phpt b/tests/Functional/testCanCastMatrixToNumberUsingEngineFallback.phpt new file mode 100644 index 0000000..6289062 --- /dev/null +++ b/tests/Functional/testCanCastMatrixToNumberUsingEngineFallback.phpt @@ -0,0 +1,24 @@ +--TEST-- +Numeric casts of a Matrix fall back to the default engine behaviour (warning and substitute value) +--INI-- +ffi.enable=1 +opcache.jit=off +error_reporting=E_ALL & ~E_DEPRECATED +--FILE-- + +--EXPECTREGEX-- +Warning: Object of class Lisachenko\\NativePhpMatrix\\Matrix could not be converted to int in .+ on line \d+ +int\(1\) + +Warning: Object of class Lisachenko\\NativePhpMatrix\\Matrix could not be converted to float in .+ on line \d+ +float\(1\) diff --git a/tests/Functional/testCanCastMatrixToString.phpt b/tests/Functional/testCanCastMatrixToString.phpt new file mode 100644 index 0000000..ce0a8d0 --- /dev/null +++ b/tests/Functional/testCanCastMatrixToString.phpt @@ -0,0 +1,20 @@ +--TEST-- +Matrix can be cast to string with "(string)" operator +--INI-- +ffi.enable=1 +opcache.jit=off +error_reporting=E_ALL & ~E_DEPRECATED +--FILE-- + +--EXPECT-- +[1, 2, 3] +[4, 5.5, 6] diff --git a/tests/Functional/testKeepsDefaultDebugOutput.phpt b/tests/Functional/testKeepsDefaultDebugOutput.phpt new file mode 100644 index 0000000..77ec5ef --- /dev/null +++ b/tests/Functional/testKeepsDefaultDebugOutput.phpt @@ -0,0 +1,26 @@ +--TEST-- +Debugging a Matrix keeps the default engine property table with visibility markers +--INI-- +ffi.enable=1 +opcache.jit=off +error_reporting=E_ALL & ~E_DEPRECATED +--FILE-- + +--EXPECT-- +bool(true) +bool(true) +bool(true) diff --git a/tests/Functional/testKeepsPropertyVisibilityForClassScopedGetObjectVars.phpt b/tests/Functional/testKeepsPropertyVisibilityForClassScopedGetObjectVars.phpt new file mode 100644 index 0000000..fce0e82 --- /dev/null +++ b/tests/Functional/testKeepsPropertyVisibilityForClassScopedGetObjectVars.phpt @@ -0,0 +1,24 @@ +--TEST-- +get_object_vars() on a Matrix returns the public view even for class-scoped callers (known deviation) +--INI-- +ffi.enable=1 +opcache.jit=off +error_reporting=E_ALL & ~E_DEPRECATED +--FILE-- + get_object_vars($m), null, Matrix::class); +var_dump($scoped($matrix)); +?> +--EXPECT-- +array(0) { +} diff --git a/tests/Functional/testKeepsPropertyVisibilityForGetObjectVars.phpt b/tests/Functional/testKeepsPropertyVisibilityForGetObjectVars.phpt new file mode 100644 index 0000000..ee0751d --- /dev/null +++ b/tests/Functional/testKeepsPropertyVisibilityForGetObjectVars.phpt @@ -0,0 +1,20 @@ +--TEST-- +get_object_vars() on a Matrix keeps default property visibility for outside callers +--INI-- +ffi.enable=1 +opcache.jit=off +error_reporting=E_ALL & ~E_DEPRECATED +--FILE-- + +--EXPECT-- +array(0) { +}