allow vendord
All checks were successful
Build, Push and Deploy / build-and-push (push) Successful in 3m3s
Gitea Actions Demo / Explore-Gitea-Actions (push) Successful in 16s
Build, Push and Deploy / deploy-staging (push) Successful in 32s
Build, Push and Deploy / deploy-production (push) Has been skipped
Build, Push and Deploy / cleanup (push) Successful in 1s

This commit is contained in:
2026-03-30 14:54:57 +07:00
parent 66aed7c4e8
commit b5e3a778ce
21316 changed files with 2777892 additions and 13 deletions

View File

@@ -0,0 +1,79 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\CodeCleaner;
use PhpParser\Node;
use PhpParser\Node\Stmt\Class_;
use PhpParser\Node\Stmt\ClassMethod;
use Psy\Exception\FatalErrorException;
/**
* The abstract class pass handles abstract classes and methods, complaining if there are too few or too many of either.
*/
class AbstractClassPass extends CodeCleanerPass
{
private Class_ $class;
private array $abstractMethods;
/**
* @throws FatalErrorException if the node is an abstract function with a body
*
* @param Node $node
*
* @return int|Node|null Replacement node (or special return value)
*/
public function enterNode(Node $node)
{
if ($node instanceof Class_) {
$this->class = $node;
$this->abstractMethods = [];
} elseif ($node instanceof ClassMethod) {
if ($node->isAbstract()) {
$name = \sprintf('%s::%s', $this->class->name, $node->name);
$this->abstractMethods[] = $name;
if ($node->stmts !== null) {
$msg = \sprintf('Abstract function %s cannot contain body', $name);
throw new FatalErrorException($msg, 0, \E_ERROR, null, $node->getStartLine());
}
}
}
return null;
}
/**
* @throws FatalErrorException if the node is a non-abstract class with abstract methods
*
* @param Node $node
*
* @return int|Node|Node[]|null Replacement node (or special return value)
*/
public function leaveNode(Node $node)
{
if ($node instanceof Class_) {
$count = \count($this->abstractMethods);
if ($count > 0 && !$node->isAbstract()) {
$msg = \sprintf(
'Class %s contains %d abstract method%s must therefore be declared abstract or implement the remaining methods (%s)',
$node->name,
$count,
($count === 1) ? '' : 's',
\implode(', ', $this->abstractMethods)
);
throw new FatalErrorException($msg, 0, \E_ERROR, null, $node->getStartLine());
}
}
return null;
}
}

View File

@@ -0,0 +1,43 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\CodeCleaner;
use PhpParser\Node;
use PhpParser\Node\Expr\Assign;
use PhpParser\Node\Expr\Variable;
use Psy\Exception\FatalErrorException;
/**
* Validate that the user input does not assign the `$this` variable.
*
* @author Martin Hasoň <martin.hason@gmail.com>
*/
class AssignThisVariablePass extends CodeCleanerPass
{
/**
* Validate that the user input does not assign the `$this` variable.
*
* @throws FatalErrorException if the user assign the `$this` variable
*
* @param Node $node
*
* @return int|Node|null Replacement node (or special return value)
*/
public function enterNode(Node $node)
{
if ($node instanceof Assign && $node->var instanceof Variable && $node->var->name === 'this') {
throw new FatalErrorException('Cannot re-assign $this', 0, \E_ERROR, null, $node->getStartLine());
}
return null;
}
}

View File

@@ -0,0 +1,59 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\CodeCleaner;
use PhpParser\Node;
use PhpParser\Node\Expr\FuncCall;
use PhpParser\Node\Expr\MethodCall;
use PhpParser\Node\Expr\StaticCall;
use PhpParser\Node\VariadicPlaceholder;
use Psy\Exception\FatalErrorException;
/**
* Validate that the user did not use the call-time pass-by-reference that causes a fatal error.
*
* As of PHP 5.4.0, call-time pass-by-reference was removed, so using it will raise a fatal error.
*
* @author Martin Hasoň <martin.hason@gmail.com>
*/
class CallTimePassByReferencePass extends CodeCleanerPass
{
const EXCEPTION_MESSAGE = 'Call-time pass-by-reference has been removed';
/**
* Validate of use call-time pass-by-reference.
*
* @throws FatalErrorException if the user used call-time pass-by-reference
*
* @param Node $node
*
* @return int|Node|null Replacement node (or special return value)
*/
public function enterNode(Node $node)
{
if (!$node instanceof FuncCall && !$node instanceof MethodCall && !$node instanceof StaticCall) {
return null;
}
foreach ($node->args as $arg) {
if ($arg instanceof VariadicPlaceholder) {
continue;
}
if ($arg->byRef) {
throw new FatalErrorException(self::EXCEPTION_MESSAGE, 0, \E_ERROR, null, $node->getStartLine());
}
}
return null;
}
}

View File

@@ -0,0 +1,104 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\CodeCleaner;
use PhpParser\Node;
use PhpParser\Node\Expr\ConstFetch;
use PhpParser\Node\Expr\FuncCall;
use PhpParser\Node\Name;
use PhpParser\Node\Stmt\Class_;
use PhpParser\Node\Stmt\Trait_;
use PhpParser\Node\VariadicPlaceholder;
use Psy\Exception\ErrorException;
/**
* The called class pass throws warnings for get_class() and get_called_class()
* outside a class context.
*/
class CalledClassPass extends CodeCleanerPass
{
private bool $inClass = false;
/**
* @param array $nodes
*
* @return Node[]|null Array of nodes
*/
public function beforeTraverse(array $nodes)
{
$this->inClass = false;
return null;
}
/**
* @throws ErrorException if get_class or get_called_class is called without an object from outside a class
*
* @param Node $node
*
* @return int|Node|null Replacement node (or special return value)
*/
public function enterNode(Node $node)
{
if ($node instanceof Class_ || $node instanceof Trait_) {
$this->inClass = true;
} elseif ($node instanceof FuncCall && !$this->inClass) {
// We'll give any args at all (besides null) a pass.
// Technically we should be checking whether the args are objects, but this will do for now.
//
// @todo switch this to actually validate args when we get context-aware code cleaner passes.
if (!empty($node->args) && !$this->isNull($node->args[0])) {
return null;
}
// We'll ignore name expressions as well (things like `$foo()`)
if (!($node->name instanceof Name)) {
return null;
}
$name = \strtolower($node->name);
if (\in_array($name, ['get_class', 'get_called_class'])) {
$msg = \sprintf('%s() called without object from outside a class', $name);
throw new ErrorException($msg, 0, \E_USER_WARNING, null, $node->getStartLine());
}
}
return null;
}
/**
* @param Node $node
*
* @return int|Node|Node[]|null Replacement node (or special return value)
*/
public function leaveNode(Node $node)
{
if ($node instanceof Class_) {
$this->inClass = false;
}
return null;
}
private function isNull(Node $node): bool
{
if ($node instanceof VariadicPlaceholder) {
return false;
}
if (!\property_exists($node, 'value')) {
return false;
}
return $node->value instanceof ConstFetch && \strtolower($node->value->name) === 'null';
}
}

View File

@@ -0,0 +1,22 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\CodeCleaner;
use PhpParser\NodeVisitorAbstract;
/**
* A CodeCleaner pass is a PhpParser Node Visitor.
*/
abstract class CodeCleanerPass extends NodeVisitorAbstract
{
// Wheee!
}

View File

@@ -0,0 +1,70 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\CodeCleaner;
use PhpParser\Node;
use PhpParser\Node\Expr\ArrayDimFetch;
use PhpParser\Node\Expr\Assign;
use PhpParser\Node\Expr\AssignRef;
use PhpParser\Node\Stmt\Foreach_;
use Psy\Exception\FatalErrorException;
/**
* Validate empty brackets are only used for assignment.
*/
class EmptyArrayDimFetchPass extends CodeCleanerPass
{
const EXCEPTION_MESSAGE = 'Cannot use [] for reading';
private array $theseOnesAreFine = [];
/**
* @return Node[]|null Array of nodes
*/
public function beforeTraverse(array $nodes)
{
$this->theseOnesAreFine = [];
return null;
}
/**
* @throws FatalErrorException if the user used empty array dim fetch outside of assignment
*
* @param Node $node
*
* @return int|Node|null Replacement node (or special return value)
*/
public function enterNode(Node $node)
{
if ($node instanceof Assign && $node->var instanceof ArrayDimFetch) {
$this->theseOnesAreFine[] = $node->var;
} elseif ($node instanceof AssignRef && $node->expr instanceof ArrayDimFetch) {
$this->theseOnesAreFine[] = $node->expr;
} elseif ($node instanceof Foreach_ && $node->valueVar instanceof ArrayDimFetch) {
$this->theseOnesAreFine[] = $node->valueVar;
} elseif ($node instanceof ArrayDimFetch && $node->var instanceof ArrayDimFetch) {
// $a[]['b'] = 'c'
if (\in_array($node, $this->theseOnesAreFine)) {
$this->theseOnesAreFine[] = $node->var;
}
}
if ($node instanceof ArrayDimFetch && $node->dim === null) {
if (!\in_array($node, $this->theseOnesAreFine)) {
throw new FatalErrorException(self::EXCEPTION_MESSAGE, $node->getStartLine());
}
}
return null;
}
}

View File

@@ -0,0 +1,40 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\CodeCleaner;
use PhpParser\Node;
use PhpParser\Node\Arg;
use PhpParser\Node\Expr\Exit_;
use PhpParser\Node\Expr\StaticCall;
use PhpParser\Node\Name\FullyQualified as FullyQualifiedName;
use Psy\Exception\BreakException;
class ExitPass extends CodeCleanerPass
{
/**
* Converts exit calls to BreakExceptions.
*
* @param \PhpParser\Node $node
*
* @return int|Node|Node[]|null Replacement node (or special return value)
*/
public function leaveNode(Node $node)
{
if ($node instanceof Exit_) {
$args = $node->expr ? [new Arg($node->expr)] : [];
return new StaticCall(new FullyQualifiedName(BreakException::class), 'exitShell', $args);
}
return null;
}
}

