Skip to content

Commit 21e9936

Browse files
committed
Replace string-based references with targeted parameter attributes
- Add AssuranceParameter (TARGET_PARAMETER) for constructor and assertion method parameter annotation, replacing Assurance's $parameter and $input string properties - Add ComposableParameter (TARGET_PARAMETER) for prefix parameter promotion, replacing Composable's $prefixParameter boolean - Add Assurance, AssuranceFrom, AssuranceCompose, AssuranceModifier attributes for type narrowing metadata - Add AssuranceAssertion (TARGET_METHOD) replacing class-level FluentAssertion - Change Composable to store raw class-strings; consumers resolve via FluentResolver instead of internal toPrefix() - Change Composable $prefix to nullable (null = not a prefix class) - Change Assurance $composeRange from string to array{int, int|null} - Use private(set) visibility on Assurance and FluentNamespace properties - Add FluentBuilder TSure/TSureNot template parameters - Add unit tests for Composable and AssuranceAssertion - Complete API documentation in docs/api.md
1 parent 93b2056 commit 21e9936

19 files changed

Lines changed: 727 additions & 231 deletions

README.md

Lines changed: 32 additions & 209 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,8 @@
1+
<!--
2+
SPDX-FileCopyrightText: (c) Respect Project Contributors
3+
SPDX-License-Identifier: ISC
4+
SPDX-FileContributor: Alexandre Gomes Gaigalas <alganet@gmail.com>
5+
-->
16
# Respect\Fluent
27

38
Build fluent interfaces from class namespaces. PHP 8.5+, zero dependencies.
@@ -104,7 +109,7 @@ namespace App\Middleware;
104109

105110
use Respect\Fluent\Attributes\Composable;
106111

