You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
251 lines
8.8 KiB
251 lines
8.8 KiB
<?php declare(strict_types=1); |
|
|
|
namespace PhpParser\Lexer; |
|
|
|
use PhpParser\Error; |
|
use PhpParser\ErrorHandler; |
|
use PhpParser\Lexer; |
|
use PhpParser\Lexer\TokenEmulator\AttributeEmulator; |
|
use PhpParser\Lexer\TokenEmulator\EnumTokenEmulator; |
|
use PhpParser\Lexer\TokenEmulator\CoaleseEqualTokenEmulator; |
|
use PhpParser\Lexer\TokenEmulator\ExplicitOctalEmulator; |
|
use PhpParser\Lexer\TokenEmulator\FlexibleDocStringEmulator; |
|
use PhpParser\Lexer\TokenEmulator\FnTokenEmulator; |
|
use PhpParser\Lexer\TokenEmulator\MatchTokenEmulator; |
|
use PhpParser\Lexer\TokenEmulator\NullsafeTokenEmulator; |
|
use PhpParser\Lexer\TokenEmulator\NumericLiteralSeparatorEmulator; |
|
use PhpParser\Lexer\TokenEmulator\ReadonlyFunctionTokenEmulator; |
|
use PhpParser\Lexer\TokenEmulator\ReadonlyTokenEmulator; |
|
use PhpParser\Lexer\TokenEmulator\ReverseEmulator; |
|
use PhpParser\Lexer\TokenEmulator\TokenEmulator; |
|
|
|
class Emulative extends Lexer |
|
{ |
|
const PHP_7_3 = '7.3dev'; |
|
const PHP_7_4 = '7.4dev'; |
|
const PHP_8_0 = '8.0dev'; |
|
const PHP_8_1 = '8.1dev'; |
|
const PHP_8_2 = '8.2dev'; |
|
|
|
/** @var mixed[] Patches used to reverse changes introduced in the code */ |
|
private $patches = []; |
|
|
|
/** @var TokenEmulator[] */ |
|
private $emulators = []; |
|
|
|
/** @var string */ |
|
private $targetPhpVersion; |
|
|
|
/** |
|
* @param mixed[] $options Lexer options. In addition to the usual options, |
|
* accepts a 'phpVersion' string that specifies the |
|
* version to emulate. Defaults to newest supported. |
|
*/ |
|
public function __construct(array $options = []) |
|
{ |
|
$this->targetPhpVersion = $options['phpVersion'] ?? Emulative::PHP_8_2; |
|
unset($options['phpVersion']); |
|
|
|
parent::__construct($options); |
|
|
|
$emulators = [ |
|
new FlexibleDocStringEmulator(), |
|
new FnTokenEmulator(), |
|
new MatchTokenEmulator(), |
|
new CoaleseEqualTokenEmulator(), |
|
new NumericLiteralSeparatorEmulator(), |
|
new NullsafeTokenEmulator(), |
|
new AttributeEmulator(), |
|
new EnumTokenEmulator(), |
|
new ReadonlyTokenEmulator(), |
|
new ExplicitOctalEmulator(), |
|
new ReadonlyFunctionTokenEmulator(), |
|
]; |
|
|
|
// Collect emulators that are relevant for the PHP version we're running |
|
// and the PHP version we're targeting for emulation. |
|
foreach ($emulators as $emulator) { |
|
$emulatorPhpVersion = $emulator->getPhpVersion(); |
|
if ($this->isForwardEmulationNeeded($emulatorPhpVersion)) { |
|
$this->emulators[] = $emulator; |
|
} else if ($this->isReverseEmulationNeeded($emulatorPhpVersion)) { |
|
$this->emulators[] = new ReverseEmulator($emulator); |
|
} |
|
} |
|
} |
|
|
|
public function startLexing(string $code, ErrorHandler $errorHandler = null) { |
|
$emulators = array_filter($this->emulators, function($emulator) use($code) { |
|
return $emulator->isEmulationNeeded($code); |
|
}); |
|
|
|
if (empty($emulators)) { |
|
// Nothing to emulate, yay |
|
parent::startLexing($code, $errorHandler); |
|
return; |
|
} |
|
|
|
$this->patches = []; |
|
foreach ($emulators as $emulator) { |
|
$code = $emulator->preprocessCode($code, $this->patches); |
|
} |
|
|
|
$collector = new ErrorHandler\Collecting(); |
|
parent::startLexing($code, $collector); |
|
$this->sortPatches(); |
|
$this->fixupTokens(); |
|
|
|
$errors = $collector->getErrors(); |
|
if (!empty($errors)) { |
|
$this->fixupErrors($errors); |
|
foreach ($errors as $error) { |
|
$errorHandler->handleError($error); |
|
} |
|
} |
|
|
|
foreach ($emulators as $emulator) { |
|
$this->tokens = $emulator->emulate($code, $this->tokens); |
|
} |
|
} |
|
|
|
private function isForwardEmulationNeeded(string $emulatorPhpVersion): bool { |
|
return version_compare(\PHP_VERSION, $emulatorPhpVersion, '<') |
|
&& version_compare($this->targetPhpVersion, $emulatorPhpVersion, '>='); |
|
} |
|
|
|
private function isReverseEmulationNeeded(string $emulatorPhpVersion): bool { |
|
return version_compare(\PHP_VERSION, $emulatorPhpVersion, '>=') |
|
&& version_compare($this->targetPhpVersion, $emulatorPhpVersion, '<'); |
|
} |
|
|
|
private function sortPatches() |
|
{ |
|
// Patches may be contributed by different emulators. |
|
// Make sure they are sorted by increasing patch position. |
|
usort($this->patches, function($p1, $p2) { |
|
return $p1[0] <=> $p2[0]; |
|
}); |
|
} |
|
|
|
private function fixupTokens() |
|
{ |
|
if (\count($this->patches) === 0) { |
|
return; |
|
} |
|
|
|
// Load first patch |
|
$patchIdx = 0; |
|
|
|
list($patchPos, $patchType, $patchText) = $this->patches[$patchIdx]; |
|
|
|
// We use a manual loop over the tokens, because we modify the array on the fly |
|
$pos = 0; |
|
for ($i = 0, $c = \count($this->tokens); $i < $c; $i++) { |
|
$token = $this->tokens[$i]; |
|
if (\is_string($token)) { |
|
if ($patchPos === $pos) { |
|
// Only support replacement for string tokens. |
|
assert($patchType === 'replace'); |
|
$this->tokens[$i] = $patchText; |
|
|
|
// Fetch the next patch |
|
$patchIdx++; |
|
if ($patchIdx >= \count($this->patches)) { |
|
// No more patches, we're done |
|
return; |
|
} |
|
list($patchPos, $patchType, $patchText) = $this->patches[$patchIdx]; |
|
} |
|
|
|
$pos += \strlen($token); |
|
continue; |
|
} |
|
|
|
$len = \strlen($token[1]); |
|
$posDelta = 0; |
|
while ($patchPos >= $pos && $patchPos < $pos + $len) { |
|
$patchTextLen = \strlen($patchText); |
|
if ($patchType === 'remove') { |
|
if ($patchPos === $pos && $patchTextLen === $len) { |
|
// Remove token entirely |
|
array_splice($this->tokens, $i, 1, []); |
|
$i--; |
|
$c--; |
|
} else { |
|
// Remove from token string |
|
$this->tokens[$i][1] = substr_replace( |
|
$token[1], '', $patchPos - $pos + $posDelta, $patchTextLen |
|
); |
|
$posDelta -= $patchTextLen; |
|
} |
|
} elseif ($patchType === 'add') { |
|
// Insert into the token string |
|
$this->tokens[$i][1] = substr_replace( |
|
$token[1], $patchText, $patchPos - $pos + $posDelta, 0 |
|
); |
|
$posDelta += $patchTextLen; |
|
} else if ($patchType === 'replace') { |
|
// Replace inside the token string |
|
$this->tokens[$i][1] = substr_replace( |
|
$token[1], $patchText, $patchPos - $pos + $posDelta, $patchTextLen |
|
); |
|
} else { |
|
assert(false); |
|
} |
|
|
|
// Fetch the next patch |
|
$patchIdx++; |
|
if ($patchIdx >= \count($this->patches)) { |
|
// No more patches, we're done |
|
return; |
|
} |
|
|
|
list($patchPos, $patchType, $patchText) = $this->patches[$patchIdx]; |
|
|
|
// Multiple patches may apply to the same token. Reload the current one to check |
|
// If the new patch applies |
|
$token = $this->tokens[$i]; |
|
} |
|
|
|
$pos += $len; |
|
} |
|
|
|
// A patch did not apply |
|
assert(false); |
|
} |
|
|
|
/** |
|
* Fixup line and position information in errors. |
|
* |
|
* @param Error[] $errors |
|
*/ |
|
private function fixupErrors(array $errors) { |
|
foreach ($errors as $error) { |
|
$attrs = $error->getAttributes(); |
|
|
|
$posDelta = 0; |
|
$lineDelta = 0; |
|
foreach ($this->patches as $patch) { |
|
list($patchPos, $patchType, $patchText) = $patch; |
|
if ($patchPos >= $attrs['startFilePos']) { |
|
// No longer relevant |
|
break; |
|
} |
|
|
|
if ($patchType === 'add') { |
|
$posDelta += strlen($patchText); |
|
$lineDelta += substr_count($patchText, "\n"); |
|
} else if ($patchType === 'remove') { |
|
$posDelta -= strlen($patchText); |
|
$lineDelta -= substr_count($patchText, "\n"); |
|
} |
|
} |
|
|
|
$attrs['startFilePos'] += $posDelta; |
|
$attrs['endFilePos'] += $posDelta; |
|
$attrs['startLine'] += $lineDelta; |
|
$attrs['endLine'] += $lineDelta; |
|
$error->setAttributes($attrs); |
|
} |
|
} |
|
}
|
|
|