diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..88b0cc9 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,12 @@ +/* export-ignore + +# Project files +/README.md -export-ignore +/composer.json -export-ignore +/docs -export-ignore +/src -export-ignore + +# SBOM information +/LICENSE -export-ignore +/LICENSES -export-ignore +/REUSE.toml -export-ignore diff --git a/README.md b/README.md index 04cfe33..fc361f9 100644 --- a/README.md +++ b/README.md @@ -178,15 +178,18 @@ a name, constructor arguments, and an optional wrapper. **NamespaceLookup vs ComposingLookup:** use `NamespaceLookup` for simple name-to-class mapping. Wrap it with `ComposingLookup` when you need prefix -composition like `notEmail()` → `Not(Email())`. `ComposingLookup` supports -recursive unwrapping, so `notNullOrEmail()` → `Not(NullOr(Email()))` works too. +composition like `notEmail()` → `Not(Email())`. Composition resolves a single +prefix level (e.g. `notEmail`, `nullOrEmail`); deeper nesting such as +`notNullOrEmail` is not decomposed. ## Assurance attributes Node classes can declare what they assure about their input via `#[Assurance]`. Assertion methods are marked with `#[AssuranceAssertion]`, and `#[AssuranceParameter]` identifies specific parameters. Constructor parameters for composition use -`#[ComposableParameter]`. +`#[ComposableParameter]`. Composable prefixes declare how they relate to the +wrapped node's subject with `#[AssuranceSubject]` (`Wrap`, `Elements`, or +`Container`). This metadata is available at runtime through reflection and is also consumed by tools like [FluentAnalysis](https://github.com/Respect/FluentAnalysis) diff --git a/composer.json b/composer.json index 24ec155..e1b3437 100644 --- a/composer.json +++ b/composer.json @@ -47,5 +47,10 @@ "allow-plugins": { "dealerdirect/phpcodesniffer-composer-installer": true } + }, + "extra": { + "branch-alias": { + "dev-ide-narrowing": "3.0.x-dev" + } } } diff --git a/docs/api.md b/docs/api.md index 0ac81ec..6e7d3aa 100644 --- a/docs/api.md +++ b/docs/api.md @@ -75,14 +75,18 @@ final readonly class ValidatorBuilder extends Append ### AssuranceParameter -Marks a parameter for assurance type resolution. Contextual based on where it -appears: +Selects **which** argument carries the assurance information — purely an index, +defaulting to the first parameter when absent. It does not itself imply any +particular derivation; `from:` decides how the selected argument maps to the +assured type. Contextual based on where it appears: -**On a constructor parameter** — the parameter's value determines the assurance -type (replaces the old `parameter:` string reference): +**On a constructor parameter** — selects which argument is the type source. The +example below pairs it with `from: TypeString` so the class-string argument +narrows to an instance of that class (replaces the old `parameter:` string +reference): ```php -#[Assurance] +#[Assurance(from: AssuranceFrom::TypeString, exact: true)] final readonly class Instance implements Validator { public function __construct( @@ -302,8 +306,9 @@ FluentAnalysis reads this to determine how each node narrows a type: #[Assurance(type: 'int')] final readonly class IntType implements Validator { /* ... */ } -// Type from a constructor parameter (use #[AssuranceParameter]) -#[Assurance] +// Instance of the class named by a class-string argument +// (#[AssuranceParameter] selects which argument; from: TypeString the derivation) +#[Assurance(from: AssuranceFrom::TypeString, exact: true)] final readonly class Instance implements Validator { public function __construct( @@ -346,24 +351,32 @@ final readonly class When implements Validator public function __construct(Validator $when, Validator $then, Validator $else) {} } -// Modifier: exclude type instead of asserting it +// Exact: passes if and only if the input is of the declared type +#[Assurance(type: 'int', exact: true)] +final readonly class IntType implements Validator { /* ... */ } + +// Modifier on a Wrap prefix: negate the wrapped node's assurance #[Assurance(modifier: AssuranceModifier::Exclude)] +#[AssuranceSubject(AssuranceSubjectMode::Wrap)] final readonly class Not implements Validator { /* ... */ } -// Modifier: add null to the assured type -#[Assurance(modifier: AssuranceModifier::Nullable)] +// Bypass set on a Wrap prefix: 'null' is admitted in union with the +// wrapped node's assurance (nullOrIntType() assures int|null) +#[Assurance(type: 'null', exact: true)] +#[AssuranceSubject(AssuranceSubjectMode::Wrap)] final readonly class NullOr implements Validator { /* ... */ } ``` Properties: -| Property | Type | Purpose | -|----------------|--------------------------|------------------------------------------------------------------| -| `type` | `?string` | Fixed type string (e.g. `'int'`, `'string'`) | -| `from` | `?AssuranceFrom` | Derive type from a method argument | -| `compose` | `?AssuranceCompose` | Combine assurances from child validators | -| `composeRange` | `?array{int, int\|null}` | Subset of arguments to compose (`[from, to]`, null = open-ended) | -| `modifier` | `?AssuranceModifier` | Modify how the assurance is applied | +| Property | Type | Purpose | +|----------------|------------------------------|------------------------------------------------------------------| +| `type` | `string\|list\|null` | Fixed type string (e.g. `'int'`); a list means their union | +| `from` | `?AssuranceFrom` | Derive type from a method argument | +| `compose` | `?AssuranceCompose` | Combine assurances from child validators | +| `composeRange` | `?array{int, int\|null}` | Subset of arguments to compose (`[from, to]`, null = open-ended) | +| `modifier` | `?AssuranceModifier` | Modify how the assurance is applied | +| `exact` | `bool` | The node passes *iff* the input is of the declared type | ### AssuranceFrom (enum) @@ -371,9 +384,10 @@ Determines how the assured type is derived from a method argument: | Case | Meaning | |------------|---------------------------------------------------------| -| `Value` | The argument's literal type (e.g. `42` → `42`) | -| `Member` | The iterable value type (e.g. `['a','b']` → `'a'\|'b'`) | -| `Elements` | An array of the inner assurance type | +| `Value` | The argument's literal type (e.g. `42` → `42`) | +| `Member` | The iterable value type (e.g. `['a','b']` → `'a'\|'b'`) | +| `Elements` | An array of the inner assurance type | +| `TypeString` | An instance of the class named by a class-string argument | ### AssuranceCompose (enum) @@ -388,7 +402,37 @@ Determines how child assurances are combined: Modifies how an assurance is applied: -| Case | Meaning | -|------------|------------------------------------------| -| `Exclude` | Removes the type instead of asserting it | -| `Nullable` | Adds `null` to the assured type | +| Case | Meaning | +|-----------|-----------------------------------------------------------------------| +| `Exclude` | The wrapped node's assurance is negated: passing implies NOT the type | + +### AssuranceSubject + +Declares how a `#[Composable]` prefix relates to its wrapped node's subject. +A prefix without it yields no assurance for composed names: tools must drop, +not copy, the wrapped node's assurance. + +```php +// Same subject, modified: notEmail() negates Email's assurance +#[Assurance(modifier: AssuranceModifier::Exclude)] +#[AssuranceSubject(AssuranceSubjectMode::Wrap)] +final readonly class Not implements Validator { /* ... */ } + +// Derived subject: keyEmail('name') assures only the container type +#[Assurance(type: ['array', 'ArrayAccess'])] +#[AssuranceSubject(AssuranceSubjectMode::Container)] +final readonly class Key implements Validator { /* ... */ } +``` + +### AssuranceSubjectMode (enum) + +| Case | Meaning | +|-------------|----------------------------------------------------------------------------------| +| `Wrap` | Same subject as the wrapped node: its assurance passes through, modified | +| `Elements` | The wrapped node validates each element: assurance becomes `iterable` | +| `Container` | The wrapped node validates a derived subject: only the container type is assured | + +A `Wrap` prefix's own `#[Assurance(type:)]` declares its *bypass set*: inputs +it admits itself, in union with whatever the wrapped node assures. It is only +meaningful in composition, never as a claim about direct calls; `exact` on it +means the bypass set is an exact characterization. diff --git a/src/Attributes/Assurance.php b/src/Attributes/Assurance.php index 2408880..54ec64e 100644 --- a/src/Attributes/Assurance.php +++ b/src/Attributes/Assurance.php @@ -25,6 +25,7 @@ public function __construct( public AssuranceCompose|null $compose = null, public array|null $composeRange = null, public AssuranceModifier|null $modifier = null, + public bool $exact = false, ) { } } diff --git a/src/Attributes/AssuranceFrom.php b/src/Attributes/AssuranceFrom.php index bed9954..eabb1fd 100644 --- a/src/Attributes/AssuranceFrom.php +++ b/src/Attributes/AssuranceFrom.php @@ -10,9 +10,21 @@ namespace Respect\Fluent\Attributes; +/** + * How the assured type is derived from the indexed argument (see #[AssuranceParameter], + * which selects which argument; this enum selects the derivation). + */ enum AssuranceFrom: string { + /** The argument's own value type is the assured type (e.g. identical($x)). */ case Value = 'value'; + + /** The argument is a haystack; the assured type is its member type (e.g. in($haystack)). */ case Member = 'member'; + + /** The argument validates elements; the assured type is iterable of them (e.g. each($v)). */ case Elements = 'elements'; + + /** The argument is a class-string; the assured type is an instance of it (e.g. instance($class)). */ + case TypeString = 'type-string'; } diff --git a/src/Attributes/AssuranceModifier.php b/src/Attributes/AssuranceModifier.php index 1638b81..8ddd2ba 100644 --- a/src/Attributes/AssuranceModifier.php +++ b/src/Attributes/AssuranceModifier.php @@ -12,6 +12,6 @@ enum AssuranceModifier: string { + /** The wrapped node's assurance is negated: passing implies NOT the type */ case Exclude = 'exclude'; - case Nullable = 'nullable'; } diff --git a/src/Attributes/AssuranceSubject.php b/src/Attributes/AssuranceSubject.php new file mode 100644 index 0000000..4b350af --- /dev/null +++ b/src/Attributes/AssuranceSubject.php @@ -0,0 +1,30 @@ + + */ + +declare(strict_types=1); + +namespace Respect\Fluent\Attributes; + +use Attribute; + +/** + * Declares how a composable prefix relates to its wrapped node's subject. + * + * A prefix without this attribute yields no assurance for composed names: + * tools must drop, not copy, the wrapped node's assurance. + */ +#[Attribute(Attribute::TARGET_CLASS)] +final readonly class AssuranceSubject +{ + /** @param string|list|null $type Container type assured about the input */ + public function __construct( + public AssuranceSubjectMode $mode, + public string|array|null $type = null, + ) { + } +} diff --git a/src/Attributes/AssuranceSubjectMode.php b/src/Attributes/AssuranceSubjectMode.php new file mode 100644 index 0000000..e72dea2 --- /dev/null +++ b/src/Attributes/AssuranceSubjectMode.php @@ -0,0 +1,30 @@ + + */ + +declare(strict_types=1); + +namespace Respect\Fluent\Attributes; + +enum AssuranceSubjectMode: string +{ + /** + * Same subject as the wrapped node: its assurance passes through, modified. + * + * A Wrap prefix's own #[Assurance(type:)] declares its bypass set — inputs + * it admits itself, in union with whatever the wrapped node assures. It is + * only meaningful in composition, never as a claim about direct calls; + * `exact` on it means the bypass set is an exact characterization. + */ + case Wrap = 'wrap'; + + /** The wrapped node validates each element: assurance becomes iterable */ + case Elements = 'elements'; + + /** The wrapped node validates a derived subject: only the container type is assured */ + case Container = 'container'; +} diff --git a/src/Builders/Append.php b/src/Builders/Append.php index 0b80dca..65bedb4 100644 --- a/src/Builders/Append.php +++ b/src/Builders/Append.php @@ -12,7 +12,7 @@ use function array_values; -/** @extends FluentBuilder, mixed, never> */ +/** @extends FluentBuilder, mixed, never, true> */ readonly class Append extends FluentBuilder { public function attach(object ...$nodes): static diff --git a/src/Builders/FluentBuilder.php b/src/Builders/FluentBuilder.php index 0b58a1c..9b40d7a 100644 --- a/src/Builders/FluentBuilder.php +++ b/src/Builders/FluentBuilder.php @@ -20,6 +20,7 @@ * @template TNodes of list * @template TSure * @template TSureNot + * @template TExact of bool = true */ abstract readonly class FluentBuilder { diff --git a/src/Builders/Prepend.php b/src/Builders/Prepend.php index f62b372..d33fdab 100644 --- a/src/Builders/Prepend.php +++ b/src/Builders/Prepend.php @@ -12,7 +12,7 @@ use function array_values; -/** @extends FluentBuilder, mixed, never> */ +/** @extends FluentBuilder, mixed, never, true> */ readonly class Prepend extends FluentBuilder { public function attach(object ...$nodes): static diff --git a/tests/unit/Attributes/AssuranceSubjectTest.php b/tests/unit/Attributes/AssuranceSubjectTest.php new file mode 100644 index 0000000..e4cf3a2 --- /dev/null +++ b/tests/unit/Attributes/AssuranceSubjectTest.php @@ -0,0 +1,44 @@ +mode); + self::assertNull($wrap->type); + self::assertSame(AssuranceSubjectMode::Container, $container->mode); + self::assertSame(['array', 'ArrayAccess'], $container->type); + } + + #[Test] + public function targetsClassesOnly(): void + { + $reflection = new ReflectionClass(AssuranceSubject::class); + $attrs = $reflection->getAttributes(Attribute::class); + + self::assertCount(1, $attrs); + self::assertSame(Attribute::TARGET_CLASS, $attrs[0]->newInstance()->flags); + } +} diff --git a/tests/unit/Attributes/AssuranceTest.php b/tests/unit/Attributes/AssuranceTest.php new file mode 100644 index 0000000..59771e7 --- /dev/null +++ b/tests/unit/Attributes/AssuranceTest.php @@ -0,0 +1,44 @@ +exact); + } + + #[Test] + public function exactCanBeDeclared(): void + { + $assurance = new Assurance(type: 'int', exact: true); + + self::assertTrue($assurance->exact); + } + + #[Test] + public function excludeModifierIsDeclarable(): void + { + $assurance = new Assurance(modifier: AssuranceModifier::Exclude); + + self::assertSame('exclude', $assurance->modifier?->value); + } +}