Subversion-Projekte lars-tiefland.laravel_shop

Revision

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