No Description

CodeCleaner.php 10KB

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