Skip to content
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
7 changes: 7 additions & 0 deletions HM/Sniffs/Files/FunctionFileNameSniff.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
* Sniff to check for namespaced functions are in `namespace.php`.
*/
class FunctionFileNameSniff implements Sniff {
use MuPluginFileTrait;

public function register() {
return array( T_FUNCTION );
}
Expand All @@ -25,6 +27,11 @@ public function process( File $phpcsFile, $stackPtr ) {
return;
}

if ( $this->is_single_file_mu_plugin( $phpcsFile->getFileName() ) ) {
// Single-file plugins cannot be split into a namespace.php file.
return;
}

$filename = basename( $phpcsFile->getFileName() );

if ( $filename === 'namespace.php' ) {
Expand Down
27 changes: 27 additions & 0 deletions HM/Sniffs/Files/MuPluginFileTrait.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<?php
namespace HM\Sniffs\Files;

/**
* Shared helper for detecting single-file must-use plugins.
*
* Single-file mu-plugins live as direct children of a `mu-plugins/` directory
* (or `client-mu-plugins/`, on VIP). This is a useful pattern to support, but
* by definition can't be split into an inc/ directory or named namespace.php
* (multiple plugins would collide), and should allow side effects.
*/
trait MuPluginFileTrait {
/**
* Is the given file a single-file mu-plugin?
*
* @param string $path Full path to the file being checked.
* @return bool True if the file is a direct child of mu-plugins/ or client-mu-plugins/.
*/
protected function is_single_file_mu_plugin( $path ) {
// Normalize the directory separator across operating systems.
if ( DIRECTORY_SEPARATOR !== '/' ) {
$path = str_replace( DIRECTORY_SEPARATOR, '/', $path );
}

return (bool) preg_match( '#/(?:client-)?mu-plugins/[^/]+\.php$#', $path );

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is plugins-mu also used?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not in any projects that I've worked on in the past 3–4 years, my inclination is to leave that one out as it's a confusing pattern

}
}
7 changes: 7 additions & 0 deletions HM/Sniffs/Files/NamespaceDirectoryNameSniff.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
* Namespaced things must be in directories matching the namespace.
*/
class NamespaceDirectoryNameSniff implements Sniff {
use MuPluginFileTrait;

/**
* Returns an array of tokens this test wants to listen for.
*
Expand Down Expand Up @@ -50,6 +52,11 @@ public function process( File $phpcsFile, $stackPtr ) {
$directory = str_replace( DIRECTORY_SEPARATOR, '/', $directory );
}

if ( $this->is_single_file_mu_plugin( $full ) ) {
// Single-file mu-plugins will naturally never have an inc/ directory.
return;
}

if ( $filename === 'plugin.php' || $filename === 'functions.php' || $filename === 'load.php' ) {
// Ignore the main file.
return;
Expand Down
12 changes: 9 additions & 3 deletions HM/Tests/Files/FunctionFileNameUnitTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@

namespace HM\Tests\Files;

use DirectoryIterator;
use RecursiveDirectoryIterator;
use RecursiveIteratorIterator;
use PHP_CodeSniffer\Tests\Standards\AbstractSniffUnitTest;

/**
Expand All @@ -20,10 +21,12 @@ protected function getTestFiles( $test_base_dir ) {
$test_base_dir = rtrim( $test_base_dir, '.' );
$test_files = [];

$di = new DirectoryIterator( $test_base_dir );
$di = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator( $test_base_dir )
);

foreach ( $di as $file ) {
if ( $file->isDot() ) {
if ( ! $file->isFile() ) {
continue;
}

Expand All @@ -46,6 +49,9 @@ public function getErrorList() {
$pass = [
'namespace.php',
'matching-namespace.php',
// Single-file mu-plugins can't all be named namespace.php.
'my-plugin.php',
'my-client-plugin.php',
];
if ( in_array( $file, $pass, true ) ) {
return [];
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<?php

namespace HM\Coding\Standards\Tests;

function foo() {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<?php

namespace HM\Coding\Standards\Tests;

function foo() {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<?php

namespace HM\Coding\Standards\Tests;

function foo() {}
3 changes: 3 additions & 0 deletions HM/Tests/Files/NamespaceDirectoryNameUnitTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,9 @@ public function getErrorList() {
'camelcased-namespace.php',
'underscored-namespace.php',
'strict-types-namespace.php',
// Single-file mu-plugins are exempt from the directory structure rules.
'mu-plugin.php',
'client-mu-plugin.php',
];
if ( in_array( $file, $pass, true ) ) {
return [];
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
<?php

namespace HM\Coding\Standards\Client_Mu_Plugin;
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
<?php

namespace HM\Coding\Standards\Mu_Plugin;
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
<?php

namespace HM\Coding\Standards\Nested;
6 changes: 5 additions & 1 deletion HM/ruleset.xml
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,11 @@
<rule ref="PSR1.Classes.ClassDeclaration" />

<!-- Declare symbols or run code, but not both. -->
<rule ref="PSR1.Files.SideEffects" />
<rule ref="PSR1.Files.SideEffects">
<!-- Permit single-file mu-plugins, which can be pragmatically useful. -->
<exclude-pattern>*/mu-plugins/[^/]+\.php$</exclude-pattern>
<exclude-pattern>*/client-mu-plugins/[^/]+\.php$</exclude-pattern>
</rule>

<!-- Namespacing required for functions. -->
<rule ref="PSR2.Namespaces.NamespaceDeclaration" />
Expand Down
1 change: 1 addition & 0 deletions phpunit.xml.dist
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
<testsuite name="all">
<file>tests/AllSniffs.php</file>
<file>tests/FixtureTests.php</file>
<file>tests/MuPluginSideEffectsTest.php</file>
</testsuite>
</testsuites>
</phpunit>
77 changes: 77 additions & 0 deletions tests/MuPluginSideEffectsTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
<?php
/**
* Test that PSR1.Files.SideEffects is excluded for single-file mu-plugins.
*
* The two HM file-structure sniffs handle their own mu-plugin exemption in
* code (covered by their sniff unit tests). PSR1.Files.SideEffects is a
* third-party sniff, so its exemption lives as an <exclude-pattern> in
* HM/ruleset.xml.
*
* We exercise it by running the phpcs binary in a subprocess rather than
* building the ruleset in-process: loading the full HM standard alongside the
* other test suites triggers sniff class redeclaration errors, and restricting
* the sniffs in-process makes PHPCS skip the ruleset processing that loads the
* exclude-patterns in the first place.
*/

namespace HM\CodingStandards\Tests;

use PHPUnit\Framework\TestCase;

/**
* Class MuPluginSideEffectsTest
*
* @group fixtures
*/
class MuPluginSideEffectsTest extends TestCase {
const PSR1_SIDE_EFFECTS = 'PSR1.Files.SideEffects.FoundWithSymbols';

/**
* Run PSR1.Files.SideEffects over a fixture and return the message sources.
*
* @param string $relative_file Fixture path relative to the tests directory.
* @return string[] List of reported message sources.
*/
protected function get_sources( string $relative_file ) {
$root = dirname( __DIR__ );
$phpcs = $root . '/vendor/bin/phpcs';
$file = __DIR__ . '/' . $relative_file;

$command = sprintf(
'%s %s --standard=HM --sniffs=PSR1.Files.SideEffects --report=json %s',
escapeshellarg( PHP_BINARY ),
escapeshellarg( $phpcs ),
escapeshellarg( $file )
);

$output = shell_exec( $command );
$report = json_decode( $output, true );

$this->assertSame( JSON_ERROR_NONE, json_last_error(), 'phpcs should return valid JSON' );

$sources = [];
foreach ( $report['files'] as $details ) {
foreach ( $details['messages'] as $message ) {
$sources[] = $message['source'];
}
}

return $sources;
}

/**
* Single-file mu-plugins should be exempt from the side-effects rule.
*/
public function test_mu_plugin_is_exempt() {
$sources = $this->get_sources( 'fixtures/sideeffects/mu-plugins/example-plugin.php' );
$this->assertNotContains( static::PSR1_SIDE_EFFECTS, $sources );
}

/**
* The same code outside mu-plugins/ should still get flagged, to prove correct scoping.
*/
public function test_regular_file_is_flagged() {
$sources = $this->get_sources( 'fixtures/sideeffects/regular/example-plugin.php' );
$this->assertContains( static::PSR1_SIDE_EFFECTS, $sources );
}
}
18 changes: 18 additions & 0 deletions tests/fixtures/sideeffects/mu-plugins/example-plugin.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<?php
/**
* Plugin Name: Example single-file mu-plugin.
*
* Single-file mu-plugins routinely declare symbols and add hooks in the same
* file, so PSR1.Files.SideEffects should not flag them.
*/

namespace HM\Coding\Standards\Example;

add_action( 'init', __NAMESPACE__ . '\\bootstrap' );

/**
* Bootstrap the plugin.
*/
function bootstrap() {
// ...
}
18 changes: 18 additions & 0 deletions tests/fixtures/sideeffects/regular/example-plugin.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<?php
/**
* Plugin Name: Example regular plugin file.
*
* Identical to the mu-plugin fixture, but not a direct child of mu-plugins/,
* so PSR1.Files.SideEffects should still flag it.
*/

namespace HM\Coding\Standards\Example;

add_action( 'init', __NAMESPACE__ . '\\bootstrap' );

/**
* Bootstrap the plugin.
*/
function bootstrap() {
// ...
}