diff --git a/docs/custom-rules-and-presets.md b/docs/custom-rules-and-presets.md index 824b9883..fe7de6b4 100644 --- a/docs/custom-rules-and-presets.md +++ b/docs/custom-rules-and-presets.md @@ -191,7 +191,7 @@ Named functions, closures, arrow functions, and anonymous classes are collected A closure declared inside a class or a named function is counted on both nodes: the enclosing `ClassNode` (or `FunctionNode`) keeps seeing everything the closure does, exactly as it sees its own method bodies, and the `AnonymousFunctionNode` reports the closure body on its own. -Anonymous classes (`new class ... {}`) are collected the same way, as `Boundwize\StructArmed\Analyser\AnonymousClassNode`: identified by `$file` and `$line` plus `$enclosingClassName` / `$enclosingFunctionName` (with `enclosingScopeName()` and `AnonymousClassNode::FILE_SCOPE`), and carrying `$extends`, `$implements`, `$traits`, `$layer` / `$layers` with `isInLayer()`, and `$hasEmptyParentheses` — whether the declaration spells `new class () {}` although it passes no constructor argument. Its parent chain is resolved like a named class's: `$parentClasses` and `$parentInterfaces` hold the direct and transitive parents found in the scanned paths, and `extendsClass()` / `implementsInterface()` answer case-insensitively through that chain, exactly as on a `ClassNode`. An anonymous class never becomes a `ClassNode`; the named class-like or function declaring it keeps seeing its body, exactly as it sees a closure's. +Anonymous classes (`new class ... {}`) are collected the same way, as `Boundwize\StructArmed\Analyser\AnonymousClassNode`: identified by `$file` and `$line` plus `$enclosingClassName` / `$enclosingFunctionName` (with `enclosingScopeName()` and `AnonymousClassNode::FILE_SCOPE`), and carrying `$extends`, `$implements`, `$traits`, `$isReadonly`, `$layer` / `$layers`, and `$hasEmptyParentheses` — whether the declaration spells `new class () {}` although it passes no constructor argument. It carries the same body-level facts and query helpers as a `ClassNode` — `$dependencies`, `$functionCalls`, `$superglobals`, and `$languageConstructs`, with `isInLayer()`, `dependsOn()`, `dependsOnNamespace()`, `callsFunction()`, `usesLanguageConstruct()`, and `accessesSuperglobals()` — and its own members: `$methods`, `$constants`, `$properties`, and `constructorParamCount()`. Its parent chain is resolved like a named class's: `$parentClasses` and `$parentInterfaces` hold the direct and transitive parents found in the scanned paths, and `extendsClass()` / `implementsInterface()` answer case-insensitively through that chain, exactly as on a `ClassNode`. An anonymous class never becomes a `ClassNode`; the named class-like or function declaring it keeps seeing its body, exactly as it sees a closure's, while the members belong to the anonymous class alone. Rules opt in to these nodes by implementing `Boundwize\StructArmed\Rule\FunctionRuleInterface`, `Boundwize\StructArmed\Rule\AnonymousFunctionRuleInterface`, and/or `Boundwize\StructArmed\Rule\AnonymousClassRuleInterface`. All share the `appliesTo()` / `evaluate()` method names with `RuleInterface`, each typed against its own node kind. Global skip paths, rule-scoped `skip()` paths, and `skipRule()` apply the same way. Function-likes and anonymous classes are not part of the declarative `ruleset()` layer-dependency check. diff --git a/src/Analyser/Analyser.php b/src/Analyser/Analyser.php index 2bbaa340..c71bb41a 100644 --- a/src/Analyser/Analyser.php +++ b/src/Analyser/Analyser.php @@ -761,6 +761,38 @@ private function dependenciesForInheritanceDependency( return $resolvedDependencies; } + /** + * A node's own inheritance-clause names (and the imports that exist for + * them) are structural relations, not value references. Excluding them + * keeps "referenced" meaningful for the unresolved dynamic instantiation + * check: a class extended by a child is not thereby a possible + * `new $class` target. The usage-aware deletion rules are unaffected — + * each combines this flag with its structural extended/implemented/trait + * marking. + * + * @param list $dependencies + * @param array $clauseNames The node's own name, if any, and its extends, implements, and traits + * @param array $used + */ + private function markDependenciesUsed(array $dependencies, array $clauseNames, array &$used): void + { + $excludedKeys = []; + + foreach ($clauseNames as $clauseName) { + if ($clauseName !== null) { + $excludedKeys[strtolower($clauseName)] = true; + } + } + + foreach ($dependencies as $dependency) { + $dependencyKey = strtolower($dependency); + + if (! isset($excludedKeys[$dependencyKey])) { + $used[$dependencyKey] = true; + } + } + } + /** * Collect and apply class-like usage flags with one collection pass and one * application pass over the class nodes. Extended classes use the recursive @@ -796,36 +828,22 @@ private function markClassLikeUsage( $used[strtolower($trait)] = true; } - // A node's own inheritance-clause names (and the imports that - // exist for them) are structural relations, not value references. - // Excluding them keeps "referenced" meaningful for the unresolved - // dynamic instantiation check below: a class extended by a child - // is not thereby a possible `new $class` target. The usage-aware - // deletion rules are unaffected — each combines this flag with its - // structural extended/implemented/trait marking. - $excludedKeys = [strtolower($classNode->className) => true]; - - if ($classNode->extends !== null) { - $excludedKeys[strtolower($classNode->extends)] = true; - } - - foreach ([$classNode->implements, $classNode->interfaceExtends, $classNode->traits] as $clauseNames) { - foreach ($clauseNames as $clauseName) { - $excludedKeys[strtolower($clauseName)] = true; - } - } - - foreach ($classNode->dependencies as $dependency) { - $dependencyKey = strtolower($dependency); - - if (! isset($excludedKeys[$dependencyKey])) { - $used[$dependencyKey] = true; - } - } + $this->markDependenciesUsed( + $classNode->dependencies, + [ + $classNode->className, + $classNode->extends, + ...$classNode->implements, + ...$classNode->interfaceExtends, + ...$classNode->traits, + ], + $used, + ); } // Anonymous classes have no ClassNode of their own, so their inheritance - // and trait-use relationships are tracked separately. + // and trait-use relationships, and their body references, are tracked + // separately. foreach ($extractionResult->anonymousClassNodes as $anonymousClassNode) { if ($markExtended && $anonymousClassNode->extends !== null) { $extended[strtolower($anonymousClassNode->extends)] = true; @@ -840,11 +858,17 @@ private function markClassLikeUsage( foreach ($anonymousClassNode->traits as $trait) { $used[strtolower($trait)] = true; } + + $this->markDependenciesUsed( + $anonymousClassNode->dependencies, + [$anonymousClassNode->extends, ...$anonymousClassNode->implements, ...$anonymousClassNode->traits], + $used, + ); } - // References made outside any named class-like scope — procedural - // functions, top-level statements, top-level anonymous class bodies — - // have no ClassNode either, so they are tracked per file. + // References made outside any class-like scope — procedural functions + // and top-level statements — have no ClassNode either, so they are + // tracked per file. foreach ($extractionResult->fileReferences as $references) { foreach ($references as $reference) { $used[strtolower($reference)] = true; diff --git a/src/Analyser/AnalysisNodeCollector.php b/src/Analyser/AnalysisNodeCollector.php index e2d6ebe8..bc5c0421 100644 --- a/src/Analyser/AnalysisNodeCollector.php +++ b/src/Analyser/AnalysisNodeCollector.php @@ -325,6 +325,15 @@ final class AnalysisNodeCollector extends NodeVisitorAbstract /** @var ClassLike[] */ private array $fileClassLikes = []; + /** + * The named scopes declaring each anonymous class left in the current + * file — innermost class-like name, innermost function name — keyed by + * the class node's object id and read once its node is built. + * + * @var array + */ + private array $anonymousClassEnclosingNames = []; + /** @var array */ private array $fileFunctions = []; @@ -408,6 +417,7 @@ public function setCurrentFile(string $file, array $tokens = []): void $this->numericLiterals = []; $this->currentNamespaceUses = []; $this->fileClassLikes = []; + $this->anonymousClassEnclosingNames = []; $this->fileFunctions = []; $this->classLikeAnalysis = []; $this->activeClassLikeAnalyses = []; @@ -444,9 +454,8 @@ public function getAnonymousClassNodes(): array } /** - * References to class-likes made outside any named class-like scope, per - * file — procedural functions, top-level statements, and top-level - * anonymous class bodies. + * References to class-likes made outside any class-like scope, per file — + * procedural functions and top-level statements. * * @return array> */ @@ -597,10 +606,7 @@ public function enterNode(Node $node): null $this->activeClassLikeScopes[] = $this->createClassLikeScope($node, $classLikeName); $this->activeClassLikeNames[] = $classLikeName; $this->functionLikeDepthAtClassLikeEntry[] = count($this->activeFunctionLikeAnalyses); - - if ($classLikeName !== null) { - $this->startClassLikeAnalysis($node); - } + $this->startClassLikeAnalysis($node); return null; } @@ -699,30 +705,16 @@ public function leaveNode(Node $node): null // Anonymous classes never become ClassNodes, but the class they // extend, the interfaces they implement, and the traits they use - // are still used within the scanned paths. + // are still used within the scanned paths, and their members and + // body facts are collected like a named class's. if ($node instanceof Class_ && $node->isAnonymous()) { // Its own (nameless) entry is already popped, so the innermost // active names are the named scopes declaring it; they also // resolve its layer, as they do for an anonymous function. - $enclosingClassName = $this->innermostActiveClassLikeName(); - $enclosingFunctionName = $this->activeFunctionNames === [] ? null : end($this->activeFunctionNames); - [$layer, $layers] = $this->resolveLayerData($enclosingClassName ?? $enclosingFunctionName ?? ''); - - $this->anonymousClassNodes[] = new AnonymousClassNode( - file: $this->currentFile, - line: $node->getStartLine(), - extends: $node->extends instanceof Name ? $node->extends->toString() : null, - implements: $this->collectImplements($node), - traits: $this->collectTraits($node), - layer: $layer, - enclosingClassName: $enclosingClassName, - enclosingFunctionName: $enclosingFunctionName, - hasEmptyParentheses: AnonymousClassParentheses::emptyTokenRange($this->currentTokens, $node) - !== null, - layers: $layers, - ); - - return null; + $this->anonymousClassEnclosingNames[spl_object_id($node)] = [ + $this->innermostActiveClassLikeName(), + $this->activeFunctionNames === [] ? null : end($this->activeFunctionNames), + ]; } $this->fileClassLikes[] = $node; @@ -735,7 +727,11 @@ traits: $this->collectTraits($node), public function afterTraverse(array $nodes): null { foreach ($this->fileClassLikes as $fileClassLike) { - $this->collectClassLike($fileClassLike); + if ($fileClassLike instanceof Class_ && $fileClassLike->isAnonymous()) { + $this->collectAnonymousClass($fileClassLike); + } else { + $this->collectClassLike($fileClassLike); + } } foreach ($this->fileFunctionLikeAnalyses as $fileFunctionLikeAnalysis) { @@ -753,6 +749,7 @@ public function afterTraverse(array $nodes): null } $this->fileClassLikes = []; + $this->anonymousClassEnclosingNames = []; $this->classLikeAnalysis = []; $this->activeClassLikeAnalyses = []; $this->activeClassLikeScopes = []; @@ -766,12 +763,19 @@ public function afterTraverse(array $nodes): null return null; } + /** + * A named class-like seeds its dependencies with the namespace imports. + * An anonymous class does not: like a function-like's, its file's imports + * belong to the file (and the named class-like declaring it), not to it. + */ private function startClassLikeAnalysis(ClassLike $classLike): void { $classLikeId = spl_object_id($classLike); $classLikeAnalysis = new ClassLikeAnalysis($classLike instanceof Interface_); - $classLikeAnalysis->dependencies = $this->currentNamespaceUses; + if ($classLike->name instanceof Identifier) { + $classLikeAnalysis->dependencies = $this->currentNamespaceUses; + } $this->classLikeAnalysis[$classLikeId] = $classLikeAnalysis; $this->activeClassLikeAnalyses[] = $classLikeAnalysis; @@ -779,15 +783,10 @@ private function startClassLikeAnalysis(ClassLike $classLike): void /** * The analysis of the class-like declaring the member being entered: the - * innermost active class-like. An anonymous class (null name) starts no - * analysis, so its members are not collected. + * innermost active class-like, named or anonymous. */ private function declaringClassLikeAnalysis(): ?ClassLikeAnalysis { - if (end($this->activeClassLikeNames) === null) { - return null; - } - $analysis = end($this->activeClassLikeAnalyses); return $analysis instanceof ClassLikeAnalysis ? $analysis : null; @@ -1022,10 +1021,9 @@ private function collectNodeAnalysis(Node $node): void } if ($this->activeClassLikeAnalyses === []) { - // Outside any named class-like scope — procedural functions, - // top-level statements, top-level anonymous class bodies — a - // class-like reference still keeps the referenced class-like - // alive. + // Outside any class-like scope — procedural functions and + // top-level statements — a class-like reference still keeps + // the referenced class-like alive. $this->currentFileReferences[$name] = true; } @@ -1444,6 +1442,37 @@ enumBackingType: $classLike instanceof Enum_ && $classLike->scalarType instan ); } + private function collectAnonymousClass(Class_ $class): void + { + $classLikeId = spl_object_id($class); + $analysis = $this->collectClassLikeAnalysis($classLikeId); + [$enclosingClassName, $enclosingFunctionName] = $this->anonymousClassEnclosingNames[$classLikeId]; + [$layer, $layers] = $this->resolveLayerData( + $enclosingClassName ?? $enclosingFunctionName ?? '' + ); + + $this->anonymousClassNodes[] = new AnonymousClassNode( + file: $this->currentFile, + line: $class->getStartLine(), + extends: $class->extends instanceof Name ? $class->extends->toString() : null, + implements: $this->collectImplements($class), + traits: $analysis['traits'], + layer: $layer, + enclosingClassName: $enclosingClassName, + enclosingFunctionName: $enclosingFunctionName, + hasEmptyParentheses: AnonymousClassParentheses::emptyTokenRange($this->currentTokens, $class) !== null, + layers: $layers, + isReadonly: $class->isReadonly(), + dependencies: $analysis['dependencies'], + methods: $analysis['methods'], + constants: $analysis['constants'], + properties: $analysis['properties'], + functionCalls: $analysis['functionCalls'], + superglobals: $analysis['superglobals'], + languageConstructs: $analysis['languageConstructs'], + ); + } + private function collectFunctionLike(FunctionLikeAnalysis $functionLikeAnalysis): void { $functionLike = $functionLikeAnalysis->functionLike; @@ -1637,29 +1666,6 @@ private function collectInterfaceExtends(ClassLike $classLike): array return $parents; } - /** - * Traits used by an anonymous class; named class-likes collect theirs in - * collectMembers(). - * - * @return string[] - */ - private function collectTraits(Class_ $class): array - { - $traits = []; - - foreach ($class->stmts as $stmt) { - if (! $stmt instanceof TraitUse) { - continue; - } - - foreach ($stmt->traits as $trait) { - $traits[] = $trait->toString(); - } - } - - return $traits; - } - private function resolveVisibilityName(ClassMethod|ClassConst|Property|Param $node): string { if ($node->isProtected()) { diff --git a/src/Analyser/AnonymousClassNode.php b/src/Analyser/AnonymousClassNode.php index d5a390c6..c0a5d75a 100644 --- a/src/Analyser/AnonymousClassNode.php +++ b/src/Analyser/AnonymousClassNode.php @@ -20,10 +20,18 @@ * Its parent chain is resolved by the analyser like a named class's, so * {@see extendsClass()} and {@see implementsInterface()} see transitive * parents too. + * + * Its members and body-level facts are collected like a named class's. The + * body-level facts of an anonymous class declared inside a class-like or + * named function are also counted on that enclosing node, exactly as the + * body of a closure is: a rule that only inspects the enclosing node keeps + * seeing everything the anonymous class does. Its members belong to the + * anonymous class alone. */ final class AnonymousClassNode { - use LayerQueryTrait; + use MemberQueryTrait; + use NodeQueryTrait; use RecursiveParentsTrait; /** @@ -36,15 +44,22 @@ final class AnonymousClassNode public readonly array $layers; /** - * @param string[] $implements Interface names this anonymous class implements - * @param string[] $traits Trait names this anonymous class uses - * @param string|null $enclosingClassName Innermost named class-like this anonymous class is declared in - * @param string|null $enclosingFunctionName Innermost named function this anonymous class is declared in - * @param bool $hasEmptyParentheses Whether `()` follows `class` although no constructor argument - * is passed: `new class () {}` rather than `new class {}` - * @param list $layers Layer names this anonymous class belongs to; defaults to [$layer] - * @param list $parentClasses Direct and transitive parent class names - * @param list $parentInterfaces Direct and transitive implemented interface names + * @param string[] $implements Interface names this anonymous class implements + * @param string[] $traits Trait names this anonymous class uses + * @param string|null $enclosingClassName Innermost named class-like this anonymous class is declared in + * @param string|null $enclosingFunctionName Innermost named function this anonymous class is declared in + * @param bool $hasEmptyParentheses Whether `()` follows `class` although no constructor argument + * is passed: `new class () {}` rather than `new class {}` + * @param list $layers Layer names this anonymous class belongs to; defaults to [$layer] + * @param list $parentClasses Direct and transitive parent class names + * @param list $parentInterfaces Direct and transitive implemented interface names + * @param list $dependencies Fully-qualified class, function, or constant dependencies + * @param MethodNode[] $methods Methods of this anonymous class + * @param ConstantNode[] $constants Constants of this anonymous class + * @param PropertyNode[] $properties Properties of this anonymous class + * @param string[] $functionCalls Functions called within this anonymous class + * @param string[] $superglobals Superglobals accessed ($_GET, $_POST, etc.) + * @param string[] $languageConstructs Language constructs used (exit, die, etc.) */ public function __construct( public readonly string $file, @@ -59,6 +74,14 @@ public function __construct( array $layers = [], public array $parentClasses = [], public array $parentInterfaces = [], + public readonly bool $isReadonly = false, + public readonly array $dependencies = [], + public readonly array $methods = [], + public readonly array $constants = [], + public readonly array $properties = [], + public readonly array $functionCalls = [], + public readonly array $superglobals = [], + public readonly array $languageConstructs = [], ) { $this->layers = $layers ?: array_filter([$this->layer]); } diff --git a/src/Analyser/ClassLikeAnalysis.php b/src/Analyser/ClassLikeAnalysis.php index c6bd5ab7..ec591251 100644 --- a/src/Analyser/ClassLikeAnalysis.php +++ b/src/Analyser/ClassLikeAnalysis.php @@ -7,7 +7,7 @@ use PhpParser\Node\Name; /** - * Facts collected while traversing a named class-like: body-level references + * Facts collected while traversing a class-like: body-level references * plus its members, each recorded as the traverser passes the declaring node. * * @internal diff --git a/src/Analyser/ClassNode.php b/src/Analyser/ClassNode.php index 0e504276..9dfa9cfe 100644 --- a/src/Analyser/ClassNode.php +++ b/src/Analyser/ClassNode.php @@ -13,6 +13,7 @@ final class ClassNode { + use MemberQueryTrait; use NodeQueryTrait; use RecursiveParentsTrait; @@ -174,15 +175,4 @@ public function implementsInterface(string $interface): bool || $this->matchesAnyClassLike($interface, $this->interfaceExtends) || $this->matchesAnyClassLike($interface, $this->parentInterfaces); } - - public function constructorParamCount(): int - { - foreach ($this->methods as $method) { - if ($method->isConstructor()) { - return $method->paramCount; - } - } - - return 0; - } } diff --git a/src/Analyser/ExtractionResult.php b/src/Analyser/ExtractionResult.php index 0b8f4141..94efb6c0 100644 --- a/src/Analyser/ExtractionResult.php +++ b/src/Analyser/ExtractionResult.php @@ -11,7 +11,7 @@ * @param array $fileAnalyses * @param list $anonymousClassNodes * @param array> $fileReferences Class-like references made outside any - * named class-like scope, per file + * class-like scope, per file * @param array> $fileInstantiations Class-like instantiations (`new X`, * with self/static/parent resolved), per file * @param list $functionNodes diff --git a/src/Analyser/LayerQueryTrait.php b/src/Analyser/LayerQueryTrait.php deleted file mode 100644 index e36bad37..00000000 --- a/src/Analyser/LayerQueryTrait.php +++ /dev/null @@ -1,24 +0,0 @@ - $layers All layer names this node belongs to; assigned once in each node's constructor - */ -trait LayerQueryTrait -{ - public function isInLayer(string $layer): bool - { - return in_array($layer, $this->layers, true); - } -} diff --git a/src/Analyser/MemberQueryTrait.php b/src/Analyser/MemberQueryTrait.php new file mode 100644 index 00000000..dd4edc95 --- /dev/null +++ b/src/Analyser/MemberQueryTrait.php @@ -0,0 +1,29 @@ +methods as $method) { + if ($method->isConstructor()) { + return $method->paramCount; + } + } + + return 0; + } +} diff --git a/src/Analyser/NodeQueryTrait.php b/src/Analyser/NodeQueryTrait.php index 2e0c7d04..22a499d9 100644 --- a/src/Analyser/NodeQueryTrait.php +++ b/src/Analyser/NodeQueryTrait.php @@ -10,14 +10,15 @@ use function strcasecmp; /** - * Query helpers shared by {@see ClassNode}, {@see FunctionNode}, and - * {@see AnonymousFunctionNode}. All three nodes carry the same body-level - * facts — layers, dependencies, function calls, superglobals, language - * constructs — so rules can ask the same questions of a function body that - * they ask of a class-like. + * Query helpers shared by {@see ClassNode}, {@see AnonymousClassNode}, + * {@see FunctionNode}, and {@see AnonymousFunctionNode}. All four nodes carry + * the same body-level facts — layers, dependencies, function calls, + * superglobals, language constructs — so rules can ask the same questions of + * a function body that they ask of a class-like. * * @internal * + * @property list $layers All layer names this node belongs to; assigned once in each node's constructor * @property-read list $dependencies Fully-qualified class, function, or constant dependencies * @property-read string[] $functionCalls Functions called within this node * @property-read string[] $superglobals Superglobals accessed ($_GET, $_POST, etc.) @@ -25,7 +26,10 @@ */ trait NodeQueryTrait { - use LayerQueryTrait; + public function isInLayer(string $layer): bool + { + return in_array($layer, $this->layers, true); + } public function dependsOn(string $class): bool { diff --git a/src/Cache/AnalysisResultCache.php b/src/Cache/AnalysisResultCache.php index 7bf76efe..0c3c293f 100644 --- a/src/Cache/AnalysisResultCache.php +++ b/src/Cache/AnalysisResultCache.php @@ -66,7 +66,7 @@ final class AnalysisResultCache * their shape or naming changes: it is recorded in the metadata marker, * so a cache written by an older format is cleared on its next use. */ - public const FORMAT_VERSION = 6; + public const FORMAT_VERSION = 7; private readonly string $cacheDirectory; @@ -419,7 +419,7 @@ public function storeExtractionResult(array $files, string $namespace, Extractio * @param list $classNodes * @param list $anonymousClassNodes * @param list $fileReferences Class-like references made outside any - * named class-like scope in this file + * class-like scope in this file * @param list $fileInstantiations Class-like instantiations in this file * @param list $functionNodes * @param list $anonymousFunctionNodes @@ -567,18 +567,37 @@ private function fileInstantiationsFromPayload(array $payload): ?array */ private function anonymousClassNodeToArray(AnonymousClassNode $anonymousClassNode): array { - return [ + $node = [ 'file' => $anonymousClassNode->file, 'line' => $anonymousClassNode->line, 'extends' => $anonymousClassNode->extends, - 'implements' => $anonymousClassNode->implements, - 'traits' => $anonymousClassNode->traits, 'layer' => $anonymousClassNode->layer, 'enclosingClassName' => $anonymousClassNode->enclosingClassName, 'enclosingFunctionName' => $anonymousClassNode->enclosingFunctionName, 'hasEmptyParentheses' => $anonymousClassNode->hasEmptyParentheses, - 'layers' => $anonymousClassNode->layers, + 'isReadonly' => $anonymousClassNode->isReadonly, + ]; + + $lists = [ + 'implements' => array_values($anonymousClassNode->implements), + 'traits' => array_values($anonymousClassNode->traits), + 'layers' => $anonymousClassNode->layers, + 'dependencies' => $anonymousClassNode->dependencies, + 'methods' => array_map($this->methodNodeToArray(...), $anonymousClassNode->methods), + 'constants' => array_map($this->constantNodeToArray(...), $anonymousClassNode->constants), + 'properties' => array_map($this->propertyNodeToArray(...), $anonymousClassNode->properties), + 'functionCalls' => array_values($anonymousClassNode->functionCalls), + 'superglobals' => array_values($anonymousClassNode->superglobals), + 'languageConstructs' => array_values($anonymousClassNode->languageConstructs), ]; + + foreach ($lists as $key => $list) { + if ($list !== []) { + $node[$key] = $list; + } + } + + return $node; } /** @@ -610,6 +629,11 @@ private function anonymousClassNodesFromPayload(array $payload): ?array $enclosingFunctionName = $rawNode['enclosingFunctionName'] ?? null; $hasEmptyParentheses = $rawNode['hasEmptyParentheses'] ?? false; $layers = $rawNode['layers'] ?? []; + $isReadonly = $rawNode['isReadonly'] ?? false; + $dependencies = $rawNode['dependencies'] ?? []; + $functionCalls = $rawNode['functionCalls'] ?? []; + $superglobals = $rawNode['superglobals'] ?? []; + $languageConstructs = $rawNode['languageConstructs'] ?? []; if ( ! is_string($file) @@ -619,6 +643,7 @@ private function anonymousClassNodesFromPayload(array $payload): ?array || ($enclosingClassName !== null && ! is_string($enclosingClassName)) || ($enclosingFunctionName !== null && ! is_string($enclosingFunctionName)) || ! is_bool($hasEmptyParentheses) + || ! is_bool($isReadonly) ) { return null; } @@ -627,10 +652,22 @@ private function anonymousClassNodesFromPayload(array $payload): ?array ! $this->isStringArray($implements) || ! $this->isStringArray($traits) || ! $this->isStringArray($layers) + || ! $this->isStringArray($dependencies) + || ! $this->isStringArray($functionCalls) + || ! $this->isStringArray($superglobals) + || ! $this->isStringArray($languageConstructs) ) { return null; } + $methods = $this->memberNodesFromArray($rawNode['methods'] ?? [], $this->methodNodeFromArray(...)); + $constants = $this->memberNodesFromArray($rawNode['constants'] ?? [], $this->constantNodeFromArray(...)); + $properties = $this->memberNodesFromArray($rawNode['properties'] ?? [], $this->propertyNodeFromArray(...)); + + if ($methods === null || $constants === null || $properties === null) { + return null; + } + $anonymousClassNodes[] = new AnonymousClassNode( file: $file, line: $line, @@ -642,6 +679,14 @@ traits: $traits, enclosingFunctionName: $enclosingFunctionName, hasEmptyParentheses: $hasEmptyParentheses, layers: array_values($layers), + isReadonly: $isReadonly, + dependencies: array_values($dependencies), + methods: $methods, + constants: $constants, + properties: $properties, + functionCalls: array_values($functionCalls), + superglobals: array_values($superglobals), + languageConstructs: array_values($languageConstructs), ); } @@ -988,10 +1033,6 @@ private function classNodeFromArray(array $node, string $file): ?ClassNode $parentClasses = $node['parentClasses'] ?? []; $parentInterfaces = $node['parentInterfaces'] ?? []; $traits = $node['traits'] ?? []; - $rawMethods = $node['methods'] ?? []; - $rawConstants = $node['constants'] ?? []; - $rawProperties = $node['properties'] ?? []; - $rawEnumCases = $node['enumCases'] ?? []; $enumBackingType = $node['enumBackingType'] ?? null; $functionCalls = $node['functionCalls'] ?? []; $superglobals = $node['superglobals'] ?? []; @@ -1015,9 +1056,6 @@ private function classNodeFromArray(array $node, string $file): ?ClassNode || ! $this->isStringArray($parentClasses) || ! $this->isStringArray($parentInterfaces) || ! $this->isStringArray($traits) - || ! is_array($rawMethods) - || ! is_array($rawConstants) - || ! is_array($rawProperties) || ! $this->isStringArray($functionCalls) || ! $this->isStringArray($superglobals) || ! $this->isStringArray($languageConstructs) @@ -1026,74 +1064,21 @@ private function classNodeFromArray(array $node, string $file): ?ClassNode return null; } - $methods = []; - - foreach ($rawMethods as $rawMethod) { - if (! is_array($rawMethod)) { - return null; - } - - $methodNode = $this->methodNodeFromArray($rawMethod); - - if (! $methodNode instanceof MethodNode) { - return null; - } - - $methods[] = $methodNode; - } - - $constants = []; - - foreach ($rawConstants as $rawConstant) { - if (! is_array($rawConstant)) { - return null; - } - - $constantNode = $this->constantNodeFromArray($rawConstant); - - if (! $constantNode instanceof ConstantNode) { - return null; - } - - $constants[] = $constantNode; - } - - $properties = []; - - foreach ($rawProperties as $rawProperty) { - if (! is_array($rawProperty)) { - return null; - } - - $propertyNode = $this->propertyNodeFromArray($rawProperty); - - if (! $propertyNode instanceof PropertyNode) { - return null; - } - - $properties[] = $propertyNode; - } + $methods = $this->memberNodesFromArray($node['methods'] ?? [], $this->methodNodeFromArray(...)); + $constants = $this->memberNodesFromArray($node['constants'] ?? [], $this->constantNodeFromArray(...)); + $properties = $this->memberNodesFromArray($node['properties'] ?? [], $this->propertyNodeFromArray(...)); + $enumCases = $this->memberNodesFromArray($node['enumCases'] ?? [], $this->enumCaseNodeFromArray(...)); - if (! is_array($rawEnumCases) || ($enumBackingType !== null && ! is_string($enumBackingType))) { + if ( + $methods === null + || $constants === null + || $properties === null + || $enumCases === null + || ($enumBackingType !== null && ! is_string($enumBackingType)) + ) { return null; } - $enumCases = []; - - foreach ($rawEnumCases as $rawEnumCase) { - if (! is_array($rawEnumCase)) { - return null; - } - - $enumCaseNode = $this->enumCaseNodeFromArray($rawEnumCase); - - if (! $enumCaseNode instanceof EnumCaseNode) { - return null; - } - - $enumCases[] = $enumCaseNode; - } - return new ClassNode( className: $className, file: $file, @@ -1124,6 +1109,36 @@ enumBackingType: $enumBackingType, ); } + /** + * @template TMember of MethodNode|ConstantNode|PropertyNode|EnumCaseNode + * @param callable(array): (TMember|null) $memberFromArray + * @return list|null + */ + private function memberNodesFromArray(mixed $rawMembers, callable $memberFromArray): ?array + { + if (! is_array($rawMembers)) { + return null; + } + + $members = []; + + foreach ($rawMembers as $rawMember) { + if (! is_array($rawMember)) { + return null; + } + + $member = $memberFromArray($rawMember); + + if ($member === null) { + return null; + } + + $members[] = $member; + } + + return $members; + } + /** * Members are stored as positional tuples: a class has many of them, * and their field names would otherwise be repeated for every one. diff --git a/tests/Analyser/AnalysisNodeCollectorTest.php b/tests/Analyser/AnalysisNodeCollectorTest.php index 5b1290f9..1c5552d3 100644 --- a/tests/Analyser/AnalysisNodeCollectorTest.php +++ b/tests/Analyser/AnalysisNodeCollectorTest.php @@ -720,6 +720,113 @@ public function testCollectsTopLevelAnonymousClassNodeInFileWithoutNamedClasses( $this->assertSame('App\BaseHandler', $anonymousClassNodes[0]->extends); } + public function testCollectsAnonymousClassMembersAndBodyFactsLikeANamedClass(): void + { + $analysisNodeCollector = $this->makeCollector(<<<'PHP' + getAnonymousClassNodes(); + $classNodes = $analysisNodeCollector->getClassNodes(); + + $this->assertCount(1, $anonymousClassNodes); + $anonymousClassNode = $anonymousClassNodes[0]; + + $this->assertTrue($anonymousClassNode->isReadonly); + $this->assertSame(['App\Helper'], $anonymousClassNode->traits); + $this->assertSame(['LIMIT'], array_column($anonymousClassNode->constants, 'name')); + $this->assertSame(['count', 'clock'], array_column($anonymousClassNode->properties, 'name')); + $this->assertSame(['__construct', '__toString'], array_column($anonymousClassNode->methods, 'name')); + $this->assertSame(1, $anonymousClassNode->constructorParamCount()); + $this->assertSame(['strtoupper'], $anonymousClassNode->functionCalls); + $this->assertSame(['$_GET'], $anonymousClassNode->superglobals); + $this->assertSame(['isset', 'exit'], $anonymousClassNode->languageConstructs); + $this->assertTrue($anonymousClassNode->dependsOn('Stringable')); + $this->assertTrue($anonymousClassNode->dependsOn('App\Helper')); + $this->assertTrue($anonymousClassNode->dependsOn('App\Support\Clock')); + $this->assertTrue($anonymousClassNode->dependsOn('App\Other')); + // The file's imports belong to the file, not to the anonymous class. + $this->assertFalse($anonymousClassNode->dependsOn('App\Support\Unused')); + + // The enclosing class keeps seeing the anonymous class body, as it + // sees a closure's, but the members belong to the anonymous class. + $this->assertCount(1, $classNodes); + $this->assertSame(['make'], array_column($classNodes[0]->methods, 'name')); + $this->assertSame([], $classNodes[0]->constants); + $this->assertSame([], $classNodes[0]->properties); + $this->assertSame([], $classNodes[0]->traits); + $this->assertTrue($classNodes[0]->dependsOn('App\Other')); + $this->assertTrue($classNodes[0]->dependsOn('App\Support\Unused')); + $this->assertSame(['strtoupper'], $classNodes[0]->functionCalls); + $this->assertSame(['$_GET'], $classNodes[0]->superglobals); + $this->assertSame(['isset', 'exit'], $classNodes[0]->languageConstructs); + } + + public function testTopLevelAnonymousClassBodyReferencesAreItsOwnDependencies(): void + { + $analysisNodeCollector = $this->makeCollector(<<<'PHP' + getAnonymousClassNodes(); + + $this->assertCount(1, $anonymousClassNodes); + $this->assertSame(['App\BaseHandler', 'App\Other'], $anonymousClassNodes[0]->dependencies); + // Resolved once the whole file is traversed, like a named class's. + $this->assertSame(['App\helper'], $anonymousClassNodes[0]->functionCalls); + $this->assertSame([], $analysisNodeCollector->getFileReferences()); + } + + public function testNestedAnonymousClassesKeepTheirOwnMembersAndShareBodyDependencies(): void + { + $anonymousClassNodes = $this->collectAnonymousClassNodes(<<<'PHP' + assertCount(2, $anonymousClassNodes); + [$inner, $outer] = $anonymousClassNodes; + + $this->assertSame(['inner'], array_column($inner->methods, 'name')); + $this->assertSame(['outer'], array_column($outer->methods, 'name')); + $this->assertNull($outer->enclosingClassName); + $this->assertNull($inner->enclosingClassName); + + // The inner body is counted on both, like a closure's on its enclosing scopes. + $this->assertSame(['App\Clock'], $inner->dependencies); + $this->assertSame(['App\Clock'], $outer->dependencies); + } + public function testCollectsAnonymousClassNodeWithoutExtends(): void { $anonymousClassNodes = $this->collectAnonymousClassNodes('assertFalse($anonymousClassNode->extendsClass('App\\Support\\BaseClass')); $this->assertFalse($anonymousClassNode->implementsInterface('App\\Contracts\\FooInterface')); } + + public function testCarriesMembersAndBodyFactsLikeAClassNode(): void + { + $anonymousClassNode = new AnonymousClassNode( + file: '/src/HandlerFactory.php', + line: 7, + extends: null, + layer: 'Source', + isReadonly: true, + dependencies: ['App\\Support\\Clock'], + methods: [new MethodNode('__construct', 'public', false, false, 2, 1, 3)], + constants: [new ConstantNode('LIMIT')], + properties: [new PropertyNode('clock', 'private', true)], + functionCalls: ['strlen'], + superglobals: ['$_GET'], + languageConstructs: ['die'], + ); + + $this->assertTrue($anonymousClassNode->isReadonly); + $this->assertTrue($anonymousClassNode->isInLayer('Source')); + $this->assertTrue($anonymousClassNode->dependsOn('App\\Support\\Clock')); + $this->assertTrue($anonymousClassNode->dependsOnNamespace('App\\Support')); + $this->assertTrue($anonymousClassNode->callsFunction('STRLEN')); + $this->assertTrue($anonymousClassNode->accessesSuperglobals()); + $this->assertTrue($anonymousClassNode->usesLanguageConstruct('exit')); + $this->assertSame(2, $anonymousClassNode->constructorParamCount()); + $this->assertSame('LIMIT', $anonymousClassNode->constants[0]->name); + $this->assertSame('clock', $anonymousClassNode->properties[0]->name); + } + + public function testMembersAndBodyFactsDefaultToEmpty(): void + { + $anonymousClassNode = new AnonymousClassNode(file: '/src/helpers.php', line: 3, extends: null); + + $this->assertFalse($anonymousClassNode->isReadonly); + $this->assertSame([], $anonymousClassNode->methods); + $this->assertSame([], $anonymousClassNode->constants); + $this->assertSame([], $anonymousClassNode->properties); + $this->assertSame(0, $anonymousClassNode->constructorParamCount()); + $this->assertFalse($anonymousClassNode->dependsOn('App\\Support\\Clock')); + $this->assertFalse($anonymousClassNode->callsFunction('strlen')); + $this->assertFalse($anonymousClassNode->accessesSuperglobals()); + $this->assertFalse($anonymousClassNode->usesLanguageConstruct('exit')); + } } diff --git a/tests/Cache/AnalysisResultCacheTest.php b/tests/Cache/AnalysisResultCacheTest.php index 782789b7..ae31bd73 100644 --- a/tests/Cache/AnalysisResultCacheTest.php +++ b/tests/Cache/AnalysisResultCacheTest.php @@ -780,6 +780,14 @@ traits: ['App\Helper'], enclosingClassName: 'App\HandlerFactory', hasEmptyParentheses: true, layers: ['Source', 'Shared'], + isReadonly: true, + dependencies: ['App\BaseHandler', 'App\Contract'], + methods: [new MethodNode('__construct', 'public', false, false, 1, 1, 3, true, 8)], + constants: [new ConstantNode('LIMIT', 'public', true, 9)], + properties: [new PropertyNode('clock', 'private', true, 8)], + functionCalls: ['strtoupper'], + superglobals: ['$_GET'], + languageConstructs: ['exit'], ), new AnonymousClassNode( file: $sourceFile, @@ -1092,6 +1100,18 @@ public static function corruptedAnonymousClassNodesProvider(): Iterator yield 'entry with invalid traits' => [ [['file' => '/Foo.php', 'line' => 7, 'extends' => null, 'traits' => 'invalid']], ]; + yield 'entry with invalid isReadonly' => [ + [['file' => '/Foo.php', 'line' => 7, 'extends' => null, 'isReadonly' => 'yes']], + ]; + yield 'entry with invalid dependencies' => [ + [['file' => '/Foo.php', 'line' => 7, 'extends' => null, 'dependencies' => [1]]], + ]; + yield 'entry with invalid methods' => [ + [['file' => '/Foo.php', 'line' => 7, 'extends' => null, 'methods' => ['invalid']]], + ]; + yield 'entry with invalid method tuple' => [ + [['file' => '/Foo.php', 'line' => 7, 'extends' => null, 'methods' => [['x']]]], + ]; } public function testLoadClassNodesRejectsCorruptedFileReferencesPayload(): void