Skip to content
Merged
Show file tree
Hide file tree
Changes from 15 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
1 change: 1 addition & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@
"test": [
"@lint",
"@phpcs",
"@phpstan",
"@phpunit",
"@behat"
]
Expand Down
15 changes: 15 additions & 0 deletions phpstan.neon.dist
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
parameters:
level: 9
paths:
- src
- i18n-command.php
scanDirectories:
- vendor/wp-cli/wp-cli/php
scanFiles:
- vendor/php-stubs/wordpress-stubs/wordpress-stubs.php
- tests/phpstan/scan-files.php

treatPhpDocTypesAsCertain: false

stubFiles:
- stubs/Gettext.stub
83 changes: 50 additions & 33 deletions src/AuditCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,10 @@
use Gettext\Translations;
use Gettext\Utils\ParsedComment;
use WP_CLI;
use WP_CLI\Path;
use WP_CLI\Utils;


/**
* Audit strings in a WordPress project.
*
Expand Down Expand Up @@ -88,33 +90,46 @@ class AuditCommand extends MakePotCommand {
* # Audit a plugin with GitHub Actions annotations format.
* $ wp i18n audit wp-content/plugins/hello-world --format=github-actions
*
* @when before_wp_load
*
* @param array<string> $args Positional arguments.
* @param array<mixed> $assoc_args Associative arguments.
* @return void
* @throws WP_CLI\ExitException
*/
public function __invoke( $args, $assoc_args ) {
$this->source = realpath( $args[0] );
if ( ! $this->source || ! is_dir( $this->source ) ) {
$source = realpath( $args[0] );
if ( ! $source || ! is_dir( $source ) ) {
WP_CLI::error( 'Not a valid source directory.' );
}

$this->slug = Utils\get_flag_value( $assoc_args, 'slug', Utils\basename( $this->source ) );
$this->domain = Utils\get_flag_value( $assoc_args, 'domain', null );
$this->skip_js = Utils\get_flag_value( $assoc_args, 'skip-js', $this->skip_js );
$this->skip_php = Utils\get_flag_value( $assoc_args, 'skip-php', $this->skip_php );
$this->skip_blade = Utils\get_flag_value( $assoc_args, 'skip-blade', $this->skip_blade );
$this->skip_block_json = Utils\get_flag_value( $assoc_args, 'skip-block-json', $this->skip_block_json );
$this->skip_theme_json = Utils\get_flag_value( $assoc_args, 'skip-theme-json', $this->skip_theme_json );
$this->format = Utils\get_flag_value( $assoc_args, 'format', $this->format );
$ignore_domain = Utils\get_flag_value( $assoc_args, 'ignore-domain', false );

$include = Utils\get_flag_value( $assoc_args, 'include', [] );
if ( ! empty( $include ) ) {
$this->source = $source;

/** @var array<string, bool|string> $assoc_args_simple */
$assoc_args_simple = $assoc_args;

$slug = Utils\get_flag_value( $assoc_args_simple, 'slug', Path::basename( $this->source ) );
$this->slug = is_string( $slug ) ? $slug : Path::basename( $this->source );

$domain = Utils\get_flag_value( $assoc_args_simple, 'domain', null );
$this->domain = is_string( $domain ) ? $domain : null;

$this->skip_js = (bool) Utils\get_flag_value( $assoc_args_simple, 'skip-js', $this->skip_js );
$this->skip_php = (bool) Utils\get_flag_value( $assoc_args_simple, 'skip-php', $this->skip_php );
$this->skip_blade = (bool) Utils\get_flag_value( $assoc_args_simple, 'skip-blade', $this->skip_blade );
$this->skip_block_json = (bool) Utils\get_flag_value( $assoc_args_simple, 'skip-block-json', $this->skip_block_json );
$this->skip_theme_json = (bool) Utils\get_flag_value( $assoc_args_simple, 'skip-theme-json', $this->skip_theme_json );

$format = Utils\get_flag_value( $assoc_args_simple, 'format', $this->format );
$this->format = is_string( $format ) ? $format : 'plaintext';

$ignore_domain = (bool) Utils\get_flag_value( $assoc_args_simple, 'ignore-domain', false );

$include = Utils\get_flag_value( $assoc_args_simple, 'include', null );
if ( is_string( $include ) && '' !== $include ) {
$this->include = array_map( 'trim', explode( ',', $include ) );
}

$exclude = Utils\get_flag_value( $assoc_args, 'exclude', [] );
if ( ! empty( $exclude ) ) {
$exclude = Utils\get_flag_value( $assoc_args_simple, 'exclude', null );
if ( is_string( $exclude ) && '' !== $exclude ) {
$this->exclude = array_map( 'trim', explode( ',', $exclude ) );
}

Expand All @@ -128,7 +143,7 @@ public function __invoke( $args, $assoc_args ) {
if ( null === $this->domain ) {
$this->domain = $this->slug;

if ( ! empty( $this->main_file_data['Text Domain']['value'] ) ) {
if ( ! empty( $this->main_file_data['Text Domain']['value'] ) && is_string( $this->main_file_data['Text Domain']['value'] ) ) {
$this->domain = $this->main_file_data['Text Domain']['value'];
}
}
Expand Down Expand Up @@ -166,7 +181,7 @@ public function __invoke( $args, $assoc_args ) {
*
* Overrides parent method to suppress log messages when using non-plaintext formats.
*
* @return array
* @return array<string, array<string, mixed>>
*/
protected function get_main_file_data() {
$files = new \IteratorIterator( new \DirectoryIterator( $this->source ) );
Expand Down Expand Up @@ -202,7 +217,8 @@ protected function get_main_file_data() {
}
WP_CLI::debug( sprintf( 'Theme stylesheet: %s', $stylesheet_path ), 'audit' );

$this->project_type = 'theme';
$this->project_type = 'theme';
assert( is_string( $project_path ) );
$this->main_file_path = $project_path;
Comment thread
swissspidy marked this conversation as resolved.
Outdated

return $theme_data;
Expand Down Expand Up @@ -378,7 +394,7 @@ protected function get_comment_text( $comment ) {
* Goes through all extracted strings to find possible mistakes.
*
* @param Translations $translations Translations object.
* @return array Array of issues found.
* @return array<int, array<string, mixed>> Array of issues found.
*/
protected function collect_audit_issues( $translations ) {
$issues = [];
Expand Down Expand Up @@ -420,8 +436,6 @@ protected function collect_audit_issues( $translations ) {
$comments = array_filter(
$comments,
function ( $comment ) {
/** @var ParsedComment|string $comment */
/** @var string $file_header */
foreach ( $this->get_file_headers( $this->project_type ) as $file_header ) {
if ( 0 === strpos( $this->get_comment_text( $comment ), $file_header ) ) {
return false;
Expand Down Expand Up @@ -453,7 +467,8 @@ function ( $comment ) {
}
}

$non_placeholder_content = trim( preg_replace( self::SPRINTF_PLACEHOLDER_REGEX, '', $translation->getOriginal() ) );
$replaced = preg_replace( self::SPRINTF_PLACEHOLDER_REGEX, '', $translation->getOriginal() );
$non_placeholder_content = trim( is_string( $replaced ) ? $replaced : '' );

// Check 3: Flag empty strings without any translatable content.
if ( '' === $non_placeholder_content ) {
Expand Down Expand Up @@ -517,7 +532,8 @@ function ( $comment ) {
/**
* Outputs audit results in the specified format.
*
* @param array $issues Array of issues found.
* @param array<int, array<string, mixed>> $issues Array of issues found.
* @return void
*/
protected function output_results( $issues ) {
if ( empty( $issues ) ) {
Expand All @@ -526,14 +542,15 @@ protected function output_results( $issues ) {

switch ( $this->format ) {
case 'json':
WP_CLI::line( json_encode( $issues, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES ) );
$json = json_encode( $issues, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES );
WP_CLI::line( false !== $json ? $json : '[]' );
break;

case 'github-actions':
foreach ( $issues as $issue ) {
$file = $issue['file'];
$line = $issue['line'] ?? 1;
$message = $issue['message'];
$file = isset( $issue['file'] ) && is_scalar( $issue['file'] ) ? (string) $issue['file'] : '';
$line = isset( $issue['line'] ) && is_scalar( $issue['line'] ) ? (int) $issue['line'] : 1;
$message = isset( $issue['message'] ) && is_scalar( $issue['message'] ) ? (string) $issue['message'] : '';

WP_CLI::line( sprintf( '::warning file=%s,line=%d::%s', $file, $line, $message ) );
}
Expand All @@ -542,9 +559,9 @@ protected function output_results( $issues ) {
case 'plaintext':
default:
foreach ( $issues as $issue ) {
$file = $issue['file'];
$line = $issue['line'] ?? null;
$message = $issue['message'];
$file = isset( $issue['file'] ) && is_scalar( $issue['file'] ) ? (string) $issue['file'] : '';
$line = isset( $issue['line'] ) && is_scalar( $issue['line'] ) ? (int) $issue['line'] : null;
$message = isset( $issue['message'] ) && is_scalar( $issue['message'] ) ? (string) $issue['message'] : '';
$location = $line ? "$file:$line" : $file;

WP_CLI::warning( sprintf( '%s: %s', $location, $message ) );
Expand Down
16 changes: 14 additions & 2 deletions src/BladeCodeExtractor.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@
final class BladeCodeExtractor extends BladeGettextExtractor {
use IterableCodeExtractor;

/**
* @var array<mixed>
*/
public static $options = [
'extractComments' => [ 'translators', 'Translators' ],
'constants' => [],
Expand Down Expand Up @@ -42,21 +45,30 @@ final class BladeCodeExtractor extends BladeGettextExtractor {
],
];

/**
* @var string
*/
protected static $functionsScannerClass = 'WP_CLI\I18n\PhpFunctionsScanner';

/**
* {@inheritdoc}
*
* @param string $text The text to extract strings from.
* @param Translations $translations Translations instance.
* @param array<mixed> $options Extraction options.
* @return void
*/
public static function fromString( $text, Translations $translations, array $options = [] ) {
WP_CLI::debug( "Parsing file {$options['file']}", 'make-pot' );
$file = isset( $options['file'] ) && is_scalar( $options['file'] ) ? (string) $options['file'] : '';
WP_CLI::debug( "Parsing file {$file}", 'make-pot' );

try {
self::fromStringMultiple( $text, [ $translations ], $options );
} catch ( Exception $exception ) {
WP_CLI::debug(
sprintf(
'Could not parse file %1$s: %2$s',
$options['file'],
$file,
$exception->getMessage()
),
'make-pot'
Expand Down
27 changes: 16 additions & 11 deletions src/BladeGettextExtractor.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,27 +16,26 @@ class BladeGettextExtractor extends \Gettext\Extractors\PhpCode {
/**
* Prepares a Blade compiler/engine and returns it.
*
* @param array<string, mixed> $options Options array.
* @return BladeOne
*/
protected static function getBladeCompiler() {
$cache_path = empty( $options['cachePath'] ) ? sys_get_temp_dir() : $options['cachePath'];
$blade_compiler = new BladeOne( null, $cache_path );
protected static function getBladeCompiler( array $options = [] ) {

if ( method_exists( $blade_compiler, 'withoutComponentTags' ) ) {
$blade_compiler->withoutComponentTags();
}
$cache_path = ! empty( $options['cachePath'] ) && is_string( $options['cachePath'] ) ? $options['cachePath'] : sys_get_temp_dir();
$blade_compiler = new BladeOne( null, $cache_path );

return $blade_compiler;
Comment thread
swissspidy marked this conversation as resolved.
}

/**
* Compiles the Blade template string into a PHP string in one step.
*
* @param string $text Blade string to be compiled to a PHP string
* @param string $text Blade string to be compiled to a PHP string
* @param array<string, mixed> $options Options array.
* @return string
*/
protected static function compileBladeToPhp( $text ) {
return static::getBladeCompiler()->compileString( $text );
protected static function compileBladeToPhp( $text, array $options = [] ) {
return static::getBladeCompiler( $options )->compileString( $text );
}

/**
Expand Down Expand Up @@ -76,11 +75,17 @@ protected static function extractComponentPropExpressions( $text ) {
/**
* {@inheritdoc}
*
* @param string $text The text to extract strings from.
* @param array<\Gettext\Translations> $translations Translations instances.
* @param array<mixed> $options Options.
* @return void
*
* Note: In the parent PhpCode class fromString() uses fromStringMultiple() (overridden here)
*/
public static function fromStringMultiple( $text, array $translations, array $options = [] ) {
$php_string = static::compileBladeToPhp( $text );
$php_string = static::compileBladeToPhp( $text, $options );

$php_string .= static::extractComponentPropExpressions( $text );
return parent::fromStringMultiple( $php_string, $translations, $options );
parent::fromStringMultiple( $php_string, $translations, $options );
}
}
6 changes: 5 additions & 1 deletion src/BlockExtractor.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ final class BlockExtractor extends JsonSchemaExtractor {
* @inheritdoc
*/
public static function fromString( $text, Translations $translations, array $options = [] ) {
$file = $options['file'];
$file = isset( $options['file'] ) && is_scalar( $options['file'] ) ? (string) $options['file'] : '';
WP_CLI::debug( "Parsing file $file", 'make-pot' );

$json = json_decode( $text, true );
Expand All @@ -28,6 +28,10 @@ public static function fromString( $text, Translations $translations, array $opt
return;
}

if ( ! is_array( $json ) ) {
return;
}

$domain = isset( $json['textdomain'] ) ? $json['textdomain'] : null;

// Always allow missing domain or when --ignore-domain is used, but skip if domains don't match.
Expand Down
22 changes: 15 additions & 7 deletions src/FileDataExtractor.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,21 +15,28 @@ class FileDataExtractor {
*
* @see get_file_data()
*
* @param string $file Path to the file.
* @param array $headers List of headers, in the format array('HeaderKey' => 'Header Name').
* @param string $file Path to the file.
* @param array<string, string> $headers List of headers, in the format array('HeaderKey' => 'Header Name').
*
* @return array Array of file headers in `HeaderKey => ['value' => Header Value, 'line' => Line Number]` format.
* @return array<string, array{value: string, line: int}> Array of file headers in `HeaderKey => ['value' => Header Value, 'line' => Line Number]` format.
*/
public static function get_file_data( $file, $headers ) {
// We don't need to write to the file, so just open for reading.
$fp = fopen( $file, 'rb' );
if ( false === $fp ) {
return [];
}

// Pull only the first 8kiB of the file in.
$file_data = fread( $fp, 8192 );

// PHP will close file handle, but we are good citizens.
fclose( $fp );

if ( false === $file_data ) {
return [];
}

// Make sure we catch CR-only line endings.
$file_data = str_replace( "\r", "\n", $file_data );

Expand All @@ -39,10 +46,10 @@ public static function get_file_data( $file, $headers ) {
/**
* Retrieves metadata from a string.
*
* @param string $text String to look for metadata in.
* @param array $headers List of headers.
* @param string $text String to look for metadata in.
* @param array<string, string> $headers List of headers.
*
* @return array Array of file headers in `HeaderKey => ['value' => Header Value, 'line' => Line Number]` format.
* @return array<string, array{value: string, line: int}> Array of file headers in `HeaderKey => ['value' => Header Value, 'line' => Line Number]` format.
*/
public static function get_file_data_from_string( $text, $headers ) {
foreach ( $headers as $field => $regex ) {
Expand Down Expand Up @@ -76,6 +83,7 @@ public static function get_file_data_from_string( $text, $headers ) {
* @return string
*/
protected static function _cleanup_header_comment( $str ) { // phpcs:ignore PSR2.Methods.MethodDeclaration.Underscore -- Not changing because third-party commands might use/extend.
return trim( preg_replace( '/\s*(?:\*\/|\?>).*/', '', $str ) );
$replaced = preg_replace( '/\s*(?:\*\/|\?>).*/', '', $str );
return trim( is_string( $replaced ) ? $replaced : '' );
}
}
Loading