4 * This file is part of Psy Shell.
6 * (c) 2012-2018 Justin Hileman
8 * For the full copyright and license information, please view the LICENSE
9 * file that was distributed with this source code.
14 use PhpParser\NodeTraverser;
16 use PhpParser\PrettyPrinter\Standard as Printer;
17 use Psy\CodeCleaner\AbstractClassPass;
18 use Psy\CodeCleaner\AssignThisVariablePass;
19 use Psy\CodeCleaner\CalledClassPass;
20 use Psy\CodeCleaner\CallTimePassByReferencePass;
21 use Psy\CodeCleaner\ExitPass;
22 use Psy\CodeCleaner\FinalClassPass;
23 use Psy\CodeCleaner\FunctionContextPass;
24 use Psy\CodeCleaner\FunctionReturnInWriteContextPass;
25 use Psy\CodeCleaner\ImplicitReturnPass;
26 use Psy\CodeCleaner\InstanceOfPass;
27 use Psy\CodeCleaner\LeavePsyshAlonePass;
28 use Psy\CodeCleaner\LegacyEmptyPass;
29 use Psy\CodeCleaner\ListPass;
30 use Psy\CodeCleaner\LoopContextPass;
31 use Psy\CodeCleaner\MagicConstantsPass;
32 use Psy\CodeCleaner\NamespacePass;
33 use Psy\CodeCleaner\PassableByReferencePass;
34 use Psy\CodeCleaner\RequirePass;
35 use Psy\CodeCleaner\StrictTypesPass;
36 use Psy\CodeCleaner\UseStatementPass;
37 use Psy\CodeCleaner\ValidClassNamePass;
38 use Psy\CodeCleaner\ValidConstantPass;
39 use Psy\CodeCleaner\ValidConstructorPass;
40 use Psy\CodeCleaner\ValidFunctionNamePass;
41 use Psy\Exception\ParseErrorException;
44 * A service to clean up user input, detect parse errors before they happen,
45 * and generally work around issues with the PHP code evaluation experience.
55 * CodeCleaner constructor.
57 * @param Parser $parser A PhpParser Parser instance. One will be created if not explicitly supplied
58 * @param Printer $printer A PhpParser Printer instance. One will be created if not explicitly supplied
59 * @param NodeTraverser $traverser A PhpParser NodeTraverser instance. One will be created if not explicitly supplied
61 public function __construct(Parser $parser = null, Printer $printer = null, NodeTraverser $traverser = null)
63 if ($parser === null) {
64 $parserFactory = new ParserFactory();
65 $parser = $parserFactory->createParser();
68 $this->parser = $parser;
69 $this->printer = $printer ?: new Printer();
70 $this->traverser = $traverser ?: new NodeTraverser();
72 foreach ($this->getDefaultPasses() as $pass) {
73 $this->traverser->addVisitor($pass);
78 * Get default CodeCleaner passes.
82 private function getDefaultPasses()
84 $useStatementPass = new UseStatementPass();
85 $namespacePass = new NamespacePass($this);
87 // Try to add implicit `use` statements and an implicit namespace,
88 // based on the file in which the `debug` call was made.
89 $this->addImplicitDebugContext([$useStatementPass, $namespacePass]);
93 new AbstractClassPass(),
94 new AssignThisVariablePass(),
95 new CalledClassPass(),
96 new CallTimePassByReferencePass(),
98 new FunctionContextPass(),
99 new FunctionReturnInWriteContextPass(),
100 new InstanceOfPass(),
101 new LeavePsyshAlonePass(),
102 new LegacyEmptyPass(),
104 new LoopContextPass(),
105 new PassableByReferencePass(),
106 new ValidConstructorPass(),
108 // Rewriting shenanigans
109 $useStatementPass, // must run before the namespace pass
111 new ImplicitReturnPass(),
112 new MagicConstantsPass(),
113 $namespacePass, // must run after the implicit return pass
115 new StrictTypesPass(),
117 // Namespace-aware validation (which depends on aforementioned shenanigans)
118 new ValidClassNamePass(),
119 new ValidConstantPass(),
120 new ValidFunctionNamePass(),
125 * "Warm up" code cleaner passes when we're coming from a debug call.
127 * This is useful, for example, for `UseStatementPass` and `NamespacePass`
128 * which keep track of state between calls, to maintain the current
129 * namespace and a map of use statements.
131 * @param array $passes
133 private function addImplicitDebugContext(array $passes)
135 $file = $this->getDebugFile();
136 if ($file === null) {
141 $code = @file_get_contents($file);
146 $stmts = $this->parse($code, true);
147 if ($stmts === false) {
151 // Set up a clean traverser for just these code cleaner passes
152 $traverser = new NodeTraverser();
153 foreach ($passes as $pass) {
154 $traverser->addVisitor($pass);
157 $traverser->traverse($stmts);
158 } catch (\Throwable $e) {
160 } catch (\Exception $e) {
166 * Search the stack trace for a file in which the user called Psy\debug.
168 * @return string|null
170 private static function getDebugFile()
172 $trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS);
174 foreach (array_reverse($trace) as $stackFrame) {
175 if (!self::isDebugCall($stackFrame)) {
179 if (preg_match('/eval\(/', $stackFrame['file'])) {
180 preg_match_all('/([^\(]+)\((\d+)/', $stackFrame['file'], $matches);
182 return $matches[1][0];
185 return $stackFrame['file'];
190 * Check whether a given backtrace frame is a call to Psy\debug.
192 * @param array $stackFrame
196 private static function isDebugCall(array $stackFrame)
198 $class = isset($stackFrame['class']) ? $stackFrame['class'] : null;
199 $function = isset($stackFrame['function']) ? $stackFrame['function'] : null;
201 return ($class === null && $function === 'Psy\debug') ||
202 ($class === 'Psy\Shell' && $function === 'debug');
206 * Clean the given array of code.
208 * @throws ParseErrorException if the code is invalid PHP, and cannot be coerced into valid PHP
210 * @param array $codeLines
211 * @param bool $requireSemicolons
213 * @return string|false Cleaned PHP code, False if the input is incomplete
215 public function clean(array $codeLines, $requireSemicolons = false)
217 $stmts = $this->parse('<?php ' . implode(PHP_EOL, $codeLines) . PHP_EOL, $requireSemicolons);
218 if ($stmts === false) {
222 // Catch fatal errors before they happen
223 $stmts = $this->traverser->traverse($stmts);
225 // Work around https://github.com/nikic/PHP-Parser/issues/399
226 $oldLocale = setlocale(LC_NUMERIC, 0);
227 setlocale(LC_NUMERIC, 'C');
229 $code = $this->printer->prettyPrint($stmts);
231 // Now put the locale back
232 setlocale(LC_NUMERIC, $oldLocale);
238 * Set the current local namespace.
240 * @param null|array $namespace (default: null)
244 public function setNamespace(array $namespace = null)
246 $this->namespace = $namespace;
250 * Get the current local namespace.
254 public function getNamespace()
256 return $this->namespace;
260 * Lex and parse a block of code.
264 * @throws ParseErrorException for parse errors that can't be resolved by
265 * waiting a line to see what comes next
267 * @param string $code
268 * @param bool $requireSemicolons
270 * @return array|false A set of statements, or false if incomplete
272 protected function parse($code, $requireSemicolons = false)
275 return $this->parser->parse($code);
276 } catch (\PhpParser\Error $e) {
277 if ($this->parseErrorIsUnclosedString($e, $code)) {
281 if ($this->parseErrorIsUnterminatedComment($e, $code)) {
285 if ($this->parseErrorIsTrailingComma($e, $code)) {
289 if (!$this->parseErrorIsEOF($e)) {
290 throw ParseErrorException::fromParseError($e);
293 if ($requireSemicolons) {
298 // Unexpected EOF, try again with an implicit semicolon
299 return $this->parser->parse($code . ';');
300 } catch (\PhpParser\Error $e) {
306 private function parseErrorIsEOF(\PhpParser\Error $e)
308 $msg = $e->getRawMessage();
310 return ($msg === 'Unexpected token EOF') || (strpos($msg, 'Syntax error, unexpected EOF') !== false);
314 * A special test for unclosed single-quoted strings.
316 * Unlike (all?) other unclosed statements, single quoted strings have
317 * their own special beautiful snowflake syntax error just for
320 * @param \PhpParser\Error $e
321 * @param string $code
325 private function parseErrorIsUnclosedString(\PhpParser\Error $e, $code)
327 if ($e->getRawMessage() !== 'Syntax error, unexpected T_ENCAPSED_AND_WHITESPACE') {
332 $this->parser->parse($code . "';");
333 } catch (\Exception $e) {
340 private function parseErrorIsUnterminatedComment(\PhpParser\Error $e, $code)
342 return $e->getRawMessage() === 'Unterminated comment';
345 private function parseErrorIsTrailingComma(\PhpParser\Error $e, $code)
347 return ($e->getRawMessage() === 'A trailing comma is not allowed here') && (substr(rtrim($code), -1) === ',');