Skip to content

Commit de5c240

Browse files
committed
Add AssuranceTypeMapper for IDE-readable type narrowing
Generate IDE-readable narrowing PHPDoc from a node's #[Assurance] / fluent mixins narrow types after assert()/check() without a PHPStan extension. - New AssuranceTypeMapper maps each assurance shape to a Chain<T> generic: concrete types, argument/element templates, Container subjects, and the Wrap/Container/Elements prefix compositions (with union de-duplication). Argument-wrapping forms stay Chain<mixed> to avoid retyping Validator parameters to Chain<T> (which would reject raw, non-fluent validators). - New NarrowingDoc and TerminalMethod value objects; InterfaceConfig gains emitNarrowing / chainType / templateParam / terminalMethods, threaded through MethodBuilder and MixinGenerator. - Unit coverage per bucket plus a MixinGenerator narrowing integration test.
1 parent 6a90655 commit de5c240

26 files changed

Lines changed: 1138 additions & 12 deletions

.gitattributes

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
/* export-ignore
2+
3+
# Project files
4+
/README.md -export-ignore
5+
/composer.json -export-ignore
6+
/src -export-ignore
7+
8+
# SBOM information
9+
/LICENSE -export-ignore
10+
/LICENSES -export-ignore
11+
/REUSE.toml -export-ignore

composer.json

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
"keywords": ["respect", "fluentgen", "mixin", "fluent"],
55
"type": "library",
66
"license": "ISC",
7+
"minimum-stability": "dev",
8+
"prefer-stable": true,
79
"authors": [
810
{
911
"name": "Respect/FluentGen Contributors",
@@ -20,7 +22,7 @@
2022
"phpstan/phpstan-strict-rules": "^2.0",
2123
"phpunit/phpunit": "^12.5",
2224
"respect/coding-standard": "^5.0",
23-
"respect/fluent": "^2.0"
25+
"respect/fluent": "3.0.x-dev"
2426
},
2527
"suggest": {
2628
"respect/fluent": "Enables #[Composable] prefix composition support"
@@ -50,5 +52,10 @@
5052
"allow-plugins": {
5153
"dealerdirect/phpcodesniffer-composer-installer": true
5254
}
55+
},
56+
"extra": {
57+
"branch-alias": {
58+
"dev-ide-narrowing": "2.1.x-dev"
59+
}
5360
}
5461
}

src/Fluent/AssuranceTypeMapper.php

Lines changed: 306 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,306 @@
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\FluentGen\Fluent;
12+
13+
use ReflectionClass;
14+
use Respect\Fluent\Attributes\Assurance;
15+
use Respect\Fluent\Attributes\AssuranceFrom;
16+
use Respect\Fluent\Attributes\AssuranceModifier;
17+
use Respect\Fluent\Attributes\AssuranceParameter;
18+
use Respect\Fluent\Attributes\AssuranceSubject;
19+
use Respect\Fluent\Attributes\AssuranceSubjectMode;
20+
21+
use function array_map;
22+
use function ctype_digit;
23+
use function explode;
24+
use function implode;
25+
use function in_array;
26+
use function is_array;
27+
use function ltrim;
28+
use function str_contains;
29+
use function trim;
30+
31+
/**
32+
* Derives the IDE-readable narrowing PHPDoc for a generated method from a node's
33+
* #[Assurance] / #[AssuranceSubject] / #[AssuranceParameter] attributes.
34+
*/
35+
final readonly class AssuranceTypeMapper
36+
{
37+
private const array BUILTINS = [
38+
'int',
39+
'float',
40+
'string',
41+
'bool',
42+
'array',
43+
'object',
44+
'callable',
45+
'iterable',
46+
'mixed',
47+
'null',
48+
'void',
49+
'false',
50+
'true',
51+
'resource',
52+
'scalar',
53+
'numeric-string',
54+
'non-empty-string',
55+
'class-string',
56+
'positive-int',
57+
'negative-int',
58+
];
59+
60+
public function __construct(
61+
private string $chainType = 'Chain',
62+
private string $templateParam = 'TSure',
63+
) {
64+
}
65+
66+
/**
67+
* @param ReflectionClass<object> $rule
68+
* @param ReflectionClass<object>|null $prefix the composing prefix when building a prefixed method
69+
*/
70+
public function for(ReflectionClass $rule, bool $static, ReflectionClass|null $prefix = null): NarrowingDoc
71+
{
72+
if (!$static) {
73+
$element = $prefix === null ? $this->elementDoc($rule) : null;
74+
if ($element !== null) {
75+
return $element;
76+
}
77+
78+
return $this->ret($prefix !== null ? 'mixed' : $this->templateParam);
79+
}
80+
81+
if ($prefix !== null) {
82+
return $this->forPrefixed($rule, $prefix);
83+
}
84+
85+
return $this->forBase($rule);
86+
}
87+
88+
/**
89+
* The element-extraction form for an each()/all()-style rule (from: Elements), or null.
90+
*
91+
* @param ReflectionClass<object> $rule
92+
*/
93+
private function elementDoc(ReflectionClass $rule): NarrowingDoc|null
94+
{
95+
if ($this->assuranceOf($rule)?->from !== AssuranceFrom::Elements) {
96+
return null;
97+
}
98+
99+
return new NarrowingDoc([
100+
'@template T',
101+
'@param ' . $this->chainType . '<T> $' . $this->firstParameterName($rule),
102+
'@return ' . $this->chainType . '<iterable<T>>',
103+
], suppressConstructorDoc: true);
104+
}
105+
106+
/** @param ReflectionClass<object> $rule */
107+
private function forBase(ReflectionClass $rule): NarrowingDoc
108+
{
109+
$assurance = $this->assuranceOf($rule);
110+
if ($assurance === null) {
111+
return $this->ret('mixed');
112+
}
113+
114+
$subject = $this->subjectOf($rule);
115+
if ($subject?->mode === AssuranceSubjectMode::Wrap) {
116+
return $this->ret('mixed');
117+
}
118+
119+
$parameterName = $this->assuranceParameterName($rule);
120+
if ($parameterName !== null) {
121+
return new NarrowingDoc([
122+
'@template T of object',
123+
'@param class-string<T> $' . $parameterName,
124+
'@return ' . $this->chainType . '<T>',
125+
], suppressConstructorDoc: true);
126+
}
127+
128+
if ($assurance->from === AssuranceFrom::Value) {
129+
return new NarrowingDoc([
130+
'@template T',
131+
'@param T $' . $this->firstParameterName($rule),
132+
'@return ' . $this->chainType . '<T>',
133+
], suppressConstructorDoc: true);
134+
}
135+
136+
$element = $this->elementDoc($rule);
137+
if ($element !== null) {
138+
return $element;
139+
}
140+
141+
if (
142+
$assurance->from === AssuranceFrom::Member
143+
|| $assurance->compose !== null
144+
|| $assurance->modifier !== null
145+
) {
146+
return $this->ret('mixed');
147+
}
148+
149+
if ($assurance->type !== null) {
150+
return $this->ret($this->typeString($assurance->type));
151+
}
152+
153+
return $this->ret('mixed');
154+
}
155+
156+
/**
157+
* @param ReflectionClass<object> $rule
158+
* @param ReflectionClass<object> $prefix
159+
*/
160+
private function forPrefixed(ReflectionClass $rule, ReflectionClass $prefix): NarrowingDoc
161+
{
162+
$subject = $this->subjectOf($prefix);
163+
if ($subject?->mode === AssuranceSubjectMode::Elements) {
164+
$inner = $this->concreteTypeOf($rule);
165+
166+
return $this->ret($inner !== null ? 'iterable<' . $inner . '>' : 'iterable');
167+
}
168+
169+
if ($subject?->mode === AssuranceSubjectMode::Wrap) {
170+
$prefixAssurance = $this->assuranceOf($prefix);
171+
if ($prefixAssurance?->modifier === AssuranceModifier::Exclude) {
172+
return $this->ret('mixed');
173+
}
174+
175+
$bypass = $prefixAssurance?->type;
176+
$inner = $this->concreteTypeOf($rule);
177+
if ($inner !== null && $bypass !== null) {
178+
return $this->ret($this->union($inner, $this->typeString($bypass)));
179+
}
180+
181+
return $this->ret('mixed');
182+
}
183+
184+
if ($subject?->mode === AssuranceSubjectMode::Container) {
185+
$type = $this->assuranceOf($prefix)?->type;
186+
187+
return $type !== null ? $this->ret($this->typeString($type)) : $this->ret('mixed');
188+
}
189+
190+
return $this->ret('mixed');
191+
}
192+
193+
/**
194+
* The plain concrete type of a rule, or null when it is not a pure type rule.
195+
*
196+
* @param ReflectionClass<object> $rule
197+
*/
198+
private function concreteTypeOf(ReflectionClass $rule): string|null
199+
{
200+
$assurance = $this->assuranceOf($rule);
201+
if ($assurance?->type === null) {
202+
return null;
203+
}
204+
205+
if (
206+
$assurance->from !== null
207+
|| $assurance->compose !== null
208+
|| $assurance->modifier !== null
209+
|| $this->subjectOf($rule) !== null
210+
|| $this->assuranceParameterName($rule) !== null
211+
) {
212+
return null;
213+
}
214+
215+
return $this->typeString($assurance->type);
216+
}
217+
218+
private function ret(string $inner): NarrowingDoc
219+
{
220+
return new NarrowingDoc(['@return ' . $this->chainType . '<' . $inner . '>']);
221+
}
222+
223+
/**
224+
* Join one or more pipe-separated type strings into a single union, preserving order
225+
* and dropping duplicate members.
226+
*/
227+
private function union(string ...$types): string
228+
{
229+
$parts = [];
230+
foreach ($types as $type) {
231+
foreach (explode('|', $type) as $part) {
232+
$part = trim($part);
233+
if ($part === '' || in_array($part, $parts, true)) {
234+
continue;
235+
}
236+
237+
$parts[] = $part;
238+
}
239+
}
240+
241+
return implode('|', $parts);
242+
}
243+
244+
/** @param ReflectionClass<object> $rule */
245+
private function assuranceOf(ReflectionClass $rule): Assurance|null
246+
{
247+
$attributes = $rule->getAttributes(Assurance::class);
248+
249+
return $attributes === [] ? null : $attributes[0]->newInstance();
250+
}
251+
252+
/** @param ReflectionClass<object> $rule */
253+
private function subjectOf(ReflectionClass $rule): AssuranceSubject|null
254+
{
255+
$attributes = $rule->getAttributes(AssuranceSubject::class);
256+
257+
return $attributes === [] ? null : $attributes[0]->newInstance();
258+
}
259+
260+
/** @param ReflectionClass<object> $rule */
261+
private function assuranceParameterName(ReflectionClass $rule): string|null
262+
{
263+
foreach ($rule->getConstructor()?->getParameters() ?? [] as $param) {
264+
if ($param->getAttributes(AssuranceParameter::class) !== []) {
265+
return $param->getName();
266+
}
267+
}
268+
269+
return null;
270+
}
271+
272+
/** @param ReflectionClass<object> $rule */
273+
private function firstParameterName(ReflectionClass $rule): string
274+
{
275+
$parameters = $rule->getConstructor()?->getParameters() ?? [];
276+
277+
return $parameters === [] ? 'input' : $parameters[0]->getName();
278+
}
279+
280+
/** @param string|list<string> $type */
281+
private function typeString(string|array $type): string
282+
{
283+
$parts = is_array($type) ? $type : explode('|', $type);
284+
285+
return implode('|', array_map($this->qualify(...), $parts));
286+
}
287+
288+
private function qualify(string $segment): string
289+
{
290+
$segment = trim($segment);
291+
292+
if ($segment === '' || $segment[0] === "'" || $segment[0] === '-' || ctype_digit($segment[0])) {
293+
return $segment;
294+
}
295+
296+
if (str_contains($segment, '\\')) {
297+
return '\\' . ltrim($segment, '\\');
298+
}
299+
300+
if (in_array($segment, self::BUILTINS, true)) {
301+
return $segment;
302+
}
303+
304+
return '\\' . $segment;
305+
}
306+
}

src/Fluent/InterfaceConfig.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
/**
1616
* @param array<string> $rootExtends
1717
* @param array<string> $rootUses
18+
* @param array<TerminalMethod> $terminalMethods methods injected verbatim into the root interface
1819
*/
1920
public function __construct(
2021
public string $suffix,
@@ -23,6 +24,10 @@ public function __construct(
2324
public array $rootExtends = [],
2425
public string|null $rootComment = null,
2526
public array $rootUses = [],
27+
public bool $emitNarrowing = false,
28+
public string $chainType = 'Chain',
29+
public string|null $templateParam = null,
30+
public array $terminalMethods = [],
2631
) {
2732
}
2833
}

0 commit comments

Comments
 (0)