From 7892ac6402c8b91efeae52d5955a18861805374a Mon Sep 17 00:00:00 2001
From: Ruud Kamphuis <ruudk@users.noreply.github.com>
Date: Fri, 27 Sep 2024 12:21:29 +0200
Subject: [PATCH 1/2] Allow asserting array offset certainty

---
 src/Rules/Debug/FileAssertRule.php            | 20 ++++++-------
 src/Testing/TypeInferenceTestCase.php         | 17 ++++++-----
 .../Analyser/LegacyNodeScopeResolverTest.php  |  2 +-
 .../Analyser/NodeScopeResolverTest.php        |  2 +-
 .../assert-variable-certainty-on-array.php    | 28 +++++++++++++++++++
 .../Rules/Debug/FileAssertRuleTest.php        |  8 ++++--
 .../PHPStan/Rules/Debug/data/file-asserts.php | 19 +++++++++++++
 7 files changed, 74 insertions(+), 22 deletions(-)
 create mode 100644 tests/PHPStan/Analyser/nsrt/assert-variable-certainty-on-array.php

diff --git a/src/Rules/Debug/FileAssertRule.php b/src/Rules/Debug/FileAssertRule.php
index 3f8e6a62ee..769f37bd1c 100644
--- a/src/Rules/Debug/FileAssertRule.php
+++ b/src/Rules/Debug/FileAssertRule.php
@@ -171,15 +171,14 @@ private function processAssertVariableCertainty(array $args, Scope $scope): arra
 		// @phpstan-ignore staticMethod.dynamicName
 		$expectedCertaintyValue = TrinaryLogic::{$certainty->name->toString()}();
 		$variable = $args[1]->value;