View File

@@ -0,0 +1,76 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\CodeCleaner;
use PhpParser\Node;
use PhpParser\Node\Stmt\Class_;
use Psy\Exception\FatalErrorException;
/**
* The final class pass handles final classes.
*/
class FinalClassPass extends CodeCleanerPass
{
private array $finalClasses = [];
/**
* @param array $nodes
*
* @return Node[]|null Array of nodes
*/
public function beforeTraverse(array $nodes)
{
$this->finalClasses = [];
return null;
}
/**
* @throws FatalErrorException if the node is a class that extends a final class
*
* @param Node $node
*
* @return int|Node|null Replacement node (or special return value)
*/
public function enterNode(Node $node)
{
if ($node instanceof Class_) {
if ($node->extends) {
$extends = (string) $node->extends;
if ($this->isFinalClass($extends)) {
$msg = \sprintf('Class %s may not inherit from final class (%s)', $node->name, $extends);
throw new FatalErrorException($msg, 0, \E_ERROR, null, $node->getStartLine());
}
}
if ($node->isFinal()) {
$this->finalClasses[\strtolower($node->name)] = true;
}
}
return null;
}
/**
* @param string $name Class name
*/
private function isFinalClass(string $name): bool
{
if (!\class_exists($name)) {
return isset($this->finalClasses[\strtolower($name)]);
}
$refl = new \ReflectionClass($name);
return $refl->isFinal();
}
}

View File

@@ -0,0 +1,73 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\CodeCleaner;
use PhpParser\Node;
use PhpParser\Node\Expr\Yield_;
use PhpParser\Node\FunctionLike;
use Psy\Exception\FatalErrorException;
class FunctionContextPass extends CodeCleanerPass
{
private int $functionDepth = 0;
/**
* @param array $nodes
*
* @return Node[]|null Array of nodes
*/
public function beforeTraverse(array $nodes)
{
$this->functionDepth = 0;
return null;
}
/**
* @return int|Node|null Replacement node (or special return value)
*/
public function enterNode(Node $node)
{
if ($node instanceof FunctionLike) {
$this->functionDepth++;
return null;
}
// node is inside function context
if ($this->functionDepth !== 0) {
return null;
}
// It causes fatal error.
if ($node instanceof Yield_) {
$msg = 'The "yield" expression can only be used inside a function';
throw new FatalErrorException($msg, 0, \E_ERROR, null, $node->getStartLine());
}
return null;
}
/**
* @param \PhpParser\Node $node
*
* @return int|Node|Node[]|null Replacement node (or special return value)
*/
public function leaveNode(Node $node)
{
if ($node instanceof FunctionLike) {
$this->functionDepth--;
}
return null;
}
}

View File

@@ -0,0 +1,79 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\CodeCleaner;
use PhpParser\Node;
use PhpParser\Node\Expr\Array_;
use PhpParser\Node\Expr\Assign;
use PhpParser\Node\Expr\FuncCall;
use PhpParser\Node\Expr\Isset_;
use PhpParser\Node\Expr\MethodCall;
use PhpParser\Node\Expr\StaticCall;
use PhpParser\Node\Stmt\Unset_;
use PhpParser\Node\VariadicPlaceholder;
use Psy\Exception\FatalErrorException;
/**
* Validate that the functions are used correctly.
*
* @author Martin Hasoň <martin.hason@gmail.com>
*/
class FunctionReturnInWriteContextPass extends CodeCleanerPass
{
const ISSET_MESSAGE = 'Cannot use isset() on the result of an expression (you can use "null !== expression" instead)';
const EXCEPTION_MESSAGE = "Can't use function return value in write context";
/**
* Validate that the functions are used correctly.
*
* @throws FatalErrorException if a function is passed as an argument reference
* @throws FatalErrorException if a function is used as an argument in the isset
* @throws FatalErrorException if a value is assigned to a function
*
* @param Node $node
*
* @return int|Node|null Replacement node (or special return value)
*/
public function enterNode(Node $node)
{
if ($node instanceof Array_ || $this->isCallNode($node)) {
$items = $node instanceof Array_ ? $node->items : $node->args;
foreach ($items as $item) {
if ($item instanceof VariadicPlaceholder) {
continue;
}
if ($item && $item->byRef && $this->isCallNode($item->value)) {
throw new FatalErrorException(self::EXCEPTION_MESSAGE, 0, \E_ERROR, null, $node->getStartLine());
}
}
} elseif ($node instanceof Isset_ || $node instanceof Unset_) {
foreach ($node->vars as $var) {
if (!$this->isCallNode($var)) {
continue;
}
$msg = $node instanceof Isset_ ? self::ISSET_MESSAGE : self::EXCEPTION_MESSAGE;
throw new FatalErrorException($msg, 0, \E_ERROR, null, $node->getStartLine());
}
} elseif ($node instanceof Assign && $this->isCallNode($node->var)) {
throw new FatalErrorException(self::EXCEPTION_MESSAGE, 0, \E_ERROR, null, $node->getStartLine());
}
return null;
}
private function isCallNode(Node $node): bool
{
return $node instanceof FuncCall || $node instanceof MethodCall || $node instanceof StaticCall;
}
}

View File

@@ -0,0 +1,125 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\CodeCleaner;
use PhpParser\Node;
use PhpParser\Node\Expr;
use PhpParser\Node\Expr\Exit_;
use PhpParser\Node\Stmt;
use PhpParser\Node\Stmt\Break_;
use PhpParser\Node\Stmt\Expression;
use PhpParser\Node\Stmt\If_;
use PhpParser\Node\Stmt\Namespace_;
use PhpParser\Node\Stmt\Return_;
use PhpParser\Node\Stmt\Switch_;
/**
* Add an implicit "return" to the last statement, provided it can be returned.
*/
class ImplicitReturnPass extends CodeCleanerPass
{
/**
* @param array $nodes
*
* @return array
*/
public function beforeTraverse(array $nodes): array
{
return $this->addImplicitReturn($nodes);
}
/**
* @param array $nodes
*
* @return array
*/
private function addImplicitReturn(array $nodes): array
{
// If nodes is empty, it can't have a return value.
if (empty($nodes)) {
return [new Return_(NoReturnValue::create())];
}
$last = \end($nodes);
// Special case a few types of statements to add an implicit return
// value (even though they technically don't have any return value)
// because showing a return value in these instances is useful and not
// very surprising.
if ($last instanceof If_) {
$last->stmts = $this->addImplicitReturn($last->stmts);
foreach ($last->elseifs as $elseif) {
$elseif->stmts = $this->addImplicitReturn($elseif->stmts);
}
if ($last->else) {
$last->else->stmts = $this->addImplicitReturn($last->else->stmts);
}
} elseif ($last instanceof Switch_) {
foreach ($last->cases as $case) {
// only add an implicit return to cases which end in break
$caseLast = \end($case->stmts);
if ($caseLast instanceof Break_) {
$case->stmts = $this->addImplicitReturn(\array_slice($case->stmts, 0, -1));
$case->stmts[] = $caseLast;
}
}
} elseif ($last instanceof Expr && !($last instanceof Exit_)) {
// @codeCoverageIgnoreStart
$nodes[\count($nodes) - 1] = new Return_($last, [
'startLine' => $last->getStartLine(),
'endLine' => $last->getEndLine(),
]);
// @codeCoverageIgnoreEnd
} elseif ($last instanceof Expression && !($last->expr instanceof Exit_)) {
$nodes[\count($nodes) - 1] = new Return_($last->expr, [
'startLine' => $last->getStartLine(),
'endLine' => $last->getEndLine(),
]);
} elseif ($last instanceof Namespace_) {
$last->stmts = $this->addImplicitReturn($last->stmts);
}
// Return a "no return value" for all non-expression statements, so that
// PsySH can suppress the `null` that `eval()` returns otherwise.
//
// Note that statements special cased above (if/elseif/else, switch)
// _might_ implicitly return a value before this catch-all return is
// reached.
//
// We're not adding a fallback return after namespace statements,
// because code outside namespace statements doesn't really work, and
// there's already an implicit return in the namespace statement anyway.
if (self::isNonExpressionStmt($last)) {
$nodes[] = new Return_(NoReturnValue::create());
}
return $nodes;
}
/**
* Check whether a given node is a non-expression statement.
*
* As of PHP Parser 4.x, Expressions are now instances of Stmt as well, so
* we'll exclude them here.
*
* @param Node $node
*/
private static function isNonExpressionStmt(Node $node): bool
{
return $node instanceof Stmt &&
!$node instanceof Expression &&
!$node instanceof Return_ &&
!$node instanceof Namespace_;
}
}

View File

