├── 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 | [](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 |
--------------------------------------------------------------------------------