diff --git a/phpstan.neon b/phpstan.neon index 22d909f..02fba58 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -36,8 +36,12 @@ parameters: paths: - tests/PHPStan/data/ + # Test fixtures intentionally contain Lab ID capitalization violations + - message: '#should use "Lab ID" instead of#' + paths: + - tests/PHPStan/data/ + # PHPStan internal API usage is acceptable in tests - message: '#is not covered by backward compatibility promise#' paths: - tests/PHPStan/ - diff --git a/src/PHPStan/Rules/CanonicalCapitalizationRule.php b/src/PHPStan/Rules/CanonicalCapitalizationRule.php new file mode 100644 index 0000000..a6658cd --- /dev/null +++ b/src/PHPStan/Rules/CanonicalCapitalizationRule.php @@ -0,0 +1,94 @@ + + */ +abstract class CanonicalCapitalizationRule implements Rule +{ + abstract protected function getCanonicalForm(): string; + + /** @return array */ + abstract protected function getWrongVariants(): array; + + abstract protected function getErrorIdentifier(): string; + + public function getNodeType(): string + { + return String_::class; + } + + /** @param String_ $node */ + public function processNode(Node $node, Scope $scope): array + { + if ($this->hasLanguageAnnotation($node)) { + return []; + } + + $value = $node->value; + + if ($this->cannotContainSpaces($value)) { + return []; + } + + $wrongVariant = $this->findWrongVariant($value); + + if ($wrongVariant === null) { + return []; + } + + $expectedValue = $this->fixCapitalization($value, $wrongVariant); + + return [ + RuleErrorBuilder::message(<<getCanonicalForm()}" instead of "{$wrongVariant}", change it to "{$expectedValue}". + TXT) + ->identifier($this->getErrorIdentifier()) + ->build(), + ]; + } + + protected function cannotContainSpaces(string $value): bool + { + return ! str_contains($value, ' '); + } + + /** @see https://www.jetbrains.com/help/phpstorm/using-language-injections.html */ + protected function hasLanguageAnnotation(String_ $node): bool + { + foreach ($node->getComments() as $comment) { + if (Str::contains($comment->getText(), '@lang')) { + return true; + } + } + + return false; + } + + public function findWrongVariant(string $value): ?string + { + foreach ($this->getWrongVariants() as $wrongVariant) { + if (Str::contains($value, $wrongVariant)) { + return $wrongVariant; + } + } + + return null; + } + + public function fixCapitalization(string $value, string $wrongVariant): string + { + return str_replace($wrongVariant, $this->getCanonicalForm(), $value); + } +} diff --git a/src/PHPStan/Rules/IdToIDRule.php b/src/PHPStan/Rules/IdToIDRule.php index 951ed34..ce376cf 100644 --- a/src/PHPStan/Rules/IdToIDRule.php +++ b/src/PHPStan/Rules/IdToIDRule.php @@ -23,7 +23,6 @@ abstract protected function getErrorIdentifier(): string; abstract protected function extractName(Node $node): ?string; - /** Override for custom formatting (e.g., adding $ prefix for variables). */ protected function formatNameForMessage(string $name): string { return $name; diff --git a/src/PHPStan/Rules/LabIDCapitalizationRule.php b/src/PHPStan/Rules/LabIDCapitalizationRule.php new file mode 100644 index 0000000..bd0583d --- /dev/null +++ b/src/PHPStan/Rules/LabIDCapitalizationRule.php @@ -0,0 +1,32 @@ + */ + protected function getWrongVariants(): array + { + return [ + 'LabID', + 'labID', + 'LABID', + 'Labid', + 'labid', + 'Lab-ID', + 'lab-Id', + 'Lab Id', + 'lab id', + ]; + } + + protected function getErrorIdentifier(): string + { + return 'mll.labIDCapitalization'; + } +} diff --git a/tests/PHPStan/CapitalizationOfIDRuleIntegrationTest.php b/tests/PHPStan/CapitalizationOfIDRuleIntegrationTest.php deleted file mode 100644 index 9061203..0000000 --- a/tests/PHPStan/CapitalizationOfIDRuleIntegrationTest.php +++ /dev/null @@ -1,99 +0,0 @@ ->}> */ - public static function dataIntegrationTests(): iterable - { - self::getContainer(); - - yield [__DIR__ . '/data/wrong-capitalization.php', [ - 5 => [ - 'Name of Stmt_Class "LabIdProcessor" should use "ID" instead of "Id", rename it to "LabIDProcessor".', - ], - 7 => [ - 'Name of Stmt_ClassMethod "getLabId" should use "ID" instead of "Id", rename it to "getLabID".', - ], - 12 => [ - 'Name of Stmt_ClassMethod "processLabId" should use "ID" instead of "Id", rename it to "processLabID".', - 'Name of Param "$labId" should use "ID" instead of "Id", rename it to "$labID".', - ], - 14 => [ - 'Name of Expr_Variable "$sampleId" should use "ID" instead of "Id", rename it to "$sampleID".', - 'Name of Expr_Variable "$labId" should use "ID" instead of "Id", rename it to "$labID".', - ], - ]]; - - yield [__DIR__ . '/data/correct-capitalization.php', []]; - } - - /** - * @param array> $expectedErrors - * - * @dataProvider dataIntegrationTests - */ - #[DataProvider('dataIntegrationTests')] - public function testIntegration(string $file, array $expectedErrors): void - { - $errors = $this->runAnalyse($file); - - $ourErrors = array_filter( - $errors, - static function (Error $error): bool { - $message = $error->getMessage(); - - return str_contains($message, 'should use "ID" instead of "Id"'); - } - ); - - if ($expectedErrors === []) { - self::assertEmpty($ourErrors, 'Should not report errors for correct capitalization'); - } else { - self::assertNotEmpty($ourErrors, 'Should detect wrong capitalization'); - $this->assertSameErrorMessages($expectedErrors, $ourErrors); - } - } - - /** @return array */ - private function runAnalyse(string $file): array - { - $file = self::getFileHelper()->normalizePath($file); - - /** @var Analyser $analyser */ - $analyser = self::getContainer()->getByType(Analyser::class); - - $result = $analyser->analyse([$file]); - - return $result->getErrors(); - } - - /** - * @param array> $expectedErrors - * @param array $errors - */ - private function assertSameErrorMessages(array $expectedErrors, array $errors): void - { - foreach ($errors as $error) { - $errorLine = $error->getLine() ?? 0; - $errorMessage = $error->getMessage(); - - self::assertArrayHasKey($errorLine, $expectedErrors, "Unexpected error at line {$errorLine}: {$errorMessage}"); - self::assertContains($errorMessage, $expectedErrors[$errorLine]); - } - } - - /** @return array */ - public static function getAdditionalConfigFiles(): array - { - return [ - __DIR__ . '/phpstan-test.neon', - ]; - } -} diff --git a/tests/PHPStan/IdToIDRuleIntegrationTest.php b/tests/PHPStan/IdToIDRuleIntegrationTest.php new file mode 100644 index 0000000..8908363 --- /dev/null +++ b/tests/PHPStan/IdToIDRuleIntegrationTest.php @@ -0,0 +1,54 @@ +>}> */ + public static function dataIntegrationTests(): iterable + { + self::getContainer(); + + yield 'wrong capitalization' => [__DIR__ . '/data/wrong-capitalization.php', [ + 5 => [ + 'Name of Stmt_Class "LabIdProcessor" should use "ID" instead of "Id", rename it to "LabIDProcessor".', + ], + 7 => [ + 'Name of Stmt_ClassMethod "getLabId" should use "ID" instead of "Id", rename it to "getLabID".', + ], + 12 => [ + 'Name of Stmt_ClassMethod "processLabId" should use "ID" instead of "Id", rename it to "processLabID".', + 'Name of Param "$labId" should use "ID" instead of "Id", rename it to "$labID".', + ], + 14 => [ + 'Name of Expr_Variable "$sampleId" should use "ID" instead of "Id", rename it to "$sampleID".', + 'Name of Expr_Variable "$labId" should use "ID" instead of "Id", rename it to "$labID".', + ], + ]]; + + yield 'correct capitalization' => [__DIR__ . '/data/correct-capitalization.php', []]; + } + + /** + * @param array> $expectedErrors + * + * @dataProvider dataIntegrationTests + */ + #[DataProvider('dataIntegrationTests')] + public function testIntegration(string $file, array $expectedErrors): void + { + $errors = $this->analyseFile($file); + $filteredErrors = $this->filterErrors($errors, self::ERROR_PATTERN); + + if ($expectedErrors === []) { + self::assertEmpty($filteredErrors, 'Should not report errors for correct capitalization'); + } else { + self::assertNotEmpty($filteredErrors, 'Should detect wrong capitalization'); + $this->assertExpectedErrors($expectedErrors, $filteredErrors); + } + } +} diff --git a/tests/PHPStan/LabIDCapitalizationRuleIntegrationTest.php b/tests/PHPStan/LabIDCapitalizationRuleIntegrationTest.php new file mode 100644 index 0000000..079f245 --- /dev/null +++ b/tests/PHPStan/LabIDCapitalizationRuleIntegrationTest.php @@ -0,0 +1,65 @@ += 8.3 + */ +final class LabIDCapitalizationRuleIntegrationTest extends PHPStanIntegrationTestCase +{ + private const ERROR_PATTERN = 'should use "Lab ID"'; + + /** @return iterable>}> */ + public static function dataIntegrationTests(): iterable + { + self::getContainer(); + + yield 'detects wrong capitalization' => [ + __DIR__ . '/data/lab-id-capitalization.php', + [ + 9 => ['String "The LabID is wrong" should use "Lab ID" instead of "LabID", change it to "The Lab ID is wrong".'], + 30 => ['String " + { + patient { + labID + } + } + " should use "Lab ID" instead of "labID", change it to " + { + patient { + Lab ID + } + } + ".'], + 66 => ['String " + SELECT exam_no AS labID + FROM examinations + " should use "Lab ID" instead of "labID", change it to " + SELECT exam_no AS Lab ID + FROM examinations + ".'], + ], + ]; + } + + /** + * @param array> $expectedErrors + * + * @dataProvider dataIntegrationTests + */ + #[DataProvider('dataIntegrationTests')] + public function testIntegration(string $file, array $expectedErrors): void + { + $errors = $this->analyseFile($file); + $filteredErrors = $this->filterErrors($errors, self::ERROR_PATTERN); + + if ($expectedErrors === []) { + self::assertEmpty($filteredErrors, 'Should not report errors for correct capitalization'); + } else { + self::assertNotEmpty($filteredErrors, 'Should detect wrong Lab ID capitalization'); + $this->assertExpectedErrors($expectedErrors, $filteredErrors); + } + } +} diff --git a/tests/PHPStan/LabIDCapitalizationRuleTest.php b/tests/PHPStan/LabIDCapitalizationRuleTest.php new file mode 100644 index 0000000..f833d22 --- /dev/null +++ b/tests/PHPStan/LabIDCapitalizationRuleTest.php @@ -0,0 +1,52 @@ +rule = new LabIDCapitalizationRule(); + } + + /** @dataProvider wrongCapitalizations */ + #[DataProvider('wrongCapitalizations')] + public function testDetectsWrongCapitalizations(string $input, string $wrongVariant, string $expected): void + { + self::assertSame($wrongVariant, $this->rule->findWrongVariant($input)); + self::assertSame($expected, $this->rule->fixCapitalization($input, $wrongVariant)); + } + + /** @return iterable */ + public static function wrongCapitalizations(): iterable + { + yield 'LabID' => ['The LabID is wrong', 'LabID', 'The Lab ID is wrong']; + yield 'labID' => ['Your labID was submitted', 'labID', 'Your Lab ID was submitted']; + yield 'LABID' => ['LABID not found', 'LABID', 'Lab ID not found']; + yield 'Labid' => ['Labid missing', 'Labid', 'Lab ID missing']; + yield 'Lab-ID with hyphen' => ['Enter Lab-ID here', 'Lab-ID', 'Enter Lab ID here']; + yield 'Lab Id wrong case' => ['Lab Id is required', 'Lab Id', 'Lab ID is required']; + } + + /** @dataProvider correctCapitalizations */ + #[DataProvider('correctCapitalizations')] + public function testAllowsCorrectCapitalizations(string $input): void + { + self::assertNull($this->rule->findWrongVariant($input)); + } + + /** @return iterable */ + public static function correctCapitalizations(): iterable + { + yield 'Correct Lab ID' => ['Lab ID']; + yield 'In sentence' => ['The Lab ID is correct']; + yield 'labId lowercase' => ['labId']; + yield 'Unrelated word' => ['Laboratory']; + } +} diff --git a/tests/PHPStan/PHPStanIntegrationTestCase.php b/tests/PHPStan/PHPStanIntegrationTestCase.php new file mode 100644 index 0000000..a750570 --- /dev/null +++ b/tests/PHPStan/PHPStanIntegrationTestCase.php @@ -0,0 +1,67 @@ + */ + protected function analyseFile(string $file): array + { + $file = self::getFileHelper()->normalizePath($file); + + /** @var Analyser $analyser */ + $analyser = self::getContainer()->getByType(Analyser::class); + + $result = $analyser->analyse([$file]); + + return $result->getErrors(); + } + + /** + * @param array $errors + * + * @return array + */ + protected function filterErrors(array $errors, string $messagePattern): array + { + return array_filter( + $errors, + static fn (Error $error): bool => str_contains($error->getMessage(), $messagePattern), + ); + } + + /** + * @param array> $expectedErrors + * @param array $actualErrors + */ + protected function assertExpectedErrors(array $expectedErrors, array $actualErrors): void + { + foreach ($actualErrors as $error) { + $errorLine = $error->getLine() ?? 0; + $errorMessage = $error->getMessage(); + + self::assertArrayHasKey($errorLine, $expectedErrors, "Unexpected error at line {$errorLine}: {$errorMessage}"); + self::assertContains($errorMessage, $expectedErrors[$errorLine]); + } + + $actualLines = array_map( + static fn (Error $error): int => $error->getLine() ?? 0, + $actualErrors, + ); + foreach (array_keys($expectedErrors) as $expectedLine) { + self::assertContains($expectedLine, $actualLines, "Expected error at line {$expectedLine} was not reported"); + } + } + + /** @return array */ + public static function getAdditionalConfigFiles(): array + { + return [ + __DIR__ . '/phpstan-test.neon', + ]; + } +} diff --git a/tests/PHPStan/data/lab-id-capitalization.php b/tests/PHPStan/data/lab-id-capitalization.php new file mode 100644 index 0000000..767d399 --- /dev/null +++ b/tests/PHPStan/data/lab-id-capitalization.php @@ -0,0 +1,71 @@ + */ + public function arrayKeysAreIgnored(): array + { + return [ + 'labID' => 'some value', + 'LabID' => 'another value', + ]; + } + + public function identifierStringsAreIgnored(): string + { + $key = 'labID'; + + return $key; + } + + public function sqlQueryIsIgnored(): string + { + return /* @lang SQL */ ' + SELECT exam_no AS labID + FROM examinations + WHERE labID > 1000 + '; + } + + public function sqlQueryWithoutAnnotationIsChecked(): string + { + return ' + SELECT exam_no AS labID + FROM examinations + '; + } +} diff --git a/tests/PHPStan/phpstan-test.neon b/tests/PHPStan/phpstan-test.neon index 6f11355..adbf016 100644 --- a/tests/PHPStan/phpstan-test.neon +++ b/tests/PHPStan/phpstan-test.neon @@ -6,3 +6,4 @@ rules: - MLL\Utils\PHPStan\Rules\ParameterNameIdToIDRule - MLL\Utils\PHPStan\Rules\MethodNameIdToIDRule - MLL\Utils\PHPStan\Rules\ClassNameIdToIDRule + - MLL\Utils\PHPStan\Rules\LabIDCapitalizationRule