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

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

/**
 * Detect closures and verify that the features used are supported.
 *
 * Version based checks:
 * - Closures are available since PHP 5.3.
 * - Closures can be declared as `static` since PHP 5.4.
 * - Closures can use the `$this` variable within a class context since PHP 5.4.
 * - Closures can use `self`/`parent`/`static` since PHP 5.4.
 *
 * Version independent checks:
 * - Static closures don't have access to the `$this` variable.
 * - Closures declared outside of a class context don't have access to the `$this`
 *   variable unless bound to an object.
 *
 * PHP version 5.3
 * PHP version 5.4
 *
 * @link https://www.php.net/manual/en/functions.anonymous.php
 * @link https://wiki.php.net/rfc/closures
 * @link https://wiki.php.net/rfc/closures/object-extension
 *
 * @since 7.0.0
 * @since 10.0.0 This class is now `final`.
 */
final class NewClosureSniff extends Sniff
{

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

    /**
     * Processes this test, when one of its tokens is encountered.
     *
     * @since 7.0.0
     * @since 7.1.4 - Added check for closure being declared as static < 5.4.
     *              - Added check for use of `$this` variable in class context < 5.4.
     *              - Added check for use of `$this` variable in static closures (unsupported).
     *              - Added check for use of `$this` variable outside class context (unsupported).
     * @since 8.2.0 Added check for use of `self`/`static`/`parent` < 5.4.
     *
     * @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 int|void Integer stack pointer to skip forward or void to continue
     *                  normal file processing.
     */
    public function process(File $phpcsFile, $stackPtr)
    {
        if (ScannedCode::shouldRunOnOrBelow('5.2') === true) {
            $phpcsFile->addError(
                'Closures / anonymous functions are not available in PHP 5.2 or earlier',
                $stackPtr,
                'Found'
            );
        }

        /*
         * Closures can only be declared as static since PHP 5.4.
         */
        $isStatic = $this->isClosureStatic($phpcsFile, $stackPtr);
        if (ScannedCode::shouldRunOnOrBelow('5.3') === true && $isStatic === true) {
            $phpcsFile->addError(
                'Closures / anonymous functions could not be declared as static in PHP 5.3 or earlier',
                $stackPtr,
                'StaticFound'
            );
        }

        $tokens = $phpcsFile->getTokens();

        if (isset($tokens[$stackPtr]['scope_opener'], $tokens[$stackPtr]['scope_closer']) === false) {
            // Live coding or parse error.
            return;
        }

        $scopeStart = $tokens[$stackPtr]['scope_opener'];
        $scopeEnd   = $tokens[$stackPtr]['scope_closer'];
        $usesThis   = $this->findThisUsageInClosure($phpcsFile, $scopeStart, $scopeEnd);

        if (ScannedCode::shouldRunOnOrBelow('5.3') === true) {
            /*
             * Closures declared within classes only have access to $this since PHP 5.4.
             */
            if ($usesThis !== false) {
                $thisFound = $usesThis;
                do {
                    $phpcsFile->addError(
                        'Closures / anonymous functions did not have access to $this in PHP 5.3 or earlier',
                        $thisFound,
                        'ThisFound'
                    );

                    $thisFound = $this->findThisUsageInClosure($phpcsFile, $thisFound, $scopeEnd);

                } while ($thisFound !== false);
            }

            /*
             * Closures declared within classes only have access to self/parent/static since PHP 5.4.
             */
            $usesClassRef = $this->findClassRefUsageInClosure($phpcsFile, $scopeStart, $scopeEnd);

            if ($usesClassRef !== false) {
                do {
                    $phpcsFile->addError(
                        'Closures / anonymous functions could not use "%s::" in PHP 5.3 or earlier',
                        $usesClassRef,
                        'ClassRefFound',
                        [\strtolower($tokens[$usesClassRef]['content'])]
                    );

                    $usesClassRef = $this->findClassRefUsageInClosure($phpcsFile, $usesClassRef, $scopeEnd);

                } while ($usesClassRef !== false);
            }
        }

        /*
         * Check for correct usage.
         */
        if (ScannedCode::shouldRunOnOrAbove('5.4') === true && $usesThis !== false) {

            $thisFound = $usesThis;

            do {
                /*
                 * Closures only have access to $this if not declared as static.
                 */
                if ($isStatic === true) {
                    $phpcsFile->addError(
                        'Closures / anonymous functions declared as static do not have access to $this',
                        $thisFound,
                        'ThisFoundInStatic'
                    );
                }

                /*
                 * Closures only have access to $this if used within a class context.
                 */
                elseif (Conditions::hasCondition($phpcsFile, $stackPtr, Tokens::$ooScopeTokens) === false) {
                    $phpcsFile->addWarning(
                        'Closures / anonymous functions only have access to $this if used within a class or when bound to an object using bindTo(). Please verify.',
                        $thisFound,
                        'ThisFoundOutsideClass'
                    );
                }

                $thisFound = $this->findThisUsageInClosure($phpcsFile, $thisFound, $scopeEnd);

            } while ($thisFound !== false);
        }
    }


