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 Universal.Classes.ModifierKeywordOrder sniff #142

Merged
merged 1 commit into from
Oct 30, 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
27 changes: 27 additions & 0 deletions Universal/Docs/Classes/ModifierKeywordOrderStandard.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<?xml version="1.0"?>
<documentation xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="https://phpcsstandards.github.io/PHPCSDevTools/phpcsdocs.xsd"
title="Class Modifier Keyword Order"
>
<standard>
<![CDATA[
Requires that class modifier keywords consistently use the same keyword order.

By default the expected order is "abstract/final readonly", but this can be changed via the sniff configuration.
]]>
</standard>
<code_comparison>
<code title="Valid: Modifier keywords ordered correctly.">
<![CDATA[
<em>final readonly</em> class Foo {}
<em>abstract readonly</em> class Bar {}
]]>
</code>
<code title="Invalid: Modifier keywords in reverse order.">
<![CDATA[
<em>readonly final</em> class Foo {}
<em>readonly abstract</em> class Bar {}
]]>
</code>
</code_comparison>
</documentation>
219 changes: 219 additions & 0 deletions Universal/Sniffs/Classes/ModifierKeywordOrderSniff.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
<?php
/**
* PHPCSExtra, a collection of sniffs and standards for use with PHP_CodeSniffer.
*
* @package PHPCSExtra
* @copyright 2022 PHPCSExtra Contributors
* @license https://opensource.org/licenses/LGPL-3.0 LGPL3
* @link https://github.com/PHPCSStandards/PHPCSExtra
*/

namespace PHPCSExtra\Universal\Sniffs\Classes;

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

/**
* Standardize the modifier keyword order for class declarations.
*
* @since 1.0.0-alpha4
*/
final class ModifierKeywordOrderSniff implements Sniff
{

/**
* Name of the metric.
*
* @since 1.0.0-alpha4
*
* @var string
*/
const METRIC_NAME = 'Class modifier keyword order';

/**
* Order preference: abstract/final readonly.
*
* @since 1.0.0-alpha4
*
* @var string
*/
const EXTEND_READONLY = 'extendability readonly';

/**
* Order preference: readonly abstract/final.
*
* @since 1.0.0-alpha4
*
* @var string
*/
const READONLY_EXTEND = 'readonly extendability';

/**
* Preferred order for the modifier keywords.
*
* Accepted values:
* - "extendability readonly".
* - or "readonly extendability".
*
* Defaults to "extendability readonly".
*
* @since 1.0.0-alpha4
*
* @var string
*/
public $order = self::EXTEND_READONLY;

/**
* Returns an array of tokens this test wants to listen for.
*
* @since 1.0.0-alpha4
*
* @return array
*/
public function register()
{
return [\T_CLASS];
}

/**
* Processes this test, when one of its tokens is encountered.
*
* @since 1.0.0-alpha4
*
* @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)
{
/*
* Note to self: This can be switched to use the `ObjectDeclarations::getClassProperties()`
* method once that has been adjusted to return stackPtrs as well.
*/
$tokens = $phpcsFile->getTokens();
$valid = Collections::classModifierKeywords() + Tokens::$emptyTokens;
$classProp = [
'abstract_token' => false,
'final_token' => false,
'readonly_token' => false,
];

for ($i = ($stackPtr - 1); $i > 0; $i--) {
if (isset($valid[$tokens[$i]['code']]) === false) {
break;
}

switch ($tokens[$i]['code']) {
case \T_ABSTRACT:
$classProp['abstract_token'] = $i;
break;

case \T_FINAL:
$classProp['final_token'] = $i;
break;

case \T_READONLY:
$classProp['readonly_token'] = $i;
break;
}
}

if ($classProp['readonly_token'] === false
|| ($classProp['final_token'] === false && $classProp['abstract_token'] === false)
) {
/*
* Either no modifier keywords found at all; or only one type of modifier
* keyword (abstract/final or readonly) declared, but not both. No ordering needed.
*/
return;
}

if ($classProp['final_token'] !== false && $classProp['abstract_token'] !== false) {
// Parse error. Ignore.
return;
}

$readonly = $classProp['readonly_token'];

if ($classProp['final_token'] !== false) {
$extendability = $classProp['final_token'];
} else {
$extendability = $classProp['abstract_token'];
}

if ($readonly < $extendability) {
$phpcsFile->recordMetric($stackPtr, self::METRIC_NAME, self::READONLY_EXTEND);
} else {
$phpcsFile->recordMetric($stackPtr, self::METRIC_NAME, self::EXTEND_READONLY);
}

$message = 'Class modifier keywords are not in the correct order. Expected: "%s", found: "%s"';

switch ($this->order) {
case self::READONLY_EXTEND:
if ($readonly < $extendability) {
// Order is correct. Nothing to do.
return;
}

$this->handleError($phpcsFile, $extendability, $readonly);
break;

case self::EXTEND_READONLY:
default:
if ($extendability < $readonly) {
// Order is correct. Nothing to do.
return;
}

$this->handleError($phpcsFile, $readonly, $extendability);
break;
}
}

