<?php
/**
 * \PHPCompatibility\Sniffs\PHP\NewConstantScalarExpressionsSniff.
 *
 * PHP version 5.6
 *
 * @category PHP
 * @package  PHPCompatibility
 * @author   Juliette Reinders Folmer <phpcompatibility_nospam@adviesenzo.nl>
 */

namespace PHPCompatibility\Sniffs\PHP;

use PHPCompatibility\Sniff;
use PHPCompatibility\PHPCSHelper;

/**
 * \PHPCompatibility\Sniffs\PHP\NewConstantScalarExpressionsSniff.
 *
 * 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 function arguments.
 *
 * PHP version 5.6
 *
 * @category PHP
 * @package  PHPCompatibility
 * @author   Juliette Reinders Folmer <phpcompatibility_nospam@adviesenzo.nl>
 */
class NewConstantScalarExpressionsSniff extends Sniff
{

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

    /**
     * Partial error phrases to be used in combination with the error message constant.
     *
     * @var array
     */
    protected $errorPhrases = array(
        'const'     => 'when defining constants using the const keyword',
        'property'  => 'in property declarations',
        'staticvar' => 'in static variable declarations',
        'default'   => 'in default function arguments',
    );

    /**
     * Tokens which were allowed to be used in these declarations prior to PHP 5.6.
     *
     * This list will be enriched in the setProperties() method.
     *
     * @var array
     */
    protected $safeOperands = array(
        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,

        T_LINE                     => T_LINE,
        T_FILE                     => T_FILE,
        T_DIR                      => T_DIR,
        T_FUNC_C                   => T_FUNC_C,
        T_CLASS_C                  => T_CLASS_C,
        T_METHOD_C                 => T_METHOD_C,
        T_NS_C                     => T_NS_C,

        // Special cases:
        T_NS_SEPARATOR             => T_NS_SEPARATOR,
        /*
         * This can be neigh anything, but for any usage except constants,
         * the T_STRING will be combined with non-allowed tokens, so we should be good.
         */
        T_STRING                   => T_STRING,
    );


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

