<?php
namespace WP_CLI;
use ArrayObject;
use Exception;
class SearchReplacer {
private $from;
private $to;
private $recurse_objects;
private $regex;
private $regex_flags;
private $regex_delimiter;
private $regex_limit;
private $logging;
private $log_data;
private $max_recursion;
/**
* @param string $from String we're looking to replace.
* @param string $to What we want it to be replaced with.
* @param bool $recurse_objects Should objects be recursively replaced?
* @param bool $regex Whether `$from` is a regular expression.
* @param string $regex_flags Flags for regular expression.
* @param string $regex_delimiter Delimiter for regular expression.
* @param bool $logging Whether logging.
* @param integer $regex_limit The maximum possible replacements for each pattern in each subject string.
*/
public function __construct( $from, $to, $recurse_objects = false, $regex = false, $regex_flags = '', $regex_delimiter = '/', $logging = false, $regex_limit = -1 ) {
$this->from = $from;
$this->to = $to;
$this->recurse_objects = $recurse_objects;
$this->regex = $regex;
$this->regex_flags = $regex_flags;
$this->regex_delimiter = $regex_delimiter;
$this->regex_limit = $regex_limit;
$this->logging = $logging;
$this->clear_log_data();
// Get the XDebug nesting level. Will be zero (no limit) if no value is set
$this->max_recursion = intval( ini_get( 'xdebug.max_nesting_level' ) );
}
/**
* Take a serialised array and unserialise it replacing elements as needed and
* unserialising any subordinate arrays and performing the replace on those too.
* Ignores any serialized objects unless $recurse_objects is set to true.
*
* @param array|string $data The data to operate on.
* @param bool $serialised Does the value of $data need to be unserialized?
*
* @return array The original array with all elements replaced as needed.
*/
public function run( $data, $serialised = false ) {
return $this->run_recursively( $data, $serialised );
}
/**
* @param int $recursion_level Current recursion depth within the original data.
* @param array $visited_data Data that has been seen in previous recursion iterations.
*/
private function run_recursively( $data, $serialised, $recursion_level = 0, $visited_data = array() ) {
// some unseriliased data cannot be re-serialised eg. SimpleXMLElements
try {
if ( $this->recurse_objects ) {
// If we've reached the maximum recursion level, short circuit
if ( 0 !== $this->max_recursion && $recursion_level >= $this->max_recursion ) {
return $data;
}
if ( is_array( $data ) || is_object( $data ) ) {
// If we've seen this exact object or array before, short circuit
if ( in_array( $data, $visited_data, true ) ) {
return $data; // Avoid infinite loops when there's a cycle
}
// Add this data to the list of
$visited_data[] = $data;
}
}
try {
// The error suppression operator is not enough in some cases, so we disable
// reporting of notices and warnings as well.
$error_reporting = error_reporting();
error_reporting( $error_reporting & ~E_NOTICE & ~E_WARNING );
$unserialized = is_string( $data ) ? @unserialize( $data ) : false;
error_reporting( $error_reporting );
} catch ( \TypeError $exception ) { // phpcs:ignore PHPCompatibility.Classes.NewClasses.typeerrorFound
// This type error is thrown when trying to unserialize a string that does not fit the
// type declarations of the properties it is supposed to fill.
// This type checking was introduced with PHP 8.1.
// See https://github.com/wp-cli/search-replace-command/issues/191
\WP_CLI::warning(
sprintf(
'Skipping an inconvertible serialized object: "%s", replacements might not be complete. Reason: %s.',
$data,
$exception->getMessage()
)
);
throw new Exception( $exception->getMessage(), $exception->getCode(), $exception );
}
if ( false !== $unserialized ) {
$data = $this->run_recursively( $unserialized, true, $recursion_level + 1 );
} elseif ( is_array( $data ) ) {
$keys = array_keys( $data );
foreach ( $keys as $key ) {
$data[ $key ] = $this->run_recursively( $data[ $key ], false, $recursion_level + 1, $visited_data );
}
} elseif ( $this->recurse_objects && ( is_object( $data ) || $data instanceof \__PHP_Incomplete_Class ) ) {
if ( $data instanceof \__PHP_Incomplete_Class ) {
$array = new ArrayObject( $data );
\WP_CLI::warning(
sprintf(
'Skipping an uninitialized class "%s", replacements might not be complete.',
$array['__PHP_Incomplete_Class_Name']
)
);
} else {
try {
foreach ( $data as $key => $value ) {
$data->$key = $this->run_recursively( $value, false, $recursion_level + 1, $visited_data );
}
} catch ( \Error $exception ) { // phpcs:ignore PHPCompatibility.Classes.NewClasses.errorFound
// This error is thrown when the object that was unserialized cannot be iterated upon.
// The most notable reason is an empty `mysqli_result` object which is then considered to be "already closed".
// See https://github.com/wp-cli/search-replace-command/pull/192#discussion_r1412310179
\WP_CLI::warning(
sprintf(
'Skipping an inconvertible serialized object of type "%s", replacements might not be complete. Reason: %s.',
is_object( $data ) ? get_class( $data ) : gettype( $data ),
$exception->getMessage()
)
);
throw new Exception( $exception->getMessage(), $exception->getCode(), $exception );
}
}
} elseif ( is_string( $data ) ) {
if ( $this->logging ) {
$old_data = $data;
}
if ( $this->regex ) {
$search_regex = $this->regex_delimiter;
$search_regex .= $this->from;
$search_regex .= $this->regex_delimiter;
$search_regex .= $this->regex_flags;
$result = preg_replace( $search_regex, $this->to, $data, $this->regex_limit );
if ( null === $result || PREG_NO_ERROR !== preg_last_error() ) {
\WP_CLI::warning(
sprintf(
'The provided regular expression threw a PCRE error - %s',
$this->preg_error_message( $result )
)
);
}
$data = $result;
} else {
$data = str_replace( $this->from, $this->to, $data );
}
if ( $this->logging && $old_data !== $data ) {
$this->log_data[] = $old_data;
}
}
if ( $serialised ) {
return serialize( $data );
}
} catch ( Exception $exception ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch -- Intentionally empty.
}
return $data;
}
/**
* Gets existing data saved for this run when logging.
* @return array Array of data strings, prior to replacements.
*/
public function get_log_data() {
return $this->log_data;
}
/**
* Clears data stored for logging.
*/
public function clear_log_data() {
$this->log_data = array();
}
/**
* Get the PCRE error constant name from an error value.
*
* @param integer $error Error code.
* @return string Error constant name.
*/
private function preg_error_message( $error ) {
static $error_names = null;
if ( null === $error_names ) {
$definitions = get_defined_constants( true );
$pcre_constants = array_key_exists( 'pcre', $definitions )
? $definitions['pcre']
: array();
$error_names = array_flip( $pcre_constants );
}
return isset( $error_names[ $error ] )
? $error_names[ $error ]
: '<unknown error>';
}
}