Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/custom-rules-and-presets.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
84 changes: 54 additions & 30 deletions src/Analyser/Analyser.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> $dependencies
* @param array<string|null> $clauseNames The node's own name, if any, and its extends, implements, and traits
* @param array<string, true> $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
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand Down
130 changes: 68 additions & 62 deletions src/Analyser/AnalysisNodeCollector.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<int, array{string|null, string|null}>
*/
private array $anonymousClassEnclosingNames = [];

/** @var array<string, true> */
private array $fileFunctions = [];

Expand Down Expand Up @@ -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 = [];
Expand Down Expand Up @@ -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<string, list<string>>
*/
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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;
Expand All @@ -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) {
Expand All @@ -753,6 +749,7 @@ public function afterTraverse(array $nodes): null
}

$this->fileClassLikes = [];
$this->anonymousClassEnclosingNames = [];
$this->classLikeAnalysis = [];
$this->activeClassLikeAnalyses = [];
$this->activeClassLikeScopes = [];
Expand All @@ -766,28 +763,30 @@ 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;
}

/**
* 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;
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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()) {
Expand Down
Loading