You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

246 lines
8.6 KiB

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