    /**
     * Check whether the closure is declared as static.
     *
     * @since 7.1.4
     *
     * @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 bool
     */
    protected function isClosureStatic(File $phpcsFile, $stackPtr)
    {
        $tokens    = $phpcsFile->getTokens();
        $prevToken = $phpcsFile->findPrevious(Tokens::$emptyTokens, ($stackPtr - 1), null, true, null, true);

        return ($prevToken !== false && $tokens[$prevToken]['code'] === \T_STATIC);
    }


    /**
     * Check if the code within a closure uses the $this variable.
     *
     * @since 7.1.4
     *
     * @param \PHP_CodeSniffer\Files\File $phpcsFile  The file being scanned.
     * @param int                         $startToken The current position within the closure to continue searching from.
     * @param int                         $endToken   The closure scope closer to stop searching at.
     *
     * @return int|false The stackPtr to the first $this usage if found or false if
     *                   $this is not used.
     */
    protected function findThisUsageInClosure(File $phpcsFile, $startToken, $endToken)
    {
        // Make sure the $startToken is valid.
        if (($startToken + 1) >= $endToken) {
            return false;
        }

        $tokens = $phpcsFile->getTokens();
        $found  = $startToken;

        $find  = [\T_VARIABLE => \T_VARIABLE];
        $find += Collections::closedScopes();

        do {
            $found = $phpcsFile->findNext($find, ($found + 1), $endToken);
            if ($found === false) {
                return false;
            }

            if (isset(Collections::closedScopes()[$tokens[$found]['code']])) {
                if (isset($tokens[$found]['scope_closer'])) {
                    // Nested closed structure. Skip over.
                    $found = $tokens[$found]['scope_closer'];
                    continue;
                } else {
                    // Shouldn't be possible.
                    return false;
                }
            }

            // This must be a variable token.
            if ($tokens[$found]['content'] !== '$this') {
                // Not our target, try and find another one.
                continue;
            }

            // Found $this.
            return $found;

        } while (true);
    }

    /**
     * Check if the code within a closure uses "self/parent/static".
     *
     * @since 8.2.0
     *
     * @param \PHP_CodeSniffer\Files\File $phpcsFile  The file being scanned.
     * @param int                         $startToken The current position within the closure to continue searching from.
     * @param int                         $endToken   The closure scope closer to stop searching at.
     *
     * @return int|false The stackPtr to the first classRef usage if found or false if
     *                   they are not used.
     */
    protected function findClassRefUsageInClosure(File $phpcsFile, $startToken, $endToken)
    {
        // Make sure the $startToken is valid.
        if (($startToken + 1) >= $endToken) {
            return false;
        }

        $tokens = $phpcsFile->getTokens();
        $found  = $startToken;

        $find  = Collections::ooHierarchyKeywords();
        $find += Collections::closedScopes();

        do {
            $found = $phpcsFile->findNext($find, ($found + 1), $endToken);
            if ($found === false) {
                return false;
            }

            if (isset(Collections::closedScopes()[$tokens[$found]['code']])) {
                if (isset($tokens[$found]['scope_closer'])) {
                    // Nested closed structure. Skip over.
                    $found = $tokens[$found]['scope_closer'];
                    continue;
                } else {
                    // Shouldn't be possible.
                    return false;
                }
            }

            // This must be an OO hierarchy keyword token.
            if ($tokens[$found]['code'] !== \T_STATIC) {
                return $found;
            }

            // T_STATIC, make sure it is used as a class reference.
            $nextNonEmpty = $phpcsFile->findNext(Tokens::$emptyTokens, ($found + 1), $endToken, true);
            if ($nextNonEmpty === false || $tokens[$nextNonEmpty]['code'] !== \T_DOUBLE_COLON) {
                return false;
            }

            return $found;

        } while (true);
    }
}
