├── LICENSE
├── README.md
├── bin
└── phpstan-type-mapping-from-bigquery
├── composer.json
├── extension.neon
├── rules.neon
└── src
├── Rules
├── ContextKeyRule.php
├── ContextRequireExceptionKeyRule.php
├── ContextTypeRule.php
├── LogLevelListInterface.php
├── LogMethodLevelRule.php
├── MessageStaticStringRule.php
├── PlaceholderCharactersRule.php
└── PlaceholderCorrespondToKeysRule.php
├── TypeMapping
└── BigQuery
│ ├── Exception
│ └── UnsupportedTypeException.php
│ ├── GenericTableFieldSchemaJsonPayloadTypeMapper.php
│ └── TableFieldSchemaJsonPayloadTypeMapperInterface.php
├── TypeProvider
├── BigQueryContextTypeProvider.php
├── ContextTypeProviderInterface.php
└── Psr3ContextTypeProvider.php
└── TypeProviderResolver
├── AnyScopeContextTypeProviderResolver.php
├── ContextTypeProviderResolverInterface.php
└── LayeredScopeContextTypeProviderResolver.php
/LICENSE:
--------------------------------------------------------------------------------
1 | Copyright (c) 2023 struggle-for-php
2 |
3 | Permission is hereby granted, free of charge, to any person obtaining a copy
4 | of this software and associated documentation files (the "Software"), to deal
5 | in the Software without restriction, including without limitation the rights
6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7 | copies of the Software, and to permit persons to whom the Software is
8 | furnished to do so, subject to the following conditions:
9 |
10 | The above copyright notice and this permission notice shall be included in
11 | all copies or substantial portions of the Software.
12 |
13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
19 | THE SOFTWARE.
20 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # psr/log(PSR-3) extensions for PHPStan
2 |
3 | [](https://packagist.org/packages/struggle-for-php/sfp-phpstan-psr-log)
4 | [](https://packagist.org/packages/struggle-for-php/sfp-phpstan-psr-log)
5 | [](https://shepherd.dev/github/struggle-for-php/sfp-phpstan-psr-log)
6 |
7 | > [!IMPORTANT]
8 | > The future version `0.25.0` or later will have a BC break. Please refer `Stubs` section.
9 |
10 | `struggle-for-php/sfp-phpstan-psr-log` is extra strict and opinionated psr/log (psr-3) rules for PHPStan.
11 |
12 | * [PHPStan](https://phpstan.org/)
13 | * [PSR-3: Logger Interface - PHP-FIG](https://www.php-fig.org/psr/psr-3/)
14 | * [PSR-3 Meta Document](https://www.php-fig.org/psr/psr-3/meta/)
15 |
16 | ## Recommendation Settings
17 |
18 | Write these parameters to your project's `phpstan.neon`.
19 |
20 | ```neon
21 | parameters:
22 | sfpPsrLog:
23 | enableMessageStaticStringRule: false # default:true
24 | enableContextRequireExceptionKeyRule: true
25 | reportContextExceptionLogLevel: 'info'
26 | contextKeyOriginalPattern: '#\A[A-Za-z0-9-_]+\z#'
27 | ```
28 |
29 | ## Stubs
30 |
31 | > [!IMPORTANT]
32 | > include psr/log stub be planned to dropped in comming release.
33 |
34 | To try out the changes in the comming version,
35 |
36 | DELETE `vendor/struggle-for-php/sfp-phpstan-psr-log/extension.neon` line from your `phpstan.neon`
37 |
38 | ```neon
39 | includes:
40 | - vendor/struggle-for-php/sfp-phpstan-psr-log/extension.neon
41 | ```
42 |
43 | and, set parameters `enableLogMethodLevelRule` and `enableContextTypeRule`
44 |
45 | ```neon
46 | parameters:
47 | sfpPsrLog:
48 | enableLogMethodLevelRule: true # default:false
49 | enableContextTypeRule: true # default:false
50 | ```
51 |
52 | ### About stub
53 |
54 | Currently, this extension depends on our psr/log stub to serve strictness.
55 |
56 | * eg.
57 | * `@param LogLevel::* $level` at `log()` method
58 | * `@param array{exception?: \Throwable} $context`
59 |
60 | See [psr/log stub](https://github.com/struggle-for-php/sfp-stubs-psr-log) repository page to get more detail.
61 |
62 | ## Rules
63 |
64 | This package provides the following rules.
65 |
66 | ### PlaceholderCharactersRule
67 |
68 | > Placeholder names SHOULD be composed only of the characters A-Z, a-z, 0-9, underscore _, and period .
69 |
70 | | :pushpin: _error identifier_ |
71 | | --- |
72 | | sfpPsrLog.placeholderCharactersInvalidChar |
73 |
74 | * reports when placeholder in `$message` characters are **not**, `A-Z`, `a-z`, `0-9`, underscore `_`, and period `.`
75 |
76 | ```php
77 | // bad
78 | $logger->info('message are {foo-hyphen}');
79 | ```
80 |
81 | | :pushpin: _error identifier_ |
82 | | --- |
83 | | sfpPsrLog.placeholderCharactersDoubleBraces |
84 |
85 | * reports when double braces pair `{{` `}}` are used.
86 |
87 | ```php
88 | // bad
89 | $logger->info('message are {{foo}}');
90 | ```
91 |
92 | ### PlaceholderCorrespondToKeysRule
93 |
94 | > Placeholder names MUST correspond to keys in the context array.
95 |
96 | | :pushpin: _error identifier_ |
97 | | --- |
98 | | sfpPsrLog.placeholderCorrespondToKeysMissedContext |
99 |
100 | * reports when placeholder exists in message, but `$context` parameter is missed.
101 |
102 | ```php
103 | // bad
104 | $logger->info('message has {nonContext} .');
105 | ```
106 |
107 | | :pushpin: _error identifier_ |
108 | | --- |
109 | | sfpPsrLog.placeholderCorrespondToKeysMissedKey |
110 |
111 | * reports when placeholder exists in message, but key in `$context` does not exist against them.
112 |
113 | ```php
114 | // bad
115 | $logger->info('user {user_id} gets an error {error} .', ['user_id' => $user_id]);
116 | ```
117 |
118 | ### ContextKeyRule
119 |
120 | > [!NOTE]
121 | > PSR-3 has no provisions for array keys, but this is useful in many cases.
122 |
123 | | :pushpin: _error identifier_ |
124 | | --- |
125 | | sfpPsrLog.contextKeyNonEmptyString |
126 |
127 | * reports when context key is not **non-empty-string**.
128 |
129 | ```php
130 | // bad
131 | [123 => 'foo']`, `['' => 'bar']`, `['baz']
132 | ```
133 |
134 | | :pushpin: _error identifier_ |
135 | | --- |
136 | | sfpPsrLog.contextKeyOriginalPattern |
137 |
138 | * reports when context key is not matched you defined pattern.
139 | * if `contextKeyOriginalPattern` parameter is not set, this check would be ignored.
140 |
141 | #### Configuration
142 |
143 | * You can set specific key pattern by regex.(`preg_match()`)
144 |
145 | ```neon
146 | parameters:
147 | sfpPsrLog:
148 | contextKeyOriginalPattern: '#\A[A-Za-z0-9-]+\z#'
149 | ```
150 |
151 | ### ContextRequireExceptionKeyRule
152 |
153 | > [!NOTE]
154 | > This is not a rule for along with PSR-3 specification, but provides best practices.
155 |
156 | | :pushpin: _error identifier_ |
157 | | --- |
158 | | sfpPsrLog.contextRequireExceptionKey |
159 |
160 | * It forces `exception` key into context parameter when current scope has `\Throwable` object.
161 |
162 | #### Example
163 |
164 | ```php
165 | warning("foo");
171 | }
172 | ```
173 |
174 | ```sh
175 | $ ../vendor/bin/phpstan analyse
176 | Note: Using configuration file /tmp/your-project/phpstan.neon.
177 | 2/2 [▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 100%
178 |
179 | ------ -------------------------------------------------------------
180 | Line Demo.php
181 | ------ -------------------------------------------------------------
182 | 6 Parameter $context of logger method Psr\Log\LoggerInterface::warning() requires \'exception\' key. Current scope has Throwable variable - $exception
183 | ------ -------------------------------------------------------------
184 |
185 |
186 | [ERROR] Found 1 error
187 | ```
188 |
189 | #### Configuration
190 |
191 | * You can set the minimum required level to report. (default level is `debug`)
192 |
193 | ```neon
194 | parameters:
195 | sfpPsrLog:
196 | reportContextExceptionLogLevel: 'warning'
197 | ```
198 |
199 | Then, `debug`| `info` | `notice` LogLevel is ignored for report.
200 |
201 | ```php
202 | } catch (\Exception $e) {
203 | $logger->info('more info'); // allow
204 | $logger->warning($e->getMessage(), ['exception' => $e]);
205 | }
206 | ```
207 |
208 | * If you want to enable this rule, please add `enableContextRequireExceptionKeyRule` as true.
209 |
210 | ```neon
211 | parameters:
212 | sfpPsrLog:
213 | enableContextRequireExceptionKeyRule: true
214 | ```
215 |
216 | ### MessageStaticStringRule
217 |
218 | | :pushpin: _error identifier_ |
219 | | --- |
220 | | sfpPsrLog.messageNotStaticString |
221 |
222 | * reports when $message is not static string value.
223 |
224 | ```php
225 | // bad
226 | $logger->info(sprintf('Message contains %s variable', $var));
227 | ```
228 |
229 | #### Configuration
230 |
231 | * If you want to disable this rule, please add `enableMessageStaticStringRule` as false.
232 |
233 | ```neon
234 | parameters:
235 | sfpPsrLog:
236 | enableMessageStaticStringRule: false
237 | ```
238 |
239 | ## Installation
240 |
241 | To use this extension, require it in [Composer](https://getcomposer.org/):
242 |
243 | ```bash
244 | composer require --dev struggle-for-php/sfp-phpstan-psr-log
245 | ```
246 |
247 | ### Manual installation
248 |
249 | include extension.neon & rules.neon in your project's PHPStan config:
250 |
251 | ```neon
252 | includes:
253 | - vendor/struggle-for-php/sfp-phpstan-psr-log/extension.neon
254 | - vendor/struggle-for-php/sfp-phpstan-psr-log/rules.neon
255 | ```
256 |
--------------------------------------------------------------------------------
/bin/phpstan-type-mapping-from-bigquery:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env php
2 | Run this PHP script from the command line to see mapping BigQuery schema to PHPStan Type. It supports Unix pipes or command line argument style.
";
16 | exit;
17 | }
18 |
19 | if (isset($argv[1])) {
20 | $schema = $argv[1];
21 | } else {
22 | $schema = stream_get_contents(fopen('php://stdin', 'r'));
23 | }
24 |
25 | $autoloadFiles = [
26 | 2 => __DIR__ . '/../vendor/autoload.php',
27 | 3 => __DIR__ . '/../../../autoload.php',
28 | ];
29 |
30 | $rootDir = false;
31 | foreach ($autoloadFiles as $deps => $autoloadFile) {
32 | if (file_exists($autoloadFile)) {
33 | $rootDir = dirname($autoloadFile, $deps);
34 | require_once $autoloadFile;
35 | break;
36 | }
37 | }
38 |
39 | // bootstrap ReflectionProvider
40 | $containerFactory = new ContainerFactory($rootDir);
41 | $containerFactory->create(sys_get_temp_dir() . DIRECTORY_SEPARATOR . __FILE__, [], []);
42 |
43 | $converter = new GenericTableFieldSchemaJsonPayloadTypeMapper();
44 | $provider = new BigQueryContextTypeProvider(
45 | 'data://,' . $schema,
46 | $converter
47 | );
48 |
49 | echo $provider->getType()->toPhpDocNode();
50 |
--------------------------------------------------------------------------------
/composer.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "struggle-for-php/sfp-phpstan-psr-log",
3 | "description": "Extra strict and opinionated psr/log (psr-3) rules for PHPStan",
4 | "type": "phpstan-extension",
5 | "keywords": ["phpstan", "psr-3", "psr3", "logging", "static analysis"],
6 | "license": [
7 | "MIT"
8 | ],
9 | "require": {
10 | "php": "^7.2.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0",
11 | "ext-json": "*",
12 | "phpstan/phpstan": "^1.12 || ^2.0",
13 | "struggle-for-php/sfp-stubs-psr-log": "^1.0.1 || ^2 || ^3.0.1"
14 | },
15 | "require-dev": {
16 | "laminas/laminas-coding-standard": "^2.0.0",
17 | "maglnet/composer-require-checker": "^2|^3|^4",
18 | "phpstan/phpstan-phpunit": "^1.3 || ^2.0",
19 | "phpstan/phpstan-strict-rules": "^1.5 || ^2.0",
20 | "phpunit/phpunit": "^8.5.31 || ^9.5.10 || ^10.5.35",
21 | "roave/security-advisories": "dev-master",
22 | "squizlabs/php_codesniffer": "^3.7",
23 | "vimeo/psalm": "^4 || ^5.26"
24 | },
25 | "conflict": {
26 | "nikic/php-parser": "<4.13.0"
27 | },
28 | "autoload": {
29 | "psr-4": {
30 | "Sfp\\PHPStan\\Psr\\Log\\": "src"
31 | }
32 | },
33 | "autoload-dev": {
34 | "psr-4": {
35 | "SfpTest\\PHPStan\\Psr\\Log\\": "test"
36 | }
37 | },
38 | "config": {
39 | "sort-packages": true,
40 | "allow-plugins": {
41 | "dealerdirect/phpcodesniffer-composer-installer": true
42 | }
43 | },
44 | "bin": ["bin/phpstan-type-mapping-from-bigquery"],
45 | "extra": {
46 | "phpstan": {
47 | "includes": [
48 | "extension.neon",
49 | "rules.neon"
50 | ]
51 | }
52 | }
53 | }
54 |
--------------------------------------------------------------------------------
/extension.neon:
--------------------------------------------------------------------------------
1 | parameters:
2 | stubFiles:
3 | - ../sfp-stubs-psr-log/stubs-for-throwable/LoggerInterface.phpstub
4 |
--------------------------------------------------------------------------------
/rules.neon:
--------------------------------------------------------------------------------
1 | parametersSchema:
2 | sfpPsrLog: structure([
3 | enableContextRequireExceptionKeyRule: bool(),
4 | enableMessageStaticStringRule: bool(),
5 | reportContextExceptionLogLevel: schema(string(), nullable()),
6 | contextKeyOriginalPattern: schema(string(), nullable()),
7 | enableLogMethodLevelRule: bool()
8 | enableContextTypeRule: bool()
9 | ])
10 |
11 | parameters:
12 | sfpPsrLog:
13 | enableContextRequireExceptionKeyRule: false
14 | enableMessageStaticStringRule: true
15 | reportContextExceptionLogLevel: 'debug'
16 | contextKeyOriginalPattern: null
17 | enableLogMethodLevelRule: false
18 | enableContextTypeRule: false
19 |
20 | conditionalTags:
21 | Sfp\PHPStan\Psr\Log\Rules\ContextRequireExceptionKeyRule:
22 | phpstan.rules.rule: %sfpPsrLog.enableContextRequireExceptionKeyRule%
23 | Sfp\PHPStan\Psr\Log\Rules\MessageStaticStringRule:
24 | phpstan.rules.rule: %sfpPsrLog.enableMessageStaticStringRule%
25 | Sfp\PHPStan\Psr\Log\Rules\LogMethodLevelRule:
26 | phpstan.rules.rule: %sfpPsrLog.enableLogMethodLevelRule%
27 | Sfp\PHPStan\Psr\Log\Rules\ContextTypeRule:
28 | phpstan.rules.rule: %sfpPsrLog.enableContextTypeRule%
29 |
30 | rules:
31 | - Sfp\PHPStan\Psr\Log\Rules\PlaceholderCorrespondToKeysRule
32 | - Sfp\PHPStan\Psr\Log\Rules\PlaceholderCharactersRule
33 |
34 | services:
35 | -
36 | class: Sfp\PHPStan\Psr\Log\Rules\ContextKeyRule
37 | arguments:
38 | contextKeyOriginalPattern: %sfpPsrLog.contextKeyOriginalPattern%
39 | tags:
40 | - phpstan.rules.rule
41 |
42 | -
43 | class: Sfp\PHPStan\Psr\Log\Rules\ContextRequireExceptionKeyRule
44 | arguments:
45 | reportContextExceptionLogLevel: %sfpPsrLog.reportContextExceptionLogLevel%
46 | -
47 | class: Sfp\PHPStan\Psr\Log\Rules\MessageStaticStringRule
48 |
49 | -
50 | class: Sfp\PHPStan\Psr\Log\Rules\LogMethodLevelRule
51 |
52 | -
53 | class: Sfp\PHPStan\Psr\Log\Rules\ContextTypeRule
54 |
55 | # -
56 | # class: Sfp\PHPStan\Psr\Log\Rules\ContextTypeRule
57 | # arguments:
58 | # contextTypeProviderResolver: @contextTypeProviderResolver
59 |
60 |
61 | # contextTypeProviderResolver:
62 |
63 | # psrLogContextTypeProvider :
64 | # class: Sfp\PHPStan\Psr\Log\TypeProvider\Psr3ContextTypeProvider
65 | # arguments:
66 | # exceptionClass: '\Exception'
67 |
68 | # BigQuery
69 | #
70 | # psrLogContextTypeProvider :
71 | # class: Sfp\PHPStan\Psr\Log\TypeProvider\BigQueryContextTypeProvider
72 | # arguments:
73 | # schemaFile:
74 | # -
75 | # class: Sfp\PHPStan\Psr\Log\TypeMapping\BigQuery\GenericTableFieldSchemaJsonPayloadTypeMapper
76 |
--------------------------------------------------------------------------------
/src/Rules/ContextKeyRule.php:
--------------------------------------------------------------------------------
1 |
23 | */
24 | final class ContextKeyRule implements Rule
25 | {
26 | // eg, DNumber
27 | private const ERROR_NOT_NON_EMPTY_STRING = 'Parameter $context of logger method Psr\Log\LoggerInterface::%s(), key should be non empty string.';
28 |
29 | private const ERROR_NOT_MATCH_ORIGINAL_PATTERN = 'Parameter $context of logger method Psr\Log\LoggerInterface::%s(), key should be match %s.';
30 |
31 | /** @var string|null */
32 | private $contextKeyOriginalPattern;
33 |
34 | public function __construct(?string $contextKeyOriginalPattern = null)
35 | {
36 | $this->contextKeyOriginalPattern = $contextKeyOriginalPattern;
37 | }
38 |
39 | public function getNodeType(): string
40 | {
41 | return Node\Expr\MethodCall::class;
42 | }
43 |
44 | /**
45 | * @throws ShouldNotHappenException
46 | */
47 | public function processNode(Node $node, Scope $scope): array
48 | {
49 | if (! $node->name instanceof Node\Identifier) {
50 | // @codeCoverageIgnoreStart
51 | return []; // @codeCoverageIgnoreEnd
52 | }
53 |
54 | $calledOnType = $scope->getType($node->var);
55 | if (! (new ObjectType('Psr\Log\LoggerInterface'))->isSuperTypeOf($calledOnType)->yes()) {
56 | // @codeCoverageIgnoreStart
57 | return []; // @codeCoverageIgnoreEnd
58 | }
59 |
60 | /** @var Node\Arg[] $args */
61 | $args = $node->getArgs();
62 | if (count($args) === 0) {
63 | // @codeCoverageIgnoreStart
64 | return []; // @codeCoverageIgnoreEnd
65 | }
66 |
67 | $methodName = $node->name->toLowerString();
68 |
69 | if ($methodName !== 'log' && ! in_array($methodName, LogLevelListInterface::LOGGER_LEVEL_METHODS, true)) {
70 | // @codeCoverageIgnoreStart
71 | return []; // @codeCoverageIgnoreEnd
72 | }
73 |
74 | $contextArgumentNo = 1;
75 | if ($methodName === 'log') {
76 | if (count($args) < 3) {
77 | return [];
78 | }
79 |
80 | $contextArgumentNo = 2;
81 | } elseif (count($args) < 2) {
82 | return [];
83 | }
84 |
85 | $context = $args[$contextArgumentNo];
86 |
87 | $arrayType = $scope->getType($context->value);
88 |
89 | if ($arrayType->isIterableAtLeastOnce()->no()) {
90 | // @codeCoverageIgnoreStart
91 | return []; // @codeCoverageIgnoreEnd
92 | }
93 |
94 | $constantArrays = $arrayType->getConstantArrays();
95 |
96 | if (count($constantArrays) === 0) {
97 | return [];
98 | }
99 |
100 | $errors = self::keysAreNonEmptyString($constantArrays, $methodName);
101 |
102 | if ($errors !== []) {
103 | return $errors;
104 | }
105 |
106 | return $this->originalPatternMatches($constantArrays, $methodName);
107 | }
108 |
109 | /**
110 | * phpcs:ignore SlevomatCodingStandard.Namespaces.ReferenceUsedNamesOnly
111 | * @phpstan-param list<\PHPStan\Type\Constant\ConstantArrayType> $constantArrays
112 | * phpcs:ignore SlevomatCodingStandard.Namespaces.ReferenceUsedNamesOnly
113 | * @phpstan-return list<\PHPStan\Rules\IdentifierRuleError>
114 | */
115 | private static function keysAreNonEmptyString(array $constantArrays, string $methodName): array
116 | {
117 | $errors = [];
118 | foreach ($constantArrays as $constantArray) {
119 | foreach ($constantArray->getKeyTypes() as $keyType) {
120 | if ($keyType->isNonEmptyString()->yes()) {
121 | continue;
122 | }
123 |
124 | $errors[] = RuleErrorBuilder::message(
125 | sprintf(self::ERROR_NOT_NON_EMPTY_STRING, $methodName)
126 | )->identifier('sfpPsrLog.contextKeyNonEmptyString')->build();
127 | }
128 | }
129 |
130 | return $errors;
131 | }
132 |
133 | /**
134 | * phpcs:ignore SlevomatCodingStandard.Namespaces.ReferenceUsedNamesOnly
135 | * @phpstan-param list<\PHPStan\Type\Constant\ConstantArrayType> $constantArrays
136 | * phpcs:ignore SlevomatCodingStandard.Namespaces.ReferenceUsedNamesOnly
137 | * @phpstan-return list<\PHPStan\Rules\IdentifierRuleError>
138 | */
139 | private function originalPatternMatches(array $constantArrays, string $methodName): array
140 | {
141 | if ($this->contextKeyOriginalPattern === null) {
142 | return [];
143 | }
144 |
145 | if ($this->contextKeyOriginalPattern === '') {
146 | throw new LogicException('provided empty string as pattern');
147 | }
148 |
149 | $errors = [];
150 | foreach ($constantArrays as $constantArray) {
151 | foreach ($constantArray->getKeyTypes() as $keyType) {
152 | $key = $keyType->getValue();
153 |
154 | if (! is_string($key)) {
155 | // keyType be string is checked by keysAreNonEmptyString()
156 | // @codeCoverageIgnoreStart
157 | continue; // @codeCoverageIgnoreEnd
158 | }
159 |
160 | $matched = preg_match($this->contextKeyOriginalPattern, $key, $matches);
161 |
162 | if ($matched === false) {
163 | throw new LogicException(sprintf('provided regex pattern %s is invalid', $this->contextKeyOriginalPattern));
164 | }
165 |
166 | if ($matched === 0) {
167 | $errors[] = RuleErrorBuilder::message(
168 | sprintf(self::ERROR_NOT_MATCH_ORIGINAL_PATTERN, $methodName, $this->contextKeyOriginalPattern)
169 | )->identifier('sfpPsrLog.contextKeyOriginalPattern')->build();
170 | }
171 | }
172 | }
173 |
174 | return $errors;
175 | }
176 | }
177 |
--------------------------------------------------------------------------------
/src/Rules/ContextRequireExceptionKeyRule.php:
--------------------------------------------------------------------------------
1 |
22 | */
23 | final class ContextRequireExceptionKeyRule implements Rule
24 | {
25 | private const LOGGER_LEVELS = [
26 | 'emergency' => 7,
27 | 'alert' => 6,
28 | 'critical' => 5,
29 | 'error' => 4,
30 | 'warning' => 3,
31 | 'notice' => 2,
32 | 'info' => 1,
33 | 'debug' => 0,
34 | ];
35 |
36 | private const ERROR_MISSED_EXCEPTION_KEY = 'Parameter $context of logger method Psr\Log\LoggerInterface::%s() requires \'exception\' key. Current scope has Throwable variable - %s';
37 |
38 | /** @var string */
39 | private $reportContextExceptionLogLevel;
40 |
41 | public function __construct(string $reportContextExceptionLogLevel = 'debug')
42 | {
43 | $this->reportContextExceptionLogLevel = $reportContextExceptionLogLevel;
44 | }
45 |
46 | public function getNodeType(): string
47 | {
48 | return Node\Expr\MethodCall::class;
49 | }
50 |
51 | /**
52 | * @throws ShouldNotHappenException
53 | */
54 | public function processNode(Node $node, Scope $scope): array
55 | {
56 | if (! $node->name instanceof Node\Identifier) {
57 | // @codeCoverageIgnoreStart
58 | return []; // @codeCoverageIgnoreEnd
59 | }
60 |
61 | $calledOnType = $scope->getType($node->var);
62 | if (! (new ObjectType('Psr\Log\LoggerInterface'))->isSuperTypeOf($calledOnType)->yes()) {
63 | // @codeCoverageIgnoreStart
64 | return []; // @codeCoverageIgnoreEnd
65 | }
66 |
67 | /** @var Node\Arg[] $args */
68 | $args = $node->getArgs();
69 | if (count($args) === 0) {
70 | // @codeCoverageIgnoreStart
71 | return []; // @codeCoverageIgnoreEnd
72 | }
73 |
74 | $methodName = $node->name->toLowerString();
75 |
76 | $logLevels = [$methodName];
77 | $contextArgumentNo = 1;
78 | if ($methodName === 'log') {
79 | if (count($args) < 2) {
80 | return [];
81 | }
82 |
83 | $logLevelType = $scope->getType($args[0]->value);
84 |
85 | $logLevels = [];
86 | foreach ($logLevelType->getConstantStrings() as $constantString) {
87 | $logLevels[] = $constantString->getValue();
88 | }
89 |
90 | if (count($logLevels) === 0) {
91 | // cant find logLevels
92 | return [];
93 | }
94 |
95 | $contextArgumentNo = 2;
96 | } elseif (! in_array($methodName, LogLevelListInterface::LOGGER_LEVEL_METHODS, true)) {
97 | return [];
98 | }
99 |
100 | $throwable = $this->findCurrentScopeThrowableVariable($scope);
101 |
102 | if ($throwable === null) {
103 | return [];
104 | }
105 |
106 | if (! isset($args[$contextArgumentNo])) {
107 | if (! $this->hasReportLogLevel($logLevels)) {
108 | return [];
109 | }
110 |
111 | return [
112 | RuleErrorBuilder::message(
113 | sprintf(self::ERROR_MISSED_EXCEPTION_KEY, $methodName, "\${$throwable}")
114 | )->identifier('sfpPsrLog.contextRequireExceptionKey')->build(),
115 | ];
116 | }
117 |
118 | $context = $args[$contextArgumentNo];
119 |
120 | if (self::contextDoesNotHaveExceptionKey($context, $scope)) {
121 | if (! $this->hasReportLogLevel($logLevels)) {
122 | return [];
123 | }
124 |
125 | return [
126 | RuleErrorBuilder::message(
127 | sprintf(self::ERROR_MISSED_EXCEPTION_KEY, $methodName, "\${$throwable}")
128 | )->identifier('sfpPsrLog.contextRequireExceptionKey')->build(),
129 | ];
130 | }
131 |
132 | return [];
133 | }
134 |
135 | /**
136 | * @phpstan-param list $logLevels
137 | */
138 | public function hasReportLogLevel(array $logLevels): bool
139 | {
140 | foreach ($logLevels as $logLevel) {
141 | if ($this->isReportLogLevel($logLevel)) {
142 | return true;
143 | }
144 | }
145 |
146 | return false;
147 | }
148 |
149 | public function isReportLogLevel(string $logLevel): bool
150 | {
151 | return self::LOGGER_LEVELS[$logLevel] >= self::LOGGER_LEVELS[$this->reportContextExceptionLogLevel];
152 | }
153 |
154 | private function findCurrentScopeThrowableVariable(Scope $scope): ?string
155 | {
156 | foreach ($scope->getDefinedVariables() as $var) {
157 | if ((new ObjectType(Throwable::class))->isSuperTypeOf($scope->getVariableType($var))->yes()) {
158 | return $var;
159 | }
160 | }
161 |
162 | return null;
163 | }
164 |
165 | private static function contextDoesNotHaveExceptionKey(Node\Arg $context, Scope $scope): bool
166 | {
167 | $type = $scope->getType($context->value);
168 | if ($type->isArray()->yes()) {
169 | if ($type->hasOffsetValueType(new ConstantStringType('exception'))->yes()) {
170 | return false;
171 | }
172 | return true;
173 | }
174 |
175 | return false;
176 | }
177 | }
178 |
--------------------------------------------------------------------------------
/src/Rules/ContextTypeRule.php:
--------------------------------------------------------------------------------
1 |
25 | */
26 | final class ContextTypeRule implements Rule
27 | {
28 | /** @var RuleLevelHelper */
29 | private $ruleLevelHelper;
30 |
31 | /** @var ContextTypeProviderResolverInterface */
32 | private $contextTypeProviderResolver;
33 |
34 | public function __construct(
35 | RuleLevelHelper $ruleLevelHelper,
36 | ?ContextTypeProviderResolverInterface $contextTypeProviderResolver
37 | ) {
38 | $this->ruleLevelHelper = $ruleLevelHelper;
39 | $this->contextTypeProviderResolver = $contextTypeProviderResolver ?? new AnyScopeContextTypeProviderResolver(new Psr3ContextTypeProvider());
40 | }
41 |
42 | public function getNodeType(): string
43 | {
44 | return Node\Expr\MethodCall::class;
45 | }
46 |
47 | /**
48 | * @throws ShouldNotHappenException
49 | */
50 | public function processNode(Node $node, Scope $scope): array
51 | {
52 | if (! $node->name instanceof Node\Identifier) {
53 | // @codeCoverageIgnoreStart
54 | return []; // @codeCoverageIgnoreEnd
55 | }
56 |
57 | $calledOnType = $scope->getType($node->var);
58 | if (! (new ObjectType('Psr\Log\LoggerInterface'))->isSuperTypeOf($calledOnType)->yes()) {
59 | // @codeCoverageIgnoreStart
60 | return []; // @codeCoverageIgnoreEnd
61 | }
62 |
63 | /** @var Node\Arg[] $args */
64 | $args = $node->getArgs();
65 | if (count($args) === 0) {
66 | // @codeCoverageIgnoreStart
67 | return []; // @codeCoverageIgnoreEnd
68 | }
69 |
70 | $methodName = $node->name->toLowerString();
71 |
72 | $contextArgumentNo = 1;
73 | if ($methodName === 'log') {
74 | $contextArgumentNo = 2;
75 | } elseif (! in_array($methodName, LogLevelListInterface::LOGGER_LEVEL_METHODS, true)) {
76 | return [];
77 | }
78 |
79 | if (! isset($args[$contextArgumentNo])) {
80 | return [];
81 | }
82 |
83 | $argContextType = $scope->getType($args[$contextArgumentNo]->value);
84 |
85 | $acceptingContextType = $this->contextTypeProviderResolver->resolveContextTypeProvider($scope)->getType();
86 |
87 | $acceptsResult = $this->ruleLevelHelper->accepts($acceptingContextType, $argContextType, $scope->isDeclareStrictTypes());
88 |
89 | // To support PHPStan 1 & 2 both.
90 | // RuleLevelHelper::accepts() return type changed from bool to RuleLevelHelperAcceptsResult
91 | // https://github.com/phpstan/phpstan/blob/2.1.x/UPGRADING.md
92 | if (
93 | /** @phpstan-ignore identical.alwaysFalse */
94 | $acceptsResult === true ||
95 | (
96 | /** @phpstan-ignore phpstanApi.class, instanceof.alwaysFalse, booleanAnd.alwaysFalse, identical.alwaysFalse, instanceof.alwaysTrue */
97 | $acceptsResult instanceof RuleLevelHelperAcceptsResult && $acceptsResult->result === true
98 | )
99 | ) {
100 | return [];
101 | }
102 |
103 | return [
104 | RuleErrorBuilder::message(
105 | sprintf(
106 | 'Parameter #%d $context of method Psr\Log\LoggerInterface::%s() expects %s, %s given.',
107 | $contextArgumentNo + 1,
108 | $methodName,
109 | (string) $acceptingContextType->toPhpDocNode(),
110 | (string) $argContextType->toPhpDocNode()
111 | )
112 | )->identifier('sfpPsrLog.contextType')->build(),
113 | ];
114 | }
115 | }
116 |
--------------------------------------------------------------------------------
/src/Rules/LogLevelListInterface.php:
--------------------------------------------------------------------------------
1 |
23 | */
24 | final class LogMethodLevelRule implements Rule
25 | {
26 | private const ERROR_INVALID_LEVEL = <<<'MESSAGE'
27 | Parameter #1 $level of method Psr\Log\LoggerInterface::log() expects %s, %s given.
28 | MESSAGE;
29 |
30 | /** @var RuleLevelHelper */
31 | private $ruleLevelHelper;
32 |
33 | /** @var UnionType */
34 | private $acceptingLogLevel;
35 |
36 | public function __construct(RuleLevelHelper $ruleLevelHelper)
37 | {
38 | $this->ruleLevelHelper = $ruleLevelHelper;
39 | $this->acceptingLogLevel = new UnionType([
40 | new ConstantStringType('emergency'),
41 | new ConstantStringType('alert'),
42 | new ConstantStringType('critical'),
43 | new ConstantStringType('error'),
44 | new ConstantStringType('warning'),
45 | new ConstantStringType('notice'),
46 | new ConstantStringType('info'),
47 | new ConstantStringType('debug'),
48 | ]);
49 | }
50 |
51 | public function getNodeType(): string
52 | {
53 | return Node\Expr\MethodCall::class;
54 | }
55 |
56 | /**
57 | * @throws ShouldNotHappenException
58 | */
59 | public function processNode(Node $node, Scope $scope): array
60 | {
61 | if (! $node->name instanceof Node\Identifier) {
62 | // @codeCoverageIgnoreStart
63 | return []; // @codeCoverageIgnoreEnd
64 | }
65 |
66 | $calledOnType = $scope->getType($node->var);
67 | if (! (new ObjectType('Psr\Log\LoggerInterface'))->isSuperTypeOf($calledOnType)->yes()) {
68 | // @codeCoverageIgnoreStart
69 | return []; // @codeCoverageIgnoreEnd
70 | }
71 |
72 | /** @var Node\Arg[] $args */
73 | $args = $node->getArgs();
74 | if (count($args) === 0) {
75 | // @codeCoverageIgnoreStart
76 | return []; // @codeCoverageIgnoreEnd
77 | }
78 |
79 | $methodName = $node->name->toLowerString();
80 |
81 | if ($methodName !== 'log') {
82 | return [];
83 | }
84 |
85 | $argLevel = $scope->getType($args[0]->value);
86 |
87 | $acceptsResult = $this->ruleLevelHelper->accepts($this->acceptingLogLevel, $argLevel, $scope->isDeclareStrictTypes());
88 |
89 | // To support PHPStan 1 & 2 both.
90 | // RuleLevelHelper::accepts() return type changed from bool to RuleLevelHelperAcceptsResult
91 | // https://github.com/phpstan/phpstan/blob/2.1.x/UPGRADING.md
92 | if (
93 | /** @phpstan-ignore identical.alwaysFalse */
94 | $acceptsResult === true ||
95 | (
96 | /** @phpstan-ignore phpstanApi.class, instanceof.alwaysFalse, booleanAnd.alwaysFalse, identical.alwaysFalse, instanceof.alwaysTrue */
97 | $acceptsResult instanceof RuleLevelHelperAcceptsResult && $acceptsResult->result === true
98 | )
99 | ) {
100 | return [];
101 | }
102 |
103 | return [
104 | RuleErrorBuilder::message(
105 | sprintf(
106 | self::ERROR_INVALID_LEVEL,
107 | $this->acceptingLogLevel->toPhpDocNode()->__toString(),
108 | $argLevel->toPhpDocNode()->__toString()
109 | )
110 | )->identifier('sfpPsrLog.logMethodLevel')->build(),
111 | ];
112 | }
113 | }
114 |
--------------------------------------------------------------------------------
/src/Rules/MessageStaticStringRule.php:
--------------------------------------------------------------------------------
1 |
20 | */
21 | final class MessageStaticStringRule implements Rule
22 | {
23 | private const ERROR_MESSAGE_NOT_STATIC = 'Parameter $message of logger method Psr\Log\LoggerInterface::%s() is not a static string';
24 |
25 | public function getNodeType(): string
26 | {
27 | return Node\Expr\MethodCall::class;
28 | }
29 |
30 | /**
31 | * @throws ShouldNotHappenException
32 | */
33 | public function processNode(Node $node, Scope $scope): array
34 | {
35 | if (! $node->name instanceof Node\Identifier) {
36 | // @codeCoverageIgnoreStart
37 | return []; // @codeCoverageIgnoreEnd
38 | }
39 |
40 | $calledOnType = $scope->getType($node->var);
41 | if (! (new ObjectType('Psr\Log\LoggerInterface'))->isSuperTypeOf($calledOnType)->yes()) {
42 | // @codeCoverageIgnoreStart
43 | return []; // @codeCoverageIgnoreEnd
44 | }
45 |
46 | /** @var Node\Arg[] $args */
47 | $args = $node->getArgs();
48 | if (count($args) === 0) {
49 | // @codeCoverageIgnoreStart
50 | return []; // @codeCoverageIgnoreEnd
51 | }
52 |
53 | $methodName = $node->name->toLowerString();
54 |
55 | $messageArgumentNo = 0;
56 | if ($methodName === 'log') {
57 | if (
58 | count($args) < 2
59 | || ! $args[0]->value instanceof Node\Scalar\String_
60 | ) {
61 | // @codeCoverageIgnoreStart
62 | return []; // @codeCoverageIgnoreEnd
63 | }
64 |
65 | $messageArgumentNo = 1;
66 | } elseif (! in_array($methodName, LogLevelListInterface::LOGGER_LEVEL_METHODS, true)) {
67 | // @codeCoverageIgnoreStart
68 | return []; // @codeCoverageIgnoreEnd
69 | }
70 |
71 | $message = $args[$messageArgumentNo];
72 | $value = $scope->getType($message->value);
73 | $strings = $value->getConstantStrings();
74 |
75 | if (count($strings) === 0) {
76 | return [
77 | RuleErrorBuilder::message(
78 | sprintf(self::ERROR_MESSAGE_NOT_STATIC, $methodName)
79 | )
80 | ->identifier('sfpPsrLog.messageNotStaticString')
81 | ->tip('See https://www.php-fig.org/psr/psr-3/meta/#static-log-messages')
82 | ->build(),
83 | ];
84 | }
85 |
86 | return [];
87 | }
88 | }
89 |
--------------------------------------------------------------------------------
/src/Rules/PlaceholderCharactersRule.php:
--------------------------------------------------------------------------------
1 |
25 | */
26 | final class PlaceholderCharactersRule implements Rule
27 | {
28 | private const ERROR_DOUBLE_BRACES = 'Parameter $message of logger method Psr\Log\LoggerInterface::%s() should not includes double braces. - %s';
29 | private const ERROR_INVALID_CHAR = 'Parameter $message of logger method Psr\Log\LoggerInterface::%s() has braces. But it includes invalid characters for placeholder. - %s';
30 |
31 | public function getNodeType(): string
32 | {
33 | return Node\Expr\MethodCall::class;
34 | }
35 |
36 | /**
37 | * @throws ShouldNotHappenException
38 | */
39 | public function processNode(Node $node, Scope $scope): array
40 | {
41 | if (! $node->name instanceof Node\Identifier) {
42 | // @codeCoverageIgnoreStart
43 | return []; // @codeCoverageIgnoreEnd
44 | }
45 |
46 | $calledOnType = $scope->getType($node->var);
47 | if (! (new ObjectType('Psr\Log\LoggerInterface'))->isSuperTypeOf($calledOnType)->yes()) {
48 | // @codeCoverageIgnoreStart
49 | return []; // @codeCoverageIgnoreEnd
50 | }
51 |
52 | /** @var Node\Arg[] $args */
53 | $args = $node->getArgs();
54 | if (count($args) === 0) {
55 | // @codeCoverageIgnoreStart
56 | return []; // @codeCoverageIgnoreEnd
57 | }
58 |
59 | $methodName = $node->name->toLowerString();
60 |
61 | $messageArgumentNo = 0;
62 | if ($methodName === 'log') {
63 | if (
64 | count($args) < 2
65 | || ! $args[0]->value instanceof Node\Scalar\String_
66 | ) {
67 | // @codeCoverageIgnoreStart
68 | return []; // @codeCoverageIgnoreEnd
69 | }
70 |
71 | $messageArgumentNo = 1;
72 | } elseif (! in_array($methodName, LogLevelListInterface::LOGGER_LEVEL_METHODS, true)) {
73 | // @codeCoverageIgnoreStart
74 | return []; // @codeCoverageIgnoreEnd
75 | }
76 |
77 | $message = $args[$messageArgumentNo];
78 |
79 | $strings = $scope->getType($message->value)->getConstantStrings();
80 |
81 | if (count($strings) === 0) {
82 | return [];
83 | }
84 |
85 | $errors = [];
86 | foreach ($strings as $constantStringType) {
87 | $message = $constantStringType->getValue();
88 |
89 | $doubleBraceError = self::checkDoubleBrace($message, $methodName);
90 | if ($doubleBraceError instanceof RuleError) {
91 | $errors[] = $doubleBraceError;
92 | continue;
93 | }
94 |
95 | $invalidCharError = self::checkInvalidChar($message, $methodName);
96 | if ($invalidCharError instanceof RuleError) {
97 | $errors[] = $invalidCharError;
98 | }
99 | }
100 |
101 | return $errors;
102 | }
103 |
104 | private static function checkDoubleBrace(string $message, string $methodName): ?IdentifierRuleError
105 | {
106 | $matched = preg_match_all('#{{(.+?)}}#', $message, $matches);
107 |
108 | if ($matched === 0 || $matched === false) {
109 | return null;
110 | }
111 |
112 | return RuleErrorBuilder::message(
113 | sprintf(self::ERROR_DOUBLE_BRACES, $methodName, implode(',', $matches[0]))
114 | )
115 | ->identifier('sfpPsrLog.placeholderCharactersDoubleBraces')
116 | ->tip('See https://www.php-fig.org/psr/psr-3/#12-message')
117 | ->build();
118 | }
119 |
120 | private static function checkInvalidChar(string $message, string $methodName): ?IdentifierRuleError
121 | {
122 | $matched = preg_match_all('#{(.+?)}#', $message, $matches);
123 |
124 | if ($matched === 0 || $matched === false) {
125 | return null;
126 | }
127 |
128 | $invalidPlaceHolders = [];
129 | foreach ($matches[1] as $i => $placeholderCandidate) {
130 | if (preg_match('#\A[A-Za-z0-9_\.]+\z#', $placeholderCandidate) !== 1) {
131 | $invalidPlaceHolders[$i] = $matches[0][$i];
132 | }
133 | }
134 |
135 | if (count($invalidPlaceHolders) === 0) {
136 | return null;
137 | }
138 |
139 | return RuleErrorBuilder::message(
140 | sprintf(self::ERROR_INVALID_CHAR, $methodName, implode(',', $invalidPlaceHolders))
141 | )
142 | ->identifier('sfpPsrLog.placeholderCharactersInvalidChar')
143 | ->tip('See https://www.php-fig.org/psr/psr-3/#12-message')
144 | ->build();
145 | }
146 | }
147 |
--------------------------------------------------------------------------------
/src/Rules/PlaceholderCorrespondToKeysRule.php:
--------------------------------------------------------------------------------
1 |
24 | */
25 | final class PlaceholderCorrespondToKeysRule implements Rule
26 | {
27 | private const ERROR_MISSED_CONTEXT = 'Parameter $context of logger method Psr\Log\LoggerInterface::%s() is required, when placeholder braces exists - %s';
28 |
29 | private const ERROR_EMPTY_CONTEXT = 'Parameter $context of logger method Psr\Log\LoggerInterface::%s() is empty, when placeholder braces exists';
30 |
31 | private const ERROR_MISSED_KEY = 'Parameter $message of logger method Psr\Log\LoggerInterface::%s() has placeholder braces, but context key is not found against them. - %s';
32 |
33 | public function getNodeType(): string
34 | {
35 | return Node\Expr\MethodCall::class;
36 | }
37 |
38 | public function processNode(Node $node, Scope $scope): array
39 | {
40 | if (! $node->name instanceof Node\Identifier) {
41 | // @codeCoverageIgnoreStart
42 | return []; // @codeCoverageIgnoreEnd
43 | }
44 |
45 | $calledOnType = $scope->getType($node->var);
46 | if (! (new ObjectType('Psr\Log\LoggerInterface'))->isSuperTypeOf($calledOnType)->yes()) {
47 | // @codeCoverageIgnoreStart
48 | return []; // @codeCoverageIgnoreEnd
49 | }
50 |
51 | $args = $node->getArgs();
52 | if (count($args) === 0) {
53 | // @codeCoverageIgnoreStart
54 | return []; // @codeCoverageIgnoreEnd
55 | }
56 |
57 | $methodName = $node->name->toLowerString();
58 |
59 | $contextArgumentNo = 1;
60 | if ($methodName === 'log') {
61 | if (
62 | count($args) < 2
63 | || ! $args[0]->value instanceof Node\Scalar\String_
64 | ) {
65 | // @codeCoverageIgnoreStart
66 | return []; // @codeCoverageIgnoreEnd
67 | }
68 |
69 | $contextArgumentNo = 2;
70 | } elseif (! in_array($methodName, LogLevelListInterface::LOGGER_LEVEL_METHODS, true)) {
71 | // @codeCoverageIgnoreStart
72 | return []; // @codeCoverageIgnoreEnd
73 | }
74 |
75 | $message = $args[$contextArgumentNo - 1];
76 | $strings = $scope->getType($message->value)->getConstantStrings();
77 |
78 | if (count($strings) === 0) {
79 | return [];
80 | }
81 |
82 | $errors = [];
83 | foreach ($strings as $constantStringType) {
84 | $message = $constantStringType->getValue();
85 |
86 | $matched = preg_match_all('#{([A-Za-z0-9_\.]+?)}#', $message, $matches);
87 |
88 | if ($matched === 0 || $matched === false) {
89 | continue;
90 | }
91 |
92 | if (! isset($args[$contextArgumentNo])) {
93 | $errors[] = RuleErrorBuilder::message(
94 | sprintf(self::ERROR_MISSED_CONTEXT, $methodName, implode(',', $matches[0]))
95 | )->identifier('sfpPsrLog.placeholderCorrespondToKeysMissedContext')->build();
96 |
97 | continue;
98 | }
99 |
100 | $context = $args[$contextArgumentNo];
101 |
102 | $contextDoesNotHaveError = self::contextDoesNotHavePlaceholderKey($scope->getType($context->value), $methodName, $matches[0], $matches[1]);
103 | if ($contextDoesNotHaveError instanceof RuleError) {
104 | $errors[] = $contextDoesNotHaveError;
105 | }
106 | }
107 |
108 | return $errors;
109 | }
110 |
111 | /**
112 | * @phpstan-param list $braces
113 | * @phpstan-param list $placeholders
114 | */
115 | private static function contextDoesNotHavePlaceholderKey(Type $arrayType, string $methodName, array $braces, array $placeholders): ?IdentifierRuleError
116 | {
117 | if ($arrayType->isIterableAtLeastOnce()->no()) {
118 | return RuleErrorBuilder::message(
119 | self::ERROR_EMPTY_CONTEXT
120 | )->identifier('sfpPsrLog.placeholderCorrespondToKeysMissedKey')->build();
121 | }
122 |
123 | $constantArrays = $arrayType->getConstantArrays();
124 |
125 | foreach ($constantArrays as $constantArray) {
126 | $contextKeys = [];
127 | $checkBraces = $braces;
128 | foreach ($constantArray->getKeyTypes() as $keyType) {
129 | if ($keyType->isString()->no()) {
130 | // keyType be string checked by ContextKeyRule
131 | // @codeCoverageIgnoreStart
132 | continue; // @codeCoverageIgnoreEnd
133 | }
134 |
135 | $contextKeys[] = $keyType->getValue();
136 | }
137 |
138 | foreach ($placeholders as $i => $placeholder) {
139 | if (in_array($placeholder, $contextKeys, true)) {
140 | unset($checkBraces[$i]);
141 | }
142 | }
143 |
144 | if (count($checkBraces) === 0) {
145 | continue;
146 | }
147 |
148 | return RuleErrorBuilder::message(
149 | sprintf(self::ERROR_MISSED_KEY, $methodName, implode(',', $checkBraces))
150 | )->identifier('sfpPsrLog.placeholderCorrespondToKeysMissedKey')->build();
151 | }
152 |
153 | return null;
154 | }
155 | }
156 |
--------------------------------------------------------------------------------
/src/TypeMapping/BigQuery/Exception/UnsupportedTypeException.php:
--------------------------------------------------------------------------------
1 | $jsonPayloadFields
33 | */
34 | public function toArrayType(array $jsonPayloadFields): ConstantArrayType
35 | {
36 | return self::convertFieldsToTypes($jsonPayloadFields);
37 | }
38 |
39 | /**
40 | * @phpstan-param list $jsonPayloadFields
41 | */
42 | public static function convertFieldsToTypes(array $jsonPayloadFields, int $nestedLevel = 0): ConstantArrayType
43 | {
44 | $keyTypes = [];
45 | $valueTypes = [];
46 | $nextAutoIndexes = [0];
47 | $optionalKeys = [];
48 |
49 | $idx = 0;
50 | foreach ($jsonPayloadFields as $item) {
51 | if ($item['type'] === 'RECORD' || $item['type'] === 'STRUCT') {
52 | if (! isset($item['mode'], $item['fields'])) {
53 | throw new UnexpectedValueException('Offset mode or fields not exists');
54 | }
55 |
56 | // if ($item['mode'] === 'REPEATED') {
57 | // todo...
58 | // }
59 |
60 | $objectType = self::reverseRecordFieldsToObjectType($item['fields']);
61 | if ($objectType === null) {
62 | // todo consier ignore deep nested
63 | // if ($nestedLevel > 2) {
64 | // continue;
65 | // }
66 | $valueTypes[] = self::convertFieldsToTypes($item['fields'], ++$nestedLevel);
67 | } else {
68 | $valueTypes[] = $objectType;
69 | }
70 | } else {
71 | $valueTypes[] = self::convertTypeToPhpScalarType($item['type']);
72 | }
73 |
74 | $keyTypes[] = new ConstantStringType($item['name']);
75 | if (is_numeric($item['name'])) {
76 | $nextAutoIndexes[] = (int) $item['name'] + 1;
77 | }
78 |
79 | $optionalKeys[] = $idx;
80 | ++$idx;
81 | }
82 |
83 | return new ConstantArrayType($keyTypes, $valueTypes, $nextAutoIndexes, $optionalKeys);
84 | }
85 |
86 | /**
87 | * eg.
88 | * json_encode(["pub_date" => new \DateTime])
89 | * would be like {"pub_date":{"date":"2025-01-04 10:00:00.396494","timezone_type":3,"timezone":"UTC"}}.
90 | * if RECORD has 'date', 'timezone_type' & 'timezone' field, it would be `{pub_date: \DateTimeInterface}`
91 | *
92 | * @phpstan-param list $fields
93 | */
94 | public static function reverseRecordFieldsToObjectType(array $fields): ?ObjectType
95 | {
96 | $names = array_column($fields, 'name');
97 | sort($names);
98 | if (['date', 'timezone', 'timezone_type'] === $names) {
99 | return new ObjectType('\DateTimeInterface');
100 | }
101 |
102 | if (['class', 'code', 'file', 'message', 'trace'] === $names || ['class', 'code', 'file', 'message', 'previous', 'trace'] === $names) {
103 | return new ObjectType('\Throwable');
104 | }
105 |
106 | return null;
107 | }
108 |
109 | /**
110 | * @phpstan-param non_record_field_type $type
111 | *
112 | * https://cloud.google.com/bigquery/docs/reference/rest/v2/tables?hl=en#TableFieldSchema
113 | */
114 | public static function convertTypeToPhpScalarType(string $type): Type
115 | {
116 | switch ($type) {
117 | case 'STRING':
118 | return new StringType();
119 | case 'INTEGER':
120 | return new IntegerType();
121 | case 'FLOAT':
122 | return TypeCombinator::union(
123 | new AccessoryNumericStringType(),
124 | new IntegerType(),
125 | new FloatType()
126 | );
127 | case 'BOOLEAN':
128 | return new BooleanType();
129 | default:
130 | throw new UnsupportedTypeException('Not supported type - ' . $type);
131 | }
132 | }
133 | }
134 |
--------------------------------------------------------------------------------
/src/TypeMapping/BigQuery/TableFieldSchemaJsonPayloadTypeMapperInterface.php:
--------------------------------------------------------------------------------
1 | }
17 | */
18 | interface TableFieldSchemaJsonPayloadTypeMapperInterface
19 | {
20 | /**
21 | * @phpstan-param list $jsonPayloadFields
22 | */
23 | public function toArrayType(array $jsonPayloadFields): ConstantArrayType;
24 | }
25 |
--------------------------------------------------------------------------------
/src/TypeProvider/BigQueryContextTypeProvider.php:
--------------------------------------------------------------------------------
1 | */
34 | private $jsonPayloadFields;
35 |
36 | public function __construct(
37 | string $schemaFile,
38 | TableFieldSchemaJsonPayloadTypeMapperInterface $tableFieldSchemaJsonPayloadTypeMapper
39 | ) {
40 | $this->schemaFile = $schemaFile;
41 | $this->tableFieldSchemaJsonPayloadTypeMapper = $tableFieldSchemaJsonPayloadTypeMapper;
42 | }
43 |
44 | public function getType(): Type
45 | {
46 | $builder = ConstantArrayTypeBuilder::createFromConstantArray(
47 | $this->tableFieldSchemaJsonPayloadTypeMapper->toArrayType($this->getJsonPayloadFields())
48 | );
49 |
50 | $builder->setOffsetValueType(
51 | new ConstantStringType('exception'),
52 | new ObjectType('\Throwable'),
53 | true
54 | );
55 |
56 | return $builder->getArray();
57 | }
58 |
59 | /**
60 | * @phpstan-return list
61 | */
62 | private function getJsonPayloadFields(): array
63 | {
64 | if (! isset($this->jsonPayloadFields)) {
65 | $schemaJson = file_get_contents($this->schemaFile);
66 | if ($schemaJson === false) {
67 | throw new RuntimeException(sprintf('File %s cant open', $this->schemaFile));
68 | }
69 |
70 | $schema = json_decode($schemaJson, true);
71 |
72 | if (! is_array($schema)) {
73 | throw new RuntimeException('schema is not array');
74 | }
75 |
76 | $jsonPayloadFields = null;
77 | foreach ($schema as $item) {
78 | if (! is_array($item)) {
79 | throw new RuntimeException('item is not array');
80 | }
81 |
82 | if (! isset($item['name']) || $item['name'] !== 'jsonPayload') {
83 | continue;
84 | }
85 |
86 | if (! isset($item['fields']) || ! is_array($item['fields'])) {
87 | throw new UnexpectedValueException('fields is not array');
88 | }
89 |
90 | $jsonPayloadFields = $item['fields'];
91 | }
92 |
93 | if ($jsonPayloadFields === null) {
94 | throw new Exception('schemaFile must have jsonPayload field');
95 | }
96 |
97 | // phpcs:ignore
98 | /**
99 | * @todo validate list
100 | * @phpstan-var list $jsonPayloadFields
101 | */
102 | $this->jsonPayloadFields = $jsonPayloadFields;
103 | }
104 |
105 | return $this->jsonPayloadFields;
106 | }
107 | }
108 |
--------------------------------------------------------------------------------
/src/TypeProvider/ContextTypeProviderInterface.php:
--------------------------------------------------------------------------------
1 | exceptionClass = $exceptionClass;
21 | }
22 |
23 | public function getType(): Type
24 | {
25 | return new ConstantArrayType(
26 | [new ConstantStringType('exception')],
27 | [new ObjectType($this->exceptionClass)],
28 | [0],
29 | [0]
30 | );
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/src/TypeProviderResolver/AnyScopeContextTypeProviderResolver.php:
--------------------------------------------------------------------------------
1 | contextTypeProvider = $contextTypeProvider;
17 | }
18 |
19 | public function resolveContextTypeProvider(Scope $scope): ContextTypeProviderInterface
20 | {
21 | return $this->contextTypeProvider;
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/src/TypeProviderResolver/ContextTypeProviderResolverInterface.php:
--------------------------------------------------------------------------------
1 | */
16 | private $layerSet;
17 |
18 | /** @var AnyScopeContextTypeProviderResolver */
19 | private $anyScopeContextTypeProviderResolver;
20 |
21 | /** @var bool */
22 | private $fallbackAnyScope;
23 |
24 | /**
25 | * @phpstan-param array $layerSet
26 | */
27 | public function __construct(array $layerSet, bool $fallbackAnyScope = true)
28 | {
29 | $this->layerSet = $layerSet;
30 | $this->fallbackAnyScope = $fallbackAnyScope;
31 | $this->anyScopeContextTypeProviderResolver = new AnyScopeContextTypeProviderResolver(new Psr3ContextTypeProvider());
32 | }
33 |
34 | public function resolveContextTypeProvider(Scope $scope): ContextTypeProviderInterface
35 | {
36 | $classReflection = $scope->getClassReflection();
37 | if (! $classReflection instanceof ClassReflection) {
38 | if ($this->fallbackAnyScope) {
39 | return $this->anyScopeContextTypeProviderResolver->resolveContextTypeProvider($scope);
40 | }
41 | throw new LogicException('can not find belongs to ');
42 | }
43 |
44 | foreach ($this->layerSet as $interface => $contextTypeProvider) {
45 | if ($classReflection->implementsInterface($interface)) {
46 | return $contextTypeProvider;
47 | }
48 | }
49 |
50 | if (! $this->fallbackAnyScope) {
51 | throw new LogicException('can not find belongs to ');
52 | }
53 |
54 | return $this->anyScopeContextTypeProviderResolver->resolveContextTypeProvider($scope);
55 | }
56 | }
57 |
--------------------------------------------------------------------------------