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

use PHPCompatibility\AbstractInitialValueSniff;
use PHPCompatibility\Helpers\ScannedCode;
use PHPCompatibility\Helpers\TokenGroup;
use PHP_CodeSniffer\Files\File;
use PHP_CodeSniffer\Util\Tokens;
use PHPCSUtils\Tokens\Collections;
use PHPCSUtils\Utils\Arrays;
use PHPCSUtils\Utils\GetTokensAsString;
use PHPCSUtils\Utils\MessageHelper;
use PHPCSUtils\Utils\PassedParameters;

/**
 * Detect constant scalar expressions being used to set an initial value.
 *
 * Since PHP 5.6, it is now possible to provide a scalar expression involving
 * numeric and string literals and/or constants in contexts where PHP previously
 * expected a static value, such as constant and property declarations and
 * default values for function parameters.
 *
 * PHP version 5.6
 *
 * @link https://www.php.net/manual/en/migration56.new-features.php#migration56.new-features.const-scalar-exprs
 * @link https://wiki.php.net/rfc/const_scalar_exprs
 *
 * @since 8.2.0
 * @since 10.0.0 - This sniff now extends the `AbstractInitialValueSniff` class instead of the
 *                 base `Sniff` class.
 *               - This class is now `final`.
 */
final class NewConstantScalarExpressionsSniff extends AbstractInitialValueSniff
{

    /**
     * Error message.
     *
     * @since 8.2.0
     *
     * @var string
     */
    const ERROR_PHRASE = 'Constant scalar expressions are not allowed %s in PHP 5.5 or earlier.';

    /**
     * Tokens which were allowed to be used in these declarations prior to PHP 5.6.
     *
     * This list will be enriched in the setProperties() method.
     *
     * @since 8.2.0
     *
     * @var array<int|string, int|string>
     */
    protected $safeOperands = [
        \T_LNUMBER                  => \T_LNUMBER,
        \T_DNUMBER                  => \T_DNUMBER,
        \T_CONSTANT_ENCAPSED_STRING => \T_CONSTANT_ENCAPSED_STRING,
        \T_TRUE                     => \T_TRUE,
        \T_FALSE                    => \T_FALSE,
        \T_NULL                     => \T_NULL,

        // Special cases:
        \T_NS_SEPARATOR             => \T_NS_SEPARATOR,
    ];


    /**
     * Returns an array of tokens this test wants to listen for.
     *
     * @since 8.2.0
     *
     * @return array<int|string>
     */
    public function register()
    {
        // Set the properties up only once.
        $this->setProperties();

        return parent::register();
    }

    /**
     * Make some adjustments to the $safeOperands property.
     *
     * @since 8.2.0
     *
     * @return void
     */
    public function setProperties()
    {
        $this->safeOperands += Tokens::$heredocTokens;
        $this->safeOperands += Tokens::$magicConstants;
        $this->safeOperands += Tokens::$emptyTokens;
        /*
         * This can be neigh anything, but for any usage except constants,
         * the namespaced name will be combined with non-allowed tokens, so we should be good.
         */
        $this->safeOperands += Collections::nameTokens();
    }

    /**
     * Do a version check to determine if this sniff needs to run at all.
     *
     * @since 8.2.0
     *
     * @return bool
     */
    protected function bowOutEarly()
    {
        return (ScannedCode::shouldRunOnOrBelow('5.5') === false);
    }

    /**
     * Process a token which has an initial value.
     *
     * @since 10.0.0
     *
     * @param \PHP_CodeSniffer\Files\File $phpcsFile The file being scanned.
     * @param int                         $stackPtr  The position of the variable/constant name token
     *                                               in the stack passed in $tokens.
     * @param int                         $start     The stackPtr to the start of the initial value.
     * @param int                         $end       The stackPtr to the end of the initial value.
     *                                               This will normally be a comma or semi-colon.
     * @param string                      $type      The "type" of initial value declaration being examined.
     *                                               The type will match one of the keys in the
     *                                               `AbstractInitialValueSniff::$initialValueTypes` property.
     *
     * @return void
     */
    protected function processInitialValue(File $phpcsFile, $stackPtr, $start, $end, $type)
    {
        $isStaticValue = $this->isStaticValue($phpcsFile, $start, ($end - 1));
        if ($isStaticValue === true) {
            // Not a constant scalar expression, nothing to do.
            return;
        }

        $this->throwError($phpcsFile, $stackPtr, $end, $type);
    }