@@ -0,0 +1,396 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\CodeCleaner;
use PhpParser\Node;
use PhpParser\Node\Name;
use PhpParser\Node\Name\FullyQualified as FullyQualifiedName;
use PhpParser\Node\Stmt\GroupUse;
use PhpParser\Node\Stmt\Namespace_;
use PhpParser\Node\Stmt\Use_;
use PhpParser\Node\Stmt\UseItem;
use PhpParser\Node\Stmt\UseUse;
use PhpParser\PrettyPrinter\Standard as PrettyPrinter;
use Psy\CodeCleaner;
/**
* Automatically add use statements for unqualified class references.
*
* When a user references a class by its short name (e.g., `User`), this pass attempts to find a
* fully-qualified class name that matches. A use statement is added if:
*
* - There is no unqualified name (class/function/constant) with that short name
* - There is no existing use statement or alias with that short name
* - There is exactly one matching class/interface/trait in the configured namespaces
*
* For example, in a project with `App\Model\User` and `App\View\User` classes, if configured with
* 'includeNamespaces' => ['App\Model'], `new User` would become `use App\Model\User; new User;`
* even though there's also an `App\View\User` class.
*
* Works great with autoload warming (--warm-autoload) to pre-load classes.
*/
class ImplicitUsePass extends CodeCleanerPass
{
private ?array $shortNameMap = null;
private array $implicitUses = [];
private array $seenNames = [];
private array $existingAliases = [];
private array $includeNamespaces = [];
private array $excludeNamespaces = [];
private ?string $currentNamespace = null;
private ?CodeCleaner $cleaner = null;
private ?PrettyPrinter $printer = null;
/**
* @param array $config Configuration array with 'includeNamespaces' and/or 'excludeNamespaces'
* @param CodeCleaner|null $cleaner CodeCleaner instance for logging
*/
public function __construct(array $config = [], ?CodeCleaner $cleaner = null)
{
$this->includeNamespaces = $this->normalizeNamespaces($config['includeNamespaces'] ?? []);
$this->excludeNamespaces = $this->normalizeNamespaces($config['excludeNamespaces'] ?? []);
$this->cleaner = $cleaner;
}
/**
* {@inheritdoc}
*/
public function beforeTraverse(array $nodes)
{
if (empty($this->includeNamespaces) && empty($this->excludeNamespaces)) {
return null;
}
$this->buildShortNameMap();
// Reset state for this traversal
$this->implicitUses = [];
$this->seenNames = [];
$this->existingAliases = [];
$this->currentNamespace = null;
$modified = false;
// Collect use statements and seen names for each namespace
foreach ($nodes as $node) {
if ($node instanceof Namespace_) {
$this->currentNamespace = $node->name ? $node->name->toString() : null;
$perNamespaceAliases = [];
$perNamespaceUses = [];
$perNamespaceSeen = [];
if ($node->stmts !== null) {
$this->collectAliasesInNodes($node->stmts, $perNamespaceAliases);
$this->collectNamesInNodes($node->stmts, $perNamespaceSeen, $perNamespaceAliases, $perNamespaceUses);
}
if (!empty($perNamespaceUses)) {
$this->logAddedUses($perNamespaceUses);
$node->stmts = \array_merge($this->createUseStatements($perNamespaceUses), $node->stmts ?? []);
$modified = true;
}
}
}
$hasNamespace = false;
foreach ($nodes as $node) {
if ($node instanceof Namespace_) {
$hasNamespace = true;
break;
}
}
// Collect use statements and seen names for top-level namespace
if (!$hasNamespace) {
$this->currentNamespace = null;
$topLevelAliases = [];
$topLevelUses = [];
$topLevelSeen = [];
$this->collectAliasesInNodes($nodes, $topLevelAliases);
$this->collectNamesInNodes($nodes, $topLevelSeen, $topLevelAliases, $topLevelUses);
if (!empty($topLevelUses)) {
$this->logAddedUses($topLevelUses);
return \array_merge($this->createUseStatements($topLevelUses), $nodes);
}
}
return $modified ? $nodes : null;
}
/**
* Collect aliases in a set of nodes.
*
* @param array $nodes Array of Node objects
* @param array $aliases Associative array mapping lowercase alias names to true
*/
private function collectAliasesInNodes(array $nodes, array &$aliases): void
{
foreach ($nodes as $node) {
if ($node instanceof Use_ || $node instanceof GroupUse) {
foreach ($node->uses as $useItem) {
$alias = $useItem->getAlias();
if ($alias !== null) {
$aliasStr = $alias instanceof Name ? $alias->toString() : (string) $alias;
$aliases[\strtolower($aliasStr)] = true;
} else {
$aliases[\strtolower($this->getShortName($useItem->name))] = true;
}
}
}
}
}
/**
* Collect unqualified names in nodes.
*
* @param array $nodes Array of Node objects to traverse
* @param array $seen Lowercase short names already processed
* @param array $aliases Lowercase alias names that exist in this namespace
* @param array $uses Map of short names to FQNs for implicit use statements
*/
private function collectNamesInNodes(array $nodes, array &$seen, array $aliases, array &$uses): void
{
foreach ($nodes as $node) {
if (!$node instanceof Node || $node instanceof Use_) {
continue;
}
if ($node instanceof Name && !$node instanceof FullyQualifiedName) {
if (!$this->isQualified($node)) {
$shortName = $this->getShortName($node);
$shortNameLower = \strtolower($shortName);
if (isset($seen[$shortNameLower])) {
continue;
}
$seen[$shortNameLower] = true;
if ($this->shouldAddImplicitUseInContext($shortName, $shortNameLower, $aliases)) {
// @phan-suppress-next-line PhanTypeArraySuspiciousNullable - shortNameMap is initialized in beforeTraverse
$uses[$shortName] = $this->shortNameMap[$shortNameLower];
}
}
}
foreach ($node->getSubNodeNames() as $subNodeName) {
$subNode = $node->$subNodeName;
if ($subNode instanceof Node) {
$subNode = [$subNode];
}
if (\is_array($subNode)) {
$this->collectNamesInNodes($subNode, $seen, $aliases, $uses);
}
}
}
}
/**
* Create Use_ statement nodes from uses array.
*
* @param array $uses Associative array mapping short names to FQNs
*
* @return Use_[]
*/
private function createUseStatements(array $uses): array
{
\asort($uses);
$useStatements = [];
foreach ($uses as $fqn) {
$useItem = \class_exists(UseItem::class) ? new UseItem(new Name($fqn)) : new UseUse(new Name($fqn));
$useStatements[] = new Use_([$useItem]);
}
return $useStatements;
}
/**
* Check if we should add an implicit use statement for this name in current context.
*
* @param string $shortName Original case short name
* @param string $shortNameLower Lowercase short name for comparison
* @param array $aliases Lowercase alias names that exist in this namespace
*/
private function shouldAddImplicitUseInContext(string $shortName, string $shortNameLower, array $aliases): bool
{
// Rule 1: No existing unqualified name (class/interface/trait) with that short name
if (\class_exists($shortName, false) || \interface_exists($shortName, false) || \trait_exists($shortName, false)) {
return false;
}
// Rule 2: No existing use statement or alias with that short name
if (isset($aliases[$shortNameLower])) {
return false;
}
// Rule 3: Exactly one matching short class/interface/trait in configured namespaces
if (!isset($this->shortNameMap[$shortNameLower]) || $this->shortNameMap[$shortNameLower] === null) {
return false;
}
// Rule 4: Don't add use statement if the class exists in the current namespace
if ($this->currentNamespace !== null) {
$expectedFqn = \trim($this->currentNamespace, '\\').'\\'.$shortName;
if (\class_exists($expectedFqn, false) || \interface_exists($expectedFqn, false) || \trait_exists($expectedFqn, false)) {
return false;
}
}
return true;
}
/**
* Build a map of short class names to fully-qualified names.
*
* Uses get_declared_classes(), get_declared_interfaces(), and get_declared_traits()
* to find all currently loaded classes. Only includes classes matching the configured
* namespace filters. Detects ambiguous short names (multiple FQNs with same short name
* within the filtered namespaces) and marks them as null.
*/
private function buildShortNameMap(): void
{
$this->shortNameMap = [];
$allClasses = \array_merge(
\get_declared_classes(),
\get_declared_interfaces(),
\get_declared_traits()
);
// First pass: collect all matching classes
$candidatesByShortName = [];
foreach ($allClasses as $fqn) {
if (!$this->shouldIncludeClass($fqn)) {
continue;
}
$parts = \explode('\\', $fqn);
$shortName = \strtolower(\end($parts));
if (!isset($candidatesByShortName[$shortName])) {
$candidatesByShortName[$shortName] = [];
}
$candidatesByShortName[$shortName][] = $fqn;
}
// Second pass: determine if each short name is unique or ambiguous
foreach ($candidatesByShortName as $shortName => $fqns) {
$uniqueFqns = \array_unique($fqns);
// Mark as null if ambiguous (multiple FQNs with same short name)
$this->shortNameMap[$shortName] = (\count($uniqueFqns) === 1) ? $uniqueFqns[0] : null;
}
}
/**
* Check if a class should be aliased based on namespace filters.
*
* @param string $fqn Fully-qualified class name
*/
private function shouldIncludeClass(string $fqn): bool
{
if (\strpos($fqn, '\\') === false) {
return false;
}
if (empty($this->includeNamespaces) && empty($this->excludeNamespaces)) {
return false;
}
foreach ($this->excludeNamespaces as $namespace) {
if (\stripos($fqn, $namespace) === 0) {
return false;
}
}
if (empty($this->includeNamespaces)) {
return true;
}
foreach ($this->includeNamespaces as $namespace) {
if (\stripos($fqn, $namespace) === 0) {
return true;
}
}
return false;
}
/**
* Normalize namespace prefixes.
*
* Removes leading backslash and ensures trailing backslash.
*
* @param string[] $namespaces
*
* @return string[]
*/
private function normalizeNamespaces(array $namespaces): array
{
return \array_map(function ($namespace) {
return \trim($namespace, '\\').'\\';
}, $namespaces);
}
/**
* Get short name from a Name node.
*/
private function getShortName(Name $name): string
{
$parts = $this->getParts($name);
return \end($parts);
}
/**
* Check if a name is qualified (contains namespace separator).
*/
private function isQualified(Name $name): bool
{
return \count($this->getParts($name)) > 1;
}
/**
* Backwards compatibility shim for PHP-Parser 4.x.
*
* @return string[]
*/
private function getParts(Name $name): array
{
return \method_exists($name, 'getParts') ? $name->getParts() : $name->parts;
}
/**
* Log added use statements to the CodeCleaner.
*
* @param array $uses Associative array mapping short names to FQNs
*/
private function logAddedUses(array $uses): void
{
if ($this->cleaner === null || empty($uses)) {
return;
}
if ($this->printer === null) {
$this->printer = new PrettyPrinter();
}
$useStmts = $this->createUseStatements($uses);
$this->cleaner->log($this->printer->prettyPrint($useStmts));
}
}

View File

