<?php
/**
 * PHPCompatibility, an external standard for PHP_CodeSniffer.
 *
 * @package   PHPCompatibility
 * @copyright 2012-2020 PHPCompatibility Contributors
 * @license   https://opensource.org/licenses/LGPL-3.0 LGPL3
 * @link      https://github.com/PHPCompatibility/PHPCompatibility
 */

namespace PHPCompatibility\Sniffs\Operators;

use PHPCompatibility\Helpers\ScannedCode;
use PHPCompatibility\Sniff;
use PHP_CodeSniffer\Files\File;
use PHP_CodeSniffer\Util\Tokens;
use PHPCSUtils\Tokens\Collections;
use PHPCSUtils\Utils\MessageHelper;
use PHPCSUtils\Utils\Operators;

/**
 * Detect code affected by the change in operator precedence of concatenation in PHP 8.0.
 *
 * In PHP < 8.0 the operator precedence of `.`, `+` and `-` are the same.
 * As of PHP 8.0, the operator precedence of the concatenation operator will be
 * lowered to be right below the `<<` and `>>` operators.
 *
 * As of PHP 7.4, a deprecation warning will be thrown upon encountering an
 * unparenthesized expression containing an `.` before a `+` or `-`.
 *
 * PHP version 7.4
 * PHP version 8.0
 *
 * @link https://wiki.php.net/rfc/concatenation_precedence
 * @link https://www.php.net/manual/en/language.operators.precedence.php
 *
 * @since 9.2.0
 * @since 10.0.0 This class is now `final`.
 */
final class ChangedConcatOperatorPrecedenceSniff extends Sniff
{

    /**
     * List of tokens with a lower operator precedence than concatenation in PHP >= 8.0.
     *
     * @since 9.2.0
     *
     * @var array<string, true>
     */
    private $tokensWithLowerPrecedence = [
        'T_BITWISE_AND' => true,
        'T_BITWISE_XOR' => true,
        'T_BITWISE_OR'  => true,
        'T_COALESCE'    => true,
        'T_INLINE_THEN' => true,
        'T_INLINE_ELSE' => true,
        'T_YIELD_FROM'  => true,
        'T_YIELD'       => true,
    ];


    /**
     * Returns an array of tokens this test wants to listen for.
     *
     * @since 9.2.0
     *
     * @return array<int|string>
     */
    public function register()
    {
        return [
            \T_PLUS,
            \T_MINUS,
        ];
    }

    /**
     * Processes this test, when one of its tokens is encountered.
     *
     * @since 9.2.0
     *
     * @param \PHP_CodeSniffer\Files\File $phpcsFile The file being scanned.
     * @param int                         $stackPtr  The position of the current token in the
     *                                               stack passed in $tokens.
     *
     * @return void
     */
    public function process(File $phpcsFile, $stackPtr)
    {
        if (ScannedCode::shouldRunOnOrAbove('7.4') === false) {
            return;
        }

        if (Operators::isUnaryPlusMinus($phpcsFile, $stackPtr) === true) {
            return;
        }

        $tokens = $phpcsFile->getTokens();

        for ($i = ($stackPtr - 1); $stackPtr >= 0; $i--) {
            if ($tokens[$i]['code'] === \T_STRING_CONCAT) {
                // Found one.
                break;
            }

            if ($tokens[$i]['code'] === \T_SEMICOLON
                || $tokens[$i]['code'] === \T_OPEN_CURLY_BRACKET
                || $tokens[$i]['code'] === \T_COMMA
                || $tokens[$i]['code'] === \T_COLON
                || $tokens[$i]['code'] === \T_CASE
                || isset(Collections::phpOpenTags()[$tokens[$i]['code']]) === true
            ) {
                // If we reached any of the above tokens, we've reached the end of
                // the statement without encountering a concatenation operator.
                return;
            }

            if ($tokens[$i]['code'] === \T_OPEN_CURLY_BRACKET
                && isset($tokens[$i]['bracket_closer'])
                && $tokens[$i]['bracket_closer'] > $stackPtr
            ) {
                // No need to look any further, this is plus/minus within curly braces
                // and we've reached the open curly.
                return;
            }

            if ($tokens[$i]['code'] === \T_OPEN_PARENTHESIS
                && isset($tokens[$i]['parenthesis_closer'])
                && $tokens[$i]['parenthesis_closer'] > $stackPtr
            ) {
                // No need to look any further, this is plus/minus within parenthesis
                // and we've reached the open parenthesis.
                return;
            }

            if (($tokens[$i]['code'] === \T_OPEN_SHORT_ARRAY
                || $tokens[$i]['code'] === \T_OPEN_SQUARE_BRACKET)
                && isset($tokens[$i]['bracket_closer'])
                && $tokens[$i]['bracket_closer'] > $stackPtr
            ) {
                // No need to look any further, this is plus/minus within a short array
                // or array key square brackets and we've reached the opener.
                return;
            }

            if ($tokens[$i]['code'] === \T_CLOSE_CURLY_BRACKET) {
                if (isset($tokens[$i]['scope_owner'])) {
                    // Different scope, we've passed the start of the statement.
                    return;
                }

                if (isset($tokens[$i]['bracket_opener'])) {
                    $i = $tokens[$i]['bracket_opener'];
                }

                continue;
            }

            if ($tokens[$i]['code'] === \T_CLOSE_PARENTHESIS
                && isset($tokens[$i]['parenthesis_opener'])
            ) {
                // Skip over statements in parenthesis, including long arrays.
                $i = $tokens[$i]['parenthesis_opener'];
                continue;
            }

            if (($tokens[$i]['code'] === \T_CLOSE_SQUARE_BRACKET
                || $tokens[$i]['code'] === \T_CLOSE_SHORT_ARRAY)
                && isset($tokens[$i]['bracket_opener'])
            ) {
                // Skip over array keys and short arrays.
                $i = $tokens[$i]['bracket_opener'];
                continue;
            }

            // Check for chain being broken by a token with a lower precedence.
            if (isset(Tokens::$booleanOperators[$tokens[$i]['code']]) === true
                || isset(Tokens::$assignmentTokens[$tokens[$i]['code']]) === true
            ) {
                return;
            }

            if (isset($this->tokensWithLowerPrecedence[$tokens[$i]['type']]) === true) {
                if ($tokens[$i]['code'] === \T_BITWISE_AND
                    && $phpcsFile->isReference($i) === true
                ) {
                    continue;
                }

                return;
            }
        }

        $message = 'Using an unparenthesized expression containing a "." before a "+" or "-" has been deprecated in PHP 7.4';
        $isError = false;
        if (ScannedCode::shouldRunOnOrAbove('8.0') === true) {
            $message .= ' and removed in PHP 8.0';
            $isError  = true;
        }

        MessageHelper::addMessage($phpcsFile, $message, $i, $isError);
    }
}
