Skip to content

Commit dc7e39d

Browse files
committed
Replace string-based references with targeted parameter attributes
- Use AssuranceParameter to detect assurance constructor params and assertion method inputs instead of string-based $parameter and $input properties - Use ComposableParameter to detect prefix parameters instead of $prefixParameter boolean - Derive prefix strings via FluentResolver::unresolve() instead of hardcoded lcfirst() - Change composeRange from string to array{int, int|null} throughout the analysis pipeline (MethodMapBuilder, CacheGenerator, extensions, NEON schema) - Add AssuranceMap, FluentTypeSpecifyingExtension, TypeStringResolver for PHPStan type narrowing - Add ExtractsAssurance trait for shared assurance resolution - Update NEON schema to accept int and array values in assurance entries - Comprehensive test coverage for all new functionality
1 parent 0ef76f3 commit dc7e39d

37 files changed

+2099
-84
lines changed

README.md

Lines changed: 72 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
# Respect\FluentAnalysis
22

33
PHPStan extension for [Respect/Fluent](https://github.com/Respect/Fluent) builders.
4-
Provides method resolution, parameter validation, and tuple-typed `getNodes()`
5-
without generated code.
4+
Provides method resolution, parameter validation, tuple-typed `getNodes()`, and
5+
type narrowing through assertion methods — without generated code.
66

77
Fluent builders use `__call` to resolve method names to class instances. Since
88
those methods don't exist as real declarations, PHPStan reports errors and can't
@@ -115,9 +115,52 @@ For builders using Respect/Fluent's composable prefixes (like Validation's
115115
`notEmail()`, `nullOrStringType()`), the extension resolves composed methods
116116
with correct parameter signatures.
117117

118+
### Type narrowing
119+
120+
Builders can narrow the type of a variable through assertion methods. Node
121+
classes declare their assurance via the `#[Assurance]` attribute, assertion
122+
methods are marked with `#[AssuranceAssertion]`, and `#[AssuranceParameter]`
123+
identifies the validated parameter and constructor parameters used for type
124+
resolution.
125+
126+
Void assertion methods narrow unconditionally:
127+
128+
```php
129+
$builder->intNode()->doAssert($x);
130+
// PHPStan now knows $x is int
131+
```
132+
133+
Bool assertion methods work as type guards:
134+
135+
```php
136+
if ($builder->intNode()->isOk($x)) {
137+
// $x is int here
138+
}
139+
// $x is not int here
140+
```
141+
142+
Chained nodes intersect their assurances:
143+
144+
```php
145+
$builder->intNode()->numericNode()->doAssert($x);
146+
// int ∩ (int|float|numeric-string) = int
147+
```
148+
149+
The extension supports several assurance modes through the `#[Assurance]`
150+
attribute:
151+
152+
- **`type`** — a fixed type string (e.g. `int`, `float|int|numeric-string`)
153+
- **`#[AssuranceParameter]`** — the type is taken from a constructor parameter
154+
annotated with the attribute (e.g. a class-string parameter)
155+
- **`from: value`** — narrows to the argument's literal type
156+
- **`from: member`** — narrows to the iterable value type of the argument
157+
- **`from: elements`** — narrows to an array of the inner assurance type
158+
- **`compose: union|intersect`** — combines assurances from multiple builder
159+
arguments
160+
118161
## How it works
119162

120-
The extension registers two PHPStan hooks:
163+
The extension registers three PHPStan hooks:
121164

122165
1. **`FluentMethodsExtension`** (`MethodsClassReflectionExtension`) — tells
123166
PHPStan which methods exist on each builder, with parameters extracted from
@@ -127,27 +170,35 @@ The extension registers two PHPStan hooks:
127170
`DynamicStaticMethodReturnTypeExtension`) — intercepts each method call to
128171
track accumulated node types as a `GenericObjectType` wrapping a
129172
`ConstantArrayType` tuple. When `getNodes()` is called, the tuple is
130-
returned directly.
173+
returned directly. Also accumulates assurance types through the chain.
174+
175+
3. **`FluentTypeSpecifyingExtension`** (`MethodTypeSpecifyingExtension`) —
176+
enables type narrowing in control flow. When a builder's assertion method
177+
is called, accumulated assurances are applied to narrow the input variable's
178+
type. Supports void assertions (unconditional) and bool guards (conditional).
131179