@@ -0,0 +1,51 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\CodeCleaner;
use PhpParser\Node;
use PhpParser\Node\Expr\ArrayDimFetch;
use PhpParser\Node\Expr\Isset_;
use PhpParser\Node\Expr\NullsafePropertyFetch;
use PhpParser\Node\Expr\PropertyFetch;
use PhpParser\Node\Expr\Variable;
use Psy\Exception\FatalErrorException;
/**
* Code cleaner pass to ensure we only allow variables, array fetch and property
* fetch expressions in isset() calls.
*/
class IssetPass extends CodeCleanerPass
{
const EXCEPTION_MSG = 'Cannot use isset() on the result of an expression (you can use "null !== expression" instead)';
/**
* @throws FatalErrorException
*
* @param Node $node
*
* @return int|Node|null Replacement node (or special return value)
*/
public function enterNode(Node $node)
{
if (!$node instanceof Isset_) {
return null;
}
foreach ($node->vars as $var) {
if (!$var instanceof Variable && !$var instanceof ArrayDimFetch && !$var instanceof PropertyFetch && !$var instanceof NullsafePropertyFetch) {
throw new FatalErrorException(self::EXCEPTION_MSG, 0, \E_ERROR, null, $node->getStartLine());
}
}
return null;
}
}

View File

@@ -0,0 +1,105 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\CodeCleaner;
use PhpParser\Node;
use PhpParser\Node\FunctionLike;
use PhpParser\Node\Stmt\Goto_;
use PhpParser\Node\Stmt\Label;
use Psy\Exception\FatalErrorException;
/**
* CodeCleanerPass for label context.
*
* This class partially emulates the PHP label specification.
* PsySH can not declare labels by sequentially executing lines with eval,
* but since it is not a syntax error, no error is raised.
* This class warns before invalid goto causes a fatal error.
* Since this is a simple checker, it does not block real fatal error
* with complex syntax. (ex. it does not parse inside function.)
*
* @see http://php.net/goto
*/
class LabelContextPass extends CodeCleanerPass
{
private int $functionDepth = 0;
private array $labelDeclarations = [];
private array $labelGotos = [];
/**
* @param array $nodes
*
* @return Node[]|null Array of nodes
*/
public function beforeTraverse(array $nodes)
{
$this->functionDepth = 0;
$this->labelDeclarations = [];
$this->labelGotos = [];
return null;
}
/**
* @return int|Node|null Replacement node (or special return value)
*/
public function enterNode(Node $node)
{
if ($node instanceof FunctionLike) {
$this->functionDepth++;
return null;
}
// node is inside function context
if ($this->functionDepth !== 0) {
return null;
}
if ($node instanceof Goto_) {
$this->labelGotos[\strtolower($node->name)] = $node->getStartLine();
} elseif ($node instanceof Label) {
$this->labelDeclarations[\strtolower($node->name)] = $node->getStartLine();
}
return null;
}
/**
* @param \PhpParser\Node $node
*
* @return int|Node|Node[]|null Replacement node (or special return value)
*/
public function leaveNode(Node $node)
{
if ($node instanceof FunctionLike) {
$this->functionDepth--;
}
return null;
}
/**
* @return Node[]|null Array of nodes
*/
public function afterTraverse(array $nodes)
{
foreach ($this->labelGotos as $name => $line) {
if (!isset($this->labelDeclarations[$name])) {
$msg = "'goto' to undefined label '{$name}'";
throw new FatalErrorException($msg, 0, \E_ERROR, null, $line);
}
}
return null;
}
}

View File

@@ -0,0 +1,40 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\CodeCleaner;
use PhpParser\Node;
use PhpParser\Node\Expr\Variable;
use Psy\Exception\RuntimeException;
/**
* Validate that the user input does not reference the `$__psysh__` variable.
*/
class LeavePsyshAlonePass extends CodeCleanerPass
{
/**
* Validate that the user input does not reference the `$__psysh__` variable.
*
* @throws RuntimeException if the user is messing with $__psysh__
*
* @param Node $node
*
* @return int|Node|null Replacement node (or special return value)
*/
public function enterNode(Node $node)
{
if ($node instanceof Variable && $node->name === '__psysh__') {
throw new RuntimeException('Don\'t mess with $__psysh__; bad things will happen');
}
return null;
}
}

View File

@@ -0,0 +1,97 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\CodeCleaner;
use PhpParser\Node;
use PhpParser\Node\ArrayItem;
use PhpParser\Node\Expr\Array_;
use PhpParser\Node\Expr\ArrayDimFetch;
// @todo Drop PhpParser\Node\Expr\ArrayItem once we drop support for PHP-Parser 4.x
use PhpParser\Node\Expr\ArrayItem as LegacyArrayItem;
use PhpParser\Node\Expr\Assign;
use PhpParser\Node\Expr\FuncCall;
use PhpParser\Node\Expr\List_;
use PhpParser\Node\Expr\MethodCall;
use PhpParser\Node\Expr\PropertyFetch;
use PhpParser\Node\Expr\Variable;
use Psy\Exception\ParseErrorException;
/**
* Validate that the list assignment.
*/
class ListPass extends CodeCleanerPass
{
/**
* Validate use of list assignment.
*
* @throws ParseErrorException if the user used empty with anything but a variable
*
* @param Node $node
*
* @return int|Node|null Replacement node (or special return value)
*/
public function enterNode(Node $node)
{
if (!$node instanceof Assign) {
return null;
}
if (!$node->var instanceof Array_ && !$node->var instanceof List_) {
return null;
}
// Polyfill for PHP-Parser 2.x
$items = isset($node->var->items) ? $node->var->items : (\property_exists($node->var, 'vars') ? $node->var->vars : []);
if ($items === [] || $items === [null]) {
throw new ParseErrorException('Cannot use empty list', ['startLine' => $node->var->getStartLine(), 'endLine' => $node->var->getEndLine()]);
}
$itemFound = false;
foreach ($items as $item) {
if ($item === null) {
continue;
}
$itemFound = true;
if (!self::isValidArrayItem($item)) {
$msg = 'Assignments can only happen to writable values';
throw new ParseErrorException($msg, ['startLine' => $item->getStartLine(), 'endLine' => $item->getEndLine()]);
}
}
if (!$itemFound) {
throw new ParseErrorException('Cannot use empty list');
}
return null;
}
/**
* Validate whether a given item in an array is valid for short assignment.
*
* @param Node $item
*/
private static function isValidArrayItem(Node $item): bool
{
$value = ($item instanceof ArrayItem || $item instanceof LegacyArrayItem) ? $item->value : $item;
while ($value instanceof ArrayDimFetch || $value instanceof PropertyFetch) {
$value = $value->var;
}
// We just kind of give up if it's a method call. We can't tell if it's
// valid via static analysis.
return $value instanceof Variable || $value instanceof MethodCall || $value instanceof FuncCall;
}
}

View File

@@ -0,0 +1,123 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\CodeCleaner;
use PhpParser\Node;
use PhpParser\Node\Scalar\DNumber;
use PhpParser\Node\Scalar\Float_;
use PhpParser\Node\Scalar\Int_;
use PhpParser\Node\Scalar\LNumber;
use PhpParser\Node\Stmt\Break_;
use PhpParser\Node\Stmt\Continue_;
use PhpParser\Node\Stmt\Do_;
use PhpParser\Node\Stmt\For_;
use PhpParser\Node\Stmt\Foreach_;
use PhpParser\Node\Stmt\Switch_;
use PhpParser\Node\Stmt\While_;
use Psy\Exception\FatalErrorException;
/**
* The loop context pass handles invalid `break` and `continue` statements.
*/
class LoopContextPass extends CodeCleanerPass
{
private int $loopDepth = 0;
/**
* {@inheritdoc}
*
* @return Node[]|null Array of nodes
*/
public function beforeTraverse(array $nodes)
{
$this->loopDepth = 0;
return null;
}
/**
* @throws FatalErrorException if the node is a break or continue in a non-loop or switch context
* @throws FatalErrorException if the node is trying to break out of more nested structures than exist
* @throws FatalErrorException if the node is a break or continue and has a non-numeric argument
* @throws FatalErrorException if the node is a break or continue and has an argument less than 1
*
* @param Node $node
*
* @return int|Node|null Replacement node (or special return value)
*/
public function enterNode(Node $node)
{
switch (true) {
case $node instanceof Do_:
case $node instanceof For_:
case $node instanceof Foreach_:
case $node instanceof Switch_:
case $node instanceof While_:
$this->loopDepth++;
break;
case $node instanceof Break_:
case $node instanceof Continue_:
$operator = $node instanceof Break_ ? 'break' : 'continue';
if ($this->loopDepth === 0) {
$msg = \sprintf("'%s' not in the 'loop' or 'switch' context", $operator);
throw new FatalErrorException($msg, 0, \E_ERROR, null, $node->getStartLine());
}
// @todo Remove LNumber and DNumber once we drop support for PHP-Parser 4.x
if (
$node->num instanceof LNumber ||
$node->num instanceof DNumber ||
$node->num instanceof Int_ ||
$node->num instanceof Float_
) {
$num = $node->num->value;
if ($node->num instanceof DNumber || $num < 1) {
$msg = \sprintf("'%s' operator accepts only positive numbers", $operator);
throw new FatalErrorException($msg, 0, \E_ERROR, null, $node->getStartLine());
}
if ($num > $this->loopDepth) {
$msg = \sprintf("Cannot '%s' %d levels", $operator, $num);
throw new FatalErrorException($msg, 0, \E_ERROR, null, $node->getStartLine());
}
} elseif ($node->num) {
$msg = \sprintf("'%s' operator with non-constant operand is no longer supported", $operator);
throw new FatalErrorException($msg, 0, \E_ERROR, null, $node->getStartLine());
}
break;
}
return null;
}
/**
* @param Node $node
*
* @return int|Node|Node[]|null Replacement node (or special return value)
*/
public function leaveNode(Node $node)
{
switch (true) {
case $node instanceof Do_:
case $node instanceof For_:
case $node instanceof Foreach_:
case $node instanceof Switch_:
case $node instanceof While_:
$this->loopDepth--;
break;
}
return null;
}
}

View File

