[root]\n" ); exit( 1 ); } $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 ), '/' ); } $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; $calls = 0; $files = 0; $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; } $path = $file->getPathname(); ++$files; $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; } ++$calls; // 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", ci_relative_path( $path, $root ), $token[2], $token[1], $domain ); ++$problems; } } } if ( $problems > 0 ) { printf( "\n%d of %d translation calls are missing the text domain.\n", $problems, $calls ); exit( 1 ); } // 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 ); exit( 0 );