2026-09-16 10:34:38 +02:00
<? php
/**
* Verify that every translation call carries the plugin's text domain.
*
* A mistyped or missing domain does not break anything visibly — the string
* simply never gets translated, and nobody notices until the shop is asked to
* run in another language.
*
* Tokenised rather than grepped, so a domain-looking string inside a sentence
* cannot pass for the real thing.
2026-09-17 00:14:40 +02:00
*
* 🔴 This script's whole job is to look at every string, which makes it the one
* check in the kit that can report success having found NOTHING. "Every
* translation call carries the text domain" is also exactly what a wrong scan
* root prints, or a refactor that moved the strings somewhere this does not
* walk. So it counts what it read and every success line carries the numbers:
* the old line could not tell 142 from 0.
2026-09-16 10:34:38 +02:00
*/
2026-09-17 00:14:40 +02:00
// The floor is the caller's decision, because only the caller knows what zero
// would mean for its plugin. This script is copied between plugins and cannot
// know any of their sizes; a number hardcoded here would make one plugin's
// shape everybody's constraint. A plugin that has strings passes --min; one
// that legitimately has none leaves it off and gets a warning instead.
$min = null ;
$args = array ();
foreach ( array_slice ( $argv , 1 ) as $arg ) {
if ( 0 === strpos ( $arg , '--min=' ) ) {
$min = ( int ) substr ( $arg , 6 );
continue ;
}
$args [] = $arg ;
}
2026-09-16 10:34:38 +02:00
// Copied between plugins, so there is no sensible default domain: a wrong one
// would report "all good" while checking for somebody else's text domain.
2026-09-17 00:14:40 +02:00
if ( empty ( $args [ 0 ] ) ) {
fwrite ( STDERR , "Usage: php ci/check-textdomain.php [--min=N] <text-domain> [root] \n " );
2026-09-16 10:34:38 +02:00
exit ( 1 );
}
2026-09-17 00:14:40 +02:00
$domain = $args [ 0 ];
$root = $args [ 1 ] ?? '.' ;
/**
* Path relative to the scan root, for the annotation.
*
* NOT str_replace( $root, '', $path ). With the usual root of "." that removes
* every dot ANYWHERE in the path, so a real error on "includes/class-db.php"
* was annotated on "includes/class-dbphp" — a filename that does not exist, is
* not clickable in a CI log, and does not match if you grep for it. Every
* error this script printed with the default root was mangled that way.
*
* @param string $path Path as the iterator produced it.
* @param string $root Scan root.
* @return string
*/
function ci_relative_path ( $path , $root ) {
$root = rtrim ( $root , '/' );
if ( '' !== $root && '.' !== $root && 0 === strpos ( $path , $root . '/' ) ) {
return substr ( $path , strlen ( $root ) + 1 );
}
return ltrim ( preg_replace ( '#^\./#' , '' , $path ), '/' );
}
2026-09-16 10:34:38 +02:00
$functions = array (
'__' ,
'_e' ,
'_x' ,
'_ex' ,
'_n' ,
'_nx' ,
'_n_noop' ,
'_nx_noop' ,
'esc_html__' ,
'esc_html_e' ,
'esc_html_x' ,
'esc_attr__' ,
'esc_attr_e' ,
'esc_attr_x' ,
);
$problems = 0 ;
2026-09-17 00:14:40 +02:00
$calls = 0 ;
$files = 0 ;
2026-09-16 10:34:38 +02:00
$iterator = new RecursiveIteratorIterator (
new RecursiveCallbackFilterIterator (
new RecursiveDirectoryIterator ( $root , FilesystemIterator :: SKIP_DOTS ),
static function ( $file ) {
$name = $file -> getFilename ();
return ! in_array ( $name , array ( '.git' , 'node_modules' , 'vendor' , 'build' ), true );
}
)
);
foreach ( $iterator as $file ) {
if ( 'php' !== strtolower ( $file -> getExtension () ) ) {
continue ;
}
2026-09-17 00:14:40 +02:00
$path = $file -> getPathname ();
++ $files ;
2026-09-16 10:34:38 +02:00
$tokens = token_get_all ( file_get_contents ( $path ) );
$count = count ( $tokens );
for ( $i = 0 ; $i < $count ; $i ++ ) {
$token = $tokens [ $i ];
if ( ! is_array ( $token ) || T_STRING !== $token [ 0 ] || ! in_array ( $token [ 1 ], $functions , true ) ) {
continue ;
}
// Skip method calls and definitions: ->__(), function __().
$prev = $i - 1 ;
while ( $prev >= 0 && is_array ( $tokens [ $prev ] ) && T_WHITESPACE === $tokens [ $prev ][ 0 ] ) {
-- $prev ;
}
if ( $prev >= 0 && is_array ( $tokens [ $prev ] ) && in_array ( $tokens [ $prev ][ 0 ], array ( T_OBJECT_OPERATOR , T_DOUBLE_COLON , T_FUNCTION ), true ) ) {
continue ;
}
// Find the opening parenthesis.
$j = $i + 1 ;
while ( $j < $count && is_array ( $tokens [ $j ] ) && T_WHITESPACE === $tokens [ $j ][ 0 ] ) {
++ $j ;
}
if ( $j >= $count || '(' !== $tokens [ $j ] ) {
continue ;
}
2026-09-17 00:14:40 +02:00
++ $calls ;
2026-09-16 10:34:38 +02:00
// Collect every literal string passed at the top level of this call.
$depth = 0 ;
$strings = array ();
for ( $k = $j ; $k < $count ; $k ++ ) {
$t = $tokens [ $k ];
if ( '(' === $t ) {
++ $depth ;
continue ;
}
if ( ')' === $t ) {
-- $depth ;
if ( 0 === $depth ) {
break ;
}
continue ;
}
if ( is_array ( $t ) && T_CONSTANT_ENCAPSED_STRING === $t [ 0 ] ) {
$strings [] = trim ( $t [ 1 ], "' \" " );
}
}
if ( ! in_array ( $domain , $strings , true ) ) {
printf (
"::error file=%s,line=%d::The %s() call is missing the text domain '%s'. \n " ,
2026-09-17 00:14:40 +02:00
ci_relative_path ( $path , $root ),
2026-09-16 10:34:38 +02:00
$token [ 2 ],
$token [ 1 ],
$domain
);
++ $problems ;
}
}
}
if ( $problems > 0 ) {
2026-09-17 00:14:40 +02:00
printf ( " \n %d of %d translation calls are missing the text domain. \n " , $problems , $calls );
2026-09-16 10:34:38 +02:00
exit ( 1 );
}
2026-09-17 00:14:40 +02:00
// Three different zeros, three different verdicts. This one is never
// legitimate and deliberately not caller-configurable: the smallest possible
// plugin still has its main file, so no PHP at all does not say "a small
// plugin", it says the ROOT IS WRONG and this check proved nothing.
if ( 0 === $files ) {
fwrite ( STDERR , "No PHP files were found under '" . $root . "'. The scan root is wrong, so this check proved nothing. \n " );
exit ( 1 );
}
// The caller expected strings and there are none: the scan no longer finds
// this plugin's strings, whatever the reason.
if ( null !== $min && $calls < $min ) {
fwrite (
STDERR ,
sprintf (
"Only %d translation call(s) across %d file(s), but at least %d were expected. The strings have moved somewhere this scan does not look, so this check proved nothing. \n " ,
$calls ,
$files ,
$min
)
);
exit ( 1 );
}
// No floor was given, so zero may be honest — a genuinely small plugin can have
// no translatable strings. It must not be silent, though.
if ( 0 === $calls ) {
fwrite ( STDERR , "Warning: no translation calls were found across " . $files . " file(s). Either this plugin has no translatable strings, or the scan missed them — pass --min=N to make that a failure. \n " );
exit ( 0 );
}
printf (
"All %d translation calls across %d files carry the text domain '%s'. \n " ,
$calls ,
$files ,
$domain
);
2026-09-16 10:34:38 +02:00
exit ( 0 );