1<?php declare(strict_types=1);
2
3namespace PhpParser\Lexer;
4
5use PhpParser\Error;
6use PhpParser\ErrorHandler;
7use PhpParser\Lexer;
8use PhpParser\Lexer\TokenEmulator\AsymmetricVisibilityTokenEmulator;
9use PhpParser\Lexer\TokenEmulator\AttributeEmulator;
10use PhpParser\Lexer\TokenEmulator\EnumTokenEmulator;
11use PhpParser\Lexer\TokenEmulator\ExplicitOctalEmulator;
12use PhpParser\Lexer\TokenEmulator\MatchTokenEmulator;
13use PhpParser\Lexer\TokenEmulator\NullsafeTokenEmulator;
14use PhpParser\Lexer\TokenEmulator\PropertyTokenEmulator;
15use PhpParser\Lexer\TokenEmulator\ReadonlyFunctionTokenEmulator;
16use PhpParser\Lexer\TokenEmulator\ReadonlyTokenEmulator;
17use PhpParser\Lexer\TokenEmulator\ReverseEmulator;
18use PhpParser\Lexer\TokenEmulator\TokenEmulator;
19use PhpParser\PhpVersion;
20use PhpParser\Token;
21
22class Emulative extends Lexer {
23    /** @var array{int, string, string}[] Patches used to reverse changes introduced in the code */
24    private array $patches = [];
25
26    /** @var list<TokenEmulator> */
27    private array $emulators = [];
28
29    private PhpVersion $targetPhpVersion;
30
31    private PhpVersion $hostPhpVersion;
32
33    /**
34     * @param PhpVersion|null $phpVersion PHP version to emulate. Defaults to newest supported.
35     */
36    public function __construct(?PhpVersion $phpVersion = null) {
37        $this->targetPhpVersion = $phpVersion ?? PhpVersion::getNewestSupported();
38        $this->hostPhpVersion = PhpVersion::getHostVersion();
39
40        $emulators = [
41            new MatchTokenEmulator(),
42            new NullsafeTokenEmulator(),
43            new AttributeEmulator(),
44            new EnumTokenEmulator(),
45            new ReadonlyTokenEmulator(),
46            new ExplicitOctalEmulator(),
47            new ReadonlyFunctionTokenEmulator(),
48            new PropertyTokenEmulator(),
49            new AsymmetricVisibilityTokenEmulator(),
50        ];
51
52        // Collect emulators that are relevant for the PHP version we're running
53        // and the PHP version we're targeting for emulation.
54        foreach ($emulators as $emulator) {
55            $emulatorPhpVersion = $emulator->getPhpVersion();
56            if ($this->isForwardEmulationNeeded($emulatorPhpVersion)) {
57                $this->emulators[] = $emulator;
58            } elseif ($this->isReverseEmulationNeeded($emulatorPhpVersion)) {
59                $this->emulators[] = new ReverseEmulator($emulator);
60            }
61        }
62    }
63
64    public function tokenize(string $code, ?ErrorHandler $errorHandler = null): array {
65        $emulators = array_filter($this->emulators, function ($emulator) use ($code) {
66            return $emulator->isEmulationNeeded($code);
67        });
68
69        if (empty($emulators)) {
70            // Nothing to emulate, yay
71            return parent::tokenize($code, $errorHandler);
72        }
73
74        if ($errorHandler === null) {
75            $errorHandler = new ErrorHandler\Throwing();
76        }
77
78        $this->patches = [];
79        foreach ($emulators as $emulator) {
80            $code = $emulator->preprocessCode($code, $this->patches);
81        }
82
83        $collector = new ErrorHandler\Collecting();
84        $tokens = parent::tokenize($code, $collector);
85        $this->sortPatches();
86        $tokens = $this->fixupTokens($tokens);
87
88        $errors = $collector->getErrors();
89        if (!empty($errors)) {
90            $this->fixupErrors($errors);
91            foreach ($errors as $error) {
92                $errorHandler->handleError($error);
93            }
94        }
95
96        foreach ($emulators as $emulator) {
97            $tokens = $emulator->emulate($code, $tokens);
98        }
99
100        return $tokens;
101    }
102
103    private function isForwardEmulationNeeded(PhpVersion $emulatorPhpVersion): bool {
104        return $this->hostPhpVersion->older($emulatorPhpVersion)
105            && $this->targetPhpVersion->newerOrEqual($emulatorPhpVersion);
106    }
107
108    private function isReverseEmulationNeeded(PhpVersion $emulatorPhpVersion): bool {
109        return $this->hostPhpVersion->newerOrEqual($emulatorPhpVersion)
110            && $this->targetPhpVersion->older($emulatorPhpVersion);
111    }
112
113    private function sortPatches(): void {
114        // Patches may be contributed by different emulators.
115        // Make sure they are sorted by increasing patch position.
116        usort($this->patches, function ($p1, $p2) {
117            return $p1[0] <=> $p2[0];
118        });
119    }
120
121    /**
122     * @param list<Token> $tokens
123     * @return list<Token>
124     */
125    private function fixupTokens(array $tokens): array {
126        if (\count($this->patches) === 0) {
127            return $tokens;
128        }
129
130        // Load first patch
131        $patchIdx = 0;
132        list($patchPos, $patchType, $patchText) = $this->patches[$patchIdx];
133
134        // We use a manual loop over the tokens, because we modify the array on the fly
135        $posDelta = 0;
136        $lineDelta = 0;
137        for ($i = 0, $c = \count($tokens); $i < $c; $i++) {
138            $token = $tokens[$i];
139            $pos = $token->pos;
140            $token->pos += $posDelta;
141            $token->line += $lineDelta;
142            $localPosDelta = 0;
143            $len = \strlen($token->text);
144            while ($patchPos >= $pos && $patchPos < $pos + $len) {
145                $patchTextLen = \strlen($patchText);
146                if ($patchType === 'remove') {
147                    if ($patchPos === $pos && $patchTextLen === $len) {
148                        // Remove token entirely
149                        array_splice($tokens, $i, 1, []);
150                        $i--;
151                        $c--;
152                    } else {
153                        // Remove from token string
154                        $token->text = substr_replace(
155                            $token->text, '', $patchPos - $pos + $localPosDelta, $patchTextLen
156                        );
157                        $localPosDelta -= $patchTextLen;
158                    }
159                    $lineDelta -= \substr_count($patchText, "\n");
160                } elseif ($patchType === 'add') {
161                    // Insert into the token string
162                    $token->text = substr_replace(
163                        $token->text, $patchText, $patchPos - $pos + $localPosDelta, 0
164                    );
165                    $localPosDelta += $patchTextLen;
166                    $lineDelta += \substr_count($patchText, "\n");
167                } elseif ($patchType === 'replace') {
168                    // Replace inside the token string
169                    $token->text = substr_replace(
170                        $token->text, $patchText, $patchPos - $pos + $localPosDelta, $patchTextLen
171                    );
172                } else {
173                    assert(false);
174                }
175
176                // Fetch the next patch
177                $patchIdx++;
178                if ($patchIdx >= \count($this->patches)) {
179                    // No more patches. However, we still need to adjust position.
180                    $patchPos = \PHP_INT_MAX;
181                    break;
182                }
183
184                list($patchPos, $patchType, $patchText) = $this->patches[$patchIdx];
185            }
186
187            $posDelta += $localPosDelta;
188        }
189        return $tokens;
190    }
191
192    /**
193     * Fixup line and position information in errors.
194     *
195     * @param Error[] $errors
196     */
197    private function fixupErrors(array $errors): void {
198        foreach ($errors as $error) {
199            $attrs = $error->getAttributes();
200
201            $posDelta = 0;
202            $lineDelta = 0;
203            foreach ($this->patches as $patch) {
204                list($patchPos, $patchType, $patchText) = $patch;
205                if ($patchPos >= $attrs['startFilePos']) {
206                    // No longer relevant
207                    break;
208                }
209
210                if ($patchType === 'add') {
211                    $posDelta += strlen($patchText);
212                    $lineDelta += substr_count($patchText, "\n");
213                } elseif ($patchType === 'remove') {
214                    $posDelta -= strlen($patchText);
215                    $lineDelta -= substr_count($patchText, "\n");
216                }
217            }
218
219            $attrs['startFilePos'] += $posDelta;
220            $attrs['endFilePos'] += $posDelta;
221            $attrs['startLine'] += $lineDelta;
222            $attrs['endLine'] += $lineDelta;
223            $error->setAttributes($attrs);
224        }
225    }
226}
227