Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

✨ New Modernize.FunctionCalls.Dirname sniff #172

Merged
merged 2 commits into from
Dec 2, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
/phpcs.xml.dist export-ignore
/phpunit.xml.dist export-ignore
/phpunit-bootstrap.php export-ignore
/Modernize/Tests/ export-ignore
/NormalizedArrays/Tests/ export-ignore
/Universal/Tests/ export-ignore

Expand Down
1 change: 1 addition & 0 deletions .github/workflows/basics.yml
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ jobs:
# Check the code-style consistency of the XML ruleset files.
- name: Check XML code style
run: |
diff -B ./Modernize/ruleset.xml <(xmllint --format "./Modernize/ruleset.xml")
diff -B ./NormalizedArrays/ruleset.xml <(xmllint --format "./NormalizedArrays/ruleset.xml")
diff -B ./Universal/ruleset.xml <(xmllint --format "./Universal/ruleset.xml")

Expand Down
40 changes: 40 additions & 0 deletions Modernize/Docs/FunctionCalls/DirnameStandard.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
<?xml version="1.0"?>
<documentation xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="https://phpcsstandards.github.io/PHPCSDevTools/phpcsdocs.xsd"
title="Function Calls to Dirname"
>
<standard>
<![CDATA[
PHP >= 5.3: Usage of dirname(__FILE__) can be replaced with __DIR__.
]]>
</standard>
<code_comparison>
<code title="Valid: Using __DIR__.">
<![CDATA[
$path = <em>__DIR__</em>;
]]>
</code>
<code title="Invalid: dirname(__FILE__).">
<![CDATA[
$path = <em>dirname(__FILE__)</em>;
]]>
</code>
</code_comparison>
<standard>
<![CDATA[
PHP >= 7.0: Nested calls to dirname() can be replaced by using dirname() with the $levels parameter.
]]>
</standard>
<code_comparison>
<code title="Valid: Using dirname() with the $levels parameter.">
<![CDATA[
$path = <em>dirname($file, 3)</em>;
]]>
</code>
<code title="Invalid: Nested function calls to dirname().">
<![CDATA[
$path = <em>dirname(dirname(dirname($file)))</em>;
]]>
</code>
</code_comparison>
</documentation>
339 changes: 339 additions & 0 deletions Modernize/Sniffs/FunctionCalls/DirnameSniff.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,339 @@
<?php
/**
* PHPCSExtra, a collection of sniffs and standards for use with PHP_CodeSniffer.
*
* @package PHPCSExtra
* @copyright 2020 PHPCSExtra Contributors
* @license https://opensource.org/licenses/LGPL-3.0 LGPL3
* @link https://github.com/PHPCSStandards/PHPCSExtra
*/

namespace PHPCSExtra\Modernize\Sniffs\FunctionCalls;

use PHP_CodeSniffer\Files\File;
use PHP_CodeSniffer\Sniffs\Sniff;
use PHP_CodeSniffer\Util\Tokens;
use PHPCSUtils\Tokens\Collections;
use PHPCSUtils\Utils\PassedParameters;

