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