diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 6ea45b0..bc91021 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -3,37 +3,76 @@ run-name: test on: workflow_dispatch: push: - branches: master + branches: + - master + - main pull_request: - branches: master + branches: + - master + - main +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true +permissions: + contents: read jobs: test: runs-on: ubuntu-latest + timeout-minutes: 15 strategy: + fail-fast: false matrix: - php-versions: - - 8.5 - - 8.4 - - 8.3 + php-version: - 8.2 - - 8.1 + - 8.3 + - 8.4 + - 8.5 - name: PHP ${{ matrix.php-versions }} + name: Test PHP ${{ matrix.php-version }} steps: - name: Checkout code - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Setup PHP uses: shivammathur/setup-php@v2 with: - php-version: ${{ matrix.php-versions }} + php-version: ${{ matrix.php-version }} extensions: json coverage: none + tools: composer:v2 + cache: composer - name: Install dependencies - run: composer install + run: composer install --prefer-dist --no-interaction --no-progress - name: Run tests - run: vendor/bin/phpunit + run: vendor/bin/pest --parallel --colors=never + - name: Run static analysis - run: vendor/bin/phpstan + run: vendor/bin/phpstan analyse --no-progress --configuration=phpstan.neon.dist + + quality: + runs-on: ubuntu-latest + timeout-minutes: 15 + name: Code Quality + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: 8.5 + extensions: json + coverage: none + tools: composer:v2 + cache: composer + + - name: Install dependencies + run: composer install --prefer-dist --no-interaction --no-progress + + - name: Run Pint + run: vendor/bin/pint --test -v + + - name: Run Rector (dry run) + run: vendor/bin/rector process --dry-run --no-progress-bar diff --git a/.gitignore b/.gitignore index 3f2f879..dca7ed0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ /.idea/ /.phpunit.result.cache +/.phpunit.cache /cache.properties /vendor/ composer.lock diff --git a/README.md b/README.md index a3f99f8..df46d5e 100644 --- a/README.md +++ b/README.md @@ -3,11 +3,16 @@ # BinaryFlags With this class you can easily add flags to your projects. -The number of flags you can use is limited to the architecture of your system, e.g.: 32 flags on a 32-bit system or 64 flags on 64-bit system. -To store 64-bits flags in a database, you will need to store it as UNSIGNED BIGINT in MySQL or an equivalent in your datastore. +The number of flags you can use is limited to the architecture of your system, e.g.: 32 flags on a 32-bit system or 64 flags on 64-bit system. +To store 64-bit flags in a database, you will need to store it as UNSIGNED BIGINT in MySQL or an equivalent in your datastore. This package also comes with a trait which you can use to implement binary flags directly in your own class. +### Trait naming +For new code, prefer `Reinder83\BinaryFlags\Traits\InteractsWithNumericFlags`. +`Reinder83\BinaryFlags\Traits\BinaryFlags` remains available for backward compatibility. +For enum-based usage, use `Reinder83\BinaryFlags\BinaryEnumFlags` (which uses `Traits\InteractsWithEnumFlags`). + ## Installing To install this package simply run the following command in the root of your project. @@ -15,6 +20,27 @@ To install this package simply run the following command in the root of your pro composer require reinder83/binary-flags ``` +## Deprecation Notice (Upcoming v3.0.0 Breaking Change) +Starting in `v2.1.0`, passing `float` values as masks or flags is deprecated. + +- Current `v2.x` behavior: floats are still accepted for backward compatibility, but trigger a deprecation warning. +- `v3.0.0` behavior: masks and flags will be `int`-only. +- `v3.0.0` behavior: `Bits::BIT_64` will be removed. + +### BIT_64 Notice +`Bits::BIT_64` is being removed because PHP numbers for bitwise flags are signed. The 64th bit is the sign bit, so it cannot be used reliably as a normal flag. + +Using integer-compatible bits (`BIT_1` through `BIT_63`) prevents these issues and is the supported path for `v3.0.0`. + +To prepare for `v3.0.0`, cast incoming values before using the API: + +```php +$flags->setMask((int) $maskFromLegacySource); +$flags->addFlag((int) $incomingFlag); +``` + +See [UPGRADE-v3.md](UPGRADE-v3.md) for migration details. + ## Methods The following methods can be used: @@ -25,6 +51,14 @@ This can be passed as first argument in the constructor. ##### getMask(): int Retrieve the current mask. +When using `BinaryEnumFlags`, `getMask()` returns a `Mask` object instead. +Use `getMaskValue(): int` on enum-based flags if you need the numeric mask. + +##### getMaskValue(): int +_Since: v2.1.0_ \ +Returns the numeric mask value for storage/interoperability. +This method is only available on enum-backed flags (`BinaryEnumFlags`). + ##### setOnModifyCallback(callable $onModify) Set a callback function which is called when the mask changes. This can be passed as second argument in the constructor. @@ -47,7 +81,7 @@ When you want to match any of the given flags set `$checkAll` to `false`. ##### checkAnyFlag(int $mask): bool _Since: v1.0.1_ \ -For you convenient I've added an alias to checkFlag with `$checkAll` set to `false`. +For your convenience I've added an alias to checkFlag with `$checkAll` set to `false`. ##### count(): int _Since: v1.2.0_ \ @@ -77,7 +111,7 @@ You can treat a BinaryFlags object as an iterable, where each iteration will ret ## Example usage -Below some example usage code +Below is some example usage code ##### Create classes ```php @@ -137,6 +171,78 @@ var_export($exampleFlags->checkAnyFlag(ExampleFlags::FOO | ExampleFlags::BAZ)); ``` +##### Enum usage (optional) +```php +use Reinder83\BinaryFlags\BinaryEnumFlags; +use Reinder83\BinaryFlags\Mask; + +enum Permission: int +{ + case CanView = Bits::BIT_1; + case CanBook = Bits::BIT_2; + case CanCancel = Bits::BIT_3; +} + +class PermissionFlags extends BinaryEnumFlags +{ + protected static function getFlagEnumClass(): string + { + return Permission::class; + } +} + +$flags = new PermissionFlags(Permission::CanView); +$flags->addFlag(Permission::CanBook); +$flags->addFlag(Mask::forEnum(Permission::class, Permission::CanCancel)); + +var_export($flags->checkFlag(Permission::CanBook)); +// true + +var_export($flags->getFlagNames()); +// 'Can View, Can Book, Can Cancel' +``` + +##### Migrating from numeric flags to enum flags +```php +// Before (numeric PermissionFlags) +use Reinder83\BinaryFlags\BinaryFlags; + +class PermissionFlags extends BinaryFlags +{ + public const CAN_VIEW = Bits::BIT_1; + public const CAN_BOOK = Bits::BIT_2; +} + +$flags = new PermissionFlags($storedMask); +$flags->addFlag(PermissionFlags::CAN_VIEW | PermissionFlags::CAN_BOOK); +$storedMask = $flags->getMask(); // int + +// After (enum PermissionFlags) +use Reinder83\BinaryFlags\BinaryEnumFlags; +use Reinder83\BinaryFlags\Mask; + +enum Permission: int +{ + case CanView = Bits::BIT_1; + case CanBook = Bits::BIT_2; +} + +class PermissionFlags extends BinaryEnumFlags +{ + protected static function getFlagEnumClass(): string + { + return Permission::class; + } +} + +$flags = new PermissionFlags(Mask::fromInt($storedMask, Permission::class)); +$flags->addFlag(Permission::CanView); +$flags->addFlag(Permission::CanBook); + +// Save as integer for storage/interop +$storedMask = $flags->getMaskValue(); +``` + ##### Flag names example _By default, the flag names are based on the constant names_ ```php @@ -228,7 +334,7 @@ class Test extends Model $test = Test::find(1); // do binary operations on the flags class as described earlier -$test->flags->checkFlag(ExampleFlag::FOO); +$test->flags->checkFlag(ExampleFlags::FOO); ``` diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md new file mode 100644 index 0000000..e5243fb --- /dev/null +++ b/RELEASE_NOTES.md @@ -0,0 +1,66 @@ +# Release Notes - v2.1.0 + +## Added +- New enum-backed API: + - `BinaryEnumFlags` + - `Traits\InteractsWithEnumFlags` + - `Flag` enum and `Mask` value object +- Enum-backed flags now return a `Mask` object from `getMask()`. +- New `getMaskValue(): int` method for enum-backed flags to persist/interoperate with integer masks. +- Deprecation warnings for passing `float` values as masks/flags. +- README migration notice for the upcoming `v3.0.0` integer-only API. +- `UPGRADE-v3.md` with migration instructions. +- New primary numeric trait: `Traits\InteractsWithNumericFlags`. +- `Traits\BinaryFlags` is now deprecated and kept for backward compatibility. + +## Deprecated +- Passing `float` to BinaryFlags mask/flag methods is deprecated in `v2.1.0`. +- Float support will be removed in `v3.0.0`. +- `Bits::BIT_64` will be removed in `v3.0.0`. + +## BIT_64 Notice +`Bits::BIT_64` is being removed because PHP numbers for bitwise flags are signed. The 64th bit is the sign bit, so it cannot be used reliably as a normal flag. + +Using integer-compatible bits avoids these issues. + +## Migration Recommendation +Cast external/legacy mask and flag values to `int` before calling BinaryFlags methods. + +```php +$flags->setMask((int) $mask); +$flags->addFlag((int) $flag); +``` + +## Enum Migration Example +```php +// Before: numeric PermissionFlags +class PermissionFlags extends BinaryFlags +{ + public const CAN_VIEW = Bits::BIT_1; + public const CAN_BOOK = Bits::BIT_2; +} + +$flags = new PermissionFlags($storedMask); +$flags->addFlag(PermissionFlags::CAN_VIEW | PermissionFlags::CAN_BOOK); +$storedMask = $flags->getMask(); + +// After: enum-backed PermissionFlags +enum Permission: int +{ + case CanView = Bits::BIT_1; + case CanBook = Bits::BIT_2; +} + +class PermissionFlags extends BinaryEnumFlags +{ + protected static function getFlagEnumClass(): string + { + return Permission::class; + } +} + +$flags = new PermissionFlags(Mask::fromInt($storedMask, Permission::class)); +$flags->addFlag(Permission::CanView); +$flags->addFlag(Permission::CanBook); +$storedMask = $flags->getMaskValue(); +``` diff --git a/UPGRADE-v3.md b/UPGRADE-v3.md new file mode 100644 index 0000000..ce8281a --- /dev/null +++ b/UPGRADE-v3.md @@ -0,0 +1,37 @@ +# Upgrade Guide for v3.0.0 + +## Summary +`v3.0.0` removes support for `float` values in masks and flags. +`v3.0.0` also removes `Bits::BIT_64`. + +## What Changed +- `v2.x`: `int|float` accepted in mask/flag methods. +- `v3.0.0`: only `int` is accepted. +- `v2.x`: `Bits::BIT_64` exists but is not reliable in real bitwise usage. +- `v3.0.0`: `Bits::BIT_64` is removed. Use `BIT_1` through `BIT_63`. + +## How to Migrate +1. Find every call that passes mask/flag values into BinaryFlags methods. +2. Ensure values are cast to `int` before passing them. +3. Ensure database or external sources provide integer-compatible values. + +## Example +Before: +```php +$flags->setMask($legacyValue); +$flags->addFlag($legacyFlag); +``` + +After: +```php +$flags->setMask((int) $legacyValue); +$flags->addFlag((int) $legacyFlag); +``` + +## v2.1+ Deprecation Signal +Starting in `v2.1.0`, float inputs trigger deprecation warnings to help detect call sites before moving to `v3.0.0`. + +## Why BIT_64 Is Being Removed +`BIT_64` is being removed because PHP numbers for bitwise flags are signed. The 64th bit is the sign bit, so it cannot be used reliably as a normal flag. + +Staying with integer-compatible bits prevents those runtime issues. diff --git a/composer.json b/composer.json index cc6b7ae..d09ac60 100644 --- a/composer.json +++ b/composer.json @@ -28,16 +28,21 @@ }, "config": { "platform": { - "php": "8.1" + "php": "8.2" + }, + "allow-plugins": { + "pestphp/pest-plugin": true } }, "require": { - "php": "^8.1", + "php": "^8.2", "ext-json": "*" }, "require-dev": { - "phpunit/phpunit": "^9.5", - "phpstan/phpstan": "^1.10" + "phpstan/phpstan": "^1.10", + "pestphp/pest": "^2.36", + "laravel/pint": "^1", + "rector/rector": "^1" }, "autoload-dev": { "psr-4": { diff --git a/phpunit.xml b/phpunit.xml index 06502ca..1b2e257 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -1,13 +1,13 @@ - - - - ./src - - + ./tests/ + + + ./src + + diff --git a/pint.json b/pint.json new file mode 100644 index 0000000..9a57915 --- /dev/null +++ b/pint.json @@ -0,0 +1,10 @@ +{ + "preset": "per", + "rules": { + "class_attributes_separation": { + "elements": { + "const": "none" + } + } + } +} diff --git a/rector.php b/rector.php new file mode 100644 index 0000000..53ad6c9 --- /dev/null +++ b/rector.php @@ -0,0 +1,14 @@ +withPaths([ + __DIR__ . '/src', + __DIR__ . '/tests', + ]) + // uncomment to reach your current PHP version + // ->withPhpSets() + ->withTypeCoverageLevel(0); diff --git a/src/BinaryEnumFlags.php b/src/BinaryEnumFlags.php new file mode 100644 index 0000000..3739022 --- /dev/null +++ b/src/BinaryEnumFlags.php @@ -0,0 +1,110 @@ + + */ +abstract class BinaryEnumFlags implements Countable, Iterator, JsonSerializable +{ + /** @use Traits\InteractsWithEnumFlags */ + use Traits\InteractsWithEnumFlags; + + private int $currentPos = 0; + + /** + * @param int|TEnum|Mask $mask + */ + public function __construct(int|BackedEnum|Mask $mask = 0, ?Closure $onModify = null) + { + $this->setMask($mask); + + if ($onModify !== null) { + $this->setOnModifyCallback($onModify); + } + } + + private function iterableMask(): int + { + return $this->mask & static::getAllFlagsMask(); + } + + /** + * @return TEnum + */ + public function current(): BackedEnum + { + $enumClass = static::getFlagEnumClass(); + + /** @var TEnum $enum */ + $enum = $enumClass::from($this->currentPos); + + return $enum; + } + + public function next(): void + { + $iterableMask = $this->iterableMask(); + $this->currentPos <<= 1; + while (($iterableMask & $this->currentPos) === 0 && $this->currentPos > 0) { + $this->currentPos <<= 1; + } + } + + public function key(): int + { + return $this->currentPos; + } + + public function valid(): bool + { + return $this->currentPos > 0 && ($this->iterableMask() & $this->currentPos) !== 0; + } + + public function rewind(): void + { + $iterableMask = $this->iterableMask(); + if ($iterableMask === 0) { + $this->currentPos = 0; + + return; + } + + $this->currentPos = 1; + while (($iterableMask & $this->currentPos) === 0) { + $this->currentPos <<= 1; + } + } + + public function count(): int + { + $count = 0; + $mask = $this->mask; + + while ($mask !== 0) { + if (($mask & 1) === 1) { + $count++; + } + $mask >>= 1; + } + + return $count; + } + + /** + * @return array{mask: int} + */ + public function jsonSerialize(): array + { + return ['mask' => $this->mask]; + } +} diff --git a/src/BinaryFlags.php b/src/BinaryFlags.php index cf03eee..ead0d0b 100644 --- a/src/BinaryFlags.php +++ b/src/BinaryFlags.php @@ -8,25 +8,20 @@ use JsonSerializable; /** - * This class holds useful methods for checking, adding or removing binary flags + * This class holds useful methods for checking, adding, or removing binary flags * * @author Reinder * * @implements Iterator */ -abstract class BinaryFlags implements Iterator, Countable, JsonSerializable +abstract class BinaryFlags implements Countable, Iterator, JsonSerializable { - use Traits\BinaryFlags; + use Traits\InteractsWithNumericFlags; - /** - * @var int - */ private int $currentPos = 0; /** * Initiate class - * @param int|float $mask - * @param Closure|null $onModify */ public function __construct(int|float $mask = 0, ?Closure $onModify = null) { @@ -42,26 +37,26 @@ public function __construct(int|float $mask = 0, ?Closure $onModify = null) * Return the current element * * @return string the description of the flag or the name of the constant + * * @since 1.2.0 */ public function current(): string { /** @var string $result Will always be string since the second argument is false */ - $result = $this->getFlagNames($this->currentPos, false); + $result = $this->getFlagNames($this->currentPos); return $result; } /** - * Move forward to next element + * Move forward to the next element * - * @return void * @since 1.2.0 */ public function next(): void { - $this->currentPos <<= 1; // shift to next bit - while (($this->mask & $this->currentPos) == 0 && $this->currentPos > 0) { + $this->currentPos <<= 1; // shift to the next bit + while (($this->mask & $this->currentPos) === 0 && $this->currentPos > 0) { $this->currentPos <<= 1; } } @@ -70,6 +65,7 @@ public function next(): void * Return the key of the current element * * @return int|float the flag + * * @since 1.2.0 */ public function key(): int|float @@ -78,9 +74,10 @@ public function key(): int|float } /** - * Checks if current position is valid + * Checks if the current position is valid + * + * @return bool Returns true on success or false on failure. * - * @return boolean Returns true on success or false on failure. * @since 1.2.0 */ public function valid(): bool @@ -91,7 +88,6 @@ public function valid(): bool /** * Rewind the Iterator to the first element * - * @return void * @since 1.2.0 */ public function rewind(): void @@ -104,7 +100,7 @@ public function rewind(): void } $this->currentPos = 1; - while (($this->mask & $this->currentPos) == 0) { + while (($this->mask & $this->currentPos) === 0) { $this->currentPos <<= 1; } } @@ -115,6 +111,7 @@ public function rewind(): void * @return int * * The return value is cast to an integer. + * * @since 1.2.0 */ public function count(): int @@ -122,8 +119,8 @@ public function count(): int $count = 0; $mask = $this->mask; - while ($mask != 0) { - if (($mask & 1) == 1) { + while ($mask !== 0) { + if (($mask & 1) === 1) { $count++; } $mask >>= 1; @@ -136,6 +133,7 @@ public function count(): int * Specify data which should be serialized to JSON * * @return array{mask: int|float} data which can be serialized by json_encode, + * * @since 1.2.0 */ public function jsonSerialize(): array diff --git a/src/Bits.php b/src/Bits.php index 8bc31ec..79e85e6 100644 --- a/src/Bits.php +++ b/src/Bits.php @@ -4,72 +4,73 @@ /** * This class holds all possible values you can use in your binary flags - * @package Reinder83\BinaryFlags */ class Bits { - const BIT_1 = 0x1; // 0000000000000000000000000000000000000000000000000000000000000001 - const BIT_2 = 0x2; // 0000000000000000000000000000000000000000000000000000000000000010 - const BIT_3 = 0x4; // 0000000000000000000000000000000000000000000000000000000000000100 - const BIT_4 = 0x8; // 0000000000000000000000000000000000000000000000000000000000001000 - const BIT_5 = 0x10; // 0000000000000000000000000000000000000000000000000000000000010000 - const BIT_6 = 0x20; // 0000000000000000000000000000000000000000000000000000000000100000 - const BIT_7 = 0x40; // 0000000000000000000000000000000000000000000000000000000001000000 - const BIT_8 = 0x80; // 0000000000000000000000000000000000000000000000000000000010000000 - const BIT_9 = 0x100; // 0000000000000000000000000000000000000000000000000000000100000000 - const BIT_10 = 0x200; // 0000000000000000000000000000000000000000000000000000001000000000 - const BIT_11 = 0x400; // 0000000000000000000000000000000000000000000000000000010000000000 - const BIT_12 = 0x800; // 0000000000000000000000000000000000000000000000000000100000000000 - const BIT_13 = 0x1000; // 0000000000000000000000000000000000000000000000000001000000000000 - const BIT_14 = 0x2000; // 0000000000000000000000000000000000000000000000000010000000000000 - const BIT_15 = 0x4000; // 0000000000000000000000000000000000000000000000000100000000000000 - const BIT_16 = 0x8000; // 0000000000000000000000000000000000000000000000001000000000000000 - const BIT_17 = 0x10000; // 0000000000000000000000000000000000000000000000010000000000000000 - const BIT_18 = 0x20000; // 0000000000000000000000000000000000000000000000100000000000000000 - const BIT_19 = 0x40000; // 0000000000000000000000000000000000000000000001000000000000000000 - const BIT_20 = 0x80000; // 0000000000000000000000000000000000000000000010000000000000000000 - const BIT_21 = 0x100000; // 0000000000000000000000000000000000000000000100000000000000000000 - const BIT_22 = 0x200000; // 0000000000000000000000000000000000000000001000000000000000000000 - const BIT_23 = 0x400000; // 0000000000000000000000000000000000000000010000000000000000000000 - const BIT_24 = 0x800000; // 0000000000000000000000000000000000000000100000000000000000000000 - const BIT_25 = 0x1000000; // 0000000000000000000000000000000000000001000000000000000000000000 - const BIT_26 = 0x2000000; // 0000000000000000000000000000000000000010000000000000000000000000 - const BIT_27 = 0x4000000; // 0000000000000000000000000000000000000100000000000000000000000000 - const BIT_28 = 0x8000000; // 0000000000000000000000000000000000001000000000000000000000000000 - const BIT_29 = 0x10000000; // 0000000000000000000000000000000000010000000000000000000000000000 - const BIT_30 = 0x20000000; // 0000000000000000000000000000000000100000000000000000000000000000 - const BIT_31 = 0x40000000; // 0000000000000000000000000000000001000000000000000000000000000000 - const BIT_32 = 0x80000000; // 0000000000000000000000000000000010000000000000000000000000000000 - const BIT_33 = 0x100000000; // 0000000000000000000000000000000100000000000000000000000000000000 - const BIT_34 = 0x200000000; // 0000000000000000000000000000001000000000000000000000000000000000 - const BIT_35 = 0x400000000; // 0000000000000000000000000000010000000000000000000000000000000000 - const BIT_36 = 0x800000000; // 0000000000000000000000000000100000000000000000000000000000000000 - const BIT_37 = 0x1000000000; // 0000000000000000000000000001000000000000000000000000000000000000 - const BIT_38 = 0x2000000000; // 0000000000000000000000000010000000000000000000000000000000000000 - const BIT_39 = 0x4000000000; // 0000000000000000000000000100000000000000000000000000000000000000 - const BIT_40 = 0x8000000000; // 0000000000000000000000001000000000000000000000000000000000000000 - const BIT_41 = 0x10000000000; // 0000000000000000000000010000000000000000000000000000000000000000 - const BIT_42 = 0x20000000000; // 0000000000000000000000100000000000000000000000000000000000000000 - const BIT_43 = 0x40000000000; // 0000000000000000000001000000000000000000000000000000000000000000 - const BIT_44 = 0x80000000000; // 0000000000000000000010000000000000000000000000000000000000000000 - const BIT_45 = 0x100000000000; // 0000000000000000000100000000000000000000000000000000000000000000 - const BIT_46 = 0x200000000000; // 0000000000000000001000000000000000000000000000000000000000000000 - const BIT_47 = 0x400000000000; // 0000000000000000010000000000000000000000000000000000000000000000 - const BIT_48 = 0x800000000000; // 0000000000000000100000000000000000000000000000000000000000000000 - const BIT_49 = 0x1000000000000; // 0000000000000001000000000000000000000000000000000000000000000000 - const BIT_50 = 0x2000000000000; // 0000000000000010000000000000000000000000000000000000000000000000 - const BIT_51 = 0x4000000000000; // 0000000000000100000000000000000000000000000000000000000000000000 - const BIT_52 = 0x8000000000000; // 0000000000001000000000000000000000000000000000000000000000000000 - const BIT_53 = 0x10000000000000; // 0000000000010000000000000000000000000000000000000000000000000000 - const BIT_54 = 0x20000000000000; // 0000000000100000000000000000000000000000000000000000000000000000 - const BIT_55 = 0x40000000000000; // 0000000001000000000000000000000000000000000000000000000000000000 - const BIT_56 = 0x80000000000000; // 0000000010000000000000000000000000000000000000000000000000000000 - const BIT_57 = 0x100000000000000; // 0000000100000000000000000000000000000000000000000000000000000000 - const BIT_58 = 0x200000000000000; // 0000001000000000000000000000000000000000000000000000000000000000 - const BIT_59 = 0x400000000000000; // 0000010000000000000000000000000000000000000000000000000000000000 - const BIT_60 = 0x800000000000000; // 0000100000000000000000000000000000000000000000000000000000000000 - const BIT_61 = 0x1000000000000000; // 0001000000000000000000000000000000000000000000000000000000000000 - const BIT_62 = 0x2000000000000000; // 0010000000000000000000000000000000000000000000000000000000000000 - const BIT_63 = 0x4000000000000000; // 0100000000000000000000000000000000000000000000000000000000000000 - const BIT_64 = 0x8000000000000000; // 1000000000000000000000000000000000000000000000000000000000000000 + public const BIT_1 = 0x1; // 0000000000000000000000000000000000000000000000000000000000000001 + public const BIT_2 = 0x2; // 0000000000000000000000000000000000000000000000000000000000000010 + public const BIT_3 = 0x4; // 0000000000000000000000000000000000000000000000000000000000000100 + public const BIT_4 = 0x8; // 0000000000000000000000000000000000000000000000000000000000001000 + public const BIT_5 = 0x10; // 0000000000000000000000000000000000000000000000000000000000010000 + public const BIT_6 = 0x20; // 0000000000000000000000000000000000000000000000000000000000100000 + public const BIT_7 = 0x40; // 0000000000000000000000000000000000000000000000000000000001000000 + public const BIT_8 = 0x80; // 0000000000000000000000000000000000000000000000000000000010000000 + public const BIT_9 = 0x100; // 0000000000000000000000000000000000000000000000000000000100000000 + public const BIT_10 = 0x200; // 0000000000000000000000000000000000000000000000000000001000000000 + public const BIT_11 = 0x400; // 0000000000000000000000000000000000000000000000000000010000000000 + public const BIT_12 = 0x800; // 0000000000000000000000000000000000000000000000000000100000000000 + public const BIT_13 = 0x1000; // 0000000000000000000000000000000000000000000000000001000000000000 + public const BIT_14 = 0x2000; // 0000000000000000000000000000000000000000000000000010000000000000 + public const BIT_15 = 0x4000; // 0000000000000000000000000000000000000000000000000100000000000000 + public const BIT_16 = 0x8000; // 0000000000000000000000000000000000000000000000001000000000000000 + public const BIT_17 = 0x10000; // 0000000000000000000000000000000000000000000000010000000000000000 + public const BIT_18 = 0x20000; // 0000000000000000000000000000000000000000000000100000000000000000 + public const BIT_19 = 0x40000; // 0000000000000000000000000000000000000000000001000000000000000000 + public const BIT_20 = 0x80000; // 0000000000000000000000000000000000000000000010000000000000000000 + public const BIT_21 = 0x100000; // 0000000000000000000000000000000000000000000100000000000000000000 + public const BIT_22 = 0x200000; // 0000000000000000000000000000000000000000001000000000000000000000 + public const BIT_23 = 0x400000; // 0000000000000000000000000000000000000000010000000000000000000000 + public const BIT_24 = 0x800000; // 0000000000000000000000000000000000000000100000000000000000000000 + public const BIT_25 = 0x1000000; // 0000000000000000000000000000000000000001000000000000000000000000 + public const BIT_26 = 0x2000000; // 0000000000000000000000000000000000000010000000000000000000000000 + public const BIT_27 = 0x4000000; // 0000000000000000000000000000000000000100000000000000000000000000 + public const BIT_28 = 0x8000000; // 0000000000000000000000000000000000001000000000000000000000000000 + public const BIT_29 = 0x10000000; // 0000000000000000000000000000000000010000000000000000000000000000 + public const BIT_30 = 0x20000000; // 0000000000000000000000000000000000100000000000000000000000000000 + public const BIT_31 = 0x40000000; // 0000000000000000000000000000000001000000000000000000000000000000 + public const BIT_32 = 0x80000000; // 0000000000000000000000000000000010000000000000000000000000000000 + public const BIT_33 = 0x100000000; // 0000000000000000000000000000000100000000000000000000000000000000 + public const BIT_34 = 0x200000000; // 0000000000000000000000000000001000000000000000000000000000000000 + public const BIT_35 = 0x400000000; // 0000000000000000000000000000010000000000000000000000000000000000 + public const BIT_36 = 0x800000000; // 0000000000000000000000000000100000000000000000000000000000000000 + public const BIT_37 = 0x1000000000; // 0000000000000000000000000001000000000000000000000000000000000000 + public const BIT_38 = 0x2000000000; // 0000000000000000000000000010000000000000000000000000000000000000 + public const BIT_39 = 0x4000000000; // 0000000000000000000000000100000000000000000000000000000000000000 + public const BIT_40 = 0x8000000000; // 0000000000000000000000001000000000000000000000000000000000000000 + public const BIT_41 = 0x10000000000; // 0000000000000000000000010000000000000000000000000000000000000000 + public const BIT_42 = 0x20000000000; // 0000000000000000000000100000000000000000000000000000000000000000 + public const BIT_43 = 0x40000000000; // 0000000000000000000001000000000000000000000000000000000000000000 + public const BIT_44 = 0x80000000000; // 0000000000000000000010000000000000000000000000000000000000000000 + public const BIT_45 = 0x100000000000; // 0000000000000000000100000000000000000000000000000000000000000000 + public const BIT_46 = 0x200000000000; // 0000000000000000001000000000000000000000000000000000000000000000 + public const BIT_47 = 0x400000000000; // 0000000000000000010000000000000000000000000000000000000000000000 + public const BIT_48 = 0x800000000000; // 0000000000000000100000000000000000000000000000000000000000000000 + public const BIT_49 = 0x1000000000000; // 0000000000000001000000000000000000000000000000000000000000000000 + public const BIT_50 = 0x2000000000000; // 0000000000000010000000000000000000000000000000000000000000000000 + public const BIT_51 = 0x4000000000000; // 0000000000000100000000000000000000000000000000000000000000000000 + public const BIT_52 = 0x8000000000000; // 0000000000001000000000000000000000000000000000000000000000000000 + public const BIT_53 = 0x10000000000000; // 0000000000010000000000000000000000000000000000000000000000000000 + public const BIT_54 = 0x20000000000000; // 0000000000100000000000000000000000000000000000000000000000000000 + public const BIT_55 = 0x40000000000000; // 0000000001000000000000000000000000000000000000000000000000000000 + public const BIT_56 = 0x80000000000000; // 0000000010000000000000000000000000000000000000000000000000000000 + public const BIT_57 = 0x100000000000000; // 0000000100000000000000000000000000000000000000000000000000000000 + public const BIT_58 = 0x200000000000000; // 0000001000000000000000000000000000000000000000000000000000000000 + public const BIT_59 = 0x400000000000000; // 0000010000000000000000000000000000000000000000000000000000000000 + public const BIT_60 = 0x800000000000000; // 0000100000000000000000000000000000000000000000000000000000000000 + public const BIT_61 = 0x1000000000000000; // 0001000000000000000000000000000000000000000000000000000000000000 + public const BIT_62 = 0x2000000000000000; // 0010000000000000000000000000000000000000000000000000000000000000 + public const BIT_63 = 0x4000000000000000; // 0100000000000000000000000000000000000000000000000000000000000000 + + /** @deprecated BIT_64 will be dropped in v3.0.0 */ + public const BIT_64 = 0x8000000000000000; // 1000000000000000000000000000000000000000000000000000000000000000 } diff --git a/src/Flag.php b/src/Flag.php new file mode 100644 index 0000000..75ff62c --- /dev/null +++ b/src/Flag.php @@ -0,0 +1,83 @@ + + */ + public static function mask(self ...$flags): Mask + { + return Mask::fromFlags(...$flags); + } +} diff --git a/src/Mask.php b/src/Mask.php new file mode 100644 index 0000000..15993b7 --- /dev/null +++ b/src/Mask.php @@ -0,0 +1,194 @@ + + */ +final class Mask implements Countable, IteratorAggregate, JsonSerializable +{ + /** @var class-string */ + private string $enumClass; + + /** @var array */ + private array $flags; + + /** + * @param class-string $enumClass + * @param array $flags + */ + private function __construct(string $enumClass, array $flags) + { + $normalized = []; + foreach ($flags as $flag) { + if (!$flag instanceof $enumClass) { + throw new InvalidArgumentException(sprintf('Expected enum of type %s.', $enumClass)); + } + + if (!is_int($flag->value)) { + throw new InvalidArgumentException('Only int-backed enums are supported.'); + } + + $normalized[$flag->value] = $flag; + } + + ksort($normalized); + $this->enumClass = $enumClass; + $this->flags = array_values($normalized); + } + + /** + * @template TFlag of BackedEnum + * @param class-string $enumClass + * @param TFlag ...$flags + * @return self + */ + public static function forEnum(string $enumClass, BackedEnum ...$flags): self + { + return new self($enumClass, $flags); + } + + /** + * @return self + */ + public static function fromFlags(Flag ...$flags): self + { + return new self(Flag::class, $flags); + } + + /** + * @template TFlag of BackedEnum + * @param class-string $enumClass + * @return self + */ + public static function fromInt(int $mask, string $enumClass = Flag::class): self + { + if (!enum_exists($enumClass)) { + throw new InvalidArgumentException(sprintf('Enum class %s does not exist.', $enumClass)); + } + + /** @var array $cases */ + $cases = $enumClass::cases(); + /** @var array $flags */ + $flags = []; + foreach ($cases as $flag) { + if (!is_int($flag->value)) { + throw new InvalidArgumentException('Only int-backed enums are supported.'); + } + + if (($mask & $flag->value) !== 0) { + $flags[] = $flag; + } + } + + return new self($enumClass, $flags); + } + + /** + * @return class-string + */ + public function enumClass(): string + { + return $this->enumClass; + } + + /** + * @return array + */ + public function flags(): array + { + return $this->flags; + } + + public function toInt(): int + { + return array_reduce( + $this->flags, + fn(int $mask, BackedEnum $flag): int => $mask | (int) $flag->value, + 0, + ); + } + + /** + * @param TEnum $flag + */ + public function has(BackedEnum $flag): bool + { + if (!$flag instanceof $this->enumClass) { + return false; + } + + return in_array($flag, $this->flags, true); + } + + /** + * @param TEnum ...$flags + * @return self + */ + public function add(BackedEnum ...$flags): self + { + return new self($this->enumClass, [...$this->flags, ...$flags]); + } + + /** + * @param TEnum ...$flags + * @return self + */ + public function remove(BackedEnum ...$flags): self + { + $removeMap = []; + foreach ($flags as $flag) { + if (!$flag instanceof $this->enumClass) { + continue; + } + $removeMap[(int) $flag->value] = true; + } + + return new self( + $this->enumClass, + array_values( + array_filter( + $this->flags, + fn(BackedEnum $flag): bool => !isset($removeMap[(int) $flag->value]), + ), + ), + ); + } + + public function count(): int + { + return count($this->flags); + } + + /** + * @return Traversable + */ + public function getIterator(): Traversable + { + yield from $this->flags; + } + + /** + * @return array{mask:int, flags: array} + */ + public function jsonSerialize(): array + { + return [ + 'mask' => $this->toInt(), + 'flags' => array_map( + fn(BackedEnum $flag): string => $flag->name, + $this->flags, + ), + ]; + } +} diff --git a/src/Traits/BinaryFlags.php b/src/Traits/BinaryFlags.php index 494dded..4423728 100644 --- a/src/Traits/BinaryFlags.php +++ b/src/Traits/BinaryFlags.php @@ -2,207 +2,12 @@ namespace Reinder83\BinaryFlags\Traits; -use Closure; -use ReflectionClass; -use ReflectionException; - /** - * This trait holds useful methods for checking, adding or removing binary flags + * Backward-compatible alias for numeric mask/flag behavior. * - * @author Reinder + * @deprecated Use InteractsWithNumericFlags in new code. */ trait BinaryFlags { - /** - * This will hold the mask for checking against - * - * @var int|float - */ - protected int|float $mask = 0; - - /** - * This will be called on changes - * - * @var Closure|null - */ - protected ?Closure $onModifyCallback = null; - - /** - * Return an array with all flags as key with a name as description - * - * @return array - */ - public static function getAllFlags(): array - { - try { - $reflection = new ReflectionClass(get_called_class()); - // @codeCoverageIgnoreStart - } catch (ReflectionException) { - return []; - } - // @codeCoverageIgnoreEnd - - $constants = $reflection->getConstants(); - - $flags = []; - if ($constants) { - foreach ($constants as $constant => $flag) { - if (is_numeric($flag)) { - $flags[(int) $flag] = implode('', array_map('ucfirst', explode('_', strtolower($constant)))); - } - } - } - - return $flags; - } - - /** - * Get all available flags as a mask - */ - public static function getAllFlagsMask(): int|float - { - return array_reduce( - array_keys(static::getAllFlags()), - function ($flag, $carry) { - return $carry | $flag; - }, - 0 - ); - } - - /** - * Check mask against constants - * and return the names or descriptions in a comma separated string or as array - * - * @param int|null $mask - * @param bool $asArray - * @return string|array - */ - public function getFlagNames(?int $mask = null, bool $asArray = false): string|array - { - $mask = $mask ?? $this->mask; - $names = []; - - foreach (static::getAllFlags() as $flag => $desc) { - if (is_numeric($flag) && ($mask & $flag)) { - $names[$flag] = $desc; - } - } - - return $asArray ? $names : implode(', ', $names); - } - - /** - * Set a function which will be called upon changes - * - * @param Closure|null $onModify - */ - public function setOnModifyCallback(?Closure $onModify): void - { - $this->onModifyCallback = $onModify; - } - - /** - * Will be called upon changes and execute the callback, if set - */ - protected function onModify(): void - { - if (is_callable($this->onModifyCallback)) { - call_user_func($this->onModifyCallback, $this); - } - } - - /** - * This method will set the mask where will be checked against - * - * @param int|float $mask - * - * @return $this - */ - public function setMask(int|float $mask): static - { - $before = $this->mask; - $this->mask = $mask; - - if ($before !== $this->mask) { - $this->onModify(); - } - - return $this; - } - - /** - * This method will return the current mask - */ - public function getMask(): int|float - { - return $this->mask; - } - - /** - * This will set flag(s) in the current mask - * - * @param int|float $flag - * - * @return $this - */ - public function addFlag(int|float $flag): static - { - $before = $this->mask; - $this->mask |= $flag; - - if ($before !== $this->mask) { - $this->onModify(); - } - - return $this; - } - - /** - * This will remove a flag(s) (if it's set) in the current mask - * - * @param int|float $flag - * - * @return $this - */ - public function removeFlag(int|float $flag): static - { - $before = $this->mask; - $this->mask &= ~$flag; - - if ($before !== $this->mask) { - $this->onModify(); - } - - return $this; - } - - /** - * Check if given flag(s) are set in the current mask - * By default it will check all bits in the given flag - * When you want to match any of the given flags set $checkAll to false - * - * @param int|float $flag - * @param bool $checkAll - * - * @return bool - */ - public function checkFlag(int|float $flag, bool $checkAll = true): bool - { - $result = $this->mask & $flag; - - return $checkAll ? $result == $flag : $result > 0; - } - - /** - * Check if any given flag(s) are set in the current mask - * - * @param int|float $mask - * - * @return bool - */ - public function checkAnyFlag(int|float $mask): bool - { - return $this->checkFlag($mask, false); - } + use InteractsWithNumericFlags; } diff --git a/src/Traits/InteractsWithEnumFlags.php b/src/Traits/InteractsWithEnumFlags.php new file mode 100644 index 0000000..8d0bbb4 --- /dev/null +++ b/src/Traits/InteractsWithEnumFlags.php @@ -0,0 +1,218 @@ + + */ + abstract protected static function getFlagEnumClass(): string; + + /** + * @param int|TEnum|Mask $value + */ + private function normalizeEnumMaskOrFlag(int|BackedEnum|Mask $value): int + { + if ($value instanceof BackedEnum) { + $enumClass = static::getFlagEnumClass(); + if (!$value instanceof $enumClass) { + throw new InvalidArgumentException(sprintf('Expected enum of type %s.', $enumClass)); + } + + if (!is_int($value->value)) { + throw new InvalidArgumentException('Only int-backed enums are supported.'); + } + + return $value->value; + } + + if ($value instanceof Mask) { + if ($value->enumClass() !== static::getFlagEnumClass()) { + throw new InvalidArgumentException(sprintf('Expected mask for enum %s.', static::getFlagEnumClass())); + } + + return $value->toInt(); + } + + return $value; + } + + /** + * Return an array with all flags as key with a name as description + * + * @return array + */ + public static function getAllFlags(): array + { + $enumClass = static::getFlagEnumClass(); + if (!enum_exists($enumClass)) { + return []; + } + + /** @var array $cases */ + $cases = $enumClass::cases(); + + $flags = []; + foreach ($cases as $case) { + if (!is_int($case->value)) { + throw new InvalidArgumentException('Only int-backed enums are supported.'); + } + if ($case->value <= 0 || ($case->value & ($case->value - 1)) !== 0) { + throw new InvalidArgumentException( + sprintf( + 'Enum case %s::%s must be a single positive bit (power-of-two).', + $enumClass, + $case->name, + ), + ); + } + $flags[(int) $case->value] = preg_replace('/(?name) ?? $case->name; + } + + return $flags; + } + + public static function getAllFlagsMask(): int + { + return array_reduce( + array_keys(static::getAllFlags()), + function (int $carry, int $flag): int { + return $carry | $flag; + }, + 0, + ); + } + + /** + * Check mask against constants + * and return the names or descriptions in a comma-separated string or as array + * + * @param int|TEnum|Mask|null $mask + * @param bool $asArray + * @return string|array + */ + public function getFlagNames(int|BackedEnum|Mask|null $mask = null, bool $asArray = false): string|array + { + $normalizedMask = $mask === null ? $this->mask : $this->normalizeEnumMaskOrFlag($mask); + $names = []; + + foreach (static::getAllFlags() as $flag => $desc) { + if (($normalizedMask & $flag) !== 0) { + $names[$flag] = $desc; + } + } + + return $asArray ? $names : implode(', ', $names); + } + + public function setOnModifyCallback(?Closure $onModify): void + { + $this->onModifyCallback = $onModify; + } + + protected function onModify(): void + { + if (is_callable($this->onModifyCallback)) { + call_user_func($this->onModifyCallback, $this); + } + } + + /** + * @param int|TEnum|Mask $mask + */ + public function setMask(int|BackedEnum|Mask $mask): static + { + $before = $this->mask; + $this->mask = $this->normalizeEnumMaskOrFlag($mask); + + if ($before !== $this->mask) { + $this->onModify(); + } + + return $this; + } + + /** + * @return Mask + */ + public function getMask(): Mask + { + return Mask::fromInt($this->mask, static::getFlagEnumClass()); + } + + public function getMaskValue(): int + { + return $this->mask; + } + + /** + * @param int|TEnum|Mask $flag + */ + public function addFlag(int|BackedEnum|Mask $flag): static + { + $before = $this->mask; + $this->mask |= $this->normalizeEnumMaskOrFlag($flag); + + if ($before !== $this->mask) { + $this->onModify(); + } + + return $this; + } + + /** + * @param int|TEnum|Mask $flag + */ + public function removeFlag(int|BackedEnum|Mask $flag): static + { + $before = $this->mask; + $normalizedFlag = $this->normalizeEnumMaskOrFlag($flag); + $this->mask &= ~$normalizedFlag; + + if ($before !== $this->mask) { + $this->onModify(); + } + + return $this; + } + + /** + * @param int|TEnum|Mask $flag + */ + public function checkFlag(int|BackedEnum|Mask $flag, bool $checkAll = true): bool + { + $normalizedFlag = $this->normalizeEnumMaskOrFlag($flag); + $result = $this->mask & $normalizedFlag; + + return $checkAll ? $result === $normalizedFlag : $result > 0; + } + + /** + * @param int|TEnum|Mask $mask + */ + public function checkAnyFlag(int|BackedEnum|Mask $mask): bool + { + return $this->checkFlag($mask, false); + } +} diff --git a/src/Traits/InteractsWithNumericFlags.php b/src/Traits/InteractsWithNumericFlags.php new file mode 100644 index 0000000..7868da8 --- /dev/null +++ b/src/Traits/InteractsWithNumericFlags.php @@ -0,0 +1,209 @@ + + */ + public static function getAllFlags(): array + { + try { + $reflection = new ReflectionClass(static::class); + // @codeCoverageIgnoreStart + } catch (ReflectionException) { + return []; + } + // @codeCoverageIgnoreEnd + + $constants = $reflection->getConstants(); + + $flags = []; + if ($constants) { + foreach ($constants as $constant => $flag) { + if (is_numeric($flag)) { + $flags[(int) $flag] = implode('', array_map('ucfirst', explode('_', strtolower($constant)))); + } + } + } + + return $flags; + } + + /** + * Get all available flags as a mask + */ + public static function getAllFlagsMask(): int|float + { + return array_reduce( + array_keys(static::getAllFlags()), + function ($carry, $flag) { + return $carry | $flag; + }, + 0, + ); + } + + /** + * Check mask against constants + * and return the names or descriptions in a comma-separated string or as array + * + * @return string|array + */ + public function getFlagNames(int|float|null $mask = null, bool $asArray = false): string|array + { + $mask = $mask === null + ? (int) $this->mask + : $this->normalizeMaskOrFlag($mask, 'mask', __METHOD__); + + $names = []; + + foreach (static::getAllFlags() as $flag => $desc) { + if (is_numeric($flag) && ($mask & $flag)) { + $names[$flag] = $desc; + } + } + + return $asArray ? $names : implode(', ', $names); + } + + /** + * Set a function which will be called upon changes + */ + public function setOnModifyCallback(?Closure $onModify): void + { + $this->onModifyCallback = $onModify; + } + + /** + * Will be called upon changes and execute the callback, if set + */ + protected function onModify(): void + { + if (is_callable($this->onModifyCallback)) { + call_user_func($this->onModifyCallback, $this); + } + } + + /** + * This method will set the mask where will be checked against + * + * @return $this + */ + public function setMask(int|float $mask): static + { + $before = $this->mask; + $this->mask = $this->normalizeMaskOrFlag($mask, 'mask', __METHOD__); + + if ($before !== $this->mask) { + $this->onModify(); + } + + return $this; + } + + /** + * This method will return the current mask + */ + public function getMask(): int|float + { + return $this->mask; + } + + /** + * This will set flag(s) in the current mask + * + * @return $this + */ + public function addFlag(int|float $flag): static + { + $before = $this->mask; + $this->mask |= $this->normalizeMaskOrFlag($flag, 'flag', __METHOD__); + + if ($before !== $this->mask) { + $this->onModify(); + } + + return $this; + } + + /** + * This will remove a flag(s) (if it's set) in the current mask + * + * @return $this + */ + public function removeFlag(int|float $flag): static + { + $before = $this->mask; + $normalizedFlag = $this->normalizeMaskOrFlag($flag, 'flag', __METHOD__); + $this->mask &= ~$normalizedFlag; + + if ($before !== $this->mask) { + $this->onModify(); + } + + return $this; + } + + /** + * Check if given flag(s) are set in the current mask + * By default it will check all bits in the given flag + * When you want to match any of the given flags set $checkAll to false + */ + public function checkFlag(int|float $flag, bool $checkAll = true): bool + { + $normalizedFlag = $this->normalizeMaskOrFlag($flag, 'flag', __METHOD__); + $result = $this->mask & $normalizedFlag; + + return $checkAll ? $result === $normalizedFlag : $result > 0; + } + + /** + * Check if any given flag(s) are set in the current mask + */ + public function checkAnyFlag(int|float $mask): bool + { + $normalizedMask = $this->normalizeMaskOrFlag($mask, 'mask', __METHOD__); + + return $this->checkFlag($normalizedMask, false); + } +} diff --git a/tests/BinaryFlagsTest.php b/tests/BinaryFlagsTest.php deleted file mode 100644 index 1c6ad5d..0000000 --- a/tests/BinaryFlagsTest.php +++ /dev/null @@ -1,173 +0,0 @@ -mask = $mask; - } - - // set up test case - protected function setUp(): void - { - // base mask - $this->mask = ExampleFlags::FOO | ExampleFlags::BAR; - - // callback function - $model = $this; - $this->callback = function (ExampleFlags $flags) use ($model) { - $model->setMask($flags->getMask()); - }; - - // create new class with FOO and BAR set - $this->test = new ExampleFlags($this->mask, $this->callback); - } - - // test base mask set in setUp - public function testBaseMask() - { - // verify if the correct flags are set - $this->assertTrue($this->test->checkFlag(ExampleFlags::FOO)); - $this->assertTrue($this->test->checkFlag(ExampleFlags::BAR)); - $this->assertFalse($this->test->checkFlag(ExampleFlags::BAZ)); - $this->assertFalse($this->test->checkFlag(ExampleFlags::QUX)); - - // flag 1 and 2 should be resulting in 3 - $this->assertEquals(0x3, $this->test->getMask()); - } - - // test check flags with multiple flags - public function testMultipleFlags() - { - // test if all are set - $this->assertTrue($this->test->checkFlag(ExampleFlags::FOO | ExampleFlags::BAR)); - $this->assertFalse($this->test->checkFlag(ExampleFlags::BAR | ExampleFlags::BAZ)); - - // test if any are set - $this->assertTrue($this->test->checkFlag(ExampleFlags::BAR | ExampleFlags::BAZ, false)); - $this->assertFalse($this->test->checkFlag(ExampleFlags::BAZ | ExampleFlags::QUX, false)); - - // test if any are set - $this->assertTrue($this->test->checkAnyFlag(ExampleFlags::BAR | ExampleFlags::BAZ)); - $this->assertFalse($this->test->checkAnyFlag(ExampleFlags::BAZ | ExampleFlags::QUX)); - } - - // test the callback method - public function testCallback() - { - // add BAZ which result in mask = 7 - $this->test->addFlag(ExampleFlags::BAZ); - - // the callback method should set the mask in this class to 7 - $this->assertEquals(0x7, $this->mask); - } - - // test adding a flag - public function testAddFlag() - { - // add a flag - $this->test->addFlag(ExampleFlags::BAZ); - - // add an existing flag - $this->test->addFlag(ExampleFlags::FOO); - - // verify if the correct flags are set - $this->assertTrue($this->test->checkFlag(ExampleFlags::FOO)); - $this->assertTrue($this->test->checkFlag(ExampleFlags::BAR)); - $this->assertTrue($this->test->checkFlag(ExampleFlags::BAZ)); - $this->assertFalse($this->test->checkFlag(ExampleFlags::QUX)); - } - - // test removing a flag - public function testRemoveFlag() - { - // remove a flag - $this->test->removeFlag(ExampleFlags::BAR); - - // remove an non-existing flag - $this->test->removeFlag(ExampleFlags::BAZ); - - // verify if the correct flags are set - $this->assertTrue($this->test->checkFlag(ExampleFlags::FOO)); - $this->assertFalse($this->test->checkFlag(ExampleFlags::BAR)); - $this->assertFalse($this->test->checkFlag(ExampleFlags::BAZ)); - $this->assertFalse($this->test->checkFlag(ExampleFlags::QUX)); - } - - public function testFlagNames() - { - $this->assertEquals('Foo, Bar', $this->test->getFlagNames()); - - $this->assertEquals('Baz', $this->test->getFlagNames(ExampleFlags::BAZ)); - - $this->assertEquals([ - ExampleFlags::FOO => 'Foo', - ExampleFlags::BAR => 'Bar', - ], $this->test->getFlagNames(null, true)); - } - - public function testNamedFlagNames() - { - // same mask as exampleFlags - $named = new ExampleFlagsWithNames($this->test->getMask()); - - $this->assertEquals('My foo description, My bar description', $named->getFlagNames()); - } - - public function testGetAllFlagsMask() - { - $this->assertEquals(1 + 2 + 4 + 8, ExampleFlags::getAllFlagsMask()); - } - - public function testCountable() - { - $this->assertEquals(2, $this->test->count()); - } - - public function testIterable() - { - $test = new ExampleFlagsWithNames(ExampleFlagsWithNames::FOO | ExampleFlagsWithNames::BAZ); - $expectedFlags = $test->getFlagNames(ExampleFlagsWithNames::FOO | ExampleFlagsWithNames::BAZ, true); - - $result = []; - foreach ($test as $flag => $description) { - $result[$flag] = $description; - } - - $this->assertEquals($expectedFlags, $result); - } - - public function testJsonSerializable() - { - $this->assertEquals( - sprintf('{"mask":%d}', $this->test->getMask()), - json_encode($this->test) - ); - } -} diff --git a/tests/EnumErrorPathsTest.php b/tests/EnumErrorPathsTest.php new file mode 100644 index 0000000..099c771 --- /dev/null +++ b/tests/EnumErrorPathsTest.php @@ -0,0 +1,29 @@ + $flags->setMask(Flag::Flag1)) + ->toThrow(InvalidArgumentException::class); + + expect(fn() => $flags->addFlag(Flag::Flag2)) + ->toThrow(InvalidArgumentException::class); +}); + +test('enum flags reject masks from a different enum type', function (): void { + $flags = new ExamplePermissionFlags(Permission::CanView); + $wrongMask = Flag::mask(Flag::Flag1, Flag::Flag2); + + expect(fn() => $flags->checkAnyFlag($wrongMask)) + ->toThrow(InvalidArgumentException::class); +}); + +test('enum flags require single-bit positive enum values', function (): void { + expect(fn() => ExampleInvalidPermissionFlags::getAllFlags()) + ->toThrow(InvalidArgumentException::class, 'must be a single positive bit'); +}); diff --git a/tests/FlagEnumTest.php b/tests/FlagEnumTest.php new file mode 100644 index 0000000..ea69666 --- /dev/null +++ b/tests/FlagEnumTest.php @@ -0,0 +1,42 @@ +addFlag(Flag::Flag2); + $flags->addFlag(Flag::mask(Flag::Flag3, Flag::Flag4)); + $flags->removeFlag(Flag::Flag1); + + expect($flags->checkFlag(Flag::Flag2))->toBeTrue() + ->and($flags->checkFlag(Flag::Flag1))->toBeFalse() + ->and($flags->checkAnyFlag(Flag::mask(Flag::Flag1, Flag::Flag3)))->toBeTrue() + ->and($flags->getMask())->toBeInstanceOf(Mask::class) + ->and($flags->getMask()->toInt())->toEqual(Flag::mask(Flag::Flag2, Flag::Flag3, Flag::Flag4)->toInt()) + ->and($flags->getFlagNames(Flag::Flag4))->toEqual('Flag4'); +}); + +test('mask value object stores flags and converts to int', function (): void { + $mask = Flag::mask(Flag::Flag1, Flag::Flag3, Flag::Flag3); + + expect($mask)->toBeInstanceOf(Mask::class) + ->and($mask->count())->toEqual(2) + ->and($mask->has(Flag::Flag1))->toBeTrue() + ->and($mask->has(Flag::Flag2))->toBeFalse() + ->and($mask->toInt())->toEqual(Flag::Flag1->value | Flag::Flag3->value) + ->and($mask->flags())->toEqual([Flag::Flag1, Flag::Flag3]); +}); + +test('enum flags accept mask value object', function (): void { + $flags = new ExampleEnumFlags(Flag::mask(Flag::Flag1, Flag::Flag2)); + $flags->addFlag(Flag::mask(Flag::Flag3, Flag::Flag4)); + $flags->removeFlag(Mask::fromInt(Flag::Flag1->value | Flag::Flag4->value)); + + expect($flags->checkAnyFlag(Flag::mask(Flag::Flag1, Flag::Flag2)))->toBeTrue() + ->and($flags->checkFlag(Mask::fromInt(Flag::Flag2->value | Flag::Flag3->value)))->toBeTrue() + ->and($flags->checkFlag(Flag::Flag1))->toBeFalse() + ->and($flags->checkFlag(Flag::Flag4))->toBeFalse() + ->and($flags->getMask()->toInt())->toEqual(Flag::mask(Flag::Flag2, Flag::Flag3)->toInt()); +}); diff --git a/tests/FlagWithNamesTest.php b/tests/FlagWithNamesTest.php new file mode 100644 index 0000000..8fc628e --- /dev/null +++ b/tests/FlagWithNamesTest.php @@ -0,0 +1,19 @@ +addFlag(Permission::CanBook); + $flags->addFlag(Mask::forEnum(Permission::class, Permission::CanCancel)); + + expect($flags->getFlagNames())->toEqual('Can View, Can Book, Can Cancel') + ->and($flags->getFlagNames(Permission::CanBook))->toEqual('Can Book') + ->and($flags->getFlagNames(null, true))->toEqual([ + Permission::CanView->value => 'Can View', + Permission::CanBook->value => 'Can Book', + Permission::CanCancel->value => 'Can Cancel', + ]); +}); diff --git a/tests/LegacyTraitCompatibilityTest.php b/tests/LegacyTraitCompatibilityTest.php new file mode 100644 index 0000000..8dc28e4 --- /dev/null +++ b/tests/LegacyTraitCompatibilityTest.php @@ -0,0 +1,12 @@ +setMask(3); + + expect($flags->getMask())->toEqual(3) + ->and($flags->checkFlag(1))->toBeTrue() + ->and($flags->checkFlag(4))->toBeFalse(); +}); diff --git a/tests/MaskTest.php b/tests/MaskTest.php new file mode 100644 index 0000000..53edf2e --- /dev/null +++ b/tests/MaskTest.php @@ -0,0 +1,52 @@ +add(Permission::CanBook); + $removed = $added->remove(Permission::CanView); + + expect($base->flags())->toEqual([Permission::CanView]) + ->and($added->flags())->toEqual([Permission::CanView, Permission::CanBook]) + ->and($removed->flags())->toEqual([Permission::CanBook]); +}); + +test('mask exposes enum class and iterator', function (): void { + $mask = Flag::mask(Flag::Flag1, Flag::Flag3); + $iterated = []; + + foreach ($mask as $flag) { + $iterated[] = $flag; + } + + expect($mask->enumClass())->toEqual(Flag::class) + ->and($iterated)->toEqual([Flag::Flag1, Flag::Flag3]); +}); + +test('mask json serialization includes mask and flag names', function (): void { + $mask = Mask::forEnum(Permission::class, Permission::CanView, Permission::CanCancel); + + expect($mask->jsonSerialize())->toEqual([ + 'mask' => Permission::CanView->value | Permission::CanCancel->value, + 'flags' => ['CanView', 'CanCancel'], + ]); +}); + +test('mask from int throws when enum class does not exist', function (): void { + expect(fn() => Mask::fromInt(1, 'Not\\A\\Real\\Enum')) + ->toThrow(InvalidArgumentException::class); +}); + +test('mask for enum throws on mismatched enum instance', function (): void { + expect(fn() => Mask::forEnum(Permission::class, Flag::Flag1)) + ->toThrow(InvalidArgumentException::class); +}); + +test('mask from int rejects non int backed enums', function (): void { + expect(fn() => Mask::fromInt(1, BadStringFlag::class)) + ->toThrow(InvalidArgumentException::class); +}); diff --git a/tests/NumericBitsNamesTest.php b/tests/NumericBitsNamesTest.php new file mode 100644 index 0000000..ef96e4c --- /dev/null +++ b/tests/NumericBitsNamesTest.php @@ -0,0 +1,23 @@ +test = new ExampleFlags(ExampleFlags::FOO | ExampleFlags::BAR); +}); + +test('flag names', function (): void { + expect($this->test->getFlagNames())->toEqual('Foo, Bar') + ->and($this->test->getFlagNames(ExampleFlags::BAZ))->toEqual('Baz') + ->and($this->test->getFlagNames(null, true))->toEqual([ + ExampleFlags::FOO => 'Foo', + ExampleFlags::BAR => 'Bar', + ]); +}); + +test('named flag names', function (): void { + $named = new ExampleFlagsWithNames($this->test->getMask()); + + expect($named->getFlagNames())->toEqual('My foo description, My bar description'); +}); diff --git a/tests/NumericBitsTest.php b/tests/NumericBitsTest.php new file mode 100644 index 0000000..c6e44c9 --- /dev/null +++ b/tests/NumericBitsTest.php @@ -0,0 +1,79 @@ +mask = ExampleFlags::FOO | ExampleFlags::BAR; + $this->callback = function (ExampleFlags $flags): void { + $this->mask = $flags->getMask(); + }; + $this->test = new ExampleFlags($this->mask, $this->callback); +}); + +test('base mask', function (): void { + expect($this->test->checkFlag(ExampleFlags::FOO))->toBeTrue() + ->and($this->test->checkFlag(ExampleFlags::BAR))->toBeTrue() + ->and($this->test->checkFlag(ExampleFlags::BAZ))->toBeFalse() + ->and($this->test->checkFlag(ExampleFlags::QUX))->toBeFalse() + ->and($this->test->getMask())->toEqual(0x3); +}); + +test('multiple flags', function (): void { + expect($this->test->checkFlag(ExampleFlags::FOO | ExampleFlags::BAR))->toBeTrue() + ->and($this->test->checkFlag(ExampleFlags::BAR | ExampleFlags::BAZ))->toBeFalse() + ->and($this->test->checkFlag(ExampleFlags::BAR | ExampleFlags::BAZ, false))->toBeTrue() + ->and($this->test->checkFlag(ExampleFlags::BAZ | ExampleFlags::QUX, false))->toBeFalse() + ->and($this->test->checkAnyFlag(ExampleFlags::BAR | ExampleFlags::BAZ))->toBeTrue() + ->and($this->test->checkAnyFlag(ExampleFlags::BAZ | ExampleFlags::QUX))->toBeFalse(); +}); + +test('callback', function (): void { + $this->test->addFlag(ExampleFlags::BAZ); + + expect($this->mask)->toEqual(0x7); +}); + +test('add flag', function (): void { + $this->test->addFlag(ExampleFlags::BAZ); + $this->test->addFlag(ExampleFlags::FOO); + + expect($this->test->checkFlag(ExampleFlags::FOO))->toBeTrue() + ->and($this->test->checkFlag(ExampleFlags::BAR))->toBeTrue() + ->and($this->test->checkFlag(ExampleFlags::BAZ))->toBeTrue() + ->and($this->test->checkFlag(ExampleFlags::QUX))->toBeFalse(); +}); + +test('remove flag', function (): void { + $this->test->removeFlag(ExampleFlags::BAR); + $this->test->removeFlag(ExampleFlags::BAZ); + + expect($this->test->checkFlag(ExampleFlags::FOO))->toBeTrue() + ->and($this->test->checkFlag(ExampleFlags::BAR))->toBeFalse() + ->and($this->test->checkFlag(ExampleFlags::BAZ))->toBeFalse() + ->and($this->test->checkFlag(ExampleFlags::QUX))->toBeFalse(); +}); + +test('get all flags mask', function (): void { + expect(ExampleFlags::getAllFlagsMask())->toEqual(1 + 2 + 4 + 8); +}); + +test('countable', function (): void { + expect($this->test->count())->toEqual(2); +}); + +test('iterable', function (): void { + $test = new ExampleFlagsWithNames(ExampleFlagsWithNames::FOO | ExampleFlagsWithNames::BAZ); + $expectedFlags = $test->getFlagNames(ExampleFlagsWithNames::FOO | ExampleFlagsWithNames::BAZ, true); + + $result = []; + foreach ($test as $flag => $description) { + $result[$flag] = $description; + } + + expect($result)->toEqual($expectedFlags); +}); + +test('json serializable', function (): void { + expect(json_encode($this->test))->toEqual(sprintf('{"mask":%d}', $this->test->getMask())); +}); diff --git a/tests/NumericDeprecationTest.php b/tests/NumericDeprecationTest.php new file mode 100644 index 0000000..f04887a --- /dev/null +++ b/tests/NumericDeprecationTest.php @@ -0,0 +1,36 @@ +test = new ExampleFlags(ExampleFlags::FOO | ExampleFlags::BAR); +}); + +test('float mask and flag values are deprecated in v2 and still work', function (): void { + $messages = []; + set_error_handler(function (int $severity, string $message) use (&$messages): bool { + if ($severity === E_USER_DEPRECATED) { + $messages[] = $message; + + return true; + } + + return false; + }); + + try { + new ExampleFlags((float) ExampleFlags::FOO); + $this->test->setMask((float) ExampleFlags::FOO); + $this->test->addFlag((float) ExampleFlags::BAR); + $this->test->removeFlag((float) ExampleFlags::FOO); + $this->test->checkFlag((float) ExampleFlags::BAR); + $this->test->checkAnyFlag((float) ExampleFlags::BAR); + $this->test->getFlagNames((float) ExampleFlags::BAR); + } finally { + restore_error_handler(); + } + + expect($messages)->toHaveCount(7) + ->and($this->test->checkFlag(ExampleFlags::BAR))->toBeTrue() + ->and($this->test->checkFlag(ExampleFlags::FOO))->toBeFalse(); +}); diff --git a/tests/PermissionEnumTest.php b/tests/PermissionEnumTest.php new file mode 100644 index 0000000..0a8c9e2 --- /dev/null +++ b/tests/PermissionEnumTest.php @@ -0,0 +1,57 @@ +addFlag(Permission::CanBook); + $flags->addFlag(Mask::forEnum(Permission::class, Permission::CanCancel)); + + expect($flags->checkFlag(Permission::CanBook))->toBeTrue() + ->and($flags->checkAnyFlag(Mask::forEnum(Permission::class, Permission::CanView, Permission::CanRefund)))->toBeTrue() + ->and($flags->getMask())->toBeInstanceOf(Mask::class) + ->and($flags->getMask()->toInt())->toEqual(Permission::CanView->value | Permission::CanBook->value | Permission::CanCancel->value); +}); + +test('enum flags do not trigger float deprecation warnings', function (): void { + $messages = []; + set_error_handler(function (int $severity, string $message) use (&$messages): bool { + if ($severity === E_USER_DEPRECATED) { + $messages[] = $message; + + return true; + } + + return false; + }); + + try { + $flags = new ExamplePermissionFlags(Permission::CanView); + $flags->setMask(Permission::CanBook); + $flags->addFlag(Permission::CanCancel); + $flags->removeFlag(Permission::CanBook); + $flags->checkFlag(Permission::CanCancel); + $flags->checkAnyFlag(Permission::CanCancel); + $flags->getFlagNames(Permission::CanCancel); + } finally { + restore_error_handler(); + } + + expect($messages)->toBeEmpty(); +}); + +test('iterating enum flags yields enum cases', function (): void { + $flags = new ExamplePermissionFlags(Mask::forEnum(Permission::class, Permission::CanView, Permission::CanCancel)); + + $result = []; + foreach ($flags as $bit => $permission) { + $result[$bit] = $permission; + } + + expect($result)->toEqual([ + Permission::CanView->value => Permission::CanView, + Permission::CanCancel->value => Permission::CanCancel, + ]); +}); diff --git a/tests/Pest.php b/tests/Pest.php new file mode 100644 index 0000000..18f2653 --- /dev/null +++ b/tests/Pest.php @@ -0,0 +1,5 @@ +in(__DIR__); diff --git a/tests/Stubs/BadStringFlag.php b/tests/Stubs/BadStringFlag.php new file mode 100644 index 0000000..329beaa --- /dev/null +++ b/tests/Stubs/BadStringFlag.php @@ -0,0 +1,8 @@ + + */ +class ExampleEnumFlags extends BinaryEnumFlags +{ + public const FOO = Bits::BIT_1; + public const BAR = Bits::BIT_2; + public const BAZ = Bits::BIT_3; + public const QUX = Bits::BIT_4; + + protected static function getFlagEnumClass(): string + { + return Flag::class; + } +} diff --git a/tests/Stubs/ExampleFlags.php b/tests/Stubs/ExampleFlags.php index 18c5221..513777c 100644 --- a/tests/Stubs/ExampleFlags.php +++ b/tests/Stubs/ExampleFlags.php @@ -7,8 +7,8 @@ class ExampleFlags extends BinaryFlags { - const FOO = Bits::BIT_1; - const BAR = Bits::BIT_2; - const BAZ = Bits::BIT_3; - const QUX = Bits::BIT_4; -} \ No newline at end of file + public const FOO = Bits::BIT_1; + public const BAR = Bits::BIT_2; + public const BAZ = Bits::BIT_3; + public const QUX = Bits::BIT_4; +} diff --git a/tests/Stubs/ExampleFlagsWithNames.php b/tests/Stubs/ExampleFlagsWithNames.php index 2cfdf16..59cb576 100644 --- a/tests/Stubs/ExampleFlagsWithNames.php +++ b/tests/Stubs/ExampleFlagsWithNames.php @@ -7,10 +7,10 @@ class ExampleFlagsWithNames extends BinaryFlags { - const FOO = Bits::BIT_1; - const BAR = Bits::BIT_2; - const BAZ = Bits::BIT_3; - const QUX = Bits::BIT_4; + public const FOO = Bits::BIT_1; + public const BAR = Bits::BIT_2; + public const BAZ = Bits::BIT_3; + public const QUX = Bits::BIT_4; public static function getAllFlags(): array { @@ -21,5 +21,4 @@ public static function getAllFlags(): array static::QUX => 'My qux description', ]; } - -} \ No newline at end of file +} diff --git a/tests/Stubs/ExampleInvalidPermissionFlags.php b/tests/Stubs/ExampleInvalidPermissionFlags.php new file mode 100644 index 0000000..69471d8 --- /dev/null +++ b/tests/Stubs/ExampleInvalidPermissionFlags.php @@ -0,0 +1,16 @@ + + */ +class ExampleInvalidPermissionFlags extends BinaryEnumFlags +{ + protected static function getFlagEnumClass(): string + { + return InvalidPermission::class; + } +} diff --git a/tests/Stubs/ExamplePermissionFlags.php b/tests/Stubs/ExamplePermissionFlags.php new file mode 100644 index 0000000..4437bf1 --- /dev/null +++ b/tests/Stubs/ExamplePermissionFlags.php @@ -0,0 +1,16 @@ + + */ +class ExamplePermissionFlags extends BinaryEnumFlags +{ + protected static function getFlagEnumClass(): string + { + return Permission::class; + } +} diff --git a/tests/Stubs/InvalidPermission.php b/tests/Stubs/InvalidPermission.php new file mode 100644 index 0000000..f6ef372 --- /dev/null +++ b/tests/Stubs/InvalidPermission.php @@ -0,0 +1,11 @@ +getFlagNames($this->currentPos); + + return $result; + } + + public function next(): void + { + $this->currentPos <<= 1; + while (($this->mask & $this->currentPos) === 0 && $this->currentPos > 0) { + $this->currentPos <<= 1; + } + } + + public function key(): int + { + return $this->currentPos; + } + + public function valid(): bool + { + return $this->currentPos > 0; + } + + public function rewind(): void + { + if ($this->mask === 0) { + $this->currentPos = 0; + + return; + } + + $this->currentPos = 1; + while (($this->mask & $this->currentPos) === 0) { + $this->currentPos <<= 1; + } + } + + public function count(): int + { + $count = 0; + $mask = $this->mask; + + while ($mask !== 0) { + if (($mask & 1) === 1) { + $count++; + } + $mask >>= 1; + } + + return $count; + } + + public function jsonSerialize(): array + { + return ['mask' => $this->mask]; + } +} diff --git a/tests/Stubs/Permission.php b/tests/Stubs/Permission.php new file mode 100644 index 0000000..da6df0e --- /dev/null +++ b/tests/Stubs/Permission.php @@ -0,0 +1,13 @@ +