annotate vendor/psy/psysh/src/CodeCleaner.php @ 19:fa3358dc1485 tip

Add ndrum files
author Chris Cannam
date Wed, 28 Aug 2019 13:14:47 +0100
parents 129ea1e6d783
children
rev   line source
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 }