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