From 00c50196fef2f013a47f73b812f6f5da58bb2472 Mon Sep 17 00:00:00 2001 From: Gustavo Karkow <14905932+karkowg@users.noreply.github.com> Date: Sun, 11 Jan 2026 17:43:42 -0500 Subject: [PATCH 1/2] simplify dx --- .github/workflows/ci.yml | 44 +++++++ CHANGELOG.md | 24 +++- CLAUDE.md | 75 ++++++++++++ README.md | 54 ++++++--- composer.json | 12 +- playground/aaronfrancis.php | 42 ++----- playground/checkout.php | 43 ++----- playground/ide.php | 31 ++--- playground/json.php | 12 +- playground/payment.php | 35 ++---- playground/rpg.php | 35 ++---- src/BaseBitmask.php | 88 -------------- src/Bitmask.php | 109 ++++++++++++++++- src/Contracts/ValueObject.php | 2 + src/Enums/MaxValue.php | 11 -- src/Enums/Size.php | 18 +++ src/MediumBitmask.php | 20 --- src/SmallBitmask.php | 20 --- src/Support/helpers.php | 33 ++++- src/TinyBitmask.php | 20 --- src/Values/PowerOfTwo.php | 2 + tests/BitmaskSpec.php | 222 ++++++++++++++++++++++------------ tests/PowerOfTwoSpec.php | 17 +-- tests/UnitFlag.php | 15 +++ 24 files changed, 560 insertions(+), 424 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 CLAUDE.md delete mode 100644 src/BaseBitmask.php delete mode 100644 src/Enums/MaxValue.php create mode 100644 src/Enums/Size.php delete mode 100644 src/MediumBitmask.php delete mode 100644 src/SmallBitmask.php delete mode 100644 src/TinyBitmask.php create mode 100644 tests/UnitFlag.php diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..af59098 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,44 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + tests: + runs-on: ubuntu-latest + + strategy: + fail-fast: true + matrix: + php: [8.2, 8.3, 8.4] + + name: PHP ${{ matrix.php }} + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + extensions: mbstring + coverage: none + + - name: Install dependencies + run: composer install --prefer-dist --no-interaction --no-progress + + - name: Check code style + run: vendor/bin/pint --test + + - name: Run Rector + run: vendor/bin/rector --dry-run + + - name: Run PHPStan + run: vendor/bin/phpstan + + - name: Run tests + run: vendor/bin/pest --colors=always diff --git a/CHANGELOG.md b/CHANGELOG.md index 7526bf7..29f8d47 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,12 +3,32 @@ All notable changes to `bitmask` will be documented in this file. ## [Unreleased] +## [1.0.0] - 2025-01-11 +> Major refactor: Unified API with enum support + +### Changed +- Consolidated all bitmask variants into single immutable `Bitmask` class +- Factory methods: `make()`, `tiny()`, `small()`, `medium()` for different sizes +- Direct enum support in all methods (BackedEnum and UnitEnum) +- PHP 8.2+ required + +### Added +- `Size` enum with computed `maxValue()` +- `maskValue()` helper for enum-to-int conversion +- UnitEnum support (position-inferred values) + +### Removed +- `BaseBitmask`, `TinyBitmask`, `SmallBitmask`, `MediumBitmask` classes +- `MaxValue` enum (replaced by `Size::maxValue()`) +- `PowerOfTwo` value object (replaced by `isPowerOfTwo()` helper) + ## [0.0.1] - 2024-05-03 > Initial release ### Added -- `Bitmask` class and its variants `TinyBitmask`, `SmallBitmask` and `MediumBitmask` +- Initial bitmask implementation - Playground examples -[unreleased]: https://github.com/dotgksh/bitmask/compare/v0.0.1...HEAD +[unreleased]: https://github.com/dotgksh/bitmask/compare/v1.0.0...HEAD +[1.0.0]: https://github.com/dotgksh/bitmask/compare/v0.0.1...v1.0.0 [0.0.1]: https://github.com/dotgksh/bitmask/releases/tag/v0.0.1 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..9c2107e --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,75 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +A PHP bitmask value object library (`gksh/bitmask`) that provides type-safe bitwise operations with direct enum support. Single immutable `Bitmask` class with factory methods for different sizes (8, 16, 24, 32-bit). + +## Commands + +```bash +composer install # Install dependencies +composer test # Run all checks (refactor, lint, types, unit tests) +composer test:unit # Run Pest unit tests only +composer test:types # Run PHPStan static analysis +composer test:lint # Check code style with Pint +composer test:refacto # Check Rector refactoring rules +composer lint # Fix code style with Pint +composer refacto # Apply Rector refactoring + +# Run a single test file +./vendor/bin/pest tests/BitmaskSpec.php + +# Run tests matching a pattern +./vendor/bin/pest --filter="sets flag" +``` + +## Architecture + +### Core Class + +`Bitmask` - Immutable (`final readonly`) value object with factory methods: +- `Bitmask::make($value, $size)` - 32-bit default +- `Bitmask::tiny($value)` - 8-bit (max 255) +- `Bitmask::small($value)` - 16-bit (max 65,535) +- `Bitmask::medium($value)` - 24-bit (max 16,777,215) + +Operations: `set()`, `unset()`, `toggle()`, `has()`, `value()`, `size()`, `equals()` + +### Key Components + +- `Enums/Size` - Defines bit-widths (UInt8, UInt16, UInt24, UInt32) with computed `maxValue()` +- `Contracts/ValueObject` - Interface requiring `value()` and `equals()` methods +- `Support/helpers.php` - Contains `isPowerOfTwo()` and `maskValue()` helper functions + +### Design Pattern + +All flags must be powers of two (1, 2, 4, 8, 16...). The `isPowerOfTwo()` helper enforces this constraint. Bitmasks are immutable - operations return new instances. + +Accepts `int`, `BackedEnum`, or `UnitEnum` in all methods: +- `BackedEnum`: Uses the enum's integer value directly +- `UnitEnum`: Infers value from position (1 << position) + +## Testing + +Uses Pest PHP with data providers. Tests use `Flag` (BackedEnum) and `UnitFlag` (UnitEnum) defined in `tests/`. Common patterns: + +```php +// Parameterized tests with enum cases +it('sets flag', function (Flag $flag) { + // ... +})->with(Flag::cases()); + +// Testing boundary conditions +it('throws if out of bounds', function (Size $size, int $value) { + // ... +})->with([...])->throws(InvalidArgumentException::class); +``` + +## Code Quality + +- PHPStan at max level with strict types +- Pint for PSR-12 code style +- Rector for automated refactoring +- PHP 8.2+ required diff --git a/README.md b/README.md index c2c008a..e131e4a 100644 --- a/README.md +++ b/README.md @@ -16,12 +16,14 @@ A simple way to use bitmask and bitwise operations in PHP. composer require gksh/bitmask ``` -## 🧪 Usage +## Usage Streamline flag handling by encoding boolean options into simple integers through bitmasking. > Please see [ide.php](./playground/ide.php) for full example and [playground](./playground) for more. ```php +use Gksh\Bitmask\Bitmask; + enum Panel: int { case Project = 1; @@ -30,26 +32,18 @@ enum Panel: int case Extensions = 8; } -class Panels extends TinyBitmask +class Ide { - public function isVisible(Panel $panel): bool - { - return $this->has($panel->value); - } + public Bitmask $panels; - public function togglePanel(Panel $panel): Panels + public function __construct() { - return $this->toggle($panel->value); + $this->panels = Bitmask::tiny(); // 8-bit } -} - -class Ide -{ - public Panels $panels; public function togglePanel(Panel $panel): self { - $this->panels->togglePanel($panel); + $this->panels = $this->panels->toggle($panel); return $this; } @@ -59,8 +53,36 @@ $ide = (new Ide()) ->togglePanel(Panel::Project) ->togglePanel(Panel::Terminal); -$ide->panels->isVisible(Panel::Terminal); // true -$ide->panels->isVisible(Panel::Extensions); // false +$ide->panels->has(Panel::Terminal); // true +$ide->panels->has(Panel::Extensions); // false +``` + +### Features + +- **Immutable**: Operations return new instances, original unchanged +- **Enum support**: Pass `BackedEnum` directly — no `->value` extraction needed +- **Size variants**: `tiny()` (8-bit), `small()` (16-bit), `medium()` (24-bit), `make()` (32-bit default) + +### Factory Methods + +```php +Bitmask::make() // 32-bit (default) +Bitmask::tiny() // 8-bit, for TINYINT columns +Bitmask::small() // 16-bit, for SMALLINT columns +Bitmask::medium() // 24-bit, for MEDIUMINT columns +``` + +### Operations + +```php +$mask = Bitmask::tiny() + ->set(Flag::A) // Set a flag + ->unset(Flag::B) // Unset a flag + ->toggle(Flag::C); // Toggle a flag + +$mask->has(Flag::A); // Check if flag is set +$mask->value(); // Get integer value +$mask->size(); // Get Size enum ``` ## Testing diff --git a/composer.json b/composer.json index 67fc719..d504999 100644 --- a/composer.json +++ b/composer.json @@ -11,14 +11,14 @@ } ], "require": { - "php": "^8.2.0" + "php": "^8.2" }, "require-dev": { - "laravel/pint": "^1.13.7", - "pestphp/pest": "^2.28.1", - "phpstan/phpstan": "^1.10.50", - "rector/rector": "^0.19.8", - "symfony/var-dumper": "^6.4.0|^7.0.0" + "laravel/pint": "^1.18", + "pestphp/pest": "^4.0", + "phpstan/phpstan": "^2.1", + "rector/rector": "^2.0", + "symfony/var-dumper": "^7.2" }, "autoload": { "psr-4": { diff --git a/playground/aaronfrancis.php b/playground/aaronfrancis.php index 6660da4..019e80e 100644 --- a/playground/aaronfrancis.php +++ b/playground/aaronfrancis.php @@ -8,7 +8,7 @@ require __DIR__.'/../vendor/autoload.php'; -use Gksh\Bitmask\TinyBitmask; +use Gksh\Bitmask\Bitmask; enum FindMethod: int { @@ -36,35 +36,15 @@ public function methodName(): string } } -class FindAttempts extends TinyBitmask -{ - public ?int $pid = null; - - public function recordAttempt(FindMethod $method): FindAttempts - { - return $this->set($method->value); - } - - public function resetAttempt(FindMethod $method): FindAttempts - { - return $this->unset($method->value); - } - - public function hasAttempted(FindMethod $method): bool - { - return $this->has($method->value); - } -} - class Property // extends Model { public ?int $pid = null; - public FindAttempts $attempts; + public Bitmask $attempts; - public function __construct(?FindAttempts $attempts = null) + public function __construct(?Bitmask $attempts = null) { - $this->attempts = $attempts ?? new FindAttempts(); + $this->attempts = $attempts ?? Bitmask::tiny(); } public function save(): void @@ -73,7 +53,7 @@ public function save(): void dump([ 'property' => $this, 'attempted' => array_map(fn (FindMethod $method) => [ - $method->name => $this->attempts->hasAttempted($method), + $method->name => $this->attempts->has($method), ], FindMethod::cases()), ]); } @@ -87,7 +67,7 @@ public function handle(): void // Loop through the methods. foreach (FindMethod::cases() as $method) { // Skip ones we've already tried. - if ($property->attempts->hasAttempted($method)) { + if ($property->attempts->has($method)) { continue; } @@ -98,7 +78,7 @@ public function handle(): void // are currently disabled for any reason. We don't // record an attempt, as we'll try those again. if ($result !== false) { - $property->attempts->recordAttempt($method); + $property->attempts = $property->attempts->set($method); } // Stop processing once we find the PID. @@ -128,11 +108,11 @@ private function queryProperties(): array { // Imagine this is querying the DB. return [ - new Property(FindAttempts::make()), - new Property(FindAttempts::make(FindMethod::ADDRESS->value)), - new Property(FindAttempts::make(FindMethod::PARCEL->value | FindMethod::STREET->value)), + new Property(Bitmask::tiny()), + new Property(Bitmask::tiny(FindMethod::ADDRESS)), + new Property(Bitmask::tiny(FindMethod::PARCEL->value | FindMethod::STREET->value)), ]; } } -(new FindIds())->handle(); +(new FindIds)->handle(); diff --git a/playground/checkout.php b/playground/checkout.php index 2ebbfc6..a6d5421 100644 --- a/playground/checkout.php +++ b/playground/checkout.php @@ -2,8 +2,9 @@ require __DIR__.'/../vendor/autoload.php'; -use Gksh\Bitmask\TinyBitmask; +use Gksh\Bitmask\Bitmask; +// BackedEnum with explicit power-of-two values enum OrderFlag: int { case Gift = 1 << 0; // 1 @@ -12,41 +13,23 @@ enum OrderFlag: int case ExpressShipping = 1 << 3; // 8 } -class OrderFlags extends TinyBitmask -{ - public function enable(OrderFlag $flag): OrderFlags - { - return $this->set($flag->value); - } - - public function disable(OrderFlag $flag): OrderFlags - { - return $this->unset($flag->value); - } - - public function enabled(OrderFlag $flag): bool - { - return $this->has($flag->value); - } -} - class Order { public ?string $promoCode = null; public ?string $giftMessage = null; - public OrderFlags $flags; + public Bitmask $flags; public function __construct() { - $this->flags = OrderFlags::make(); + $this->flags = Bitmask::tiny(); } public function promo(string $code): self { $this->promoCode = $code; - $this->flags->enable(OrderFlag::PromoCode); + $this->flags = $this->flags->set(OrderFlag::PromoCode); return $this; } @@ -54,33 +37,33 @@ public function promo(string $code): self public function gift(string $message): self { $this->giftMessage = $message; - $this->flags->enable(OrderFlag::Gift); + $this->flags = $this->flags->set(OrderFlag::Gift); return $this; } public function freeShipping(): self { - $this->flags->enable(OrderFlag::FreeShipping); + $this->flags = $this->flags->set(OrderFlag::FreeShipping); return $this; } public function expressShipping(): self { - $this->flags->enable(OrderFlag::ExpressShipping); + $this->flags = $this->flags->set(OrderFlag::ExpressShipping); return $this; } } -$order = (new Order()) +$order = (new Order) ->promo('XMAS2024') ->gift('Merry Christmas!'); dump([ - 'is_gift' => $order->flags->enabled(OrderFlag::Gift), // true - 'has_promo_code' => $order->flags->enabled(OrderFlag::PromoCode), // true - 'free_shipping' => $order->flags->enabled(OrderFlag::FreeShipping), // false - 'express_shipping' => $order->flags->enabled(OrderFlag::ExpressShipping), // false + 'is_gift' => $order->flags->has(OrderFlag::Gift), // true + 'has_promo_code' => $order->flags->has(OrderFlag::PromoCode), // true + 'free_shipping' => $order->flags->has(OrderFlag::FreeShipping), // false + 'express_shipping' => $order->flags->has(OrderFlag::ExpressShipping), // false ]); diff --git a/playground/ide.php b/playground/ide.php index d9c6141..68c6e83 100644 --- a/playground/ide.php +++ b/playground/ide.php @@ -2,7 +2,7 @@ require __DIR__.'/../vendor/autoload.php'; -use Gksh\Bitmask\TinyBitmask; +use Gksh\Bitmask\Bitmask; enum Panel: int { @@ -12,43 +12,30 @@ enum Panel: int case Extensions = 1 << 3; } -class Panels extends TinyBitmask -{ - public function isVisible(Panel $panel): bool - { - return $this->has($panel->value); - } - - public function togglePanel(Panel $panel): Panels - { - return $this->toggle($panel->value); - } -} - class Ide { - public Panels $panels; + public Bitmask $panels; public function __construct() { - $this->panels = Panels::make(); + $this->panels = Bitmask::tiny(); } public function togglePanel(Panel $panel): self { - $this->panels->togglePanel($panel); + $this->panels = $this->panels->toggle($panel); return $this; } } -$ide = (new Ide()) +$ide = (new Ide) ->togglePanel(Panel::Project) ->togglePanel(Panel::Terminal); dump([ - 'project' => $ide->panels->isVisible(Panel::Project), // true - 'terminal' => $ide->panels->isVisible(Panel::Terminal), // true - 'source_control' => $ide->panels->isVisible(Panel::SourceControl), // false - 'extensions' => $ide->panels->isVisible(Panel::Extensions), // false + 'project' => $ide->panels->has(Panel::Project), // true + 'terminal' => $ide->panels->has(Panel::Terminal), // true + 'source_control' => $ide->panels->has(Panel::SourceControl), // false + 'extensions' => $ide->panels->has(Panel::Extensions), // false ]); diff --git a/playground/json.php b/playground/json.php index 76e3aa6..6d9bc80 100644 --- a/playground/json.php +++ b/playground/json.php @@ -2,20 +2,14 @@ require __DIR__.'/../vendor/autoload.php'; -use Gksh\Bitmask\SmallBitmask; - -class JsonFlags extends SmallBitmask -{ -} +use Gksh\Bitmask\Bitmask; class Payload { /** * @param array $data */ - public function __construct(public array $data, public JsonFlags $flags) - { - } + public function __construct(public array $data, public Bitmask $flags) {} public function serialize(): false|string { @@ -25,7 +19,7 @@ public function serialize(): false|string $payload = new Payload( ['foo' => 'bar', 'baz' => 'qux'], - JsonFlags::make() + Bitmask::small() ->set(JSON_PRETTY_PRINT) ->set(JSON_UNESCAPED_SLASHES) ); diff --git a/playground/payment.php b/playground/payment.php index d48e2ca..9611630 100644 --- a/playground/payment.php +++ b/playground/payment.php @@ -2,7 +2,7 @@ require __DIR__.'/../vendor/autoload.php'; -use Gksh\Bitmask\TinyBitmask; +use Gksh\Bitmask\Bitmask; enum PaymentMethod: int { @@ -12,38 +12,19 @@ enum PaymentMethod: int case Check = 8; } -class PaymentMethods extends TinyBitmask -{ - public function accept(PaymentMethod $method): PaymentMethods - { - return $this->set($method->value); - } - - public function reject(PaymentMethod $method): PaymentMethods - { - return $this->unset($method->value); - } - - public function accepts(PaymentMethod $method): bool - { - return $this->has($method->value); - } -} - class Invoice { - public function __construct(public PaymentMethods $paymentMethods) - { - } + public function __construct(public Bitmask $paymentMethods) {} } +// Create invoice accepting Cash and Debit only $invoice = new Invoice( - PaymentMethods::make() - ->accept(PaymentMethod::Cash) - ->accept(PaymentMethod::Debit) + Bitmask::tiny() + ->set(PaymentMethod::Cash) + ->set(PaymentMethod::Debit) ); dump([ - 'accepts_debit' => $invoice->paymentMethods->accepts(PaymentMethod::Debit), // true - 'accepts_credit' => $invoice->paymentMethods->accepts(PaymentMethod::Credit), // false + 'accepts_debit' => $invoice->paymentMethods->has(PaymentMethod::Debit), // true + 'accepts_credit' => $invoice->paymentMethods->has(PaymentMethod::Credit), // false ]); diff --git a/playground/rpg.php b/playground/rpg.php index 277bf44..7d3875f 100644 --- a/playground/rpg.php +++ b/playground/rpg.php @@ -2,49 +2,28 @@ require __DIR__.'/../vendor/autoload.php'; -use Gksh\Bitmask\TinyBitmask; +use Gksh\Bitmask\Bitmask; enum AttackType: int { - case None = 0; case Melee = 1; case Fire = 2; case Ice = 4; case Poison = 8; } -class AttackTypes extends TinyBitmask -{ - public function add(AttackType $type): AttackTypes - { - return $this->set($type->value); - } - - public function remove(AttackType $type): AttackTypes - { - return $this->unset($type->value); - } - - public function allows(AttackType $type): bool - { - return $this->has($type->value); - } -} - class Weapon { - public function __construct(public AttackTypes $attackTypes) - { - } + public function __construct(public Bitmask $attackTypes) {} } $fireSword = new Weapon( - AttackTypes::make() - ->add(AttackType::Melee) - ->add(AttackType::Fire) + Bitmask::tiny() + ->set(AttackType::Melee) + ->set(AttackType::Fire) ); dump([ - 'allows_fire' => $fireSword->attackTypes->allows(AttackType::Fire), // true - 'allows_ice' => $fireSword->attackTypes->allows(AttackType::Ice), // false + 'allows_fire' => $fireSword->attackTypes->has(AttackType::Fire), // true + 'allows_ice' => $fireSword->attackTypes->has(AttackType::Ice), // false ]); diff --git a/src/BaseBitmask.php b/src/BaseBitmask.php deleted file mode 100644 index f62183d..0000000 --- a/src/BaseBitmask.php +++ /dev/null @@ -1,88 +0,0 @@ -maxValue = $this->maxValue(); - - if ($value < 0 || $value > $this->maxValue) { - throw new InvalidArgumentException("Invalid unsigned integer $value"); - } - - $this->value = $value; - } - - protected function maxValue(): int - { - return MaxValue::UInt32->value; - } - - private function assertPowerOfTwo(int $value): void - { - new PowerOfTwo($value); - } - - public function set(int $flag): static - { - $this->assertPowerOfTwo($flag); - - $value = $this->value | $flag; - - if ($value < 0 || $value > $this->maxValue) { - throw new InvalidArgumentException("Invalid unsigned integer $value"); - } - - $this->value = $value; - - return $this; - } - - public function unset(int $flag): static - { - $this->assertPowerOfTwo($flag); - - $this->value &= (~$flag); - - return $this; - } - - public function toggle(int $flag): static - { - $this->assertPowerOfTwo($flag); - - $this->value ^= $flag; - - return $this; - } - - public function has(int $flag): bool - { - $this->assertPowerOfTwo($flag); - - return ($this->value & $flag) === $flag; - } - - public function value(): int - { - return $this->value; - } - - public function equals(BaseBitmask|ValueObject $other): bool - { - return $this->value === $other->value(); - } -} diff --git a/src/Bitmask.php b/src/Bitmask.php index b917d04..77f9b5b 100644 --- a/src/Bitmask.php +++ b/src/Bitmask.php @@ -4,17 +4,114 @@ namespace Gksh\Bitmask; -use Gksh\Bitmask\Enums\MaxValue; +use BackedEnum; +use Gksh\Bitmask\Contracts\ValueObject; +use Gksh\Bitmask\Enums\Size; +use InvalidArgumentException; +use UnitEnum; -class Bitmask extends BaseBitmask +use function Gksh\Bitmask\Support\isPowerOfTwo; +use function Gksh\Bitmask\Support\maskValue; + +final readonly class Bitmask implements ValueObject { - public static function make(int $value = 0): static + private int $value; + + private int $maxValue; + + public function __construct( + int|UnitEnum|BackedEnum $value = 0, + private Size $size = Size::UInt32, + ) { + $this->maxValue = $this->size->maxValue(); + $this->value = maskValue($value); + + if ($this->value < 0 || $this->value > $this->maxValue) { + throw new InvalidArgumentException("Invalid unsigned integer {$this->value}"); + } + } + + public static function make(int|UnitEnum|BackedEnum $value = 0, Size $size = Size::UInt32): self + { + return new self($value, $size); + } + + public static function tiny(int|UnitEnum|BackedEnum $value = 0): self + { + return new self($value, Size::UInt8); + } + + public static function small(int|UnitEnum|BackedEnum $value = 0): self + { + return new self($value, Size::UInt16); + } + + public static function medium(int|UnitEnum|BackedEnum $value = 0): self + { + return new self($value, Size::UInt24); + } + + public function set(int|UnitEnum|BackedEnum $flag): self + { + $flagValue = $this->flagValue($flag); + $newValue = $this->value | $flagValue; + + if ($newValue > $this->maxValue) { + throw new InvalidArgumentException("Value $newValue exceeds max {$this->maxValue}"); + } + + return new self($newValue, $this->size); + } + + public function unset(int|UnitEnum|BackedEnum $flag): self + { + $flagValue = $this->flagValue($flag); + + return new self($this->value & (~$flagValue), $this->size); + } + + public function toggle(int|UnitEnum|BackedEnum $flag): self + { + $flagValue = $this->flagValue($flag); + $newValue = $this->value ^ $flagValue; + + if ($newValue > $this->maxValue) { + throw new InvalidArgumentException("Value $newValue exceeds max {$this->maxValue}"); + } + + return new self($newValue, $this->size); + } + + public function has(int|UnitEnum|BackedEnum $flag): bool + { + $flagValue = $this->flagValue($flag); + + return ($this->value & $flagValue) === $flagValue; + } + + public function value(): int + { + return $this->value; + } + + public function size(): Size { - return new static($value); + return $this->size; } - protected function maxValue(): int + public function equals(ValueObject $other): bool { - return MaxValue::UInt32->value; + return $this->value === $other->value(); + } + + private function flagValue(int|UnitEnum|BackedEnum $flag): int + { + $value = maskValue($flag); + + if (! isPowerOfTwo($value)) { + throw new InvalidArgumentException("Value $value is not a power of two"); + } + + return $value; } } diff --git a/src/Contracts/ValueObject.php b/src/Contracts/ValueObject.php index 847080b..3c3d77a 100644 --- a/src/Contracts/ValueObject.php +++ b/src/Contracts/ValueObject.php @@ -1,5 +1,7 @@ value) - 1; + } +} diff --git a/src/MediumBitmask.php b/src/MediumBitmask.php deleted file mode 100644 index 2d61e92..0000000 --- a/src/MediumBitmask.php +++ /dev/null @@ -1,20 +0,0 @@ -value; - } -} diff --git a/src/SmallBitmask.php b/src/SmallBitmask.php deleted file mode 100644 index 6a80192..0000000 --- a/src/SmallBitmask.php +++ /dev/null @@ -1,20 +0,0 @@ -value; - } -} diff --git a/src/Support/helpers.php b/src/Support/helpers.php index 07bb582..e0be94c 100644 --- a/src/Support/helpers.php +++ b/src/Support/helpers.php @@ -1,8 +1,33 @@ value)) { + throw new InvalidArgumentException('BackedEnum must have integer backing value'); + } + + return $flag->value; } + + if ($flag instanceof UnitEnum) { + $position = array_search($flag, $flag::cases(), true); + + return 1 << $position; + } + + return $flag; } diff --git a/src/TinyBitmask.php b/src/TinyBitmask.php deleted file mode 100644 index fe02e8b..0000000 --- a/src/TinyBitmask.php +++ /dev/null @@ -1,20 +0,0 @@ -value; - } -} diff --git a/src/Values/PowerOfTwo.php b/src/Values/PowerOfTwo.php index 4617d75..4bbfea0 100644 --- a/src/Values/PowerOfTwo.php +++ b/src/Values/PowerOfTwo.php @@ -7,6 +7,8 @@ use Gksh\Bitmask\Contracts\ValueObject; use InvalidArgumentException; +use function Gksh\Bitmask\Support\isPowerOfTwo; + readonly class PowerOfTwo implements ValueObject { public function __construct(private int $value) diff --git a/tests/BitmaskSpec.php b/tests/BitmaskSpec.php index 509b6bc..ad756c1 100644 --- a/tests/BitmaskSpec.php +++ b/tests/BitmaskSpec.php @@ -1,77 +1,133 @@ value())->toBe(0); }); +test('default size is UInt32', function () { + expect(Bitmask::make()->size())->toBe(Size::UInt32); +}); + it('makes instance with flag value', function (Flag $flag) { $mask = Bitmask::make($flag->value); expect($mask->value())->toBe($flag->value); })->with(Flag::cases()); -it('sets flag', function (Flag $flag) { +it('makes instance with BackedEnum', function (Flag $flag) { + $mask = Bitmask::tiny($flag); + + expect($mask->value())->toBe($flag->value); +})->with(Flag::cases()); + +it('makes instance with UnitEnum', function (UnitFlag $flag) { + $mask = Bitmask::tiny($flag); + $expectedValue = 1 << array_search($flag, UnitFlag::cases(), true); + + expect($mask->value())->toBe($expectedValue); +})->with(UnitFlag::cases()); + +it('sets flag with int', function (Flag $flag) { $mask = Bitmask::make()->set($flag->value); expect($mask->value())->toBe($flag->value) ->and($mask->has($flag->value))->toBeTrue(); })->with(Flag::cases()); +it('sets flag with BackedEnum', function (Flag $flag) { + $mask = Bitmask::make()->set($flag); + + expect($mask->value())->toBe($flag->value) + ->and($mask->has($flag))->toBeTrue(); +})->with(Flag::cases()); + +it('sets flag with UnitEnum', function (UnitFlag $flag) { + $mask = Bitmask::make()->set($flag); + $expectedValue = 1 << array_search($flag, UnitFlag::cases(), true); + + expect($mask->value())->toBe($expectedValue) + ->and($mask->has($flag))->toBeTrue(); +})->with(UnitFlag::cases()); + it('sets multiple flags', function () { $mask = Bitmask::make() - ->set($a = Flag::A->value) - ->set($b = Flag::B->value) - ->set($c = Flag::C->value); - - expect($mask->value())->toBe($a | $b | $c) - ->and($mask->has($a))->toBeTrue() - ->and($mask->has($b))->toBeTrue() - ->and($mask->has($c))->toBeTrue(); + ->set(Flag::A) + ->set(Flag::B) + ->set(Flag::C); + + expect($mask->value())->toBe(Flag::A->value | Flag::B->value | Flag::C->value) + ->and($mask->has(Flag::A))->toBeTrue() + ->and($mask->has(Flag::B))->toBeTrue() + ->and($mask->has(Flag::C))->toBeTrue(); +}); + +it('is immutable - set returns new instance', function () { + $original = Bitmask::make(); + $modified = $original->set(Flag::A); + + expect($original->value())->toBe(0) + ->and($modified->value())->toBe(Flag::A->value) + ->and($original)->not->toBe($modified); }); it('toggles flag', function (Flag $flag) { - $mask = Bitmask::make()->toggle($flag->value); + $mask = Bitmask::make()->toggle($flag); - expect($mask->has($flag->value))->toBeTrue(); + expect($mask->has($flag))->toBeTrue(); - $mask->toggle($flag->value); + $mask = $mask->toggle($flag); - expect($mask->has($flag->value))->toBeFalse(); + expect($mask->has($flag))->toBeFalse(); })->with(Flag::cases()); +it('is immutable - toggle returns new instance', function () { + $original = Bitmask::make(); + $modified = $original->toggle(Flag::A); + + expect($original->value())->toBe(0) + ->and($modified->value())->toBe(Flag::A->value); +}); + it('unsets flag', function (Flag $flag) { - $mask = Bitmask::make()->set($flag->value); + $mask = Bitmask::make()->set($flag); expect($mask->value())->toBe($flag->value) - ->and($mask->has($flag->value))->toBeTrue(); + ->and($mask->has($flag))->toBeTrue(); - $mask->unset($flag->value); + $mask = $mask->unset($flag); expect($mask->value())->toBe(0) - ->and($mask->has($flag->value))->toBeFalse(); + ->and($mask->has($flag))->toBeFalse(); })->with(Flag::cases()); +it('is immutable - unset returns new instance', function () { + $original = Bitmask::make()->set(Flag::A); + $modified = $original->unset(Flag::A); + + expect($original->value())->toBe(Flag::A->value) + ->and($modified->value())->toBe(0); +}); + it('has flag', function (Flag $flag) { $mask = Bitmask::make($flag->value); - expect($mask->has($flag->value))->toBeTrue(); + expect($mask->has($flag))->toBeTrue(); })->with(Flag::cases()); it('does not have flag', function () { $mask = Bitmask::make(Flag::A->value); - expect($mask->has(Flag::A->value))->toBeTrue() - ->and($mask->has(Flag::B->value))->toBeFalse(); + expect($mask->has(Flag::A))->toBeTrue() + ->and($mask->has(Flag::B))->toBeFalse(); }); test('equality', function () { - expect(Bitmask::make()->equals(new Bitmask()))->toBeTrue() + expect(Bitmask::make()->equals(new Bitmask))->toBeTrue() ->and(Bitmask::make()->equals(Bitmask::make()))->toBeTrue(); $mask1 = Bitmask::make(Flag::A->value); @@ -83,9 +139,9 @@ $mask1 = Bitmask::make(Flag::A->value | Flag::B->value | Flag::C->value); $mask2 = Bitmask::make() - ->set(Flag::A->value) - ->set(Flag::B->value) - ->set(Flag::C->value); + ->set(Flag::A) + ->set(Flag::B) + ->set(Flag::C); expect($mask1->equals($mask2))->toBeTrue() ->and($mask2->equals($mask1))->toBeTrue(); @@ -101,74 +157,86 @@ ->and($mask2->equals($mask1))->toBeFalse(); $mask1 = Bitmask::make() - ->set(Flag::A->value) - ->set(Flag::B->value) - ->set(Flag::C->value); + ->set(Flag::A) + ->set(Flag::B) + ->set(Flag::C); $mask2 = Bitmask::make() - ->set(Flag::A->value) - ->set(Flag::C->value); + ->set(Flag::A) + ->set(Flag::C); expect($mask1->equals($mask2))->toBeFalse() ->and($mask2->equals($mask1))->toBeFalse(); }); -it('can instantiate with max integer value', function (string $class, int $value) { - match ($class) { - TinyBitmask::class => TinyBitmask::make($value), - SmallBitmask::class => SmallBitmask::make($value), - MediumBitmask::class => MediumBitmask::make($value), - default => Bitmask::make($value), - }; +// Factory method tests +test('tiny factory creates 8-bit bitmask', function () { + $mask = Bitmask::tiny(); + expect($mask->size())->toBe(Size::UInt8); +}); + +test('small factory creates 16-bit bitmask', function () { + $mask = Bitmask::small(); + expect($mask->size())->toBe(Size::UInt16); +}); + +test('medium factory creates 24-bit bitmask', function () { + $mask = Bitmask::medium(); + expect($mask->size())->toBe(Size::UInt24); +}); + +test('make factory with size parameter', function () { + expect(Bitmask::make(0, Size::UInt8)->size())->toBe(Size::UInt8) + ->and(Bitmask::make(0, Size::UInt16)->size())->toBe(Size::UInt16) + ->and(Bitmask::make(0, Size::UInt24)->size())->toBe(Size::UInt24) + ->and(Bitmask::make(0, Size::UInt32)->size())->toBe(Size::UInt32); +}); + +// Size boundary tests +it('can instantiate with max integer value', function (Size $size, int $value) { + Bitmask::make($value, $size); }) ->with([ - [TinyBitmask::class, 0b11111111], - [SmallBitmask::class, 0b1111111111111111], - [MediumBitmask::class, 0b111111111111111111111111], - [Bitmask::class, 0b11111111111111111111111111111111], + [Size::UInt8, 0b11111111], + [Size::UInt16, 0b1111111111111111], + [Size::UInt24, 0b111111111111111111111111], + [Size::UInt32, 0b11111111111111111111111111111111], ]) ->throwsNoExceptions(); -it('throws if instantiating with out of bounds integer', function (string $class, int $value) { - match ($class) { - TinyBitmask::class => TinyBitmask::make($value), - SmallBitmask::class => SmallBitmask::make($value), - MediumBitmask::class => MediumBitmask::make($value), - default => Bitmask::make($value), - }; +it('throws if instantiating with out of bounds integer', function (Size $size, int $value) { + Bitmask::make($value, $size); }) ->with([ - [TinyBitmask::class, -1], - [TinyBitmask::class, 0b100000000], - [SmallBitmask::class, -1], - [SmallBitmask::class, 0b10000000000000000], - [MediumBitmask::class, -1], - [MediumBitmask::class, 0b1000000000000000000000000], - [Bitmask::class, -1], - [Bitmask::class, 0b100000000000000000000000000000000], - [Bitmask::class, PHP_INT_MAX], + [Size::UInt8, -1], + [Size::UInt8, 0b100000000], + [Size::UInt16, -1], + [Size::UInt16, 0b10000000000000000], + [Size::UInt24, -1], + [Size::UInt24, 0b1000000000000000000000000], + [Size::UInt32, -1], + [Size::UInt32, 0b100000000000000000000000000000000], + [Size::UInt32, PHP_INT_MAX], ]) ->throws(InvalidArgumentException::class); -it('throws if setting out of bounds integer flag', function (string $class, int $value) { - $mask = match ($class) { - TinyBitmask::class => TinyBitmask::make(), - SmallBitmask::class => SmallBitmask::make(), - MediumBitmask::class => MediumBitmask::make(), - default => Bitmask::make(), - }; - - $mask->set($value); +it('throws if setting out of bounds integer flag', function (Size $size, int $value) { + Bitmask::make(0, $size)->set($value); }) ->with([ - [TinyBitmask::class, -1], - [TinyBitmask::class, 0b100000000], - [SmallBitmask::class, -1], - [SmallBitmask::class, 0b10000000000000000], - [MediumBitmask::class, -1], - [MediumBitmask::class, 0b1000000000000000000000000], - [Bitmask::class, -1], - [Bitmask::class, 0b100000000000000000000000000000000], - [Bitmask::class, PHP_INT_MAX], + [Size::UInt8, 0b100000000], + [Size::UInt16, 0b10000000000000000], + [Size::UInt24, 0b1000000000000000000000000], + [Size::UInt32, 0b100000000000000000000000000000000], ]) ->throws(InvalidArgumentException::class); + +// String-backed enum rejection test +it('throws for string-backed enum', function () { + enum StringFlag: string + { + case X = 'x'; + } + + Bitmask::make()->set(StringFlag::X); +})->throws(InvalidArgumentException::class); diff --git a/tests/PowerOfTwoSpec.php b/tests/PowerOfTwoSpec.php index d3bb937..c285c8e 100644 --- a/tests/PowerOfTwoSpec.php +++ b/tests/PowerOfTwoSpec.php @@ -1,18 +1,21 @@ with($notPowerOfTwoGenerator(min: 0, max: MaxValue::UInt8->value)) + ->with($notPowerOfTwoGenerator(min: 0, max: Size::UInt8->maxValue())) ->throws(InvalidArgumentException::class); it('is not power of two w/ random int from 256 to 65535', function (int $value) { new PowerOfTwo($value); }) - ->with($notPowerOfTwoGenerator(min: MaxValue::UInt8->value + 1, max: MaxValue::UInt16->value)) + ->with($notPowerOfTwoGenerator(min: Size::UInt8->maxValue() + 1, max: Size::UInt16->maxValue())) ->throws(InvalidArgumentException::class); it('is not power of two w/ random int from 65536 to 16777215', function (int $value) { new PowerOfTwo($value); }) - ->with($notPowerOfTwoGenerator(min: MaxValue::UInt16->value + 1, max: MaxValue::UInt24->value)) + ->with($notPowerOfTwoGenerator(min: Size::UInt16->maxValue() + 1, max: Size::UInt24->maxValue())) ->throws(InvalidArgumentException::class); it('is not power of two w/ random int from 16777216 to 4294967295', function (int $value) { new PowerOfTwo($value); }) - ->with($notPowerOfTwoGenerator(min: MaxValue::UInt24->value + 1, max: MaxValue::UInt32->value)) + ->with($notPowerOfTwoGenerator(min: Size::UInt24->maxValue() + 1, max: Size::UInt32->maxValue())) ->throws(InvalidArgumentException::class); diff --git a/tests/UnitFlag.php b/tests/UnitFlag.php new file mode 100644 index 0000000..06a2973 --- /dev/null +++ b/tests/UnitFlag.php @@ -0,0 +1,15 @@ + Date: Sun, 11 Jan 2026 22:26:11 -0500 Subject: [PATCH 2/2] fix pest version --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index d504999..0f57ec5 100644 --- a/composer.json +++ b/composer.json @@ -15,7 +15,7 @@ }, "require-dev": { "laravel/pint": "^1.18", - "pestphp/pest": "^4.0", + "pestphp/pest": "^3.0|^4.0", "phpstan/phpstan": "^2.1", "rector/rector": "^2.0", "symfony/var-dumper": "^7.2"