Skip to content

Commit 58513ea

Browse files
committed
added Registry::getMapper(), getAsset() and Mapper::getAsset() return type narrowing
1 parent 85268f6 commit 58513ea

13 files changed

+545
-0
lines changed

CLAUDE.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,22 @@ Extensions for specific Nette packages use dedicated namespaces: `Nette\PHPStan\
8585

8686
`AssertTypeNarrowingExtension` (`StaticMethodTypeSpecifyingExtension` + `TypeSpecifierAwareExtension`) narrows variable types after `Tester\Assert` assertion calls. Each assertion method is mapped to an equivalent PHP expression that PHPStan already understands, then delegated to `TypeSpecifier::specifyTypesInCondition()`. Supported methods: `null`, `notNull`, `true`, `false`, `truthy`, `falsey`, `same`, `notSame`, and `type` (with built-in type strings like `'string'`, `'int'`, etc. and class/interface names). Config: `extension-nette.neon`.
8787

88+
### MapperTypeResolver (Assets)
89+
90+
`MapperTypeResolver` is a shared service used by the three assets extensions below. It resolves mapper IDs to mapper class types from a `mapping` config using keywords (`'file'``FilesystemMapper`, `'vite'``ViteMapper`, or FQCN for custom classes), resolves asset references to asset class types based on file extension (mirroring `Helpers::createAssetFromUrl()` logic), parses qualified references (`'mapper:reference'`), and checks whether a mapper is a known type (`FilesystemMapper` or `ViteMapper`). Config: `extension-nette.neon` parameter `nette.assets.mapping`.
91+
92+
### GetMapperReturnTypeExtension
93+
94+
`GetMapperReturnTypeExtension` (`DynamicMethodReturnTypeExtension`) narrows return type of `Registry::getMapper()` from `Mapper` to the specific mapper class based on NEON configuration. When no argument is passed, uses `'default'` as mapper ID (matching the method's default parameter). Falls back to declared return type for unknown mapper IDs. Config: `extension-nette.neon`.
95+
96+
### MapperGetAssetExtension
97+
98+
`MapperGetAssetExtension` (`DynamicMethodReturnTypeExtension`) narrows return type of `FilesystemMapper::getAsset()` and `ViteMapper::getAsset()` from `Asset` to the specific asset class based on file extension (e.g. `.jpg``ImageAsset`, `.js``ScriptAsset`). Single class registered twice in NEON with different `className` argument. For ViteMapper, `.js` narrows to `ScriptAsset` (safe because `EntryAsset extends ScriptAsset`). Config: `extension-nette.neon`.
99+
100+
### RegistryGetAssetExtension
101+
102+
`RegistryGetAssetExtension` (`DynamicMethodReturnTypeExtension`) narrows return types of `Registry::getAsset()` and `Registry::tryGetAsset()` from `Asset`/`?Asset` to specific asset class. Parses the qualified reference to extract mapper ID and asset path, checks if the mapper is a known type (`FilesystemMapper` or `ViteMapper`), then resolves asset type from file extension. For `tryGetAsset()`, adds `|null` via `TypeCombinator::addNull()`. Only narrows for string references; array references fall back to declared type. Config: `extension-nette.neon`.
103+
88104
### TableRowTypeResolver
89105

90106
`TableRowTypeResolver` is a shared service used by the three database extensions below. It resolves database table names to entity row class types using a configurable convention mask (e.g. `App\Entity\*Row` where `*` is replaced by PascalCase table name) and optional explicit table-to-class overrides. Checks class existence via `ReflectionProvider`. Config: `extension-nette.neon` parameters `nette.database.mapping.convention` and `nette.database.mapping.tables`.

composer.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
"phpstan/phpstan": "^2.1.40"
1717
},
1818
"require-dev": {
19+
"nette/assets": "^1.0",
1920
"nette/component-model": "^3.1",
2021
"nette/database": "^3.2",
2122
"nette/forms": "^3.2",

extension-nette.neon

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ parameters:
77
- Nette\Utils\Html
88

99
nette:
10+
assets:
11+
mapping: []
1012
database:
1113
mapping:
1214
convention: ''
@@ -15,6 +17,9 @@ parameters:
1517

1618
parametersSchema:
1719
nette: structure([
20+
assets: structure([
21+
mapping: arrayOf(string())
22+
])
1823
database: structure([
1924
mapping: structure([
2025
convention: string()
@@ -25,6 +30,29 @@ parametersSchema:
2530

2631

2732
services:
33+
# nette/assets
34+
nette.assets.mapperTypeResolver:
35+
class: Nette\PHPStan\Assets\MapperTypeResolver
36+
arguments:
37+
mapping: %nette.assets.mapping%
38+
39+
-
40+
class: Nette\PHPStan\Assets\GetMapperReturnTypeExtension
41+
tags: [phpstan.broker.dynamicMethodReturnTypeExtension]
42+
-
43+
class: Nette\PHPStan\Assets\MapperGetAssetExtension
44+
arguments:
45+
className: Nette\Assets\FilesystemMapper
46+
tags: [phpstan.broker.dynamicMethodReturnTypeExtension]
47+
-
48+
class: Nette\PHPStan\Assets\MapperGetAssetExtension
49+
arguments:
50+
className: Nette\Assets\ViteMapper
51+
tags: [phpstan.broker.dynamicMethodReturnTypeExtension]
52+
-
53+
class: Nette\PHPStan\Assets\RegistryGetAssetExtension
54+
tags: [phpstan.broker.dynamicMethodReturnTypeExtension]
55+
2856
# nette/component-model
2957
-
3058
class: Nette\PHPStan\ComponentModel\GetComponentReturnTypeExtension

readme.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,19 @@ parameters:
5555
special_table: App\Entity\SpecialRow
5656
```
5757

