src/Phan/Parse/ParseVisitor.php
<?php
declare(strict_types=1);
namespace Phan\Parse;
use AssertionError;
use ast;
use ast\Node;
use InvalidArgumentException;
use Phan\Analysis\ScopeVisitor;
use Phan\AST\ASTReverter;
use Phan\AST\ContextNode;
use Phan\AST\UnionTypeVisitor;
use Phan\CodeBase;
use Phan\Config;
use Phan\Daemon;
use Phan\Exception\FQSENException;
use Phan\Exception\IssueException;
use Phan\Exception\UnanalyzableException;
use Phan\Issue;
use Phan\Language\Context;
use Phan\Language\Element\ClassConstant;
use Phan\Language\Element\Clazz;
use Phan\Language\Element\Comment;
use Phan\Language\Element\Func;
use Phan\Language\Element\FunctionFactory;
use Phan\Language\Element\FunctionInterface;
use Phan\Language\Element\GlobalConstant;
use Phan\Language\Element\Method;
use Phan\Language\Element\Parameter;
use Phan\Language\Element\Property;
use Phan\Language\ElementContext;
use Phan\Language\FQSEN\FullyQualifiedClassConstantName;
use Phan\Language\FQSEN\FullyQualifiedClassName;
use Phan\Language\FQSEN\FullyQualifiedFunctionName;
use Phan\Language\FQSEN\FullyQualifiedGlobalConstantName;
use Phan\Language\FQSEN\FullyQualifiedMethodName;
use Phan\Language\FQSEN\FullyQualifiedPropertyName;
use Phan\Language\FutureUnionType;
use Phan\Language\Type;
use Phan\Language\Type\ArrayShapeType;
use Phan\Language\Type\ArrayType;
use Phan\Language\Type\CallableObjectType;
use Phan\Language\Type\MixedType;
use Phan\Language\Type\NullType;
use Phan\Language\Type\StringType;
use Phan\Language\UnionType;
use Phan\Library\FileCache;
use Phan\Library\None;
/**
* The class is a visitor for AST nodes that does parsing. Each
* visitor populates the $code_base with any
* globally accessible structural elements and will return a
* possibly new context as modified by the given node.
*
* @property-read CodeBase $code_base
*
* @phan-file-suppress PhanUnusedPublicMethodParameter implementing faster no-op methods for common visit*
* @phan-file-suppress PhanPartialTypeMismatchArgument
* @phan-file-suppress PhanPartialTypeMismatchArgumentInternal
*
* @method Context __invoke(Node $node)
*/
class ParseVisitor extends ScopeVisitor
{
/**
* @param Context $context
* The context of the parser at the node for which we'd
* like to determine a type
*
* @param CodeBase $code_base
* The global code base in which we store all
* state
*/
/*
public function __construct(
CodeBase $code_base,
Context $context
) {
parent::__construct($code_base, $context);
}
*/
/**
* Visit a node with kind `\ast\AST_CLASS`
*
* @param Node $node
* A node to parse
*
* @return Context
* A new or an unchanged context resulting from
* parsing the node
* @throws FQSENException if the node has invalid names
*/
public function visitClass(Node $node): Context
{
if ($node->flags & \ast\flags\CLASS_ANONYMOUS) {
$class_name = (new ContextNode(
$this->code_base,
$this->context,
$node
))->getUnqualifiedNameForAnonymousClass();
} else {
$class_name = (string)$node->children['name'];
}
// This happens now and then and I have no idea
// why.
if ($class_name === '') {
return $this->context;
}
$class_fqsen = FullyQualifiedClassName::fromStringInContext(
$class_name,
$this->context
);
// Hunt for an available alternate ID if necessary
$alternate_id = 0;
while ($this->code_base->hasClassWithFQSEN($class_fqsen)) {
$class_fqsen = $class_fqsen->withAlternateId(++$alternate_id);
}
if ($alternate_id > 0) {
Daemon::debugf("Using an alternate for %s: %d\n", $class_fqsen, $alternate_id);
}
// Build the class from what we know so far
$class_context = $this->context
->withLineNumberStart($node->lineno ?? 0)
->withLineNumberEnd($node->endLineno ?? 0);
$class = new Clazz(
$class_context,
$class_name,
$class_fqsen->asRealUnionType(),
$node->flags,
$class_fqsen
);
$class->setDeclId($node->children['__declId']);
$class->setDidFinishParsing(false);
try {
// Set the scope of the class's context to be the
// internal scope of the class
$class_context = $class_context->withScope(
$class->getInternalScope()
);
$doc_comment = $node->children['docComment'] ?? '';
$class->setDocComment($doc_comment);
// Add the class to the code base as a globally
// accessible object
// This must be done before Comment::fromStringInContext
// so that the class definition is available there.
$this->code_base->addClass($class);
// Get a comment on the class declaration
$comment = Comment::fromStringInContext(
$doc_comment,
$this->code_base,
$class_context,
$node->lineno,
Comment::ON_CLASS
);
// Add any template types parameterizing a generic class
foreach ($comment->getTemplateTypeList() as $template_type) {
$class->getInternalScope()->addTemplateType($template_type);
}
// Handle @phan-immutable, @deprecated, @internal,
// @phan-forbid-undeclared-magic-properties, and @phan-forbid-undeclared-magic-methods
$class->setPhanFlags($class->getPhanFlags() | $comment->getPhanFlagsForClass());
$class->setSuppressIssueSet(
$comment->getSuppressIssueSet()
);
// Depends on code_base for checking existence of __get and __set.
// TODO: Add a check in analyzeClasses phase that magic @property declarations
// are limited to classes with either __get or __set declared (or interface/abstract
$class->setMagicPropertyMap(
$comment->getMagicPropertyMap(),
$this->code_base
);
// Depends on code_base for checking existence of __call or __callStatic.
// TODO: Add a check in analyzeClasses phase that magic @method declarations
// are limited to classes with either __get or __set declared (or interface/abstract)
$class->setMagicMethodMap(
$comment->getMagicMethodMap(),
$this->code_base
);
// Look to see if we have a parent class
$extends_node = $node->children['extends'] ?? null;
if ($extends_node instanceof Node) {
$parent_class_name = (string)UnionTypeVisitor::unionTypeFromClassNode($this->code_base, $this->context, $extends_node);
// The name is fully qualified.
$parent_fqsen = FullyQualifiedClassName::fromFullyQualifiedString(
$parent_class_name
);
// Set the parent for the class
$class->setParentType($parent_fqsen->asType(), $extends_node->lineno);
}
// If the class explicitly sets its overriding extension type,
// set that on the class
$inherited_type_option = $comment->getInheritedTypeOption();
if ($inherited_type_option->isDefined()) {
$class->setParentType($inherited_type_option->get());
}
$class->setMixinTypes($comment->getMixinTypes());
// Add any implemented interfaces
foreach ($node->children['implements']->children ?? [] as $name_node) {
if (!$name_node instanceof Node) {
throw new AssertionError('Expected list of AST_NAME nodes');
}
$name = (string)UnionTypeVisitor::unionTypeFromClassNode($this->code_base, $this->context, $name_node);
$class->addInterfaceClassFQSEN(
FullyQualifiedClassName::fromFullyQualifiedString(
$name
),
$name_node->lineno
);
}
} finally {
$class->setDidFinishParsing(true);
}
return $class_context;
}
/**
* Visit a node with kind `\ast\AST_USE_TRAIT`
*
* @param Node $node
* A node to parse
*
* @return Context
* A new or an unchanged context resulting from
* parsing the node
*
* @throws UnanalyzableException if saw an invalid AST node (e.g. from polyfill)
*/
public function visitUseTrait(Node $node): Context
{
// Bomb out if we're not in a class context
$class = $this->getContextClass();
// @phan-suppress-next-line PhanThrowTypeMismatchForCall should be impossible
$trait_fqsen_list = (new ContextNode(
$this->code_base,
$this->context,
$node->children['traits']
))->getTraitFQSENList();
// Add each trait to the class
foreach ($trait_fqsen_list as $trait_fqsen) {
$class->addTraitFQSEN($trait_fqsen, $node->children['traits']->lineno ?? 0);
}
// Get the adaptations for those traits
// Pass in the corresponding FQSENs for those traits.
$trait_adaptations_map = (new ContextNode(
$this->code_base,
$this->context,
$node->children['adaptations']
))->getTraitAdaptationsMap($trait_fqsen_list);
foreach ($trait_adaptations_map as $trait_adaptations) {
$class->addTraitAdaptations($trait_adaptations);
}
return $this->context;
}
/**
* Visit a node with kind `\ast\AST_METHOD`
*
* @param Node $node
* A node to parse
*
* @return Context
* A new or an unchanged context resulting from
* parsing the node
*/
public function visitMethod(Node $node): Context
{
// Bomb out if we're not in a class context
$class = $this->getContextClass();
$context = $this->context;
$code_base = $this->code_base;
$method_name = (string)$node->children['name'];
$method_fqsen = FullyQualifiedMethodName::make(
$class->getFQSEN(),
$method_name
);
// Hunt for an available alternate ID if necessary
$alternate_id = 0;
while ($code_base->hasMethodWithFQSEN($method_fqsen)) {
$method_fqsen =
$method_fqsen->withAlternateId(++$alternate_id);
}
$method = Method::fromNode(
clone($context),
$code_base,
$node,
$method_fqsen,
$class
);
if ($context->isPHPInternal()) {
// only for stubs
foreach (FunctionFactory::functionListFromFunction($method) as $method_variant) {
if (!($method_variant instanceof Method)) {
throw new AssertionError("Expected variants of Method to be Method");
}
$class->addMethod($code_base, $method_variant, None::instance());
}
} else {
$class->addMethod($code_base, $method, None::instance());
}
$method_name_lower = \strtolower($method_name);
if ('__construct' === $method_name_lower) {
$class->setIsParentConstructorCalled(false);
// Handle constructor property promotion of __construct parameters
foreach ($method->getParameterList() as $i => $parameter) {
if ($parameter->getFlags() & Parameter::PARAM_MODIFIER_VISIBILITY_FLAGS) {
// @phan-suppress-next-line PhanTypeMismatchArgumentNullable kind is AST_PARAM
$this->addPromotedConstructorPropertyFromParam($class, $method, $parameter, $node->children['params']->children[$i]);
}
}
} elseif ('__invoke' === $method_name_lower) {
// TODO: More precise callable shape
$class->addAdditionalType(CallableObjectType::instance(false));
} elseif ('__tostring' === $method_name_lower
&& !$this->context->isStrictTypes()
) {
$class->addAdditionalType(StringType::instance(false));
}
// Create a new context with a new scope
return $this->context->withScope($method->getInternalScope());
}
/**
* Add an instance property from php 8.0 constructor property promotion
* (`__construct(public int $param)`)
*
* This heavily duplicates parts of visitPropGroup
*/
private function addPromotedConstructorPropertyFromParam(
Clazz $class,
Method $method,
Parameter $parameter,
Node $parameter_node
): void {
$lineno = $parameter_node->lineno;
$context = (clone($this->context))->withLineNumberStart($lineno);
if ($parameter_node->flags & ast\flags\PARAM_VARIADIC) {
$this->emitIssue(
Issue::InvalidNode,
$lineno,
"Cannot declare variadic promoted property"
);
return;
}
// TODO support attributes in Phan 4
// TODO: this should probably use FutureUnionType instead.
$doc_comment = $parameter_node->children['docComment'] ?? '';
$name = $parameter->getName();
$method_comment = $method->getComment();
$variable_comment = $method_comment ? ($method_comment->getParameterMap()[$name] ?? null) : null;
$property_comment = Comment::fromStringInContext(
$doc_comment,
$this->code_base,
$this->context,
$lineno,
Comment::ON_PROPERTY
);
$property = $this->addProperty(
$class,
$parameter->getName(),
$parameter_node->children['default'],
$parameter->getUnionType()->getRealUnionType(),
$variable_comment,
$lineno,
$parameter_node->flags & Parameter::PARAM_MODIFIER_VISIBILITY_FLAGS,
$doc_comment,
$property_comment
);
if (!$property) {
return;
}
// Get a comment on the property declaration
$property->setHasWriteReference(); // Assigned from within constructor
$property->addReference($context); // Assigned from within constructor
if ($class->isImmutable()) {
if (!$property->isStatic() && !$property->isWriteOnly()) {
$property->setIsReadOnly(true);
}
}
}
/**
* Visit a node with kind `\ast\AST_PROP_GROUP`
*
* @param Node $node
* A node to parse
*
* @return Context
* A new or an unchanged context resulting from
* parsing the node
*/
public function visitPropGroup(Node $node): Context
{
// Bomb out if we're not in a class context
['props' => $props_node, 'type' => $type_node] = $node->children;
if (!$props_node instanceof Node) {
throw new AssertionError('Expected list of properties to be a node');
}
if ($type_node) {
try {
// Normalize to normalize php8 union types such as int|false|null to ?int|?false
$real_union_type = (new UnionTypeVisitor($this->code_base, $this->context))->fromTypeInSignature($type_node)->asNormalizedTypes();
} catch (IssueException $e) {
Issue::maybeEmitInstance($this->code_base, $this->context, $e->getIssueInstance());
$real_union_type = UnionType::empty();
}
if (Config::get_closest_minimum_target_php_version_id() < 70400) {
$this->emitIssue(
Issue::CompatibleTypedProperty,
$type_node->lineno,
((string)$real_union_type) ?: '(unknown)'
);
}
} else {
$real_union_type = UnionType::empty();
}
$class = $this->getContextClass();
$doc_comment = '';
$first_child_node = $props_node->children[0] ?? null;
if ($first_child_node instanceof Node) {
$doc_comment = $first_child_node->children['docComment'] ?? '';
}
// Get a comment on the property declaration
$comment = Comment::fromStringInContext(
$doc_comment,
$this->code_base,
$this->context,
$props_node->lineno ?? 0,
Comment::ON_PROPERTY
);
foreach ($props_node->children as $i => $child_node) {
// Ignore children which are not property elements
if (!($child_node instanceof Node)
|| $child_node->kind !== \ast\AST_PROP_ELEM
) {
continue;
}
$variable = $comment->getVariableList()[$i] ?? null;
$default_node = $child_node->children['default'];
$property_name = $child_node->children['name'];
if (!\is_string($property_name)) {
throw new AssertionError(
'Property name must be a string. '
. 'Got '
. \print_r($property_name, true)
. ' at '
. (clone($this->context))->withLineNumberStart($child_node->lineno)
);
}
$this->addProperty($class, $property_name, $default_node, $real_union_type, $variable, $child_node->lineno, $node->flags, $doc_comment, $comment);
}
return $this->context;
}
/**
* @param ?(ast\Node|string|float|int) $default_node
*/
private function addProperty(Clazz $class, string $property_name, $default_node, UnionType $real_union_type, ?Comment\Parameter $variable, int $lineno, int $flags, ?string $doc_comment, Comment $property_comment): ?Property
{
$variable_has_literals = $variable && $variable->getUnionType()->hasLiterals();
// If something goes wrong will getting the type of
// a property, we'll store it as a future union
// type and try to figure it out later
$future_union_type_node = null;
$context_for_property = (clone($this->context))->withLineNumberStart($lineno);
$real_type_set = $real_union_type->getTypeSet();
if ($default_node === null) {
// This is a declaration such as `public $x;` with no $default_node
// (we don't assume the property is always null, to reduce false positives)
// We don't need to compare this to the real union type
$union_type = $real_union_type;
$default_type = NullType::instance(false)->asRealUnionType();
} else {
if ($default_node instanceof Node) {
$this->checkNodeIsConstExpr($default_node);
$union_type = $this->resolveDefaultPropertyNode($default_node);
if (!$union_type) {
// We'll type check this union type against the real union type when the future union type is resolved
$future_union_type_node = $default_node;
$union_type = UnionType::empty();
}
} else {
// Get the type of the default (not a literal)
// The literal value needs to be known to warn about incompatible composition of traits
$union_type = Type::fromObject($default_node)->asPHPDocUnionType();
}
$default_type = $union_type;
// Erase the corresponding real type set to avoid false positives such as `$x->prop['field'] === null` is redundant/impossible.
$union_type = $union_type->asNonLiteralType()->eraseRealTypeSetRecursively();
if ($real_union_type->isEmpty()) {
if ($union_type->isType(NullType::instance(false))) {
$union_type = UnionType::empty();
}
} else {
if (!$union_type->isStrictSubtypeOf($this->code_base, $real_union_type)) {
$this->emitIssue(
Issue::TypeMismatchPropertyDefaultReal,
$context_for_property->getLineNumberStart(),
$real_union_type,
$property_name,
ASTReverter::toShortString($default_node),
$union_type
);
$union_type = $real_union_type;
} else {
$original_union_type = $union_type;
foreach ($real_union_type->getTypeSet() as $type) {
if (!$type->asPHPDocUnionType()->isStrictSubtypeOf($this->code_base, $original_union_type)) {
$union_type = $union_type->withType($type);
}
}
}
$union_type = $union_type->withRealTypeSet($real_union_type->getTypeSet())->asNormalizedTypes();
}
}
$property_fqsen = FullyQualifiedPropertyName::make(
$class->getFQSEN(),
$property_name
);
if ($this->code_base->hasPropertyWithFQSEN($property_fqsen)) {
$old_property = $this->code_base->getPropertyByFQSEN($property_fqsen);
if ($old_property->getDefiningFQSEN() === $property_fqsen) {
// Note: PHPDoc properties are parsed by Phan before real properties, so they take precedence (e.g. they are more visible)
// PhanRedefineMagicProperty is a separate check.
if ($old_property->isFromPHPDoc()) {
return null;
}
$this->emitIssue(
Issue::RedefineProperty,
$lineno,
$property_name,
$this->context->getFile(),
$lineno,
$this->context->getFile(),
$old_property->getContext()->getLineNumberStart()
);
return null;
}
}
$property = new Property(
$context_for_property,
$property_name,
$union_type,
$flags,
$property_fqsen,
$real_union_type
);
if ($variable) {
$property->setPHPDocUnionType($variable->getUnionType());
} else {
$property->setPHPDocUnionType($real_union_type);
}
$property->setDefaultType($default_type);
$property->setPhanFlags($property_comment->getPhanFlagsForProperty());
$property->setDocComment($doc_comment);
// Add the property to the class
$class->addProperty($this->code_base, $property, None::instance());
$property->setSuppressIssueSet($property_comment->getSuppressIssueSet());
if ($future_union_type_node instanceof Node) {
$future_union_type = new FutureUnionType(
$this->code_base,
new ElementContext($property),
//new ElementContext($property),
$future_union_type_node
);
} else {
$future_union_type = null;
}
// Look for any @var declarations
if ($variable) {
$original_union_type = $union_type;
// We try to avoid resolving $future_union_type except when necessary,
// to avoid issues such as https://github.com/phan/phan/issues/311 and many more.
if ($future_union_type) {
try {
$original_union_type = $future_union_type->get()->eraseRealTypeSetRecursively();
if (!$variable_has_literals) {
$original_union_type = $original_union_type->asNonLiteralType();
}
// We successfully resolved the union type. We no longer need $future_union_type
$future_union_type = null;
} catch (IssueException $_) {
// Do nothing
}
if ($future_union_type === null) {
if ($original_union_type->isType(ArrayShapeType::empty())) {
$union_type = ArrayType::instance(false)->asPHPDocUnionType();
} elseif ($original_union_type->isType(NullType::instance(false))) {
$union_type = UnionType::empty();
} else {
$union_type = $original_union_type;
}
// Replace the empty union type with the resolved union type.
$property->setUnionType($union_type->withRealTypeSet($real_type_set));
}
}
if ($default_node !== null &&
!$original_union_type->isType(NullType::instance(false)) &&
!$variable->getUnionType()->asExpandedTypes($this->code_base)->canCastToUnionType($original_union_type) &&
!$original_union_type->asExpandedTypes($this->code_base)->canCastToUnionType($variable->getUnionType()) &&
!$property->checkHasSuppressIssueAndIncrementCount(Issue::TypeMismatchPropertyDefault)
) {
$this->emitIssue(
Issue::TypeMismatchPropertyDefault,
$lineno,
(string)$variable->getUnionType(),
$property->getName(),
ASTReverter::toShortString($default_node),
(string)$original_union_type
);
}
$original_property_type = $property->getUnionType();
$original_variable_type = $variable->getUnionType();
$variable_type = $original_variable_type->withStaticResolvedInContext($this->context);
if ($variable_type !== $original_variable_type) {
// Instance properties with (at)var static will have the same type as the class they're in
// TODO: Support `static[]` as well when inheriting
if ($property->isStatic()) {
$this->emitIssue(
Issue::StaticPropIsStaticType,
$variable->getLineno(),
$property->getRepresentationForIssue(),
$original_variable_type,
$variable_type
);
} else {
$property->setHasStaticInUnionType(true);
}
}
if ($variable_type->hasGenericArray() && !$original_property_type->hasTypeMatchingCallback(static function (Type $type): bool {
return \get_class($type) !== ArrayType::class;
})) {
// Don't convert `/** @var T[] */ public $x = []` to union type `T[]|array`
$property->setUnionType($variable_type->withRealTypeSet($real_type_set));
} else {
// Set the declared type to the doc-comment type and add
// |null if the default value is null
$property->setUnionType($original_property_type->withUnionType($variable_type)->withRealTypeSet($real_type_set));
}
}
// Wait until after we've added the (at)var type
// before setting the future so that calling
// $property->getUnionType() doesn't force the
// future to be reified.
if ($future_union_type instanceof FutureUnionType) {
$property->setFutureUnionType($future_union_type);
}
if ($class->isImmutable()) {
if (!$property->isStatic() && !$property->isWriteOnly()) {
$property->setIsReadOnly(true);
}
}
return $property;
}
/**
* Resolve the union type of a property's default node.
* This is being done to resolve the most common cases - e.g. `null`, `false`, and `true`
*
* FIXME: Handle 2+2, -1 (unary op), etc.
*/
private function resolveDefaultPropertyNode(Node $node): ?UnionType
{
if ($node->kind === ast\AST_CONST) {
try {
return (new ContextNode(
$this->code_base,
$this->context,
$node
))->getConst()->getUnionType()->eraseRealTypeSetRecursively();
} catch (IssueException $_) {
// ignore
}
}
return null;
}
/**
* Visit a node with kind `\ast\AST_CLASS_CONST_DECL`
*
* @param Node $node
* A node to parse
*
* @return Context
* A new or an unchanged context resulting from
* parsing the node
*
*/
public function visitClassConstDecl(Node $node): Context
{
$class = $this->getContextClass();
foreach ($node->children as $child_node) {
if (!$child_node instanceof Node) {
throw new AssertionError('expected class const element to be a Node');
}
$name = $child_node->children['name'];
if (!\is_string($name)) {
throw new AssertionError('expected class const name to be a string');
}
$fqsen = FullyQualifiedClassConstantName::make(
$class->getFQSEN(),
$name
);
if ($this->code_base->hasClassConstantWithFQSEN($fqsen)) {
$old_constant = $this->code_base->getClassConstantByFQSEN($fqsen);
if ($old_constant->getDefiningFQSEN() === $fqsen) {
$this->emitIssue(
Issue::RedefineClassConstant,
$child_node->lineno,
$name,
$this->context->getFile(),
$child_node->lineno,
$this->context->getFile(),
$old_constant->getContext()->getLineNumberStart()
);
continue;
}
}
// Get a comment on the declaration
$doc_comment = $child_node->children['docComment'] ?? '';
$comment = Comment::fromStringInContext(
$doc_comment,
$this->code_base,
$this->context,
$child_node->lineno ?? 0,
Comment::ON_CONST
);
$line_number_start = $child_node->lineno ?? 0;
$constant = new ClassConstant(
$this->context
->withLineNumberStart($line_number_start)
->withLineNumberEnd($child_node->endLineno ?? $line_number_start),
$name,
UnionType::empty(),
$node->flags ?? 0,
$fqsen
);
$constant->setDocComment($doc_comment);
$constant->setIsDeprecated($comment->isDeprecated());
$constant->setIsNSInternal($comment->isNSInternal());
$constant->setIsOverrideIntended($comment->isOverrideIntended());
$constant->setIsPHPDocAbstract($comment->isPHPDocAbstract());
$constant->setSuppressIssueSet($comment->getSuppressIssueSet());
$value_node = $child_node->children['value'];
if ($value_node instanceof Node) {
try {
self::checkIsAllowedInConstExpr($value_node);
// TODO: Avoid using this when it only contains literals (nothing depending on the CodeBase),
$constant->setFutureUnionType(
new FutureUnionType(
$this->code_base,
new ElementContext($constant),
$value_node
)
);
} catch (InvalidArgumentException $_) {
$constant->setUnionType(MixedType::instance(false)->asPHPDocUnionType());
$this->emitIssue(
Issue::InvalidConstantExpression,
$value_node->lineno
);
}
} else {
// This is a literal scalar value.
// Assume that this is the only definition of the class constant and that it's not a stub for something that depends on configuration.
//
// TODO: What about internal stubs (isPHPInternal()) - if Phan would treat those like being from phpdoc,
// it should do the same for FutureUnionType
$constant->setUnionType(Type::fromObject($value_node)->asRealUnionType());
}
$constant->setNodeForValue($value_node);
$constant->setComment($comment);
$class->addConstant(
$this->code_base,
$constant
);
foreach ($comment->getVariableList() as $var) {
if ($var->getUnionType()->hasTemplateTypeRecursive()) {
$this->emitIssue(
Issue::TemplateTypeConstant,
$constant->getFileRef()->getLineNumberStart(),
(string)$constant->getFQSEN()
);
break;
}
}
}
return $this->context;
}
/**
* Visit a node with kind `\ast\AST_STATIC` (a static variable)
*/
public function visitStatic(Node $node): Context
{
$default = $node->children['default'];
if ($default instanceof Node) {
$this->checkNodeIsConstExpr($default);
}
return $this->context;
}
private function checkNodeIsConstExpr(Node $node): void
{
try {
self::checkIsAllowedInConstExpr($node);
} catch (InvalidArgumentException $_) {
$this->emitIssue(
Issue::InvalidConstantExpression,
$node->lineno
);
}
}
/**
* Visit a node with kind `\ast\AST_CONST`
*
* @param Node $node
* A node to parse
*
* @return Context
* A new or an unchanged context resulting from
* parsing the node
*/
public function visitConstDecl(Node $node): Context
{
foreach ($node->children as $child_node) {
if (!$child_node instanceof Node) {
throw new AssertionError("Expected global constant element to be a Node");
}
$value_node = $child_node->children['value'];
try {
self::checkIsAllowedInConstExpr($value_node);
} catch (InvalidArgumentException $_) {
// InvalidArgumentException was caused by an invalid node kind in a constant expression (value_node should be a Node but Phan can't tell)
$this->emitIssue(
Issue::InvalidConstantExpression,
$value_node->lineno ?? $child_node->lineno
);
// Note: Global constants with invalid value expressions aren't declared.
// However, class constants are declared with placeholders to make inheritance checks, etc. easier.
// Both will emit PhanInvalidConstantExpression
continue;
}
self::addConstant(
$this->code_base,
$this->context,
$child_node->lineno,
$child_node->children['name'],
$value_node,
$child_node->flags ?? 0,
$child_node->children['docComment'] ?? '',
true
);
}
return $this->context;
}
/**
* Visit a node with kind `\ast\AST_FUNC_DECL`
*
* @param Node $node
* A node to parse
*
* @return Context
* A new or an unchanged context resulting from
* parsing the node
*/
public function visitFuncDecl(Node $node): Context
{
$function_name = (string)$node->children['name'];
$context = $this->context;
$code_base = $this->code_base;
// Hunt for an un-taken alternate ID
$alternate_id = 0;
do {
// @phan-suppress-next-line PhanThrowTypeAbsentForCall this is valid
$function_fqsen = FullyQualifiedFunctionName::fromFullyQualifiedString(
\rtrim($context->getNamespace(), '\\') . '\\' . $function_name
)->withAlternateId($alternate_id++);
} while ($code_base->hasFunctionWithFQSEN($function_fqsen));
$func = Func::fromNode(
$context
->withLineNumberStart($node->lineno ?? 0)
->withLineNumberEnd($node->endLineno ?? 0),
$code_base,
$node,
$function_fqsen
);
if ($context->isPHPInternal()) {
// only for stubs
foreach (FunctionFactory::functionListFromFunction($func) as $func_variant) {
if (!($func_variant instanceof Func)) {
throw new AssertionError("Expecteded variant of Func to be a Func");
}
$code_base->addFunction($func_variant);
}
} else {
$code_base->addFunction($func);
}
// Send the context into the function and reset the scope
$context = $this->context->withScope(
$func->getInternalScope()
);
return $context;
}
/**
* Visit a node with kind `\ast\AST_CLOSURE`
*
* @param Node $node
* A node to parse
*
* @return Context
* A new or an unchanged context resulting from
* parsing the node
*/
public function visitClosure(Node $node): Context
{
$closure_fqsen = FullyQualifiedFunctionName::fromClosureInContext(
$this->context->withLineNumberStart($node->lineno),
$node
);
$func = Func::fromNode(
$this->context,
$this->code_base,
$node,
$closure_fqsen
);
$this->code_base->addFunction($func);
// Send the context into the function and reset the scope
// (E.g. to properly check for the presence of `return` statements.
$context = $this->context->withScope(
$func->getInternalScope()
);
return $context;
}
/**
* Visit a node with kind `\ast\AST_ARROW_FUNC`
*
* @param Node $node
* A node to parse
*
* @return Context
* A new or an unchanged context resulting from
* parsing the node
*/
public function visitArrowFunc(Node $node): Context
{
if (!isset($node->children['params'])) {
$msg = "php-ast 1.0.2 or newer is required to correctly parse short arrow functions, but an older version is installed. A short arrow function was seen at $this->context";
// @phan-suppress-next-line PhanPluginRemoveDebugCall
\fwrite(\STDERR, $msg . \PHP_EOL);
throw new AssertionError($msg);
}
return $this->visitClosure($node);
}
/**
* Visit a node with kind `\ast\AST_CALL`
*
* @param Node $node
* A node to parse
*
* @return Context
* A new or an unchanged context resulting from
* parsing the node
*/
public function visitCall(Node $node): Context
{
// If this is a call to a method that indicates that we
// are treating the method in scope as a varargs method,
// then set its optional args to something very high so
// it can be called with anything.
$expression = $node->children['expr'];
if ($expression instanceof Node && $expression->kind === \ast\AST_NAME) {
$function_name = \strtolower($expression->children['name']);
if (\in_array($function_name, [
'func_get_args', 'func_get_arg', 'func_num_args'
], true)) {
if ($this->context->isInFunctionLikeScope()) {
$this->context->getFunctionLikeInScope($this->code_base)
->setNumberOfOptionalParameters(FunctionInterface::INFINITE_PARAMETERS);
}
} elseif ($function_name === 'define') {
$this->analyzeDefine($node);
} elseif ($function_name === 'class_alias') {
if (Config::getValue('enable_class_alias_support') && $this->context->isInGlobalScope()) {
$this->recordClassAlias($node);
}
}
}
if (Config::get_backward_compatibility_checks()) {
$this->analyzeBackwardCompatibility($node);
foreach ($node->children['args']->children as $arg_node) {
if ($arg_node instanceof Node) {
$this->analyzeBackwardCompatibility($arg_node);
}
}
}
return $this->context;
}
private function analyzeDefine(Node $node): void
{
$args = $node->children['args'];
if (\count($args->children) < 2) {
return;
}
$name = $args->children[0];
if ($name instanceof Node) {
try {
$name_type = UnionTypeVisitor::unionTypeFromNode($this->code_base, $this->context, $name, false);
} catch (IssueException $_) {
// If this is really an issue, we'll emit it in the analysis phase when we have all of the element definitions.
return;
}
$name = $name_type->asSingleScalarValueOrNull();
}
if (!\is_string($name)) {
return;
}
self::addConstant(
$this->code_base,
$this->context,
$node->lineno,
$name,
$args->children[1],
0,
'',
true,
true
);
}
/**
* Visit a node with kind `\ast\AST_STATIC_CALL`
*
* @param Node $node
* A node to parse
*
* @return Context
* A new or an unchanged context resulting from
* parsing the node
*/
public function visitStaticCall(Node $node): Context
{
$call = $node->children['class'];
if ($call instanceof Node && $call->kind === \ast\AST_NAME) {
$func_name = \strtolower($call->children['name']);
if ($func_name === 'parent') {
// Make sure it is not a crazy dynamic parent method call
if (!($node->children['method'] instanceof Node)) {
$meth = \strtolower($node->children['method']);
if ($meth === '__construct' && $this->context->isInClassScope()) {
$class = $this->getContextClass();
$class->setIsParentConstructorCalled(true);
}
}
}
}
return $this->context;
}
/**
* Analyze a node for syntax backward compatibility, if that option is enabled
*/
private function analyzeBackwardCompatibility(Node $node): void
{
if (Config::get_backward_compatibility_checks()) {
(new ContextNode(
$this->code_base,
$this->context,
$node
))->analyzeBackwardCompatibility();
}
}
/**
* Visit a node with kind `\ast\AST_RETURN`
*
* @param Node $node
* A node to parse
*
* @return Context
* A new or an unchanged context resulting from
* parsing the node
*
* TODO: Defer analysis of the inside of methods until the class gets hydrated.
*/
public function visitReturn(Node $node): Context
{
$this->analyzeBackwardCompatibility($node);
// Make sure we're actually returning from a method.
if (!$this->context->isInFunctionLikeScope()) {
return $this->context;
}
// Get the method/function/closure we're in
$method = $this->context->getFunctionLikeInScope(
$this->code_base
);
// Mark the method as returning something if expr is not null
if (isset($node->children['expr'])) {
$method->setHasReturn(true);
}
return $this->context;
}
/**
* Visit a node with kind `\ast\AST_YIELD`
*
* @param Node $node
* A node to parse
*
* @return Context
* A new or an unchanged context resulting from
* parsing the node
*
* TODO: Defer analysis of the inside of methods until the method/function gets hydrated.
*/
public function visitYield(Node $node): Context
{
return $this->analyzeYield($node);
}
/**
* Visit a node with kind `\ast\AST_YIELD_FROM`
*
* @param Node $node
* A node to parse
*
* @return Context
* A new or an unchanged context resulting from
* parsing the node
*/
public function visitYieldFrom(Node $node): Context
{
return $this->analyzeYield($node);
}
/**
* Visit a node with kind `\ast\AST_YIELD_FROM` or kind `\ast_YIELD`
*
* @param Node $node
* A node to parse
*
* @return Context
* A new or an unchanged context resulting from
* parsing the node
*/
private function analyzeYield(Node $node): Context
{
$this->analyzeBackwardCompatibility($node);
// Make sure we're actually returning from a method.
if (!$this->context->isInFunctionLikeScope()) {
return $this->context;
}
// Get the method/function/closure we're in
$method = $this->context->getFunctionLikeInScope(
$this->code_base
);
// Mark the method as yielding something (and returning a generator)
$method->setHasYield(true);
$method->setHasReturn(true);
return $this->context;
}
/**
* Visit a node with kind `\ast\AST_PRINT`
*
* @param Node $node
* A node to parse
*
* @return Context
* A new or an unchanged context resulting from
* parsing the node
*/
public function visitPrint(Node $node): Context
{
// Analyze backward compatibility for the arguments of this print statement.
$this->analyzeBackwardCompatibility($node);
return $this->context;
}
/**
* Visit a node with kind `\ast\AST_ECHO`
*
* @param Node $node
* A node to parse
*
* @return Context
* A new or an unchanged context resulting from
* parsing the node
*/
public function visitEcho(Node $node): Context
{
// Analyze backward compatibility for the arguments of this echo statement.
$this->analyzeBackwardCompatibility($node);
return $this->context;
}
/**
* Visit a node with kind `\ast\AST_METHOD_CALL`
*
* @param Node $node
* A node to parse
*
* @return Context
* A new or an unchanged context resulting from
* parsing the node
*/
public function visitMethodCall(Node $node): Context
{
// Analyze backward compatibility for the arguments of this method call
$this->analyzeBackwardCompatibility($node);
return $this->context;
}
public function visitAssign(Node $node): Context
{
if (!Config::get_backward_compatibility_checks()) {
return $this->context;
}
// Analyze the assignment for compatibility with some
// breaking changes between PHP5 and PHP7.
$var_node = $node->children['var'];
if ($var_node instanceof Node) {
$this->analyzeBackwardCompatibility($var_node);
}
$expr_node = $node->children['expr'];
if ($expr_node instanceof Node) {
$this->analyzeBackwardCompatibility($expr_node);
}
return $this->context;
}
public function visitDim(Node $node): Context
{
if (!Config::get_backward_compatibility_checks()) {
return $this->context;
}
$expr = $node->children['expr'];
if (!($expr instanceof Node)) {
return $this->context;
}
// check for $$var[]
if ($expr->kind === \ast\AST_VAR
&& ($expr->children['name']->kind ?? null) === \ast\AST_VAR
) {
$temp = $expr->children['name'];
$depth = 1;
while ($temp instanceof Node) {
if (!isset($temp->children['name'])) {
throw new AssertionError("Expected to find a name in context, something else found.");
}
$temp = $temp->children['name'];
$depth++;
}
$dollars = \str_repeat('$', $depth);
$cache_entry = FileCache::getOrReadEntry($this->context->getFile());
$line = $cache_entry->getLine($node->lineno);
if (!\is_string($line)) {
return $this->context;
}
if (\strpos($line, '{') === false
|| \strpos($line, '}') === false
) {
$this->emitIssue(
Issue::CompatibleExpressionPHP7,
$node->lineno ?? 0,
"{$dollars}{$temp}[]"
);
}
// $foo->$bar['baz'];
} elseif ($expr->kind === \ast\AST_PROP &&
($expr->children['expr']->kind ?? null) === ast\AST_VAR &&
($expr->children['prop']->kind ?? null) === ast\AST_VAR
) {
$cache_entry = FileCache::getOrReadEntry($this->context->getFile());
$line = $cache_entry->getLines()[$node->lineno] ?? null;
if (!\is_string($line)) {
return $this->context;
}
if (\strpos($line, '{') === false
|| \strpos($line, '}') === false
) {
$this->emitIssue(
Issue::CompatiblePHP7,
$node->lineno ?? 0
);
}
}
return $this->context;
}
/**
* Add a constant to the codebase
*
* @param CodeBase $code_base
* The global code base in which we store all
* state
*
* @param Context $context
* The context of the parser at the node which declares the constant
*
* @param int $lineno
* The line number where the node declaring the constant was found
*
* @param string $name
* The name of the constant
*
* @param Node|mixed $value
* Either a node or a constant to be used as the value of
* the constant.
*
* @param int $flags
* Any flags on the definition of the constant
*
* @param string $comment_string
* A possibly empty comment string on the declaration
*
* @param bool $use_future_union_type
* Should this lazily resolve the value of the constant declaration?
*
* @param bool $is_fully_qualified
* Is the provided $name already fully qualified?
*/
public static function addConstant(
CodeBase $code_base,
Context $context,
int $lineno,
string $name,
$value,
int $flags,
string $comment_string,
bool $use_future_union_type,
bool $is_fully_qualified = false
): void {
$i = \strrpos($name, '\\');
if ($i !== false) {
$name_fragment = (string)\substr($name, $i + 1);
} else {
$name_fragment = $name;
}
if (\in_array(\strtolower($name_fragment), ['true', 'false', 'null'], true)) {
Issue::maybeEmit(
$code_base,
$context,
Issue::ReservedConstantName,
$lineno,
$name
);
return;
}
try {
// Give it a fully-qualified name
if ($is_fully_qualified) {
$fqsen = FullyQualifiedGlobalConstantName::fromFullyQualifiedString(
$name
);
} else {
$fqsen = FullyQualifiedGlobalConstantName::fromStringInContext(
$name,
$context
);
}
} catch (InvalidArgumentException | FQSENException $_) {
Issue::maybeEmit(
$code_base,
$context,
Issue::InvalidConstantFQSEN,
$lineno,
$name
);
return;
}
// Create the constant
$constant = new GlobalConstant(
$context->withLineNumberStart($lineno),
$name,
UnionType::fromFullyQualifiedRealString('array|bool|float|int|string|resource|null'),
$flags,
$fqsen
);
// $is_fully_qualified is true for define('name', $value)
// define() is typically used to conditionally set constants or to set them to variable values.
// TODO: Could add 'configuration_constant_set' to add additional constants to treat as dynamic such as PHP_OS, PHP_VERSION_ID, etc. (convert literals to non-literal types?)
$constant->setIsDynamicConstant($is_fully_qualified);
if ($code_base->hasGlobalConstantWithFQSEN($fqsen)) {
$other_constant = $code_base->getGlobalConstantByFQSEN($fqsen);
$other_context = $other_constant->getContext();
if (!$other_context->equals($context)) {
// Be consistent about the constant's type and only track the first declaration seen when parsing (or redeclarations)
// Note that global constants don't have alternates.
return;
}
// Keep track of old references to the new constant
$constant->copyReferencesFrom($other_constant);
// Otherwise, add the constant now that we know about all of the elements in the codebase
}
// Get a comment on the declaration
$comment = Comment::fromStringInContext(
$comment_string,
$code_base,
$context,
$lineno,
Comment::ON_CONST
);
if ($use_future_union_type) {
if ($value instanceof Node) {
// TODO: Avoid using this when it only contains literals (nothing depending on the CodeBase),
// e.g. `['key' => 'value']`
$constant->setFutureUnionType(
new FutureUnionType(
$code_base,
$context,
$value
)
);
} else {
$constant->setUnionType(Type::fromObject($value)->asRealUnionType());
}
} else {
$constant->setUnionType(UnionTypeVisitor::unionTypeFromNode($code_base, $context, $value));
}
$constant->setNodeForValue($value);
$constant->setDocComment($comment_string);
$constant->setIsDeprecated($comment->isDeprecated());
$constant->setIsNSInternal($comment->isNSInternal());
$code_base->addGlobalConstant(
$constant
);
}
/**
* @return Clazz
* Get the class on this scope or fail real hard
*/
private function getContextClass(): Clazz
{
// throws AssertionError if not in class scope
return $this->context->getClassInScope($this->code_base);
}
/**
* Return the existence of a class_alias from one FQSEN to the other.
* Modifies $this->codebase if successful.
*
* Supports 'MyClass' and MyClass::class
*
* @param Node $node - An AST_CALL node with name 'class_alias' to attempt to resolve
*/
private function recordClassAlias(Node $node): void
{
$args = $node->children['args']->children;
if (\count($args) < 2 || \count($args) > 3) {
return;
}
$code_base = $this->code_base;
$context = $this->context;
try {
$original_fqsen = (new ContextNode($code_base, $context, $args[0]))->resolveClassNameInContext();
$alias_fqsen = (new ContextNode($code_base, $context, $args[1]))->resolveClassNameInContext();
} catch (IssueException $exception) {
Issue::maybeEmitInstance(
$code_base,
$context,
$exception->getIssueInstance()
);
return;
}
if ($original_fqsen === null || $alias_fqsen === null) {
return;
}
// Add the class alias during parse phase.
// Figure out if any of the aliases are wrong after analysis phase.
$this->code_base->addClassAlias($original_fqsen, $alias_fqsen, $context, $node->lineno ?? 0);
}
/**
* Visit a node with kind `\ast\AST_NAMESPACE`
* Store the maps for use statements in the CodeBase to use later during analysis.
*
* @param Node $node
* A node to parse
*
* @return Context
* A new context resulting from parsing the node
*/
public function visitNamespace(Node $node): Context
{
$context = $this->context;
// @phan-suppress-next-line PhanAccessMethodInternal addParsedNamespaceMap and getNamespaceMap
$this->code_base->addParsedNamespaceMap($context->getFile(), $context->getNamespace(), $context->getNamespaceId(), $context->getNamespaceMap());
return parent::visitNamespace($node);
}
// common no-ops
public function visitArrayElem(Node $node): Context
{
return $this->context;
}
public function visitVar(Node $node): Context
{
return $this->context;
}
public function visitName(Node $node): Context
{
return $this->context;
}
public function visitArgList(Node $node): Context
{
return $this->context;
}
public function visitStmtList(Node $node): Context
{
return $this->context;
}
public function visitNullsafeProp(Node $node): Context
{
return $this->context;
}
public function visitProp(Node $node): Context
{
return $this->context;
}
public function visitArray(Node $node): Context
{
return $this->context;
}
public function visitBinaryOp(Node $node): Context
{
return $this->context;
}
/**
* @internal
*/
public const ALLOWED_CONST_EXPRESSION_KINDS = [
ast\AST_ARRAY_ELEM => true,
ast\AST_ARRAY => true,
ast\AST_BINARY_OP => true,
ast\AST_CLASS_CONST => true,
ast\AST_CLASS_NAME => true,
ast\AST_CONDITIONAL => true,
ast\AST_CONST => true,
ast\AST_DIM => true,
ast\AST_MAGIC_CONST => true,
ast\AST_NAME => true,
ast\AST_UNARY_OP => true,
ast\AST_UNPACK => true,
];
/**
* This is meant to avoid causing errors in Phan where Phan expects a constant to be found.
*
* @param Node|string|float|int|bool|null $n
*
* @return void - If this doesn't throw, then $n is a valid constant AST.
*
* @throws InvalidArgumentException if this is not allowed in a constant expression
* Based on zend_bool zend_is_allowed_in_const_expr from Zend/zend_compile.c
*
* @internal
*/
public static function checkIsAllowedInConstExpr($n): void
{
if (!($n instanceof Node)) {
return;
}
if (!\array_key_exists($n->kind, self::ALLOWED_CONST_EXPRESSION_KINDS)) {
throw new InvalidArgumentException();
}
foreach ($n->children as $child_node) {
self::checkIsAllowedInConstExpr($child_node);
}
}
/**
* @param Node|string|float|int|bool|null $n
* @return bool - If true, then $n is a valid constant AST.
*/
public static function isConstExpr($n): bool
{
try {
self::checkIsAllowedInConstExpr($n);
return true;
} catch (InvalidArgumentException $_) {
return false;
}
}
protected const ALLOWED_NON_VARIABLE_EXPRESSION_KINDS = [
// Contains everything from ALLOWED_CONST_EXPRESSION_KINDS
ast\AST_ARRAY_ELEM => true,
ast\AST_ARRAY => true,
ast\AST_BINARY_OP => true,
ast\AST_CLASS_CONST => true,
ast\AST_CLASS_NAME => true,
ast\AST_CONDITIONAL => true,
ast\AST_CONST => true,
ast\AST_DIM => true,
ast\AST_MAGIC_CONST => true,
ast\AST_NAME => true,
ast\AST_UNARY_OP => true,
// In addition to expressions where the real type can be statically inferred (assuming types of child nodes were correctly inferred)
ast\AST_ARG_LIST => true,
ast\AST_CALL => true,
ast\AST_CLONE => true,
ast\AST_EMPTY => true,
ast\AST_ISSET => true,
ast\AST_NEW => true,
ast\AST_PRINT => true,
ast\AST_SHELL_EXEC => true,
ast\AST_STATIC_CALL => true,
ast\AST_STATIC_PROP => true,
ast\AST_UNPACK => true,
// Stop here
ast\AST_CLOSURE => false,
ast\AST_CLASS => false,
];
/**
* This is meant to tell Phan expects an expression not depending on the current scope (e.g. global, loop) to be found.
*
* @param Node|string|float|int|bool|null $n
*
* @return void - If this doesn't throw, then $n is a valid constant AST.
*
* @throws InvalidArgumentException if this is not allowed in a constant expression
* Based on zend_bool zend_is_allowed_in_const_expr from Zend/zend_compile.c
*
* @internal
*/
private static function checkIsNonVariableExpression($n): void
{
if (!($n instanceof Node)) {
return;
}
$value = self::ALLOWED_NON_VARIABLE_EXPRESSION_KINDS[$n->kind] ?? null;
if ($value === true) {
foreach ($n->children as $child_node) {
self::checkIsNonVariableExpression($child_node);
}
return;
}
if ($value !== false) {
throw new InvalidArgumentException();
}
// Skip checking child nodes for anonymous classes, closures
}
/**
* @param Node|string|float|int|bool|null $n
* @return bool - If true, then the inferred type for $n does not depend on the current scope, but isn't necessarily constant (e.g. static method invocation in loop, global)
*/
public static function isNonVariableExpr($n): bool
{
try {
self::checkIsNonVariableExpression($n);
return true;
} catch (InvalidArgumentException $_) {
return false;
}
}
}