        return array(
            T_CONST,
            T_VARIABLE,
            T_FUNCTION,
            T_CLOSURE,
            T_STATIC,
        );
    }


    /**
     * Make some adjustments to the $safeOperands property.
     *
     * @return void
     */
    public function setProperties()
    {
        /*
         * Not available on PHPCS 1.x icw PHP 5.3.
         * Not a problem for recognition as when the token is not available
         * __TRAIT__ will be tokenized as T_STRING which will pass.
         */
        if (defined('T_TRAIT_C') === true) {
            // phpcs:ignore PHPCompatibility.PHP.NewConstants.t_trait_cFound
            $this->safeOperands[T_TRAIT_C] = T_TRAIT_C;
        }

        $emptyTokens   = \PHP_CodeSniffer_Tokens::$emptyTokens;
        $heredocTokens = \PHP_CodeSniffer_Tokens::$heredocTokens;
        if (version_compare(PHPCSHelper::getVersion(), '2.0', '<')) {
            // PHPCS 1.x compat.
            $emptyTokens   = array_combine($emptyTokens, $emptyTokens);
            $heredocTokens = array_combine($heredocTokens, $heredocTokens);
        }

        $this->safeOperands = $this->safeOperands + $heredocTokens + $emptyTokens;
    }


    /**
     * Do a version check to determine if this sniff needs to run at all.
     *
     * @return bool
     */
    protected function bowOutEarly()
    {
        return ($this->supportsBelow('5.5') !== true);
    }


    /**
     * Processes this test, when one of its tokens is encountered.
     *
     * @param \PHP_CodeSniffer_File $phpcsFile The file being scanned.
     * @param int                   $stackPtr  The position of the current token in the
     *                                         stack passed in $tokens.
     *
     * @return void|int Null or integer stack pointer to skip forward.
     */
    public function process(\PHP_CodeSniffer_File $phpcsFile, $stackPtr)
    {
        if ($this->bowOutEarly() === true) {
            return;
        }

        $tokens = $phpcsFile->getTokens();

        switch ($tokens[$stackPtr]['type']) {
            case 'T_FUNCTION':
            case 'T_CLOSURE':
                $params = PHPCSHelper::getMethodParameters($phpcsFile, $stackPtr);
                if (empty($params)) {
                    // No parameters.
                    return;
                }

                $funcToken = $tokens[$stackPtr];

                if (isset($funcToken['parenthesis_owner'], $funcToken['parenthesis_opener'], $funcToken['parenthesis_closer']) === false
                    || $funcToken['parenthesis_owner'] !== $stackPtr
                    || isset($tokens[$funcToken['parenthesis_opener']], $tokens[$funcToken['parenthesis_closer']]) === false
                ) {
                    // Hmm.. something is going wrong as these should all be available & valid.
                    return;
                }

                $opener = $funcToken['parenthesis_opener'];
                $closer = $funcToken['parenthesis_closer'];

                // Which nesting level is the one we are interested in ?
                $nestedParenthesisCount = 1;
                if (isset($tokens[$opener]['nested_parenthesis'])) {
                    $nestedParenthesisCount += count($tokens[$opener]['nested_parenthesis']);
                }

                foreach ($params as $param) {
                    if (isset($param['default']) === false) {
                        continue;
                    }

                    $end = $param['token'];
                    while (($end = $phpcsFile->findNext(array(T_COMMA, T_CLOSE_PARENTHESIS), ($end + 1), ($closer + 1))) !== false) {
                        $maybeSkipTo = $this->isRealEndOfDeclaration($tokens, $end, $nestedParenthesisCount);
                        if ($maybeSkipTo !== true) {
                            $end = $maybeSkipTo;
                            continue;
                        }

                        // Ignore closing parenthesis/bracket if not 'ours'.
                        if ($tokens[$end]['code'] === T_CLOSE_PARENTHESIS && $end !== $closer) {
                            continue;
                        }

                        // Ok, we've found the end of the param default value declaration.
                        break;
                    }

                    if ($this->isValidAssignment($phpcsFile, $param['token'], $end) === false) {
                        $this->throwError($phpcsFile, $param['token'], 'default', $param['content']);
                    }
                }

                /*
                 * No need for the sniff to be triggered by the T_VARIABLEs in the function
                 * definition as we've already examined them above, so let's skip over them.
                 */
                return $closer;

            case 'T_VARIABLE':
            case 'T_STATIC':
            case 'T_CONST':
                $type = 'const';

                // Filter out non-property declarations.
                if ($tokens[$stackPtr]['code'] === T_VARIABLE) {
                    if ($this->isClassProperty($phpcsFile, $stackPtr) === false) {
                        return;
                    }

                    $type = 'property';

                    // Move back one token to have the same starting point as the others.
                    $stackPtr = ($stackPtr - 1);
                }

                // Filter out late static binding and class properties.
                if ($tokens[$stackPtr]['code'] === T_STATIC) {
                    $next = $phpcsFile->findNext(
                        \PHP_CodeSniffer_Tokens::$emptyTokens,
                        ($stackPtr + 1),
                        null,
                        true,
                        null,
                        true
                    );
                    if ($next === false || $tokens[$next]['code'] !== T_VARIABLE) {
                        // Late static binding.
                        return;
                    }

                    if ($this->isClassProperty($phpcsFile, $next) === true) {
                        // Class properties are examined based on the T_VARIABLE token.
                        return;
                    }
                    unset($next);

                    $type = 'staticvar';
                }

                $endOfStatement = $phpcsFile->findNext(array(T_SEMICOLON, T_CLOSE_TAG), ($stackPtr + 1));
                if ($endOfStatement === false) {
                    // No semi-colon - live coding.
                    return;
                }

                $targetNestingLevel = 0;
                if (isset($tokens[$stackPtr]['nested_parenthesis']) === true) {
                    $targetNestingLevel = count($tokens[$stackPtr]['nested_parenthesis']);
                }

                // Examine each variable/constant in multi-declarations.
                $start = $stackPtr;
                $end   = $stackPtr;
                while (($end = $phpcsFile->findNext(array(T_COMMA, T_SEMICOLON, T_OPEN_SHORT_ARRAY, T_CLOSE_TAG), ($end + 1), ($endOfStatement + 1))) !== false) {

                    $maybeSkipTo = $this->isRealEndOfDeclaration($tokens, $end, $targetNestingLevel);
                    if ($maybeSkipTo !== true) {
                        $end = $maybeSkipTo;
                        continue;
                    }

                    $start = $phpcsFile->findNext(\PHP_CodeSniffer_Tokens::$emptyTokens, ($start + 1), $end, true);
                    if ($start === false
                        || ($tokens[$stackPtr]['code'] === T_CONST && $tokens[$start]['code'] !== T_STRING)
                        || ($tokens[$stackPtr]['code'] !== T_CONST && $tokens[$start]['code'] !== T_VARIABLE)
                    ) {
                        // Shouldn't be possible.
                        continue;
                    }

                    if ($this->isValidAssignment($phpcsFile, $start, $end) === false) {
                        // Create the "found" snippet.
                        $content    = '';
                        $tokenCount = ($end - $start);
                        if ($tokenCount < 20) {
                            // Prevent large arrays from being added to the error message.
                            $content = $phpcsFile->getTokensAsString($start, ($tokenCount + 1));
                        }

                        $this->throwError($phpcsFile, $start, $type, $content);
                    }

                    $start = $end;
                }

                // Skip to the end of the statement to prevent duplicate messages for multi-declarations.
                return $endOfStatement;
        }
    }


    /**
     * Is a value declared and is the value declared valid pre-PHP 5.6 ?
     *
     * @param \PHP_CodeSniffer_File $phpcsFile The file being scanned.
     * @param int                   $stackPtr  The position of the current token in the
     *                                         stack passed in $tokens.
     * @param int                   $end       The end of the value definition.
     *                                         This will normally be a comma or semi-colon.
     *
     * @return bool
     */
    protected function isValidAssignment(\PHP_CodeSniffer_File $phpcsFile, $stackPtr, $end)
    {
        $tokens = $phpcsFile->getTokens();
        $next   = $phpcsFile->findNext(\PHP_CodeSniffer_Tokens::$emptyTokens, ($stackPtr + 1), $end, true);
        if ($next === false || $tokens[$next]['code'] !== T_EQUAL) {
            // No value assigned.
            return true;
        }

        return $this->isStaticValue($phpcsFile, $tokens, ($next + 1), ($end - 1));
    }


    /**
     * Is a value declared and is the value declared constant as accepted in PHP 5.5 and lower ?
     *
     * @param \PHP_CodeSniffer_File $phpcsFile    The file being scanned.
     * @param array                 $tokens       The token stack of the current file.
     * @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 bool                  $nestedArrays Optional. Array nesting level when examining
     *                                            the content of an array.
     *
     * @return bool
     */
    protected function isStaticValue(\PHP_CodeSniffer_File $phpcsFile, $tokens, $start, $end, $nestedArrays = 0)
    {
        $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 ($this->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(
                    \PHP_CodeSniffer_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 `T_STRING::T_STRING`.
                    if ($nextNonEmpty === false || $tokens[$nextNonEmpty]['code'] !== T_STRING) {
                        return false;
                    }

                    $prevNonEmpty = $phpcsFile->findPrevious(\PHP_CodeSniffer_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 || $tokens[$prevNonEmpty]['code'] !== T_STRING) {
                        return false;
                    }
                }

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

            case T_ARRAY:
            case T_OPEN_SHORT_ARRAY:
                ++$nestedArrays;

                $arrayItems = $this->getFunctionCallParameters($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 = false;

                        $maybeDoubleArrow = $phpcsFile->findNext(
                            array(T_DOUBLE_ARROW, T_ARRAY, T_OPEN_SHORT_ARRAY),
                            $item['start'],
                            ($item['end'] + 1)
                        );
                        if ($maybeDoubleArrow !== false && $tokens[$maybeDoubleArrow]['code'] === T_DOUBLE_ARROW) {
                            // Double arrow is for this nesting level.
                            $doubleArrow = $maybeDoubleArrow;
                        }

                        if ($doubleArrow === false) {
                            if ($this->isStaticValue($phpcsFile, $tokens, $item['start'], $item['end'], $nestedArrays) === false) {
                                return false;
                            }

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

                            // Examine array value.
                            if ($this->isStaticValue($phpcsFile, $tokens, ($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(
                        \PHP_CodeSniffer_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, $tokens, ($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.
     *
     * @param \PHP_CodeSniffer_File $phpcsFile The file being scanned.
     * @param int                   $stackPtr  The position of the token to link the error to.
     * @param string                $type      Type of usage found.
     * @param string                $content   Optional. The value for the declaration as found.
     *
     * @return void
     */
    protected function throwError(\PHP_CodeSniffer_File $phpcsFile, $stackPtr, $type, $content = '')
    {
        $error     = static::ERROR_PHRASE;
        $phrase    = '';
        $errorCode = 'Found';

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

        $data = array($phrase);

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

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


    /**
     * Helper function to find the end of multi variable/constant declarations.
     *
     * Checks whether a certain part of a declaration needs to be skipped over or
     * if it is the real end of the declaration.
     *
     * @param array $tokens      Token stack of the current file.
     * @param int   $endPtr      The token to examine as a candidate end pointer.
     * @param int   $targetLevel Target nesting level.
     *
     * @return bool|int True if this is the real end. Int stackPtr to skip to if not.
     */
    private function isRealEndOfDeclaration($tokens, $endPtr, $targetLevel)
    {
        // Ignore anything within short array definition brackets for now.
        if ($tokens[$endPtr]['code'] === T_OPEN_SHORT_ARRAY
            && (isset($tokens[$endPtr]['bracket_opener'])
                && $tokens[$endPtr]['bracket_opener'] === $endPtr)
            && isset($tokens[$endPtr]['bracket_closer'])
        ) {
            // Skip forward to the end of the short array definition.
            return $tokens[$endPtr]['bracket_closer'];
        }

        // Skip past comma's at a lower nesting level.
        if ($tokens[$endPtr]['code'] === T_COMMA) {
            // Check if a comma is at the nesting level we're targetting.
            $nestingLevel = 0;
            if (isset($tokens[$endPtr]['nested_parenthesis']) === true) {
                $nestingLevel = count($tokens[$endPtr]['nested_parenthesis']);
            }
            if ($nestingLevel > $targetLevel) {
                return $endPtr;
            }
        }

        return true;
    }
}