-		if (!$variable instanceof Node\Expr\Variable) {
-			return [
-				RuleErrorBuilder::message('Invalid assertVariableCertainty call.')
-					->nonIgnorable()
-					->identifier('phpstan.unknownExpectation')
-					->build(),
-			];
-		}
-		if (!is_string($variable->name)) {
+		if ($variable instanceof Node\Expr\Variable && is_string($variable->name)) {
+			$actualCertaintyValue = $scope->hasVariableType($variable->name);
+			$variableDescription = sprintf('variable $%s', $variable->name);
+		} elseif ($variable instanceof Node\Expr\ArrayDimFetch && $variable->dim !== null) {
+			$offset = $scope->getType($variable->dim);
+			$actualCertaintyValue = $scope->getType($variable->var)->hasOffsetValueType($offset);
+			$variableDescription = sprintf('offset %s', $offset->describe(VerbosityLevel::precise()));
+		} else {
 			return [
 				RuleErrorBuilder::message('Invalid assertVariableCertainty call.')
 					->nonIgnorable()
@@ -188,13 +187,12 @@ private function processAssertVariableCertainty(array $args, Scope $scope): arra
 			];
 		}
 
-		$actualCertaintyValue = $scope->hasVariableType($variable->name);
 		if ($expectedCertaintyValue->equals($actualCertaintyValue)) {
 			return [];
 		}
 
 		return [
-			RuleErrorBuilder::message(sprintf('Expected variable certainty %s, actual: %s', $expectedCertaintyValue->describe(), $actualCertaintyValue->describe()))
+			RuleErrorBuilder::message(sprintf('Expected %s certainty %s, actual: %s', $variableDescription, $expectedCertaintyValue->describe(), $actualCertaintyValue->describe()))
 				->nonIgnorable()
 				->identifier('phpstan.variable')
 				->build(),
diff --git a/src/Testing/TypeInferenceTestCase.php b/src/Testing/TypeInferenceTestCase.php
index a80e510da3..2b8d8e855c 100644
--- a/src/Testing/TypeInferenceTestCase.php
+++ b/src/Testing/TypeInferenceTestCase.php
@@ -135,7 +135,7 @@ public function assertFileAsserts(
 			$variableName = $args[2];
 			$this->assertTrue(
 				$expectedCertainty->equals($actualCertainty),
-				sprintf('Expected %s, actual certainty of variable $%s is %s in %s on line %d.', $expectedCertainty->describe(), $variableName, $actualCertainty->describe(), $file, $args[3]),
+				sprintf('Expected %s, actual certainty of %s is %s in %s on line %d.', $expectedCertainty->describe(), $variableName, $actualCertainty->describe(), $file, $args[3]),
 			);
 		}
 	}
@@ -216,15 +216,18 @@ public static function gatherAssertTypes(string $file): array
 				// @phpstan-ignore staticMethod.dynamicName
 				$expectedertaintyValue = TrinaryLogic::{$certainty->name->toString()}();
 				$variable = $node->getArgs()[1]->value;
-				if (!$variable instanceof Node\Expr\Variable) {
-					self::fail(sprintf('ERROR: Invalid assertVariableCertainty call.'));
-				}
-				if (!is_string($variable->name)) {
+				if ($variable instanceof Node\Expr\Variable && is_string($variable->name)) {
+					$actualCertaintyValue = $scope->hasVariableType($variable->name);
+					$variableDescription = sprintf('variable $%s', $variable->name);
+				} elseif ($variable instanceof Node\Expr\ArrayDimFetch && $variable->dim !== null) {
+					$offset = $scope->getType($variable->dim);
+					$actualCertaintyValue = $scope->getType($variable->var)->hasOffsetValueType($offset);
+					$variableDescription = sprintf('offset %s', $offset->describe(VerbosityLevel::precise()));
+				} else {
 					self::fail(sprintf('ERROR: Invalid assertVariableCertainty call.'));
 				}
 
-				$actualCertaintyValue = $scope->hasVariableType($variable->name);
-				$assert = ['variableCertainty', $file, $expectedertaintyValue, $actualCertaintyValue, $variable->name, $node->getStartLine()];
+				$assert = ['variableCertainty', $file, $expectedertaintyValue, $actualCertaintyValue, $variableDescription, $node->getStartLine()];
 			} else {
 				$correctFunction = null;
 
diff --git a/tests/PHPStan/Analyser/LegacyNodeScopeResolverTest.php b/tests/PHPStan/Analyser/LegacyNodeScopeResolverTest.php
index 430b0e02f8..aea1961e32 100644
--- a/tests/PHPStan/Analyser/LegacyNodeScopeResolverTest.php
+++ b/tests/PHPStan/Analyser/LegacyNodeScopeResolverTest.php
@@ -906,7 +906,7 @@ private function assertVariables(
 		$this->assertTrue(
 			$expectedCertainty->equals($certainty),
 			sprintf(
-				'Certainty of variable $%s is %s, expected %s',
+				'Certainty of %s is %s, expected %s',
 				$variableName,
 				$certainty->describe(),
 				$expectedCertainty->describe(),
diff --git a/tests/PHPStan/Analyser/NodeScopeResolverTest.php b/tests/PHPStan/Analyser/NodeScopeResolverTest.php
index 2816b5ce52..e0998e5252 100644
--- a/tests/PHPStan/Analyser/NodeScopeResolverTest.php
+++ b/tests/PHPStan/Analyser/NodeScopeResolverTest.php
@@ -255,7 +255,7 @@ public function testFile(string $file): void
 				$variableName = $args[2];
 
 				if ($expectedCertainty->equals($actualCertainty) !== true) {
-					$failures[] = sprintf("Certainty of variable \$%s on line %d:\nExpected: %s\nActual:   %s\n", $variableName, $args[3], $expectedCertainty->describe(), $actualCertainty->describe());
+					$failures[] = sprintf("Certainty of %s on line %d:\nExpected: %s\nActual:   %s\n", $variableName, $args[3], $expectedCertainty->describe(), $actualCertainty->describe());
 				}
 			}
 		}
diff --git a/tests/PHPStan/Analyser/nsrt/assert-variable-certainty-on-array.php b/tests/PHPStan/Analyser/nsrt/assert-variable-certainty-on-array.php
new file mode 100644
index 0000000000..813518812a
--- /dev/null
+++ b/tests/PHPStan/Analyser/nsrt/assert-variable-certainty-on-array.php
@@ -0,0 +1,28 @@
+<?php
+
+declare(strict_types=1);
+
+namespace AssertVariableCertaintyOnArray;
+
+use PHPStan\TrinaryLogic;
+use function PHPStan\Testing\assertVariableCertainty;
+
+class Foo
+{
+	/**
+	 * @param array{firstName: string, lastName?: string, sub: array{other: string}} $context
+	 */
+	public function __invoke(array $context) : void
+	{
+		assertVariableCertainty(TrinaryLogic::createYes(), $context['firstName']);
+		assertVariableCertainty(TrinaryLogic::createYes(), $context['sub']);
+		assertVariableCertainty(TrinaryLogic::createYes(), $context['sub']['other']);
+
+		assertVariableCertainty(TrinaryLogic::createMaybe(), $context['lastName']);
+		assertVariableCertainty(TrinaryLogic::createMaybe(), $context['nonexistent']['somethingElse']);
+
+		assertVariableCertainty(TrinaryLogic::createNo(), $context['sub']['nonexistent']);
+		assertVariableCertainty(TrinaryLogic::createNo(), $context['email']);
+	}
+
+}
diff --git a/tests/PHPStan/Rules/Debug/FileAssertRuleTest.php b/tests/PHPStan/Rules/Debug/FileAssertRuleTest.php
index bf1ea7711f..aec3ed1500 100644
--- a/tests/PHPStan/Rules/Debug/FileAssertRuleTest.php
+++ b/tests/PHPStan/Rules/Debug/FileAssertRuleTest.php
@@ -32,13 +32,17 @@ public function testRule(): void
 				37,
 			],
 			[
-				'Expected variable certainty Yes, actual: No',
+				'Expected variable $b certainty Yes, actual: No',
 				45,
 			],
 			[
-				'Expected variable certainty Maybe, actual: No',
+				'Expected variable $b certainty Maybe, actual: No',
 				46,
 			],
+			[
+				"Expected offset 'firstName' certainty No, actual: Yes",
+				65,
+			],
 		]);
 	}
 
