1: <?php declare(strict_types=1);
2:
3: namespace PhpParser\Lexer;
4:
5: use PhpParser\Error;
6: use PhpParser\ErrorHandler;
7: use PhpParser\Lexer;
8: use PhpParser\Lexer\TokenEmulator\AsymmetricVisibilityTokenEmulator;
9: use PhpParser\Lexer\TokenEmulator\AttributeEmulator;
10: use PhpParser\Lexer\TokenEmulator\EnumTokenEmulator;
11: use PhpParser\Lexer\TokenEmulator\ExplicitOctalEmulator;
12: use PhpParser\Lexer\TokenEmulator\FnTokenEmulator;
13: use PhpParser\Lexer\TokenEmulator\MatchTokenEmulator;
14: use PhpParser\Lexer\TokenEmulator\NullsafeTokenEmulator;
15: use PhpParser\Lexer\TokenEmulator\PipeOperatorEmulator;
16: use PhpParser\Lexer\TokenEmulator\PropertyTokenEmulator;
17: use PhpParser\Lexer\TokenEmulator\ReadonlyFunctionTokenEmulator;
18: use PhpParser\Lexer\TokenEmulator\ReadonlyTokenEmulator;
19: use PhpParser\Lexer\TokenEmulator\ReverseEmulator;
20: use PhpParser\Lexer\TokenEmulator\TokenEmulator;
21: use PhpParser\Lexer\TokenEmulator\VoidCastEmulator;
22: use PhpParser\PhpVersion;
23: use PhpParser\Token;
24:
25: class Emulative extends Lexer {
26: /** @var array{int, string, string}[] Patches used to reverse changes introduced in the code */
27: private array $patches = [];
28:
29: /** @var list<TokenEmulator> */
30: private array $emulators = [];
31:
32: private PhpVersion $targetPhpVersion;
33:
34: private PhpVersion $hostPhpVersion;
35:
36: /**
37: * @param PhpVersion|null $phpVersion PHP version to emulate. Defaults to newest supported.
38: */
39: public function __construct(?PhpVersion $phpVersion = null) {
40: $this->targetPhpVersion = $phpVersion ?? PhpVersion::getNewestSupported();
41: $this->hostPhpVersion = PhpVersion::getHostVersion();
42:
43: $emulators = [
44: new FnTokenEmulator(),
45: new MatchTokenEmulator(),
46: new NullsafeTokenEmulator(),
47: new AttributeEmulator(),
48: new EnumTokenEmulator(),
49: new ReadonlyTokenEmulator(),
50: new ExplicitOctalEmulator(),
51: new ReadonlyFunctionTokenEmulator(),
52: new PropertyTokenEmulator(),
53: new AsymmetricVisibilityTokenEmulator(),
54: new PipeOperatorEmulator(),
55: new VoidCastEmulator(),
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: } elseif ($this->isReverseEmulationNeeded($emulatorPhpVersion)) {
65: $this->emulators[] = new ReverseEmulator($emulator);
66: }
67: }
68: }
69:
70: public function tokenize(string $code, ?ErrorHandler $errorHandler = null): array {
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: return parent::tokenize($code, $errorHandler);
78: }
79:
80: if ($errorHandler === null) {
81: $errorHandler = new ErrorHandler\Throwing();
82: }
83:
84: $this->patches = [];
85: foreach ($emulators as $emulator) {
86: $code = $emulator->preprocessCode($code, $this->patches);
87: }
88:
89: $collector = new ErrorHandler\Collecting();
90: $tokens = parent::tokenize($code, $collector);
91: $this->sortPatches();
92: $tokens = $this->fixupTokens($tokens);
93:
94: $errors = $collector->getErrors();
95: if (!empty($errors)) {
96: $this->fixupErrors($errors);
97: foreach ($errors as $error) {
98: $errorHandler->handleError($error);
99: }
100: }
101:
102: foreach ($emulators as $emulator) {
103: $tokens = $emulator->emulate($code, $tokens);
104: }
105:
106: return $tokens;
107: }
108:
109: private function isForwardEmulationNeeded(PhpVersion $emulatorPhpVersion): bool {
110: return $this->hostPhpVersion->older($emulatorPhpVersion)
111: && $this->targetPhpVersion->newerOrEqual($emulatorPhpVersion);
112: }
113:
114: private function isReverseEmulationNeeded(PhpVersion $emulatorPhpVersion): bool {
115: return $this->hostPhpVersion->newerOrEqual($emulatorPhpVersion)
116: && $this->targetPhpVersion->older($emulatorPhpVersion);
117: }
118:
119: private function sortPatches(): void {
120: // Patches may be contributed by different emulators.
121: // Make sure they are sorted by increasing patch position.
122: usort($this->patches, function ($p1, $p2) {
123: return $p1[0] <=> $p2[0];
124: });
125: }
126:
127: /**
128: * @param list<Token> $tokens
129: * @return list<Token>
130: */
131: private function fixupTokens(array $tokens): array {
132: if (\count($this->patches) === 0) {
133: return $tokens;
134: }
135:
136: // Load first patch
137: $patchIdx = 0;
138: list($patchPos, $patchType, $patchText) = $this->patches[$patchIdx];
139:
140: // We use a manual loop over the tokens, because we modify the array on the fly
141: $posDelta = 0;
142: $lineDelta = 0;
143: for ($i = 0, $c = \count($tokens); $i < $c; $i++) {
144: $token = $tokens[$i];
145: $pos = $token->pos;
146: $token->pos += $posDelta;
147: $token->line += $lineDelta;
148: $localPosDelta = 0;
149: $len = \strlen($token->text);
150: while ($patchPos >= $pos && $patchPos < $pos + $len) {
151: $patchTextLen = \strlen($patchText);
152: if ($patchType === 'remove') {
153: if ($patchPos === $pos && $patchTextLen === $len) {
154: // Remove token entirely
155: array_splice($tokens, $i, 1, []);
156: $i--;
157: $c--;
158: } else {
159: // Remove from token string
160: $token->text = substr_replace(
161: $token->text, '', $patchPos - $pos + $localPosDelta, $patchTextLen
162: );
163: $localPosDelta -= $patchTextLen;
164: }
165: $lineDelta -= \substr_count($patchText, "\n");
166: } elseif ($patchType === 'add') {
167: // Insert into the token string
168: $token->text = substr_replace(
169: $token->text, $patchText, $patchPos - $pos + $localPosDelta, 0
170: );
171: $localPosDelta += $patchTextLen;
172: $lineDelta += \substr_count($patchText, "\n");
173: } elseif ($patchType === 'replace') {
174: // Replace inside the token string
175: $token->text = substr_replace(
176: $token->text, $patchText, $patchPos - $pos + $localPosDelta, $patchTextLen
177: );
178: } else {
179: assert(false);
180: }
181:
182: // Fetch the next patch
183: $patchIdx++;
184: if ($patchIdx >= \count($this->patches)) {
185: // No more patches. However, we still need to adjust position.
186: $patchPos = \PHP_INT_MAX;
187: break;
188: }
189:
190: list($patchPos, $patchType, $patchText) = $this->patches[$patchIdx];
191: }
192:
193: $posDelta += $localPosDelta;
194: }
195: return $tokens;
196: }
197:
198: /**
199: * Fixup line and position information in errors.
200: *
201: * @param Error[] $errors
202: */
203: private function fixupErrors(array $errors): void {
204: foreach ($errors as $error) {
205: $attrs = $error->getAttributes();
206:
207: $posDelta = 0;
208: $lineDelta = 0;
209: foreach ($this->patches as $patch) {
210: list($patchPos, $patchType, $patchText) = $patch;
211: if ($patchPos >= $attrs['startFilePos']) {
212: // No longer relevant
213: break;
214: }
215:
216: if ($patchType === 'add') {
217: $posDelta += strlen($patchText);
218: $lineDelta += substr_count($patchText, "\n");
219: } elseif ($patchType === 'remove') {
220: $posDelta -= strlen($patchText);
221: $lineDelta -= substr_count($patchText, "\n");
222: }
223: }
224:
225: $attrs['startFilePos'] += $posDelta;
226: $attrs['endFilePos'] += $posDelta;
227: $attrs['startLine'] += $lineDelta;
228: $attrs['endLine'] += $lineDelta;
229: $error->setAttributes($attrs);
230: }
231: }
232: }
233: