Skip to content

Commit eacdfea

Browse files
committed
Add dynamic function return extension for service and single_service
1 parent 2944a05 commit eacdfea

File tree

8 files changed

+288
-0
lines changed

8 files changed

+288
-0
lines changed

README.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
This extension provides the following features:
1111

1212
* Provides precise return types for `config()` and `model()` functions.
13+
* Provides precise return types for `service()` and `single_service()` functions.
1314
* Checks if the string argument passed to `config()` or `model()` function is a valid class string extending `CodeIgniter\Config\BaseConfig` or `CodeIgniter\Model`, respectively. This can be turned off by setting `codeigniter.checkArgumentTypeOfFactories: false` in your `phpstan.neon`.
1415
* Disallows instantiating cache handlers using `new` and suggests to use the `CacheFactory` class instead.
1516

@@ -51,3 +52,12 @@ parameters:
5152
- Acme\Blog\Models\
5253
5354
```
55+
56+
For the `service()` and `single_service()` functions, you can declare your own service implementation classes.
57+
58+
```yml
59+
parameters:
60+
codeigniter:
61+
additionalServices:
62+
- Acme\Blog\Config\ServiceFactory
63+
```

extension.neon

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,14 @@ parameters:
88
codeigniter:
99
additionalConfigNamespaces: []
1010
additionalModelNamespaces: []
11+
additionalServices: []
1112
checkArgumentTypeOfFactories: true
1213

1314
parametersSchema:
1415
codeigniter: structure([
1516
additionalConfigNamespaces: listOf(string())
1617
additionalModelNamespaces: listOf(string())
18+
additionalServices: listOf(string())
1719
checkArgumentTypeOfFactories: bool()
1820
])
1921

@@ -24,11 +26,21 @@ services:
2426
additionalConfigNamespaces: %codeigniter.additionalConfigNamespaces%
2527
additionalModelNamespaces: %codeigniter.additionalModelNamespaces%
2628

29+
servicesReturnTypeHelper:
30+
class: CodeIgniter\PHPStan\Type\ServicesReturnTypeHelper
31+
arguments:
32+
additionalServices: %codeigniter.additionalServices%
33+
2734
-
2835
class: CodeIgniter\PHPStan\Type\FactoriesFunctionReturnTypeExtension
2936
tags:
3037
- phpstan.broker.dynamicFunctionReturnTypeExtension
3138

39+
-
40+
class: CodeIgniter\PHPStan\Type\ServicesFunctionReturnTypeExtension
41+
tags:
42+
- phpstan.broker.dynamicFunctionReturnTypeExtension
43+
3244
-
3345
class: CodeIgniter\PHPStan\Rules\Functions\FactoriesFunctionArgumentTypeRule
3446

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* This file is part of CodeIgniter 4 framework.
7+
*
8+
* (c) 2023 CodeIgniter Foundation <admin@codeigniter.com>
9+
*
10+
* For the full copyright and license information, please view
11+
* the LICENSE file that was distributed with this source code.
12+
*/
13+
14+
namespace CodeIgniter\PHPStan\Type;
15+
16+
use PhpParser\Node\Expr\FuncCall;
17+
use PHPStan\Analyser\Scope;
18+
use PHPStan\Reflection\FunctionReflection;
19+
use PHPStan\Type\DynamicFunctionReturnTypeExtension;
20+
use PHPStan\Type\Type;
21+
22+
final class ServicesFunctionReturnTypeExtension implements DynamicFunctionReturnTypeExtension
23+
{
24+
public function __construct(
25+
private readonly ServicesReturnTypeHelper $servicesReturnTypeHelper
26+
) {}
27+
28+
public function isFunctionSupported(FunctionReflection $functionReflection): bool
29+
{
30+
return in_array($functionReflection->getName(), ['service', 'single_service'], true);
31+
}
32+
33+
public function getTypeFromFunctionCall(FunctionReflection $functionReflection, FuncCall $functionCall, Scope $scope): ?Type
34+
{
35+
$arguments = $functionCall->getArgs();
36+
37+
if ($arguments === []) {
38+
return null;
39+
}
40+
41+
return $this->servicesReturnTypeHelper->check($scope->getType($arguments[0]->value), $scope);
42+
}
43+
}
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* This file is part of CodeIgniter 4 framework.
7+
*
8+
* (c) 2023 CodeIgniter Foundation <admin@codeigniter.com>
9+
*
10+
* For the full copyright and license information, please view
11+
* the LICENSE file that was distributed with this source code.
12+
*/
13+
14+
namespace CodeIgniter\PHPStan\Type;
15+
16+
use CodeIgniter\Config\Services as FrameworkServices;
17+
use Config\Services as AppServices;
18+
use PHPStan\Analyser\Scope;
19+
use PHPStan\Reflection\ClassReflection;
20+
use PHPStan\Reflection\ParametersAcceptorSelector;
21+
use PHPStan\Reflection\ReflectionProvider;
22+
use PHPStan\ShouldNotHappenException;
23+
use PHPStan\Type\IntersectionType;
24+
use PHPStan\Type\NullType;
25+
use PHPStan\Type\Type;
26+
use PHPStan\Type\TypeTraverser;
27+
use PHPStan\Type\UnionType;
28+
29+
final class ServicesReturnTypeHelper
30+
{
31+
/**
32+
* @var array<int, string>
33+
*/
34+
private const IMPOSSIBLE_SERVICE_METHOD_NAMES = [
35+
'__callStatic',
36+
'buildServicesCache',
37+
'createRequest',
38+
'discoverServices',
39+
'getSharedInstance',
40+
'injectMock',
41+
'reset',
42+
'resetSingle',
43+
'serviceExists',
44+
];
45+
46+
/**
47+
* @var array<int, class-string>
48+
*/
49+
private array $services;
50+
51+
/**
52+
* @var array<int, ClassReflection>
53+
*/
54+
private static array $servicesReflection = [];
55+
56+
/**
57+
* @param array<int, class-string> $additionalServices
58+
*/
59+
public function __construct(
60+
private readonly ReflectionProvider $reflectionProvider,
61+
array $additionalServices
62+
) {
63+
$this->services = [
64+
FrameworkServices::class,
65+
AppServices::class,
66+
...$additionalServices,
67+
];
68+
}
69+
70+
public function check(Type $type, Scope $scope): Type
71+
{
72+
if (self::$servicesReflection === []) {
73+
self::$servicesReflection = array_map(function (string $service): ClassReflection {
74+
if (! $this->reflectionProvider->hasClass($service)) {
75+
throw new ShouldNotHappenException(sprintf('Service class "%s" is not found.', $service));
76+
}
77+
78+
return $this->reflectionProvider->getClass($service);
79+
}, $this->services);
80+
}
81+
82+
return TypeTraverser::map($type, static function (Type $type, callable $traverse) use ($scope): Type {
83+
if ($type instanceof UnionType || $type instanceof IntersectionType) {
84+
return $traverse($type);
85+
}
86+
87+
$constantStrings = $type->getConstantStrings();
88+
89+
if ($constantStrings === []) {
90+
return new NullType();
91+
}
92+
93+
$constantString = current($constantStrings)->getValue();
94+
95+
foreach (self::IMPOSSIBLE_SERVICE_METHOD_NAMES as $impossibleServiceMethodName) {
96+
if (strtolower($constantString) === strtolower($impossibleServiceMethodName)) {
97+
return new NullType();
98+
}
99+
}
100+
101+
$methodReflection = null;
102+
103+
foreach (self::$servicesReflection as $servicesReflection) {
104+
if ($servicesReflection->hasMethod($constantString)) {
105+
$methodReflection = $servicesReflection->getMethod($constantString, $scope);
106+
}
107+
}
108+
109+
if ($methodReflection === null) {
110+
return new NullType();
111+
}
112+
113+
if (! $methodReflection->isStatic() || ! $methodReflection->isPublic()) {
114+
return new NullType();
115+
}
116+
117+
return ParametersAcceptorSelector::selectSingle($methodReflection->getVariants())->getReturnType();
118+
});
119+
}
120+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* This file is part of CodeIgniter 4 framework.
7+
*
8+
* (c) 2023 CodeIgniter Foundation <admin@codeigniter.com>
9+
*
10+
* For the full copyright and license information, please view
11+
* the LICENSE file that was distributed with this source code.
12+
*/
13+
14+
namespace CodeIgniter\PHPStan\Tests\Fixtures\Type;
15+
16+
use Closure;
17+
use CodeIgniter\Config\BaseService;
18+
use stdClass;
19+
20+
final class OtherServices extends BaseService
21+
{
22+
/**
23+
* This should overwrite native migrations.
24+
*/
25+
public static function migrations(): stdClass
26+
{
27+
return new stdClass();
28+
}
29+
30+
public static function invoker(string $callable): Closure
31+
{
32+
return Closure::fromCallable($callable);
33+
}
34+
}

tests/Fixtures/Type/services.php

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* This file is part of CodeIgniter 4 framework.
7+
*
8+
* (c) 2023 CodeIgniter Foundation <admin@codeigniter.com>
9+
*
10+
* For the full copyright and license information, please view
11+
* the LICENSE file that was distributed with this source code.
12+
*/
13+
14+
use CodeIgniter\Autoloader\Autoloader;
15+
use CodeIgniter\Autoloader\FileLocator;
16+
use CodeIgniter\Cache\CacheInterface;
17+
use CodeIgniter\CLI\Commands;
18+
use CodeIgniter\Debug\Exceptions;
19+
use CodeIgniter\Debug\Iterator;
20+
use CodeIgniter\Debug\Toolbar;
21+
use CodeIgniter\Email\Email;
22+
use CodeIgniter\Filters\Filters;
23+
use CodeIgniter\HTTP\CLIRequest;
24+
use CodeIgniter\HTTP\ResponseInterface;
25+
use CodeIgniter\Log\Logger;
26+
use CodeIgniter\Pager\Pager;
27+
use CodeIgniter\Validation\ValidationInterface;
28+
use CodeIgniter\View\Cell;
29+
use CodeIgniter\View\Parser;
30+
use CodeIgniter\View\View;
31+
32+
use function PHPStan\Testing\assertType;
33+
34+
// from CodeIgniter\Config\Services
35+
assertType(CacheInterface::class, service('cache'));
36+
assertType(Commands::class, service('commands'));
37+
assertType(CLIRequest::class, single_service('clirequest'));
38+
assertType(Email::class, service('email'));
39+
assertType(Exceptions::class, single_service('exceptions'));
40+
assertType(Filters::class, service('filters'));
41+
assertType(Iterator::class, service('iterator'));
42+
assertType(Logger::class, single_service('logger'));
43+
assertType(Pager::class, service('pager'));
44+
assertType(Parser::class, service('parser'));
45+
assertType(View::class, service('renderer'));
46+
assertType('CodeIgniter\HTTP\CLIRequest|CodeIgniter\HTTP\IncomingRequest', service('request'));
47+
assertType(ResponseInterface::class, service('response'));
48+
assertType(Toolbar::class, service('toolbar'));
49+
assertType(ValidationInterface::class, service('validation'));
50+
assertType(Cell::class, service('viewcell'));
51+
assertType('null', service('createRequest'));
52+
53+
// from CodeIgniter\Config\BaseService
54+
assertType(Autoloader::class, single_service('autoloader'));
55+
assertType(FileLocator::class, service('locator'));
56+
assertType('null', service('__callStatic'));
57+
assertType('null', service('serviceExists'));
58+
assertType('null', service('reset'));
59+
assertType('null', service('resetSingle'));
60+
assertType('null', service('injectMock'));
61+
62+
// from OtherServices
63+
// this should be overridden by OtherServices
64+
assertType(stdClass::class, service('migrations'));
65+
assertType(Closure::class, service('invoker'));

tests/Type/DynamicFunctionReturnTypeExtensionTest.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@ public static function provideFileAssertsCases(): iterable
3434
yield from self::gatherAssertTypes(__DIR__ . '/../Fixtures/Type/config.php');
3535

3636
yield from self::gatherAssertTypes(__DIR__ . '/../Fixtures/Type/model.php');
37+
38+
yield from self::gatherAssertTypes(__DIR__ . '/../Fixtures/Type/services.php');
3739
}
3840

3941
public static function getAdditionalConfigFiles(): array

tests/extension-test.neon

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,5 @@ parameters:
44
codeigniter:
55
additionalModelNamespaces:
66
- CodeIgniter\PHPStan\Tests\Fixtures\Type
7+
additionalServices:
8+
- CodeIgniter\PHPStan\Tests\Fixtures\Type\OtherServices

0 commit comments

Comments
 (0)