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