227 lines
8.1 KiB
PHP
227 lines
8.1 KiB
PHP
|
<?php declare(strict_types=1);
|
||
|
|
||
|
namespace PhpParser\Lexer;
|
||
|
|
||
|
use PhpParser\Error;
|
||
|
use PhpParser\ErrorHandler;
|
||
|
use PhpParser\Lexer;
|
||
|
use PhpParser\Lexer\TokenEmulator\AsymmetricVisibilityTokenEmulator;
|
||
|
use PhpParser\Lexer\TokenEmulator\AttributeEmulator;
|
||
|
use PhpParser\Lexer\TokenEmulator\EnumTokenEmulator;
|
||
|
use PhpParser\Lexer\TokenEmulator\ExplicitOctalEmulator;
|
||
|
use PhpParser\Lexer\TokenEmulator\MatchTokenEmulator;
|
||
|
use PhpParser\Lexer\TokenEmulator\NullsafeTokenEmulator;
|
||
|
use PhpParser\Lexer\TokenEmulator\PropertyTokenEmulator;
|
||
|
use PhpParser\Lexer\TokenEmulator\ReadonlyFunctionTokenEmulator;
|
||
|
use PhpParser\Lexer\TokenEmulator\ReadonlyTokenEmulator;
|
||
|
use PhpParser\Lexer\TokenEmulator\ReverseEmulator;
|
||
|
use PhpParser\Lexer\TokenEmulator\TokenEmulator;
|
||
|
use PhpParser\PhpVersion;
|
||
|
use PhpParser\Token;
|
||
|
|
||
|
class Emulative extends Lexer {
|
||
|
/** @var array{int, string, string}[] Patches used to reverse changes introduced in the code */
|
||
|
private array $patches = [];
|
||
|
|
||
|
/** @var list<TokenEmulator> */
|
||
|
private array $emulators = [];
|
||
|
|
||
|
private PhpVersion $targetPhpVersion;
|
||
|
|
||
|
private PhpVersion $hostPhpVersion;
|
||
|
|
||
|
/**
|
||
|
* @param PhpVersion|null $phpVersion PHP version to emulate. Defaults to newest supported.
|
||
|
*/
|
||
|
public function __construct(?PhpVersion $phpVersion = null) {
|
||
|
$this->targetPhpVersion = $phpVersion ?? PhpVersion::getNewestSupported();
|
||
|
$this->hostPhpVersion = PhpVersion::getHostVersion();
|
||
|
|
||
|
$emulators = [
|
||
|
new MatchTokenEmulator(),
|
||
|
new NullsafeTokenEmulator(),
|
||
|
new AttributeEmulator(),
|
||
|
new EnumTokenEmulator(),
|
||
|
new ReadonlyTokenEmulator(),
|
||
|
new ExplicitOctalEmulator(),
|
||
|
new ReadonlyFunctionTokenEmulator(),
|
||
|
new PropertyTokenEmulator(),
|
||
|
new AsymmetricVisibilityTokenEmulator(),
|
||
|
];
|
||
|
|
||
|
// 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;
|
||
|
} elseif ($this->isReverseEmulationNeeded($emulatorPhpVersion)) {
|
||
|
$this->emulators[] = new ReverseEmulator($emulator);
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
public function tokenize(string $code, ?ErrorHandler $errorHandler = null): array {
|
||
|
$emulators = array_filter($this->emulators, function ($emulator) use ($code) {
|
||
|
return $emulator->isEmulationNeeded($code);
|
||
|
});
|
||
|
|
||
|
if (empty($emulators)) {
|
||
|
// Nothing to emulate, yay
|
||
|
return parent::tokenize($code, $errorHandler);
|
||
|
}
|
||
|
|
||
|
if ($errorHandler === null) {
|
||
|
$errorHandler = new ErrorHandler\Throwing();
|
||
|
}
|
||
|
|
||
|
$this->patches = [];
|
||
|
foreach ($emulators as $emulator) {
|
||
|
$code = $emulator->preprocessCode($code, $this->patches);
|
||
|
}
|
||
|
|
||
|
$collector = new ErrorHandler\Collecting();
|
||
|
$tokens = parent::tokenize($code, $collector);
|
||
|
$this->sortPatches();
|
||
|
$tokens = $this->fixupTokens($tokens);
|
||
|
|
||
|
$errors = $collector->getErrors();
|
||
|
if (!empty($errors)) {
|
||
|
$this->fixupErrors($errors);
|
||
|
foreach ($errors as $error) {
|
||
|
$errorHandler->handleError($error);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
foreach ($emulators as $emulator) {
|
||
|
$tokens = $emulator->emulate($code, $tokens);
|
||
|
}
|
||
|
|
||
|
return $tokens;
|
||
|
}
|
||
|
|
||
|
private function isForwardEmulationNeeded(PhpVersion $emulatorPhpVersion): bool {
|
||
|
return $this->hostPhpVersion->older($emulatorPhpVersion)
|
||
|
&& $this->targetPhpVersion->newerOrEqual($emulatorPhpVersion);
|
||
|
}
|
||
|
|
||
|
private function isReverseEmulationNeeded(PhpVersion $emulatorPhpVersion): bool {
|
||
|
return $this->hostPhpVersion->newerOrEqual($emulatorPhpVersion)
|
||
|
&& $this->targetPhpVersion->older($emulatorPhpVersion);
|
||
|
}
|
||
|
|
||
|
private function sortPatches(): void {
|
||
|
// 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];
|
||
|
});
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* @param list<Token> $tokens
|
||
|
* @return list<Token>
|
||
|
*/
|
||
|
private function fixupTokens(array $tokens): array {
|
||
|
if (\count($this->patches) === 0) {
|
||
|
return $tokens;
|
||
|
}
|
||
|
|
||
|
// 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
|
||
|
$posDelta = 0;
|
||
|
$lineDelta = 0;
|
||
|
for ($i = 0, $c = \count($tokens); $i < $c; $i++) {
|
||
|
$token = $tokens[$i];
|
||
|
$pos = $token->pos;
|
||
|
$token->pos += $posDelta;
|
||
|
$token->line += $lineDelta;
|
||
|
$localPosDelta = 0;
|
||
|
$len = \strlen($token->text);
|
||
|
while ($patchPos >= $pos && $patchPos < $pos + $len) {
|
||
|
$patchTextLen = \strlen($patchText);
|
||
|
if ($patchType === 'remove') {
|
||
|
if ($patchPos === $pos && $patchTextLen === $len) {
|
||
|
// Remove token entirely
|
||
|
array_splice($tokens, $i, 1, []);
|
||
|
$i--;
|
||
|
$c--;
|
||
|
} else {
|
||
|
// Remove from token string
|
||
|
$token->text = substr_replace(
|
||
|
$token->text, '', $patchPos - $pos + $localPosDelta, $patchTextLen
|
||
|
);
|
||
|
$localPosDelta -= $patchTextLen;
|
||
|
}
|
||
|
$lineDelta -= \substr_count($patchText, "\n");
|
||
|
} elseif ($patchType === 'add') {
|
||
|
// Insert into the token string
|
||
|
$token->text = substr_replace(
|
||
|
$token->text, $patchText, $patchPos - $pos + $localPosDelta, 0
|
||
|
);
|
||
|
$localPosDelta += $patchTextLen;
|
||
|
$lineDelta += \substr_count($patchText, "\n");
|
||
|
} elseif ($patchType === 'replace') {
|
||
|
// Replace inside the token string
|
||
|
$token->text = substr_replace(
|
||
|
$token->text, $patchText, $patchPos - $pos + $localPosDelta, $patchTextLen
|
||
|
);
|
||
|
} else {
|
||
|
assert(false);
|
||
|
}
|
||
|
|
||
|
// Fetch the next patch
|
||
|
$patchIdx++;
|
||
|
if ($patchIdx >= \count($this->patches)) {
|
||
|
// No more patches. However, we still need to adjust position.
|
||
|
$patchPos = \PHP_INT_MAX;
|
||
|
break;
|
||
|
}
|
||
|
|
||
|
list($patchPos, $patchType, $patchText) = $this->patches[$patchIdx];
|
||
|
}
|
||
|
|
||
|
$posDelta += $localPosDelta;
|
||
|
}
|
||
|
return $tokens;
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Fixup line and position information in errors.
|
||
|
*
|
||
|
* @param Error[] $errors
|
||
|
*/
|
||
|
private function fixupErrors(array $errors): void {
|
||
|
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");
|
||
|
} elseif ($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);
|
||
|
}
|
||
|
}
|
||
|
}
|