From 6119cfc061b99eb7d8e0c8404144f77f8635e89f Mon Sep 17 00:00:00 2001 From: Andrew Nicols Date: Tue, 17 Oct 2023 17:05:28 +0800 Subject: [PATCH] WIP: Components should be qualified --- .../ComponentNameFullyQualifiedSniff.php | 216 ++++++++++++++++++ .../ComponentNameFullyQualifiedSniffTest.php | 107 +++++++++ .../fixtures/mocked/get_string.php | 10 + .../fixtures/mocked/get_string.php.fixed | 10 + .../fixtures/unmocked/get_string.php | 10 + moodle/Util/MoodleUtil.php | 23 ++ 6 files changed, 376 insertions(+) create mode 100644 moodle/Sniffs/CodeAnalysis/ComponentNameFullyQualifiedSniff.php create mode 100644 moodle/Tests/Sniffs/CodeAnalysis/ComponentNameFullyQualifiedSniffTest.php create mode 100644 moodle/Tests/Sniffs/CodeAnalysis/fixtures/mocked/get_string.php create mode 100644 moodle/Tests/Sniffs/CodeAnalysis/fixtures/mocked/get_string.php.fixed create mode 100644 moodle/Tests/Sniffs/CodeAnalysis/fixtures/unmocked/get_string.php diff --git a/moodle/Sniffs/CodeAnalysis/ComponentNameFullyQualifiedSniff.php b/moodle/Sniffs/CodeAnalysis/ComponentNameFullyQualifiedSniff.php new file mode 100644 index 0000000..bda4273 --- /dev/null +++ b/moodle/Sniffs/CodeAnalysis/ComponentNameFullyQualifiedSniff.php @@ -0,0 +1,216 @@ +. + +namespace MoodleHQ\MoodleCS\moodle\Sniffs\CodeAnalysis; + +use MoodleHQ\MoodleCS\moodle\Util\MoodleUtil; +use PHP_CodeSniffer\Files\File; +use PHP_CodeSniffer\Sniffs\Sniff; + +// phpcs:disable moodle.NamingConventions + +/** + * Checks that calls to methods which consume a component have a fully-qualified component argument. + * + * @package moodle-cs + * @copyright 2023 Andrew Lyons + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class ComponentNameFullyQualifiedSniff implements Sniff { + protected array $componentFunctions = [ + 'get_string' => 2, + ]; + + protected array $componentClassesConstructors = [ + 'lang_string' => 2, + ]; + + public function register() + { + return [ + T_STRING, + ]; + } + + public function process(File $phpcsFile, $stackPtr) + { + $this->checkFunctions($phpcsFile, $stackPtr); + $this->checkClassConstructors($phpcsFile, $stackPtr); + } + + protected function checkFunctions( + File $phpcsFile, + $stackPtr, + ): void { + $tokens = $phpcsFile->getTokens(); + + $ignore = [ + T_DOUBLE_COLON => true, + T_OBJECT_OPERATOR => true, + T_NULLSAFE_OBJECT_OPERATOR => true, + T_FUNCTION => true, + T_CONST => true, + T_PUBLIC => true, + T_PRIVATE => true, + T_PROTECTED => true, + T_AS => true, + T_NEW => true, + T_INSTEADOF => true, + T_NS_SEPARATOR => true, + T_IMPLEMENTS => true, + ]; + + $prevToken = $phpcsFile->findPrevious(T_WHITESPACE, ($stackPtr - 1), null, true); + + // If function call is directly preceded by a NS_SEPARATOR it points to the + // global namespace, so we should still catch it. + if ($tokens[$prevToken]['code'] === T_NS_SEPARATOR) { + $prevToken = $phpcsFile->findPrevious(T_WHITESPACE, ($prevToken - 1), null, true); + if ($tokens[$prevToken]['code'] === T_STRING) { + // Not in the global namespace. + return; + } + } + + if (isset($ignore[$tokens[$prevToken]['code']]) === true) { + // Not a call to a PHP function. + return; + } + + $nextToken = $phpcsFile->findNext(T_WHITESPACE, ($stackPtr + 1), null, true); + if (isset($ignore[$tokens[$nextToken]['code']]) === true) { + // Not a call to a PHP function. + return; + } + + if ($tokens[$stackPtr]['code'] === T_STRING && $tokens[$nextToken]['code'] !== T_OPEN_PARENTHESIS) { + // Not a call to a PHP function. + return; + } + + $function = strtolower($tokens[$stackPtr]['content']); + + if (array_key_exists($function, $this->componentFunctions)) { + $this->checkFunction($phpcsFile, $stackPtr, $tokens, $function, $this->componentFunctions[$function]); + } + } + + protected function checkFunction( + File $phpcsFile, + $stackPtr, + array $tokens, + string $functionName, + int $argumentPosition, + ): void { + $openParens = $phpcsFile->findNext( + T_OPEN_PARENTHESIS, + $stackPtr + 1, + ); + + if ($openParens === false) { + return; + } + + $closeParens = $tokens[$openParens]['parenthesis_closer']; + + $beforeArgumentPtr = $openParens + 1; + if ($argumentPosition > 1) { + for ($i = 0; $i < $argumentPosition; $i++) { + $beforeArgumentPtr = $phpcsFile->findNext(T_COMMA, ($openParens + 1), $closeParens); + if ($beforeArgumentPtr === false) { + return; + } + } + } + + $argumentPtr = $phpcsFile->findNext( + types: [ + T_WHITESPACE, + T_COMMA, + ], + start: $beforeArgumentPtr + 1, + end: $closeParens, + exclude: true, + ); + + if ($argumentPtr === false) { + return; + } + + if ($tokens[$argumentPtr]['code'] !== T_CONSTANT_ENCAPSED_STRING) { + // TODO See if we can handle this a little. + return; + } + + $component = $tokens[$argumentPtr]['content']; + if (str_contains($component, '_')) { + // Component carries an underscore. + return; + } + // xdebug_break(); + + // Extract the value from the string. + $componentValue = preg_replace([ + '/^"(.*)"$/', // Double quotes. + "/^'(.*)'$/", // Single quotes. + ], '$1', $component); + + $canFix = false; + $componentExists = MoodleUtil::componentExists($phpcsFile, "core_{$componentValue}"); + if ($componentExists === true) { + $newValue = "core_{$componentValue}"; + $canFix = true; + } else if ($componentExists === false) { + $newValue = "mod_{$componentValue}"; + $canFix = true; + } + + $fix = false; + if ($canFix) { + $fix = $phpcsFile->addFixableWarning( + "Component name should be '%s' in call to %s: '%s'", + $argumentPtr, + 'ComponentNameFullyQualified', + [ + $newValue, + $functionName, + $componentValue, + ] + ); + } else { + $phpcsFile->addWarning( + "Component name not fully qualified in call to %s: '%s'", + $argumentPtr, + 'ComponentNameFullyQualified', + [ + $functionName, + $componentValue, + ] + ); + } + + if ($fix) { + $phpcsFile->fixer->beginChangeset(); + $phpcsFile->fixer->replaceToken($argumentPtr, "'$newValue'"); + $phpcsFile->fixer->endChangeset(); + } + } + + protected function checkClassConstructors( + File $phpcsFile, + $stackPtr, + ): void {} +} diff --git a/moodle/Tests/Sniffs/CodeAnalysis/ComponentNameFullyQualifiedSniffTest.php b/moodle/Tests/Sniffs/CodeAnalysis/ComponentNameFullyQualifiedSniffTest.php new file mode 100644 index 0000000..2cc6c8a --- /dev/null +++ b/moodle/Tests/Sniffs/CodeAnalysis/ComponentNameFullyQualifiedSniffTest.php @@ -0,0 +1,107 @@ +. + +namespace MoodleHQ\MoodleCS\moodle\Tests\Sniffs\CodeAnalysis; + +use MoodleHQ\MoodleCS\moodle\Tests\MoodleCSBaseTestCase; + +// phpcs:disable moodle.NamingConventions + +/** + * Test the NoLeadingSlash sniff. + * + * @package moodle-cs + * @category test + * @copyright 2023 Andrew Lyons + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * + * @covers \MoodleHQ\MoodleCS\moodle\Sniffs\Namespaces\NamespaceStatementSniff + */ +class ComponentNameFullyQualifiedSniffTest extends MoodleCSBaseTestCase +{ + public static function provider(): array + { + return [ + [ + 'fixture' => 'get_string', + 'warnings' => [ + 4 => "Component name should be 'mod_forum' in call to get_string: 'forum'", + 5 => "Component name should be 'core_grades' in call to get_string: 'grades'", + 7 => "Component name should be 'mod_forum' in call to get_string: 'forum'", + 8 => "Component name should be 'core_grades' in call to get_string: 'grades'", + ], + 'errors' => [], + ], + ]; + } + /** + * @dataProvider provider + */ + public function test_component_name( + string $fixture, + array $warnings, + array $errors + ): void + { + $this->set_standard('moodle'); + $this->set_sniff('moodle.CodeAnalysis.ComponentNameFullyQualified'); + $this->set_fixture(sprintf("%s/fixtures/mocked/%s.php", __DIR__, $fixture)); + + $base = dirname(__DIR__, 3); + $this->set_component_mapping([ + 'core' => "{$base}/lib", + 'core_grades' => "{$base}/grades", + 'mod_forum' => "{$base}/mod/forum", + ]); + $this->set_warnings($warnings); + $this->set_errors($errors); + + $this->verify_cs_results(); + } + + public static function components_unavailable_provider(): array + { + return [ + [ + 'fixture' => 'get_string', + 'warnings' => [ + 4 => "Component name not fully qualified in call to get_string: 'forum'", + 5 => "Component name not fully qualified in call to get_string: 'grades'", + 7 => "Component name not fully qualified in call to get_string: 'forum'", + 8 => "Component name not fully qualified in call to get_string: 'grades'", + ], + 'errors' => [], + ], + ]; + } + /** + * @dataProvider components_unavailable_provider + */ + public function test_component_name_unavailable( + string $fixture, + array $warnings, + array $errors + ): void + { + $this->set_standard('moodle'); + $this->set_sniff('moodle.CodeAnalysis.ComponentNameFullyQualified'); + $this->set_fixture(sprintf("%s/fixtures/unmocked/%s.php", __DIR__, $fixture)); + $this->set_warnings($warnings); + $this->set_errors($errors); + + $this->verify_cs_results(); + } +} diff --git a/moodle/Tests/Sniffs/CodeAnalysis/fixtures/mocked/get_string.php b/moodle/Tests/Sniffs/CodeAnalysis/fixtures/mocked/get_string.php new file mode 100644 index 0000000..3002b9a --- /dev/null +++ b/moodle/Tests/Sniffs/CodeAnalysis/fixtures/mocked/get_string.php @@ -0,0 +1,10 @@ +