Skip to content

Commit 93b2056

Browse files
committed
Add FluentNamespace attribute, unresolve() support
Introduce #[FluentNamespace] attribute as a single source of truth for builder factory configuration, read by both runtime (factoryFromAttribute()) and in the future static analysis tools. Add unresolve() to FluentResolver so class names can be mapped back to method names. Simplify ComposingLookup to default to ComposableAttributes when no resolver is provided. Expose NamespaceLookup properties for tooling access.
1 parent 41861e2 commit 93b2056

14 files changed

Lines changed: 204 additions & 63 deletions

README.md

Lines changed: 95 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,8 @@ $stack = Middleware::cors('*')
1515
$stack->getNodes(); // [Cors('*'), RateLimit(100), Auth('bearer'), JsonBody()]
1616
```
1717

18-
Middlewares, validators, processors. Anything that has potential for chain
19-
composability could leverage respect/fluent.
18+
Middlewares, validators, processors: anything that composes well as a chain
19+
can leverage Respect/Fluent.
2020

2121
## Installation
2222

@@ -28,10 +28,8 @@ composer require respect/fluent
2828

2929
### 1. Choose a namespace and interface
3030

31-
Fluent uses classes from one or more namespaces, and they all must share
32-
a single interface.
33-
34-
For example:
31+
Fluent discovers classes from one or more namespaces. Giving them a shared
32+
interface lets your builder enforce type safety and expose domain methods.
3533

3634
```php
3735
namespace App\Middleware;
@@ -57,29 +55,25 @@ final readonly class RateLimit implements Middleware
5755

5856
### 2. Extend FluentBuilder
5957

60-
The `__call` method, immutable accumulation, and `withNamespace` support are
61-
inherited. You only add domain logic:
58+
The `#[FluentNamespace]` attribute declares where your classes live and how to
59+
resolve them. The builder inherits `__call`, immutable accumulation, and
60+
`withNamespace` support, you only add domain logic:
6261

6362
```php
6463
namespace App;
6564

65+
use Respect\Fluent\Attributes\FluentNamespace;
6666
use Respect\Fluent\Builders\Append;
6767
use Respect\Fluent\Factories\NamespaceLookup;
6868
use Respect\Fluent\Resolvers\Ucfirst;
6969
use App\Middleware\Middleware;
7070

71+
#[FluentNamespace(new NamespaceLookup(new Ucfirst(), Middleware::class, 'App\\Middleware'))]
7172
final readonly class MiddlewareStack extends Append
7273
{
7374
public function __construct(Middleware ...$layers)
7475
{
75-
parent::__construct(
76-
new NamespaceLookup(
77-
new Ucfirst(), // fooBar -> new FooBar
78-
Middleware::class, // FooBar implements Middleware
79-
'App\\Middleware' // App\Middleware\FooBar
80-
),
81-
...$layers,
82-
);
76+
parent::__construct(static::factoryFromAttribute(), ...$layers);
8377
}
8478

8579
/** @return array<int, Middleware> */
@@ -90,15 +84,20 @@ final readonly class MiddlewareStack extends Append
9084
}
9185
```
9286

93-
That's it. `MiddlewareStack::cors()->auth('bearer')->jsonBody()` then
94-
builds the layers for you.
87+
The attribute carries the full factory configuration: the resolver (`Ucfirst`),
88+
optional type constraint (`Middleware::class`), and namespace to search. The
89+
inherited `factoryFromAttribute()` reads it at runtime so there's a single
90+
source of truth.
91+
92+
Now `MiddlewareStack::cors()->auth('bearer')->jsonBody()` builds the
93+
layers for you.
9594

9695
### 3. Add composition if you want
9796

9897
Prefix composition lets `optionalAuth()` create `Optional(Auth())`. You're
99-
not limited to `Optional` cases, you can design nesting as much as you want.
98+
not limited to `Optional` cases, you can design nesting as deep as you want.
10099

101-
Annotate wrapper classes with `#[Composable]` and use `ComposingLookup`:
100+
Annotate wrapper classes with `#[Composable]`:
102101

103102
```php
104103
namespace App\Middleware;
@@ -120,17 +119,16 @@ final readonly class Optional implements Middleware
120119
}
121120
```
122121

123-
Update the constructor to use `ComposingLookup`:
122+
Then switch the attribute to use `ComposingLookup`, it automatically discovers
123+
`#[Composable]` prefixes from the same namespace:
124124

