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