    /**
     * Is a value declared and is the value declared constant as accepted in PHP 5.5 and lower ?
     *
     * @since 8.2.0
     * @since 10.0.0 The `$tokens` parameter which was previously at the second position,
     *               has been removed.
     *
     * @param \PHP_CodeSniffer\Files\File $phpcsFile    The file being scanned.
     * @param int                         $start        The stackPtr from which to start examining.
     * @param int                         $end          The end of the value definition (inclusive),
     *                                                  i.e. this token will be examined as part of
     *                                                  the snippet.
     * @param int                         $nestedArrays Optional. Array nesting level when examining
     *                                                  the content of an array.
     *
     * @return bool
     */
    protected function isStaticValue(File $phpcsFile, $start, $end, $nestedArrays = 0)
    {
        $tokens        = $phpcsFile->getTokens();
        $nextNonSimple = $phpcsFile->findNext($this->safeOperands, $start, ($end + 1), true);
        if ($nextNonSimple === false) {
            return true;
        }

        /*
         * OK, so we have at least one token which needs extra examination.
         */
        switch ($tokens[$nextNonSimple]['code']) {
            case \T_MINUS:
            case \T_PLUS:
                if (TokenGroup::isNumber($phpcsFile, $start, $end, true) !== false) {
                    // Int or float with sign.
                    return true;
                }

                return false;

            case \T_NAMESPACE:
            case \T_PARENT:
            case \T_SELF:
            case \T_DOUBLE_COLON:
                $nextNonEmpty = $phpcsFile->findNext(Tokens::$emptyTokens, ($nextNonSimple + 1), ($end + 1), true);

                if ($tokens[$nextNonSimple]['code'] === \T_NAMESPACE) {
                    // Allow only `namespace\...`.
                    if ($nextNonEmpty === false || $tokens[$nextNonEmpty]['code'] !== \T_NS_SEPARATOR) {
                        return false;
                    }
                } elseif ($tokens[$nextNonSimple]['code'] === \T_PARENT
                    || $tokens[$nextNonSimple]['code'] === \T_SELF
                ) {
                    // Allow only `parent::` and `self::`.
                    if ($nextNonEmpty === false || $tokens[$nextNonEmpty]['code'] !== \T_DOUBLE_COLON) {
                        return false;
                    }
                } elseif ($tokens[$nextNonSimple]['code'] === \T_DOUBLE_COLON) {
                    // Allow only `NAME_TOKEN::T_STRING`.
                    if ($nextNonEmpty === false || $tokens[$nextNonEmpty]['code'] !== \T_STRING) {
                        return false;
                    }

                    $prevNonEmpty = $phpcsFile->findPrevious(Tokens::$emptyTokens, ($nextNonSimple - 1), null, true);
                    // No need to worry about parent/self, that's handled above and
                    // the double colon is skipped over in that case.
                    if ($prevNonEmpty === false
                        || isset(Collections::nameTokens()[$tokens[$prevNonEmpty]['code']]) === false
                    ) {
                        return false;
                    }
                }

                // Examine what comes after the namespace/parent/self/double colon, if anything.
                return $this->isStaticValue($phpcsFile, ($nextNonEmpty + 1), $end, $nestedArrays);

            case \T_ARRAY:
            case \T_OPEN_SHORT_ARRAY:
                ++$nestedArrays;

                $arrayItems = PassedParameters::getParameters($phpcsFile, $nextNonSimple);
                if (empty($arrayItems) === false) {
                    foreach ($arrayItems as $item) {
                        // Check for a double arrow, but only if it's for this array item, not for a nested array.
                        $doubleArrow = Arrays::getDoubleArrowPtr($phpcsFile, $item['start'], $item['end']);

                        if ($doubleArrow === false) {
                            if ($this->isStaticValue($phpcsFile, $item['start'], $item['end'], $nestedArrays) === false) {
                                return false;
                            }
                        } else {
                            // Examine array key.
                            if ($this->isStaticValue($phpcsFile, $item['start'], ($doubleArrow - 1), $nestedArrays) === false) {
                                return false;
                            }

                            // Examine array value.
                            if ($this->isStaticValue($phpcsFile, ($doubleArrow + 1), $item['end'], $nestedArrays) === false) {
                                return false;
                            }
                        }
                    }
                }

                --$nestedArrays;

                /*
                 * Find the end of the array.
                 * We already know we will have a valid closer as otherwise we wouldn't have been
                 * able to get the array items.
                 */
                $closer = ($nextNonSimple + 1);
                if ($tokens[$nextNonSimple]['code'] === \T_OPEN_SHORT_ARRAY
                    && isset($tokens[$nextNonSimple]['bracket_closer']) === true
                ) {
                    $closer = $tokens[$nextNonSimple]['bracket_closer'];
                } else {
                    $maybeOpener = $phpcsFile->findNext(Tokens::$emptyTokens, ($nextNonSimple + 1), ($end + 1), true);
                    if ($tokens[$maybeOpener]['code'] === \T_OPEN_PARENTHESIS) {
                        $opener = $maybeOpener;
                        if (isset($tokens[$opener]['parenthesis_closer']) === true) {
                            $closer = $tokens[$opener]['parenthesis_closer'];
                        }
                    }
                }

                if ($closer === $end) {
                    return true;
                }

                // Examine what comes after the array, if anything.
                return $this->isStaticValue($phpcsFile, ($closer + 1), $end, $nestedArrays);
        }

        // Ok, so this unsafe token was not one of the exceptions, i.e. this is a PHP 5.6+ syntax.
        return false;
    }

    /**
     * Throw an error if a scalar expression is found.
     *
     * @since 8.2.0
     * @since 10.0.0 The `$end` parameter has been added, moving the `$type` parameter
     *               from the third to the fourth position.
     *               The previously optional fourth `$content` parameter has been removed.
     *
     * @param \PHP_CodeSniffer\Files\File $phpcsFile The file being scanned.
     * @param int                         $stackPtr  The position of the token to link the error to.
     * @param int                         $end       The end of the value definition (inclusive).
     * @param string                      $type      Type of usage found.
     *
     * @return void
     */
    protected function throwError(File $phpcsFile, $stackPtr, $end, $type)
    {
        $error     = self::ERROR_PHRASE;
        $errorCode = 'Found';
        $phrase    = '';

        if (isset($this->initialValueTypes[$type]) === true) {
            $errorCode = MessageHelper::stringToErrorCode($type) . 'Found';
            $phrase    = $this->initialValueTypes[$type];
        }

        $data = [$phrase];

        // Create the "found" snippet.
        $content    = '';
        $tokenCount = ($end - $stackPtr);
        if ($tokenCount < 20) {
            // Prevent large arrays from being added to the error message.
            $content = \trim(GetTokensAsString::noComments($phpcsFile, $stackPtr, $end));
        }

        if (empty($content) === false) {
            $error .= ' Found: %s';
            $data[] = $content;
        }

        $phpcsFile->addError($error, $stackPtr, $errorCode, $data);
    }
}
