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