CodeCleaner.php 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390
  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 $yolo = false;
  48. private $strictTypes = false;
  49. private $parser;
  50. private $printer;
  51. private $traverser;
  52. private $namespace;
  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. if ($this->yolo) {
  88. return $this->getYoloPasses();
  89. }
  90. $useStatementPass = new UseStatementPass();
  91. $namespacePass = new NamespacePass($this);
  92. // Try to add implicit `use` statements and an implicit namespace,
  93. // based on the file in which the `debug` call was made.
  94. $this->addImplicitDebugContext([$useStatementPass, $namespacePass]);
  95. return [
  96. // Validation passes
  97. new AbstractClassPass(),
  98. new AssignThisVariablePass(),
  99. new CalledClassPass(),
  100. new CallTimePassByReferencePass(),
  101. new FinalClassPass(),
  102. new FunctionContextPass(),
  103. new FunctionReturnInWriteContextPass(),
  104. new IssetPass(),
  105. new LabelContextPass(),
  106. new LeavePsyshAlonePass(),
  107. new ListPass(),
  108. new LoopContextPass(),
  109. new PassableByReferencePass(),
  110. new ReturnTypePass(),
  111. new EmptyArrayDimFetchPass(),
  112. new ValidConstructorPass(),
  113. // Rewriting shenanigans
  114. $useStatementPass, // must run before the namespace pass
  115. new ExitPass(),
  116. new ImplicitReturnPass(),
  117. new MagicConstantsPass(),
  118. $namespacePass, // must run after the implicit return pass
  119. new RequirePass(),
  120. new StrictTypesPass($this->strictTypes),
  121. // Namespace-aware validation (which depends on aforementioned shenanigans)
  122. new ValidClassNamePass(),
  123. new ValidFunctionNamePass(),
  124. ];
  125. }
  126. /**
  127. * A set of code cleaner passes that don't try to do any validation, and
  128. * only do minimal rewriting to make things work inside the REPL.
  129. *
  130. * This list should stay in sync with the "rewriting shenanigans" in
  131. * getDefaultPasses above.
  132. *
  133. * @return CodeCleanerPass[]
  134. */
  135. private function getYoloPasses(): array
  136. {
  137. $useStatementPass = new UseStatementPass();
  138. $namespacePass = new NamespacePass($this);
  139. // Try to add implicit `use` statements and an implicit namespace,
  140. // based on the file in which the `debug` call was made.
  141. $this->addImplicitDebugContext([$useStatementPass, $namespacePass]);
  142. return [
  143. new LeavePsyshAlonePass(),
  144. $useStatementPass, // must run before the namespace pass
  145. new ExitPass(),
  146. new ImplicitReturnPass(),
  147. new MagicConstantsPass(),
  148. $namespacePass, // must run after the implicit return pass
  149. new RequirePass(),
  150. new StrictTypesPass($this->strictTypes),
  151. ];
  152. }
  153. /**
  154. * "Warm up" code cleaner passes when we're coming from a debug call.
  155. *
  156. * This is useful, for example, for `UseStatementPass` and `NamespacePass`
  157. * which keep track of state between calls, to maintain the current
  158. * namespace and a map of use statements.
  159. *
  160. * @param array $passes
  161. */
  162. private function addImplicitDebugContext(array $passes)
  163. {
  164. $file = $this->getDebugFile();
  165. if ($file === null) {
  166. return;
  167. }
  168. try {
  169. $code = @\file_get_contents($file);
  170. if (!$code) {
  171. return;
  172. }
  173. $stmts = $this->parse($code, true);
  174. if ($stmts === false) {
  175. return;
  176. }
  177. // Set up a clean traverser for just these code cleaner passes
  178. // @todo Pass visitors directly to once we drop support for PHP-Parser 4.x
  179. $traverser = new NodeTraverser();
  180. foreach ($passes as $pass) {
  181. $traverser->addVisitor($pass);
  182. }
  183. $traverser->traverse($stmts);
  184. } catch (\Throwable $e) {
  185. // Don't care.
  186. }
  187. }
  188. /**
  189. * Search the stack trace for a file in which the user called Psy\debug.
  190. *
  191. * @return string|null
  192. */
  193. private static function getDebugFile()
  194. {
  195. $trace = \debug_backtrace(\DEBUG_BACKTRACE_IGNORE_ARGS);
  196. foreach (\array_reverse($trace) as $stackFrame) {
  197. if (!self::isDebugCall($stackFrame)) {
  198. continue;
  199. }
  200. if (\preg_match('/eval\(/', $stackFrame['file'])) {
  201. \preg_match_all('/([^\(]+)\((\d+)/', $stackFrame['file'], $matches);
  202. return $matches[1][0];
  203. }
  204. return $stackFrame['file'];
  205. }
  206. }
  207. /**
  208. * Check whether a given backtrace frame is a call to Psy\debug.
  209. *
  210. * @param array $stackFrame
  211. */
  212. private static function isDebugCall(array $stackFrame): bool
  213. {
  214. $class = isset($stackFrame['class']) ? $stackFrame['class'] : null;
  215. $function = isset($stackFrame['function']) ? $stackFrame['function'] : null;
  216. return ($class === null && $function === 'Psy\\debug') ||
  217. ($class === Shell::class && $function === 'debug');
  218. }
  219. /**
  220. * Clean the given array of code.
  221. *
  222. * @throws ParseErrorException if the code is invalid PHP, and cannot be coerced into valid PHP
  223. *
  224. * @param array $codeLines
  225. * @param bool $requireSemicolons
  226. *
  227. * @return string|false Cleaned PHP code, False if the input is incomplete
  228. */
  229. public function clean(array $codeLines, bool $requireSemicolons = false)
  230. {
  231. $stmts = $this->parse('<?php '.\implode(\PHP_EOL, $codeLines).\PHP_EOL, $requireSemicolons);
  232. if ($stmts === false) {
  233. return false;
  234. }
  235. // Catch fatal errors before they happen
  236. $stmts = $this->traverser->traverse($stmts);
  237. // Work around https://github.com/nikic/PHP-Parser/issues/399
  238. $oldLocale = \setlocale(\LC_NUMERIC, 0);
  239. \setlocale(\LC_NUMERIC, 'C');
  240. $code = $this->printer->prettyPrint($stmts);
  241. // Now put the locale back
  242. \setlocale(\LC_NUMERIC, $oldLocale);
  243. return $code;
  244. }
  245. /**
  246. * Set the current local namespace.
  247. *
  248. * @param array|null $namespace (default: null)
  249. */
  250. public function setNamespace(?array $namespace = null)
  251. {
  252. $this->namespace = $namespace;
  253. }
  254. /**
  255. * Get the current local namespace.
  256. *
  257. * @return array|null
  258. */
  259. public function getNamespace()
  260. {
  261. return $this->namespace;
  262. }
  263. /**
  264. * Lex and parse a block of code.
  265. *
  266. * @see Parser::parse
  267. *
  268. * @throws ParseErrorException for parse errors that can't be resolved by
  269. * waiting a line to see what comes next
  270. *
  271. * @param string $code
  272. * @param bool $requireSemicolons
  273. *
  274. * @return array|false A set of statements, or false if incomplete
  275. */
  276. protected function parse(string $code, bool $requireSemicolons = false)
  277. {
  278. try {
  279. return $this->parser->parse($code);
  280. } catch (\PhpParser\Error $e) {
  281. if ($this->parseErrorIsUnclosedString($e, $code)) {
  282. return false;
  283. }
  284. if ($this->parseErrorIsUnterminatedComment($e, $code)) {
  285. return false;
  286. }
  287. if ($this->parseErrorIsTrailingComma($e, $code)) {
  288. return false;
  289. }
  290. if (!$this->parseErrorIsEOF($e)) {
  291. throw ParseErrorException::fromParseError($e);
  292. }
  293. if ($requireSemicolons) {
  294. return false;
  295. }
  296. try {
  297. // Unexpected EOF, try again with an implicit semicolon
  298. return $this->parser->parse($code.';');
  299. } catch (\PhpParser\Error $e) {
  300. return false;
  301. }
  302. }
  303. }
  304. private function parseErrorIsEOF(\PhpParser\Error $e): bool
  305. {
  306. $msg = $e->getRawMessage();
  307. return ($msg === 'Unexpected token EOF') || (\strpos($msg, 'Syntax error, unexpected EOF') !== false);
  308. }
  309. /**
  310. * A special test for unclosed single-quoted strings.
  311. *
  312. * Unlike (all?) other unclosed statements, single quoted strings have
  313. * their own special beautiful snowflake syntax error just for
  314. * themselves.
  315. *
  316. * @param \PhpParser\Error $e
  317. * @param string $code
  318. */
  319. private function parseErrorIsUnclosedString(\PhpParser\Error $e, string $code): bool
  320. {
  321. if ($e->getRawMessage() !== 'Syntax error, unexpected T_ENCAPSED_AND_WHITESPACE') {
  322. return false;
  323. }
  324. try {
  325. $this->parser->parse($code."';");
  326. } catch (\Throwable $e) {
  327. return false;
  328. }
  329. return true;
  330. }
  331. private function parseErrorIsUnterminatedComment(\PhpParser\Error $e, $code): bool
  332. {
  333. return $e->getRawMessage() === 'Unterminated comment';
  334. }
  335. private function parseErrorIsTrailingComma(\PhpParser\Error $e, $code): bool
  336. {
  337. return ($e->getRawMessage() === 'A trailing comma is not allowed here') && (\substr(\rtrim($code), -1) === ',');
  338. }
  339. }