<?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\Security;

use PHP_CodeSniffer\Util\Tokens;
use PHPCSUtils\BackCompat\BCFile;
use PHPCSUtils\Tokens\Collections;
use PHPCSUtils\Utils\Arrays;
use PHPCSUtils\Utils\Conditions;
use PHPCSUtils\Utils\Operators;
use PHPCSUtils\Utils\PassedParameters;
use PHPCSUtils\Utils\TextStrings;
use WordPressCS\WordPress\AbstractFunctionRestrictionsSniff;
use WordPressCS\WordPress\Helpers\ArrayWalkingFunctionsHelper;
use WordPressCS\WordPress\Helpers\ConstantsHelper;
use WordPressCS\WordPress\Helpers\ContextHelper;
use WordPressCS\WordPress\Helpers\EscapingFunctionsTrait;
use WordPressCS\WordPress\Helpers\FormattingFunctionsHelper;
use WordPressCS\WordPress\Helpers\PrintingFunctionsTrait;
use WordPressCS\WordPress\Helpers\VariableHelper;

/**
 * Verifies that all outputted strings are escaped.
 *
 * @link https://developer.wordpress.org/apis/security/data-validation/ WordPress Developer Docs on Data Validation.
 *
 * @since 2013-06-11
 * @since 0.4.0  This class now extends the WordPressCS native `Sniff` class.
 * @since 0.5.0  The various function list properties which used to be contained in this class
 *               have been moved to the WordPressCS native `Sniff` parent class.
 * @since 0.12.0 This sniff will now also check for output escaping when using shorthand
 *               echo tags `<?=`.
 * @since 0.13.0 Class name changed: this class is now namespaced.
 * @since 1.0.0  This sniff has been moved from the `XSS` category to the `Security` category.
 * @since 3.0.0  This class now extends the WordPressCS native
 *               `AbstractFunctionRestrictionsSniff` class.
 *               The parent `exclude` property is disabled.
 *
 * @uses \WordPressCS\WordPress\Helpers\EscapingFunctionsTrait::$customEscapingFunctions
 * @uses \WordPressCS\WordPress\Helpers\EscapingFunctionsTrait::$customAutoEscapedFunctions
 * @uses \WordPressCS\WordPress\Helpers\PrintingFunctionsTrait::$customPrintingFunctions
 */
class EscapeOutputSniff extends AbstractFunctionRestrictionsSniff {

	use EscapingFunctionsTrait;
	use PrintingFunctionsTrait;

	/**
	 * Printing functions that incorporate unsafe values.
	 *
	 * @since 0.4.0
	 * @since 0.11.0 Changed from public static to protected non-static.
	 * @since 3.0.0  The format of the array values has changed from plain string to array.
	 *
	 * @var array<string, array>
	 */
	protected $unsafePrintingFunctions = array(
		'_e'  => array(
			'alternative' => 'esc_html_e() or esc_attr_e()',
			'params'      => array(
				1 => 'text',
			),
		),
		'_ex' => array(
			'alternative' => 'echo esc_html_x() or echo esc_attr_x()',
			'params'      => array(
				1 => 'text',
			),
		),
	);

	/**
	 * List of names of the native PHP constants which can be considered safe.
	 *
	 * @since 1.0.0
	 *
	 * @var array<string, bool>
	 */
	private $safe_php_constants = array(
		'PHP_EOL'             => true, // String.
		'PHP_VERSION'         => true, // Integer.
		'PHP_MAJOR_VERSION'   => true, // Integer.
		'PHP_MINOR_VERSION'   => true, // Integer.
		'PHP_RELEASE_VERSION' => true, // Integer.
		'PHP_VERSION_ID'      => true, // Integer.
		'PHP_EXTRA_VERSION'   => true, // String.
		'PHP_DEBUG'           => true, // Integer.
	);