diff --git a/tests/PHPStan/Rules/Debug/data/file-asserts.php b/tests/PHPStan/Rules/Debug/data/file-asserts.php
index abd8e54d07..10289de586 100644
--- a/tests/PHPStan/Rules/Debug/data/file-asserts.php
+++ b/tests/PHPStan/Rules/Debug/data/file-asserts.php
@@ -46,4 +46,23 @@ public function doBaz($a): void
 		assertVariableCertainty(TrinaryLogic::createMaybe(), $b);
 	}
 
+	/**
+	 * @param array{firstName: string, lastName?: string, sub: array{other: string}} $context
+	 */
+	public function arrayOffset(array $context) : void
+	{
+		assertVariableCertainty(TrinaryLogic::createYes(), $context['firstName']);
+		assertVariableCertainty(TrinaryLogic::createYes(), $context['sub']);
+		assertVariableCertainty(TrinaryLogic::createYes(), $context['sub']['other']);
+
+		assertVariableCertainty(TrinaryLogic::createMaybe(), $context['lastName']);
+		assertVariableCertainty(TrinaryLogic::createMaybe(), $context['nonexistent']['somethingElse']);
+
+		assertVariableCertainty(TrinaryLogic::createNo(), $context['sub']['nonexistent']);
+		assertVariableCertainty(TrinaryLogic::createNo(), $context['email']);
+
+		// Deliberate error:
+		assertVariableCertainty(TrinaryLogic::createNo(), $context['firstName']);
+	}
+
 }

From a5472914e5cb4314e9b471e1aceb0555346c5645 Mon Sep 17 00:00:00 2001
From: Ruud Kamphuis <ruudk@users.noreply.github.com>
Date: Tue, 1 Oct 2024 18:49:55 +0200
Subject: [PATCH 2/2] Add test for variable description

---
 .../PHPStan/Testing/TypeInferenceTestCaseTest.php | 11 +++++++++++
 .../data/assert-certainty-variable-or-offset.php  | 15 +++++++++++++++
 2 files changed, 26 insertions(+)
 create mode 100644 tests/PHPStan/Testing/data/assert-certainty-variable-or-offset.php

diff --git a/tests/PHPStan/Testing/TypeInferenceTestCaseTest.php b/tests/PHPStan/Testing/TypeInferenceTestCaseTest.php
index eae3f7a3e2..a2cf8cd484 100644
--- a/tests/PHPStan/Testing/TypeInferenceTestCaseTest.php
+++ b/tests/PHPStan/Testing/TypeInferenceTestCaseTest.php
@@ -4,6 +4,7 @@
 
 use PHPStan\File\FileHelper;
 use PHPUnit\Framework\AssertionFailedError;
+use function array_values;
 use function sprintf;
 
 final class TypeInferenceTestCaseTest extends TypeInferenceTestCase
@@ -90,4 +91,14 @@ public function testFileAssertionFailedErrors(string $filePath, string $errorMes
 		$this->gatherAssertTypes($filePath);
 	}
 
+	public function testVariableOrOffsetDescription(): void
+	{
+		$filePath = __DIR__ . '/data/assert-certainty-variable-or-offset.php';
+
+		[$variableAssert, $offsetAssert] = array_values($this->gatherAssertTypes($filePath));
+
+		$this->assertSame('variable $context', $variableAssert[4]);
+		$this->assertSame("offset 'email'", $offsetAssert[4]);
+	}
+
 }
diff --git a/tests/PHPStan/Testing/data/assert-certainty-variable-or-offset.php b/tests/PHPStan/Testing/data/assert-certainty-variable-or-offset.php
new file mode 100644
index 0000000000..b06391db45
--- /dev/null
+++ b/tests/PHPStan/Testing/data/assert-certainty-variable-or-offset.php
@@ -0,0 +1,15 @@
+<?php
+
+namespace AssertCertaintyVariableOrOffset;
+
+use PHPStan\TrinaryLogic;
+use function PHPStan\Testing\assertVariableCertainty;
+
+/**
+ * @param array{} $context
+ */
+function someMethod(array $context) : void
+{
+	assertVariableCertainty(TrinaryLogic::createNo(), $context);
+	assertVariableCertainty(TrinaryLogic::createYes(), $context['email']);
+}