107-
#[Composable('optional')]
112+
#[Composable(self::class)]
108113
final readonly class Optional implements Middleware
109114
{
110115
public function __construct(private Middleware $inner) {}
@@ -161,232 +166,50 @@ A **FluentNode** carries the resolution state between resolvers and factories:
161166
a name, constructor arguments, and an optional wrapper.
162167

163168
```
164-
+-----------+
165-
'notEmail' --------> | Resolver | ------> FluentNode('Email', wrapper: FluentNode('Not'))
166-
+-----------+
169+
+----------+
170+
'notEmail' --------> | Resolver | ------> FluentNode('Email', wrapper: FluentNode('Not'))
171+
+----------+
167172
|
168173
v
169-
+-----------+
170-
FluentNode -----------> | Factory | ------> Not(Email())
171-
+-----------+
174+
+----------+
175+
FluentNode ---------> | Factory | ------> Not(Email())
176+
+----------+
172177
```
173178

174179
**NamespaceLookup vs ComposingLookup:** use `NamespaceLookup` for simple
175180
name-to-class mapping. Wrap it with `ComposingLookup` when you need prefix
176181
composition like `notEmail()``Not(Email())`. `ComposingLookup` supports
177182
recursive unwrapping, so `notNullOrEmail()``Not(NullOr(Email()))` works too.
178183

179-
## API Reference
184+
## Assurance attributes
180185

181-
### FluentNamespace (attribute)
186+
Node classes can declare what they assure about their input via `#[Assurance]`.
187+
Assertion methods are marked with `#[AssuranceAssertion]`, and `#[AssuranceParameter]`
188+
identifies specific parameters. Constructor parameters for composition use
189+
`#[ComposableParameter]`.
182190

183-
Declares the factory configuration for a builder class. Both the runtime
184-
(`factoryFromAttribute()`) and static analysis (FluentAnalysis) read from this
185-
single source of truth:
191+
This metadata is available at runtime through reflection and is also consumed
192+
by tools like [FluentAnalysis](https://github.com/Respect/FluentAnalysis)
193+
for static type narrowing.
186194

187195
```php
188-
use Respect\Fluent\Attributes\FluentNamespace;
189-
190-
// Simple lookup
191-
#[FluentNamespace(new NamespaceLookup(new Ucfirst(), null, 'App\\Handlers'))]
192-
193-
// With type validation
194-
#[FluentNamespace(new NamespaceLookup(new Ucfirst(), Handler::class, 'App\\Handlers'))]
195-
196-
// With prefix composition
197-
#[FluentNamespace(new ComposingLookup(
198-
new NamespaceLookup(new Ucfirst(), Validator::class, 'App\\Validators'),
199-
))]
200-
```
201-
202-
### Builders
203-
204-
Abstract base `FluentBuilder` provides `__call`, `__callStatic`, `getNodes()`,
205-
`withNamespace()`, `factoryFromAttribute()`, and the abstract `attach()` method.
206-
Two concrete builders:
207-
208-
**`Append`** — each `attach()` appends nodes to the end:
209-
210-
```php
211-
$builder = new Append($factory);
212-
$chain = $builder->cors()->auth('bearer');
213-
$chain->getNodes(); // [Cors(), Auth('bearer')]
214-
$chain->attach($manualNode); // add pre-built objects
215-
$chain->withNamespace('Extra\\Ns'); // prepend a search namespace
216-
```
217-
218-
**`Prepend`** — each `attach()` prepends nodes to the front:
219-
220-
```php
221-
$builder = new Prepend($factory);
222-
$chain = $builder->cors()->auth('bearer');
223-
$chain->getNodes(); // [Auth('bearer'), Cors()]
224-
```
225-
226-
Both are `readonly` and not `final`, extend them and add your domain methods.
227-
`__callStatic` calls `new static()` by default; override it if your subclass
228-
needs a different way to obtain a default instance.
229-
230-
### FluentFactory
231-
232-
Interface implemented by both factories:
196+
#[Assurance(type: 'int')]
197+
final readonly class IntType implements Validator { /* ... */ }
233198

234-
```php
235-
interface FluentFactory
199+
final readonly class ValidatorBuilder extends Append
236200
{
237-
public function create(string $name, array $arguments = []): object;
238-
public function withNamespace(string $namespace): static;
239-
}
240-
```
241-
242-
#### NamespaceLookup
243-
244-
The primary factory. Searches namespaces in order for a matching class.
245-
246-
```php
247-
$lookup = new NamespaceLookup(
248-
new Ucfirst(), // resolver: 'email' → 'Email'
249-
MyInterface::class, // optional type validation
250-
'App\\Handlers', // primary namespace
251-
'App\\Handlers\\Fallback', // fallback namespace
252-
);
253-
254-
$lookup->create('email', ['strict' => true]); // new App\Handlers\Email(strict: true)
255-
$lookup->resolve('email'); // ReflectionClass (without instantiating)
256-
```
257-
258-
The `$resolver` and `$namespaces` properties are `public private(set)`, you
259-
can read them (useful for tooling like FluentAnalysis) but not reassign them.
260-
261-
Immutable builders: `withNamespace()` prepends a namespace, `withNodeType()`
262-
adds type validation. Both return new instances.
263-
264-
#### ComposingLookup
265-
266-
Wraps a `NamespaceLookup` to handle prefix composition. When the resolver
267-
produces a wrapper FluentNode, ComposingLookup creates the inner instance
268-
first, then wraps it. Supports recursive unwrapping for nested wrappers.
269-
270-
```php
271-
$nested = new ComposingLookup($lookup); // defaults to ComposableAttributes
272-
$nested->create('notEmail'); // Not(Email())
273-
```
274-
275-
You can pass a custom resolver as the second argument if you don't want
276-
automatic `#[Composable]` attribute discovery:
277-
278-
```php
279-
$nested = new ComposingLookup($lookup, new ComposableMap(
280-
composable: ['not' => true],
281-
));
282-
```
283-
284-
### FluentResolver
201+
#[AssuranceAssertion]
202+
public function assert(#[AssuranceParameter] mixed $input): void { /* ... */ }
285203

286-
Interface for name transformers. Each resolver can `resolve` a method name
287-
into a class name, and `unresolve` it back:
288-
289-
```php
290-
interface FluentResolver
291-
{
292-
public function resolve(FluentNode $nodeSpec): FluentNode;
293-
public function unresolve(FluentNode $nodeSpec): FluentNode;
294-
}
295-
```
296-
297-
The `unresolve` method is the inverse of `resolve`: it converts a class name
298-
back to the method name that would produce it. This is used by FluentAnalysis
299-
to derive method maps from discovered classes.
300-
301-
#### Ucfirst
302-
303-
Capitalizes the first letter: `'email'``'Email'`.
304-
Unresolve does the opposite: `'Email'``'email'`.
305-
306-
#### Suffix
307-
308-
Strips a prefix and appends a suffix: `Suffix('of', 'Handler')` turns
309-
`'ofArray'``'ArrayHandler'`.
310-
Unresolve reverses it: `'ArrayHandler'``'ofArray'`.
311-
312-
#### Composable (attribute)
313-
314-
A PHP attribute that marks a class as a prefix wrapper for composition.
315-
Constraints (`without`, `with`, `optIn`) are enforced at resolve time:
316-
317-
```php
318-
#[Composable('not', without: ['not'])] // prevents notNot()
319-
final readonly class Not implements Validator
320-
{
321-
public function __construct(private Validator $validator) {}
204+
#[AssuranceAssertion]
205+
public function isValid(#[AssuranceParameter] mixed $input): bool { /* ... */ }
322206
}
323207
```
324208

325-
Attribute properties:
326-
327-
| Property | Type | Purpose |
328-
|-------------------|----------|-------------------------------------------------|
329-
| `prefix` | `string` | Registers this class as a composition prefix |
330-
| `prefixParameter` | `bool` | First argument goes to the wrapper |
331-
| `optIn` | `bool` | Only compose with prefixes listed in `with` |
332-
| `without` | `array` | Prefixes this class should not be composed with |
333-
| `with` | `array` | Prefixes this class should be composed with |
334-
335-
#### ComposableAttributes
336-
337-
Discovers `#[Composable]` attributes at runtime and decomposes prefixed names:
338-
`'notEmail'``FluentNode('Email', wrapper: FluentNode('Not'))`.
339-
340-
```php
341-
$resolver = new ComposableAttributes($lookup);
342-
```
343-
344-
Caches prefix discoveries, suffix constraints, and negative lookups for
345-
performance. Unresolve flattens wrapper structures back to flat names.
346-
347-
#### ComposableMap
348-
349-
Pre-built resolver using a compiled prefix map instead of runtime discovery.
350-
Useful for code-generated setups where all prefixes are known ahead of time:
351-
352-
```php
353-
$resolver = new ComposableMap(
354-
composable: ['not' => true, 'nullOr' => true],
355-
composableWithArgument: ['key' => true],
356-
forbidden: ['Not' => ['not' => true]], // suffix => [prefix => true]
357-
);
358-
```
359-
360-
### FluentNode
361-
362-
Readonly data class carrying resolution state:
363-
364-
```php
365-
new FluentNode(
366-
name: 'Email',
367-
arguments: ['strict' => true],
368-
wrapper: new FluentNode('Not'), // optional
369-
);
370-
```
371-
372-
### Exceptions
373-
374-
All exceptions implement `FluentException` (a `Throwable` marker interface),
375-
so you can catch all Fluent errors with a single type:
376-
377-
```php
378-
use Respect\Fluent\Exceptions\FluentException;
209+
See `Assurance`, `AssuranceParameter`, `ComposableParameter`, and the enum
210+
types in the [API reference](docs/api.md#assurance) for the full set of options.
379211

380-
try {
381-
$factory->create('nonExistent');
382-
} catch (FluentException $e) {
383-
// ...
384-
}
385-
```
386-
387-
| Exception | Parent | Thrown when |
388-
|-------------------|----------------------------|------------------------------------------------|
389-
| `CouldNotResolve` | `InvalidArgumentException` | Name not found in any registered namespace |
390-
| `CouldNotCreate` | `InvalidArgumentException` | Instantiation failed or type validation failed |
212+
## API Reference
391213

392-
Both extend `InvalidArgumentException` for backwards compatibility.
214+
See [docs/api.md](docs/api.md) for the complete API reference covering
215+
attributes, builders, factories, resolvers, and exceptions.

0 commit comments

Comments
 (0)