wishthis/vendor/wp-coding-standards/wpcs/WordPress/Sniffs/Utils/I18nTextDomainFixerSniff.php

863 lines
24 KiB
PHP
Raw Normal View History

2023-09-19 17:26:46 +00:00
<?php
/**
* WordPress Coding Standard.
*
* @package WPCS\WordPressCodingStandards
* @link https://github.com/WordPress/WordPress-Coding-Standards
* @license https://opensource.org/licenses/MIT MIT
*/
namespace WordPressCS\WordPress\Sniffs\Utils;
use PHP_CodeSniffer\Util\Tokens;
use PHPCSUtils\BackCompat\Helper;
use PHPCSUtils\Utils\GetTokensAsString;
use PHPCSUtils\Utils\PassedParameters;
use PHPCSUtils\Utils\TextStrings;
use WordPressCS\WordPress\AbstractFunctionParameterSniff;
use WordPressCS\WordPress\Helpers\RulesetPropertyHelper;
/**
* Comprehensive I18n text domain fixer tool.
*
* This sniff can:
* - Add missing text domains.
* - Replace text domains based on an array of `old` values to a `new` value.
*
* Note: Without a user-defined configuration in a custom ruleset, this sniff will be ignored.
*
* @since 1.2.0
*/
final class I18nTextDomainFixerSniff extends AbstractFunctionParameterSniff {
/**
* A list of tokenizers this sniff supports.
*
* @since 1.2.0
*
* @var array
*/
public $supportedTokenizers = array(
'PHP',
'CSS',
);
/**
* Old text domain(s) to replace.
*
* @since 1.2.0
*
* @var string[]
*/
public $old_text_domain;
/**
* New text domain.
*
* @since 1.2.0
*
* @var string
*/
public $new_text_domain = '';
/**
* The group name for this group of functions.
*
* @since 1.2.0
*
* @var string
*/
protected $group_name = 'i18nfixer';
/**
* The WP Internationalization related functions to target for the replacements.
*
* @since 1.2.0
* @since 3.0.0 The format of the value has changed from a numerically indexed
* array containing parameter positions to an array with the parameter
* position as the index and the parameter name as value.
*
* @var array<string, array<string, int|string>> Function name as key, array with target
* parameter and name as value.
*/
protected $target_functions = array(
'load_textdomain' => array(
'position' => 1,
'name' => 'domain',
),
'load_plugin_textdomain' => array(
'position' => 1,
'name' => 'domain',
),
'load_muplugin_textdomain' => array(
'position' => 1,
'name' => 'domain',
),
'load_theme_textdomain' => array(
'position' => 1,
'name' => 'domain',
),
'load_child_theme_textdomain' => array(
'position' => 1,
'name' => 'domain',
),
'load_script_textdomain' => array(
'position' => 2,
'name' => 'domain',
),
'unload_textdomain' => array(
'position' => 1,
'name' => 'domain',
),
'__' => array(
'position' => 2,
'name' => 'domain',
),
'_e' => array(
'position' => 2,
'name' => 'domain',
),
'_x' => array(
'position' => 3,
'name' => 'domain',
),
'_ex' => array(
'position' => 3,
'name' => 'domain',
),
'_n' => array(
'position' => 4,
'name' => 'domain',
),
'_nx' => array(
'position' => 5,
'name' => 'domain',
),
'_n_noop' => array(
'position' => 3,
'name' => 'domain',
),
'_nx_noop' => array(
'position' => 4,
'name' => 'domain',
),
'translate_nooped_plural' => array(
'position' => 3,
'name' => 'domain',
),
'esc_html__' => array(
'position' => 2,
'name' => 'domain',
),
'esc_html_e' => array(
'position' => 2,
'name' => 'domain',
),
'esc_html_x' => array(
'position' => 3,
'name' => 'domain',
),
'esc_attr__' => array(
'position' => 2,
'name' => 'domain',
),
'esc_attr_e' => array(
'position' => 2,
'name' => 'domain',
),
'esc_attr_x' => array(
'position' => 3,
'name' => 'domain',
),
'is_textdomain_loaded' => array(
'position' => 1,
'name' => 'domain',
),
'get_translations_for_domain' => array(
'position' => 1,
'name' => 'domain',
),
// Deprecated functions.
'_c' => array(
'position' => 2,
'name' => 'domain',
),
'_nc' => array(
'position' => 4,
'name' => 'domain',
),
'__ngettext' => array(
'position' => 4,
'name' => 'domain',
),
'__ngettext_noop' => array(
'position' => 3,
'name' => 'domain',
),
'translate_with_context' => array(
'position' => 2,
'name' => 'domain',
),
// Shouldn't be used by plugins/themes.
'translate' => array(
'position' => 2,
'name' => 'domain',
),
'translate_with_gettext_context' => array(
'position' => 3,
'name' => 'domain',
),
// WP private functions. Shouldn't be used by plugins/themes.
'_load_textdomain_just_in_time' => array(
'position' => 1,
'name' => 'domain',
),
'_get_path_to_translation_from_lang_dir' => array(
'position' => 1,
'name' => 'domain',
),
'_get_path_to_translation' => array(
'position' => 1,
'name' => 'domain',
),
);
/**
* Whether a valid new text domain was found.
*
* @since 1.2.0
*
* @var bool
*/
private $is_valid = false;
/**
* The new text domain as validated.
*
* @since 1.2.0
*
* @var string
*/
private $validated_textdomain = '';
/**
* Whether the plugin/theme header has been seen and fixed yet.
*
* @since 1.2.0
*
* @var bool
*/
private $header_found = false;
/**
* Possible headers for a theme.
*
* @link https://developer.wordpress.org/themes/basics/main-stylesheet-style-css/
*
* @since 1.2.0
*
* @var array<string, bool> Array key is the header name, the value indicated whether it is a
* required (true) or optional (false) header.
*/
private $theme_headers = array(
'Theme Name' => true,
'Theme URI' => false,
'Author' => true,
'Author URI' => false,
'Description' => true,
'Version' => true,
'Requires at least' => true,
'Tested up to' => true,
'Requires PHP' => true,
'License' => true,
'License URI' => true,
'Text Domain' => true,
'Tags' => false,
'Domain Path' => false,
);
/**
* Possible headers for a plugin.
*
* @link https://developer.wordpress.org/plugins/plugin-basics/header-requirements/
*
* @since 1.2.0
*
* @var array<string, bool> Array key is the header name, the value indicated whether it is a
* required (true) or optional (false) header.
*/
private $plugin_headers = array(
'Plugin Name' => true,
'Plugin URI' => false,
'Description' => false,
'Version' => false,
'Requires at least' => false,
'Requires PHP' => false,
'Author' => false,
'Author URI' => false,
'License' => false,
'License URI' => false,
'Text Domain' => false,
'Domain Path' => false,
'Network' => false,
'Update URI' => false,
);
/**
* Regex template to match theme/plugin headers.
*
* @since 1.2.0
*
* @var string
*/
private $header_regex_template = '`^(?:\s*(?:(?:\*|//)\s*)?)?(%s)\s*:\s*([^\r\n]+)`';
/**
* Regex to match theme headers.
*
* Set from within the register() method.
*
* @since 1.2.0
*
* @var string
*/
private $theme_header_regex;
/**
* Regex to match plugin headers.
*
* Set from within the register() method.
*
* @since 1.2.0
*
* @var string
*/
private $plugin_header_regex;
/**
* The --tab-width CLI value that is being used.
*
* @since 1.2.0
*
* @var int
*/
private $tab_width = null;
/**
* Returns an array of tokens this test wants to listen for.
*
* @since 1.2.0
*
* @return array
*/
public function register() {
$headers = array_map(
'preg_quote',
array_keys( $this->theme_headers ),
array_fill( 0, \count( $this->theme_headers ), '`' )
);
$this->theme_header_regex = sprintf( $this->header_regex_template, implode( '|', $headers ) );
$headers = array_map(
'preg_quote',
array_keys( $this->plugin_headers ),
array_fill( 0, \count( $this->plugin_headers ), '`' )
);
$this->plugin_header_regex = sprintf( $this->header_regex_template, implode( '|', $headers ) );
$targets = parent::register();
$targets[] = \T_DOC_COMMENT_OPEN_TAG;
$targets[] = \T_COMMENT;
return $targets;
}
/**
* Processes this test, when one of its tokens is encountered.
*
* @since 1.2.0
*
* @param int $stackPtr The position of the current token in the stack.
*
* @return int|void Integer stack pointer to skip forward or void to continue
* normal file processing.
*/
public function process_token( $stackPtr ) {
// Check if the old/new properties are correctly set. If not, bow out.
if ( ! is_string( $this->new_text_domain )
|| '' === $this->new_text_domain
) {
return ( $this->phpcsFile->numTokens + 1 );
}
if ( isset( $this->old_text_domain ) ) {
$this->old_text_domain = RulesetPropertyHelper::merge_custom_array( $this->old_text_domain, array(), false );
if ( ! is_array( $this->old_text_domain )
|| array() === $this->old_text_domain
) {
return ( $this->phpcsFile->numTokens + 1 );
}
}
// Only validate and throw warning about the text domain once.
if ( $this->new_text_domain !== $this->validated_textdomain ) {
$this->is_valid = false;
$this->validated_textdomain = $this->new_text_domain;
$this->header_found = false;
if ( 'default' === $this->new_text_domain ) {
$this->phpcsFile->addWarning(
'The "default" text domain is reserved for WordPress core use and should not be used by plugins or themes',
0,
'ReservedNewDomain',
array( $this->new_text_domain )
);
return ( $this->phpcsFile->numTokens + 1 );
}
if ( preg_match( '`^[a-z0-9-]+$`', $this->new_text_domain ) !== 1 ) {
$this->phpcsFile->addWarning(
'The text domain should be a simple lowercase text string with words separated by dashes. "%s" appears invalid',
0,
'InvalidNewDomain',
array( $this->new_text_domain )
);
return ( $this->phpcsFile->numTokens + 1 );
}
// If the text domain passed both validations, it should be considered valid.
$this->is_valid = true;
} elseif ( false === $this->is_valid ) {
return ( $this->phpcsFile->numTokens + 1 );
}
if ( isset( $this->tab_width ) === false ) {
$this->tab_width = Helper::getTabWidth( $this->phpcsFile );
}
if ( \T_DOC_COMMENT_OPEN_TAG === $this->tokens[ $stackPtr ]['code']
|| \T_COMMENT === $this->tokens[ $stackPtr ]['code']
) {
// Examine for plugin/theme file header.
return $this->process_comments( $stackPtr );
} elseif ( isset( $this->phpcsFile->tokenizerType ) === false || 'CSS' !== $this->phpcsFile->tokenizerType ) {
// Examine a T_STRING token in a PHP file as a function call.
return parent::process_token( $stackPtr );
}
}
/**
* Process the parameters of a matched function.
*
* @since 1.2.0
*
* @param int $stackPtr The position of the current token in the stack.
* @param string $group_name The name of the group which was matched.
* @param string $matched_content The token content (function name) which was matched
* in lowercase.
* @param array $parameters Array with information about the parameters.
*
* @return void
*/
public function process_parameters( $stackPtr, $group_name, $matched_content, $parameters ) {
$target_param_specs = $this->target_functions[ $matched_content ];
$found_param = PassedParameters::getParameterFromStack( $parameters, $target_param_specs['position'], $target_param_specs['name'] );
if ( false === $found_param && 1 !== $target_param_specs['position'] ) {
$error_msg = 'Missing $domain arg';
$error_code = 'MissingArgDomain';
$has_named_params = false;
foreach ( $parameters as $param ) {
if ( isset( $param['name'] ) ) {
$has_named_params = true;
break;
}
}
if ( false === $has_named_params && isset( $parameters[ ( $target_param_specs['position'] - 1 ) ] ) ) {
$fix = $this->phpcsFile->addFixableError( $error_msg, $stackPtr, $error_code );
if ( true === $fix ) {
$start_previous = $parameters[ ( $target_param_specs['position'] - 1 ) ]['start'];
$end_previous = $parameters[ ( $target_param_specs['position'] - 1 ) ]['end'];
if ( \T_WHITESPACE === $this->tokens[ $start_previous ]['code']
&& $this->tokens[ $start_previous ]['content'] === $this->phpcsFile->eolChar
) {
// Replicate the new line + indentation of the previous item.
$replacement = ',';
for ( $i = $start_previous; $i <= $end_previous; $i++ ) {
if ( \T_WHITESPACE !== $this->tokens[ $i ]['code'] ) {
break;
}
if ( isset( $this->tokens[ $i ]['orig_content'] ) ) {
$replacement .= $this->tokens[ $i ]['orig_content'];
} else {
$replacement .= $this->tokens[ $i ]['content'];
}
}
$replacement .= "'{$this->new_text_domain}'";
} else {
$replacement = ", '{$this->new_text_domain}'";
}
if ( \T_WHITESPACE === $this->tokens[ $end_previous ]['code'] ) {
$this->phpcsFile->fixer->addContentBefore( $end_previous, $replacement );
} else {
$this->phpcsFile->fixer->addContent( $end_previous, $replacement );
}
}
} elseif ( true === $has_named_params ) {
/*
* Function call using named arguments. For now, we will not auto-fix this.
*
* {@internal If we don't bother with indentation and such, this can be made
* auto-fixable by getting the 'end' of the last seen parameter and adding the
* domain parameter, with the 'domain: ' parameter label, after the last
* seen parameter.}
*/
$this->phpcsFile->addError( $error_msg, $stackPtr, $error_code );
} else {
$error_msg .= ' and preceding argument(s)';
$error_code = 'MissingArgs';
// Expected preceeding param also missing, just throw the warning.
$this->phpcsFile->addWarning( $error_msg, $stackPtr, $error_code );
}
return;
}
// Target parameter found. Let's examine it.
$domain_param_start = $found_param['start'];
$domain_param_end = $found_param['end'];
$domain_token = null;
for ( $i = $domain_param_start; $i <= $domain_param_end; $i++ ) {
if ( isset( Tokens::$emptyTokens[ $this->tokens[ $i ]['code'] ] ) ) {
continue;
}
if ( \T_CONSTANT_ENCAPSED_STRING !== $this->tokens[ $i ]['code'] ) {
// Unexpected token found, not our concern. This is handled by the I18n sniff.
return;
}
if ( isset( $domain_token ) ) {
// More than one T_CONSTANT_ENCAPSED_STRING found, not our concern. This is handled by the I18n sniff.
return;
}
$domain_token = $i;
}
// If we're still here, this means only one T_CONSTANT_ENCAPSED_STRING was found.
$old_domain = TextStrings::stripQuotes( $this->tokens[ $domain_token ]['content'] );
if ( ! \in_array( $old_domain, $this->old_text_domain, true ) ) {
// Not a text domain targetted for replacement, ignore.
return;
}
$fix = $this->phpcsFile->addFixableError(
'Mismatched text domain. Expected \'%s\' but found \'%s\'',
$domain_token,
'TextDomainMismatch',
array( $this->new_text_domain, $old_domain )
);
if ( true === $fix ) {
$replacement = str_replace( $old_domain, $this->new_text_domain, $this->tokens[ $domain_token ]['content'] );
$this->phpcsFile->fixer->replaceToken( $domain_token, $replacement );
}
}
/**
* Process the function if no parameters were found.
*
* @since 1.2.0
*
* @param int $stackPtr The position of the current token in the stack.
* @param string $group_name The name of the group which was matched.
* @param string $matched_content The token content (function name) which was matched
* in lowercase.
*
* @return void
*/
public function process_no_parameters( $stackPtr, $group_name, $matched_content ) {
$target_param = $this->target_functions[ $matched_content ];
if ( 1 !== $target_param['position'] ) {
// Only process the no param case as fixable if the text domain is expected to be the first parameter.
$this->phpcsFile->addWarning( 'Missing $domain arg and preceding argument(s)', $stackPtr, 'MissingArgs' );
return;
}
$opener = $this->phpcsFile->findNext( Tokens::$emptyTokens, ( $stackPtr + 1 ), null, true );
if ( \T_OPEN_PARENTHESIS !== $this->tokens[ $opener ]['code']
|| isset( $this->tokens[ $opener ]['parenthesis_closer'] ) === false
) {
// Parse error or live coding.
return;
}
$fix = $this->phpcsFile->addFixableError( 'Missing $domain arg', $stackPtr, 'MissingArgDomain' );
if ( true === $fix ) {
$closer = $this->tokens[ $opener ]['parenthesis_closer'];
$replacement = " '{$this->new_text_domain}' ";
if ( $this->tokens[ $opener ]['line'] !== $this->tokens[ $closer ]['line'] ) {
$replacement = trim( $replacement );
$addBefore = ( $closer - 1 );
if ( \T_WHITESPACE === $this->tokens[ ( $closer - 1 ) ]['code']
&& $this->tokens[ $closer - 1 ]['line'] === $this->tokens[ $closer ]['line']
) {
if ( isset( $this->tokens[ ( $closer - 1 ) ]['orig_content'] ) ) {
$replacement = $this->tokens[ ( $closer - 1 ) ]['orig_content']
. "\t"
. $replacement;
} else {
$replacement = $this->tokens[ ( $closer - 1 ) ]['content']
. str_repeat( ' ', $this->tab_width )
. $replacement;
}
--$addBefore;
} else {
// We don't know whether the code uses tabs or spaces, so presume WPCS, i.e. tabs.
$replacement = "\t" . $replacement;
}
$replacement = $this->phpcsFile->eolChar . $replacement;
$this->phpcsFile->fixer->addContentBefore( $addBefore, $replacement );
} elseif ( \T_WHITESPACE === $this->tokens[ ( $closer - 1 ) ]['code'] ) {
$this->phpcsFile->fixer->replaceToken( ( $closer - 1 ), $replacement );
} else {
$this->phpcsFile->fixer->addContentBefore( $closer, $replacement );
}
}
}
/**
* Process comments to find the plugin/theme headers.
*
* @since 1.2.0
*
* @param int $stackPtr The position of the current token in the stack.
*
* @return int|void Integer stack pointer to skip forward or void to continue
* normal file processing.
*/
public function process_comments( $stackPtr ) {
if ( true === $this->header_found && ! defined( 'PHP_CODESNIFFER_IN_TESTS' ) ) {
return;
}
$regex = $this->plugin_header_regex;
$headers = $this->plugin_headers;
$type = 'plugin';
$skip_to = $stackPtr;
$file = TextStrings::stripQuotes( $this->phpcsFile->getFileName() );
if ( 'STDIN' === $file ) {
return;
}
$file_name = basename( $file );
if ( isset( $this->phpcsFile->tokenizerType ) && 'CSS' === $this->phpcsFile->tokenizerType ) {
if ( 'style.css' !== $file_name && ! defined( 'PHP_CODESNIFFER_IN_TESTS' ) ) {
// CSS files only need to be examined for the file header.
return ( $this->phpcsFile->numTokens + 1 );
}
$regex = $this->theme_header_regex;
$headers = $this->theme_headers;
$type = 'theme';
}
$comment_details = array(
'required_header_found' => false,
'headers_found' => 0,
'text_domain_ptr' => false,
'text_domain_found' => '',
'last_header_ptr' => false,
'last_header_matches' => array(),
);
if ( \T_COMMENT === $this->tokens[ $stackPtr ]['code'] ) {
$block_comment = false;
if ( substr( $this->tokens[ $stackPtr ]['content'], 0, 2 ) === '/*' ) {
$block_comment = true;
}
$current = $stackPtr;
do {
if ( false === $comment_details['text_domain_ptr']
|| false === $comment_details['required_header_found']
|| $comment_details['headers_found'] < 3
) {
$comment_details = $this->examine_comment_line( $current, $regex, $headers, $comment_details );
}
if ( true === $block_comment && substr( $this->tokens[ $current ]['content'], -2 ) === '*/' ) {
++$current;
break;
}
++$current;
} while ( isset( $this->tokens[ $current ] ) && \T_COMMENT === $this->tokens[ $current ]['code'] );
$skip_to = $current;
} else {
if ( ! isset( $this->tokens[ $stackPtr ]['comment_closer'] ) ) {
return;
}
$closer = $this->tokens[ $stackPtr ]['comment_closer'];
$current = $stackPtr;
while ( ( $current = $this->phpcsFile->findNext( \T_DOC_COMMENT_STRING, ( $current + 1 ), $closer ) ) !== false ) {
$comment_details = $this->examine_comment_line( $current, $regex, $headers, $comment_details );
if ( false !== $comment_details['text_domain_ptr']
&& true === $comment_details['required_header_found']
&& $comment_details['headers_found'] >= 3
) {
// No need to look at the rest of the docblock.
break;
}
}
$skip_to = $closer;
}
// So, was this the plugin/theme header ?
if ( true === $comment_details['required_header_found']
&& $comment_details['headers_found'] >= 3
) {
$this->header_found = true;
$text_domain_ptr = $comment_details['text_domain_ptr'];
$text_domain_found = $comment_details['text_domain_found'];
if ( false !== $text_domain_ptr ) {
if ( $this->new_text_domain !== $text_domain_found
&& ( \in_array( $text_domain_found, $this->old_text_domain, true ) )
) {
$fix = $this->phpcsFile->addFixableError(
'Mismatched text domain in %s header. Expected \'%s\' but found \'%s\'',
$text_domain_ptr,
'TextDomainHeaderMismatch',
array(
$type,
$this->new_text_domain,
$text_domain_found,
)
);
if ( true === $fix ) {
if ( isset( $this->tokens[ $text_domain_ptr ]['orig_content'] ) ) {
$replacement = $this->tokens[ $text_domain_ptr ]['orig_content'];
} else {
$replacement = $this->tokens[ $text_domain_ptr ]['content'];
}
$replacement = str_replace( $text_domain_found, $this->new_text_domain, $replacement );
$this->phpcsFile->fixer->replaceToken( $text_domain_ptr, $replacement );
}
}
} else {
$last_header_ptr = $comment_details['last_header_ptr'];
$last_header_matches = $comment_details['last_header_matches'];
$fix = $this->phpcsFile->addFixableError(
'Missing "Text Domain" in %s header',
$last_header_ptr,
'MissingTextDomainHeader',
array( $type )
);
if ( true === $fix ) {
if ( isset( $this->tokens[ $last_header_ptr ]['orig_content'] ) ) {
$replacement = $this->tokens[ $last_header_ptr ]['orig_content'];
} else {
$replacement = $this->tokens[ $last_header_ptr ]['content'];
}
$replacement = str_replace( $last_header_matches[1], 'Text Domain', $replacement );
$replacement = str_replace( $last_header_matches[2], $this->new_text_domain, $replacement );
if ( \T_DOC_COMMENT_OPEN_TAG === $this->tokens[ $stackPtr ]['code'] ) {
for ( $i = ( $last_header_ptr - 1 ); ; $i-- ) {
if ( $this->tokens[ $i ]['line'] !== $this->tokens[ $last_header_ptr ]['line'] ) {
++$i;
break;
}
}
$replacement = $this->phpcsFile->eolChar
. GetTokensAsString::origContent( $this->phpcsFile, $i, ( $last_header_ptr - 1 ) )
. $replacement;
}
$this->phpcsFile->fixer->addContent( $comment_details['last_header_ptr'], $replacement );
}
}
}
return $skip_to;
}
/**
* Examine an individual token in a larger comment for plugin/theme headers.
*
* @since 1.2.0
*
* @param int $stackPtr The position of the current token in the stack.
* @param string $regex The regex to use to examine the comment line.
* @param array $headers Valid headers for a plugin or theme.
* @param array $comment_details The information collected so far.
*
* @return array Adjusted $comment_details array
*/
protected function examine_comment_line( $stackPtr, $regex, $headers, $comment_details ) {
if ( preg_match( $regex, $this->tokens[ $stackPtr ]['content'], $matches ) === 1 ) {
++$comment_details['headers_found'];
if ( true === $headers[ $matches[1] ] ) {
$comment_details['required_header_found'] = true;
}
if ( 'Text Domain' === $matches[1] ) {
$comment_details['text_domain_ptr'] = $stackPtr;
$comment_details['text_domain_found'] = trim( $matches[2] );
}
$comment_details['last_header_ptr'] = $stackPtr;
$comment_details['last_header_matches'] = $matches;
}
return $comment_details;
}
}