@@ -0,0 +1,44 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\CodeCleaner;
use PhpParser\Node;
use PhpParser\Node\Expr\FuncCall;
use PhpParser\Node\Name;
use PhpParser\Node\Scalar\MagicConst\Dir;
use PhpParser\Node\Scalar\MagicConst\File;
use PhpParser\Node\Scalar\String_;
/**
* Swap out __DIR__ and __FILE__ magic constants with our best guess?
*/
class MagicConstantsPass extends CodeCleanerPass
{
/**
* Swap out __DIR__ and __FILE__ constants, because the default ones when
* calling eval() don't make sense.
*
* @param Node $node
*
* @return FuncCall|String_|null
*/
public function enterNode(Node $node)
{
if ($node instanceof Dir) {
return new FuncCall(new Name('getcwd'), [], $node->getAttributes());
} elseif ($node instanceof File) {
return new String_('', $node->getAttributes());
}
return null;
}
}

View File

@@ -0,0 +1,169 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\CodeCleaner;
use PhpParser\Node;
use PhpParser\Node\Name;
use PhpParser\Node\Name\FullyQualified as FullyQualifiedName;
use PhpParser\Node\Stmt\GroupUse;
use PhpParser\Node\Stmt\Namespace_;
use PhpParser\Node\Stmt\Use_;
use Psy\CodeCleaner;
/**
* Abstract namespace-aware code cleaner pass.
*
* Tracks both namespace and use statement aliases for proper name resolution.
*/
abstract class NamespaceAwarePass extends CodeCleanerPass
{
protected array $namespace = [];
protected array $currentScope = [];
protected array $aliases = [];
protected ?CodeCleaner $cleaner = null;
/**
* Set the CodeCleaner instance for state management.
*/
public function setCleaner(CodeCleaner $cleaner)
{
$this->cleaner = $cleaner;
}
/**
* @todo should this be final? Extending classes should be sure to either
* use afterTraverse or call parent::beforeTraverse() when overloading.
*
* Reset the namespace and the current scope before beginning analysis
*
* @return Node[]|null Array of nodes
*/
public function beforeTraverse(array $nodes)
{
$this->namespace = [];
$this->currentScope = [];
return null;
}
/**
* @todo should this be final? Extending classes should be sure to either use
* leaveNode or call parent::enterNode() when overloading
*
* @param Node $node
*
* @return int|Node|null Replacement node (or special return value)
*/
public function enterNode(Node $node)
{
if ($node instanceof Namespace_) {
$this->namespace = isset($node->name) ? $this->getParts($node->name) : [];
// Only restore use statement aliases for PsySH re-injected namespaces.
// Explicit namespace declarations start with a clean slate.
if ($this->cleaner && $node->getAttribute('psyshReinjected')) {
$this->aliases = $this->cleaner->getAliasesForNamespace($node->name);
} else {
$this->aliases = [];
}
}
// Track use statements for alias resolution
if ($node instanceof Use_) {
foreach ($node->uses as $useItem) {
$this->aliases[\strtolower($useItem->getAlias())] = $useItem->name;
}
}
// Track group use statements
if ($node instanceof GroupUse) {
foreach ($node->uses as $useItem) {
$this->aliases[\strtolower($useItem->getAlias())] = Name::concat($node->prefix, $useItem->name);
}
}
return null;
}
/**
* Save alias state when leaving a namespace.
*
* Braced namespaces (like `namespace { ... }`) are self-contained and don't persist their use
* statements between executions.
*
* Only save aliases for open namespaces (like `namespace Foo;`), or implicit namespace wrappers
* re-injected by PsySH (psyshReinjected).
*
* {@inheritdoc}
*/
public function leaveNode(Node $node)
{
if ($node instanceof Namespace_) {
// Open namespaces (like `namespace Foo;`) have kind == KIND_SEMICOLON.
if ($node->getAttribute('kind') === Namespace_::KIND_SEMICOLON || $node->getAttribute('psyshReinjected')) {
if ($this->cleaner) {
$this->cleaner->setAliasesForNamespace($node->name, $this->aliases);
}
}
$this->aliases = [];
}
return null;
}
/**
* Get a fully-qualified name (class, function, interface, etc).
*
* Resolves use statement aliases before applying namespace.
*
* @param mixed $name
*/
protected function getFullyQualifiedName($name): string
{
if ($name instanceof FullyQualifiedName) {
return \implode('\\', $this->getParts($name));
}
// Check if this name matches a use statement alias
if ($name instanceof Name) {
$nameParts = $this->getParts($name);
$firstPart = \strtolower($nameParts[0]);
if (isset($this->aliases[$firstPart])) {
// Replace first part with the aliased namespace
$aliasedParts = $this->getParts($this->aliases[$firstPart]);
\array_shift($nameParts); // Remove first part
return \implode('\\', \array_merge($aliasedParts, $nameParts));
}
}
if ($name instanceof Name) {
$name = $this->getParts($name);
} elseif (!\is_array($name)) {
$name = [$name];
}
return \implode('\\', \array_merge($this->namespace, $name));
}
/**
* Backwards compatibility shim for PHP-Parser 4.x.
*
* At some point we might want to make $namespace a plain string, to match how Name works?
*/
protected function getParts(Name $name): array
{
return \method_exists($name, 'getParts') ? $name->getParts() : $name->parts;
}
}

View File

@@ -0,0 +1,119 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\CodeCleaner;
use PhpParser\Node;
use PhpParser\Node\Name;
use PhpParser\Node\Stmt\Namespace_;
use Psy\CodeCleaner;
/**
* Provide implicit namespaces for subsequent execution.
*
* The namespace pass remembers the last standalone namespace line encountered:
*
* namespace Foo\Bar;
*
* ... which it then applies implicitly to all future evaluated code, until the
* namespace is replaced by another namespace. To reset to the top level
* namespace, enter `namespace {}`. This is a bit ugly, but it does the trick :)
*/
class NamespacePass extends NamespaceAwarePass
{
/**
* @param ?CodeCleaner $cleaner deprecated parameter, use setCleaner() instead
*
* @phpstan-ignore-next-line method.unused
*/
public function __construct(?CodeCleaner $cleaner = null)
{
// No-op, since cleaner is provided by NamespaceAwarePass
}
/**
* If this is a standalone namespace line, remember it for later.
*
* Otherwise, apply remembered namespaces to the code until a new namespace
* is encountered.
*
* @param array $nodes
*
* @return Node[]|null Array of nodes
*/
public function beforeTraverse(array $nodes)
{
if (empty($nodes)) {
return $nodes;
}
$last = \end($nodes);
if ($last instanceof Namespace_) {
$kind = $last->getAttribute('kind');
if ($kind === Namespace_::KIND_SEMICOLON) {
// Save the current namespace for open namespaces
$this->setNamespace($last->name);
} else {
// Clear the current namespace after a braced namespace
$this->setNamespace(null);
}
return $nodes;
}
// Wrap in current namespace if one is set
$currentNamespace = $this->getCurrentNamespace();
if (!$currentNamespace) {
return $nodes;
}
// Mark as re-injected so UseStatementPass knows it can re-inject use statements
return [new Namespace_($currentNamespace, $nodes, ['psyshReinjected' => true])];
}
/**
* Get the current namespace as a Name node.
*
* This is more complicated than it needs to be, because we're not storing namespace as a Name.
*
* @return Name|null
*/
private function getCurrentNamespace(): ?Name
{
$namespace = $this->cleaner->getNamespace();
return $namespace ? new Name($namespace) : null;
}
/**
* Update the namespace in CodeCleaner and clear aliases.
*
* @param Name|null $namespace
*/
private function setNamespace(?Name $namespace)
{
$this->cleaner->setNamespace($namespace);
// Always clear aliases when changing namespace
$this->cleaner->setAliasesForNamespace($namespace, []);
}
/**
* @deprecated unused and will be removed in a future version
*/
protected function getParts(Name $name): array
{
return \method_exists($name, 'getParts') ? $name->getParts() : $name->parts;
}
}

View File

@@ -0,0 +1,33 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\CodeCleaner;
use PhpParser\Node\Expr\New_;
use PhpParser\Node\Name\FullyQualified as FullyQualifiedName;
/**
* A class used internally by CodeCleaner to represent input, such as
* non-expression statements, with no return value.
*
* Note that user code returning an instance of this class will act like it
* has no return value, so you prolly shouldn't do that.
*/
class NoReturnValue
{
/**
* Get PhpParser AST expression for creating a new NoReturnValue.
*/
public static function create(): New_
{
return new New_(new FullyQualifiedName(self::class));
}
}

View File