132-
Both extensions share a `MethodMap` that resolves method names to target
133-
class FQCNs, with parent-class fallback for builder inheritance.
180+
The extensions share a `MethodMap` for method resolution and an `AssuranceMap`
181+
for type narrowing configuration, both with parent-class fallback for builder
182+
inheritance.
134183

135184
The `generate` command reads the `#[FluentNamespace]` attribute from each
136185
builder, extracts the factory's resolver and namespaces, discovers classes,
137186
and uses `FluentResolver::unresolve()` to derive method names from class names.
138187

139-
## vs. `@mixin`-style interfaces
140-
141-
| | FluentAnalysis | `@mixin` |
142-
|---------------------|-------------------------------------|--------------------------------------|
143-
| Generated files | None (one small neon cache) | Interface files per builder + prefix |
144-
| Return type | `Builder<array{A, B, C}>` | `Builder` (via `@mixin`) |
145-
| `getNodes()` type | `array{A, B, C}` (exact tuple) | `array<int, Node>` (generic) |
146-
| Element access | `$nodes[0]` typed as `A` | `mixed` |
147-
| Deprecation | Forwarded automatically | Must regenerate |
148-
| Composable prefixes | Resolved from cache | Full method signatures |
149-
| IDE support | PHPStan-powered (PhpStorm, VS Code) | Direct IDE autocomplete |
150-
| Maintenance | Re-run `generate` on class changes | Manual/generated |
151-
152-
Both approaches work. Use FluentAnalysis for precise type tracking. Use `@mixin`s
153-
for broader IDE autocomplete without PHPStan.
188+
## FluentAnalysis vs FluentGen
189+
190+
Another similar project is [FluentGen](https://github.com/Respect/FluentGen).
191+
192+
Both are complementary, offering IDE support and type inference as separate packages.
193+
194+
| | FluentAnalysis | FluentGen |
195+
|---------------------|--------------------------------------|--------------------------------------|
196+
| Generated files | None (one small neon cache) | Interface files per builder + prefix |
197+
| Return type | `Builder<array{A, B, C}>` | `Builder` (via `@mixin`) |
198+
| `getNodes()` type | `array{A, B, C}` (exact tuple) | `array<int, Node>` (generic) |
199+
| Element access | `$nodes[0]` typed as `A` | `mixed` |
200+
| Deprecation | Forwarded automatically | Must regenerate |
201+
| Composable prefixes | Resolved from cache | Full method signatures |
202+
| Type narrowing | Assertion methods narrow input types | Not supported |
203+
| IDE support | PHPStan-powered (PhpStorm, VS Code) | Direct IDE autocomplete |
204+
| Maintenance | Re-run `generate` on class changes | Manual/generated |

composer.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
"require": {
77
"php": "^8.5",
88
"phpstan/phpstan": "^2.1",
9-
"respect/fluent": "^1.0",
9+
"respect/fluent": "^2.0",
1010
"symfony/console": "^6.0|^7.0"
1111
},
1212
"require-dev": {

composer.lock

Lines changed: 11 additions & 11 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

extension.neon

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
11
parameters:
22
fluent:
33
methods: []
4+
assurances: []
5+
assertions: []
46

57
parametersSchema:
68
fluent: structure([
7-
methods: arrayOf(arrayOf(string()))
9+
methods: arrayOf(arrayOf(string())),
10+
assurances: arrayOf(arrayOf(arrayOf(anyOf(string(), int(), listOf(anyOf(int(), type('null'))))))),
11+
assertions: arrayOf(listOf(string()))
812
])
913

1014
services:
@@ -13,6 +17,12 @@ services:
1317
arguments:
1418
methods: %fluent.methods%
1519

20+
fluentAssuranceMap:
21+
class: Respect\FluentAnalysis\AssuranceMap
22+
arguments:
23+
assurances: %fluent.assurances%
24+
assertions: %fluent.assertions%
25+
1626
-
1727
class: Respect\FluentAnalysis\FluentMethodsExtension
1828
arguments:
@@ -24,6 +34,16 @@ services:
2434
class: Respect\FluentAnalysis\FluentDynamicReturnTypeExtension
2535
arguments:
2636
methodMap: @fluentMethodMap
37+
assuranceMap: @fluentAssuranceMap
38+
targetClass: Respect\Fluent\Builders\FluentBuilder
2739
tags:
2840
- phpstan.broker.dynamicMethodReturnTypeExtension
2941
- phpstan.broker.dynamicStaticMethodReturnTypeExtension
42+
43+
-
44+
class: Respect\FluentAnalysis\FluentTypeSpecifyingExtension
45+
arguments:
46+
assuranceMap: @fluentAssuranceMap
47+
targetClass: Respect\Fluent\Builders\FluentBuilder
48+
tags:
49+
- phpstan.typeSpecifier.methodTypeSpecifyingExtension

phpcs.xml.dist

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,10 @@
1717
<!-- PHPStan fixtures require global functions and assertType strings use long FQCNs -->
1818
<rule ref="Squiz.Functions.GlobalFunction.Found">
1919
<exclude-pattern>tests/fixtures/</exclude-pattern>
20+
<exclude-pattern>tests/assertions/</exclude-pattern>
21+
</rule>
22+
<rule ref="Squiz.Commenting.FunctionComment.WrongStyle">
23+
<exclude-pattern>tests/assertions/</exclude-pattern>
2024
</rule>
2125
<rule ref="Generic.Files.LineLength.TooLong">
2226
<exclude-pattern>tests/assertions/</exclude-pattern>

phpunit.xml.dist

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,11 @@
2121
<directory>src</directory>
2222
</include>
2323
<exclude>
24-
<!-- Runs inside PHPStan's DI container; not reachable by Xdebug.
24+
<!-- Run inside PHPStan's DI container; not reachable by Xdebug.
2525
Covered by Integration/TypeInference assertType tests. -->
2626
<file>src/FluentDynamicReturnTypeExtension.php</file>
27+
<file>src/FluentTypeSpecifyingExtension.php</file>
28+
<file>src/ExtractsAssurance.php</file>
2729
</exclude>
2830
</source>
2931
</phpunit>

src/AssuranceMap.php

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
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\FluentAnalysis;
12+
13+
use PHPStan\Reflection\ClassReflection;
14+
15+
use function in_array;
16+
17+
/**
18+
* @phpstan-type AssuranceEntry array{
19+
* type?: string,
20+
* parameterIndex?: int,
21+
* from?: string,
22+
* compose?: string,
23+
* composeRange?: array{int, int|null},
24+
* wrapperModifier?: string,
25+
* }
26+
*/
27+
final readonly class AssuranceMap
28+
{
29+
/**
30+
* @param array<string, array<string, AssuranceEntry>> $assurances
31+
* @param array<string, list<string>> $assertions
32+
*/
33+
public function __construct(
34+
private array $assurances = [],
35+
private array $assertions = [],
36+
) {
37+
}
38+
39+
/** @return AssuranceEntry|null */
40+
public function resolveAssurance(ClassReflection $classReflection, string $methodName): array|null
41+
{
42+
$className = $classReflection->getName();
43+
44+
if (isset($this->assurances[$className][$methodName])) {
45+
return $this->assurances[$className][$methodName];
46+
}
47+
48+
foreach ($this->assurances as $registeredClass => $methods) {
49+
if ($classReflection->is($registeredClass) && isset($methods[$methodName])) {
50+
return $methods[$methodName];
51+
}
52+
}
53+
54+
return null;
55+
}
56+
57+
public function isAssertionMethod(ClassReflection $classReflection, string $methodName): bool
58+
{
59+
$className = $classReflection->getName();
60+
61+
if (isset($this->assertions[$className])) {
62+
return in_array($methodName, $this->assertions[$className], true);
63+
}
64+
65+
foreach ($this->assertions as $registeredClass => $methods) {
66+
if ($classReflection->is($registeredClass) && in_array($methodName, $methods, true)) {
67+
return true;
68+
}
69+
}
70+
71+
return false;
72+
}
73+
}

0 commit comments

Comments
 (0)