<?php

namespace staabm\SideEffectsDetector;

final class SideEffectsDetector {
    /**
     * @var array<int>
     */
    private array $scopePollutingTokens = [
        T_CLASS,
        T_FUNCTION,
        T_NEW,
        T_EVAL,
        T_GLOBAL,
        T_GOTO,
        T_HALT_COMPILER,
        T_INCLUDE,
        T_INCLUDE_ONCE,
        T_REQUIRE,
        T_REQUIRE_ONCE,
        T_THROW,
        T_UNSET,
        T_UNSET_CAST
    ];

    private const PROCESS_EXIT_TOKENS = [
        T_EXIT
    ];

    private const OUTPUT_TOKENS = [
        T_PRINT,
        T_ECHO,
        T_INLINE_HTML
    ];

    private const SCOPE_POLLUTING_FUNCTIONS = [
        'putenv',
        'setlocale',
        'class_exists',
        'ini_set',
    ];

    private const STANDARD_OUTPUT_FUNCTIONS = [
        'printf',
        'vprintf'
    ];

    private const INPUT_OUTPUT_FUNCTIONS = [
        'fopen',
        'file_get_contents',
        'file_put_contents',
        'fwrite',
        'fputs',
        'fread',
        'unlink'
    ];

    /**
     * @var array<string, array{'hasSideEffects': bool}>
     */
    private array $functionMetadata;

    public function __construct() {
        $functionMeta = require __DIR__ . '/functionMetadata.php';
        if (!is_array($functionMeta)) {
            throw new \RuntimeException('Invalid function metadata');
        }
        $this->functionMetadata = $functionMeta;

        if (defined('T_ENUM')) {
            $this->scopePollutingTokens[] = T_ENUM;
        }
    }

    /**
     * @api
     *
     * @return array<SideEffect::*>
     */
    public function getSideEffects(string $code): array {
        $tokens = token_get_all($code);

        $sideEffects = [];
        for ($i = 0; $i < count($tokens); $i++) {
            $token = $tokens[$i];

            if (!is_array($token)) {
                continue;
            }

            if ($this->isAnonymousFunction($tokens, $i)) {
                continue;
            }

            if (in_array($token[0], self::OUTPUT_TOKENS, true)) {
                $sideEffects[] = SideEffect::STANDARD_OUTPUT;
                continue;
            }
            if (in_array($token[0], self::PROCESS_EXIT_TOKENS, true)) {
                $sideEffects[] = SideEffect::PROCESS_EXIT;
                continue;
            }
            if (in_array($token[0], $this->scopePollutingTokens, true)) {
                $sideEffects[] = SideEffect::SCOPE_POLLUTION;

                $i++;
                if (in_array($token[0], [T_FUNCTION, T_CLASS], true)) {
                    $this->consumeWhitespaces($tokens, $i);
                }

                // consume function/class-name
                if (
                    !array_key_exists($i, $tokens)
                    || !is_array($tokens[$i])
                    || $tokens[$i][0] !== T_STRING
                ) {
                    continue;
                }

                $i++;
                continue;
            }

            $functionCall = $this->getFunctionCall($tokens, $i);
            if ($functionCall !== null) {
                $callSideEffect = $this->getFunctionCallSideEffect($functionCall);
                if ($callSideEffect !== null) {
                    $sideEffects[] = $callSideEffect;
                }
                continue;
            }

            $methodCall = $this->getMethodCall($tokens, $i);
            if ($methodCall !== null) {
                $sideEffects[] = SideEffect::MAYBE;
                continue;
            }

            $propertyAccess = $this->getPropertyAccess($tokens, $i);
            if ($propertyAccess !== null) {
                $sideEffects[] = SideEffect::SCOPE_POLLUTION;
                continue;
            }

            if ($this->isNonLocalVariable($tokens, $i)) {
                $sideEffects[] = SideEffect::SCOPE_POLLUTION;
                continue;
            }
        }

        return array_values(array_unique($sideEffects));
    }

    /**
     * @return SideEffect::*|null
     */
    private function getFunctionCallSideEffect(string $functionName): ?string { // @phpstan-ignore return.unusedType
        if (in_array($functionName, self::STANDARD_OUTPUT_FUNCTIONS, true)) {
            return SideEffect::STANDARD_OUTPUT;
        }

        if (in_array($functionName, self::INPUT_OUTPUT_FUNCTIONS, true)) {
            return SideEffect::INPUT_OUTPUT;
        }

        if (in_array($functionName, self::SCOPE_POLLUTING_FUNCTIONS, true)) {
            return SideEffect::SCOPE_POLLUTION;
        }

        if (array_key_exists($functionName, $this->functionMetadata)) {
            if ($this->functionMetadata[$functionName]['hasSideEffects'] === true) {
                return SideEffect::UNKNOWN_CLASS;
            }
        } else {
            try {
                $reflectionFunction = new \ReflectionFunction($functionName);
                $returnType = $reflectionFunction->getReturnType();
                if ($returnType === null) {
                    return SideEffect::MAYBE; // no reflection information -> we don't know
                }
                if ((string)$returnType === 'void') {
                    return SideEffect::UNKNOWN_CLASS; // functions with void return type must have side-effects
                }
            } catch (\ReflectionException $e) {
                return SideEffect::MAYBE; // function does not exist -> we don't know
            }
        }

        return null;
    }

