From 841737e7de1fc9fea771c972ff101051e2b64c89 Mon Sep 17 00:00:00 2001 From: Owen Voke Date: Wed, 6 Apr 2022 15:22:21 +0100 Subject: [PATCH] feat: add PHPStan rule for kebab-casing Artisan commands --- README.md | 5 +- larastan.neon | 1 + .../EnforceKebabCaseArtisanCommandsRule.php | 113 ++++++++++++++++++ ...nforceKebabCaseArtisanCommandsRuleTest.php | 105 ++++++++++++++++ ...ss_with_descriptions_for_signature.php.inc | 14 +++ ...nd_class_with_multi_line_signature.php.inc | 16 +++ ...class_with_non_kebab_case_argument.php.inc | 14 +++ ...non_kebab_case_argument_and_option.php.inc | 14 +++ ...and_class_with_non_kebab_case_name.php.inc | 12 ++ ...ebab_case_name_argument_and_option.php.inc | 14 +++ ...d_class_with_non_kebab_case_option.php.inc | 14 +++ .../Fixture/skip_non_command_class.php.inc | 7 ++ ...mand_class_with_signature_property.php.inc | 8 ++ .../Fixture/skip_valid_command_class.php.inc | 14 +++ 14 files changed, 350 insertions(+), 1 deletion(-) create mode 100644 src/PHPStan/Laravel/EnforceKebabCaseArtisanCommandsRule.php create mode 100644 tests/PHPStan/Laravel/EnforceKebabCaseArtisanCommandsRule/EnforceKebabCaseArtisanCommandsRuleTest.php create mode 100644 tests/PHPStan/Laravel/EnforceKebabCaseArtisanCommandsRule/Fixture/command_class_with_descriptions_for_signature.php.inc create mode 100644 tests/PHPStan/Laravel/EnforceKebabCaseArtisanCommandsRule/Fixture/command_class_with_multi_line_signature.php.inc create mode 100644 tests/PHPStan/Laravel/EnforceKebabCaseArtisanCommandsRule/Fixture/command_class_with_non_kebab_case_argument.php.inc create mode 100644 tests/PHPStan/Laravel/EnforceKebabCaseArtisanCommandsRule/Fixture/command_class_with_non_kebab_case_argument_and_option.php.inc create mode 100644 tests/PHPStan/Laravel/EnforceKebabCaseArtisanCommandsRule/Fixture/command_class_with_non_kebab_case_name.php.inc create mode 100644 tests/PHPStan/Laravel/EnforceKebabCaseArtisanCommandsRule/Fixture/command_class_with_non_kebab_case_name_argument_and_option.php.inc create mode 100644 tests/PHPStan/Laravel/EnforceKebabCaseArtisanCommandsRule/Fixture/command_class_with_non_kebab_case_option.php.inc create mode 100644 tests/PHPStan/Laravel/EnforceKebabCaseArtisanCommandsRule/Fixture/skip_non_command_class.php.inc create mode 100644 tests/PHPStan/Laravel/EnforceKebabCaseArtisanCommandsRule/Fixture/skip_non_command_class_with_signature_property.php.inc create mode 100644 tests/PHPStan/Laravel/EnforceKebabCaseArtisanCommandsRule/Fixture/skip_valid_command_class.php.inc diff --git a/README.md b/README.md index 6ed569f..0dbce0e 100644 --- a/README.md +++ b/README.md @@ -70,4 +70,7 @@ partial route resources should be split into multiple routes. Similar to `DisallowPartialRouteFacadeResource`, but prevents partial resource usage when used in a route group. #### DisallowPHPUnit -This rule prevents PHPUnit tests in favour of Pest PHP. It will allow abstract `TestCase` classes. \ No newline at end of file +This rule prevents PHPUnit tests in favour of Pest PHP. It will allow abstract `TestCase` classes. + +#### EnforceKebabCaseArtisanCommandsRule +This rule will enforce the use of kebab-case for Artisan commands. diff --git a/larastan.neon b/larastan.neon index 4ada409..8a4479b 100644 --- a/larastan.neon +++ b/larastan.neon @@ -13,6 +13,7 @@ rules: - Worksome\CodingStyle\PHPStan\Laravel\DisallowAppHelperUsageRule - Worksome\CodingStyle\PHPStan\Laravel\DisallowPartialRouteResource\DisallowPartialRouteFacadeResourceRule - Worksome\CodingStyle\PHPStan\Laravel\DisallowPartialRouteResource\DisallowPartialRouteVariableResourceRule + - Worksome\CodingStyle\PHPStan\Laravel\EnforceKebabCaseArtisanCommandsRule services: - class: Worksome\CodingStyle\PHPStan\Laravel\DisallowPartialRouteResource\PartialRouteResourceInspector diff --git a/src/PHPStan/Laravel/EnforceKebabCaseArtisanCommandsRule.php b/src/PHPStan/Laravel/EnforceKebabCaseArtisanCommandsRule.php new file mode 100644 index 0000000..79a8b22 --- /dev/null +++ b/src/PHPStan/Laravel/EnforceKebabCaseArtisanCommandsRule.php @@ -0,0 +1,113 @@ + + */ +class EnforceKebabCaseArtisanCommandsRule implements Rule +{ + public function __construct(private array $excludedCommandClasses = []) + { + } + + public function getNodeType(): string + { + return Class_::class; + } + + /** + * @param Class_ $node + */ + public function processNode(Node $node, Scope $scope): array + { + if ($node->namespacedName === null) { + return []; + } + + $className = $node->namespacedName->toString(); + + if (! $node->extends || !str_contains($node->extends->toString(), Command::class)) { + return []; + } + + if (in_array($className, $this->excludedCommandClasses)) { + return []; + } + + if (($property = $node->getProperty('signature')) === null) { + return []; + } + + return $this->getCommandSignatureErrors($className, $property); + } + + public function getCommandSignatureErrors(string $className, Property $signature): array + { + $value = (string) $signature->props[0]->default->value; + $errors = []; + $command = Parser::parse((string) $value); + + foreach ($command as $segment) { + if ($segment === []) { + continue; + } + + if (is_string($segment)) { + $errors = $this->getCommandNameErrors($segment, $className, $signature, $errors); + } + + if (is_array($segment)) { + $errors = $this->getOptionOrArgumentErrors($segment, $className, $signature, $errors); + } + } + + return $errors; + } + + public function getCommandNameErrors(string $segment, string $className, Property $signature, array $errors): array + { + if (!preg_match('/^[a-z0-9\-:]+$/', $segment)) { + $errors[] = RuleErrorBuilder::message( + "Command \"{$className}\" is not using kebab-case for the command name in its signature." + )->line($signature->getLine())->build(); + } + + return $errors; + } + + public function getOptionOrArgumentErrors(array $segment, string $className, Property $signature, array $errors): array + { + foreach ($segment as $optionOrArgument) { + $isArgument = $optionOrArgument instanceof InputArgument; + $isOption = $optionOrArgument instanceof InputOption; + + if (!$isArgument && !$isOption) { + continue; + } + + $type = $isArgument ? 'argument' : 'option'; + + if (!preg_match('/^[a-z0-9\-]+$/', $optionOrArgument->getName())) { + $errors[] = RuleErrorBuilder::message( + "Command \"{$className}\" is not using kebab-case for the \"{$optionOrArgument->getName()}\" {$type} in its signature." + )->line($signature->getLine())->build(); + } + } + + return $errors; + } +} diff --git a/tests/PHPStan/Laravel/EnforceKebabCaseArtisanCommandsRule/EnforceKebabCaseArtisanCommandsRuleTest.php b/tests/PHPStan/Laravel/EnforceKebabCaseArtisanCommandsRule/EnforceKebabCaseArtisanCommandsRuleTest.php new file mode 100644 index 0000000..44a7f90 --- /dev/null +++ b/tests/PHPStan/Laravel/EnforceKebabCaseArtisanCommandsRule/EnforceKebabCaseArtisanCommandsRuleTest.php @@ -0,0 +1,105 @@ +rule = new EnforceKebabCaseArtisanCommandsRule(); + + expect($path)->toHaveRuleErrors($errors); +})->with([ + 'valid command class' => [ + __DIR__ . '/Fixture/skip_valid_command_class.php.inc', + ], + 'non-command class' => [ + __DIR__ . '/Fixture/skip_non_command_class.php.inc', + ], + 'non-command class with signature property' => [ + __DIR__ . '/Fixture/skip_non_command_class_with_signature_property.php.inc', + ], + 'command with non-kebab-case command name' => [ + __DIR__ . '/Fixture/command_class_with_non_kebab_case_name.php.inc', + [ + 'Command "' . TestCommandWithNonKebabCaseName::class . '" is not using kebab-case for the command name in its signature.', + 7, + ], + ], + 'command with non-kebab-case argument' => [ + __DIR__ . '/Fixture/command_class_with_non_kebab_case_argument.php.inc', + [ + 'Command "' . TestCommandWithNonKebabCaseArgument::class . '" is not using kebab-case for the "Blah" argument in its signature.', + 9, + ], + ], + 'command with non-kebab-case option' => [ + __DIR__ . '/Fixture/command_class_with_non_kebab_case_option.php.inc', + [ + 'Command "' . TestCommandWithNonKebabCaseOption::class . '" is not using kebab-case for the "Blah" option in its signature.', + 9, + ], + ], + 'command with non-kebab-case argument and option' => [ + __DIR__ . '/Fixture/command_class_with_non_kebab_case_argument_and_option.php.inc', + [ + 'Command "' . TestCommandWithNonKebabCaseArgumentAndOption::class . '" is not using kebab-case for the "Blah" argument in its signature.', + 9, + ], + [ + 'Command "' . TestCommandWithNonKebabCaseArgumentAndOption::class . '" is not using kebab-case for the "Blah" option in its signature.', + 9, + ], + ], + 'command with non-kebab-case name, argument, and option' => [ + __DIR__ . '/Fixture/command_class_with_non_kebab_case_name_argument_and_option.php.inc', + [ + 'Command "' . TestCommandWithNonKebabCaseNameArgumentAndOption::class . '" is not using kebab-case for the command name in its signature.', + 9, + ], + [ + 'Command "' . TestCommandWithNonKebabCaseNameArgumentAndOption::class . '" is not using kebab-case for the "Blah" argument in its signature.', + 9, + ], + [ + 'Command "' . TestCommandWithNonKebabCaseNameArgumentAndOption::class . '" is not using kebab-case for the "Blah" option in its signature.', + 9, + ], + ], + 'command with multi-line signature' => [ + __DIR__ . '/Fixture/command_class_with_multi_line_signature.php.inc', + [ + 'Command "' . TestCommandWithMultilineSignature::class . '" is not using kebab-case for the command name in its signature.', + 9, + ], + [ + 'Command "' . TestCommandWithMultilineSignature::class . '" is not using kebab-case for the "Blah" argument in its signature.', + 9, + ], + [ + 'Command "' . TestCommandWithMultilineSignature::class . '" is not using kebab-case for the "Blah" option in its signature.', + 9, + ], + ], + 'command with descriptions for signature' => [ + __DIR__ . '/Fixture/command_class_with_descriptions_for_signature.php.inc', + [ + 'Command "' . TestCommandWithDescriptions::class . '" is not using kebab-case for the command name in its signature.', + 9, + ], + [ + 'Command "' . TestCommandWithDescriptions::class . '" is not using kebab-case for the "Blah" argument in its signature.', + 9, + ], + [ + 'Command "' . TestCommandWithDescriptions::class . '" is not using kebab-case for the "Blah" option in its signature.', + 9, + ], + ], +]); diff --git a/tests/PHPStan/Laravel/EnforceKebabCaseArtisanCommandsRule/Fixture/command_class_with_descriptions_for_signature.php.inc b/tests/PHPStan/Laravel/EnforceKebabCaseArtisanCommandsRule/Fixture/command_class_with_descriptions_for_signature.php.inc new file mode 100644 index 0000000..2f9c0d6 --- /dev/null +++ b/tests/PHPStan/Laravel/EnforceKebabCaseArtisanCommandsRule/Fixture/command_class_with_descriptions_for_signature.php.inc @@ -0,0 +1,14 @@ +