Skip to content

Commit

Permalink
Merge pull request #348 from Yoast/JRF/namespacename-add-check-for-ps…
Browse files Browse the repository at this point in the history
…r4-compliance

NamingConventions/NamespaceName: add support for strict PSR-4 compliance checking
  • Loading branch information
jrfnl authored Nov 20, 2023
2 parents 37d4b83 + 02b03dd commit ec67c28
Show file tree
Hide file tree
Showing 13 changed files with 544 additions and 60 deletions.
7 changes: 2 additions & 5 deletions .phpcs.xml.dist
Original file line number Diff line number Diff line change
Expand Up @@ -84,11 +84,8 @@

<rule ref="Yoast.NamingConventions.NamespaceName">
<properties>
<property name="prefixes" type="array">
<element value="YoastCS\Yoast"/>
</property>
<property name="src_directory" type="array">
<element value="Yoast"/>
<property name="psr4_paths" type="array">
<element key="YoastCS\Yoast\\" value="Yoast"/>
</property>
</properties>
</rule>
Expand Down
28 changes: 26 additions & 2 deletions Yoast/Docs/NamingConventions/NamespaceNameStandard.xml
Original file line number Diff line number Diff line change
Expand Up @@ -50,10 +50,18 @@ namespace Yoast\WP\Plugin\Tests\<em>Foo\Bar\Flo\Sub</em>;
<standard>
<![CDATA[
The levels in a namespace name should reflect the path to the file.
Optional, strict PSR-4 compliance for the path to namespace name translation can be enforced by setting the `psr4_paths` property.
If a path to name translation would result in an invalid namespace name based on the naming rules for PHP, an error will be thrown to rename the problem directory.
The differences between the "basic" path to namespace translation and strict PSR-4 path to namespace translation, are as follows:
* The "basic" translation is case-insensitive, while the PSR-4 translation is case-sensitive.
* The "basic" translation will convert dashes and other punctuation characters in the path to underscores, while the PSR-4 translation enforces that the names match exactly.
* The "basic" translation will accept any of the provided prefixes, while the strict PSR-4 translation will require the exact prefix assigned to the matched PSR-4 path.
* The "basic" translation is suitable for use in combination with a Composer classmap for autoloading.
The PSR-4 compliant translation is suitable for use in combination with the Composer `psr4` autoload directive.
If the `psr4_paths` property is provided, the PSR-4 based path translation will always take precedence for a file matching any of the PSR-4 paths.
The plugin specific prefix is disregarded for this check.
If a path to name translation would result in an invalid namespace name based on the naming rules for PHP, an error will be thrown to rename the problem directory.
]]>
</standard>
<code_comparison>
Expand All @@ -72,4 +80,20 @@ namespace Yoast\WP\Plugin\<em>Unrelated</em>;
]]>
</code>
</code_comparison>
<code_comparison>
<code title="Valid: PSR-4 compliant namespace name reflects the path to the file exactly.">
<![CDATA[
<!-- Path to file: <em>User_Forms/</em>file.php -->
<?php
namespace Yoast\WP\Plugin\<em>User_Forms</em>;
]]>
</code>
<code title="Invalid: non-PSR-4 compliant namespace name does not reflect the path to the file exactly.">
<![CDATA[
<!-- Path to file: <em>User_forms/</em>file.php -->
<?php
namespace Yoast\WP\Plugin\<em>user_Forms</em>;
]]>
</code>
</code_comparison>
</documentation>
188 changes: 135 additions & 53 deletions Yoast/Sniffs/NamingConventions/NamespaceNameSniff.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
use YoastCS\Yoast\Utils\CustomPrefixesTrait;
use YoastCS\Yoast\Utils\PathHelper;
use YoastCS\Yoast\Utils\PathValidationHelper;
use YoastCS\Yoast\Utils\PSR4PathsTrait;