125125
```php
126126
use Respect\Fluent\Factories\ComposingLookup;
127-
use Respect\Fluent\Resolvers\ComposableAttributes;
128127

129-
$flat = new NamespaceLookup(new Ucfirst(), Middleware::class, 'App\\Middleware');
130-
parent::__construct(
131-
new ComposingLookup($flat, new ComposableAttributes($flat)),
132-
...$layers,
133-
);
128+
#[FluentNamespace(new ComposingLookup(
129+
new NamespaceLookup(new Ucfirst(), Middleware::class, 'App\\Middleware'),
130+
))]
131+
final readonly class MiddlewareStack extends Append { /* ... */ }
134132
```
135133

136134
Now `MiddlewareStack::optionalAuth('bearer')` creates `Optional(Auth('bearer'))`.
@@ -174,16 +172,38 @@ a name, constructor arguments, and an optional wrapper.
174172
```
175173

176174
**NamespaceLookup vs ComposingLookup:** use `NamespaceLookup` for simple
177-
nameclass mapping. Add `ComposingLookup` when you need prefix composition
178-
like `notEmail()``Not(Email())`. `ComposingLookup` supports recursive
179-
unwrapping, so `notNullOrEmail()``Not(NullOr(Email()))` works too.
175+
name-to-class mapping. Wrap it with `ComposingLookup` when you need prefix
176+
composition like `notEmail()``Not(Email())`. `ComposingLookup` supports
177+
recursive unwrapping, so `notNullOrEmail()``Not(NullOr(Email()))` works too.
180178

181179
## API Reference
182180

181+
### FluentNamespace (attribute)
182+
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:
186+
187+
```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+
183202
### Builders
184203

185204
Abstract base `FluentBuilder` provides `__call`, `__callStatic`, `getNodes()`,
186-
`withNamespace()`, and the abstract `attach()` method. Two concrete builders:
205+
`withNamespace()`, `factoryFromAttribute()`, and the abstract `attach()` method.
206+
Two concrete builders:
187207

188208
**`Append`** — each `attach()` appends nodes to the end:
189209

@@ -203,7 +223,7 @@ $chain = $builder->cors()->auth('bearer');
203223
$chain->getNodes(); // [Auth('bearer'), Cors()]
204224
```
205225

206-
Both are `readonly` and not `final` extend them and add your domain methods.
226+
Both are `readonly` and not `final`, extend them and add your domain methods.
207227
`__callStatic` calls `new static()` by default; override it if your subclass
208228
needs a different way to obtain a default instance.
209229

@@ -235,40 +255,59 @@ $lookup->create('email', ['strict' => true]); // new App\Handlers\Email(strict:
235255
$lookup->resolve('email'); // ReflectionClass (without instantiating)
236256
```
237257

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+
238261
Immutable builders: `withNamespace()` prepends a namespace, `withNodeType()`
239262
adds type validation. Both return new instances.
240263

241264
#### ComposingLookup
242265

243-
Wraps a `NamespaceLookup` + `FluentResolver` to handle prefix composition. When
244-
the resolver produces a wrapper FluentNode, ComposingLookup creates the inner
245-
instance first, then wraps it. Supports recursive unwrapping for nested
246-
wrappers.
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.
247269

248270
```php
249-
$nested = new ComposingLookup($lookup, new ComposableAttributes($lookup));
250-
$nested->create('notEmail'); // Not(Email())
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+
));
251282
```
252283

253284
### FluentResolver
254285

255-
Interface for name transformers:
286+
Interface for name transformers. Each resolver can `resolve` a method name
287+
into a class name, and `unresolve` it back:
256288

257289
```php
258290
interface FluentResolver
259291
{
260292
public function resolve(FluentNode $nodeSpec): FluentNode;
293+
public function unresolve(FluentNode $nodeSpec): FluentNode;
261294
}
262295
```
263296

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+
264301
#### Ucfirst
265302

266303
Capitalizes the first letter: `'email'``'Email'`.
304+
Unresolve does the opposite: `'Email'``'email'`.
267305

268306
#### Suffix
269307

270308
Strips a prefix and appends a suffix: `Suffix('of', 'Handler')` turns
271309
`'ofArray'``'ArrayHandler'`.
310+
Unresolve reverses it: `'ArrayHandler'``'ofArray'`.
272311

273312
#### Composable (attribute)
274313

