CodeCleaner.php 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360
  1. <?php
  2. /*
  3. * This file is part of Psy Shell.
  4. *
  5. * (c) 2012-2023 Justin Hileman
  6. *
  7. * For the full copyright and license information, please view the LICENSE
  8. * file that was distributed with this source code.
  9. */
  10. namespace Psy;
  11. use PhpParser\NodeTraverser;
  12. use PhpParser\Parser;
  13. use PhpParser\PrettyPrinter\Standard as Printer;
  14. use Psy\CodeCleaner\AbstractClassPass;
  15. use Psy\CodeCleaner\AssignThisVariablePass;
  16. use Psy\CodeCleaner\CalledClassPass;
  17. use Psy\CodeCleaner\CallTimePassByReferencePass;
  18. use Psy\CodeCleaner\CodeCleanerPass;
  19. use Psy\CodeCleaner\EmptyArrayDimFetchPass;
  20. use Psy\CodeCleaner\ExitPass;
  21. use Psy\CodeCleaner\FinalClassPass;
  22. use Psy\CodeCleaner\FunctionContextPass;
  23. use Psy\CodeCleaner\FunctionReturnInWriteContextPass;
  24. use Psy\CodeCleaner\ImplicitReturnPass;
  25. use Psy\CodeCleaner\IssetPass;
  26. use Psy\CodeCleaner\LabelContextPass;
  27. use Psy\CodeCleaner\LeavePsyshAlonePass;
  28. use Psy\CodeCleaner\ListPass;
  29. use Psy\CodeCleaner\LoopContextPass;
  30. use Psy\CodeCleaner\MagicConstantsPass;
  31. use Psy\CodeCleaner\NamespacePass;
  32. use Psy\CodeCleaner\PassableByReferencePass;
  33. use Psy\CodeCleaner\RequirePass;
  34. use Psy\CodeCleaner\ReturnTypePass;
  35. use Psy\CodeCleaner\StrictTypesPass;
  36. use Psy\CodeCleaner\UseStatementPass;
  37. use Psy\CodeCleaner\ValidClassNamePass;
  38. use Psy\CodeCleaner\ValidConstructorPass;
  39. use Psy\CodeCleaner\ValidFunctionNamePass;
  40. use Psy\Exception\ParseErrorException;
  41. /**
  42. * A service to clean up user input, detect parse errors before they happen,
  43. * and generally work around issues with the PHP code evaluation experience.
  44. */
  45. class CodeCleaner
  46. {
  47. private bool $yolo = false;
  48. private bool $strictTypes = false;
  49. private Parser $parser;
  50. private Printer $printer;
  51. private NodeTraverser $traverser;
  52. private ?array $namespace = null;
  53. /**
  54. * CodeCleaner constructor.
  55. *
  56. * @param Parser|null $parser A PhpParser Parser instance. One will be created if not explicitly supplied
  57. * @param Printer|null $printer A PhpParser Printer instance. One will be created if not explicitly supplied
  58. * @param NodeTraverser|null $traverser A PhpParser NodeTraverser instance. One will be created if not explicitly supplied
  59. * @param bool $yolo run without input validation
  60. * @param bool $strictTypes enforce strict types by default
  61. */
  62. public function __construct(?Parser $parser = null, ?Printer $printer = null, ?NodeTraverser $traverser = null, bool $yolo = false, bool $strictTypes = false)
  63. {
  64. $this->yolo = $yolo;
  65. $this->strictTypes = $strictTypes;
  66. $this->parser = $parser ?? (new ParserFactory())->createParser();
  67. $this->printer = $printer ?: new Printer();
  68. $this->traverser = $traverser ?: new NodeTraverser();
  69. foreach ($this->getDefaultPasses() as $pass) {
  70. $this->traverser->addVisitor($pass);
  71. }
  72. }
  73. /**
  74. * Check whether this CodeCleaner is in YOLO mode.
  75. */
  76. public function yolo(): bool
  77. {
  78. return $this->yolo;
  79. }
  80. /**
  81. * Get default CodeCleaner passes.
  82. *
  83. * @return CodeCleanerPass[]
  84. */
  85. private function getDefaultPasses(): array
  86. {
  87. $useStatementPass = new UseStatementPass();
  88. $namespacePass = new NamespacePass($this);
  89. // Try to add implicit `use` statements and an implicit namespace,
  90. // based on the file in which the `debug` call was made.
  91. $this->addImplicitDebugContext([$useStatementPass, $namespacePass]);
  92. // A set of code cleaner passes that don't try to do any validation, and
  93. // only do minimal rewriting to make things work inside the REPL.
  94. //
  95. // When in --yolo mode, these are the only code cleaner passes used.
  96. $rewritePasses = [
  97. new LeavePsyshAlonePass(),
  98. $useStatementPass, // must run before the namespace pass
  99. new ExitPass(),
  100. new ImplicitReturnPass(),
  101. new MagicConstantsPass(),
  102. $namespacePass, // must run after the implicit return pass
  103. new RequirePass(),
  104. new StrictTypesPass($this->strictTypes),
  105. ];
  106. if ($this->yolo) {
  107. return $rewritePasses;
  108. }
  109. return [
  110. // Validation passes
  111. new AbstractClassPass(),
  112. new AssignThisVariablePass(),
  113. new CalledClassPass(),
  114. new CallTimePassByReferencePass(),
  115. new FinalClassPass(),
  116. new FunctionContextPass(),
  117. new FunctionReturnInWriteContextPass(),
  118. new IssetPass(),
  119. new LabelContextPass(),
  120. new ListPass(),
  121. new LoopContextPass(),
  122. new PassableByReferencePass(),
  123. new ReturnTypePass(),
  124. new EmptyArrayDimFetchPass(),
  125. new ValidConstructorPass(),
  126. // Rewriting shenanigans
  127. ...$rewritePasses,
  128. // Namespace-aware validation (which depends on aforementioned shenanigans)
  129. new ValidClassNamePass(),
  130. new ValidFunctionNamePass(),
  131. ];
  132. }
  133. /**
  134. * "Warm up" code cleaner passes when we're coming from a debug call.
  135. *
  136. * This is useful, for example, for `UseStatementPass` and `NamespacePass`
  137. * which keep track of state between calls, to maintain the current
  138. * namespace and a map of use statements.
  139. *
  140. * @param array $passes
  141. */
  142. private function addImplicitDebugContext(array $passes)
  143. {
  144. $file = $this->getDebugFile();
  145. if ($file === null) {
  146. return;
  147. }
  148. try {
  149. $code = @\file_get_contents($file);
  150. if (!$code) {
  151. return;
  152. }
  153. $stmts = $this->parse($code, true);
  154. if ($stmts === false) {
  155. return;
  156. }
  157. // Set up a clean traverser for just these code cleaner passes
  158. // @todo Pass visitors directly to once we drop support for PHP-Parser 4.x
  159. $traverser = new NodeTraverser();
  160. foreach ($passes as $pass) {
  161. $traverser->addVisitor($pass);
  162. }
  163. $traverser->traverse($stmts);
  164. } catch (\Throwable $e) {
  165. // Don't care.
  166. }
  167. }
  168. /**
  169. * Search the stack trace for a file in which the user called Psy\debug.
  170. *
  171. * @return string|null
  172. */
  173. private static function getDebugFile()
  174. {
  175. $trace = \debug_backtrace(\DEBUG_BACKTRACE_IGNORE_ARGS);
  176. foreach (\array_reverse($trace) as $stackFrame) {
  177. if (!self::isDebugCall($stackFrame)) {
  178. continue;
  179. }
  180. if (\preg_match('/eval\(/', $stackFrame['file'])) {
  181. \preg_match_all('/([^\(]+)\((\d+)/', $stackFrame['file'], $matches);
  182. return $matches[1][0];
  183. }
  184. return $stackFrame['file'];
  185. }
  186. }
  187. /**
  188. * Check whether a given backtrace frame is a call to Psy\debug.
  189. *
  190. * @param array $stackFrame
  191. */
  192. private static function isDebugCall(array $stackFrame): bool
  193. {
  194. $class = isset($stackFrame['class']) ? $stackFrame['class'] : null;
  195. $function = isset($stackFrame['function']) ? $stackFrame['function'] : null;
  196. return ($class === null && $function === 'Psy\\debug') ||
  197. ($class === Shell::class && $function === 'debug');
  198. }
  199. /**
  200. * Clean the given array of code.
  201. *
  202. * @throws ParseErrorException if the code is invalid PHP, and cannot be coerced into valid PHP
  203. *
  204. * @param array $codeLines
  205. * @param bool $requireSemicolons
  206. *
  207. * @return string|false Cleaned PHP code, False if the input is incomplete
  208. */
  209. public function clean(array $codeLines, bool $requireSemicolons = false)
  210. {
  211. $stmts = $this->parse('<?php '.\implode(\PHP_EOL, $codeLines).\PHP_EOL, $requireSemicolons);
  212. if ($stmts === false) {
  213. return false;
  214. }
  215. // Catch fatal errors before they happen
  216. $stmts = $this->traverser->traverse($stmts);
  217. // Work around https://github.com/nikic/PHP-Parser/issues/399
  218. $oldLocale = \setlocale(\LC_NUMERIC, 0);
  219. \setlocale(\LC_NUMERIC, 'C');
  220. $code = $this->printer->prettyPrint($stmts);
  221. // Now put the locale back
  222. \setlocale(\LC_NUMERIC, $oldLocale);
  223. return $code;
  224. }
  225. /**
  226. * Set the current local namespace.
  227. */
  228. public function setNamespace(?array $namespace = null)
  229. {
  230. $this->namespace = $namespace;
  231. }
  232. /**
  233. * Get the current local namespace.
  234. *
  235. * @return array|null
  236. */
  237. public function getNamespace()
  238. {
  239. return $this->namespace;
  240. }
  241. /**
  242. * Lex and parse a block of code.
  243. *
  244. * @see Parser::parse
  245. *
  246. * @throws ParseErrorException for parse errors that can't be resolved by
  247. * waiting a line to see what comes next
  248. *
  249. * @return array|false A set of statements, or false if incomplete
  250. */
  251. protected function parse(string $code, bool $requireSemicolons = false)
  252. {
  253. try {
  254. return $this->parser->parse($code);
  255. } catch (\PhpParser\Error $e) {
  256. if ($this->parseErrorIsUnclosedString($e, $code)) {
  257. return false;
  258. }
  259. if ($this->parseErrorIsUnterminatedComment($e, $code)) {
  260. return false;
  261. }
  262. if ($this->parseErrorIsTrailingComma($e, $code)) {
  263. return false;
  264. }
  265. if (!$this->parseErrorIsEOF($e)) {
  266. throw ParseErrorException::fromParseError($e);
  267. }
  268. if ($requireSemicolons) {
  269. return false;
  270. }
  271. try {
  272. // Unexpected EOF, try again with an implicit semicolon
  273. return $this->parser->parse($code.';');
  274. } catch (\PhpParser\Error $e) {
  275. return false;
  276. }
  277. }
  278. }
  279. private function parseErrorIsEOF(\PhpParser\Error $e): bool
  280. {
  281. $msg = $e->getRawMessage();
  282. return ($msg === 'Unexpected token EOF') || (\strpos($msg, 'Syntax error, unexpected EOF') !== false);
  283. }
  284. /**
  285. * A special test for unclosed single-quoted strings.
  286. *
  287. * Unlike (all?) other unclosed statements, single quoted strings have
  288. * their own special beautiful snowflake syntax error just for
  289. * themselves.
  290. */
  291. private function parseErrorIsUnclosedString(\PhpParser\Error $e, string $code): bool
  292. {
  293. if ($e->getRawMessage() !== 'Syntax error, unexpected T_ENCAPSED_AND_WHITESPACE') {
  294. return false;
  295. }
  296. try {
  297. $this->parser->parse($code."';");
  298. } catch (\Throwable $e) {
  299. return false;
  300. }
  301. return true;
  302. }
  303. private function parseErrorIsUnterminatedComment(\PhpParser\Error $e, string $code): bool
  304. {
  305. return $e->getRawMessage() === 'Unterminated comment';
  306. }
  307. private function parseErrorIsTrailingComma(\PhpParser\Error $e, string $code): bool
  308. {
  309. return ($e->getRawMessage() === 'A trailing comma is not allowed here') && (\substr(\rtrim($code), -1) === ',');
  310. }
  311. }