@@ -0,0 +1,137 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\CodeCleaner;
use PhpParser\Node;
use PhpParser\Node\Expr;
use PhpParser\Node\Expr\Array_;
use PhpParser\Node\Expr\ArrayDimFetch;
use PhpParser\Node\Expr\ClassConstFetch;
use PhpParser\Node\Expr\FuncCall;
use PhpParser\Node\Expr\MethodCall;
use PhpParser\Node\Expr\PropertyFetch;
use PhpParser\Node\Expr\StaticCall;
use PhpParser\Node\Expr\Variable;
use PhpParser\Node\VariadicPlaceholder;
use Psy\Exception\FatalErrorException;
/**
* Validate that only variables (and variable-like things) are passed by reference.
*/
class PassableByReferencePass extends CodeCleanerPass
{
const EXCEPTION_MESSAGE = 'Only variables can be passed by reference';
/**
* @throws FatalErrorException if non-variables are passed by reference
*
* @param Node $node
*
* @return int|Node|null Replacement node (or special return value)
*/
public function enterNode(Node $node)
{
// @todo support MethodCall and StaticCall as well.
if ($node instanceof FuncCall) {
// if function name is an expression or a variable, give it a pass for now.
if ($node->name instanceof Expr || $node->name instanceof Variable) {
return null;
}
$name = (string) $node->name;
if ($name === 'array_multisort') {
return $this->validateArrayMultisort($node);
}
try {
$refl = new \ReflectionFunction($name);
} catch (\ReflectionException $e) {
// Well, we gave it a shot!
return null;
}
$args = [];
foreach ($node->args as $position => $arg) {
if ($arg instanceof VariadicPlaceholder) {
continue;
}
// Named arguments were added in php-parser 4.1, so we need to check if the property exists
$key = (\property_exists($arg, 'name') && $arg->name !== null) ? $arg->name->name : $position;
$args[$key] = $arg;
}
foreach ($refl->getParameters() as $key => $param) {
if (\array_key_exists($key, $args) || \array_key_exists($param->name, $args)) {
$arg = $args[$param->name] ?? $args[$key];
if ($param->isPassedByReference() && !$this->isPassableByReference($arg)) {
throw new FatalErrorException(self::EXCEPTION_MESSAGE, 0, \E_ERROR, null, $node->getStartLine());
}
}
}
}
return null;
}
private function isPassableByReference(Node $arg): bool
{
if (!\property_exists($arg, 'value')) {
return false;
}
// Unpacked arrays can be passed by reference
if ($arg->value instanceof Array_) {
return \property_exists($arg, 'unpack') && $arg->unpack;
}
// FuncCall, MethodCall and StaticCall are all PHP _warnings_ not fatal errors, so we'll let
// PHP handle those ones :)
return $arg->value instanceof ClassConstFetch ||
$arg->value instanceof PropertyFetch ||
$arg->value instanceof Variable ||
$arg->value instanceof FuncCall ||
$arg->value instanceof MethodCall ||
$arg->value instanceof StaticCall ||
$arg->value instanceof ArrayDimFetch;
}
/**
* Because array_multisort has a problematic signature...
*
* The argument order is all sorts of wonky, and whether something is passed
* by reference or not depends on the values of the two arguments before it.
* We'll do a good faith attempt at validating this, but err on the side of
* permissive.
*
* This is why you don't design languages where core code and extensions can
* implement APIs that wouldn't be possible in userland code.
*
* @throws FatalErrorException for clearly invalid arguments
*
* @param Node $node
*/
private function validateArrayMultisort(Node $node)
{
$nonPassable = 2; // start with 2 because the first one has to be passable by reference
foreach ($node->args as $arg) {
if ($this->isPassableByReference($arg)) {
$nonPassable = 0;
} elseif (++$nonPassable > 2) {
// There can be *at most* two non-passable-by-reference args in a row. This is about
// as close as we can get to validating the arguments for this function :-/
throw new FatalErrorException(self::EXCEPTION_MESSAGE, 0, \E_ERROR, null, $node->getStartLine());
}
}
}
}

View File

@@ -0,0 +1,138 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\CodeCleaner;
use PhpParser\Node;
use PhpParser\Node\Arg;
use PhpParser\Node\Expr\Include_;
use PhpParser\Node\Expr\StaticCall;
use PhpParser\Node\Name\FullyQualified as FullyQualifiedName;
use PhpParser\Node\Scalar\LNumber;
use Psy\Exception\ErrorException;
use Psy\Exception\FatalErrorException;
/**
* Add runtime validation for `require` and `require_once` calls.
*/
class RequirePass extends CodeCleanerPass
{
private const REQUIRE_TYPES = [Include_::TYPE_REQUIRE, Include_::TYPE_REQUIRE_ONCE];
/**
* {@inheritdoc}
*
* @return int|Node|null Replacement node (or special return value)
*/
public function enterNode(Node $origNode)
{
if (!$this->isRequireNode($origNode)) {
return null;
}
$node = clone $origNode;
/*
* rewrite
*
* $foo = require $bar
*
* to
*
* $foo = require \Psy\CodeCleaner\RequirePass::resolve($bar)
*/
// PHP-Parser 4.x uses LNumber, 5.x has LNumber as an alias to Int_
// Just use LNumber for compatibility with both versions
// @todo Switch to Int_ once we drop support for PHP-Parser 4.x
$arg = new LNumber($origNode->getStartLine());
$node->expr = new StaticCall(
new FullyQualifiedName(self::class),
'resolve',
[new Arg($origNode->expr), new Arg($arg)],
$origNode->getAttributes()
);
return $node;
}
/**
* Runtime validation that $file can be resolved as an include path.
*
* If $file can be resolved, return $file. Otherwise throw a fatal error exception.
*
* If $file collides with a path in the currently running PsySH phar, it will be resolved
* relative to the include path, to prevent PHP from grabbing the phar version of the file.
*
* @throws FatalErrorException when unable to resolve include path for $file
* @throws ErrorException if $file is empty and E_WARNING is included in error_reporting level
*
* @param string $file
* @param int $startLine Line number of the original require expression
*
* @return string Exactly the same as $file, unless $file collides with a path in the currently running phar
*/
public static function resolve($file, $startLine = null): string
{
$file = (string) $file;
if ($file === '') {
// @todo Shell::handleError would be better here, because we could
// fake the file and line number, but we can't call it statically.
// So we're duplicating some of the logics here.
if (\E_WARNING & \error_reporting()) {
ErrorException::throwException(\E_WARNING, 'Filename cannot be empty', null, $startLine);
}
// @todo trigger an error as fallback? this is pretty ugly…
// trigger_error('Filename cannot be empty', E_USER_WARNING);
}
$resolvedPath = \stream_resolve_include_path($file);
if ($file === '' || !$resolvedPath) {
$msg = \sprintf("Failed opening required '%s'", $file);
throw new FatalErrorException($msg, 0, \E_ERROR, null, $startLine);
}
// Special case: if the path is not already relative or absolute, and it would resolve to
// something inside the currently running phar (e.g. `vendor/autoload.php`), we'll resolve
// it relative to the include path so PHP won't grab the phar version.
//
// Note that this only works if the phar has `psysh` in the path. We might want to lift this
// restriction and special case paths that would collide with any running phar?
if ($resolvedPath !== $file && $file[0] !== '.') {
$runningPhar = \Phar::running();
if (\strpos($runningPhar, 'psysh') !== false && \is_file($runningPhar.\DIRECTORY_SEPARATOR.$file)) {
foreach (self::getIncludePath() as $prefix) {
$resolvedPath = $prefix.\DIRECTORY_SEPARATOR.$file;
if (\is_file($resolvedPath)) {
return $resolvedPath;
}
}
}
}
return $file;
}
private function isRequireNode(Node $node): bool
{
return $node instanceof Include_ && \in_array($node->type, self::REQUIRE_TYPES);
}
private static function getIncludePath(): array
{
if (\PATH_SEPARATOR === ':') {
return \preg_split('#:(?!//)#', \get_include_path());
}
return \explode(\PATH_SEPARATOR, \get_include_path());
}
}

View File

@@ -0,0 +1,123 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\CodeCleaner;
use PhpParser\Node;
use PhpParser\Node\Expr\Closure;
use PhpParser\Node\Expr\ConstFetch;
use PhpParser\Node\Identifier;
use PhpParser\Node\IntersectionType;
use PhpParser\Node\Name;
use PhpParser\Node\NullableType;
use PhpParser\Node\Stmt\Function_;
use PhpParser\Node\Stmt\Return_;
use PhpParser\Node\UnionType;
use Psy\Exception\FatalErrorException;
/**
* Add runtime validation for return types.
*/
class ReturnTypePass extends CodeCleanerPass
{
const MESSAGE = 'A function with return type must return a value';
const NULLABLE_MESSAGE = 'A function with return type must return a value (did you mean "return null;" instead of "return;"?)';
const VOID_MESSAGE = 'A void function must not return a value';
const VOID_NULL_MESSAGE = 'A void function must not return a value (did you mean "return;" instead of "return null;"?)';
const NULLABLE_VOID_MESSAGE = 'Void type cannot be nullable';
private array $returnTypeStack = [];
/**
* {@inheritdoc}
*
* @return int|Node|null Replacement node (or special return value)
*/
public function enterNode(Node $node)
{
if ($this->isFunctionNode($node)) {
$this->returnTypeStack[] = \property_exists($node, 'returnType') ? $node->returnType : null;
return null;
}
if (!empty($this->returnTypeStack) && $node instanceof Return_) {
$expectedType = \end($this->returnTypeStack);
if ($expectedType === null) {
return null;
}
$msg = null;
if ($this->typeName($expectedType) === 'void') {
// Void functions
if ($expectedType instanceof NullableType) {
$msg = self::NULLABLE_VOID_MESSAGE;
} elseif ($node->expr instanceof ConstFetch && \strtolower($node->expr->name) === 'null') {
$msg = self::VOID_NULL_MESSAGE;
} elseif ($node->expr !== null) {
$msg = self::VOID_MESSAGE;
}
} else {
// Everything else
if ($node->expr === null) {
$msg = $expectedType instanceof NullableType ? self::NULLABLE_MESSAGE : self::MESSAGE;
}
}
if ($msg !== null) {
throw new FatalErrorException($msg, 0, \E_ERROR, null, $node->getStartLine());
}
}
return null;
}
/**
* {@inheritdoc}
*
* @return int|Node|Node[]|null Replacement node (or special return value)
*/
public function leaveNode(Node $node)
{
if (!empty($this->returnTypeStack) && $this->isFunctionNode($node)) {
\array_pop($this->returnTypeStack);
}
return null;
}
private function isFunctionNode(Node $node): bool
{
return $node instanceof Function_ || $node instanceof Closure;
}
private function typeName(Node $node): string
{
if ($node instanceof UnionType) {
return \implode('|', \array_map([$this, 'typeName'], $node->types));
}
if ($node instanceof IntersectionType) {
return \implode('&', \array_map([$this, 'typeName'], $node->types));
}
if ($node instanceof NullableType) {
return $this->typeName($node->type);
}
if ($node instanceof Identifier || $node instanceof Name) {
return $node->toLowerString();
}
throw new \InvalidArgumentException('Unable to find type name');
}
}

View File

