diff --git a/modules/system/Phpcs/Sniffs/NoGlobalAliasesSniff.php b/modules/system/Phpcs/Sniffs/NoGlobalAliasesSniff.php new file mode 100644 index 000000000..213459cd3 --- /dev/null +++ b/modules/system/Phpcs/Sniffs/NoGlobalAliasesSniff.php @@ -0,0 +1,180 @@ +aliases = include $aliasesFile; + } + } + + /** + * Register tokens to listen for. + * + * @return array + */ + public function register() + { + return [T_USE]; + } + + /** + * Process the file for global alias usage. + * + * @param File $phpcsFile + * @param int $stackPtr + */ + public function process(File $phpcsFile, $stackPtr) + { + // Determine if the use statement is at the top of the file or within a class + $isTopLevelUse = $this->isTopLevelUse($phpcsFile, $stackPtr); + + if (!$isTopLevelUse) { + // This is a trait import, check if the alias is already imported + $this->processTraitUse($phpcsFile, $stackPtr); + return; + } + + // Detect the full use statement + $endOfStatement = $phpcsFile->findEndOfStatement($stackPtr); + + // Build the `use` statement without trailing semicolon + $useStatement = ''; + for ($i = $stackPtr + 1; $i < $endOfStatement; $i++) { + $useStatement .= $phpcsFile->getTokens()[$i]['content']; + } + + // Normalize the use statement and remove "as" aliases + $useStatement = trim(preg_replace('/\s+as\s+\w+$/i', '', $useStatement)); + + // $phpcsFile->addError( + // $useStatement . var_export(isset($this->aliases[$useStatement]), true), + // $stackPtr, + // 'NoGlobalAliases' + // ); + + // Check if the use statement matches a key in the aliases + foreach ($this->aliases as $alias => $fullyQualifiedName) { + if ($useStatement === $alias) { + $fix = $phpcsFile->addFixableWarning( + "Avoid using global class alias '{$alias}'. Use '{$fullyQualifiedName}' instead.", + $stackPtr, + 'NoGlobalAliases' + ); + + if ($fix) { + $phpcsFile->fixer->beginChangeset(); + + // Check if the original `use` statement includes an alias (e.g., `as $alias`) + $asPosition = $phpcsFile->findNext(T_AS, $stackPtr, $endOfStatement); + if ($asPosition !== false) { + // Retain the alias by capturing everything after "as" + $alias = $phpcsFile->getTokensAsString($asPosition, $endOfStatement - $asPosition); + $replacement = ' ' . $fullyQualifiedName . ' ' . trim($alias) . ';'; + } else { + // No alias, replace with the fully qualified name directly + $replacement = ' ' . $fullyQualifiedName . ';'; + } + + // Replace the `use` statement + $phpcsFile->fixer->replaceToken($stackPtr + 1, $replacement); + + // Remove any extra tokens between `use` and the end of the statement + for ($i = $stackPtr + 2; $i <= $endOfStatement; $i++) { + $phpcsFile->fixer->replaceToken($i, ''); + } + + $phpcsFile->fixer->endChangeset(); + } + + return; + } + } + } + + /** + * Determine if the current use statement is at the top of the file. + * + * @param File $phpcsFile + * @param int $stackPtr + * @return bool + */ + private function isTopLevelUse(File $phpcsFile, int $stackPtr): bool + { + $prevClassToken = $phpcsFile->findPrevious([T_CLASS, T_TRAIT, T_INTERFACE], $stackPtr); + return $prevClassToken === false; + } + + /** + * Process trait imports within a class. + * + * @param File $phpcsFile + * @param int $stackPtr + */ + private function processTraitUse(File $phpcsFile, int $stackPtr) + { + $endOfStatement = $phpcsFile->findEndOfStatement($stackPtr); + $traitUse = $phpcsFile->getTokensAsString($stackPtr + 1, $endOfStatement - $stackPtr); + $traitUse = trim($traitUse); + + // Extract the short name of the trait + $classParts = explode('\\', $traitUse); + $alias = end($classParts); + + // Check if the alias matches a key in the aliases + if (isset($this->aliases[$alias]) && !$this->isAlreadyImported($phpcsFile, $alias, $stackPtr)) { + $phpcsFile->addWarning( + "Trait '{$alias}' matches a global alias. Ensure it is explicitly imported at the top of the file.", + $stackPtr, + 'TraitMatchesAlias' + ); + } + } + + /** + * Check if a short alias has already been imported at the top of the file. + * + * @param File $phpcsFile + * @param string $alias + * @param int $stackPtr + * @return bool + */ + private function isAlreadyImported(File $phpcsFile, string $alias, int $stackPtr): bool + { + // Iterate over previous `use` statements at the top of the file + $prevUse = $phpcsFile->findPrevious(T_USE, $stackPtr - 1); + while ($prevUse !== false) { + $endOfStatement = $phpcsFile->findEndOfStatement($prevUse); + $useStatement = $phpcsFile->getTokensAsString($prevUse + 1, $endOfStatement - $prevUse); + + // Normalize and extract the alias + $classParts = explode('\\', trim(preg_replace('/\s+as\s+\w+$/i', '', $useStatement))); + $importedAlias = end($classParts); + + if ($importedAlias === $alias) { + return true; // Found an existing import + } + + $prevUse = $phpcsFile->findPrevious(T_USE, $prevUse - 1); + } + + return false; + } +} diff --git a/modules/system/Phpcs/Tests/Sniffs/NoGlobalAliasesSniffTest.php b/modules/system/Phpcs/Tests/Sniffs/NoGlobalAliasesSniffTest.php new file mode 100644 index 000000000..04f8a6d5a --- /dev/null +++ b/modules/system/Phpcs/Tests/Sniffs/NoGlobalAliasesSniffTest.php @@ -0,0 +1,36 @@ + Line numbers as keys, number of errors as values. + */ + public function getErrorList(): array + { + return [ + 3 => 1, // Global alias Config + 4 => 1, // Global alias Lang + 5 => 1, // Global alias URL + 9 => 0, // Fully-qualified import (no error) + 10 => 1, // Global alias Yaml + 14 => 0, // Trait imported (no error) + 15 => 1, // Trait conflicts with alias + ]; + } + + /** + * Returns the lines where warnings should occur. + * + * @return array Line numbers as keys, number of warnings as values. + */ + public function getWarningList(): array + { + return []; + } +} diff --git a/modules/system/Phpcs/Tests/Sniffs/NoGlobalAliasesSniffUnitTest.fixed b/modules/system/Phpcs/Tests/Sniffs/NoGlobalAliasesSniffUnitTest.fixed new file mode 100644 index 000000000..e78902f80 --- /dev/null +++ b/modules/system/Phpcs/Tests/Sniffs/NoGlobalAliasesSniffUnitTest.fixed @@ -0,0 +1,12 @@ + Illuminate\Support\Facades\Cookie::class, 'Crypt' => Illuminate\Support\Facades\Crypt::class, 'Date' => Illuminate\Support\Facades\Date::class, - 'DB' => Illuminate\Support\Facades\DB::class, + // 'DB' => Illuminate\Support\Facades\DB::class, // Replaced by Winter 'Eloquent' => Illuminate\Database\Eloquent\Model::class, - 'Event' => Illuminate\Support\Facades\Event::class, + // 'Event' => Illuminate\Support\Facades\Event::class, // Replaced by Winter // 'File' => Illuminate\Support\Facades\File::class, // Replaced by Winter // 'Gate' => Illuminate\Support\Facades\Gate::class, // Currently unsupported in Winter 'Hash' => Illuminate\Support\Facades\Hash::class, @@ -41,7 +41,7 @@ 'Session' => Illuminate\Support\Facades\Session::class, 'Storage' => Illuminate\Support\Facades\Storage::class, // 'Str' => Illuminate\Support\Str::class, // Replaced by Winter - 'URL' => Illuminate\Support\Facades\URL::class, + // 'URL' => Illuminate\Support\Facades\URL::class, // Replaced by Winter // 'Validator' => Illuminate\Support\Facades\Validator::class, // Replaced by Winter 'View' => Illuminate\Support\Facades\View::class, @@ -56,8 +56,10 @@ 'BackendMenu' => Backend\Facades\BackendMenu::class, 'Block' => Winter\Storm\Support\Facades\Block::class, 'Cms' => Cms\Facades\Cms::class, + 'DB' => Winter\Storm\Support\Facades\DB::class, 'Config' => Winter\Storm\Support\Facades\Config::class, 'DbDongle' => Winter\Storm\Support\Facades\DbDongle::class, + 'Event' => Winter\Storm\Support\Facades\Event::class, 'File' => Winter\Storm\Support\Facades\File::class, 'Flash' => Winter\Storm\Support\Facades\Flash::class, 'Form' => Winter\Storm\Support\Facades\Form::class, @@ -71,8 +73,10 @@ 'Schema' => Winter\Storm\Support\Facades\Schema::class, 'Seeder' => Winter\Storm\Database\Updates\Seeder::class, 'Str' => Winter\Storm\Support\Str::class, + 'Svg' => Winter\Storm\Support\Facades\Svg::class, 'SystemException' => Winter\Storm\Exception\SystemException::class, 'Twig' => Winter\Storm\Support\Facades\Twig::class, + 'URL' => Winter\Storm\Support\Facades\Url::class, 'ValidationException' => Winter\Storm\Exception\ValidationException::class, 'Validator' => Winter\Storm\Support\Facades\Validator::class, 'Yaml' => Winter\Storm\Support\Facades\Yaml::class, @@ -80,8 +84,8 @@ /* * Backwards compatibility aliases */ - 'Db' => Illuminate\Support\Facades\DB::class, - 'Url' => Illuminate\Support\Facades\URL::class, + 'Db' => Winter\Storm\Support\Facades\DB::class, + 'Url' => Winter\Storm\Support\Facades\URL::class, 'TestCase' => System\Tests\Bootstrap\TestCase::class, 'PluginTestCase' => System\Tests\Bootstrap\PluginTestCase::class, diff --git a/modules/system/phpcs.base.xml b/modules/system/phpcs.base.xml index 45dd99163..78c5c603d 100644 --- a/modules/system/phpcs.base.xml +++ b/modules/system/phpcs.base.xml @@ -181,4 +181,6 @@ + +