Subversion-Projekte lars-tiefland.laravel_shop

Revision

Zur aktuellen Revision | Details | Letzte Änderung | Log anzeigen | RSS feed

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