@@ -0,0 +1,94 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\CodeCleaner;
use PhpParser\Node;
use PhpParser\Node\DeclareItem;
use PhpParser\Node\Scalar\Int_;
use PhpParser\Node\Scalar\LNumber;
use PhpParser\Node\Stmt\Declare_;
use PhpParser\Node\Stmt\DeclareDeclare;
use Psy\Exception\FatalErrorException;
/**
* Provide implicit strict types declarations for for subsequent execution.
*
* The strict types pass remembers the last strict types declaration:
*
* declare(strict_types=1);
*
* ... which it then applies implicitly to all future evaluated code, until it
* is replaced by a new declaration.
*/
class StrictTypesPass extends CodeCleanerPass
{
const EXCEPTION_MESSAGE = 'strict_types declaration must have 0 or 1 as its value';
private bool $strictTypes;
/**
* @param bool $strictTypes enforce strict types by default
*/
public function __construct(bool $strictTypes = false)
{
$this->strictTypes = $strictTypes;
}
/**
* If this is a standalone strict types declaration, remember it for later.
*
* Otherwise, apply remembered strict types declaration to to the code until
* a new declaration is encountered.
*
* @throws FatalErrorException if an invalid `strict_types` declaration is found
*
* @param array $nodes
*
* @return Node[]|null Array of nodes
*/
public function beforeTraverse(array $nodes)
{
$prependStrictTypes = $this->strictTypes;
foreach ($nodes as $node) {
if ($node instanceof Declare_) {
foreach ($node->declares as $declare) {
if ($declare->key->toString() === 'strict_types') {
$value = $declare->value;
// @todo Remove LNumber once we drop support for PHP-Parser 4.x
if ((!$value instanceof LNumber && !$value instanceof Int_) || ($value->value !== 0 && $value->value !== 1)) {
throw new FatalErrorException(self::EXCEPTION_MESSAGE, 0, \E_ERROR, null, $node->getStartLine());
}
$this->strictTypes = $value->value === 1;
}
}
}
}
if ($prependStrictTypes) {
$first = \reset($nodes);
if (!$first instanceof Declare_) {
// @todo Switch to PhpParser\Node\DeclareItem once we drop support for PHP-Parser 4.x
// @todo Remove LNumber once we drop support for PHP-Parser 4.x
$arg = \class_exists('PhpParser\Node\Scalar\Int_') ? new Int_(1) : new LNumber(1);
$declareItem = \class_exists('PhpParser\Node\DeclareItem') ?
new DeclareItem('strict_types', $arg) :
new DeclareDeclare('strict_types', $arg);
$declare = new Declare_([$declareItem]);
\array_unshift($nodes, $declare);
}
}
return $nodes;
}
}

View File

@@ -0,0 +1,155 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\CodeCleaner;
use PhpParser\Node;
use PhpParser\Node\Identifier;
use PhpParser\Node\Name; // @phan-suppress-current-line PhanUnreferencedUseNormal - used for type checks
use PhpParser\Node\Stmt\Namespace_;
use PhpParser\Node\Stmt\Use_;
use PhpParser\Node\Stmt\UseItem;
use PhpParser\Node\Stmt\UseUse;
use Psy\Exception\FatalErrorException;
/**
* Provide implicit use statements for subsequent execution.
*
* The use statement pass remembers the last use statement line encountered:
*
* use Foo\Bar as Baz;
*
* ... which it then applies implicitly to all future evaluated code, until the
* current namespace is replaced by another namespace.
*
* Extends NamespaceAwarePass to leverage shared alias tracking.
*/
class UseStatementPass extends NamespaceAwarePass
{
/**
* {@inheritdoc}
*/
public function enterNode(Node $node)
{
// Check for use statement conflicts BEFORE parent adds it to aliases
// Skip re-injected use statements (marked with 'psyshReinjected' attribute)
if ($node instanceof Use_ && !$node->getAttribute('psyshReinjected')) {
$this->validateUseStatement($node);
}
return parent::enterNode($node);
}
/**
* Re-inject use statements from previous inputs.
*
* Each REPL input is evaluated separately; re-injecting use statements matches PHP behavior for
* namespaces and use statements in a file.
*
* @return Node[]|null Array of nodes
*/
public function beforeTraverse(array $nodes)
{
parent::beforeTraverse($nodes);
if (!$this->cleaner) {
return null;
}
// Check for namespace declarations in the input
foreach ($nodes as $node) {
if ($node instanceof Namespace_) {
// Only re-inject use statements if this is a wrapper created by NamespacePass.
// This matches PHP behavior: explicit namespace declaration clears use statements.
if ($node->getAttribute('psyshReinjected')) {
$aliases = $this->cleaner->getAliasesForNamespace($node->name);
if (!empty($aliases)) {
$useStatements = $this->createUseStatements($aliases);
$node->stmts = \array_merge($useStatements, $node->stmts ?? []);
}
}
// Don't process other nodes or return modified nodes
return null;
}
}
// No namespace declaration in input, or re-applied by NamespacePass; re-inject use
// statements for the empty namespace.
$aliases = $this->cleaner->getAliasesForNamespace(null);
if (!empty($aliases)) {
$useStatements = $this->createUseStatements($aliases);
$nodes = \array_merge($useStatements, $nodes);
}
return $nodes;
}
/**
* If we have aliases but didn't leave a namespace (global namespace case), persist them to
* CodeCleaner for the next traversal.
*
* {@inheritdoc}
*/
public function afterTraverse(array $nodes)
{
if (!$this->cleaner) {
return null;
}
// Persist aliases if they're at the global level (not inside any namespace)
if (!empty($this->aliases)) {
$this->cleaner->setAliasesForNamespace(null, $this->aliases);
}
return null;
}
/**
* Validate that a use statement doesn't conflict with existing aliases.
*
* @throws FatalErrorException if the alias is already in use
*
* @param Use_ $stmt The use statement node
*/
private function validateUseStatement(Use_ $stmt): void
{
foreach ($stmt->uses as $useItem) {
$alias = \strtolower($useItem->getAlias());
if (isset($this->aliases[$alias])) {
throw new FatalErrorException(\sprintf('Cannot use %s as %s because the name is already in use', $useItem->name->toString(), $useItem->getAlias()), 0, \E_ERROR, null, $stmt->getStartLine());
}
}
}
/**
* Create use statement nodes from stored aliases.
*
* @param array $aliases Map of lowercase alias names to Name nodes
*
* @return Use_[] Array of use statement nodes
*/
private function createUseStatements(array $aliases): array
{
$useStatements = [];
foreach ($aliases as $alias => $name) {
// Create UseItem (PHP-Parser 5.x) or UseUse (PHP-Parser 4.x)
$useItem = \class_exists(UseItem::class)
? new UseItem($name, new Identifier($alias))
: new UseUse($name, $alias);
// Mark as re-injected so we don't validate it
$useStatements[] = new Use_([$useItem], Use_::TYPE_NORMAL, ['psyshReinjected' => true]);
}
return $useStatements;
}
}

View File

