Zur aktuellen Revision | Blame | Vergleich mit vorheriger | Letzte Änderung | Log anzeigen | RSS feed
<?php/** This file is part of Psy Shell.** (c) 2012-2022 Justin Hileman** For the full copyright and license information, please view the LICENSE* file that was distributed with this source code.*/namespace Psy;use PhpParser\NodeTraverser;use PhpParser\Parser;use PhpParser\PrettyPrinter\Standard as Printer;use Psy\CodeCleaner\AbstractClassPass;use Psy\CodeCleaner\AssignThisVariablePass;use Psy\CodeCleaner\CalledClassPass;use Psy\CodeCleaner\CallTimePassByReferencePass;use Psy\CodeCleaner\EmptyArrayDimFetchPass;use Psy\CodeCleaner\ExitPass;use Psy\CodeCleaner\FinalClassPass;use Psy\CodeCleaner\FunctionContextPass;use Psy\CodeCleaner\FunctionReturnInWriteContextPass;use Psy\CodeCleaner\ImplicitReturnPass;use Psy\CodeCleaner\InstanceOfPass;use Psy\CodeCleaner\IssetPass;use Psy\CodeCleaner\LabelContextPass;use Psy\CodeCleaner\LeavePsyshAlonePass;use Psy\CodeCleaner\ListPass;use Psy\CodeCleaner\LoopContextPass;use Psy\CodeCleaner\MagicConstantsPass;use Psy\CodeCleaner\NamespacePass;use Psy\CodeCleaner\PassableByReferencePass;use Psy\CodeCleaner\RequirePass;use Psy\CodeCleaner\ReturnTypePass;use Psy\CodeCleaner\StrictTypesPass;use Psy\CodeCleaner\UseStatementPass;use Psy\CodeCleaner\ValidClassNamePass;use Psy\CodeCleaner\ValidConstructorPass;use Psy\CodeCleaner\ValidFunctionNamePass;use Psy\Exception\ParseErrorException;/*** A service to clean up user input, detect parse errors before they happen,* and generally work around issues with the PHP code evaluation experience.*/class CodeCleaner{private $yolo = false;private $parser;private $printer;private $traverser;private $namespace;/*** CodeCleaner constructor.** @param Parser|null $parser A PhpParser Parser instance. One will be created if not explicitly supplied* @param Printer|null $printer A PhpParser Printer instance. One will be created if not explicitly supplied* @param NodeTraverser|null $traverser A PhpParser NodeTraverser instance. One will be created if not explicitly supplied* @param bool $yolo run without input validation*/public function __construct(Parser $parser = null, Printer $printer = null, NodeTraverser $traverser = null, bool $yolo = false){$this->yolo = $yolo;if ($parser === null) {$parserFactory = new ParserFactory();$parser = $parserFactory->createParser();}$this->parser = $parser;$this->printer = $printer ?: new Printer();$this->traverser = $traverser ?: new NodeTraverser();foreach ($this->getDefaultPasses() as $pass) {$this->traverser->addVisitor($pass);}}/*** Check whether this CodeCleaner is in YOLO mode.** @return bool*/public function yolo(): bool{return $this->yolo;}/*** Get default CodeCleaner passes.** @return array*/private function getDefaultPasses(): array{if ($this->yolo) {return $this->getYoloPasses();}$useStatementPass = new UseStatementPass();$namespacePass = new NamespacePass($this);// Try to add implicit `use` statements and an implicit namespace,// based on the file in which the `debug` call was made.$this->addImplicitDebugContext([$useStatementPass, $namespacePass]);return [// Validation passesnew AbstractClassPass(),new AssignThisVariablePass(),new CalledClassPass(),new CallTimePassByReferencePass(),new FinalClassPass(),new FunctionContextPass(),new FunctionReturnInWriteContextPass(),new InstanceOfPass(),new IssetPass(),new LabelContextPass(),new LeavePsyshAlonePass(),new ListPass(),new LoopContextPass(),new PassableByReferencePass(),new ReturnTypePass(),new EmptyArrayDimFetchPass(),new ValidConstructorPass(),// Rewriting shenanigans$useStatementPass, // must run before the namespace passnew ExitPass(),new ImplicitReturnPass(),new MagicConstantsPass(),$namespacePass, // must run after the implicit return passnew RequirePass(),new StrictTypesPass(),// Namespace-aware validation (which depends on aforementioned shenanigans)new ValidClassNamePass(),new ValidFunctionNamePass(),];}/*** A set of code cleaner passes that don't try to do any validation, and* only do minimal rewriting to make things work inside the REPL.** This list should stay in sync with the "rewriting shenanigans" in* getDefaultPasses above.** @return array*/private function getYoloPasses(): array{$useStatementPass = new UseStatementPass();$namespacePass = new NamespacePass($this);// Try to add implicit `use` statements and an implicit namespace,// based on the file in which the `debug` call was made.$this->addImplicitDebugContext([$useStatementPass, $namespacePass]);return [new LeavePsyshAlonePass(),$useStatementPass, // must run before the namespace passnew ExitPass(),new ImplicitReturnPass(),new MagicConstantsPass(),$namespacePass, // must run after the implicit return passnew RequirePass(),new StrictTypesPass(),];}/*** "Warm up" code cleaner passes when we're coming from a debug call.** This is useful, for example, for `UseStatementPass` and `NamespacePass`* which keep track of state between calls, to maintain the current* namespace and a map of use statements.** @param array $passes*/private function addImplicitDebugContext(array $passes){$file = $this->getDebugFile();if ($file === null) {return;}try {$code = @\file_get_contents($file);if (!$code) {return;}$stmts = $this->parse($code, true);if ($stmts === false) {return;}// Set up a clean traverser for just these code cleaner passes$traverser = new NodeTraverser();foreach ($passes as $pass) {$traverser->addVisitor($pass);}$traverser->traverse($stmts);} catch (\Throwable $e) {// Don't care.}}/*** Search the stack trace for a file in which the user called Psy\debug.** @return string|null*/private static function getDebugFile(){$trace = \debug_backtrace(\DEBUG_BACKTRACE_IGNORE_ARGS);foreach (\array_reverse($trace) as $stackFrame) {if (!self::isDebugCall($stackFrame)) {continue;}if (\preg_match('/eval\(/', $stackFrame['file'])) {\preg_match_all('/([^\(]+)\((\d+)/', $stackFrame['file'], $matches);return $matches[1][0];}return $stackFrame['file'];}}/*** Check whether a given backtrace frame is a call to Psy\debug.** @param array $stackFrame** @return bool*/private static function isDebugCall(array $stackFrame): bool{$class = isset($stackFrame['class']) ? $stackFrame['class'] : null;$function = isset($stackFrame['function']) ? $stackFrame['function'] : null;return ($class === null && $function === 'Psy\\debug') ||($class === Shell::class && $function === 'debug');}/*** Clean the given array of code.** @throws ParseErrorException if the code is invalid PHP, and cannot be coerced into valid PHP** @param array $codeLines* @param bool $requireSemicolons** @return string|false Cleaned PHP code, False if the input is incomplete*/public function clean(array $codeLines, bool $requireSemicolons = false){$stmts = $this->parse('<?php '.\implode(\PHP_EOL, $codeLines).\PHP_EOL, $requireSemicolons);if ($stmts === false) {return false;}// Catch fatal errors before they happen$stmts = $this->traverser->traverse($stmts);// Work around https://github.com/nikic/PHP-Parser/issues/399$oldLocale = \setlocale(\LC_NUMERIC, 0);\setlocale(\LC_NUMERIC, 'C');$code = $this->printer->prettyPrint($stmts);// Now put the locale back\setlocale(\LC_NUMERIC, $oldLocale);return $code;}/*** Set the current local namespace.** @param array|null $namespace (default: null)** @return array|null*/public function setNamespace(array $namespace = null){$this->namespace = $namespace;}/*** Get the current local namespace.** @return array|null*/public function getNamespace(){return $this->namespace;}/*** Lex and parse a block of code.** @see Parser::parse** @throws ParseErrorException for parse errors that can't be resolved by* waiting a line to see what comes next** @param string $code* @param bool $requireSemicolons** @return array|false A set of statements, or false if incomplete*/protected function parse(string $code, bool $requireSemicolons = false){try {return $this->parser->parse($code);} catch (\PhpParser\Error $e) {if ($this->parseErrorIsUnclosedString($e, $code)) {return false;}if ($this->parseErrorIsUnterminatedComment($e, $code)) {return false;}if ($this->parseErrorIsTrailingComma($e, $code)) {return false;}if (!$this->parseErrorIsEOF($e)) {throw ParseErrorException::fromParseError($e);}if ($requireSemicolons) {return false;}try {// Unexpected EOF, try again with an implicit semicolonreturn $this->parser->parse($code.';');} catch (\PhpParser\Error $e) {return false;}}}private function parseErrorIsEOF(\PhpParser\Error $e): bool{$msg = $e->getRawMessage();return ($msg === 'Unexpected token EOF') || (\strpos($msg, 'Syntax error, unexpected EOF') !== false);}/*** A special test for unclosed single-quoted strings.** Unlike (all?) other unclosed statements, single quoted strings have* their own special beautiful snowflake syntax error just for* themselves.** @param \PhpParser\Error $e* @param string $code** @return bool*/private function parseErrorIsUnclosedString(\PhpParser\Error $e, string $code): bool{if ($e->getRawMessage() !== 'Syntax error, unexpected T_ENCAPSED_AND_WHITESPACE') {return false;}try {$this->parser->parse($code."';");} catch (\Throwable $e) {return false;}return true;}private function parseErrorIsUnterminatedComment(\PhpParser\Error $e, $code): bool{return $e->getRawMessage() === 'Unterminated comment';}private function parseErrorIsTrailingComma(\PhpParser\Error $e, $code): bool{return ($e->getRawMessage() === 'A trailing comma is not allowed here') && (\substr(\rtrim($code), -1) === ',');}}