58+
**Asset type narrowing** — narrows return types of `Registry::getMapper()` to the specific mapper class, and `Registry::getAsset()` / `tryGetAsset()` to the specific asset type (e.g. `ImageAsset`, `ScriptAsset`) based on file extension. Also narrows `FilesystemMapper::getAsset()` and `ViteMapper::getAsset()` directly. Configure via:
59+
60+
```neon
61+
parameters:
62+
nette:
63+
assets:
64+
mapping:
65+
default: file # FilesystemMapper
66+
images: file # FilesystemMapper
67+
vite: vite # ViteMapper
68+
custom: App\MyMapper # custom class (FQCN)
69+
```
70+
5871
**Html magic methods** — resolves `$html->getXxx()`, `setXxx()`, and `addXxx()` calls on `Nette\Utils\Html` that go through `__call()` but aren't declared via `@method` annotations.
5972

6073
**Removes `|false` and `|null` from PHP functions** — many native functions like `getcwd`, `json_encode`, `preg_split`, `preg_replace`, and [many more](extension-php.neon) include `false` or `null` in their return type even though these error values are unrealistic on modern systems.
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
<?php declare(strict_types=1);
2+
3+
namespace Nette\PHPStan\Assets;
4+
5+
use PhpParser\Node\Expr\MethodCall;
6+
use PHPStan\Analyser\Scope;
7+
use PHPStan\Reflection\MethodReflection;
8+
use PHPStan\Type\DynamicMethodReturnTypeExtension;
9+
use PHPStan\Type\Type;
10+
use function count;
11+
12+
13+
/**
14+
* Narrows return type of Registry::getMapper() from Mapper
15+
* to the specific mapper class based on NEON configuration.
16+
*/
17+
class GetMapperReturnTypeExtension implements DynamicMethodReturnTypeExtension
18+
{
19+
public function __construct(
20+
private MapperTypeResolver $resolver,
21+
) {
22+
}
23+
24+
25+
public function getClass(): string
26+
{
27+
return 'Nette\Assets\Registry';
28+
}
29+
30+
31+
public function isMethodSupported(MethodReflection $methodReflection): bool
32+
{
33+
return $methodReflection->getName() === 'getMapper';
34+
}
35+
36+
37+
public function getTypeFromMethodCall(
38+
MethodReflection $methodReflection,
39+
MethodCall $methodCall,
40+
Scope $scope,
41+
): ?Type
42+
{
43+
$args = $methodCall->getArgs();
44+
if ($args === []) {
45+
return $this->resolver->resolveMapper('default');
46+
}
47+
48+
$idType = $scope->getType($args[0]->value);
49+
$constantStrings = $idType->getConstantStrings();
50+
if (count($constantStrings) !== 1) {
51+
return null;
52+
}
53+
54+
return $this->resolver->resolveMapper($constantStrings[0]->getValue());
55+
}
56+
}
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
<?php declare(strict_types=1);
2+
3+
namespace Nette\PHPStan\Assets;
4+
5+
use PhpParser\Node\Expr\MethodCall;
6+
use PHPStan\Analyser\Scope;
7+
use PHPStan\Reflection\MethodReflection;
8+
use PHPStan\Type\DynamicMethodReturnTypeExtension;
9+
use PHPStan\Type\Type;
10+
use function count;
11+
12+
13+
/**
14+
* Narrows return type of FilesystemMapper::getAsset() and ViteMapper::getAsset()
15+
* from Asset to the specific asset class based on file extension.
16+
* Registered twice in NEON — once per mapper class.
17+
*/
18+
class MapperGetAssetExtension implements DynamicMethodReturnTypeExtension
19+
{
20+
/**
21+
* @param class-string $className
22+
*/
23+
public function __construct(
24+
private MapperTypeResolver $resolver,
25+
private string $className,
26+
) {
27+
}
28+
29+
30+
public function getClass(): string
31+
{
32+
return $this->className;
33+
}
34+
35+
36+
public function isMethodSupported(MethodReflection $methodReflection): bool
37+
{
38+
return $methodReflection->getName() === 'getAsset';
39+
}
40+
41+
42+
public function getTypeFromMethodCall(
43+
MethodReflection $methodReflection,
44+
MethodCall $methodCall,
45+
Scope $scope,
46+
): ?Type
47+
{
48+
$args = $methodCall->getArgs();
49+
if ($args === []) {
50+
return null;
51+
}
52+
53+
$refType = $scope->getType($args[0]->value);
54+
$constantStrings = $refType->getConstantStrings();
55+
if (count($constantStrings) !== 1) {
56+
return null;
57+
}
58+
59+
return $this->resolver->resolveAssetType($constantStrings[0]->getValue());
60+
}
61+
}