@@ -0,0 +1,332 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\CodeCleaner;
use PhpParser\Node;
use PhpParser\Node\Expr;
use PhpParser\Node\Expr\Ternary;
use PhpParser\Node\Stmt;
use PhpParser\Node\Stmt\Class_;
use PhpParser\Node\Stmt\Do_;
use PhpParser\Node\Stmt\If_;
use PhpParser\Node\Stmt\Interface_;
use PhpParser\Node\Stmt\Switch_;
use PhpParser\Node\Stmt\Trait_;
use PhpParser\Node\Stmt\While_;
use Psy\Exception\FatalErrorException;
/**
* Validate that classes exist.
*
* This pass throws a FatalErrorException rather than letting PHP run
* headfirst into a real fatal error and die.
*/
class ValidClassNamePass extends NamespaceAwarePass
{
const CLASS_TYPE = 'class';
const INTERFACE_TYPE = 'interface';
const TRAIT_TYPE = 'trait';
private int $conditionalScopes = 0;
/**
* Validate class, interface and trait definitions.
*
* Validate them upon entering the node, so that we know about their
* presence and can validate constant fetches and static calls in class or
* trait methods.
*
* @param Node $node
*
* @return int|Node|null Replacement node (or special return value)
*/
public function enterNode(Node $node)
{
parent::enterNode($node);
if (self::isConditional($node)) {
$this->conditionalScopes++;
return null;
}
if ($this->conditionalScopes === 0) {
if ($node instanceof Class_) {
$this->validateClassStatement($node);
} elseif ($node instanceof Interface_) {
$this->validateInterfaceStatement($node);
} elseif ($node instanceof Trait_) {
$this->validateTraitStatement($node);
}
}
return null;
}
/**
* @param Node $node
*
* @return int|Node|Node[]|null Replacement node (or special return value)
*/
public function leaveNode(Node $node)
{
if (self::isConditional($node)) {
$this->conditionalScopes--;
}
return null;
}
private static function isConditional(Node $node): bool
{
return $node instanceof If_ ||
$node instanceof While_ ||
$node instanceof Do_ ||
$node instanceof Switch_ ||
$node instanceof Ternary;
}
/**
* Validate a class definition statement.
*
* @param Class_ $stmt
*/
protected function validateClassStatement(Class_ $stmt)
{
$this->ensureCanDefine($stmt, self::CLASS_TYPE);
if (isset($stmt->extends)) {
$this->ensureClassExists($this->getFullyQualifiedName($stmt->extends), $stmt);
}
$this->ensureInterfacesExist($stmt->implements, $stmt);
}
/**
* Validate an interface definition statement.
*
* @param Interface_ $stmt
*/
protected function validateInterfaceStatement(Interface_ $stmt)
{
$this->ensureCanDefine($stmt, self::INTERFACE_TYPE);
$this->ensureInterfacesExist($stmt->extends, $stmt);
}
/**
* Validate a trait definition statement.
*
* @param Trait_ $stmt
*/
protected function validateTraitStatement(Trait_ $stmt)
{
$this->ensureCanDefine($stmt, self::TRAIT_TYPE);
}
/**
* Ensure that no class, interface or trait name collides with a new definition.
*
* @throws FatalErrorException
*
* @param Stmt $stmt
* @param string $scopeType
*/
protected function ensureCanDefine(Stmt $stmt, string $scopeType = self::CLASS_TYPE)
{
// Anonymous classes don't have a name, and uniqueness shouldn't be enforced.
if (!\property_exists($stmt, 'name') || $stmt->name === null) {
return;
}
$name = $this->getFullyQualifiedName($stmt->name);
// check for name collisions
$errorType = null;
if ($this->classExists($name)) {
$errorType = self::CLASS_TYPE;
} elseif ($this->interfaceExists($name)) {
$errorType = self::INTERFACE_TYPE;
} elseif ($this->traitExists($name)) {
$errorType = self::TRAIT_TYPE;
}
if ($errorType !== null) {
throw $this->createError(\sprintf('%s named %s already exists', \ucfirst($errorType), $name), $stmt);
}
// Store creation for the rest of this code snippet so we can find local
// issue too
$this->currentScope[\strtolower($name)] = $scopeType;
}
/**
* Ensure that a referenced class exists.
*
* @throws FatalErrorException
*
* @param string $name
* @param Stmt $stmt
*/
protected function ensureClassExists(string $name, Stmt $stmt)
{
if (!$this->classExists($name)) {
throw $this->createError(\sprintf('Class \'%s\' not found', $name), $stmt);
}
}
/**
* Ensure that a referenced class _or interface_ exists.
*
* @throws FatalErrorException
*
* @param string $name
* @param Stmt $stmt
*/
protected function ensureClassOrInterfaceExists(string $name, Stmt $stmt)
{
if (!$this->classExists($name) && !$this->interfaceExists($name)) {
throw $this->createError(\sprintf('Class \'%s\' not found', $name), $stmt);
}
}
/**
* Ensure that a referenced class _or trait_ exists.
*
* @throws FatalErrorException
*
* @param string $name
* @param Stmt $stmt
*/
protected function ensureClassOrTraitExists(string $name, Stmt $stmt)
{
if (!$this->classExists($name) && !$this->traitExists($name)) {
throw $this->createError(\sprintf('Class \'%s\' not found', $name), $stmt);
}
}
/**
* Ensure that a statically called method exists.
*
* @throws FatalErrorException
*
* @param string $class
* @param string $name
* @param Stmt $stmt
*/
protected function ensureMethodExists(string $class, string $name, Stmt $stmt)
{
$this->ensureClassOrTraitExists($class, $stmt);
// let's pretend all calls to self, parent and static are valid
if (\in_array(\strtolower($class), ['self', 'parent', 'static'])) {
return;
}
// ... and all calls to classes defined right now
if ($this->findInScope($class) === self::CLASS_TYPE) {
return;
}
// if method name is an expression, give it a pass for now
if ($name instanceof Expr) {
return;
}
if (!\method_exists($class, $name) && !\method_exists($class, '__callStatic')) {
throw $this->createError(\sprintf('Call to undefined method %s::%s()', $class, $name), $stmt);
}
}
/**
* Ensure that a referenced interface exists.
*
* @throws FatalErrorException
*
* @param Interface_[] $interfaces
* @param Stmt $stmt
*/
protected function ensureInterfacesExist(array $interfaces, Stmt $stmt)
{
foreach ($interfaces as $interface) {
/** @var string $name */
$name = $this->getFullyQualifiedName($interface);
if (!$this->interfaceExists($name)) {
throw $this->createError(\sprintf('Interface \'%s\' not found', $name), $stmt);
}
}
}
/**
* Check whether a class exists, or has been defined in the current code snippet.
*
* Gives `self`, `static` and `parent` a free pass.
*
* @param string $name
*/
protected function classExists(string $name): bool
{
// Give `self`, `static` and `parent` a pass. This will actually let
// some errors through, since we're not checking whether the keyword is
// being used in a class scope.
if (\in_array(\strtolower($name), ['self', 'static', 'parent'])) {
return true;
}
return \class_exists($name) || $this->findInScope($name) === self::CLASS_TYPE;
}
/**
* Check whether an interface exists, or has been defined in the current code snippet.
*
* @param string $name
*/
protected function interfaceExists(string $name): bool
{
return \interface_exists($name) || $this->findInScope($name) === self::INTERFACE_TYPE;
}
/**
* Check whether a trait exists, or has been defined in the current code snippet.
*
* @param string $name
*/
protected function traitExists(string $name): bool
{
return \trait_exists($name) || $this->findInScope($name) === self::TRAIT_TYPE;
}
/**
* Find a symbol in the current code snippet scope.
*
* @param string $name
*
* @return string|null
*/
protected function findInScope(string $name)
{
$name = \strtolower($name);
if (isset($this->currentScope[$name])) {
return $this->currentScope[$name];
}
return null;
}
/**
* Error creation factory.
*
* @param string $msg
* @param Stmt $stmt
*/
protected function createError(string $msg, Stmt $stmt): FatalErrorException
{
return new FatalErrorException($msg, 0, \E_ERROR, null, $stmt->getStartLine());
}
}

View File

@@ -0,0 +1,125 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\CodeCleaner;
use PhpParser\Node;
use PhpParser\Node\Name;
use PhpParser\Node\Stmt\Class_;
use PhpParser\Node\Stmt\ClassMethod;
use PhpParser\Node\Stmt\Namespace_;
use Psy\Exception\FatalErrorException;
/**
* Validate that the constructor method is not static, and does not have a
* return type.
*
* Checks both explicit __construct methods as well as old-style constructor
* methods with the same name as the class (for non-namespaced classes).
*
* As of PHP 5.3.3, methods with the same name as the last element of a
* namespaced class name will no longer be treated as constructor. This change
* doesn't affect non-namespaced classes.
*
* @author Martin Hasoň <martin.hason@gmail.com>
*/
class ValidConstructorPass extends CodeCleanerPass
{
private array $namespace = [];
/**
* @return Node[]|null Array of nodes
*/
public function beforeTraverse(array $nodes)
{
$this->namespace = [];
return null;
}
/**
* Validate that the constructor is not static and does not have a return type.
*
* @throws FatalErrorException the constructor function is static
* @throws FatalErrorException the constructor function has a return type
*
* @param Node $node
*
* @return int|Node|null Replacement node (or special return value)
*/
public function enterNode(Node $node)
{
if ($node instanceof Namespace_) {
$this->namespace = isset($node->name) ? $this->getParts($node->name) : [];
} elseif ($node instanceof Class_) {
$constructor = null;
foreach ($node->stmts as $stmt) {
if ($stmt instanceof ClassMethod) {
// If we find a new-style constructor, no need to look for the old-style
if (\property_exists($stmt, 'name') && \strtolower($stmt->name) === '__construct') {
$this->validateConstructor($stmt, $node);
return null;
}
// We found a possible old-style constructor (unless there is also a __construct method)
if (empty($this->namespace) && $node->name !== null && \property_exists($stmt, 'name') && \strtolower($node->name) === \strtolower($stmt->name)) {
$constructor = $stmt;
}
}
}
if ($constructor) {
$this->validateConstructor($constructor, $node);
}
}
return null;
}
/**
* @throws FatalErrorException the constructor function is static
* @throws FatalErrorException the constructor function has a return type
*
* @param Node $constructor
* @param Node $classNode
*/
private function validateConstructor(Node $constructor, Node $classNode)
{
if (\method_exists($constructor, 'isStatic') && $constructor->isStatic()) {
$msg = \sprintf(
'Constructor %s::%s() cannot be static',
\implode('\\', \array_merge($this->namespace, (array) $classNode->name->toString())),
\property_exists($constructor, 'name') ? $constructor->name : '__construct'
);
throw new FatalErrorException($msg, 0, \E_ERROR, null, $classNode->getStartLine());
}
if (\method_exists($constructor, 'getReturnType') && $constructor->getReturnType()) {
$msg = \sprintf(
'Constructor %s::%s() cannot declare a return type',
\implode('\\', \array_merge($this->namespace, (array) $classNode->name->toString())),
\property_exists($constructor, 'name') ? $constructor->name : '__construct'
);
throw new FatalErrorException($msg, 0, \E_ERROR, null, $classNode->getStartLine());
}
}
/**
* Backwards compatibility shim for PHP-Parser 4.x.
*
* At some point we might want to make $namespace a plain string, to match how Name works?
*/
protected function getParts(Name $name): array
{
return \method_exists($name, 'getParts') ? $name->getParts() : $name->parts;
}
}

View File

@@ -0,0 +1,87 @@
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\CodeCleaner;
use PhpParser\Node;
use PhpParser\Node\Stmt\Do_;
use PhpParser\Node\Stmt\Function_;
use PhpParser\Node\Stmt\If_;
use PhpParser\Node\Stmt\Switch_;
use PhpParser\Node\Stmt\While_;
use Psy\Exception\FatalErrorException;
/**
* Validate that function calls will succeed.
*
* This pass throws a FatalErrorException rather than letting PHP run
* headfirst into a real fatal error and die.
*/
class ValidFunctionNamePass extends NamespaceAwarePass
{
private int $conditionalScopes = 0;
/**
* Store newly defined function names on the way in, to allow recursion.
*
* @throws FatalErrorException if a function is redefined in a non-conditional scope
*
* @param Node $node
*
* @return int|Node|null Replacement node (or special return value)
*/
public function enterNode(Node $node)
{
parent::enterNode($node);
if (self::isConditional($node)) {
$this->conditionalScopes++;
} elseif ($node instanceof Function_) {
$name = $this->getFullyQualifiedName($node->name);
// @todo add an "else" here which adds a runtime check for instances where we can't tell
// whether a function is being redefined by static analysis alone.
if ($this->conditionalScopes === 0) {
if (\function_exists($name) ||
isset($this->currentScope[\strtolower($name)])) {
$msg = \sprintf('Cannot redeclare %s()', $name);
throw new FatalErrorException($msg, 0, \E_ERROR, null, $node->getStartLine());
}
}
$this->currentScope[\strtolower($name)] = true;
}
return null;
}
/**
* @param Node $node
*
* @return int|Node|Node[]|null Replacement node (or special return value)
*/
public function leaveNode(Node $node)
{
if (self::isConditional($node)) {
$this->conditionalScopes--;
}
return null;
}
private static function isConditional(Node $node)
{
return $node instanceof If_ ||
$node instanceof While_ ||
$node instanceof Do_ ||
$node instanceof Switch_;
}
}