diff --git a/rules.neon b/rules.neon index 56a6367..827b7ee 100644 --- a/rules.neon +++ b/rules.neon @@ -1,6 +1,7 @@ rules: - SilverStripe\Standards\PHPStan\MethodAnnotationsRule - SilverStripe\Standards\PHPStan\KeywordSelfRule + - SilverStripe\Standards\PHPStan\TranslationFunctionRule parameters: # Setting customRulestUsed to true allows us to avoid using the built-in rules diff --git a/src/PHPStan/TranslationFunctionRule.php b/src/PHPStan/TranslationFunctionRule.php new file mode 100644 index 0000000..0692e8f --- /dev/null +++ b/src/PHPStan/TranslationFunctionRule.php @@ -0,0 +1,152 @@ + + */ +class TranslationFunctionRule implements Rule +{ + public function getNodeType(): string + { + return Node::class; + } + + public function processNode(Node $node, Scope $scope): array + { + if ($node instanceof FuncCall) { + return $this->processFuncCall($node, $scope); + } + if ($node instanceof StaticCall) { + return $this->processStaticCall($node, $scope); + } + return []; + } + + /** + * Process calls to the global function _t() + */ + private function processFuncCall(FuncCall $node, Scope $scope): array + { + if (!$this->callingUnderscoreT($node->name, $scope)) { + return []; + } + return $this->processArgs($node->getArgs(), $scope); + } + + /** + * Process calls to the static method i18n::_t() + */ + private function processStaticCall(StaticCall $node, Scope $scope): array + { + $class = $node->class; + if (!($class instanceof Name)) { + return []; + } + if ($class->toString() !== 'SilverStripe\i18n\i18n') { + return []; + } + if (!$this->callingUnderscoreT($node->name, $scope)) { + return []; + } + return $this->processArgs($node->getArgs(), $scope); + } + + /** + * Check if the method/function is called _t + */ + private function callingUnderscoreT(Name|Identifier|Expr $name, Scope $scope) + { + if (($name instanceof Name) || ($name instanceof Identifier)) { + $name = $name->toString(); + } else { + $nameType = $scope->getType($name); + // Ignore callables we can't get the names of + if (!method_exists($nameType, 'getValue')) { + return false; + } + $name = $nameType->getValue(); + } + return $name === '_t'; + } + + /** + * Check that the first arg value can be evaluated and has exactly one period. + * + * @param Arg[] $args + */ + private function processArgs(array $args, Scope $scope): array + { + // If we have no args PHP itself will complain and it'll be caught by other linting, so just skip. + if (count($args) < 1) { + return []; + } + + $entityArg = $args[0]->value; + $argValue = $this->getStringValue($entityArg, $scope); + // If phpstan can't get us a nice clear value, text collector almost certainly can't either. + if ($argValue === null) { + return [ + RuleErrorBuilder::message( + 'Can\'t determine value of first argument to _t(). Use a simpler value.' + )->build() + ]; + } + + if (substr_count($argValue, '.') !== 1) { + return [RuleErrorBuilder::message('First argument passed to _t() must have exactly one period.')->build()]; + } + + return []; + } + + /** + * Get a string value from a node, if one can be derived. + */ + private function getStringValue(Node $node, Scope $scope): ?string + { + // e.g. MyClass + if (($node instanceof Name) || ($node instanceof Identifier)) { + return $node->toString(); + } + $type = $scope->getType($node); + // Variables and other types PHPStan can directly reason about + if (method_exists($type, 'getValue')) { + return $type->getValue(); + } + // e.g. static::class + if (($type instanceof GenericClassStringType) && ($type->getGenericType() instanceof TypeWithClassName)) { + return $type->getGenericType()->getClassName(); + } + // e.g. static::class . '.myKey' + if ($node instanceof Concat) { + $left = $this->getStringValue($node->left, $scope); + $right = $this->getStringValue($node->right, $scope); + if ($left === null || $right === null) { + return null; + } + return $left . $right; + } + return null; + } +} diff --git a/tests/PHPStan/TranslationFunctionRuleTest.php b/tests/PHPStan/TranslationFunctionRuleTest.php new file mode 100644 index 0000000..ddc00b7 --- /dev/null +++ b/tests/PHPStan/TranslationFunctionRuleTest.php @@ -0,0 +1,58 @@ + + */ +class TranslationFunctionRuleTest extends RuleTestCase +{ + protected function getRule(): Rule + { + return new TranslationFunctionRule(); + } + + public function provideRule() + { + return [ + 'no class scope' => [ + 'filePaths' => [__DIR__ . '/TranslationFunctionRuleTest/raw-php.php'], + 'errorMessage' => 'First argument passed to _t() must have exactly one period.', + 'errorLines' => [8,9,10,11,12,14,15,17], + ], + 'no class scope, no errors' => [ + 'filePaths' => [__DIR__ . '/TranslationFunctionRuleTest/raw-php-correct.php'], + 'errorMessage' => '', + 'errorLines' => [], + ], + 'in class scope' => [ + 'filePaths' => [__DIR__ . '/TranslationFunctionRuleTest/InClass.php'], + 'errorMessage' => 'First argument passed to _t() must have exactly one period.', + 'errorLines' => [13,14,15,16,17,19,20,22,23], + ], + 'in class scope, no errors' => [ + 'filePaths' => [__DIR__ . '/TranslationFunctionRuleTest/InClassCorrect.php'], + 'errorMessage' => '', + 'errorLines' => [], + ], + ]; + } + + /** + * @dataProvider provideRule + */ + public function testRule(array $filePaths, string $errorMessage, array $errorLines): void + { + $errors = []; + foreach ($errorLines as $line) { + $errors[] = [$errorMessage, $line]; + } + $this->analyse($filePaths, $errors); + } +} diff --git a/tests/PHPStan/TranslationFunctionRuleTest/InClass.php b/tests/PHPStan/TranslationFunctionRuleTest/InClass.php new file mode 100644 index 0000000..ae50fd6 --- /dev/null +++ b/tests/PHPStan/TranslationFunctionRuleTest/InClass.php @@ -0,0 +1,39 @@ +_t('abc'); + $callable = [i18n::class, '_t']; + $callable('abc'); + $callable2 = fn ($x) => $x; + $callable2('abc'); + $callable3 = function ($x) { + return $x; + }; + $callable3('abc'); + i18n::set_locale('en-us'); + } +} diff --git a/tests/PHPStan/TranslationFunctionRuleTest/InClassCorrect.php b/tests/PHPStan/TranslationFunctionRuleTest/InClassCorrect.php new file mode 100644 index 0000000..b61375e --- /dev/null +++ b/tests/PHPStan/TranslationFunctionRuleTest/InClassCorrect.php @@ -0,0 +1,24 @@ +_t('abc'); +$callable = [i18n::class, '_t']; +$callable('abc'); +$callable2 = fn ($x) => $x; +$callable2('abc'); +$callable3 = function ($x) { + return $x; +}; +$callable3('abc'); +i18n::set_locale('en-us');