├── 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 | [![Latest Stable Version](https://poser.pugx.org/struggle-for-php/sfp-phpstan-psr-log/v/stable)](https://packagist.org/packages/struggle-for-php/sfp-phpstan-psr-log) 4 | [![License](https://poser.pugx.org/struggle-for-php/sfp-phpstan-psr-log/license)](https://packagist.org/packages/struggle-for-php/sfp-phpstan-psr-log) 5 | [![Psalm coverage](https://shepherd.dev/github/struggle-for-php/sfp-phpstan-psr-log/coverage.svg)](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 | --------------------------------------------------------------------------------