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

use PHPCompatibility\Sniff;
use PHP_CodeSniffer\Files\File;
use PHP_CodeSniffer\Util\Tokens;
use PHPCSUtils\BackCompat\BCTokens;
use PHPCSUtils\Tokens\Collections;
use PHPCSUtils\Utils\FunctionDeclarations;
use PHPCSUtils\Utils\Scopes;

/**
 * Detect using `$this` in incompatible contexts.
 *
 * "Whilst `$this` is considered a special variable in PHP, it lacked proper checks
 *  to ensure it wasn't used as a variable name or reassigned. This has now been
 *  rectified to ensure that `$this` cannot be a user-defined variable, reassigned
 *  to a different value, or be globalised."
 *
 * This sniff only addresses those situations which did *not* throw an error prior
 * to PHP 7.1, either at all or only in PHP 7.0.
 * In other words, the following situation, while mentioned in the RFC, will NOT
 * be sniffed for:
 * - Using $this as static variable. (error _message_ change only).
 *
 * Also, the changes with relation to assigning `$this` dynamically can not be
 * sniffed for reliably, so are not covered by this sniff.
 * - Disable ability to re-assign `$this` indirectly through `$$`.
 * - Disable ability to re-assign `$this` indirectly through reference.
 * - Disable ability to re-assign `$this` indirectly through `extract()` and `parse_str()`.
 *
 * Other changes not (yet) covered:
 * - `get_defined_vars()` always doesn't show value of variable `$this`.
 * - Always show true `$this` value in magic method `__call()`.
 *   {@internal This could possibly be covered. Similar logic as "outside object context",
 *   but with function name check and supportsBelow('7.0').}
 *
 * PHP version 7.1
 *
 * @link https://www.php.net/manual/en/migration71.other-changes.php#migration71.other-changes.inconsistency-fixes-to-this
 * @link https://wiki.php.net/rfc/this_var
 *
 * @since 9.1.0
 */
class ForbiddenThisUseContextsSniff extends Sniff
{

    /**
     * Scopes to skip over when examining the contents of functions.
     *
     * @since 9.1.0
     *
     * @var array
     */
    private $skipOverScopes = [
        \T_FUNCTION => true,
        \T_CLOSURE  => true,
    ];

    /**
     * Valid uses of $this in plain functions or methods outside object context.
     *
     * @since 9.1.0
     *
     * @var array
     */
    private $validUseOutsideObject = [
        \T_ISSET => true,
        \T_EMPTY => true,
    ];

    /**
     * Returns an array of tokens this test wants to listen for.
     *
     * @since 9.1.0
     *
     * @return array
     */
    public function register()
    {
        $this->skipOverScopes += BCTokens::ooScopeTokens();

        return [
            \T_FUNCTION,
            \T_CLOSURE,
            \T_GLOBAL,
            \T_CATCH,
            \T_FOREACH,
            \T_UNSET,
        ];
    }

    /**
     * Processes this test, when one of its tokens is encountered.
     *
     * @since 9.1.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 ($this->supportsAbove('7.1') === false) {
            return;
        }

        $tokens = $phpcsFile->getTokens();

        switch ($tokens[$stackPtr]['code']) {
            case \T_FUNCTION:
                $this->isThisUsedAsParameter($phpcsFile, $stackPtr);
                $this->isThisUsedOutsideObjectContext($phpcsFile, $stackPtr);
                break;

            case \T_CLOSURE:
                $this->isThisUsedAsParameter($phpcsFile, $stackPtr);
                break;

            case \T_GLOBAL:
                /*
                 * $this can no longer be imported using the `global` keyword.
                 * This worked in PHP 7.0, though in PHP 5.x, it would throw a
                 * fatal "Cannot re-assign $this" error.
                 */
                $endOfStatement = $phpcsFile->findNext([\T_SEMICOLON, \T_CLOSE_TAG], ($stackPtr + 1));
                if ($endOfStatement === false) {
                    // No semi-colon - live coding.
                    return;
                }