src/Assets/MapperTypeResolver.php

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
<?php declare(strict_types=1);
2+
3+
namespace Nette\PHPStan\Assets;
4+
5+
use PHPStan\Reflection\ReflectionProvider;
6+
use PHPStan\Type\ObjectType;
7+
use function pathinfo, strpos, strtolower, substr;
8+
9+
10+
/**
11+
* Resolves mapper IDs to mapper class types and asset references to asset class types.
12+
* Mapper IDs are resolved from a flat map configured in NEON.
13+
* Asset types are resolved from file extensions using hardcoded mapping mirroring Helpers::createAssetFromUrl().
14+
*/
15+
class MapperTypeResolver
16+
{
17+
private const ExtensionToAssetClass = [
18+
'avif' => 'Nette\Assets\ImageAsset',
19+
'gif' => 'Nette\Assets\ImageAsset',
20+
'ico' => 'Nette\Assets\ImageAsset',
21+
'jpeg' => 'Nette\Assets\ImageAsset',
22+
'jpg' => 'Nette\Assets\ImageAsset',
23+
'png' => 'Nette\Assets\ImageAsset',
24+
'svg' => 'Nette\Assets\ImageAsset',
25+
'webp' => 'Nette\Assets\ImageAsset',
26+
'js' => 'Nette\Assets\ScriptAsset',
27+
'mjs' => 'Nette\Assets\ScriptAsset',
28+
'css' => 'Nette\Assets\StyleAsset',
29+
'aac' => 'Nette\Assets\AudioAsset',
30+
'flac' => 'Nette\Assets\AudioAsset',
31+
'm4a' => 'Nette\Assets\AudioAsset',
32+
'mp3' => 'Nette\Assets\AudioAsset',
33+
'ogg' => 'Nette\Assets\AudioAsset',
34+
'wav' => 'Nette\Assets\AudioAsset',
35+
'avi' => 'Nette\Assets\VideoAsset',
36+
'mkv' => 'Nette\Assets\VideoAsset',
37+
'mov' => 'Nette\Assets\VideoAsset',
38+
'mp4' => 'Nette\Assets\VideoAsset',
39+
'ogv' => 'Nette\Assets\VideoAsset',
40+
'webm' => 'Nette\Assets\VideoAsset',
41+
'woff' => 'Nette\Assets\FontAsset',
42+
'woff2' => 'Nette\Assets\FontAsset',
43+
'ttf' => 'Nette\Assets\FontAsset',
44+
];
45+
46+
private const KnownMappers = [
47+
'Nette\Assets\FilesystemMapper',
48+
'Nette\Assets\ViteMapper',
49+
];
50+
51+
52+
/**
53+
* @param array<string, string> $mapping mapper ID → type keyword ('file', 'vite') or FQCN
54+
*/
55+
public function __construct(
56+
private ReflectionProvider $reflectionProvider,
57+
private array $mapping = [],
58+
) {
59+
}
60+
61+
62+
/**
63+
* Resolves a mapper ID to an ObjectType for the mapper class.
64+
*/
65+
public function resolveMapper(string $mapperId): ?ObjectType
66+
{
67+
if (!isset($this->mapping[$mapperId])) {
68+
return null;
69+
}
70+
71+
$className = $this->inferMapperClass($this->mapping[$mapperId]);
72+
return $this->reflectionProvider->hasClass($className)
73+
? new ObjectType($className)
74+
: null;
75+
}
76+
77+
78+
private function inferMapperClass(string $value): string
79+
{
80+
return match ($value) {
81+
'file' => 'Nette\Assets\FilesystemMapper',
82+
'vite' => 'Nette\Assets\ViteMapper',
83+
default => $value,
84+
};
85+
}
86+
87+
88+
/**
89+
* Checks whether the mapper for a given ID is a known mapper type
90+
* (FilesystemMapper or ViteMapper) whose asset types can be narrowed.
91+
*/
92+
public function isKnownMapper(string $mapperId): bool
93+
{
94+
$mapperType = $this->resolveMapper($mapperId);
95+
if ($mapperType === null) {
96+
return false;
97+
}
98+
99+
foreach (self::KnownMappers as $knownClass) {
100+
if ((new ObjectType($knownClass))->isSuperTypeOf($mapperType)->yes()) {
101+
return true;
102+
}
103+
}
104+
105+
return false;
106+
}
107+
108+
109+
/**
110+
* Resolves an asset reference to an ObjectType based on its file extension.
111+
*/
112+
public function resolveAssetType(string $reference): ?ObjectType
113+
{
114+
$extension = strtolower(pathinfo($reference, PATHINFO_EXTENSION));
115+
return isset(self::ExtensionToAssetClass[$extension])
116+
? new ObjectType(self::ExtensionToAssetClass[$extension])
117+
: null;
118+
}
119+
120+
121+
/**
122+
* Splits a qualified reference 'mapper:reference' into [mapperId, assetPath].
123+
* @return array{string, string}
124+
*/
125+
public function parseReference(string $ref): array
126+
{
127+
$pos = strpos($ref, ':');
128+
return $pos !== false
129+
? [substr($ref, 0, $pos), substr($ref, $pos + 1)]
130+
: ['default', $ref];
131+
}
132+
}

0 commit comments

Comments
 (0)