diff --git a/moodle/Sniffs/Files/LangFilesOrderingSniff.php b/moodle/Sniffs/Files/LangFilesOrderingSniff.php new file mode 100644 index 0000000..a2dc577 --- /dev/null +++ b/moodle/Sniffs/Files/LangFilesOrderingSniff.php @@ -0,0 +1,335 @@ +. + +/** + * This sniff verifies that lang files are sorted alphabetically by string key. + * + * @copyright 2024 onwards Eloy Lafuente (stronk7) {@link https://stronk7.com} + * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace MoodleHQ\MoodleCS\moodle\Sniffs\Files; + +use MoodleHQ\MoodleCS\moodle\Util\MoodleUtil; +use PHP_CodeSniffer\Files\File; +use PHP_CodeSniffer\Sniffs\Sniff; + +class LangFilesOrderingSniff implements Sniff +{ + /** + * @var string|null The previous string that has been processed. + * + * We use this variable to compare the current string with the previous one. And decide + * if the current string is a duplicate or if it's out of order. + */ + protected ?string $previousString = null; + + /** + * @var int pointer to the token where we should stop fixing the file (defaults to last token). + * + * When we find a comment that is not a "Deprecated since Moodle" one, we will stop fixing the file. + */ + protected int $stopFixingPtr = 999999999; + + /** + * @var array An array which keys are all the known strings, grouped, and the values are the start and end pointers to them. + * + * We use this array to, accurately, know where to move every string on each fixing iteration. + */ + protected array $strings = []; + + public function register(): array { + return [T_OPEN_TAG]; // We are going to process the whole file, finding all the strings and comments within it. + } + + public function process(File $phpcsFile, $stackPtr): void { + // If the file is not a lang file, return. + if (!MoodleUtil::isLangFile($phpcsFile)) { + return; + } + + // Only for Moodle 4.4dev (404) and up. + // Make and exception for unit tests, so they are run always. + if (!MoodleUtil::meetsMinimumMoodleVersion($phpcsFile, 404) && !MoodleUtil::isUnitTestRunning()) { + return; // @codeCoverageIgnore + } + + // Get the file tokens, for ease of use. + $tokens = $phpcsFile->getTokens(); + + // Ensure that we start from scratch on each file and pass. + $this->previousString = null; + $this->stopFixingPtr = 999999999; + $this->strings = []; + + // Let's find the first variable and start the process. + $currentPtr = $phpcsFile->findNext(T_VARIABLE, $stackPtr + 1); + if ($currentPtr === false) { + return; // No strings found, nothing to do. + } + + // It's time to iterate over all the strings and comments till the end of the file. + // We'll go accumulating all the strings by group, with their start and end pointers as values. + $currentGroup = 'main'; // The default group to start with, we'll change it each time we find a new section. + do { + // Let's manage comments first (so we know if we are changing the current group). + + // Correct comments are in new line and begin with "// Deprecated since ". + if ( + $tokens[$currentPtr]['code'] === T_COMMENT && + strpos($tokens[$currentPtr]['content'], '// Deprecated since ') === 0 && + $tokens[$currentPtr - 1]['content'] === "\n" + ) { + $currentGroup = trim($tokens[$currentPtr]['content']); + } + + // If we find a comment that is not the standard one, we will stop fixing the file here. And error. + if ( + $tokens[$currentPtr]['code'] === T_COMMENT && + strpos($tokens[$currentPtr]['content'], '// Deprecated since ') === false + ) { + $phpcsFile->addWarning( + 'Unexpected comment found. Auto-fixing will not work after this comment', + $currentPtr, + 'UnexpectedComment' + ); + if ($this->stopFixingPtr > $currentPtr) { + $this->stopFixingPtr = $currentPtr; // Update the stop fixing pointer. + } + } + + if ($tokens[$currentPtr]['code'] === T_COMMENT) { + continue; // We are done for comment tokens. + } + + // Arrived here, all the tokens are variables, so we don't need to check for that. + + // If the name of the variable is not "$string", error. + if ($tokens[$currentPtr]['content'] !== '$string') { + $phpcsFile->addError( + 'Variable "%s" is not expected in a lang file', + $currentPtr, + 'UnexpectedVariable', + [$tokens[$currentPtr]['content']] + ); + continue; // We are done for this token. + } + + // Get the string key, if any. + if (!$stringKey = $this->getStringKey($phpcsFile, $currentPtr)) { + continue; // Problems with this string key, skip it (has been already reported). + } + + // Have found a valid $string[KEY], let's calculate the end and store it. + if ($currentEnd = $this->getStringEnd($phpcsFile, $currentPtr)) { + if (!isset($this->strings[$currentGroup])) { + $this->strings[$currentGroup] = []; + $this->previousString = null; // Reset the previous string on new group. + } + // Check if the string already has been found earlier. + if (isset($this->strings[$currentGroup][$stringKey])) { + $phpcsFile->addError('The string key "%s" is duplicated', $currentPtr, 'DuplicatedKey', [$stringKey]); + continue; // We are done for this string, won't report anything about it till fixed. + } else { + // We can safely add the string to the group, if we are before the last pointer to fix. + if ($currentPtr < $this->stopFixingPtr) { + $this->strings[$currentGroup][$stringKey] = [$currentPtr, $currentEnd]; + } + } + } + + if (null === $currentEnd) { + // The string end is not as expected, report as error unless the next token + // after the semicolon is a comment. In that case, we won't report it because + // UnexpectedComment will take on it. + $delegateToUnexpectedComment = false; + $semicolonPtr = $phpcsFile->findNext(T_SEMICOLON, $currentPtr + 1); + if ( + ( + isset($tokens[$semicolonPtr + 1]) && // There is a next token (not the end of the file) + $tokens[$semicolonPtr + 1]['code'] === T_COMMENT // And it's a comment. + ) || + ( + isset($tokens[$semicolonPtr + 2]) && // Or there are 2 more tokens (not the end of the file). + $tokens[$semicolonPtr + 1]['code'] === T_WHITESPACE && // And they are whitespace + comment. + $tokens[$semicolonPtr + 2]['code'] === T_COMMENT + ) + ) { + $delegateToUnexpectedComment = true; + } + if (!$delegateToUnexpectedComment) { + $phpcsFile->addError( + 'Unexpected string end, it should be a line feed after a semicolon', + $currentPtr, + 'UnexpectedEnd' + ); + continue; // We are done for this string, won't report anything about it till fixed. + } + } + + // Note: We only issue these warnings if there are previous strings to compare with, + // and, obviously, if the string is out of order. + if ($this->previousString && strcmp($this->previousString, $stringKey) > 0) { + // We are going to add this as fixable warning only if we are + // before the last pointer to fix. This is an unordered string. + $phpcsFile->addWarning( + 'The string key "%s" is not in the correct order, it should be before "%s"', + $currentPtr, + 'IncorrectOrder', + [$stringKey, $this->previousString], + 0, + $currentPtr < $this->stopFixingPtr + ); + } + + // Feed $previousString with the current string key. + $this->previousString = $stringKey; + } while ($currentPtr = $phpcsFile->findNext([T_VARIABLE, T_COMMENT], $currentPtr + 1)); // Move to next. + + // If we are fixing the file, we need to sort the strings and move them to the correct position. + if ($phpcsFile->fixer->enabled) { + $this->sortStringsAndFix($phpcsFile); + } + } + + /** + * Given a lang file, fix all the sorting issues found. + * + * This is really similar to the insertion-sort algorithm, but with + * a few optimisations to avoid unnecessary iterations. Should be + * efficient enough against lists that are expected to be not + * too long and already mostly sorted. + * + * @param File $phpcsFile The lang file being processed. + */ + protected function sortStringsAndFix(File $phpcsFile): void { + // Because of hard restrictions in CodeSniffer fixer (we cannot apply more than one change + // to the same token in the same pass), we need to accumulate all the changes and apply them + // at the end of the process. So we are going to build a big changeset to be applied all together. + // Keys will be the token index and values an array, with operation (index, DELETE, INSERT) and content. + + // Get the file tokens, for ease of use. + $tokens = $phpcsFile->getTokens(); + + // We are going to perform the sorting within each detected group/section. + foreach ($this->strings as $group => $strings) { + $changeSet = []; // The changeset to be applied at the end of the iteration. + + $strings = $this->strings[$group]; + // Let's compare the keys in the array of strings with the sorted version of it. + $sorted = $unSorted = array_keys($strings); + sort($sorted, SORT_STRING); + $count = count($sorted); + for ($i = 0; $i < $count; $i++) { + $sortedKey = $sorted[$i]; + $stringsKey = $unSorted[$i]; + + // The string being checked is not in order (comparing with the sorted array). + if ($sortedKey !== $stringsKey) { + // Apply the changes to the strings array by moving the key to the correct position. + $keyValue = $strings[$sortedKey]; + // Remove the element to move. + unset($strings[$sortedKey]); + // Rebuild the array, with the element in new position. + $strings = array_slice($strings, 0, $i, true) + + [$sortedKey => $keyValue] + + array_slice($strings, $i, null, true); + $this->strings[$group] = $strings; // Update the group array with the rebuilt version. + $unSorted = array_keys($strings); // Update the unsorted keys array. + + // Now add the required changes to the changeset that we'll be using when fixing the file. + // For every token in the string being moved, delete it and add it in the correct position. + foreach (range($keyValue[0], $keyValue[1]) as $tokenIndex) { + $tempToken = $tokens[$tokenIndex]; // Store the token. + $changeSet[$tokenIndex]['DELETE'] = ''; // Delete the current string token. + // Insert the token before the previous string. + if (!isset($changeSet[$strings[$stringsKey][0] - 1]['INSERT'])) { + $changeSet[$strings[$stringsKey][0] - 1]['INSERT'] = ''; + } + $changeSet[$strings[$stringsKey][0] - 1]['INSERT'] .= $tempToken['content']; + } + } + } + + // Let's apply the accumulated changes to the file. + if (!empty($changeSet)) { + $phpcsFile->fixer->beginChangeset(); + foreach ($changeSet as $tokenIndex => $operations) { + if (isset($operations['DELETE'])) { + $phpcsFile->fixer->replaceToken($tokenIndex, ''); + } + if (isset($operations['INSERT'])) { + $phpcsFile->fixer->addContent($tokenIndex, $operations['INSERT']); + } + } + $phpcsFile->fixer->endChangeset(); + } + } + } + + /** + * Return the string key corresponding to the string at the pointer. + * Note that the key has got any quote (single or double) trimmed. + * + * @param File $phpcsFile + * @param int $stackPtr + * @return string|null + */ + protected function getStringKey(File $phpcsFile, int $stackPtr): ?string { + $tokens = $phpcsFile->getTokens(); + + // If the structure is not exactly: $string[KEY], add error and return null. + if ( + $tokens[$stackPtr + 1]['code'] !== T_OPEN_SQUARE_BRACKET || + $tokens[$stackPtr + 2]['code'] !== T_CONSTANT_ENCAPSED_STRING || + $tokens[$stackPtr + 3]['code'] !== T_CLOSE_SQUARE_BRACKET + ) { + $phpcsFile->addError( + "Unexpected string syntax, it should be `\$string['key']`", + $stackPtr, + 'UnexpectedSyntax' + ); + return null; + } + + // Now we can safely extract the string key and return it. + return trim($tokens[$stackPtr + 2]['content'], "'\""); + } + + /** + * Return the string final pointer, it should be always a \n after a T_SEMICOLON. + * + * @param File $phpcsFile + * @param int $stackPtr + * @return int|null The pointer to the end of the string, or null if it's not an expected string end. + */ + protected function getStringEnd(File $phpcsFile, int $stackPtr): ?int { + $tokens = $phpcsFile->getTokens(); + $currentEndToken = $phpcsFile->findNext(T_SEMICOLON, $stackPtr + 1) + 1; + + // Verify that the current end token is a line feed, if not, we won't be able to fix (swap). + if ( + !isset($tokens[$currentEndToken]) || + $tokens[$currentEndToken]['code'] !== T_WHITESPACE || + $tokens[$currentEndToken]['content'] !== "\n" + ) { + return null; // This is not an expected string end. + } + + return $currentEndToken; + } +} diff --git a/moodle/Tests/Sniffs/Files/LangFilesOrderingTest.php b/moodle/Tests/Sniffs/Files/LangFilesOrderingTest.php new file mode 100644 index 0000000..65eca2c --- /dev/null +++ b/moodle/Tests/Sniffs/Files/LangFilesOrderingTest.php @@ -0,0 +1,97 @@ +. + +namespace MoodleHQ\MoodleCS\moodle\Tests\Files; + +use MoodleHQ\MoodleCS\moodle\Tests\MoodleCSBaseTestCase; + +/** + * Test the LangFilesOrderingSniff sniff. + * + * @copyright 2024 onwards Eloy Lafuente (stronk7) {@link https://stronk7.com} + * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * + * @covers \MoodleHQ\MoodleCS\moodle\Sniffs\Files\LangFilesOrderingSniff + */ +class LangFilesOrderingTest extends MoodleCSBaseTestCase +{ + /** + * @dataProvider filesOrderingProvider + */ + public function testLangFilesOrdering( + string $fixture, + array $warnings, + array $errors + ) { + $this->setStandard('moodle'); + $this->setSniff('moodle.Files.LangFilesOrdering'); + $this->setFixture(__DIR__ . '/fixtures/langFilesOrdering/' . $fixture); + $this->setWarnings($warnings); + $this->setErrors($errors); + + $this->verifyCsResults(); + } + + /** + * Data provider for testLangFilesOrdering tests. + * + * @return array + */ + public static function filesOrderingProvider(): array { + return [ + 'processed correct' => [ + 'lang/en/correct.php', + [], + [], + ], + 'processed with problems' => [ + 'lang/en/withWarningsAndErrors.php', + [ + 27 => '"modvisiblewithstealth_help" is not in the correct order', + 30 => 1, + 34 => 1, + 37 => 1, + 38 => 1, + 45 => 1, + 46 => 1, + 47 => 1, + 51 => 1, + 60 => 'Unexpected comment found. Auto-fixing will not work after this', + 61 => 1, + 63 => 1, + 64 => 1, + ], + [ + 31 => 'Variable "$anothervar" is not expected', + 33 => 'Unexpected string syntax, it should be', + 40 => 'The string key "yourself" is duplicated', + 42 => 'Unexpected string end', + ], + ], + 'without strings' => [ + 'lang/en/withoutLangStrings.php', + [], + [], + ], + 'not processed' => [ + 'lang/en@wrong/incorrectLangDir.php', + [], + [], + ], + ]; + } +} diff --git a/moodle/Tests/Sniffs/Files/fixtures/langFilesOrdering/lang/en/correct.php b/moodle/Tests/Sniffs/Files/fixtures/langFilesOrdering/lang/en/correct.php new file mode 100644 index 0000000..57ff946 --- /dev/null +++ b/moodle/Tests/Sniffs/Files/fixtures/langFilesOrdering/lang/en/correct.php @@ -0,0 +1,28 @@ +. + +/** + * A fixture lang-like file in correct place to test the LangFilesOrderingSniff. + * + * @package core + * @copyright 1999 onwards Martin Dougiamas {@link http://moodle.com} + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +// This lang file is perfect! +$string['aaaa'] = 'aaaa'; +$string['bbbb'] = 'bbbb'; +$string['cccc'] = 'cccc'; diff --git a/moodle/Tests/Sniffs/Files/fixtures/langFilesOrdering/lang/en/correct.php.fixed b/moodle/Tests/Sniffs/Files/fixtures/langFilesOrdering/lang/en/correct.php.fixed new file mode 100644 index 0000000..57ff946 --- /dev/null +++ b/moodle/Tests/Sniffs/Files/fixtures/langFilesOrdering/lang/en/correct.php.fixed @@ -0,0 +1,28 @@ +. + +/** + * A fixture lang-like file in correct place to test the LangFilesOrderingSniff. + * + * @package core + * @copyright 1999 onwards Martin Dougiamas {@link http://moodle.com} + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +// This lang file is perfect! +$string['aaaa'] = 'aaaa'; +$string['bbbb'] = 'bbbb'; +$string['cccc'] = 'cccc'; diff --git a/moodle/Tests/Sniffs/Files/fixtures/langFilesOrdering/lang/en/withWarningsAndErrors.php b/moodle/Tests/Sniffs/Files/fixtures/langFilesOrdering/lang/en/withWarningsAndErrors.php new file mode 100644 index 0000000..50de328 --- /dev/null +++ b/moodle/Tests/Sniffs/Files/fixtures/langFilesOrdering/lang/en/withWarningsAndErrors.php @@ -0,0 +1,64 @@ +. + +/** + * A fixture lang-like file in correct place to test the LangFilesOrderingSniff. + * + * @package core + * @copyright 1999 onwards Martin Dougiamas {@link http://moodle.com} + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +$string['abouttobeinstalled'] = 'about to be installed'; +$string['yourlastlogin'] = 'Your last login was'; +$string['modvisiblewithstealth_help'] = '* Show on course page: Available to students (subject to any access restrictions which may be set). +* Hide on course page: Not available to students. +* Make available but don\'t show on course page: Available to students if you provide a link. Activities will still appear in the gradebook and other reports.'; +$string['action'] = 'Action'; +$anothervar['choco'] = 'kk'; +$string['youneedtoenrol'] = 'To perform that action you need to enrol in this course.'; +$string['bad' ] = 'Bad'; +$string['actionchoice'] = 'What do you want to do with the file \'{$a}\'?'; +$string['actions'] = 'Actions'; +$string['moodlenet:sharesuccesstext'] = "Almost done! Visit your drafts in MoodleNet to finish sharing your content."; +$string['actionsmenu2'] = "Actions menu2"; +$string["actionsmenu"] = 'Actions menu'; +$string['yourself'] = 'yourself'; +$string['yourself'] = 'yourself'; +$string['yourteacher'] = 'your {$a}'; +$string['withoutlinefeed'] = 'Without line feed'; echo 'I should not be here. Not fixable'; +$string['yourwordforx'] = 'Your word for \'{$a}\''; +$string['zippingbackup'] = 'Zipping backup'; +$string['deprecatedeventname'] = '{$a} (no longer in use)'; +$string['accept'] = 'Accept'; +$string['1111'] = '1111'; + +// Deprecated since Moodle 4.0. +$string['cupplyinfo'] = 'More details'; +$string['bcreateuserandpass'] = 'Choose your username and password'; + +// Deprecated since Moodle 4.3. +$string['clicktochangeinbrackets'] = '{$a} (Click to change)'; + +// Deprecated since Moodle 4.4. +$string['asocialheadline'] = 'Social forum - latest topics'; +$string['topicshow'] = 'Show this topic to {$a}'; + +$string['zzzz'] = 'zzzz'; // This comment shouldn't be here. No more auto-fixing after this line. +$string['yyyy'] = 'yyyy'; + +$string['bbbb'] = 'bbbb'; +$string['aaaa'] = 'aaaa'; diff --git a/moodle/Tests/Sniffs/Files/fixtures/langFilesOrdering/lang/en/withWarningsAndErrors.php.fixed b/moodle/Tests/Sniffs/Files/fixtures/langFilesOrdering/lang/en/withWarningsAndErrors.php.fixed new file mode 100644 index 0000000..68f43fc --- /dev/null +++ b/moodle/Tests/Sniffs/Files/fixtures/langFilesOrdering/lang/en/withWarningsAndErrors.php.fixed @@ -0,0 +1,64 @@ +. + +/** + * A fixture lang-like file in correct place to test the LangFilesOrderingSniff. + * + * @package core + * @copyright 1999 onwards Martin Dougiamas {@link http://moodle.com} + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +$string['1111'] = '1111'; +$string['abouttobeinstalled'] = 'about to be installed'; +$string['accept'] = 'Accept'; +$string['action'] = 'Action'; +$string['actionchoice'] = 'What do you want to do with the file \'{$a}\'?'; +$string['actions'] = 'Actions'; +$string["actionsmenu"] = 'Actions menu'; +$string['actionsmenu2'] = "Actions menu2"; +$string['deprecatedeventname'] = '{$a} (no longer in use)'; +$string['modvisiblewithstealth_help'] = '* Show on course page: Available to students (subject to any access restrictions which may be set). +* Hide on course page: Not available to students. +* Make available but don\'t show on course page: Available to students if you provide a link. Activities will still appear in the gradebook and other reports.'; +$string['moodlenet:sharesuccesstext'] = "Almost done! Visit your drafts in MoodleNet to finish sharing your content."; +$string['youneedtoenrol'] = 'To perform that action you need to enrol in this course.'; +$string['yourlastlogin'] = 'Your last login was'; +$anothervar['choco'] = 'kk'; +$string['bad' ] = 'Bad'; +$string['yourself'] = 'yourself'; +$string['yourself'] = 'yourself'; +$string['yourteacher'] = 'your {$a}'; +$string['withoutlinefeed'] = 'Without line feed'; echo 'I should not be here. Not fixable'; +$string['yourwordforx'] = 'Your word for \'{$a}\''; +$string['zippingbackup'] = 'Zipping backup'; + +// Deprecated since Moodle 4.0. +$string['bcreateuserandpass'] = 'Choose your username and password'; +$string['cupplyinfo'] = 'More details'; + +// Deprecated since Moodle 4.3. +$string['clicktochangeinbrackets'] = '{$a} (Click to change)'; + +// Deprecated since Moodle 4.4. +$string['asocialheadline'] = 'Social forum - latest topics'; +$string['topicshow'] = 'Show this topic to {$a}'; + +$string['zzzz'] = 'zzzz'; // This comment shouldn't be here. No more auto-fixing after this line. +$string['yyyy'] = 'yyyy'; + +$string['bbbb'] = 'bbbb'; +$string['aaaa'] = 'aaaa'; diff --git a/moodle/Tests/Sniffs/Files/fixtures/langFilesOrdering/lang/en/withoutLangStrings.php b/moodle/Tests/Sniffs/Files/fixtures/langFilesOrdering/lang/en/withoutLangStrings.php new file mode 100644 index 0000000..5a93d88 --- /dev/null +++ b/moodle/Tests/Sniffs/Files/fixtures/langFilesOrdering/lang/en/withoutLangStrings.php @@ -0,0 +1,25 @@ +. + +/** + * A fixture lang-like file in correct place to test the LangFilesOrderingSniff. + * + * @package core + * @copyright 1999 onwards Martin Dougiamas {@link http://moodle.com} + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +// Surprise, surprise, this lang file does not have any string. \ No newline at end of file diff --git a/moodle/Tests/Sniffs/Files/fixtures/langFilesOrdering/lang/en@wrong/incorrectLangDir.php b/moodle/Tests/Sniffs/Files/fixtures/langFilesOrdering/lang/en@wrong/incorrectLangDir.php new file mode 100644 index 0000000..673b4b3 --- /dev/null +++ b/moodle/Tests/Sniffs/Files/fixtures/langFilesOrdering/lang/en@wrong/incorrectLangDir.php @@ -0,0 +1,25 @@ +. + +/** + * A fixture lang-like file in incorrect place to test the LangFilesOrderingSniff. + * + * @package core + * @copyright 1999 onwards Martin Dougiamas {@link http://moodle.com} + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +$string['wedontneedmany'] = 'As far as this file is not going to be processed (wrong location), one string is enough.'; \ No newline at end of file diff --git a/moodle/Tests/Util/MoodleUtilTest.php b/moodle/Tests/Util/MoodleUtilTest.php index c8e8844..de526f9 100644 --- a/moodle/Tests/Util/MoodleUtilTest.php +++ b/moodle/Tests/Util/MoodleUtilTest.php @@ -480,6 +480,55 @@ protected function cleanMoodleUtilCaches() { $apiCache->setValue(null, []); } + /** + * Data provider for testIsLangFile. + * + * @return array + */ + public static function isLangFileProvider(): array + { + return [ + 'Not in lang directory' => [ + 'value' => '/path/to/standard/file.php', + 'return' => false, + ], + 'In lang/en directory' => [ + 'value' => '/path/to/standard/lang/en/file.php', + 'return' => true, + ], + 'In lang directory but missing en' => [ + 'value' => '/path/to/standard/lang/file.php', + 'return' => false, + ], + 'In lang/en directory but missing .php' => [ + 'value' => '/path/to/standard/lang/en/file', + 'return' => false, + ], + 'In lang/en directory but another extension' => [ + 'value' => '/path/to/standard/lang/en/file.md', + 'return' => false, + ], + 'In lang sub-directory with not allowed chars' => [ + 'value' => '/path/to/standard/lang/@@@/file.php', + 'return' => false, + ], + ]; + } + + /** + * @dataProvider isLangFileProvider + */ + public function testIsLangFile( + string $filepath, + bool $expected + ): void { + $phpcsConfig = new Config(); + $phpcsRuleset = new Ruleset($phpcsConfig); + $file = new File($filepath, $phpcsRuleset, $phpcsConfig); + + $this->assertEquals($expected, MoodleUtil::isLangFile($file)); + } + /** * Data provider for testIsUnitTest. * diff --git a/moodle/Util/MoodleUtil.php b/moodle/Util/MoodleUtil.php index 563c28f..045d3ed 100644 --- a/moodle/Util/MoodleUtil.php +++ b/moodle/Util/MoodleUtil.php @@ -442,6 +442,28 @@ public static function getMoodleRoot(?File $file = null, bool $selfPath = true): return self::$moodleRoot; } + /** + * Whether this file is a lang file. + * + * @param File $phpcsFile + * @return bool + */ + public static function isLangFile(File $phpcsFile): bool + { + // If the file is not under a /lang/[a-zA-Z0-9_-]+/ directory, nothing to check. + // (note that we are using that regex because it's what PARAM_LANG does). + if (preg_match('~/lang/[a-zA-Z0-9_-]+/~', $phpcsFile->getFilename()) === 0) { + return false; + } + + // If the file is not a PHP file, nothing to check. + if (substr($phpcsFile->getFilename(), -4) !== '.php') { + return false; + } + + return true; + } + /** * Whether this file is a unit test file. *