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\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\StrictTypesPass;
35 use Psy\CodeCleaner\UseStatementPass;
36 use Psy\CodeCleaner\ValidClassNamePass;
37 use Psy\CodeCleaner\ValidConstantPass;
38 use Psy\CodeCleaner\ValidConstructorPass;
39 use Psy\CodeCleaner\ValidFunctionNamePass;
40 use Psy\Exception\ParseErrorException;
43 * A service to clean up user input, detect parse errors before they happen,
44 * and generally work around issues with the PHP code evaluation experience.
54 * CodeCleaner constructor.
56 * @param Parser $parser A PhpParser Parser instance. One will be created if not explicitly supplied
57 * @param Printer $printer A PhpParser Printer instance. One will be created if not explicitly supplied
58 * @param NodeTraverser $traverser A PhpParser NodeTraverser instance. One will be created if not explicitly supplied
60 public function __construct(Parser $parser = null, Printer $printer = null, NodeTraverser $traverser = null)
62 if ($parser === null) {
63 $parserFactory = new ParserFactory();
64 $parser = $parserFactory->createParser();
67 $this->parser = $parser;
68 $this->printer = $printer ?: new Printer();
69 $this->traverser = $traverser ?: new NodeTraverser();
71 foreach ($this->getDefaultPasses() as $pass) {
72 $this->traverser->addVisitor($pass);
77 * Get default CodeCleaner passes.
81 private function getDefaultPasses()
83 $useStatementPass = new UseStatementPass();
84 $namespacePass = new NamespacePass($this);
86 // Try to add implicit `use` statements and an implicit namespace,
87 // based on the file in which the `debug` call was made.
88 $this->addImplicitDebugContext([$useStatementPass, $namespacePass]);
92 new AbstractClassPass(),
93 new AssignThisVariablePass(),
94 new CalledClassPass(),
95 new CallTimePassByReferencePass(),
97 new FunctionContextPass(),
98 new FunctionReturnInWriteContextPass(),
100 new LeavePsyshAlonePass(),
101 new LegacyEmptyPass(),
102 new LoopContextPass(),
103 new PassableByReferencePass(),
104 new ValidConstructorPass(),
106 // Rewriting shenanigans
107 $useStatementPass, // must run before the namespace pass
109 new ImplicitReturnPass(),
110 new MagicConstantsPass(),
111 $namespacePass, // must run after the implicit return pass
113 new StrictTypesPass(),
115 // Namespace-aware validation (which depends on aforementioned shenanigans)
116 new ValidClassNamePass(),
117 new ValidConstantPass(),
118 new ValidFunctionNamePass(),
123 * "Warm up" code cleaner passes when we're coming from a debug call.
125 * This is useful, for example, for `UseStatementPass` and `NamespacePass`
126 * which keep track of state between calls, to maintain the current
127 * namespace and a map of use statements.
129 * @param array $passes
131 private function addImplicitDebugContext(array $passes)
133 $file = $this->getDebugFile();
134 if ($file === null) {
139 $code = @file_get_contents($file);
144 $stmts = $this->parse($code, true);
145 if ($stmts === false) {
149 // Set up a clean traverser for just these code cleaner passes
150 $traverser = new NodeTraverser();
151 foreach ($passes as $pass) {
152 $traverser->addVisitor($pass);
155 $traverser->traverse($stmts);
156 } catch (\Throwable $e) {
158 } catch (\Exception $e) {
164 * Search the stack trace for a file in which the user called Psy\debug.
166 * @return string|null
168 private static function getDebugFile()
170 $trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS);
172 foreach (array_reverse($trace) as $stackFrame) {
173 if (!self::isDebugCall($stackFrame)) {
177 if (preg_match('/eval\(/', $stackFrame['file'])) {
178 preg_match_all('/([^\(]+)\((\d+)/', $stackFrame['file'], $matches);
180 return $matches[1][0];
183 return $stackFrame['file'];
188 * Check whether a given backtrace frame is a call to Psy\debug.
190 * @param array $stackFrame
194 private static function isDebugCall(array $stackFrame)
196 $class = isset($stackFrame['class']) ? $stackFrame['class'] : null;
197 $function = isset($stackFrame['function']) ? $stackFrame['function'] : null;
199 return ($class === null && $function === 'Psy\debug') ||
200 ($class === 'Psy\Shell' && $function === 'debug');
204 * Clean the given array of code.
206 * @throws ParseErrorException if the code is invalid PHP, and cannot be coerced into valid PHP
208 * @param array $codeLines
209 * @param bool $requireSemicolons
211 * @return string|false Cleaned PHP code, False if the input is incomplete
213 public function clean(array $codeLines, $requireSemicolons = false)
215 $stmts = $this->parse('<?php ' . implode(PHP_EOL, $codeLines) . PHP_EOL, $requireSemicolons);
216 if ($stmts === false) {
220 // Catch fatal errors before they happen
221 $stmts = $this->traverser->traverse($stmts);
223 // Work around https://github.com/nikic/PHP-Parser/issues/399
224 $oldLocale = setlocale(LC_NUMERIC, 0);
225 setlocale(LC_NUMERIC, 'C');
227 $code = $this->printer->prettyPrint($stmts);
229 // Now put the locale back
230 setlocale(LC_NUMERIC, $oldLocale);
236 * Set the current local namespace.
238 * @param null|array $namespace (default: null)
242 public function setNamespace(array $namespace = null)
244 $this->namespace = $namespace;
248 * Get the current local namespace.
252 public function getNamespace()
254 return $this->namespace;
258 * Lex and parse a block of code.
262 * @throws ParseErrorException for parse errors that can't be resolved by
263 * waiting a line to see what comes next
265 * @param string $code
266 * @param bool $requireSemicolons
268 * @return array|false A set of statements, or false if incomplete
270 protected function parse($code, $requireSemicolons = false)
273 return $this->parser->parse($code);
274 } catch (\PhpParser\Error $e) {
275 if ($this->parseErrorIsUnclosedString($e, $code)) {
279 if ($this->parseErrorIsUnterminatedComment($e, $code)) {
283 if ($this->parseErrorIsTrailingComma($e, $code)) {
287 if (!$this->parseErrorIsEOF($e)) {
288 throw ParseErrorException::fromParseError($e);
291 if ($requireSemicolons) {
296 // Unexpected EOF, try again with an implicit semicolon
297 return $this->parser->parse($code . ';');
298 } catch (\PhpParser\Error $e) {
304 private function parseErrorIsEOF(\PhpParser\Error $e)
306 $msg = $e->getRawMessage();
308 return ($msg === 'Unexpected token EOF') || (strpos($msg, 'Syntax error, unexpected EOF') !== false);
312 * A special test for unclosed single-quoted strings.
314 * Unlike (all?) other unclosed statements, single quoted strings have
315 * their own special beautiful snowflake syntax error just for
318 * @param \PhpParser\Error $e
319 * @param string $code
323 private function parseErrorIsUnclosedString(\PhpParser\Error $e, $code)
325 if ($e->getRawMessage() !== 'Syntax error, unexpected T_ENCAPSED_AND_WHITESPACE') {
330 $this->parser->parse($code . "';");
331 } catch (\Exception $e) {
338 private function parseErrorIsUnterminatedComment(\PhpParser\Error $e, $code)
340 return $e->getRawMessage() === 'Unterminated comment';
343 private function parseErrorIsTrailingComma(\PhpParser\Error $e, $code)
345 return ($e->getRawMessage() === 'A trailing comma is not allowed here') && (substr(rtrim($code), -1) === ',');