Skip to content

Commit 9c442e3

Browse files
authored
Add precommit hook with automated formatting (#2122)
1 parent be6f800 commit 9c442e3

File tree

7 files changed

+270
-2
lines changed

7 files changed

+270
-2
lines changed

.distignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
.git
77
.github
88
.gitignore
9+
.githooks
910
.php_cs
1011
.prettierignore
1112
.prettierrc.js

.githooks/pre-commit

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
#!/usr/bin/env sh
2+
3+
# Get all changed PHP files once.
4+
PHP_FILES=$(git diff --cached --name-only --diff-filter=ACMR | grep "\.php$" || true)
5+
6+
# Skip if no PHP files are changed.
7+
if [ -n "$PHP_FILES" ]; then
8+
# First sort PHP imports
9+
for file in $PHP_FILES; do
10+
node bin/precommit/sort-php-imports.js "$file"
11+
done
12+
13+
# Then check for unused imports.
14+
UNUSED_IMPORTS=0
15+
for file in $PHP_FILES; do
16+
if ! node bin/precommit/check-unused-imports.js "$file"; then
17+
UNUSED_IMPORTS=1
18+
fi
19+
done
20+
21+
# Exit if unused imports were found.
22+
if [ $UNUSED_IMPORTS -eq 1 ]; then
23+
exit 1
24+
fi
25+
26+
# Run PHP Code Sniffer on all changed PHP files at once.
27+
echo "$PHP_FILES" | xargs composer lint:fix > /dev/null 2>&1 || { echo "PHP formatting failed"; exit 1; }
28+
fi
29+
30+
# Run the WordPress formatter without showing all file output.
31+
npm run format > /dev/null 2>&1 || { echo "JavaScript formatting failed"; exit 1; }
32+
33+
# Make sure git is aware of all the changes made by the formatters.
34+
git update-index --again

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ _site
88
.cursor
99
.DS_Store
1010
.php_cs.cache
11+
.phpunit.cache
1112
.phpunit.result.cache
1213
.sass-cache
1314
.vscode/settings.json

.prettierignore

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
1+
.claude
12
build
3+
coverage
24
node_modules
3-
tests
5+
tests/fixtures
46
vendor
57

68
# Temporary ignores while breaking out each component.
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
/**
2+
* Script to check for unused PHP imports
3+
*
4+
* This script analyzes PHP files to detect unused imports
5+
* and reports them with clear error messages
6+
*/
7+
8+
const fs = require( 'fs' );
9+
10+
// Check for unused imports in a PHP file
11+
const checkUnusedImports = ( filePath ) => {
12+
try {
13+
const content = fs.readFileSync( filePath, 'utf8' );
14+
15+
// Find all import statements
16+
const useStatementRegex = /^use\s+([^;]+);/gm;
17+
let match;
18+
let allImports = [];
19+
20+
// Find all imports
21+
while ( ( match = useStatementRegex.exec( content ) ) !== null ) {
22+
const fullMatch = match[ 0 ];
23+
const importName = match[ 1 ].trim();
24+
25+
// Store the import for later checking
26+
const isFunction = fullMatch.includes( 'use function' );
27+
28+
// Handle renamed imports (using 'as' keyword)
29+
let shortName;
30+
if ( importName.includes( ' as ' ) ) {
31+
// For renamed imports, use the alias (after 'as')
32+
shortName = importName.split( ' as ' ).pop().trim();
33+
} else {
34+
// For regular imports, use the last part of the namespace
35+
shortName = importName.split( '\\' ).pop();
36+
}
37+
38+
allImports.push( {
39+
fullMatch,
40+
importName,
41+
shortName,
42+
isFunction,
43+
} );
44+
}
45+
46+
// Check for unused imports
47+
const unusedImports = [];
48+
49+
allImports.forEach( ( importInfo ) => {
50+
const { shortName, isFunction, fullMatch } = importInfo;
51+
52+
// Create regex patterns to find usages
53+
let patterns = [];
54+
55+
if ( isFunction ) {
56+
// For functions, look for function calls: shortName(
57+
patterns.push( new RegExp( `\\b${ shortName }\\s*\\(` ) );
58+
} else {
59+
// For classes, look for various usages:
60+
// 1. new ClassName(
61+
patterns.push( new RegExp( `new\\s+${ shortName }\\b` ) );
62+
// 2. ClassName::
63+
patterns.push( new RegExp( `\\b${ shortName }::` ) );
64+
// 3. instanceof ClassName
65+
patterns.push( new RegExp( `instanceof\\s+${ shortName }\\b` ) );
66+
// 4. Type hints: function(ClassName $param)
67+
patterns.push( new RegExp( `[\\(,]\\s*${ shortName }\\s+\\$` ) );
68+
// 5. Return type hints: function(): ClassName
69+
patterns.push( new RegExp( `:\\s*${ shortName }\\b` ) );
70+
// 6. Used as a variable
71+
patterns.push( new RegExp( `\\b${ shortName }\\b` ) );
72+
}
73+
74+
// Check if the import is used anywhere in the file
75+
const isUsed = patterns.some( ( pattern ) => {
76+
// Skip checking in the import section itself
77+
const contentWithoutImports = content.replace( /^use\s+[^;]+;/gm, '' );
78+
return pattern.test( contentWithoutImports );
79+
} );
80+
81+
if ( ! isUsed ) {
82+
unusedImports.push( { fullMatch, shortName } );
83+
}
84+
} );
85+
86+
return {
87+
unusedImports,
88+
};
89+
} catch ( error ) {
90+
console.error( `Error processing ${ filePath }:`, error );
91+
return {
92+
unusedImports: [],
93+
};
94+
}
95+
};
96+
97+
// If this script is run directly
98+
if ( require.main === module ) {
99+
const args = process.argv.slice( 2 );
100+
if ( args.length === 0 ) {
101+
console.error( 'Please provide a file path' );
102+
process.exit( 1 );
103+
}
104+
105+
const filePath = args[ 0 ];
106+
const result = checkUnusedImports( filePath );
107+
108+
if ( result.unusedImports.length > 0 ) {
109+
console.error( `\x1b[31mERROR: Unused imports found in \x1b[36m${ filePath }\x1b[31m:\x1b[0m` );
110+
result.unusedImports.forEach( ( { fullMatch, shortName } ) => {
111+
console.error( ` - \x1b[33m${ shortName }\x1b[0m (${ fullMatch.trim() })` );
112+
} );
113+
process.exit( 1 );
114+
}
115+
}
116+
117+
// Export for use in other scripts
118+
module.exports = { checkUnusedImports };

bin/precommit/sort-php-imports.js

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
/**
2+
* Script to sort PHP namespace imports
3+
*
4+
* This script is meant to be run before Prettier to sort PHP imports
5+
* It preserves blank lines between different types of imports (class vs function)
6+
*/
7+
8+
const fs = require( 'fs' );
9+
const path = require( 'path' );
10+
11+
// Sort imports in a PHP file
12+
const sortImports = ( filePath ) => {
13+
try {
14+
const content = fs.readFileSync( filePath, 'utf8' );
15+
16+
// Find blocks of use statements
17+
const useStatementRegex = /^use\s+([^;]+);/gm;
18+
let match;
19+
let blocks = [];
20+
21+
// Find all blocks of consecutive use statements
22+
while ( ( match = useStatementRegex.exec( content ) ) !== null ) {
23+
const currentIndex = match.index;
24+
const fullMatch = match[ 0 ];
25+
26+
// Check if this is part of an existing block or a new block
27+
if ( blocks.length > 0 ) {
28+
const lastBlock = blocks[ blocks.length - 1 ];
29+
30+
// If this statement is close to the previous one, add it to the same block
31+
// We check for newlines between statements to determine if they're in the same block
32+
const textBetween = content.substring( lastBlock.end, currentIndex );
33+
const newlineCount = ( textBetween.match( /\n/g ) || [] ).length;
34+
35+
if ( newlineCount <= 2 ) {
36+
lastBlock.statements.push( fullMatch );
37+
lastBlock.end = currentIndex + fullMatch.length;
38+
continue;
39+
}
40+
}
41+
42+
// Start a new block
43+
blocks.push( {
44+
start: currentIndex,
45+
end: currentIndex + fullMatch.length,
46+
statements: [ fullMatch ],
47+
} );
48+
}
49+
50+
// Sort each block of use statements, preserving separation between class and function imports
51+
let result = content;
52+
let offset = 0;
53+
let changed = false;
54+
55+
blocks.forEach( ( block ) => {
56+
// Separate class and function imports
57+
const classImports = block.statements.filter( ( stmt ) => ! stmt.includes( 'use function' ) );
58+
const functionImports = block.statements.filter( ( stmt ) => stmt.includes( 'use function' ) );
59+
60+
// Sort each group separately
61+
const sortedClassImports = [ ...classImports ].sort();
62+
const sortedFunctionImports = [ ...functionImports ].sort();
63+
64+
// Combine with a blank line between if both types exist
65+
let sortedBlock;
66+
if ( sortedClassImports.length > 0 && sortedFunctionImports.length > 0 ) {
67+
sortedBlock = sortedClassImports.join( '\n' ) + '\n\n' + sortedFunctionImports.join( '\n' );
68+
} else {
69+
sortedBlock = [ ...sortedClassImports, ...sortedFunctionImports ].join( '\n' );
70+
}
71+
72+
// Replace the original block with sorted statements
73+
const originalBlock = result.substring( block.start + offset, block.end + offset );
74+
75+
if ( originalBlock !== sortedBlock ) {
76+
changed = true;
77+
result =
78+
result.substring( 0, block.start + offset ) + sortedBlock + result.substring( block.end + offset );
79+
80+
// Update offset for subsequent replacements
81+
offset += sortedBlock.length - originalBlock.length;
82+
}
83+
} );
84+
85+
// Only write the file if changes were made
86+
if ( changed ) {
87+
fs.writeFileSync( filePath, result, 'utf8' );
88+
return { changed: true };
89+
}
90+
91+
return { changed: false };
92+
} catch ( error ) {
93+
console.error( `Error processing ${ filePath }:`, error );
94+
return { changed: false };
95+
}
96+
};
97+
98+
// If this script is run directly
99+
if ( require.main === module ) {
100+
const args = process.argv.slice( 2 );
101+
if ( args.length === 0 ) {
102+
console.error( 'Please provide a file path' );
103+
process.exit( 1 );
104+
}
105+
106+
const filePath = args[ 0 ];
107+
sortImports( filePath );
108+
}
109+
110+
// Export for use in other scripts
111+
module.exports = { sortImports };

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,8 @@
1919
"env-start": "wp-env start",
2020
"env-stop": "wp-env stop",
2121
"env-test": "wp-env run tests-cli --env-cwd=\"wp-content/plugins/activitypub\" vendor/bin/phpunit",
22-
"release": "node bin/release.js"
22+
"release": "node bin/release.js",
23+
"prepare": "git config core.hooksPath .githooks"
2324
},
2425
"license": "MIT",
2526
"bugs": {

0 commit comments

Comments
 (0)