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