├── LICENSE.md ├── README.md ├── composer.json ├── composer.lock └── src ├── ArgumentValidator ├── ArgumentValidationResult.php ├── ArgumentValidator.php ├── ScanfArgumentValidator.php └── StringfArgumentValidator.php ├── EventHandler ├── FunctionArgumentValidator.php ├── PossiblyInvalidArgumentForSpecifierValidator.php ├── PrintfFunctionArgumentValidator.php ├── ScanfFunctionArgumentValidator.php ├── SprintfFunctionReturnProvider.php └── UnnecessaryFunctionCallValidator.php ├── Parser ├── PhpParser │ ├── ArgumentValueParser.php │ ├── ConstantTypeParser.php │ ├── LiteralStringVariableInContextParser.php │ ├── ReturnTypeParser.php │ ├── StringableVariableInContextParser.php │ ├── VariableFromConstInContextParser.php │ └── VariableTypeParser.php ├── Psalm │ ├── FloatVariableParser.php │ ├── LiteralIntVariableParser.php │ ├── LiteralStringVariableParser.php │ ├── PhpVersion.php │ └── TypeParser.php └── TemplatedStringParser │ ├── Placeholder.php │ ├── SpecifierTypeGenerator.php │ └── TemplatedStringParser.php ├── Plugin.php └── Psalm └── Issue ├── PossiblyInvalidArgument.php ├── TooFewArguments.php ├── TooManyArguments.php └── UnnecessaryFunctionCall.php /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright (c) 2021 Maximilian Bösing. 2 | 3 | Redistribution and use in source and binary forms, with or without 4 | modification, are permitted provided that the following conditions are met: 5 | 6 | - Redistributions of source code must retain the above copyright notice, this 7 | list of conditions and the following disclaimer. 8 | 9 | - Redistributions in binary form must reproduce the above copyright notice, 10 | this list of conditions and the following disclaimer in the documentation 11 | and/or other materials provided with the distribution. 12 | 13 | - Neither the name of Maximilian Bösing nor the names of its contributors may 14 | be used to endorse or promote products derived from this software without 15 | specific prior written permission. 16 | 17 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 18 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 19 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 20 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR 21 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 22 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 23 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON 24 | ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 25 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 26 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 27 | 28 | This project includes other software related under the MIT license: 29 | - [TemplatedStringParser](src/Parser/TemplatedStringParser/TemplatedStringParser.php) partly based on the work of 30 | [PHPStan](https://github.com/phpstan/phpstan-src/blob/c471c7b050e0929daf432288770de673b394a983/src/Rules/Functions/PrintfParametersRule.php), Copyright 2016 Ondřej Mirtes 31 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Psalm Plugin Stringf 2 | 3 | [![Build Status](https://github.com/boesing/psalm-plugin-stringf/actions/workflows/continuous-integration.yml/badge.svg)](https://github.com/boesing/psalm-plugin-stringf/actions/workflows/continuous-integration.yml) 4 | 5 | This plugin provides additional checks to the built-in `sprintf`, `printf`, `sscanf` and `fscanf` function usage. 6 | 7 | ## Installation 8 | 9 | ### Require composer dev-dependency 10 | 11 | ``` 12 | composer require --dev boesing/psalm-plugin-stringf 13 | ``` 14 | 15 | ### Run Psalm-Plugin Binary 16 | 17 | ``` 18 | vendor/bin/psalm-plugin enable boesing/psalm-plugin-stringf 19 | ``` 20 | 21 | ## Features 22 | 23 | - Parses `sprintf` and `printf` arguments to verify if the number of passed arguments matches the amount of specifiers 24 | - Verifies if the return value of `sprintf` might be a `non-empty-string` 25 | - Verifies possibly invalid argument of `sprintf` and `printf` ([experimental](#report-possibly-invalid-argument-for-specifier)) 26 | - Verifies unnecessary function calls of `sprintf` and `printf` ([experimental](#report-unnecessary-function-calls)) 27 | 28 | ## Experimental 29 | 30 | This plugin also provides experimental features. 31 | 32 | Experimental features can be enabled by extending the plugin configuration as follows: 33 | 34 | ```xml 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | ``` 46 | 47 | ### Report Possibly Invalid Argument for Specifier 48 | 49 | ```xml 50 | 51 | 52 | 53 | 54 | 55 | ``` 56 | 57 | The `ReportPossiblyInvalidArgumentForSpecifier` experimental feature will report `PossiblyInvalidArgument` errors for 58 | arguments used with `sprintf` or `printf`. Here are some examples: 59 | 60 | ```php 61 | printf('%s', 1); 62 | ``` 63 | 64 | ``` 65 | PossiblyInvalidArgument: Argument 1 inferred as "int" does not match (any of) the suggested type(s) "string" 66 | ``` 67 | 68 | 69 | ```php 70 | printf('%d', 'foo'); 71 | ``` 72 | 73 | ``` 74 | PossiblyInvalidArgument: Argument 1 inferred as "string" does not match (any of) the suggested type(s) "float\|int\|numeric-string" 75 | ``` 76 | 77 | ### Report Unnecessary Function Calls 78 | 79 | ```xml 80 | 81 | 82 | 83 | 84 | 85 | ``` 86 | 87 | The `ReportUnnecessaryFunctionCalls` experimental feature will report `UnnecessaryFunctionCall` errors for 88 | function calls to `sprintf` or `printf` which can be omitted. Here are some examples: 89 | 90 | ```php 91 | printf('Some text without any placeholder'); 92 | sprintf('Some text without any placeholder'); 93 | ``` 94 | 95 | ``` 96 | UnnecessaryFunctionCall: Function call is unnecessary as there is no placeholder within the template. 97 | ``` 98 | 99 | 100 | ## Release Versioning Disclaimer 101 | 102 | This plugin won't follow semantic versioning even tho the version numbers state to be semantic versioning compliant. 103 | The source code of this plugin is not meant to used like library code and therefore **MUST** be treated as internal code. 104 | - This package will raise dependency requirements whenever necessary. 105 | - If there is a new major version of psalm, this plugin **MAY** migrate to that version but won't be early adopter. 106 | - If there is a new PHP minor/major version which is not supported by this library, this library **MAY** migrate to that version but won't be early adopter. 107 | 108 | So to summarize: If your project depends on the latest shiny versions of either Psalm or PHP, this plugin is not for you. If you can live with that, feel free to install. Demands in any way will be either ignored or handled whenever I feel I want to spend time on it. 109 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "boesing/psalm-plugin-stringf", 3 | "type": "psalm-plugin", 4 | "description": "Psalm plugin to work with `sprintf`, `printf`, `sscanf` and `fscanf`.", 5 | "license": "BSD-3-Clause", 6 | "require": { 7 | "php": "^7.4 || ~8.0.0 || ~8.1.0 || ~8.2.0", 8 | "ext-simplexml": "*", 9 | "vimeo/psalm": "^5.7.1", 10 | "webmozart/assert": "^1.11" 11 | }, 12 | "require-dev": { 13 | "codeception/codeception": "^4.1", 14 | "doctrine/coding-standard": "^11.1", 15 | "codeception/module-phpbrowser": "^2.0", 16 | "codeception/module-asserts": "^2.0", 17 | "weirdan/codeception-psalm-module": "^0.14.0", 18 | "symfony/yaml": "^5.4", 19 | "symfony/console": "^5.4", 20 | "symfony/finder": "^5.4", 21 | "composer/xdebug-handler": "^2" 22 | }, 23 | "extra": { 24 | "psalm": { 25 | "pluginClass": "Boesing\\PsalmPluginStringf\\Plugin" 26 | } 27 | }, 28 | "autoload": { 29 | "psr-4": { 30 | "Boesing\\PsalmPluginStringf\\": "src/" 31 | } 32 | }, 33 | "minimum-stability": "stable", 34 | "scripts": { 35 | "cs-check": "phpcs", 36 | "cs-fix": "phpcbf", 37 | "test": "codecept run", 38 | "analyze": "psalm --no-cache" 39 | }, 40 | "config": { 41 | "allow-plugins": { 42 | "dealerdirect/phpcodesniffer-composer-installer": true 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/ArgumentValidator/ArgumentValidationResult.php: -------------------------------------------------------------------------------- 1 | requiredArgumentCount = $requiredArgumentCount; 24 | $this->actualArgumentCount = $actualArgumentCount; 25 | } 26 | 27 | public function valid(): bool 28 | { 29 | return $this->requiredArgumentCount === $this->actualArgumentCount; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/ArgumentValidator/ArgumentValidator.php: -------------------------------------------------------------------------------- 1 | $arguments 15 | */ 16 | public function validate(TemplatedStringParser $templatedStringParser, array $arguments): ArgumentValidationResult; 17 | } 18 | -------------------------------------------------------------------------------- /src/ArgumentValidator/ScanfArgumentValidator.php: -------------------------------------------------------------------------------- 1 | printfArgumentValidator = new StringfArgumentValidator(2); 16 | } 17 | 18 | public function validate(TemplatedStringParser $templatedStringParser, array $arguments): ArgumentValidationResult 19 | { 20 | $result = $this->printfArgumentValidator->validate($templatedStringParser, $arguments); 21 | if ($result->valid()) { 22 | return $result; 23 | } 24 | 25 | if ($result->actualArgumentCount !== 0) { 26 | return $result; 27 | } 28 | 29 | // sscanf and fscanf can return the arguments in case no arguments are passed 30 | return new ArgumentValidationResult( 31 | 0, 32 | 0, 33 | ); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/ArgumentValidator/StringfArgumentValidator.php: -------------------------------------------------------------------------------- 1 | argumentsPriorPlaceholderArgumentsStart = $argumentsPriorPlaceholderArgumentsStart; 23 | } 24 | 25 | public function validate(TemplatedStringParser $templatedStringParser, array $arguments): ArgumentValidationResult 26 | { 27 | $requiredArgumentCount = $templatedStringParser->getPlaceholderCount(); 28 | $currentArgumentCount = $this->countArguments($arguments) - $this->argumentsPriorPlaceholderArgumentsStart; 29 | Assert::natural($currentArgumentCount); 30 | 31 | return new ArgumentValidationResult( 32 | $requiredArgumentCount, 33 | $currentArgumentCount, 34 | ); 35 | } 36 | 37 | /** 38 | * @param array $arguments 39 | */ 40 | private function countArguments(array $arguments): int 41 | { 42 | $argumentCount = 0; 43 | foreach ($arguments as $argument) { 44 | if ($argument instanceof VariadicPlaceholder) { 45 | continue; 46 | } 47 | 48 | $argumentCount++; 49 | } 50 | 51 | return $argumentCount; 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/EventHandler/FunctionArgumentValidator.php: -------------------------------------------------------------------------------- 1 | statementsSource = $statementsSource; 43 | $this->codeLocation = $codeLocation; 44 | $this->phpVersion = $phpVersion; 45 | $this->functionCall = $functionCall; 46 | } 47 | 48 | /** 49 | * @return 0|positive-int 50 | */ 51 | abstract protected function getTemplateArgumentIndex(): int; 52 | 53 | /** 54 | * @return non-empty-string 55 | */ 56 | abstract protected function getIssueTemplate(): string; 57 | 58 | abstract protected function getArgumentValidator(): ArgumentValidator; 59 | 60 | private function createCodeIssue( 61 | CodeLocation $codeLocation, 62 | string $functionName, 63 | int $argumentCount, 64 | int $requiredArgumentCount 65 | ): PluginIssue { 66 | $message = $this->createIssueMessage( 67 | $functionName, 68 | $requiredArgumentCount, 69 | $argumentCount, 70 | ); 71 | 72 | if ($argumentCount < $requiredArgumentCount) { 73 | return new TooFewArguments($message, $codeLocation, $functionName); 74 | } 75 | 76 | return new TooManyArguments($message, $codeLocation, $functionName); 77 | } 78 | 79 | /** 80 | * @psalm-return non-empty-string 81 | */ 82 | private function createIssueMessage(string $functionName, int $requiredArgumentCount, int $argumentCount): string 83 | { 84 | $message = sprintf( 85 | $this->getIssueTemplate(), 86 | $functionName, 87 | $requiredArgumentCount, 88 | $argumentCount, 89 | ); 90 | 91 | assert($message !== ''); 92 | 93 | return $message; 94 | } 95 | 96 | /** 97 | * @param non-empty-string $functionId 98 | */ 99 | abstract protected function canHandleFunction(string $functionId): bool; 100 | 101 | public static function afterEveryFunctionCallAnalysis(AfterEveryFunctionCallAnalysisEvent $event): void 102 | { 103 | $functionId = $event->getFunctionId(); 104 | if ($functionId === '') { 105 | return; 106 | } 107 | 108 | $functionCall = $event->getExpr(); 109 | $arguments = $functionCall->args; 110 | 111 | $statementsSource = $event->getStatementsSource(); 112 | 113 | (new static($statementsSource, new CodeLocation($statementsSource, $functionCall), PhpVersion::fromCodebase($event->getCodebase()), $functionCall))->validate( 114 | $functionId, 115 | $arguments, 116 | $event->getContext(), 117 | ); 118 | } 119 | 120 | /** 121 | * @param non-empty-string $functionName 122 | * @param array $arguments 123 | */ 124 | private function validate( 125 | string $functionName, 126 | array $arguments, 127 | Context $context 128 | ): void { 129 | if (! $this->canHandleFunction($functionName)) { 130 | return; 131 | } 132 | 133 | $templateArgumentIndex = $this->getTemplateArgumentIndex(); 134 | $template = null; 135 | 136 | foreach ($arguments as $index => $argument) { 137 | if ($index < $templateArgumentIndex) { 138 | continue; 139 | } 140 | 141 | if ($argument instanceof VariadicPlaceholder) { 142 | continue; 143 | } 144 | 145 | $template = $argument; 146 | break; 147 | } 148 | 149 | // Unable to detect template argument 150 | if ($template === null) { 151 | return; 152 | } 153 | 154 | try { 155 | $parsed = TemplatedStringParser::fromArgument( 156 | $functionName, 157 | $template, 158 | $context, 159 | $this->phpVersion->versionId, 160 | false, 161 | $this->statementsSource, 162 | ); 163 | } catch (InvalidArgumentException $exception) { 164 | return; 165 | } 166 | 167 | $validator = $this->getArgumentValidator(); 168 | $validationResult = $validator->validate($parsed, $arguments); 169 | 170 | if ($validationResult->valid()) { 171 | return; 172 | } 173 | 174 | IssueBuffer::maybeAdd($this->createCodeIssue( 175 | $this->codeLocation, 176 | $functionName, 177 | $validationResult->actualArgumentCount, 178 | $validationResult->requiredArgumentCount, 179 | )); 180 | } 181 | } 182 | -------------------------------------------------------------------------------- /src/EventHandler/PossiblyInvalidArgumentForSpecifierValidator.php: -------------------------------------------------------------------------------- 1 | */ 41 | private array $arguments; 42 | 43 | /** 44 | * @param non-empty-string $functionName 45 | * @param non-empty-list $arguments 46 | */ 47 | public function __construct( 48 | string $functionName, 49 | array $arguments 50 | ) { 51 | $this->functionName = $functionName; 52 | $this->arguments = $arguments; 53 | } 54 | 55 | public static function afterEveryFunctionCallAnalysis(AfterEveryFunctionCallAnalysisEvent $event): void 56 | { 57 | $functionId = $event->getFunctionId(); 58 | if (! in_array($functionId, self::FUNCTIONS, true)) { 59 | return; 60 | } 61 | 62 | $expression = $event->getExpr(); 63 | $arguments = $expression->args; 64 | 65 | $template = $arguments[0] ?? null; 66 | if ($template === null) { 67 | return; 68 | } 69 | 70 | Assert::allIsInstanceOf($arguments, Arg::class); 71 | Assert::isNonEmptyList($arguments); 72 | 73 | $context = $event->getContext(); 74 | $statementsSource = $event->getStatementsSource(); 75 | 76 | (new self( 77 | $functionId, 78 | $arguments, 79 | ))->assert( 80 | $statementsSource, 81 | new CodeLocation($statementsSource, $expression), 82 | $context, 83 | PhpVersion::fromCodebase($event->getCodebase())->versionId, 84 | ); 85 | } 86 | 87 | /** 88 | * @psalm-param positive-int $phpVersion 89 | */ 90 | public function assert( 91 | StatementsSource $statementsSource, 92 | CodeLocation $codeLocation, 93 | Context $context, 94 | int $phpVersion 95 | ): void { 96 | $template = $this->arguments[0]; 97 | 98 | try { 99 | $parsed = TemplatedStringParser::fromArgument( 100 | $this->functionName, 101 | $template, 102 | $context, 103 | $phpVersion, 104 | self::$allowIntegerForStringPlaceholder, 105 | $statementsSource, 106 | ); 107 | } catch (InvalidArgumentException $exception) { 108 | return; 109 | } 110 | 111 | $this->assertArgumentsMatchingPlaceholderTypes( 112 | $codeLocation, 113 | $parsed, 114 | $this->arguments, 115 | $context, 116 | ); 117 | } 118 | 119 | /** 120 | * @psalm-param non-empty-list $args 121 | */ 122 | private function assertArgumentsMatchingPlaceholderTypes( 123 | CodeLocation $codeLocation, 124 | TemplatedStringParser $parsed, 125 | array $args, 126 | Context $context 127 | ): void { 128 | foreach ($parsed->getPlaceholders() as $placeholder) { 129 | $argumentType = $placeholder->getArgumentType($args, $context); 130 | if ($argumentType === null) { 131 | continue; 132 | } 133 | 134 | $type = $placeholder->getSuggestedType(); 135 | if ($type === null) { 136 | continue; 137 | } 138 | 139 | if ($this->validateArgumentTypeMatchesSuggestedType($argumentType, $type)) { 140 | continue; 141 | } 142 | 143 | IssueBuffer::maybeAdd( 144 | new PossiblyInvalidArgument( 145 | sprintf( 146 | 'Argument %d inferred as "%s" does not match (any of) the suggested type(s) "%s"', 147 | $placeholder->position, 148 | $argumentType->getId(), 149 | $type->getId(), 150 | ), 151 | $codeLocation, 152 | $this->functionName, 153 | ), 154 | ); 155 | } 156 | } 157 | 158 | private function validateArgumentTypeMatchesSuggestedType(Union $argument, Union $suggested): bool 159 | { 160 | foreach ($argument->getAtomicTypes() as $type) { 161 | if ($this->invalidTypeWouldBeCoveredByPsalmItself($type)) { 162 | return true; 163 | } 164 | 165 | foreach ($suggested->getAtomicTypes() as $suggestType) { 166 | if ($type instanceof $suggestType) { 167 | return true; 168 | } 169 | 170 | if ($this->typeMatchesSuggestedTypeDueToAdditionalChecks($type, $suggestType)) { 171 | return true; 172 | } 173 | } 174 | } 175 | 176 | return false; 177 | } 178 | 179 | private function typeMatchesSuggestedTypeDueToAdditionalChecks(Atomic $type, Atomic $suggestType): bool 180 | { 181 | if ($suggestType instanceof TNumericString) { 182 | if ($type instanceof TLiteralString) { 183 | return is_numeric($type->value); 184 | } 185 | } 186 | 187 | return false; 188 | } 189 | 190 | private function invalidTypeWouldBeCoveredByPsalmItself(Atomic $type): bool 191 | { 192 | if ($type instanceof Atomic\TString) { 193 | return false; 194 | } 195 | 196 | if ($type instanceof Atomic\TInt) { 197 | return false; 198 | } 199 | 200 | if ($type instanceof Atomic\TFloat) { 201 | return false; 202 | } 203 | 204 | return true; 205 | } 206 | 207 | /** 208 | * @see \Boesing\PsalmPluginStringf\Plugin::registerFeatureHook() 209 | * 210 | * @param array $options 211 | */ 212 | public static function applyOptions(array $options): void 213 | { 214 | if (! isset($options['allowIntegerForString']) || $options['allowIntegerForString'] === 'yes') { 215 | return; 216 | } 217 | 218 | self::$allowIntegerForStringPlaceholder = false; 219 | } 220 | } 221 | -------------------------------------------------------------------------------- /src/EventHandler/PrintfFunctionArgumentValidator.php: -------------------------------------------------------------------------------- 1 | 25 | */ 26 | public static function getFunctionIds(): array 27 | { 28 | return [self::FUNCTION_SPRINTF]; 29 | } 30 | 31 | public static function getFunctionReturnType(FunctionReturnTypeProviderEvent $event): ?Type\Union 32 | { 33 | $functionName = $event->getFunctionId(); 34 | $functionCallArguments = $event->getCallArgs(); 35 | 36 | if (! isset($functionCallArguments[self::TEMPLATE_ARGUMENT_POSITION])) { 37 | return null; 38 | } 39 | 40 | $templateArgument = $functionCallArguments[self::TEMPLATE_ARGUMENT_POSITION]; 41 | $context = $event->getContext(); 42 | $statementSource = $event->getStatementsSource(); 43 | try { 44 | $parser = TemplatedStringParser::fromArgument( 45 | $functionName, 46 | $templateArgument, 47 | $context, 48 | PhpVersion::fromStatementSource($statementSource)->versionId, 49 | false, 50 | $statementSource, 51 | ); 52 | } catch (InvalidArgumentException $exception) { 53 | return null; 54 | } 55 | 56 | return self::detectTypes($parser, $functionCallArguments, $context); 57 | } 58 | 59 | /** 60 | * @psalm-param list $functionCallArguments 61 | */ 62 | private static function detectTypes( 63 | TemplatedStringParser $parser, 64 | array $functionCallArguments, 65 | Context $context 66 | ): ?Type\Union { 67 | $templateWithoutPlaceholder = $parser->getTemplateWithoutPlaceholder(); 68 | 69 | if ($templateWithoutPlaceholder !== '') { 70 | return new Type\Union( 71 | [new Type\Atomic\TNonEmptyString()], 72 | ); 73 | } 74 | 75 | $placeholders = $parser->getPlaceholders(); 76 | 77 | return self::detectReturnTypeWithConsideringFunctionCallArguments( 78 | $placeholders, 79 | $functionCallArguments, 80 | $context, 81 | ); 82 | } 83 | 84 | /** 85 | * @psalm-param array $placeholders 86 | * @psalm-param list $functionCallArguments 87 | */ 88 | private static function detectReturnTypeWithConsideringFunctionCallArguments( 89 | array $placeholders, 90 | array $functionCallArguments, 91 | Context $context 92 | ): ?Type\Union { 93 | if ($placeholders === [] || $functionCallArguments === []) { 94 | return null; 95 | } 96 | 97 | foreach ($placeholders as $placeholder) { 98 | if ($placeholder->stringifiedValueMayBeEmpty($functionCallArguments, $context)) { 99 | continue; 100 | } 101 | 102 | return new Type\Union([new Type\Atomic\TNonEmptyString()]); 103 | } 104 | 105 | return null; 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /src/EventHandler/UnnecessaryFunctionCallValidator.php: -------------------------------------------------------------------------------- 1 | */ 33 | private array $arguments; 34 | 35 | /** 36 | * @param non-empty-string $functionName 37 | * @param non-empty-list $arguments 38 | */ 39 | public function __construct( 40 | string $functionName, 41 | array $arguments 42 | ) { 43 | $this->functionName = $functionName; 44 | $this->arguments = $arguments; 45 | } 46 | 47 | public static function afterEveryFunctionCallAnalysis(AfterEveryFunctionCallAnalysisEvent $event): void 48 | { 49 | $functionId = $event->getFunctionId(); 50 | if (! in_array($functionId, self::FUNCTIONS, true)) { 51 | return; 52 | } 53 | 54 | $expression = $event->getExpr(); 55 | $arguments = $expression->args; 56 | 57 | $template = $arguments[0] ?? null; 58 | if ($template === null) { 59 | return; 60 | } 61 | 62 | Assert::allIsInstanceOf($arguments, Arg::class); 63 | Assert::isNonEmptyList($arguments); 64 | 65 | $context = $event->getContext(); 66 | $statementsSource = $event->getStatementsSource(); 67 | 68 | (new self( 69 | $functionId, 70 | $arguments, 71 | ))->assert( 72 | $statementsSource, 73 | new CodeLocation($statementsSource, $expression), 74 | $context, 75 | PhpVersion::fromCodebase($event->getCodebase())->versionId, 76 | ); 77 | } 78 | 79 | /** 80 | * @psalm-param positive-int $phpVersion 81 | */ 82 | private function assert( 83 | StatementsSource $statementsSource, 84 | CodeLocation $codeLocation, 85 | Context $context, 86 | int $phpVersion 87 | ): void { 88 | $template = $this->arguments[0]; 89 | 90 | try { 91 | $parsed = TemplatedStringParser::fromArgument( 92 | $this->functionName, 93 | $template, 94 | $context, 95 | $phpVersion, 96 | false, 97 | $statementsSource, 98 | ); 99 | } catch (InvalidArgumentException $exception) { 100 | return; 101 | } 102 | 103 | $this->assertFunctionCallMakesSense( 104 | $codeLocation, 105 | $parsed, 106 | ); 107 | } 108 | 109 | private function assertFunctionCallMakesSense( 110 | CodeLocation $codeLocation, 111 | TemplatedStringParser $parsed 112 | ): void { 113 | if ($parsed->getTemplate() !== $parsed->getTemplateWithoutPlaceholder()) { 114 | return; 115 | } 116 | 117 | // TODO: find out how to provide psalter functionality 118 | IssueBuffer::maybeAdd(new UnnecessaryFunctionCall($codeLocation, $this->functionName)); 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /src/Parser/PhpParser/ArgumentValueParser.php: -------------------------------------------------------------------------------- 1 | expr = $expr; 33 | $this->context = $context; 34 | $this->statementsSource = $statementsSource; 35 | } 36 | 37 | public static function create(Expr $expr, Context $context, StatementsSource $statementsSource): self 38 | { 39 | return new self($expr, $context, $statementsSource); 40 | } 41 | 42 | public function toString(): string 43 | { 44 | return $this->parse($this->expr, $this->context, false); 45 | } 46 | 47 | public function toType(): Union 48 | { 49 | if ($this->expr instanceof String_) { 50 | return new Union([Type::getString($this->expr->value)->getSingleStringLiteral()]); 51 | } 52 | 53 | if ($this->expr instanceof Expr\Variable) { 54 | return VariableTypeParser::parse($this->expr, $this->context); 55 | } 56 | 57 | if ($this->expr instanceof DNumber) { 58 | return Type::getFloat($this->expr->value); 59 | } 60 | 61 | if ($this->expr instanceof LNumber) { 62 | return Type::getInt(false, $this->expr->value); 63 | } 64 | 65 | if ($this->expr instanceof Expr\ConstFetch) { 66 | return ConstantTypeParser::parse($this->expr); 67 | } 68 | 69 | throw new InvalidArgumentException(sprintf( 70 | 'Cannot detect type from expression of type "%s"', 71 | $this->expr->getType(), 72 | )); 73 | } 74 | 75 | /** 76 | * Should return a string value which would also be used when casting the value to string. 77 | */ 78 | public function stringify(): string 79 | { 80 | return $this->parse($this->expr, $this->context, true); 81 | } 82 | 83 | private function parse(Expr $expr, Context $context, bool $cast): string 84 | { 85 | if ($expr instanceof String_) { 86 | return $expr->value; 87 | } 88 | 89 | if ($expr instanceof Expr\Variable) { 90 | return $cast 91 | ? StringableVariableInContextParser::parse($expr, $context, $this->statementsSource) 92 | : LiteralStringVariableInContextParser::parse($expr, $context, $this->statementsSource); 93 | } 94 | 95 | if ($expr instanceof Expr\ClassConstFetch || $expr instanceof Expr\ConstFetch) { 96 | return VariableFromConstInContextParser::parse($expr, $context, $this->statementsSource); 97 | } 98 | 99 | throw new InvalidArgumentException(sprintf(self::UNPARSABLE_ARGUMENT_VALUE, $expr->getType())); 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /src/Parser/PhpParser/ConstantTypeParser.php: -------------------------------------------------------------------------------- 1 | expr = $expr; 23 | } 24 | 25 | public static function parse(Expr\ConstFetch $expr): Union 26 | { 27 | return (new self($expr))->toType(); 28 | } 29 | 30 | private function toType(): Union 31 | { 32 | $resolvedName = $this->expr->name->parts[0] ?? null; 33 | if ($resolvedName === null) { 34 | throw new InvalidArgumentException('Provided constant does not contain resolved name.'); 35 | } 36 | 37 | if ($resolvedName === 'false') { 38 | return Type::getFalse(); 39 | } 40 | 41 | if ($resolvedName === 'true') { 42 | return Type::getTrue(); 43 | } 44 | 45 | throw new InvalidArgumentException(sprintf('Cannot convert constant "%s" to a type.', $resolvedName)); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/Parser/PhpParser/LiteralStringVariableInContextParser.php: -------------------------------------------------------------------------------- 1 | variable = $variable; 22 | } 23 | 24 | public static function parse(Expr\Variable $variable, Context $context, StatementsSource $statementsSource): string 25 | { 26 | return (new self($variable))->toString($context, $statementsSource); 27 | } 28 | 29 | private function toString(Context $context, StatementsSource $statementsSource): string 30 | { 31 | $name = $this->variable->name; 32 | if ($name instanceof Expr) { 33 | return ArgumentValueParser::create($name, $context, $statementsSource)->toString(); 34 | } 35 | 36 | $variableName = sprintf('$%s', $name); 37 | if (! isset($context->vars_in_scope[$variableName])) { 38 | throw new InvalidArgumentException(sprintf('Variable "%s" is not known in scope.', $variableName)); 39 | } 40 | 41 | return LiteralStringVariableParser::parse( 42 | $variableName, 43 | $context->vars_in_scope[$variableName], 44 | ); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/Parser/PhpParser/ReturnTypeParser.php: -------------------------------------------------------------------------------- 1 | statementsSource = $statementsSource; 41 | $this->context = $context; 42 | $this->value = $value; 43 | } 44 | 45 | /** 46 | * @param FuncCall|StaticCall|MethodCall $value 47 | */ 48 | public static function create( 49 | StatementsSource $statementsSource, 50 | Context $context, 51 | Expr $value 52 | ): self { 53 | return new self($statementsSource, $context, $value); 54 | } 55 | 56 | public function toType(): Union 57 | { 58 | if ($this->value instanceof FuncCall) { 59 | return $this->detectTypeFromFunctionCall($this->value); 60 | } 61 | 62 | if ($this->value instanceof StaticCall) { 63 | return $this->detectTypeFromStaticMethodCall($this->value); 64 | } 65 | 66 | return $this->detectTypeFromMethodCall($this->value); 67 | } 68 | 69 | private function detectTypeFromFunctionCall(FuncCall $value): Union 70 | { 71 | $name = $value->name; 72 | Assert::isInstanceOf($name, Name::class, 'Could not detect function name.'); 73 | 74 | $source = $this->statementsSource; 75 | if (! $source instanceof StatementsAnalyzer) { 76 | throw new InvalidArgumentException(sprintf( 77 | 'Invalid statements source given. Can only handle %s at this time.', 78 | StatementsAnalyzer::class, 79 | )); 80 | } 81 | 82 | $function_id = $name->toLowerString(); 83 | 84 | /** @psalm-suppress InternalMethod I don't see any other way of detecting the return type of a function (yet) */ 85 | $analyzer = $source->getFunctionAnalyzer($function_id); 86 | if ($analyzer === null) { 87 | throw new InvalidArgumentException(sprintf( 88 | 'Could not detect function analyzer for `function_id`: %s', 89 | $function_id, 90 | )); 91 | } 92 | 93 | /** @psalm-suppress InternalMethod I don't see any other way of detecting the return type of a function (yet) */ 94 | $storage = $analyzer->getFunctionLikeStorage($source); 95 | $declared_return_type = $storage->return_type; 96 | if ($declared_return_type === null) { 97 | throw new InvalidArgumentException(sprintf('Could not detect return type for `function_id`: %s', $function_id)); 98 | } 99 | 100 | return $declared_return_type; 101 | } 102 | 103 | private function detectTypeFromStaticMethodCall(StaticCall $value): Union 104 | { 105 | $class = $value->class; 106 | if (! $class instanceof Name) { 107 | throw new InvalidArgumentException(sprintf( 108 | 'Expected `class` to be instance of `%s`: `%s` given.', 109 | Name::class, 110 | get_class($class), 111 | )); 112 | } 113 | 114 | $method = $value->name; 115 | if (! $method instanceof Identifier) { 116 | throw new InvalidArgumentException(sprintf( 117 | 'Expected `name` to be instance of `%s`: `%s` given.', 118 | Identifier::class, 119 | get_class($method), 120 | )); 121 | } 122 | 123 | /** @var class-string $className */ 124 | $className = $class->toString(); 125 | 126 | return $this->detectTypeFromMethodCallOfClass($method, $className); 127 | } 128 | 129 | private function detectTypeFromMethodCall(MethodCall $value): Union 130 | { 131 | $class = $this->detectClass($value->var); 132 | $method = $value->name; 133 | 134 | if (! $method instanceof Identifier) { 135 | throw new InvalidArgumentException(sprintf( 136 | 'Expected `name` to be instance of `%s`: `%s` given.', 137 | Identifier::class, 138 | get_class($method), 139 | )); 140 | } 141 | 142 | return $this->detectTypeFromMethodCallOfClass($method, $class); 143 | } 144 | 145 | /** 146 | * @param class-string $class 147 | */ 148 | private function detectTypeFromMethodCallOfClass(Identifier $method, string $class): Union 149 | { 150 | /** @psalm-suppress InternalMethod We need the class like storage to detect the return type of the method call of a specific class. */ 151 | $classLikeStorage = $this->statementsSource->getCodebase()->classlike_storage_provider->get($class); 152 | /** @var lowercase-string $lowercasedMethodName */ 153 | $lowercasedMethodName = $method->toLowerString(); 154 | $methodStorage = $classLikeStorage->methods[$lowercasedMethodName] ?? null; 155 | if (! $methodStorage instanceof MethodStorage) { 156 | throw new InvalidArgumentException( 157 | 'Provided static call does contain a method call to a method which can not be found within the methods parsed from the class.', 158 | ); 159 | } 160 | 161 | $returnType = $methodStorage->return_type; 162 | if ($returnType === null) { 163 | throw new InvalidArgumentException(sprintf('Could not detect return type for method call: %s::%s', $class, $method->toString())); 164 | } 165 | 166 | return $returnType; 167 | } 168 | 169 | /** @return class-string */ 170 | private function detectClass(Expr $var): string 171 | { 172 | if ($var instanceof Expr\New_) { 173 | $class = $var->class; 174 | if (! $class instanceof Name) { 175 | throw new InvalidArgumentException(sprintf( 176 | 'Expected `class` to be instance of `%s`: `%s` given.', 177 | Name::class, 178 | get_class($class), 179 | )); 180 | } 181 | 182 | /** @var class-string $className */ 183 | $className = $class->toString(); 184 | 185 | return $className; 186 | } 187 | 188 | if ($var instanceof Expr\Variable) { 189 | $variableName = $var->name; 190 | if (! is_string($variableName)) { 191 | throw new InvalidArgumentException('Can\'t handle non-string variable name'); 192 | } 193 | 194 | $variableNameWithLeadingDollar = sprintf('$%s', $variableName); 195 | if (! isset($this->context->vars_possibly_in_scope[$variableNameWithLeadingDollar])) { 196 | throw new InvalidArgumentException('Used variable is unknown in context'); 197 | } 198 | 199 | $variable = $this->context->vars_in_scope[$variableNameWithLeadingDollar]; 200 | if (! $variable->isSingle()) { 201 | throw new InvalidArgumentException('Unable to detect class name from non-single typed variable.'); 202 | } 203 | 204 | $type = $variable->getSingleAtomic(); 205 | if (! $type instanceof TNamedObject) { 206 | throw new InvalidArgumentException('Unable to detect class name from non-named object.'); 207 | } 208 | 209 | /** @var class-string $className */ 210 | $className = $type->value; 211 | 212 | return $className; 213 | } 214 | 215 | throw new InvalidArgumentException(sprintf('Unable to parse class name from expression of type: %s', get_class($var))); 216 | } 217 | } 218 | -------------------------------------------------------------------------------- /src/Parser/PhpParser/StringableVariableInContextParser.php: -------------------------------------------------------------------------------- 1 | variable = $variable; 25 | } 26 | 27 | public static function parse(Variable $variable, Context $context, StatementsSource $statementsSource): string 28 | { 29 | return (new self($variable))->toString($context, $statementsSource); 30 | } 31 | 32 | private function toString(Context $context, StatementsSource $statementsSource): string 33 | { 34 | $name = $this->variable->name; 35 | if ($name instanceof Expr) { 36 | return ArgumentValueParser::create($name, $context, $statementsSource)->stringify(); 37 | } 38 | 39 | $variableName = sprintf('$%s', $name); 40 | if (! isset($context->vars_in_scope[$variableName])) { 41 | throw new InvalidArgumentException(sprintf('Variable "%s" is not known in scope.', $variableName)); 42 | } 43 | 44 | $variable = $context->vars_in_scope[$variableName]; 45 | if ($variable->isSingleStringLiteral()) { 46 | return LiteralStringVariableParser::parse($variableName, $variable); 47 | } 48 | 49 | if ($variable->isSingleIntLiteral()) { 50 | return LiteralIntVariableParser::stringify($variableName, $variable); 51 | } 52 | 53 | if ($variable->isSingleFloatLiteral()) { 54 | return FloatVariableParser::stringify($variable); 55 | } 56 | 57 | throw new InvalidArgumentException(sprintf( 58 | 'Cannot extract string from variable "%s" with type "%s"', 59 | $variableName, 60 | (string) $variable, 61 | )); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/Parser/PhpParser/VariableFromConstInContextParser.php: -------------------------------------------------------------------------------- 1 | expr = $expr; 41 | $this->context = $context; 42 | $this->statementsSource = $statementsSource; 43 | } 44 | 45 | /** 46 | * @param ClassConstFetch|ConstFetch $expr 47 | */ 48 | public static function parse( 49 | Expr $expr, 50 | Context $context, 51 | StatementsSource $statementsSource 52 | ): string { 53 | return (new self($expr, $context, $statementsSource))->toString(); 54 | } 55 | 56 | private function toString(): string 57 | { 58 | if ($this->expr instanceof ClassConstFetch) { 59 | return $this->parseClassConstant($this->expr, $this->context); 60 | } 61 | 62 | return $this->parseConstant($this->expr, $this->context); 63 | } 64 | 65 | private function parseClassConstant(ClassConstFetch $expr, Context $context): string 66 | { 67 | if (! $expr->class instanceof Name) { 68 | throw new InvalidArgumentException(sprintf( 69 | 'Expected an instance of "%s" as ClassConstFetch::$class property, got: %s', 70 | Name::class, 71 | get_class($expr->class), 72 | )); 73 | } 74 | 75 | if (! $expr->name instanceof Identifier) { 76 | throw new InvalidArgumentException(sprintf( 77 | 'Expected an instance of "%s" as ClassConstFetch::$name property, got: %s', 78 | Identifier::class, 79 | get_class($expr->name), 80 | )); 81 | } 82 | 83 | $className = $expr->class->toString(); 84 | 85 | if ($expr->class->toLowerString() === 'self') { 86 | assert($context->self !== null); 87 | $className = $context->self; 88 | } 89 | 90 | $constant = sprintf('%s::%s', $className, $expr->name->toString()); 91 | 92 | if ($context->hasVariable($constant)) { 93 | return $this->extractMostAccurateStringRepresentationOfType( 94 | $context->vars_in_scope[$constant], 95 | ); 96 | } 97 | 98 | throw new InvalidArgumentException(sprintf('Could not find class constant "%s" in scope.', $constant)); 99 | } 100 | 101 | private function parseConstant(ConstFetch $expr, Context $context): string 102 | { 103 | $constant = (string) $expr->name; 104 | 105 | if ($context->hasVariable($constant)) { 106 | return $this->extractMostAccurateStringRepresentationOfType( 107 | $context->vars_in_scope[$constant], 108 | ); 109 | } 110 | 111 | throw new InvalidArgumentException(sprintf('Could not find constant "%s" in scope.', $constant)); 112 | } 113 | 114 | /** 115 | * @param ClassConstFetch|ConstFetch $expr 116 | */ 117 | private function extractMostAccurateStringRepresentationOfType( 118 | Union $type 119 | ): string { 120 | if ($type->isSingleStringLiteral()) { 121 | return $type->getSingleStringLiteral()->value; 122 | } 123 | 124 | if ($type->isSingleFloatLiteral()) { 125 | return (string) $type->getSingleFloatLiteral()->value; 126 | } 127 | 128 | if ($type->isSingleIntLiteral()) { 129 | return (string) $type->getSingleIntLiteral()->value; 130 | } 131 | 132 | $nodeTypeProvider = $this->statementsSource->getNodeTypeProvider(); 133 | if ($nodeTypeProvider instanceof NodeDataProvider) { 134 | return $this->extractMostAccurateStringRepresentationOfTypeFromNodeDataProvider( 135 | $nodeTypeProvider, 136 | $type, 137 | ); 138 | } 139 | 140 | throw $this->createInvalidArgumentException($type); 141 | } 142 | 143 | /** 144 | * Method uses reflection to hijack the native string which was inferred by php-parser. By doing this, we can 145 | * bypass the `maxStringLength` psalm setting. 146 | */ 147 | private function extractMostAccurateStringRepresentationOfTypeFromNodeDataProvider( 148 | NodeDataProvider $nodeDataProvider, 149 | Union $type 150 | ): string { 151 | $reflectionClass = new ReflectionClass($nodeDataProvider); 152 | if (! $reflectionClass->hasProperty('node_types')) { 153 | throw $this->createInvalidArgumentException($type); 154 | } 155 | 156 | $nodeTypesProperty = $reflectionClass->getProperty('node_types'); 157 | $nodeTypesProperty->setAccessible(true); 158 | $nodeTypes = $nodeTypesProperty->getValue($nodeDataProvider); 159 | if (! $nodeTypes instanceof SplObjectStorage) { 160 | throw $this->createInvalidArgumentException($type); 161 | } 162 | 163 | foreach ($nodeTypes as $phpParserType) { 164 | if (! $phpParserType instanceof String_) { 165 | continue; 166 | } 167 | 168 | $psalmType = $nodeTypes->offsetGet($phpParserType); 169 | if (! $psalmType instanceof TypeNode) { 170 | continue; 171 | } 172 | 173 | if ($psalmType !== $type) { 174 | continue; 175 | } 176 | 177 | return $phpParserType->value; 178 | } 179 | 180 | throw $this->createInvalidArgumentException($type); 181 | } 182 | 183 | private function createInvalidArgumentException(Union $type): InvalidArgumentException 184 | { 185 | return new InvalidArgumentException(sprintf( 186 | 'Unable to parse a string representation of the provided type: %s', 187 | $type->getId(), 188 | )); 189 | } 190 | } 191 | -------------------------------------------------------------------------------- /src/Parser/PhpParser/VariableTypeParser.php: -------------------------------------------------------------------------------- 1 | expr = $expr; 21 | } 22 | 23 | public static function parse(Expr\Variable $expr, Context $context): Union 24 | { 25 | return (new self($expr))->fromContext($context); 26 | } 27 | 28 | private function fromContext(Context $context): Union 29 | { 30 | $name = $this->expr->name; 31 | if ($name instanceof Expr) { 32 | throw new InvalidArgumentException('Cannot detect type from expression variable'); 33 | } 34 | 35 | $variableName = sprintf('$%s', $name); 36 | $variable = $context->vars_in_scope[$variableName] ?? null; 37 | if ($variable === null) { 38 | throw new InvalidArgumentException(sprintf( 39 | 'Variable "%s" is not available in provided scope.', 40 | $variableName, 41 | )); 42 | } 43 | 44 | return $variable; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/Parser/Psalm/FloatVariableParser.php: -------------------------------------------------------------------------------- 1 | isFloat()); 22 | $this->variable = $this->extract($variableType); 23 | } 24 | 25 | public static function stringify(Union $variableType): string 26 | { 27 | return (new self($variableType))->toString(); 28 | } 29 | 30 | private function toString(): string 31 | { 32 | return (string) $this->toSingleFloat(); 33 | } 34 | 35 | private function toSingleFloat(): float 36 | { 37 | if (! $this->variable instanceof TLiteralFloat) { 38 | throw new LogicException('Variable is not a literal float.'); 39 | } 40 | 41 | return $this->variable->value; 42 | } 43 | 44 | private function extract(Union $variableType): TFloat 45 | { 46 | if ($variableType->isSingleFloatLiteral()) { 47 | return $variableType->getSingleFloatLiteral(); 48 | } 49 | 50 | $atomicTypes = $variableType->getAtomicTypes(); 51 | assert(isset($atomicTypes['float'])); 52 | $type = $atomicTypes['float']; 53 | assert($type instanceof TFloat); 54 | 55 | return $type; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/Parser/Psalm/LiteralIntVariableParser.php: -------------------------------------------------------------------------------- 1 | variable = $variableType; 19 | if (! $variableType->isSingleIntLiteral()) { 20 | throw new InvalidArgumentException(sprintf( 21 | 'Cannot parse literal int from variable "%s" of type: %s', 22 | $variableName, 23 | (string) $variableType, 24 | )); 25 | } 26 | } 27 | 28 | public static function stringify(string $variableName, Union $variableType): string 29 | { 30 | return (new self($variableName, $variableType))->toString(); 31 | } 32 | 33 | private function toString(): string 34 | { 35 | $literal = $this->variable->getSingleIntLiteral(); 36 | 37 | return (string) $literal->value; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/Parser/Psalm/LiteralStringVariableParser.php: -------------------------------------------------------------------------------- 1 | isSingleStringLiteral()) { 22 | throw new InvalidArgumentException(sprintf( 23 | 'Cannot parse literal string from variable "%s" of type: %s', 24 | $variableName, 25 | (string) $variableType, 26 | )); 27 | } 28 | 29 | return self::string($variableType->getSingleStringLiteral()); 30 | } 31 | 32 | public static function string(TLiteralString $literalString): string 33 | { 34 | return $literalString->value; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/Parser/Psalm/PhpVersion.php: -------------------------------------------------------------------------------- 1 | versionId = $versionId; 22 | } 23 | 24 | public static function fromCodebase(Codebase $codebase): self 25 | { 26 | $versionId = $codebase->analysis_php_version_id; 27 | Assert::positiveInteger($versionId); 28 | 29 | return new self($versionId); 30 | } 31 | 32 | public static function fromStatementSource(StatementsSource $statementsSource): self 33 | { 34 | return self::fromCodebase($statementsSource->getCodebase()); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/Parser/Psalm/TypeParser.php: -------------------------------------------------------------------------------- 1 | type = $type; 16 | } 17 | 18 | public static function create(Union $type): self 19 | { 20 | return new self($type); 21 | } 22 | 23 | public function stringify(): ?string 24 | { 25 | $type = $this->type; 26 | 27 | if ($type->isNull()) { 28 | return ''; 29 | } 30 | 31 | if ($type->isSingleIntLiteral()) { 32 | return (string) $type->getSingleIntLiteral()->value; 33 | } 34 | 35 | if ($type->isSingleStringLiteral()) { 36 | return $type->getSingleStringLiteral()->value; 37 | } 38 | 39 | if ($type->isFloat()) { 40 | if ($type->isSingleFloatLiteral()) { 41 | return FloatVariableParser::stringify($type); 42 | } 43 | 44 | return null; 45 | } 46 | 47 | if ($type->isTrue()) { 48 | return '1'; 49 | } 50 | 51 | if ($type->isFalsable()) { 52 | return ''; 53 | } 54 | 55 | return null; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/Parser/TemplatedStringParser/Placeholder.php: -------------------------------------------------------------------------------- 1 | */ 29 | private array $repeated = []; 30 | 31 | private ?Union $type; 32 | 33 | private bool $allowIntegerForStringPlaceholder; 34 | 35 | private StatementsSource $statementsSource; 36 | 37 | /** 38 | * @psalm-param non-empty-string $value 39 | * @psalm-param positive-int $position 40 | */ 41 | private function __construct( 42 | string $value, 43 | int $position, 44 | bool $allowIntegerForStringPlaceholder, 45 | StatementsSource $statementsSource 46 | ) { 47 | $this->value = $value; 48 | $this->position = $position; 49 | $this->argumentValueType = null; 50 | $this->type = null; 51 | $this->allowIntegerForStringPlaceholder = $allowIntegerForStringPlaceholder; 52 | $this->statementsSource = $statementsSource; 53 | } 54 | 55 | /** 56 | * @psalm-param non-empty-string $value 57 | * @psalm-param positive-int $position 58 | */ 59 | public static function create(string $value, int $position, bool $allowIntegerForStringPlaceholder, StatementsSource $statementsSource): self 60 | { 61 | return new self($value, $position, $allowIntegerForStringPlaceholder, $statementsSource); 62 | } 63 | 64 | /** 65 | * @psalm-param list $functionCallArguments 66 | */ 67 | public function stringifiedValueMayBeEmpty(array $functionCallArguments, Context $context): bool 68 | { 69 | $type = $this->getArgumentType($functionCallArguments, $context); 70 | if ($type === null) { 71 | return true; 72 | } 73 | 74 | if ($this->isNonEmptyString($type)) { 75 | return false; 76 | } 77 | 78 | $string = $this->stringify($type); 79 | 80 | return $string === null || $string === ''; 81 | } 82 | 83 | /** 84 | * @psalm-param list $functionCallArguments 85 | */ 86 | public function getArgumentType(array $functionCallArguments, Context $context): ?Union 87 | { 88 | if ($this->argumentValueType) { 89 | return $this->argumentValueType; 90 | } 91 | 92 | $argument = $functionCallArguments[$this->position] ?? null; 93 | if ($argument === null) { 94 | return null; 95 | } 96 | 97 | try { 98 | $this->argumentValueType = $this->getArgumentValueType($argument->value, $context); 99 | } catch (InvalidArgumentException $exception) { 100 | return null; 101 | } 102 | 103 | return $this->argumentValueType; 104 | } 105 | 106 | private function stringify(Union $type): ?string 107 | { 108 | return TypeParser::create($type)->stringify(); 109 | } 110 | 111 | private function isNonEmptyString(Union $type): bool 112 | { 113 | if (! $type->isString()) { 114 | return false; 115 | } 116 | 117 | foreach ($type->getAtomicTypes() as $type) { 118 | if (! $type instanceof TNonEmptyString) { 119 | return false; 120 | } 121 | } 122 | 123 | return true; 124 | } 125 | 126 | public function withRepeatedPlaceholder(Placeholder $placeholder): self 127 | { 128 | $instance = clone $this; 129 | $instance->repeated[] = $placeholder; 130 | $instance->type = null; 131 | 132 | return $instance; 133 | } 134 | 135 | public function getSuggestedType(): ?Union 136 | { 137 | if ($this->type) { 138 | return $this->type; 139 | } 140 | 141 | try { 142 | $type = SpecifierTypeGenerator::create($this->value, $this->allowIntegerForStringPlaceholder)->getSuggestedType(); 143 | } catch (InvalidArgumentException $exception) { 144 | return null; 145 | } 146 | 147 | if ($this->repeated === []) { 148 | return $this->type = $type; 149 | } 150 | 151 | $unions = [$type]; 152 | 153 | foreach ($this->repeated as $placeholder) { 154 | $suggestion = $placeholder->getSuggestedType(); 155 | if ($suggestion === null) { 156 | return null; 157 | } 158 | 159 | $unions[] = $suggestion; 160 | } 161 | 162 | $types = []; 163 | foreach ($unions as $union) { 164 | foreach ($union->getAtomicTypes() as $type) { 165 | $types[] = $type; 166 | } 167 | } 168 | 169 | return $this->type = new Union($types); 170 | } 171 | 172 | private function getArgumentValueType(Expr $value, Context $context): Union 173 | { 174 | if ($value instanceof Expr\FuncCall || $value instanceof Expr\StaticCall || $value instanceof Expr\MethodCall) { 175 | return ReturnTypeParser::create($this->statementsSource, $context, $value)->toType(); 176 | } 177 | 178 | return ArgumentValueParser::create($value, $context, $this->statementsSource)->toType(); 179 | } 180 | } 181 | -------------------------------------------------------------------------------- /src/Parser/TemplatedStringParser/SpecifierTypeGenerator.php: -------------------------------------------------------------------------------- 1 | specifier = $this->parse($specifier); 31 | $this->allowIntegerForStringPlaceholder = $allowIntegerForStringPlaceholder; 32 | } 33 | 34 | /** 35 | * @psalm-param non-empty-string $specifier 36 | */ 37 | public static function create(string $specifier, bool $allowIntegerForStringPlaceholder): self 38 | { 39 | return new self($specifier, $allowIntegerForStringPlaceholder); 40 | } 41 | 42 | public function getSuggestedType(): Type\Union 43 | { 44 | switch ($this->specifier) { 45 | case 's': 46 | return $this->stringable(); 47 | 48 | case 'd': 49 | case 'f': 50 | return $this->numeric(); 51 | 52 | default: 53 | throw new InvalidArgumentException(sprintf('Specifier "%s" is not yet supported.', $this->specifier)); 54 | } 55 | } 56 | 57 | /** @psalm-return non-empty-string */ 58 | private function parse(string $specifier): string 59 | { 60 | if (strlen($specifier) === 1) { 61 | Assert::contains(self::KNOWN_SPECIFIERS, $specifier); 62 | assert($specifier !== ''); 63 | 64 | return $specifier; 65 | } 66 | 67 | preg_match(sprintf('#(?[%s])#', self::KNOWN_SPECIFIERS), $specifier, $matches); 68 | if (! isset($matches['specifier'])) { 69 | throw new InvalidArgumentException(sprintf('Provided specifier %s is unknown!', $specifier)); 70 | } 71 | 72 | $parsed = $matches['specifier']; 73 | Assert::stringNotEmpty($parsed); 74 | 75 | return $parsed; 76 | } 77 | 78 | private function numeric(): Type\Union 79 | { 80 | return new Type\Union([ 81 | new Type\Atomic\TInt(), 82 | new Type\Atomic\TFloat(), 83 | new Type\Atomic\TNumericString(), 84 | ]); 85 | } 86 | 87 | private function stringable(): Type\Union 88 | { 89 | $types = [ 90 | new Type\Atomic\TString(), 91 | ]; 92 | 93 | if ($this->allowIntegerForStringPlaceholder) { 94 | $types[] = new Type\Atomic\TInt(); 95 | } 96 | 97 | return new Type\Union($types); 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /src/Parser/TemplatedStringParser/TemplatedStringParser.php: -------------------------------------------------------------------------------- 1 | %*)%(?:(?\d+)\$)?[-+]?(?:[ 0]|' 34 | . '(?:\'[^%]))?-?\d*(?:\.\d*)?'; 35 | private const PRINTF_SPECIFIERS_REGEX_PATTERN_TEMPLATE = '[bcdeEfFgGosuxX%s]'; 36 | private const SCANF_SPECIFIERS_REGEX_PATTERN_TEMPLATE = '(?:[cdDeEfinosuxX%s]|\[[^\]]+\])'; 37 | private const SPECIFIER_SINCE_PHP8 = 'hH'; 38 | 39 | private string $templateWithoutPlaceholder; 40 | 41 | /** @psalm-var array */ 42 | private array $placeholders; 43 | 44 | private string $template; 45 | 46 | private StatementsSource $statementsSource; 47 | 48 | /** 49 | * @psalm-param positive-int $phpVersion 50 | */ 51 | private function __construct( 52 | string $functionName, 53 | string $template, 54 | int $phpVersion, 55 | bool $allowIntegerForStringPlaceholder, 56 | StatementsSource $statementsSource 57 | ) { 58 | $this->template = $template; 59 | $this->templateWithoutPlaceholder = $template; 60 | $this->placeholders = []; 61 | $this->statementsSource = $statementsSource; 62 | $this->parse($functionName, $template, $phpVersion, $allowIntegerForStringPlaceholder); 63 | } 64 | 65 | private function parse( 66 | string $functionName, 67 | string $template, 68 | int $phpVersion, 69 | bool $allowIntegerForStringPlaceholder 70 | ): void { 71 | $additionalSpecifierDependingOnPhpVersion = ''; 72 | if ($phpVersion >= 80000) { 73 | $additionalSpecifierDependingOnPhpVersion .= self::SPECIFIER_SINCE_PHP8; 74 | } 75 | 76 | $specifiers = sprintf( 77 | in_array($functionName, ['sprintf', 'printf'], true) 78 | ? self::PRINTF_SPECIFIERS_REGEX_PATTERN_TEMPLATE : self::SCANF_SPECIFIERS_REGEX_PATTERN_TEMPLATE, 79 | $additionalSpecifierDependingOnPhpVersion, 80 | ); 81 | 82 | $pattern = self::ARGUMENT_SCAN_REGEX_PATTERN_PREFIX . $specifiers; 83 | $potentialPlaceholders = []; 84 | preg_match_all( 85 | sprintf('~%s~', $pattern), 86 | $template, 87 | $potentialPlaceholders, 88 | PREG_SET_ORDER | PREG_OFFSET_CAPTURE, 89 | ); 90 | 91 | if ($potentialPlaceholders === []) { 92 | return; 93 | } 94 | 95 | $placeholders = array_filter( 96 | $potentialPlaceholders, 97 | /** @param array{before:array{0:string}} $placeholder */ 98 | static function ( 99 | array $placeholder 100 | ): bool { 101 | $patternPrefix = $placeholder['before'][0]; 102 | 103 | return strlen($patternPrefix) % 2 === 0; 104 | }, 105 | ARRAY_FILTER_USE_BOTH, 106 | ); 107 | 108 | if ($placeholders === []) { 109 | return; 110 | } 111 | 112 | /** @var array $placeholderInstances */ 113 | $placeholderInstances = []; 114 | $removedCharacters = 0; 115 | $maximumOrdinalPosition = 1; 116 | $maximumPositionByPlaceholder = 0; 117 | 118 | $templateWithoutPlaceholders = $template; 119 | 120 | foreach ($placeholders as $placeholder) { 121 | assert(isset($placeholder[0])); 122 | [$placeholderValue, $placeholderIndex] = $placeholder[0]; 123 | $placeholderLength = strlen($placeholderValue); 124 | $templateWithoutPlaceholders = substr_replace( 125 | $templateWithoutPlaceholders, 126 | '', 127 | $placeholderIndex - $removedCharacters, 128 | $placeholderLength, 129 | ); 130 | $removedCharacters += $placeholderLength; 131 | $placeholderPosition = (int) ($placeholder['position'][0] ?? 0); 132 | $maximumPositionByPlaceholder = max($maximumPositionByPlaceholder, $placeholderPosition); 133 | if ($placeholderPosition === 0) { 134 | $placeholderPosition = $maximumOrdinalPosition; 135 | $maximumOrdinalPosition++; 136 | } 137 | 138 | Assert::positiveInteger($placeholderPosition); 139 | assert($placeholderValue !== ''); 140 | 141 | $initialPlaceholderInstance = $placeholderInstances[$placeholderPosition] ?? null; 142 | $placeholderInstance = Placeholder::create( 143 | $placeholderValue, 144 | $placeholderPosition, 145 | $allowIntegerForStringPlaceholder, 146 | $this->statementsSource, 147 | ); 148 | 149 | if ($initialPlaceholderInstance !== null) { 150 | $placeholderInstance = $initialPlaceholderInstance 151 | ->withRepeatedPlaceholder($placeholderInstance); 152 | } 153 | 154 | $placeholderInstances[$placeholderPosition] = $placeholderInstance; 155 | } 156 | 157 | $this->placeholders = $placeholderInstances; 158 | $this->templateWithoutPlaceholder = $templateWithoutPlaceholders; 159 | } 160 | 161 | /** @psalm-param positive-int $phpVersion */ 162 | public static function fromArgument( 163 | string $functionName, 164 | Arg $templateArgument, 165 | Context $context, 166 | int $phpVersion, 167 | bool $allowIntegerForStringPlaceholder, 168 | StatementsSource $statementsSource 169 | ): self { 170 | return new self( 171 | $functionName, 172 | ArgumentValueParser::create($templateArgument->value, $context, $statementsSource)->toString(), 173 | $phpVersion, 174 | $allowIntegerForStringPlaceholder, 175 | $statementsSource, 176 | ); 177 | } 178 | 179 | public function getTemplate(): string 180 | { 181 | return $this->template; 182 | } 183 | 184 | public function getTemplateWithoutPlaceholder(): string 185 | { 186 | return $this->templateWithoutPlaceholder; 187 | } 188 | 189 | /** 190 | * @psalm-return array 191 | */ 192 | public function getPlaceholders(): array 193 | { 194 | return $this->placeholders; 195 | } 196 | 197 | /** 198 | * @return 0|positive-int 199 | */ 200 | public function getPlaceholderCount(): int 201 | { 202 | // TODO: normalize as %1$s is the same as %s, e.g. 203 | return count($this->placeholders); 204 | } 205 | } 206 | -------------------------------------------------------------------------------- /src/Plugin.php: -------------------------------------------------------------------------------- 1 | PossiblyInvalidArgumentForSpecifierValidator::class, 26 | 'ReportUnnecessaryFunctionCalls' => UnnecessaryFunctionCallValidator::class, 27 | ]; 28 | 29 | public function __invoke(RegistrationInterface $registration, ?SimpleXMLElement $config = null): void 30 | { 31 | require_once __DIR__ . '/EventHandler/SprintfFunctionReturnProvider.php'; 32 | require_once __DIR__ . '/EventHandler/PrintfFunctionArgumentValidator.php'; 33 | require_once __DIR__ . '/EventHandler/ScanfFunctionArgumentValidator.php'; 34 | $registration->registerHooksFromClass(SprintfFunctionReturnProvider::class); 35 | $registration->registerHooksFromClass(PrintfFunctionArgumentValidator::class); 36 | $registration->registerHooksFromClass(ScanfFunctionArgumentValidator::class); 37 | 38 | if ($config === null) { 39 | return; 40 | } 41 | 42 | $this->registerExperimentalHooks($registration, $config); 43 | } 44 | 45 | private function registerExperimentalHooks(RegistrationInterface $registration, SimpleXMLElement $config): void 46 | { 47 | if (! $config->experimental instanceof SimpleXMLElement) { 48 | return; 49 | } 50 | 51 | foreach ($config->experimental->children() ?? [] as $element) { 52 | $name = $element->getName(); 53 | if (! isset(self::EXPERIMENTAL_FEATURES[$name])) { 54 | continue; 55 | } 56 | 57 | $options = $this->extractOptionsFromElement($element); 58 | $this->registerFeatureHook($registration, $name, $options); 59 | } 60 | } 61 | 62 | /** 63 | * @param array $options 64 | */ 65 | private function registerFeatureHook( 66 | RegistrationInterface $registration, 67 | string $featureName, 68 | array $options 69 | ): void { 70 | $eventHandlerClassName = self::EXPERIMENTAL_FEATURES[$featureName]; 71 | 72 | $fileName = __DIR__ . sprintf( 73 | '/EventHandler/%s.php', 74 | basename(str_replace('\\', '/', $eventHandlerClassName)), 75 | ); 76 | assert(file_exists($fileName)); 77 | require_once $fileName; 78 | 79 | $registration->registerHooksFromClass($eventHandlerClassName); 80 | if ($eventHandlerClassName !== PossiblyInvalidArgumentForSpecifierValidator::class) { 81 | return; 82 | } 83 | 84 | $eventHandlerClassName::applyOptions($options); 85 | } 86 | 87 | /** 88 | * @return array 89 | */ 90 | private function extractOptionsFromElement(SimpleXMLElement $element): array 91 | { 92 | $options = []; 93 | 94 | foreach ($element->attributes() ?? [] as $attribute) { 95 | $name = $attribute->getName(); 96 | assert($name !== ''); 97 | 98 | $options[$name] = (string) $attribute; 99 | } 100 | 101 | return $options; 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /src/Psalm/Issue/PossiblyInvalidArgument.php: -------------------------------------------------------------------------------- 1 | function_id = $function_id ? strtolower($function_id) : null; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/Psalm/Issue/TooFewArguments.php: -------------------------------------------------------------------------------- 1 | function_id = $function_id ? strtolower($function_id) : null; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/Psalm/Issue/TooManyArguments.php: -------------------------------------------------------------------------------- 1 | function_id = $function_id ? strtolower($function_id) : null; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/Psalm/Issue/UnnecessaryFunctionCall.php: -------------------------------------------------------------------------------- 1 | function_id = $function_id; 22 | } 23 | } 24 | --------------------------------------------------------------------------------