/**
* Check namespace name declarations.
Expand All @@ -23,13 +24,16 @@
* placed in.
*
* @since 2.0.0
* @since 3.0.0 Added new check to verify a prefix is used.
* @since 3.0.0 - Added new check to verify a prefix is used.
* - The sniff now also has the ability to check for PSR-4 compliant namespace names.
*
* @uses \YoastCS\Yoast\Utils\CustomPrefixesTrait::$prefixes
* @uses \YoastCS\Yoast\Utils\PSR4PathsTrait::$psr4_paths
*/
final class NamespaceNameSniff implements Sniff {

use CustomPrefixesTrait;
use PSR4PathsTrait;

/**
* Double/Mock/Fixture directories to allow for.
Expand Down Expand Up @@ -135,14 +139,42 @@ public function process( File $phpcsFile, $stackPtr ) {
return;
}

$this->validate_prefixes();
// Stripping potential quotes to ensure `stdin_path` passed by IDEs does not include quotes.
$file = TextStrings::stripQuotes( $phpcsFile->getFileName() );
if ( $file === 'STDIN' ) {
$file = ''; // @codeCoverageIgnore
}
else {
$file = PathHelper::normalize_absolute_path( $file );
}

$valid_prefixes = [];
$psr4_info = false;
if ( $file !== '' ) {
$psr4_info = $this->get_psr4_info( $phpcsFile, $file );
}

if ( \is_array( $psr4_info ) ) {
// If a PSR4 path matched, there will only ever be one valid prefix for the matched path.
$valid_prefixes = [ $psr4_info['prefix'] . '\\' ];
}
else {
// Safeguard that the PSR-4 prefixes are always included.
// Makes sure level depth check still happens even if there is no basepath or path doesn't match PSR-4 path.
if ( empty( $this->prefixes ) && ! empty( $this->psr4_paths ) ) {
$this->prefixes = \array_keys( $this->psr4_paths );
}

$this->validate_prefixes();
$valid_prefixes = $this->validated_prefixes;
}

// Strip off the (longest) plugin prefix.
$namespace_name_no_prefix = $namespace_name;
$found_prefix = '';
if ( ! empty( $this->validated_prefixes ) ) {
if ( ! empty( $valid_prefixes ) ) {
$name = $namespace_name . '\\'; // Validated prefixes always have a \ at the end.
foreach ( $this->validated_prefixes as $prefix ) {
foreach ( $valid_prefixes as $prefix ) {
if ( \strpos( $name, $prefix ) === 0 ) {
$namespace_name_no_prefix = \rtrim( \substr( $name, \strlen( $prefix ) ), '\\' );
$found_prefix = \rtrim( $prefix, '\\' );
Expand All @@ -153,19 +185,31 @@ public function process( File $phpcsFile, $stackPtr ) {
}

// Check if a prefix is used.
if ( ! empty( $this->validated_prefixes ) && $found_prefix === '' ) {
if ( \count( $this->validated_prefixes ) === 1 ) {
$error = 'A namespace name is required to start with the "%s" prefix.';
if ( ! empty( $valid_prefixes ) && $found_prefix === '' ) {
$prefixes = $valid_prefixes;

if ( $psr4_info !== false ) {
$error = 'PSR-4 namespace name for this path is required to start with the "%1$s" prefix.';
$errorcode = 'MissingPSR4Prefix';
}
else {
$error = 'A namespace name is required to start with one of the following prefixes: "%s"';
$error = 'A namespace name is required to start with one of the following prefixes: "%s"';
$errorcode = 'MissingPrefix';

$prefixes = \array_merge( $prefixes, \array_keys( $this->psr4_paths ) );
$prefixes = \array_unique( $prefixes );

if ( \count( $prefixes ) === 1 ) {
$error = 'A namespace name is required to start with the "%s" prefix.';
}
else {
\natcasesort( $prefixes );
}
}

$prefixes = $this->validated_prefixes;
\natcasesort( $prefixes );
$data = [ \implode( '", "', $prefixes ) ];

$phpcsFile->addError( $error, $stackPtr, 'MissingPrefix', $data );
$phpcsFile->addError( $error, $stackPtr, $errorcode, $data );
}

/*
Expand All @@ -175,8 +219,9 @@ public function process( File $phpcsFile, $stackPtr ) {
$namespace_for_level_check = $namespace_name_no_prefix;

// Allow for a variation of `Tests\` and `Tests\*\Doubles\` after the prefix.
$starts_with_tests = ( \strpos( $namespace_for_level_check, 'Tests\\' ) === 0 );
if ( $starts_with_tests === true ) {
$starts_with_tests = ( \strpos( $namespace_for_level_check, 'Tests\\' ) === 0 );
$prefix_ends_with_tests = ( \substr( $found_prefix, -6 ) === '\Tests' );
if ( $starts_with_tests === true || $prefix_ends_with_tests === true ) {
$stripped = false;
foreach ( self::DOUBLE_DIRS as $dir => $length ) {
if ( \strpos( $namespace_for_level_check, $dir ) !== false ) {
Expand All @@ -187,16 +232,27 @@ public function process( File $phpcsFile, $stackPtr ) {
}

if ( $stripped === false ) {
// No double dir found, now check/strip typical test dirs.
if ( \strpos( $namespace_for_level_check, 'Tests\WP\\' ) === 0 ) {
$namespace_for_level_check = \substr( $namespace_for_level_check, 9 );
}
elseif ( \strpos( $namespace_for_level_check, 'Tests\Unit\\' ) === 0 ) {
$namespace_for_level_check = \substr( $namespace_for_level_check, 11 );
if ( $starts_with_tests === true ) {
// No double dir found, now check/strip typical test dirs.
if ( \strpos( $namespace_for_level_check, 'Tests\WP\\' ) === 0 ) {
$namespace_for_level_check = \substr( $namespace_for_level_check, 9 );
}
elseif ( \strpos( $namespace_for_level_check, 'Tests\Unit\\' ) === 0 ) {
$namespace_for_level_check = \substr( $namespace_for_level_check, 11 );
}
else {
// Okay, so this only has the `Tests` prefix, just strip it.
$namespace_for_level_check = \substr( $namespace_for_level_check, 6 );
}
}
else {
// Okay, so this only has the `Tests` prefix, just strip it.
$namespace_for_level_check = \substr( $namespace_for_level_check, 6 );
elseif ( $prefix_ends_with_tests === true ) {
// Prefix which already includes `Tests`.
if ( \strpos( $namespace_for_level_check, 'WP\\' ) === 0 ) {
$namespace_for_level_check = \substr( $namespace_for_level_check, 3 );
}
elseif ( \strpos( $namespace_for_level_check, 'Unit\\' ) === 0 ) {
$namespace_for_level_check = \substr( $namespace_for_level_check, 5 );
}
}
}
}
Expand Down Expand Up @@ -237,40 +293,50 @@ public function process( File $phpcsFile, $stackPtr ) {
return;
}

// Stripping potential quotes to ensure `stdin_path` passed by IDEs does not include quotes.
$file = TextStrings::stripQuotes( $phpcsFile->getFileName() );
if ( $file === 'STDIN' ) {
if ( $file === '' ) {
// STDIN.
return; // @codeCoverageIgnore
}

$directory = PathHelper::normalize_absolute_path( \dirname( $file ) );
$relative_directory = '';
if ( \is_array( $psr4_info ) ) {
$relative_directory = $psr4_info['relative'];
}
else {
$directory = PathHelper::normalize_absolute_path( \dirname( $file ) );

$this->validate_src_directory( $phpcsFile );
$this->validate_src_directory( $phpcsFile );

if ( empty( $this->validated_src_directory ) === false ) {
foreach ( $this->validated_src_directory as $absolute_src_path ) {
if ( PathHelper::path_starts_with( $directory, $absolute_src_path ) === false ) {
continue;
}
if ( empty( $this->validated_src_directory ) === false ) {
foreach ( $this->validated_src_directory as $absolute_src_path ) {
if ( PathHelper::path_starts_with( $directory, $absolute_src_path ) === false ) {
continue;
}

$relative_directory = PathHelper::strip_basepath( $directory, $absolute_src_path );
if ( $relative_directory === '.' ) {
$relative_directory = '';
$relative_directory = PathHelper::strip_basepath( $directory, $absolute_src_path );
break;
}
break;
}
}

if ( $relative_directory === '.' ) {
$relative_directory = '';
}

// Now any potential src directory has been stripped, remove surrounding slashes.
$relative_directory = \trim( $relative_directory, '/' );

// Directory to namespace translation.
$expected = '[Plugin\Prefix]';
if ( $found_prefix !== '' ) {
$expected = $found_prefix;
}
elseif ( \count( $this->validated_prefixes ) === 1 ) {
$expected = \rtrim( $this->validated_prefixes[0], '\\' );
// Namespace name doesn't have the correct prefix, but we do know what the prefix should be.
elseif ( \is_array( $psr4_info ) ) {
$expected = $psr4_info['prefix'];
}
elseif ( \count( $valid_prefixes ) === 1 ) {
$expected = \rtrim( $valid_prefixes[0], '\\' );
}

$clean = [];
Expand All @@ -281,10 +347,13 @@ public function process( File $phpcsFile, $stackPtr ) {
$levels = \array_filter( $levels ); // Remove empties, just in case.

foreach ( $levels as $level ) {
$cleaned_level = \preg_replace( '`[[:punct:]]`', '_', $level );
$words = \explode( '_', $cleaned_level );
$words = \array_map( 'ucfirst', $words );
$cleaned_level = \implode( '_', $words );
$cleaned_level = $level;
if ( $psr4_info === false ) {
$cleaned_level = \preg_replace( '`[[:punct:]]`', '_', $cleaned_level );
$words = \explode( '_', $cleaned_level );
$words = \array_map( 'ucfirst', $words );
$cleaned_level = \implode( '_', $words );
}

if ( NamingConventions::isValidIdentifierName( $cleaned_level ) === false ) {
$phpcsFile->addError(
Expand All @@ -304,23 +373,36 @@ public function process( File $phpcsFile, $stackPtr ) {
$name_for_compare = \implode( '\\', $clean );
}

if ( \strcasecmp( $name_for_compare, $namespace_name_no_prefix ) === 0 ) {
return;
// Check whether the namespace name complies with the rules.
if ( $psr4_info !== false ) {
// Check for PSR-4 compliant namespace.
if ( \strcmp( $name_for_compare, $namespace_name_no_prefix ) === 0 ) {
return;
}

$error = 'Directory marked as a PSR-4 path. The namespace name should match the path exactly in a case-sensitive manner. Expected namespace name: "%s"; found: "%s"';
$code = 'NotPSR4Compliant';
}
else {
// Check for "old-style" namespace.
if ( \strcasecmp( $name_for_compare, $namespace_name_no_prefix ) === 0 ) {
return;
}

$error = 'The namespace (sub)level(s) should reflect the directory path to the file. Expected: "%s"; Found: "%s"';
$code = 'Invalid';
}

if ( $name_for_compare !== '' ) {
$expected .= '\\' . $name_for_compare;
}

$phpcsFile->addError(
'The namespace (sub)level(s) should reflect the directory path to the file. Expected: "%s"; Found: "%s"',
$stackPtr,
'Invalid',
[
$expected,
$namespace_name,
]
);
$data = [
$expected,
$namespace_name,
];

$phpcsFile->addError( $error, $stackPtr, $code, $data );
}

/**
Expand Down
Loading

0 comments on commit ec67c28

Please sign in to comment.