	/**
	 * List of tokens which can be considered as safe when directly part of the output.
	 *
	 * This list is enhanced with additional tokens in the `register()` method.
	 *
	 * @since 0.12.0
	 *
	 * @var array<string|int, string|int>
	 */
	private $safe_components = array(
		\T_LNUMBER                  => \T_LNUMBER,
		\T_DNUMBER                  => \T_DNUMBER,
		\T_TRUE                     => \T_TRUE,
		\T_FALSE                    => \T_FALSE,
		\T_NULL                     => \T_NULL,
		\T_CONSTANT_ENCAPSED_STRING => \T_CONSTANT_ENCAPSED_STRING,
		\T_START_NOWDOC             => \T_START_NOWDOC,
		\T_NOWDOC                   => \T_NOWDOC,
		\T_END_NOWDOC               => \T_END_NOWDOC,
		\T_BOOLEAN_NOT              => \T_BOOLEAN_NOT,
	);

	/**
	 * List of keyword tokens this sniff listens for, which can also be used as an inline expression.
	 *
	 * @since 3.0.0
	 *
	 * @var array<string|int, string|int>
	 */
	private $target_keywords = array(
		\T_EXIT  => \T_EXIT,
		\T_PRINT => \T_PRINT,
		\T_THROW => \T_THROW,
	);

	/**
	 * Returns an array of tokens this test wants to listen for.
	 *
	 * @return string|int[]
	 */
	public function register() {
		// Enrich the list of "safe components" tokens.
		$this->safe_components += Tokens::$comparisonTokens;
		$this->safe_components += Tokens::$operators;
		$this->safe_components += Tokens::$booleanOperators;
		$this->safe_components += Collections::incrementDecrementOperators();

		// Set up the tokens the sniff should listen too.
		$targets   = array_merge( parent::register(), $this->target_keywords );
		$targets[] = \T_ECHO;
		$targets[] = \T_OPEN_TAG_WITH_ECHO;

		return $targets;
	}

	/**
	 * Groups of functions this sniff is looking for.
	 *
	 * @since 3.0.0
	 *
	 * @return array
	 */
	public function getGroups() {
		// Make sure all array keys are lowercase (could contain user provided function names).
		$printing_functions = array_change_key_case( $this->get_printing_functions(), \CASE_LOWER );

		// Remove the unsafe printing functions to prevent duplicate notices.
		$printing_functions = array_diff_key( $printing_functions, $this->unsafePrintingFunctions );

		return array(
			'unsafe_printing_functions' => array(
				'functions' => array_keys( $this->unsafePrintingFunctions ),
			),
			'printing_functions' => array(
				'functions' => array_keys( $printing_functions ),
			),
		);
	}

