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