@@ -283,6 +322,16 @@ final readonly class Not implements Validator
283322
}
284323
```
285324

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+
286335
#### ComposableAttributes
287336

288337
Discovers `#[Composable]` attributes at runtime and decomposes prefixed names:
@@ -293,22 +342,12 @@ $resolver = new ComposableAttributes($lookup);
293342
```
294343

295344
Caches prefix discoveries, suffix constraints, and negative lookups for
296-
performance.
297-
298-
Attribute properties:
299-
300-
| Property | Type | Purpose |
301-
|---|---|---|
302-
| `prefix` | `string` | Registers this class as a composition prefix |
303-
| `prefixParameter` | `bool` | First argument goes to the wrapper |
304-
| `optIn` | `bool` | Only compose with prefixes listed in `with` |
305-
| `without` | `array` | Prefixes this class should not be composed with |
306-
| `with` | `array` | Prefixes this class should be composed with |
345+
performance. Unresolve flattens wrapper structures back to flat names.
307346

308347
#### ComposableMap
309348

310349
Pre-built resolver using a compiled prefix map instead of runtime discovery.
311-
Ideal for code-generated setups where all prefixes are known ahead of time:
350+
Useful for code-generated setups where all prefixes are known ahead of time:
312351

313352
```php
314353
$resolver = new ComposableMap(
@@ -333,7 +372,7 @@ new FluentNode(
333372
### Exceptions
334373

335374
All exceptions implement `FluentException` (a `Throwable` marker interface),
336-
so consumers can catch all Fluent errors with a single type:
375+
so you can catch all Fluent errors with a single type:
337376

338377
```php
339378
use Respect\Fluent\Exceptions\FluentException;
@@ -345,9 +384,9 @@ try {
345384
}
346385
```
347386

348-
| Exception | Parent | Thrown when |
349-
|---|---|---|
350-
| `CouldNotResolve` | `InvalidArgumentException` | Name not found in any registered namespace |
351-
| `CouldNotCreate` | `InvalidArgumentException` | Instantiation failed or type validation failed |
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 |
352391

353392
Both extend `InvalidArgumentException` for backwards compatibility.

src/Attributes/FluentNamespace.php

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
<?php
2+
3+
/*
4+
* SPDX-License-Identifier: ISC
5+
* SPDX-FileCopyrightText: (c) Respect Project Contributors
6+
* SPDX-FileContributor: Alexandre Gomes Gaigalas <alganet@gmail.com>
7+
*/
8+
9+
declare(strict_types=1);
10+
11+
namespace Respect\Fluent\Attributes;
12+
13+
use Attribute;
14+
use Respect\Fluent\FluentFactory;
15+
16+
#[Attribute(Attribute::TARGET_CLASS)]
17+
final readonly class FluentNamespace
18+
{
19+
public function __construct(
20+
public FluentFactory $factory,
21+
) {
22+
}
23+
}

src/Builders/Append.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212

1313
use function array_values;
1414

15+
/** @extends FluentBuilder<list<object>> */
1516
readonly class Append extends FluentBuilder
1617
{
1718
public function attach(object ...$nodes): static

src/Builders/FluentBuilder.php

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,16 @@
1010

1111
namespace Respect\Fluent\Builders;
1212

13+
use ReflectionClass;
14+
use Respect\Fluent\Attributes\FluentNamespace;
1315
use Respect\Fluent\FluentFactory;
1416

1517
use function array_values;
1618

19+
/** @template TNodes of list<object> */
1720
abstract readonly class FluentBuilder
1821
{
19-
/** @var array<int, object> */
22+
/** @var list<object> */
2023
protected array $nodes;
2124

2225
public function __construct(
@@ -28,7 +31,7 @@ public function __construct(
2831

2932
abstract public function attach(object ...$nodes): static;
3033

31-
/** @return array<int, object> */
34+
/** @return list<object> */
3235
public function getNodes(): array
3336
{
3437
return $this->nodes;
@@ -39,6 +42,14 @@ public function withNamespace(string $namespace): static
3942
return clone ($this, ['factory' => $this->factory->withNamespace($namespace)]);
4043
}
4144

45+
protected static function factoryFromAttribute(): FluentFactory
46+
{
47+
return (new ReflectionClass(static::class))
48+
->getAttributes(FluentNamespace::class)[0]
49+
->newInstance()
50+
->factory;
51+
}
52+
4253
/** @param array<int|string, mixed> $arguments */
4354
public function __call(string $name, array $arguments): static
4455
{

src/Builders/Prepend.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212

1313
use function array_values;
1414

15+
/** @extends FluentBuilder<list<object>> */
1516
readonly class Prepend extends FluentBuilder
1617
{
1718
public function attach(object ...$nodes): static

0 commit comments

Comments
 (0)