                for ($i = ($stackPtr + 1); $i < $endOfStatement; $i++) {
                    if ($tokens[$i]['code'] !== \T_VARIABLE || $tokens[$i]['content'] !== '$this') {
                        continue;
                    }

                    $phpcsFile->addError(
                        '"$this" can no longer be used with the "global" keyword since PHP 7.1.',
                        $i,
                        'Global'
                    );
                }

                break;

            case \T_CATCH:
                /*
                 * $this can no longer be used as a catch variable.
                 */
                if (isset($tokens[$stackPtr]['parenthesis_opener'], $tokens[$stackPtr]['parenthesis_closer']) === false) {
                    return;
                }

                $varPtr = $phpcsFile->findNext(
                    \T_VARIABLE,
                    ($tokens[$stackPtr]['parenthesis_opener'] + 1),
                    $tokens[$stackPtr]['parenthesis_closer']
                );

                if ($varPtr === false || $tokens[$varPtr]['content'] !== '$this') {
                    return;
                }

                $phpcsFile->addError(
                    '"$this" can no longer be used as a catch variable since PHP 7.1.',
                    $varPtr,
                    'Catch'
                );

                break;

            case \T_FOREACH:
                /*
                 * $this can no longer be used as a foreach *value* variable.
                 * This worked in PHP 7.0, though in PHP 5.x, it would throw a
                 * fatal "Cannot re-assign $this" error.
                 */
                if (isset($tokens[$stackPtr]['parenthesis_opener'], $tokens[$stackPtr]['parenthesis_closer']) === false) {
                    return;
                }

                $stopPtr = $phpcsFile->findPrevious(
                    [\T_AS, \T_DOUBLE_ARROW],
                    ($tokens[$stackPtr]['parenthesis_closer'] - 1),
                    $tokens[$stackPtr]['parenthesis_opener']
                );
                if ($stopPtr === false) {
                    return;
                }

                $valueVarPtr = $phpcsFile->findNext(
                    \T_VARIABLE,
                    ($stopPtr + 1),
                    $tokens[$stackPtr]['parenthesis_closer']
                );
                if ($valueVarPtr === false || $tokens[$valueVarPtr]['content'] !== '$this') {
                    return;
                }

                $afterThis = $phpcsFile->findNext(
                    Tokens::$emptyTokens,
                    ($valueVarPtr + 1),
                    $tokens[$stackPtr]['parenthesis_closer'],
                    true
                );

                if ($afterThis !== false
                    && isset(Collections::objectOperators()[$tokens[$afterThis]['code']]) === true
                ) {
                    return;
                }

                $phpcsFile->addError(
                    '"$this" can no longer be used as value variable in a foreach control structure since PHP 7.1.',
                    $valueVarPtr,
                    'ForeachValueVar'
                );

                break;

            case \T_UNSET:
                /*
                 * $this can no longer be unset.
                 */
                $openParenthesis = $phpcsFile->findNext(Tokens::$emptyTokens, ($stackPtr + 1), null, true);
                if ($openParenthesis === false
                    || $tokens[$openParenthesis]['code'] !== \T_OPEN_PARENTHESIS
                    || isset($tokens[$openParenthesis]['parenthesis_closer']) === false
                ) {
                    return;
                }

                for ($i = ($openParenthesis + 1); $i < $tokens[$openParenthesis]['parenthesis_closer']; $i++) {
                    if ($tokens[$i]['code'] !== \T_VARIABLE || $tokens[$i]['content'] !== '$this') {
                        continue;
                    }

                    $afterThis = $phpcsFile->findNext(
                        Tokens::$emptyTokens,
                        ($i + 1),
                        $tokens[$openParenthesis]['parenthesis_closer'],
                        true
                    );

                    if ($afterThis !== false
                        && (isset(Collections::objectOperators()[$tokens[$afterThis]['code']]) === true
                            || $tokens[$afterThis]['code'] === \T_OPEN_SQUARE_BRACKET)
                    ) {
                        $i = $afterThis;
                        continue;
                    }

                    $phpcsFile->addError(
                        '"$this" can no longer be unset since PHP 7.1.',
                        $i,
                        'Unset'
                    );
                }