	/**
	 * Processes this test, when one of its tokens is encountered.
	 *
	 * @since 3.0.0 This method has been split up.
	 *
	 * @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 ) {
		$start = ( $stackPtr + 1 );
		$end   = $start;

		switch ( $this->tokens[ $stackPtr ]['code'] ) {
			case \T_STRING:
				// Prevent exclusion of any of the function groups.
				$this->exclude = array();

				// In the tests, custom printing functions may be added/removed on the fly.
				if ( defined( 'PHP_CODESNIFFER_IN_TESTS' ) ) {
					$this->setup_groups( 'functions' );
				}

				// Let the abstract parent class handle the initial function call check.
				return parent::process_token( $stackPtr );

			case \T_EXIT:
				$next_non_empty = $this->phpcsFile->findNext( Tokens::$emptyTokens, ( $stackPtr + 1 ), null, true );
				if ( false === $next_non_empty
					|| \T_OPEN_PARENTHESIS !== $this->tokens[ $next_non_empty ]['code']
					|| isset( $this->tokens[ $next_non_empty ]['parenthesis_closer'] ) === false
				) {
					// Live coding/parse error or an exit/die which doesn't pass a status code. Ignore.
					return;
				}

				// $end is not examined, so make sure the parentheses are balanced.
				$start = $next_non_empty;
				$end   = ( $this->tokens[ $next_non_empty ]['parenthesis_closer'] + 1 );
				break;

			case \T_THROW:
				// Find the open parentheses, while stepping over the exception creation tokens.
				$ignore  = Tokens::$emptyTokens;
				$ignore += Collections::namespacedNameTokens();
				$ignore += Collections::functionCallTokens();
				$ignore += Collections::objectOperators();

				$next_relevant = $this->phpcsFile->findNext( $ignore, ( $stackPtr + 1 ), null, true );
				if ( false === $next_relevant ) {
					return;
				}

				if ( \T_NEW === $this->tokens[ $next_relevant ]['code'] ) {
					$next_relevant = $this->phpcsFile->findNext( $ignore, ( $next_relevant + 1 ), null, true );
					if ( false === $next_relevant ) {
						return;
					}
				}

				if ( \T_OPEN_PARENTHESIS !== $this->tokens[ $next_relevant ]['code']
					|| isset( $this->tokens[ $next_relevant ]['parenthesis_closer'] ) === false
				) {
					// Live codind/parse error or a pre-created exception. Nothing to do for us.
					return;
				}

				$end = $this->tokens[ $next_relevant ]['parenthesis_closer'];

				// Check if the throw is within a `try-catch`.
				// Doing this here (instead of earlier) to allow skipping to the end of the statement.
				$search_for           = Collections::closedScopes();
				$search_for[ \T_TRY ] = \T_TRY;

				$last_condition = Conditions::getLastCondition( $this->phpcsFile, $stackPtr, $search_for );
				if ( false !== $last_condition && \T_TRY === $this->tokens[ $last_condition ]['code'] ) {
					// This exception will (probably) be caught, so ignore it.
					return $end;
				}

				$call_token = $this->phpcsFile->findPrevious( Tokens::$emptyTokens, ( $next_relevant - 1 ), null, true );
				$params     = PassedParameters::getParameters( $this->phpcsFile, $call_token );
				if ( empty( $params ) ) {
					// No parameters passed, nothing to do.
					return $end;
				}

				// Examine each parameter individually.
				foreach ( $params as $param ) {
					$this->check_code_is_escaped( $param['start'], ( $param['end'] + 1 ) );
				}

				return $end;

			case \T_PRINT:
				$end = BCFile::findEndOfStatement( $this->phpcsFile, $stackPtr );
				if ( \T_COMMA !== $this->tokens[ $end ]['code']
					&& \T_SEMICOLON !== $this->tokens[ $end ]['code']
					&& \T_COLON !== $this->tokens[ $end ]['code']
					&& \T_DOUBLE_ARROW !== $this->tokens[ $end ]['code']
					&& isset( $this->tokens[ ( $end + 1 ) ] )
				) {
					/*
					 * FindEndOfStatement includes a comma/(semi-)colon/double arrow if that's the end of
					 * the statement, but for everything else, it returns the last non-empty token _before_
					 * the end, which would mean the last non-empty token in the statement would not
					 * be examined. Let's fix that.
					 */
					++$end;
				}

				// Note: no need to check for close tag as close tag will have the token before the tag as the $end.
				if ( $end >= ( $this->phpcsFile->numTokens - 1 ) ) {
					$last_non_empty = $this->phpcsFile->findPrevious( Tokens::$emptyTokens, $end, null, true );
					if ( \T_SEMICOLON !== $this->tokens[ $last_non_empty ]['code'] ) {
						// Live coding/parse error at end of file. Ignore.
						return;
					}
				}

				// Special case for a print statement *within* a ternary, where we need to find the "inline else" as the end token.
				$prev_non_empty = $this->phpcsFile->findPrevious( Tokens::$emptyTokens, ( $stackPtr - 1 ), null, true );
				if ( \T_INLINE_THEN === $this->tokens[ $prev_non_empty ]['code'] ) {
					$target_nesting_level = 0;
					if ( empty( $this->tokens[ $stackPtr ]['nested_parenthesis'] ) === false ) {
						$target_nesting_level = \count( $this->tokens[ $stackPtr ]['nested_parenthesis'] );
					}

					$inline_else = false;
					for ( $i = ( $stackPtr + 1 ); $i < $end; $i++ ) {
						if ( \T_INLINE_ELSE !== $this->tokens[ $i ]['code'] ) {
							continue;
						}

						if ( empty( $this->tokens[ $i ]['nested_parenthesis'] )
							&& 0 === $target_nesting_level
						) {
							$inline_else = $i;
							break;
						}

						if ( empty( $this->tokens[ $i ]['nested_parenthesis'] ) === false
							&& \count( $this->tokens[ $i ]['nested_parenthesis'] ) === $target_nesting_level
						) {
							$inline_else = $i;
							break;
						}
					}

					if ( false === $inline_else ) {
						// Live coding/parse error. Bow out.
						return;
					}

					$end = $inline_else;
				}

				break;

			// Echo, open tag with echo.
			default:
				$end = $this->phpcsFile->findNext( array( \T_SEMICOLON, \T_CLOSE_TAG ), $stackPtr );
				if ( false === $end ) {
					// Live coding/parse error. Bow out.
					return;
				}

				break;
		}

		return $this->check_code_is_escaped( $start, $end );
	}

	/**
	 * Process a matched function call token.
	 *
	 * @since 3.0.0 Split off from the process_token() method.
	 *
	 * @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 int|void Integer stack pointer to skip forward or void to continue
	 *                  normal file processing.
	 */
	public function process_matched_token( $stackPtr, $group_name, $matched_content ) {
		// Make sure we only deal with actual function calls, not function import use statements.
		$next_non_empty = $this->phpcsFile->findNext( Tokens::$emptyTokens, ( $stackPtr + 1 ), null, true );
		if ( false === $next_non_empty
			|| \T_OPEN_PARENTHESIS !== $this->tokens[ $next_non_empty ]['code']
			|| isset( $this->tokens[ $next_non_empty ]['parenthesis_closer'] ) === false
		) {
			// Live coding, parse error or not a function _call_.
			return;
		}

		$end = $this->tokens[ $next_non_empty ]['parenthesis_closer'];

		if ( 'unsafe_printing_functions' === $group_name ) {
			$error = $this->phpcsFile->addError(
				"All output should be run through an escaping function (like %s), found '%s'.",
				$stackPtr,
				'UnsafePrintingFunction',
				array( $this->unsafePrintingFunctions[ $matched_content ]['alternative'], $matched_content )
			);

			// If the error was reported, don't bother checking the function's arguments.
			if ( $error || empty( $this->unsafePrintingFunctions[ $matched_content ]['params'] ) ) {
				return $end;
			}

			// If the function was not reported for being unsafe, examine the relevant parameters.
			$params = PassedParameters::getParameters( $this->phpcsFile, $stackPtr );
			foreach ( $this->unsafePrintingFunctions[ $matched_content ]['params'] as $position => $name ) {
				$param = PassedParameters::getParameterFromStack( $params, $position, $name );
				if ( false === $param ) {
					// Parameter doesn't exist. Nothing to do.
					continue;
				}

				$this->check_code_is_escaped( $param['start'], ( $param['end'] + 1 ) );
			}

			return $end;
		}

		$params = PassedParameters::getParameters( $this->phpcsFile, $stackPtr );

		/*
		 * These functions only need to have their first argument - `$message` - escaped.
		 * Note: user_error() is an alias for trigger_error(), so the param names are the same.
		 */
		if ( 'trigger_error' === $matched_content || 'user_error' === $matched_content ) {
			$message_param = PassedParameters::getParameterFromStack( $params, 1, 'message' );
			if ( false === $message_param ) {
				// Message parameter doesn't exist. Nothing to do.
				return $end;
			}

			return $this->check_code_is_escaped( $message_param['start'], ( $message_param['end'] + 1 ) );
		}

		/*
		 * If the first param to `_deprecated_file()` - `$file` - follows the typical `basename( __FILE__ )`
		 * pattern, it doesn't need to be escaped.
		 */
		if ( '_deprecated_file' === $matched_content ) {
			$file_param = PassedParameters::getParameterFromStack( $params, 1, 'file' );

			if ( false !== $file_param ) {
				// Check for a particular code pattern which can safely be ignored.
				if ( preg_match( '`^[\\\\]?basename\s*\(\s*__FILE__\s*\)$`', $file_param['clean'] ) === 1 ) {
					unset( $params[1], $params['file'] ); // Remove the param, whether passed positionally or named.
				}
			}
			unset( $file_param );
		}

		// Examine each parameter individually.
		foreach ( $params as $param ) {
			$this->check_code_is_escaped( $param['start'], ( $param['end'] + 1 ) );
		}

		return $end;
	}

	/**
	 * Check whether each relevant part of an arbitrary group of token is output escaped.
	 *
	 * @since 3.0.0 Split off from the process_token() method.
	 *
	 * @param int $start The position to start checking from.
	 * @param int $end   The position to stop the check at.
	 *
	 * @return int Integer stack pointer to skip forward.
	 */
	protected function check_code_is_escaped( $start, $end ) {
		/*
		 * Check for a ternary operator.
		 * We only need to do this here if this statement is lacking parenthesis.
		 * Otherwise it will be handled in the below loop.
		 */
		$ternary        = false;
		$next_non_empty = $this->phpcsFile->findNext( Tokens::$emptyTokens, ( $start + 1 ), null, true );
		$last_non_empty = $this->phpcsFile->findPrevious( Tokens::$emptyTokens, ( $end - 1 ), null, true );

		if ( \T_OPEN_PARENTHESIS !== $this->tokens[ $next_non_empty ]['code']
			|| \T_CLOSE_PARENTHESIS !== $this->tokens[ $last_non_empty ]['code']
			|| ( \T_OPEN_PARENTHESIS === $this->tokens[ $next_non_empty ]['code']
				&& \T_CLOSE_PARENTHESIS === $this->tokens[ $last_non_empty ]['code']
				&& isset( $this->tokens[ $next_non_empty ]['parenthesis_closer'] )
				&& $this->tokens[ $next_non_empty ]['parenthesis_closer'] !== $last_non_empty
			)
		) {
			// If there is a (long) ternary skip over the part before the ?.
			$ternary = $this->find_long_ternary( $start, $end );
			if ( false !== $ternary ) {
				$start = ( $ternary + 1 );
			}
		}

		$in_cast = false;
		$watch   = true;

		// Looping through echo'd components.
		for ( $i = $start; $i < $end; $i++ ) {
			// Ignore whitespaces and comments.
			if ( isset( Tokens::$emptyTokens[ $this->tokens[ $i ]['code'] ] ) ) {
				continue;
			}

			// Skip over irrelevant tokens.
			if ( isset( Tokens::$magicConstants[ $this->tokens[ $i ]['code'] ] ) // Magic constants for debug functions.
				|| \T_NS_SEPARATOR === $this->tokens[ $i ]['code']
				|| \T_DOUBLE_ARROW === $this->tokens[ $i ]['code']
				|| \T_CLOSE_PARENTHESIS === $this->tokens[ $i ]['code']
			) {
				continue;
			}

			if ( \T_OPEN_PARENTHESIS === $this->tokens[ $i ]['code'] ) {
				if ( ! isset( $this->tokens[ $i ]['parenthesis_closer'] ) ) {
					// Live coding or parse error.
					break;
				}

				if ( $in_cast ) {
					// Skip to the end of a function call if it has been casted to a safe value.
					$i       = $this->tokens[ $i ]['parenthesis_closer'];
					$in_cast = false;

				} else {
					// Skip over the condition part of a (long) ternary (i.e., to after the ?).
					$ternary = $this->find_long_ternary( ( $i + 1 ), $this->tokens[ $i ]['parenthesis_closer'] );
					if ( false !== $ternary ) {
						$i = $ternary;
					}
				}

				continue;
			}

			/*
			 * If a keyword is encountered in an inline expression and the keyword is one
			 * this sniff listens to, recurse into the sniff, handle the expression
			 * based on the keyword and skip over the code examined.
			 */
			if ( isset( $this->target_keywords[ $this->tokens[ $i ]['code'] ] ) ) {
				$return_value = $this->process_token( $i );
				if ( isset( $return_value ) ) {
					$i = $return_value;
				}
				continue;
			}

			// Handle PHP 8.0+ match expressions.
			if ( \T_MATCH === $this->tokens[ $i ]['code'] ) {
				$match_valid = $this->walk_match_expression( $i );
				if ( false === $match_valid ) {
					// Live coding or parse error. Shouldn't be possible as PHP[CS] will tokenize the keyword as `T_STRING` in that case.
					break; // @codeCoverageIgnore
				}

				$i = $match_valid;
				continue;
			}

			// Examine the items in an array individually for array parameters.
			if ( isset( Collections::arrayOpenTokensBC()[ $this->tokens[ $i ]['code'] ] ) ) {
				$array_open_close = Arrays::getOpenClose( $this->phpcsFile, $i );
				if ( false === $array_open_close ) {
					// Short list or misidentified short array token.
					continue;
				}

				$array_items = PassedParameters::getParameters( $this->phpcsFile, $i, 0, true );
				if ( ! empty( $array_items ) ) {
					foreach ( $array_items as $array_item ) {
						$this->check_code_is_escaped( $array_item['start'], ( $array_item['end'] + 1 ) );
					}
				}

				$i = $array_open_close['closer'];
				continue;
			}

			// Ignore safe PHP native constants.
			if ( \T_STRING === $this->tokens[ $i ]['code']
				&& isset( $this->safe_php_constants[ $this->tokens[ $i ]['content'] ] )
				&& ConstantsHelper::is_use_of_global_constant( $this->phpcsFile, $i )
			) {
				continue;
			}

			// Wake up on concatenation characters, another part to check.
			if ( \T_STRING_CONCAT === $this->tokens[ $i ]['code'] ) {
				$watch = true;
				continue;
			}

			// Wake up after a ternary else (:).
			if ( false !== $ternary && \T_INLINE_ELSE === $this->tokens[ $i ]['code'] ) {
				$watch = true;
				continue;
			}

			// Wake up for commas.
			if ( \T_COMMA === $this->tokens[ $i ]['code'] ) {
				$in_cast = false;
				$watch   = true;
				continue;
			}

			if ( false === $watch ) {
				continue;
			}

			// Allow T_CONSTANT_ENCAPSED_STRING eg: echo 'Some String';
			// Also T_LNUMBER, e.g.: echo 45; exit -1; and booleans.
			if ( isset( $this->safe_components[ $this->tokens[ $i ]['code'] ] ) ) {
				continue;
			}

			// Check for use of *::class.
			if ( \T_STRING === $this->tokens[ $i ]['code']
				|| \T_VARIABLE === $this->tokens[ $i ]['code']
				|| isset( Collections::ooHierarchyKeywords()[ $this->tokens[ $i ]['code'] ] )
			) {
				$double_colon = $this->phpcsFile->findNext( Tokens::$emptyTokens, ( $i + 1 ), $end, true );
				if ( false !== $double_colon
					&& \T_DOUBLE_COLON === $this->tokens[ $double_colon ]['code']
				) {
					$class_keyword = $this->phpcsFile->findNext( Tokens::$emptyTokens, ( $double_colon + 1 ), $end, true );
					if ( false !== $class_keyword
						&& \T_STRING === $this->tokens[ $class_keyword ]['code']
						&& 'class' === strtolower( $this->tokens[ $class_keyword ]['content'] )
					) {
						$i = $class_keyword;
						continue;
					}
				}
			}

			$watch = false;

			// Allow int/double/bool casted variables.
			if ( isset( ContextHelper::get_safe_cast_tokens()[ $this->tokens[ $i ]['code'] ] ) ) {
				/*
				 * If the next thing is a match expression, skip over it as whatever is
				 * being returned will be safe casted.
				 * Do not set `$in_cast` to `true`.
				 */
				$next_non_empty = $this->phpcsFile->findNext( Tokens::$emptyTokens, ( $i + 1 ), $end, true );
				if ( false !== $next_non_empty
					&& \T_MATCH === $this->tokens[ $next_non_empty ]['code']
					&& isset( $this->tokens[ $next_non_empty ]['scope_closer'] )
				) {
					$i = $this->tokens[ $next_non_empty ]['scope_closer'];
					continue;
				}

				$in_cast = true;
				continue;
			}

			// Handle heredocs separately as they only need escaping when interpolation is used.
			if ( \T_START_HEREDOC === $this->tokens[ $i ]['code'] ) {
				$current = ( $i + 1 );
				while ( isset( $this->tokens[ $current ] ) && \T_HEREDOC === $this->tokens[ $current ]['code'] ) {
					$embeds = TextStrings::getEmbeds( $this->tokens[ $current ]['content'] );
					if ( ! empty( $embeds ) ) {
						$this->phpcsFile->addError(
							'All output should be run through an escaping function (see the Security sections in the WordPress Developer Handbooks), found interpolation in unescaped heredoc.',
							$current,
							'HeredocOutputNotEscaped'
						);
					}
					++$current;
				}

				$i = $current;
				continue;
			}

			// Now check that next token is a function call.
			if ( \T_STRING === $this->tokens[ $i ]['code'] ) {
				$ptr                    = $i;
				$functionName           = $this->tokens[ $i ]['content'];
				$function_opener        = $this->phpcsFile->findNext( Tokens::$emptyTokens, ( $i + 1 ), null, true );
				$is_formatting_function = FormattingFunctionsHelper::is_formatting_function( $functionName );

				if ( false !== $function_opener
					&& \T_OPEN_PARENTHESIS === $this->tokens[ $function_opener ]['code']
				) {
					if ( ArrayWalkingFunctionsHelper::is_array_walking_function( $functionName ) ) {
						// Get the callback parameter.
						$callback = ArrayWalkingFunctionsHelper::get_callback_parameter( $this->phpcsFile, $ptr );

						if ( ! empty( $callback ) ) {
							/*
							 * If this is a function callback (not a method callback array) and we're able
							 * to resolve the function name, do so.
							 */
							$mapped_function = $this->phpcsFile->findNext(
								Tokens::$emptyTokens,
								$callback['start'],
								( $callback['end'] + 1 ),
								true
							);

							if ( false !== $mapped_function
								&& \T_CONSTANT_ENCAPSED_STRING === $this->tokens[ $mapped_function ]['code']
							) {
								$functionName = TextStrings::stripQuotes( $this->tokens[ $mapped_function ]['content'] );
								$ptr          = $mapped_function;
							}
						}
					}

					// If this is a formatting function, we examine the parameters individually.
					if ( $is_formatting_function ) {
						$formatting_params = PassedParameters::getParameters( $this->phpcsFile, $i );
						if ( ! empty( $formatting_params ) ) {
							foreach ( $formatting_params as $format_param ) {
								$this->check_code_is_escaped( $format_param['start'], ( $format_param['end'] + 1 ) );
							}
						}

						$watch = true;
					}

					// Skip pointer to after the function.
					if ( isset( $this->tokens[ $function_opener ]['parenthesis_closer'] ) ) {
						$i = $this->tokens[ $function_opener ]['parenthesis_closer'];
					} else {
						// Live coding or parse error.
						break;
					}
				}

				// If this is a safe function, we don't flag it.
				if ( $is_formatting_function
					|| $this->is_escaping_function( $functionName )
					|| $this->is_auto_escaped_function( $functionName )
				) {
					// Special case get_search_query() which is unsafe if $escaped = false.
					if ( 'get_search_query' === strtolower( $functionName ) ) {
						$escaped_param = PassedParameters::getParameter( $this->phpcsFile, $ptr, 1, 'escaped' );
						if ( false !== $escaped_param && 'true' !== $escaped_param['clean'] ) {
							$this->phpcsFile->addError(
								'Output from get_search_query() is unsafe due to $escaped parameter being set to "false".',
								$ptr,
								'UnsafeSearchQuery'
							);
						}
					}

					continue;
				}

				$content = $functionName;

			} else {
				$content = $this->tokens[ $i ]['content'];
				$ptr     = $i;
			}

			// Make the error message a little more informative for array access variables.
			if ( \T_VARIABLE === $this->tokens[ $ptr ]['code'] ) {
				$array_keys = VariableHelper::get_array_access_keys( $this->phpcsFile, $ptr );

				if ( ! empty( $array_keys ) ) {
					$content .= '[' . implode( '][', $array_keys ) . ']';
				}
			}

			$this->phpcsFile->addError(
				"All output should be run through an escaping function (see the Security sections in the WordPress Developer Handbooks), found '%s'.",
				$ptr,
				'OutputNotEscaped',
				array( $content )
			);
		}

		return $end;
	}

	/**
	 * Check whether there is a ternary token at the right nesting level in an arbitrary set of tokens.
	 *
	 * @since 3.0.0 Split off from the process_token() method.
	 *
	 * @param int $start The position to start checking from.
	 * @param int $end   The position to stop the check at.
	 *
	 * @return int|false Stack pointer to the ternary or FALSE if no ternary was found or
	 *                   if this is a short ternary.
	 */
	private function find_long_ternary( $start, $end ) {
		for ( $i = $start; $i < $end; $i++ ) {
			// Ignore anything within square brackets.
			if ( isset( $this->tokens[ $i ]['bracket_opener'], $this->tokens[ $i ]['bracket_closer'] )
				&& $i === $this->tokens[ $i ]['bracket_opener']
			) {
				$i = $this->tokens[ $i ]['bracket_closer'];
				continue;
			}

			// Skip past nested arrays, function calls and arbitrary groupings.
			if ( \T_OPEN_PARENTHESIS === $this->tokens[ $i ]['code']
				&& isset( $this->tokens[ $i ]['parenthesis_closer'] )
			) {
				$i = $this->tokens[ $i ]['parenthesis_closer'];
				continue;
			}

			// Skip past closures, anonymous classes and anything else scope related.
			if ( isset( $this->tokens[ $i ]['scope_condition'], $this->tokens[ $i ]['scope_closer'] )
				&& $this->tokens[ $i ]['scope_condition'] === $i
			) {
				$i = $this->tokens[ $i ]['scope_closer'];
				continue;
			}

			if ( \T_INLINE_THEN !== $this->tokens[ $i ]['code'] ) {
				continue;
			}

			/*
			 * Okay, we found a ternary and it should be at the correct nesting level.
			 * If this is a short ternary, it shouldn't be ignored though.
			 */
			if ( Operators::isShortTernary( $this->phpcsFile, $i ) === true ) {
				return false;
			}

			return $i;
		}

		return false;
	}

	/**
	 * Examine a match expression and only check for escaping in the "returned" parts of the match expression.
	 *
	 * {@internal PHPCSUtils will likely contain a utility for parsing match expressions in the future.
	 *            Ref: https://github.com/PHPCSStandards/PHPCSUtils/issues/497}
	 *
	 * @since 3.0.0
	 *
	 * @param int $stackPtr Pointer to a T_MATCH token.
	 *
	 * @return int|false Stack pointer to skip to or FALSE if the match expression contained a parse error.
	 */
	private function walk_match_expression( $stackPtr ) {
		if ( ! isset( $this->tokens[ $stackPtr ]['scope_opener'], $this->tokens[ $stackPtr ]['scope_closer'] ) ) {
			// Parse error/live coding. Shouldn't be possible as PHP[CS] will tokenize the keyword as `T_STRING` in that case.
			return false; // @codeCoverageIgnore
		}

		$current = $this->tokens[ $stackPtr ]['scope_opener'];
		$end     = $this->tokens[ $stackPtr ]['scope_closer'];
		do {
			$current = $this->phpcsFile->findNext( \T_MATCH_ARROW, ( $current + 1 ), $end );
			if ( false === $current ) {
				// We must have reached the last match item (or there is a parse error).
				break;
			}

			$item_start = ( $current + 1 );
			$item_end   = false;

			// Find the first comma at the same level.
			for ( $i = $item_start; $i <= $end; $i++ ) {
				// Ignore anything within square brackets.
				if ( isset( $this->tokens[ $i ]['bracket_opener'], $this->tokens[ $i ]['bracket_closer'] )
					&& $i === $this->tokens[ $i ]['bracket_opener']
				) {
					$i = $this->tokens[ $i ]['bracket_closer'];
					continue;
				}

				// Skip past nested arrays, function calls and arbitrary groupings.
				if ( \T_OPEN_PARENTHESIS === $this->tokens[ $i ]['code']
					&& isset( $this->tokens[ $i ]['parenthesis_closer'] )
				) {
					$i = $this->tokens[ $i ]['parenthesis_closer'];
					continue;
				}

				// Skip past closures, anonymous classes and anything else scope related.
				if ( isset( $this->tokens[ $i ]['scope_condition'], $this->tokens[ $i ]['scope_closer'] )
					&& $this->tokens[ $i ]['scope_condition'] === $i
				) {
					$i = $this->tokens[ $i ]['scope_closer'];
					continue;
				}

				if ( \T_COMMA !== $this->tokens[ $i ]['code']
					&& $i !== $end
				) {
					continue;
				}

				$item_end = $i;
				break;
			}

			if ( false === $item_end ) {
				// Parse error/live coding. Shouldn't be possible.
				return false; // @codeCoverageIgnore
			}

			// Now check that the value returned by this match "leaf" is correctly escaped.
			$this->check_code_is_escaped( $item_start, $item_end );

			// Independently of whether or not the check was succesfull or ran into (parse error) problems,
			// always skip to the identified end of the item.
			$current = $item_end;
		} while ( $current < $end );

		return $end;
	}
}