/**
* Throw the error and potentially fix it.
*
* @since 1.0.0-alpha4
*
* @param \PHP_CodeSniffer\Files\File $phpcsFile The file being scanned.
* @param int $firstKeyword The position of the first keyword found.
* @param int $secondKeyword The position of the second keyword token.
*
* @return void
*/
private function handleError(File $phpcsFile, $firstKeyword, $secondKeyword)
{
$tokens = $phpcsFile->getTokens();

$message = 'Class modifier keywords are not in the correct order. Expected: "%s", found: "%s"';
$data = [
$tokens[$secondKeyword]['content'] . ' ' . $tokens[$firstKeyword]['content'],
$tokens[$firstKeyword]['content'] . ' ' . $tokens[$secondKeyword]['content'],
];

$fix = $phpcsFile->addFixableError($message, $firstKeyword, 'Incorrect', $data);

if ($fix === true) {
$phpcsFile->fixer->beginChangeset();

$phpcsFile->fixer->replaceToken($secondKeyword, '');

// Prevent leaving behind trailing whitespace.
$i = ($secondKeyword + 1);
while ($tokens[$i]['code'] === \T_WHITESPACE) {
$phpcsFile->fixer->replaceToken($i, '');
$i++;
}

// Use the original token content as the case used for keywords is not the concern of this sniff.
$phpcsFile->fixer->addContentBefore($firstKeyword, $tokens[$secondKeyword]['content'] . ' ');

$phpcsFile->fixer->endChangeset();
}
}
}
93 changes: 93 additions & 0 deletions Universal/Tests/Classes/ModifierKeywordOrderUnitTest.inc
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
<?php

/*
* Not our targets, no keyword possible.
* Includes some safeguarding against potential tokenizer issues.
*/
namespace Foo\class\bar;
interface Foo {}
$a = new class() {
public function class() {}
};
echo MyName::class;
function_call(class: $var);

/*
* OK, no or single keyword, no ordering needed.
*/
class NoModifiers {}

final class OnlyFinal {}
abstract class OnlyAbstract extends Something {}
readonly class OnlyReadonly {}

/*
* Ignore, compile errors, not our concern.
*/
final abstract class FinalAbstract {}

readonly final abstract class ReadonlyFinalAbstract {}
final readonly abstract class FinalReadonlyAbstract {}
abstract readonly final class AbstractReadonlyFinal {}

/*
* OK, expected order with default settings.
*/
#[SomeAttribute]
final readonly class FinalReadonlyA {}

abstract /*comment*/ readonly class AbstractReadonlyA implements MyInterface {}

/*
* Bad with default settings.
*/
readonly final class ReadonlyFinalA {}

ReadOnly Abstract class ReadonlyAbstractA extends Something {}

readonly
// comment
final


// phpcs:ignore Stdn.Cat.SniffName -- for reasons.
class ReadonlyFinalB implements MyInterface {}

readonly abstract /*comment*/ class ReadonlyAbstractB {}


// phpcs:set Universal.Classes.ModifierKeywordOrder order readonly extendability

/*
* OK, expected order with custom settings.
*/
readonly final class ReadonlyFinalC {}

readonly abstract class ReadonlyAbstractC extends Something {}

/*
* Bad with custom settings.
*/
FINAL readonly class FinalReadonlyB extends KeywordCaseShouldBeUnchanged {}

abstract readonly class AbstractReadonlyB {}


// phpcs:set Universal.Classes.ModifierKeywordOrder order readonly extends

/*
* OK, expected order with invalid settings (default is used).
*/
abstract readonly class AbstractReadonlyC implements MyInterface {}

/*
* Bad with invalid settings (default is used).
*/
readonly final class ReadonlyFinalD {}

// Reset to default.
// phpcs:set Universal.Classes.ModifierKeywordOrder order extendability readonly


// Live coding. Ignore. This must be the last test in the file.
class LiveCoding
Loading