Repository: spaze/phpstan-disallowed-calls (php)

Issue:
When a call is disallowed due to a parameter rule, display a different message
### Discussed in https://github.com/spaze/phpstan-disallowed-calls/discussions/154

<div type='discussions-op-text'>

<sup>Originally posted by **eithed** December 19, 2022</sup>
I'd like to disallow different methods, with different messages, dependent upon the number of parameters used. For example, if I've got function:
```
function foo($bar = '', $xyz = ''){}
```

I'd like to display:
- if `foo()` is used anywhere, display `Don't use foo`
- if `foo(*)` is used anywhere, display `Don't use foo with parameter $bar`
- if `foo(*,*)` is used anywhere, display `Don't use foo with parameters $bar and $xyz`

Is this possible at all?</div>

_Maintainer note: I think a param-level message could be added that would override the main message when a call would be disallowed due to disallowed/missing param._

Pull request:
Can add more rules for the same call to have different messages for various params
The rules have to be sorted from the most specific to the most general to have different messages for different params, see `FunctionCallsParamsMessagesTest`.

This makes sense mostly|only when you want to show a different message when various params have been disallowed. For which the new config options `allowExceptParamsAnyValue` and `disallowParamsAnyValue` (which is an alias for the former) have been added.

Close #155

Patch:
diff --git a/docs/allow-with-parameters.md b/docs/allow-with-parameters.md
index c20ef82..eab4c4c 100644
--- a/docs/allow-with-parameters.md
+++ b/docs/allow-with-parameters.md
@@ -96,6 +96,22 @@ parameters:
 ```
 will disallow `foo('bar', 'baz')` but not `foo('bar', 'BAZ')`.
 
+If you don't care about the value but would like to disallow a call based just on the parameter presence, you can use `allowExceptParamsAnyValue` (or `disallowParamsAnyValue`):
+```neon
+parameters:
+    disallowedFunctionCalls:
+        -
+            function: 'waldo()'
+            disallowParamsAnyValue:
+                -
+                    position: 1
+                    name: 'message'
+                -
+                    position: 2
+                    name: 'alert'
+```
+This configuration will disallow calls like `waldo('foo', 'bar')` or `waldo('*', '*')`, but `waldo('foo')` or `waldo()` will be still allowed.
+
 It's also possible to disallow functions and methods previously allowed by path (using `allowIn`) or by function/method name (`allowInMethods`) when they're called with specified parameters, and allow when called with any other parameter. This is done using the `allowExceptParamsInAllowed` config option.
 
 Take this example configuration:
diff --git a/extension.neon b/extension.neon
index 4f3a5ec..1cfcb31 100644
--- a/extension.neon
+++ b/extension.neon
@@ -67,6 +67,8 @@ parametersSchema:
 			?disallowParamsInAllowed: arrayOf(anyOf(int(), string(), bool(), structure([position: int(), ?value: anyOf(int(), string(), bool()), ?name: string()])), anyOf(int(), string())),
 			?allowExceptParams: arrayOf(anyOf(int(), string(), bool(), structure([position: int(), ?value: anyOf(int(), string(), bool()), ?name: string()])), anyOf(int(), string())),
 			?disallowParams: arrayOf(anyOf(int(), string(), bool(), structure([position: int(), ?value: anyOf(int(), string(), bool()), ?name: string()])), anyOf(int(), string())),
+			?allowExceptParamsAnyValue: arrayOf(anyOf(int(), structure([position: int(), ?name: string()])), anyOf(int(), string())),
+			?disallowParamsAnyValue: arrayOf(anyOf(int(), structure([position: int(), ?name: string()])), anyOf(int(), string())),
 			?allowExceptParamFlags: arrayOf(anyOf(int(), structure([position: int(), ?value: int(), ?name: string()])), anyOf(int(), string())),
 			?disallowParamFlags: arrayOf(anyOf(int(), structure([position: int(), ?value: int(), ?name: string()])), anyOf(int(), string())),
 			?allowExceptCaseInsensitiveParams: arrayOf(anyOf(int(), string(), bool(), structure([position: int(), ?value: anyOf(int(), string(), bool()), ?name: string()])), anyOf(int(), string())),
@@ -103,6 +105,8 @@ parametersSchema:
 			?disallowParamsInAllowed: arrayOf(anyOf(int(), string(), bool(), structure([position: int(), ?value: anyOf(int(), string(), bool()), ?name: string()])), anyOf(int(), string())),
 			?allowExceptParams: arrayOf(anyOf(int(), string(), bool(), structure([position: int(), ?value: anyOf(int(), string(), bool()), ?name: string()])), anyOf(int(), string())),
 			?disallowParams: arrayOf(anyOf(int(), string(), bool(), structure([position: int(), ?value: anyOf(int(), string(), bool()), ?name: string()])), anyOf(int(), string())),
+			?allowExceptParamsAnyValue: arrayOf(anyOf(int(), structure([position: int(), ?name: string()])), anyOf(int(), string())),
+			?disallowParamsAnyValue: arrayOf(anyOf(int(), structure([position: int(), ?name: string()])), anyOf(int(), string())),
 			?allowExceptParamFlags: arrayOf(anyOf(int(), structure([position: int(), ?value: int(), ?name: string()])), anyOf(int(), string())),
 			?disallowParamFlags: arrayOf(anyOf(int(), structure([position: int(), ?value: int(), ?name: string()])), anyOf(int(), string())),
 			?allowExceptCaseInsensitiveParams: arrayOf(anyOf(int(), string(), bool(), structure([position: int(), ?value: anyOf(int(), string(), bool()), ?name: string()])), anyOf(int(), string())),
@@ -139,6 +143,8 @@ parametersSchema:
 			?disallowParamsInAllowed: arrayOf(anyOf(int(), string(), bool(), structure([position: int(), ?value: anyOf(int(), string(), bool()), ?name: string()])), anyOf(int(), string())),
 			?allowExceptParams: arrayOf(anyOf(int(), string(), bool(), structure([position: int(), ?value: anyOf(int(), string(), bool()), ?name: string()])), anyOf(int(), string())),
 			?disallowParams: arrayOf(anyOf(int(), string(), bool(), structure([position: int(), ?value: anyOf(int(), string(), bool()), ?name: string()])), anyOf(int(), string())),
+			?allowExceptParamsAnyValue: arrayOf(anyOf(int(), structure([position: int(), ?name: string()])), anyOf(int(), string())),
+			?disallowParamsAnyValue: arrayOf(anyOf(int(), structure([position: int(), ?name: string()])), anyOf(int(), string())),
 			?allowExceptParamFlags: arrayOf(anyOf(int(), structure([position: int(), ?value: int(), ?name: string()])), anyOf(int(), string())),
 			?disallowParamFlags: arrayOf(anyOf(int(), structure([position: int(), ?value: int(), ?name: string()])), anyOf(int(), string())),
 			?allowExceptCaseInsensitiveParams: arrayOf(anyOf(int(), string(), bool(), structure([position: int(), ?value: anyOf(int(), string(), bool()), ?name: string()])), anyOf(int(), string())),
@@ -196,6 +202,8 @@ parametersSchema:
 			?disallowParamsInAllowed: arrayOf(anyOf(int(), string(), bool(), structure([position: int(), ?value: anyOf(int(), string(), bool()), ?name: string()])), anyOf(int(), string())),
 			?allowExceptParams: arrayOf(anyOf(int(), string(), bool(), structure([position: int(), ?value: anyOf(int(), string(), bool()), ?name: string()])), anyOf(int(), string())),
 			?disallowParams: arrayOf(anyOf(int(), string(), bool(), structure([position: int(), ?value: anyOf(int(), string(), bool()), ?name: string()])), anyOf(int(), string())),
+			?allowExceptParamsAnyValue: arrayOf(anyOf(int(), structure([position: int(), ?name: string()])), anyOf(int(), string())),
+			?disallowParamsAnyValue: arrayOf(anyOf(int(), structure([position: int(), ?name: string()])), anyOf(int(), string())),
 			?allowExceptParamFlags: arrayOf(anyOf(int(), structure([position: int(), ?value: int(), ?name: string()])), anyOf(int(), string())),
 			?disallowParamFlags: arrayOf(anyOf(int(), structure([position: int(), ?value: int(), ?name: string()])), anyOf(int(), string())),
 			?allowExceptCaseInsensitiveParams: arrayOf(anyOf(int(), string(), bool(), structure([position: int(), ?value: anyOf(int(), string(), bool()), ?name: string()])), anyOf(int(), string())),
diff --git a/phpstan.neon b/phpstan.neon
index 93a1589..fe1052d 100644
--- a/phpstan.neon
+++ b/phpstan.neon
@@ -6,7 +6,7 @@ parameters:
 		CallParamConfig: 'array<int|string, int|bool|string|array{position:int, value?:int|bool|string, name?:string}>'
 		CallParamAnyValueConfig: 'array<int|string, int|array{position:int, value?:int|bool|string, name?:string}>'
 		CallParamFlagAnyValueConfig: 'array<int|string, int|array{position:int, value?:int, name?:string}>'
-		AllowDirectives: 'allowIn?:list<string>, allowExceptIn?:list<string>, disallowIn?:list<string>, allowInFunctions?:list<string>, allowInMethods?:list<string>, allowExceptInFunctions?:list<string>, allowExceptInMethods?:list<string>, disallowInFunctions?:list<string>, disallowInMethods?:list<string>, allowParamsInAllowed?:CallParamConfig, allowParamsInAllowedAnyValue?:CallParamAnyValueConfig, allowParamFlagsInAllowed?:CallParamFlagAnyValueConfig, allowParamsAnywhere?:CallParamConfig, allowParamsAnywhereAnyValue?:CallParamAnyValueConfig, allowParamFlagsAnywhere?:CallParamFlagAnyValueConfig, allowExceptParamsInAllowed?:CallParamConfig, allowExceptParamFlagsInAllowed?:CallParamFlagAnyValueConfig, disallowParamFlagsInAllowed?:CallParamFlagAnyValueConfig, disallowParamsInAllowed?:CallParamConfig, allowExceptParams?:CallParamConfig, disallowParams?:CallParamConfig, allowExceptParamFlags?:CallParamFlagAnyValueConfig, disallowParamFlags?:CallParamFlagAnyValueConfig, allowExceptCaseInsensitiveParams?:CallParamConfig, disallowCaseInsensitiveParams?:CallParamConfig'
+		AllowDirectives: 'allowIn?:list<string>, allowExceptIn?:list<string>, disallowIn?:list<string>, allowInFunctions?:list<string>, allowInMethods?:list<string>, allowExceptInFunctions?:list<string>, allowExceptInMethods?:list<string>, disallowInFunctions?:list<string>, disallowInMethods?:list<string>, allowParamsInAllowed?:CallParamConfig, allowParamsInAllowedAnyValue?:CallParamAnyValueConfig, allowParamFlagsInAllowed?:CallParamFlagAnyValueConfig, allowParamsAnywhere?:CallParamConfig, allowParamsAnywhereAnyValue?:CallParamAnyValueConfig, allowParamFlagsAnywhere?:CallParamFlagAnyValueConfig, allowExceptParamsInAllowed?:CallParamConfig, allowExceptParamFlagsInAllowed?:CallParamFlagAnyValueConfig, disallowParamFlagsInAllowed?:CallParamFlagAnyValueConfig, disallowParamsInAllowed?:CallParamConfig, allowExceptParams?:CallParamConfig, disallowParams?:CallParamConfig, allowExceptParamsAnyValue?:CallParamAnyValueConfig, disallowParamsAnyValue?:CallParamAnyValueConfig, allowExceptParamFlags?:CallParamFlagAnyValueConfig, disallowParamFlags?:CallParamFlagAnyValueConfig, allowExceptCaseInsensitiveParams?:CallParamConfig, disallowCaseInsensitiveParams?:CallParamConfig'
 		ForbiddenCallsConfig: 'array<array{function?:string|list<string>, method?:string|list<string>, exclude?:string|list<string>, definedIn?:string|list<string>, message?:string, %typeAliases.AllowDirectives%, errorIdentifier?:string, errorTip?:string}>'
 		DisallowedAttributesConfig: 'array<array{attribute:string|list<string>, exclude?:string|list<string>, message?:string, %typeAliases.AllowDirectives%, errorIdentifier?:string, errorTip?:string}>'
 		AllowDirectivesConfig: 'array{%typeAliases.AllowDirectives%}'
diff --git a/src/Allowed/Allowed.php b/src/Allowed/Allowed.php
index ca7563e..d5fc886 100644
--- a/src/Allowed/Allowed.php
+++ b/src/Allowed/Allowed.php
@@ -19,6 +19,7 @@ use Spaze\PHPStan\Rules\Disallowed\Params\ParamValue;
 use Spaze\PHPStan\Rules\Disallowed\Params\ParamValueAny;
 use Spaze\PHPStan\Rules\Disallowed\Params\ParamValueCaseInsensitiveExcept;
 use Spaze\PHPStan\Rules\Disallowed\Params\ParamValueExcept;
+use Spaze\PHPStan\Rules\Disallowed\Params\ParamValueExceptAny;
 use Spaze\PHPStan\Rules\Disallowed\Params\ParamValueFlagExcept;
 use Spaze\PHPStan\Rules\Disallowed\Params\ParamValueFlagSpecific;
 use Spaze\PHPStan\Rules\Disallowed\Params\ParamValueSpecific;
@@ -111,6 +112,7 @@ class Allowed
 			return true;
 		}
 
+		$disallowedParams = false;
 		foreach ($allowConfig as $param) {
 			$type = $this->getArgType($args, $scope, $param);
 			if ($type === null) {
@@ -123,15 +125,13 @@ class Allowed
 			}
 			foreach ($types as $type) {
 				try {
-					if (!$param->matches($type)) {
-						return false;
-					}
+					$disallowedParams = $disallowedParams || !$param->matches($type);
 				} catch (UnsupportedParamTypeException $e) {
 					return !$paramsRequired;
 				}
 			}
 		}
-		return true;
+		return !$disallowedParams;
 	}
 
 
@@ -216,6 +216,9 @@ class Allowed
 		foreach ($allowed['allowExceptParams'] ?? $allowed['disallowParams'] ?? [] as $param => $value) {
 			$allowExceptParams[$param] = $this->paramFactory(ParamValueExcept::class, $param, $value);
 		}
+		foreach ($allowed['allowExceptParamsAnyValue'] ?? $allowed['disallowParamsAnyValue'] ?? [] as $param => $value) {
+			$allowExceptParams[$param] = $this->paramFactory(ParamValueExceptAny::class, $param, $value);
+		}
 		foreach ($allowed['allowExceptParamFlags'] ?? $allowed['disallowParamFlags'] ?? [] as $param => $value) {
 			$allowExceptParams[$param] = $this->paramFactory(ParamValueFlagExcept::class, $param, $value);
 		}
@@ -250,7 +253,7 @@ class Allowed
 				$paramPosition = $value['position'];
 				$paramName = $value['name'] ?? null;
 				$paramValue = $value['value'] ?? null;
-			} elseif ($class === ParamValueAny::class) {
+			} elseif (in_array($class, [ParamValueAny::class, ParamValueExceptAny::class], true)) {
 				if (is_numeric($value)) {
 					$paramPosition = (int)$value;
 					$paramName = null;
diff --git a/src/Params/ParamValueExceptAny.php b/src/Params/ParamValueExceptAny.php
new file mode 100644
index 0000000..91b443e
--- /dev/null
+++ b/src/Params/ParamValueExceptAny.php
@@ -0,0 +1,19 @@
+<?php
+declare(strict_types = 1);
+
+namespace Spaze\PHPStan\Rules\Disallowed\Params;
+
+use PHPStan\Type\Type;
+
+/**
+ * @extends ParamValue<int|bool|string|null>
+ */
+final class ParamValueExceptAny extends ParamValue
+{
+
+	public function matches(Type $type): bool
+	{
+		return false;
+	}
+
+}


Test patch:
diff --git a/tests/Calls/FunctionCallsParamsMessagesTest.php b/tests/Calls/FunctionCallsParamsMessagesTest.php
new file mode 100644
index 0000000..0c1e7d4
--- /dev/null
+++ b/tests/Calls/FunctionCallsParamsMessagesTest.php
@@ -0,0 +1,136 @@
+<?php
+declare(strict_types = 1);
+
+namespace Spaze\PHPStan\Rules\Disallowed\Calls;
+
+use PHPStan\Rules\Rule;
+use PHPStan\ShouldNotHappenException;
+use PHPStan\Testing\RuleTestCase;
+use Spaze\PHPStan\Rules\Disallowed\DisallowedCallFactory;
+use Spaze\PHPStan\Rules\Disallowed\RuleErrors\DisallowedCallsRuleErrors;
+
+class FunctionCallsParamsMessagesTest extends RuleTestCase
+{
+
+	/**
+	 * @throws ShouldNotHappenException
+	 */
+	protected function getRule(): Rule
+	{
+		$container = self::getContainer();
+		return new FunctionCalls(
+			$container->getByType(DisallowedCallsRuleErrors::class),
+			$container->getByType(DisallowedCallFactory::class),
+			$this->createReflectionProvider(),
+			[
+				[
+					'function' => '\Foo\Bar\Waldo\config()',
+					'message' => 'foo & bar',
+					'allowIn' => [
+						__DIR__ . '/../src/disallowed-allow/*.php',
+						__DIR__ . '/../src/*-allow/*.*',
+					],
+					'disallowParamsAnyValue' => [
+						1,
+						2,
+					],
+				],
+				[
+					'function' => '\Foo\Bar\Waldo\config()',
+					'message' => 'foo',
+					'allowIn' => [
+						__DIR__ . '/../src/disallowed-allow/*.php',
+						__DIR__ . '/../src/*-allow/*.*',
+					],
+					'disallowParamsAnyValue' => [
+						1,
+					],
+				],
+				[
+					'function' => '\Foo\Bar\Waldo\config()',
+					'message' => 'nothing',
+					'allowIn' => [
+						__DIR__ . '/../src/disallowed-allow/*.php',
+						__DIR__ . '/../src/*-allow/*.*',
+					],
+				],
+				[
+					'function' => '\Foo\Bar\Waldo\bar()',
+					'message' => 'foo & bar',
+					'allowIn' => [
+						__DIR__ . '/../src/disallowed-allow/*.php',
+						__DIR__ . '/../src/*-allow/*.*',
+					],
+					'disallowParams' => [
+						1 => 'foo',
+						2 => 'bar',
+					],
+				],
+				[
+					'function' => '\Foo\Bar\Waldo\bar()',
+					'message' => 'foo',
+					'allowIn' => [
+						__DIR__ . '/../src/disallowed-allow/*.php',
+						__DIR__ . '/../src/*-allow/*.*',
+					],
+					'disallowParams' => [
+						1 => 'foo',
+					],
+				],
+				[
+					'function' => '\Foo\Bar\Waldo\bar()',
+					'message' => 'nothing',
+					'allowIn' => [
+						__DIR__ . '/../src/disallowed-allow/*.php',
+						__DIR__ . '/../src/*-allow/*.*',
+					],
+				],
+			]
+		);
+	}
+
+
+	public function testRule(): void
+	{
+		// Based on the configuration above, in this file:
+		$this->analyse([__DIR__ . '/../src/disallowed/functionCallsParamsMessages.php'], [
+			[
+				// expect this error message:
+				'Calling Foo\Bar\Waldo\config() is forbidden, nothing.',
+				// on this line:
+				5,
+			],
+			[
+				'Calling Foo\Bar\Waldo\config() is forbidden, foo.',
+				6,
+			],
+			[
+				'Calling Foo\Bar\Waldo\config() is forbidden, foo & bar.',
+				7,
+			],
+			[
+				'Calling Foo\Bar\Waldo\bar() is forbidden, nothing.',
+				8,
+			],
+			[
+				'Calling Foo\Bar\Waldo\bar() is forbidden, foo.',
+				9,
+			],
+			[
+				'Calling Foo\Bar\Waldo\bar() is forbidden, foo & bar.',
+				10,
+			],
+		]);
+		// Based on the configuration above, no errors in this file:
+		$this->analyse([__DIR__ . '/../src/disallowed-allow/functionCallsParamsMessages.php'], []);
+	}
+
+
+	public static function getAdditionalConfigFiles(): array
+	{
+		return [
+			__DIR__ . '/../../extension.neon',
+		];
+	}
+
+}
diff --git a/tests/src/disallowed-allow/functionCallsParamsMessages.php b/tests/src/disallowed-allow/functionCallsParamsMessages.php
new file mode 100644
index 0000000..b2cdf7b
--- /dev/null
+++ b/tests/src/disallowed-allow/functionCallsParamsMessages.php
@@ -0,0 +1,10 @@
+<?php
+declare(strict_types = 1);
+
+// allowed by path
+\Foo\Bar\Waldo\config();
+\Foo\Bar\Waldo\config('foo');
+\Foo\Bar\Waldo\config('foo', 'bar');
+\Foo\Bar\Waldo\bar();
+\Foo\Bar\Waldo\bar('foo');
+\Foo\Bar\Waldo\bar('foo', 'bar');
diff --git a/tests/src/disallowed/functionCallsParamsMessages.php b/tests/src/disallowed/functionCallsParamsMessages.php
new file mode 100644
index 0000000..b390b9b
--- /dev/null
+++ b/tests/src/disallowed/functionCallsParamsMessages.php
@@ -0,0 +1,10 @@
+<?php
+declare(strict_types = 1);
+
+// disallowed with different messages
+\Foo\Bar\Waldo\config();
+\Foo\Bar\Waldo\config('foo');
+\Foo\Bar\Waldo\config('foo', 'bar');
+\Foo\Bar\Waldo\bar();
+\Foo\Bar\Waldo\bar('foo');
+\Foo\Bar\Waldo\bar('foo', 'bar');