                break;
        }
    }

    /**
     * Check if $this is used as a parameter in a function declaration.
     *
     * $this can no longer be used as a parameter in a *global* function.
     * Use as a parameter in a method was already an error prior to PHP 7.1.
     *
     * @since 9.1.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
     */
    protected function isThisUsedAsParameter(File $phpcsFile, $stackPtr)
    {
        if (Scopes::validDirectScope($phpcsFile, $stackPtr, BCTokens::ooScopeTokens()) !== false) {
            return;
        }

        $params = FunctionDeclarations::getParameters($phpcsFile, $stackPtr);
        if (empty($params)) {
            return;
        }

        $tokens = $phpcsFile->getTokens();

        foreach ($params as $param) {
            if ($param['name'] !== '$this') {
                continue;
            }

            if ($tokens[$stackPtr]['code'] === \T_FUNCTION) {
                $phpcsFile->addError(
                    '"$this" can no longer be used as a parameter since PHP 7.1.',
                    $param['token'],
                    'FunctionParam'
                );
            } else {
                $phpcsFile->addError(
                    '"$this" can no longer be used as a closure parameter since PHP 7.0.7.',
                    $param['token'],
                    'ClosureParam'
                );
            }
        }
    }

    /**
     * Check if $this is used in a plain function or method.
     *
     * Prior to PHP 7.1, this would result in an "undefined variable" notice
     * and execution would continue with $this regarded as `null`.
     * As of PHP 7.1, this throws an exception.
     *
     * Note: use within isset() and empty() to check object context is still allowed.
     * Note: $this can still be used within a closure.
     *
     * @since 9.1.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
     */
    protected function isThisUsedOutsideObjectContext(File $phpcsFile, $stackPtr)
    {
        $tokens = $phpcsFile->getTokens();

        if (isset($tokens[$stackPtr]['scope_opener'], $tokens[$stackPtr]['scope_closer']) === false) {
            return;
        }

        if (Scopes::validDirectScope($phpcsFile, $stackPtr, BCTokens::ooScopeTokens()) !== false) {
            $methodProps = $phpcsFile->getMethodProperties($stackPtr);
            if ($methodProps['is_static'] === false) {
                return;
            } else {
                $methodName = $phpcsFile->getDeclarationName($stackPtr);
                if ($methodName === '__call') {
                    /*
                     * This is an exception.
                     * @link https://wiki.php.net/rfc/this_var#always_show_true_this_value_in_magic_method_call
                     */
                    return;
                }
            }
        }

        for ($i = ($tokens[$stackPtr]['scope_opener'] + 1); $i < $tokens[$stackPtr]['scope_closer']; $i++) {
            if (isset($this->skipOverScopes[$tokens[$i]['code']])) {
                if (isset($tokens[$i]['scope_closer']) === false) {
                    // Live coding or parse error, will only lead to inaccurate results.
                    return;
                }

                // Skip over nested structures.
                $i = $tokens[$i]['scope_closer'];
                continue;
            }

            if ($tokens[$i]['code'] !== \T_VARIABLE || $tokens[$i]['content'] !== '$this') {
                continue;
            }

            if (isset($tokens[$i]['nested_parenthesis']) === true) {
                $nestedParenthesis     = $tokens[$i]['nested_parenthesis'];
                $nestedOpenParenthesis = \array_keys($nestedParenthesis);
                $lastOpenParenthesis   = \array_pop($nestedOpenParenthesis);

                $previousNonEmpty = $phpcsFile->findPrevious(
                    Tokens::$emptyTokens,
                    ($lastOpenParenthesis - 1),
                    null,
                    true,
                    null,
                    true
                );

                if (isset($this->validUseOutsideObject[$tokens[$previousNonEmpty]['code']])) {
                    continue;
                }
            }

            $phpcsFile->addError(
                '"$this" can no longer be used in a plain function or method since PHP 7.1.',
                $i,
                'OutsideObjectContext'
            );
        }
    }
}