    /**
     * @param array<int, array{0:int,1:string,2:int}|string|int> $tokens
     */
    private function getFunctionCall(array $tokens, int $index): ?string {
        if (
            !array_key_exists($index, $tokens)
            || !is_array($tokens[$index])
            || $tokens[$index][0] !== T_STRING
        ) {
            return null;
        }
        $functionName = $tokens[$index][1];

        $index++;
        $this->consumeWhitespaces($tokens, $index);

        if (
            array_key_exists($index, $tokens)
            && $tokens[$index] === '('
        ) {
            return $functionName;
        }

        return null;
    }

    /**
     * @param array<int, array{0:int,1:string,2:int}|string|int> $tokens
     */
    private function getMethodCall(array $tokens, int $index): ?string {
        if (
            !array_key_exists($index, $tokens)
            || !is_array($tokens[$index])
            || !in_array($tokens[$index][0], [T_VARIABLE, T_STRING], true)
        ) {
            return null;
        }
        $callee = $tokens[$index][1];

        $index++;
        $this->consumeWhitespaces($tokens, $index);

        if (
            !array_key_exists($index, $tokens)
            || !is_array($tokens[$index])
            || !in_array($tokens[$index][0], [T_OBJECT_OPERATOR , T_DOUBLE_COLON ], true)
        ) {
            return null;
        }
        $operator = $tokens[$index][1];

        $index++;
        $this->consumeWhitespaces($tokens, $index);

        if (
            !array_key_exists($index, $tokens)
            || !is_array($tokens[$index])
            || !in_array($tokens[$index][0], [T_STRING], true)
        ) {
            return null;
        }
        $method = $tokens[$index][1];

        $index++;
        $this->consumeWhitespaces($tokens, $index);

        if (
            array_key_exists($index, $tokens)
            && $tokens[$index] !== '('
        ) {
            return null;
        }

        return $callee . $operator . $method;
    }

    /**
     * @param array<int, array{0:int,1:string,2:int}|string|int> $tokens
     */
    private function getPropertyAccess(array $tokens, int $index): ?string {
        if (
            !array_key_exists($index, $tokens)
            || !is_array($tokens[$index])
            || !in_array($tokens[$index][0], [T_VARIABLE, T_STRING], true)
        ) {
            return null;
        }
        $objectOrClass = $tokens[$index][1];

        $index++;
        $this->consumeWhitespaces($tokens, $index);

        if (
            !array_key_exists($index, $tokens)
            || !is_array($tokens[$index])
            || !in_array($tokens[$index][0], [T_OBJECT_OPERATOR , T_DOUBLE_COLON ], true)
        ) {
            return null;
        }
        $operator = $tokens[$index][1];

        $index++;
        $this->consumeWhitespaces($tokens, $index);

        if (
            !array_key_exists($index, $tokens)
            || !is_array($tokens[$index])
            || !in_array($tokens[$index][0], [T_STRING, T_VARIABLE], true)
        ) {
            return null;
        }
        $propName = $tokens[$index][1];

        return $objectOrClass . $operator . $propName;
    }

    /**
     * @param array<int, array{0:int,1:string,2:int}|string|int> $tokens
     */
    private function isAnonymousFunction(array $tokens, int $index): bool
    {
        if (
            !array_key_exists($index, $tokens)
            || !is_array($tokens[$index])
            || $tokens[$index][0] !== T_FUNCTION
        ) {
            return false;
        }

        $index++;
        $this->consumeWhitespaces($tokens, $index);

        if (
            array_key_exists($index, $tokens)
            && $tokens[$index] === '('
        ) {
            return true;
        }

        return false;
    }

    /**
     * @param array<int, array{0:int,1:string,2:int}|string|int> $tokens
     */
    private function isNonLocalVariable(array $tokens, int $index): bool
    {
        if (
            array_key_exists($index, $tokens)
            && is_array($tokens[$index])
            && $tokens[$index][0] === T_VARIABLE
        ) {
            if (
                in_array(
                $tokens[$index][1],
                [
                    '$this',
                    '$GLOBALS', '$_SERVER', '$_GET', '$_POST', '$_FILES', '$_COOKIE', '$_SESSION', '$_REQUEST', '$_ENV',
                ],
            true)
            ) {
                return true;
            }
        }

        return false;
    }

    /**
     * @param array<int, array{0:int,1:string,2:int}|string|int> $tokens
     */
    private function consumeWhitespaces(array $tokens, int &$index): void {
        while (
            array_key_exists($index, $tokens)
            && is_array($tokens[$index])
            && $tokens[$index][0] === T_WHITESPACE
        ) {
            $index++;
        }
    }
}
                                                                                                                                                                                                                                                                                                                                                                                      MIT License

Copyright (c) 2021 Markus Staab

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
