Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -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
9 changes: 6 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
5 changes: 5 additions & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,5 +47,10 @@
"allow-plugins": {
"dealerdirect/phpcodesniffer-composer-installer": true
}
},
"extra": {
"branch-alias": {
"dev-ide-narrowing": "3.0.x-dev"
}
}
}
92 changes: 68 additions & 24 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -346,34 +351,43 @@ 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<string>\|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)

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)

Expand All @@ -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<T>` |
| `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.
1 change: 1 addition & 0 deletions src/Attributes/Assurance.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
) {
}
}
12 changes: 12 additions & 0 deletions src/Attributes/AssuranceFrom.php
Original file line number Diff line number Diff line change
Expand Up @@ -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';
}
2 changes: 1 addition & 1 deletion src/Attributes/AssuranceModifier.php
Original file line number Diff line number Diff line change
Expand Up @@ -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';
}
30 changes: 30 additions & 0 deletions src/Attributes/AssuranceSubject.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<?php

/*
* SPDX-License-Identifier: ISC
* SPDX-FileCopyrightText: (c) Respect Project Contributors
* SPDX-FileContributor: Alexandre Gomes Gaigalas <alganet@gmail.com>
*/

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<string>|null $type Container type assured about the input */
public function __construct(
public AssuranceSubjectMode $mode,
public string|array|null $type = null,
) {
}
}
30 changes: 30 additions & 0 deletions src/Attributes/AssuranceSubjectMode.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<?php

/*
* SPDX-License-Identifier: ISC
* SPDX-FileCopyrightText: (c) Respect Project Contributors
* SPDX-FileContributor: Alexandre Gomes Gaigalas <alganet@gmail.com>
*/

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<T> */
case Elements = 'elements';

/** The wrapped node validates a derived subject: only the container type is assured */
case Container = 'container';
}
2 changes: 1 addition & 1 deletion src/Builders/Append.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@

use function array_values;

/** @extends FluentBuilder<list<object>, mixed, never> */
/** @extends FluentBuilder<list<object>, mixed, never, true> */
readonly class Append extends FluentBuilder
{
public function attach(object ...$nodes): static
Expand Down
1 change: 1 addition & 0 deletions src/Builders/FluentBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
* @template TNodes of list<object>
* @template TSure
* @template TSureNot
* @template TExact of bool = true
*/
abstract readonly class FluentBuilder
{
Expand Down
2 changes: 1 addition & 1 deletion src/Builders/Prepend.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@

use function array_values;

/** @extends FluentBuilder<list<object>, mixed, never> */
/** @extends FluentBuilder<list<object>, mixed, never, true> */
readonly class Prepend extends FluentBuilder
{
public function attach(object ...$nodes): static
Expand Down
44 changes: 44 additions & 0 deletions tests/unit/Attributes/AssuranceSubjectTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
<?php

/*
* SPDX-License-Identifier: ISC
* SPDX-FileCopyrightText: (c) Respect Project Contributors
*/

declare(strict_types=1);

namespace Respect\Fluent\Test\Unit\Attributes;

use Attribute;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use ReflectionClass;
use Respect\Fluent\Attributes\AssuranceSubject;
use Respect\Fluent\Attributes\AssuranceSubjectMode;

#[CoversClass(AssuranceSubject::class)]
final class AssuranceSubjectTest extends TestCase
{
#[Test]
public function holdsModeAndOptionalType(): void
{
$wrap = new AssuranceSubject(AssuranceSubjectMode::Wrap);
$container = new AssuranceSubject(AssuranceSubjectMode::Container, ['array', 'ArrayAccess']);

self::assertSame(AssuranceSubjectMode::Wrap, $wrap->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);
}
}
44 changes: 44 additions & 0 deletions tests/unit/Attributes/AssuranceTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
<?php

/*
* SPDX-License-Identifier: ISC
* SPDX-FileCopyrightText: (c) Respect Project Contributors
*/

declare(strict_types=1);

namespace Respect\Fluent\Test\Unit\Attributes;

use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use Respect\Fluent\Attributes\Assurance;
use Respect\Fluent\Attributes\AssuranceModifier;

#[CoversClass(Assurance::class)]
final class AssuranceTest extends TestCase
{
#[Test]
public function exactDefaultsToFalse(): void
{
$assurance = new Assurance(type: 'int');

self::assertFalse($assurance->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);
}
}
Loading