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