/**
* Detect `dirname(__FILE__)` and nested uses of `dirname()`.
*
* @since 1.0.0
*/
final class DirnameSniff implements Sniff
{

/**
* Registers the tokens that this sniff wants to listen for.
*
* @since 1.0.0
*
* @return int|string[]
*/
public function register()
{
return [\T_STRING];
}

/**
* Processes this test, when one of its tokens is encountered.
*
* @since 1.0.0
*
* @param \PHP_CodeSniffer\Files\File $phpcsFile The file being scanned.
* @param int $stackPtr The position of the current token
* in the stack passed in $tokens.
*
* @return void
*/
public function process(File $phpcsFile, $stackPtr)
{
$tokens = $phpcsFile->getTokens();

if (\strtolower($tokens[$stackPtr]['content']) !== 'dirname') {
// Not our target.
return;
}

$nextNonEmpty = $phpcsFile->findNext(Tokens::$emptyTokens, ($stackPtr + 1), null, true);
if ($nextNonEmpty === false
|| $tokens[$nextNonEmpty]['code'] !== \T_OPEN_PARENTHESIS
|| isset($tokens[$nextNonEmpty]['parenthesis_owner']) === true
) {
// Not our target.
return;
}

if (isset($tokens[$nextNonEmpty]['parenthesis_closer']) === false) {
// Live coding or parse error, ignore.
return;
}

// Check if it is really a function call to the global function.
$prevNonEmpty = $phpcsFile->findPrevious(Tokens::$emptyTokens, ($stackPtr - 1), null, true);

if (isset(Collections::objectOperators()[$tokens[$prevNonEmpty]['code']]) === true
|| $tokens[$prevNonEmpty]['code'] === \T_NEW
) {
// Method call, class instantiation or other "not our target".
return;
}

if ($tokens[$prevNonEmpty]['code'] === \T_NS_SEPARATOR) {
$prevPrevToken = $phpcsFile->findPrevious(Tokens::$emptyTokens, ($prevNonEmpty - 1), null, true);
if ($tokens[$prevPrevToken]['code'] === \T_STRING
|| $tokens[$prevPrevToken]['code'] === \T_NAMESPACE
) {
// Namespaced function.
return;
}
}

/*
* As of here, we can be pretty sure this is a function call to the global function.
*/
$opener = $nextNonEmpty;
$closer = $tokens[$nextNonEmpty]['parenthesis_closer'];

$parameters = PassedParameters::getParameters($phpcsFile, $stackPtr);
$paramCount = \count($parameters);
if (empty($parameters) || $paramCount > 2) {
// No parameters or too many parameter.
return;
}

$pathParam = PassedParameters::getParameterFromStack($parameters, 1, 'path');
if ($pathParam === false) {
// If the path parameter doesn't exist, there's nothing to do.
return;
}

$levelsParam = PassedParameters::getParameterFromStack($parameters, 2, 'levels');
if ($levelsParam === false && $paramCount === 2) {
// There must be a typo in the param name or an otherwise stray parameter. Ignore.
return;
}

/*
* PHP 5.3+: Detect use of dirname(__FILE__).
*/
if ($pathParam['clean'] === '__FILE__') {
// Determine if the issue is auto-fixable.
$hasComment = $phpcsFile->findNext(Tokens::$commentTokens, ($opener + 1), $closer);
$fixable = ($hasComment === false);

if ($fixable === true) {
$levelsValue = $this->getLevelsValue($phpcsFile, $levelsParam);
if ($levelsParam !== false && $levelsValue === false) {
// Can't autofix if we don't know the value of the $levels parameter.
$fixable = false;
}
}

$error = 'Use the __DIR__ constant instead of calling dirname(__FILE__) (PHP >= 5.3)';
$code = 'FileConstant';

// Throw the error.
if ($fixable === false) {
$phpcsFile->addError($error, $stackPtr, $code);
return;
}

$fix = $phpcsFile->addFixableError($error, $stackPtr, $code);
if ($fix === true) {
if ($levelsParam === false || $levelsValue === 1) {
// No $levels or $levels set to 1: we can replace the complete function call.
$phpcsFile->fixer->beginChangeset();

$phpcsFile->fixer->replaceToken($stackPtr, '__DIR__');

for ($i = ($stackPtr + 1); $i <= $closer; $i++) {
$phpcsFile->fixer->replaceToken($i, '');
}

// Remove potential leading \.
if ($tokens[$prevNonEmpty]['code'] === \T_NS_SEPARATOR) {
$phpcsFile->fixer->replaceToken($prevNonEmpty, '');
}

$phpcsFile->fixer->endChangeset();
} else {
// We can replace the $path parameter and will need to adjust the $levels parameter.
$filePtr = $phpcsFile->findNext(\T_FILE, $pathParam['start'], ($pathParam['end'] + 1));
$levelsPtr = $phpcsFile->findNext(\T_LNUMBER, $levelsParam['start'], ($levelsParam['end'] + 1));

$phpcsFile->fixer->beginChangeset();
$phpcsFile->fixer->replaceToken($filePtr, '__DIR__');
$phpcsFile->fixer->replaceToken($levelsPtr, ($levelsValue - 1));
$phpcsFile->fixer->endChangeset();
}
}

return;
}

/*
* PHP 7.0+: Detect use of nested calls to dirname().
*/
if (\preg_match('`^\s*\\\\?dirname\s*\(`i', $pathParam['clean']) !== 1) {
return;
}

/*
* Check if there is something _behind_ the nested dirname() call within the same parameter.
*
* Note: the findNext() calls are safe and will always match the dirname() function call
* as otherwise the above regex wouldn't have matched.
*/
$innerDirnamePtr = $phpcsFile->findNext(\T_STRING, $pathParam['start'], ($pathParam['end'] + 1));
$innerOpener = $phpcsFile->findNext(\T_OPEN_PARENTHESIS, ($innerDirnamePtr + 1), ($pathParam['end'] + 1));
if (isset($tokens[$innerOpener]['parenthesis_closer']) === false) {
// Shouldn't be possible.
return; // @codeCoverageIgnore
}

$innerCloser = $tokens[$innerOpener]['parenthesis_closer'];
if ($innerCloser !== $pathParam['end']) {
$hasContentAfter = $phpcsFile->findNext(
Tokens::$emptyTokens,
($innerCloser + 1),
($pathParam['end'] + 1),
true
);
if ($hasContentAfter !== false) {
// Matched code like: `dirname(dirname($file) . 'something')`. Ignore.
return;
}
}

/*
* Determine if this is an auto-fixable error.
*/

// Step 1: Are there comments ? If so, not auto-fixable as we don't want to remove comments.
$fixable = true;
for ($i = ($opener + 1); $i < $closer; $i++) {
if (isset(Tokens::$commentTokens[$tokens[$i]['code']])) {
$fixable = false;
break;
}

if ($tokens[$i]['code'] === \T_OPEN_PARENTHESIS
&& isset($tokens[$i]['parenthesis_closer'])
) {
// Skip over everything within the nested dirname() function call.
$i = $tokens[$i]['parenthesis_closer'];
}
}

// Step 2: Does the `$levels` parameter exist for the outer dirname() call and if so, is it usable ?
if ($fixable === true) {
$outerLevelsValue = $this->getLevelsValue($phpcsFile, $levelsParam);
if ($levelsParam !== false && $outerLevelsValue === false) {
// Can't autofix if we don't know the value of the $levels parameter.
$fixable = false;
}
}

// Step 3: Does the `$levels` parameter exist for the inner dirname() call and if so, is it usable ?
if ($fixable === true) {
$innerParameters = PassedParameters::getParameters($phpcsFile, $innerDirnamePtr);
$innerLevelsParam = PassedParameters::getParameterFromStack($innerParameters, 2, 'levels');
$innerLevelsValue = $this->getLevelsValue($phpcsFile, $innerLevelsParam);
if ($innerLevelsParam !== false && $innerLevelsValue === false) {
// Can't autofix if we don't know the value of the $levels parameter.
$fixable = false;
}
}

/*
* Throw the error.
*/
$error = 'Pass the $levels parameter to the dirname() call instead of using nested dirname() calls';
$error .= ' (PHP >= 7.0)';
$code = 'Nested';

if ($fixable === false) {
$phpcsFile->addError($error, $stackPtr, $code);
return;
}

$fix = $phpcsFile->addFixableError($error, $stackPtr, $code);
if ($fix === false) {
return;
}

/*
* Fix the error.
*/
$phpcsFile->fixer->beginChangeset();

// Remove the info in the _outer_ param call.
for ($i = $opener; $i < $innerOpener; $i++) {
$phpcsFile->fixer->replaceToken($i, '');
}

for ($i = ($innerCloser + 1); $i <= $closer; $i++) {
$phpcsFile->fixer->replaceToken($i, '');
}

if ($innerLevelsParam !== false) {
// Inner $levels parameter already exists, just adjust the value.
$innerLevelsPtr = $phpcsFile->findNext(
\T_LNUMBER,
$innerLevelsParam['start'],
($innerLevelsParam['end'] + 1)
);
$phpcsFile->fixer->replaceToken($innerLevelsPtr, ($innerLevelsValue + $outerLevelsValue));
} else {
// Inner $levels parameter does not exist yet. We need to add it.
$content = ', ';

$prevBeforeCloser = $phpcsFile->findPrevious(Tokens::$emptyTokens, ($innerCloser - 1), null, true);
if ($tokens[$prevBeforeCloser]['code'] === \T_COMMA) {
// Trailing comma found, no need to add the comma.
$content = ' ';
}

$innerPathParam = PassedParameters::getParameterFromStack($innerParameters, 1, 'path');
if (isset($innerPathParam['name_token']) === true) {
// Non-named param cannot follow named param, so add param name.
$content .= 'levels: ';
}

$content .= ($innerLevelsValue + $outerLevelsValue);
$phpcsFile->fixer->addContentBefore($innerCloser, $content);
}

$phpcsFile->fixer->endChangeset();
}

/**
* Determine the value of the $levels parameter passed to dirname().
*
* @since 1.0.0
*
* @param \PHP_CodeSniffer\Files\File $phpcsFile The file being scanned.
* @param array|false $levelsParam The information about the parameter as retrieved
* via PassedParameters::getParameterFromStack().
*
* @return int|false Integer levels value or FALSE if the levels value couldn't be determined.
*/
private function getLevelsValue($phpcsFile, $levelsParam)
{
if ($levelsParam === false) {
return 1;
}

$ignore = Tokens::$emptyTokens;
$ignore[] = \T_LNUMBER;

$hasNonNumber = $phpcsFile->findNext($ignore, $levelsParam['start'], ($levelsParam['end'] + 1), true);
if ($hasNonNumber !== false) {
return false;
}

return (int) $levelsParam['clean'];
}
}
Loading