From 818b372853d8f947cda2c9c013f01190e72b675d Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Thu, 3 Sep 2026 18:25:48 +0700 Subject: [PATCH 1/4] Add AnonymousClassRuleInterface and new AnonymousClassMayNotHaveEmptyParenthesesRule --- docs/available-rules.md | 1 + docs/custom-rules-and-presets.md | 8 +- docs/presets.md | 2 +- src/Analyser/Analyser.php | 27 +- src/Analyser/AnalysisNodeCollector.php | 32 ++- src/Analyser/AnalysisNodeExtractor.php | 2 +- src/Analyser/AnonymousClassNode.php | 54 +++- src/Analyser/FileAnalysisProvider.php | 13 + src/Architecture.php | 14 +- src/Baseline/Baseline.php | 2 +- src/Cache/AnalysisResultCache.php | 63 +++-- src/Preset/Presets/PerPreset.php | 9 + src/Rule/AnonymousClassRuleInterface.php | 29 ++ ...RemoveAnonymousClassParenthesesVisitor.php | 78 ++++++ .../PhpParser/PhpParserFixerProcessor.php | 13 +- .../PhpParser/TokenAwareVisitorInterface.php | 21 ++ ...ousClassMayNotHaveEmptyParenthesesRule.php | 56 ++++ .../PhpParser/AnonymousClassParentheses.php | 67 +++++ tests/Analyser/AnalyserTest.php | 82 ++++++ tests/Analyser/AnalysisNodeCollectorTest.php | 58 +++- tests/Cache/AnalysisResultCacheTest.php | 20 +- tests/Preset/PresetTest.php | 1 + ...lassMayNotHaveEmptyParenthesesRuleTest.php | 249 ++++++++++++++++++ .../AnonymousClassParenthesesTest.php | 65 +++++ 24 files changed, 904 insertions(+), 62 deletions(-) create mode 100644 src/Rule/AnonymousClassRuleInterface.php create mode 100644 src/Rule/Fixer/PhpParser/Class_/RemoveAnonymousClassParenthesesVisitor.php create mode 100644 src/Rule/Fixer/PhpParser/TokenAwareVisitorInterface.php create mode 100644 src/Rule/Rules/Class_/AnonymousClassMayNotHaveEmptyParenthesesRule.php create mode 100644 src/Util/PhpParser/AnonymousClassParentheses.php create mode 100644 tests/Rule/Class_/AnonymousClassMayNotHaveEmptyParenthesesRuleTest.php create mode 100644 tests/Util/PhpParser/AnonymousClassParenthesesTest.php diff --git a/docs/available-rules.md b/docs/available-rules.md index 536ba66e..3612cd65 100644 --- a/docs/available-rules.md +++ b/docs/available-rules.md @@ -75,6 +75,7 @@ Namespace: `Boundwize\StructArmed\Rule\Rules\Class_`. | Rule | Constructor | Checks | |---|---|---| +| `AnonymousClassMayNotHaveEmptyParenthesesRule` | `new AnonymousClassMayNotHaveEmptyParenthesesRule(layer: 'Source')` | Anonymous classes that pass no constructor argument omit the parentheses after `class` (`new class {}`, not `new class () {}`), per [PER Coding Style](https://www.php-fig.org/per/coding-style/#8-anonymous-classes). Supports `--fix` by removing the empty parentheses. | | `ClassConstantNameMustBeUpperCaseRule` | `new ClassConstantNameMustBeUpperCaseRule(layer: 'Domain')` | Class, interface, and trait constants use upper case with underscore separators. Enums are skipped (PER Coding Style recommends PascalCase enum constants). | | `ClassImplementingInterfaceMustHaveSuffixRule` | `new ClassImplementingInterfaceMustHaveSuffixRule(layer: 'HTTP', interface: MiddlewareInterface::class, suffix: 'Middleware')` | Classes implementing a specific interface use the required suffix. | | `ClassNameMustBeStudlyCapsRule` | `new ClassNameMustBeStudlyCapsRule(layer: 'Source')` | Class names use StudlyCaps. | diff --git a/docs/custom-rules-and-presets.md b/docs/custom-rules-and-presets.md index 4b6a68fb..8bc06117 100644 --- a/docs/custom-rules-and-presets.md +++ b/docs/custom-rules-and-presets.md @@ -190,7 +190,9 @@ Both carry the body-level facts a `ClassNode` has — `$dependencies`, `$functio 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. -Rules opt in to these nodes by implementing `Boundwize\StructArmed\Rule\FunctionRuleInterface` and/or `Boundwize\StructArmed\Rule\AnonymousFunctionRuleInterface`. Both 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 are not part of the declarative `ruleset()` layer-dependency check. +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. 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. + +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. ```php functionName, $layerAwareRule->injectClassNodeMap($classDependencyMaps['classNodeMap']); } - // Function-likes are not part of the class hierarchy, so they take no - // part in the declarative ruleset below; a rule only sees the node - // kind whose interface it implements, so each node collection is - // paired with the rules grouped for its kind above. + // Function-likes and anonymous classes are not part of the class + // hierarchy, so they take no part in the declarative ruleset below; a + // rule only sees the node kind whose interface it implements, so each + // node collection is paired with the rules grouped for its kind above. $this->evaluateNodeRules( [ [$classNodes, $classNodeRules], [$extractionResult->functionNodes, $functionNodeRules], [$extractionResult->anonymousFunctionNodes, $anonymousFunctionNodeRules], + [$extractionResult->anonymousClassNodes, $anonymousClassNodeRules], ], $globalSkipPathMatcher, $ruleSkipMatchers, @@ -368,15 +376,16 @@ className: $classNode->className, /** * Evaluates each node collection against the rules grouped for its node - * kind, in a single evaluation implementation: all three rule interfaces + * kind, in a single evaluation implementation: all four rule interfaces * share the appliesTo()/evaluate() method names, and a rule only receives * the node kind whose interface it implements. * - * @param list, 1: array}> $nodeGroups + * @param list, 1: array}> $nodeGroups * @param array $ruleSkipMatchers * @phpstan-param list|list|list, - * 1: array + * 0: list|list|list|list, + * 1: array * }> $nodeGroups */ private function evaluateNodeRules( @@ -548,7 +557,7 @@ private function isSourceSynthesised(Architecture $architecture): bool } /** - * @param array $nodeRules + * @param array $nodeRules Node rules of every kind, by key * @param array> $ruleSkipPaths * @return array */ diff --git a/src/Analyser/AnalysisNodeCollector.php b/src/Analyser/AnalysisNodeCollector.php index e18029ce..1f1ae362 100644 --- a/src/Analyser/AnalysisNodeCollector.php +++ b/src/Analyser/AnalysisNodeCollector.php @@ -5,6 +5,7 @@ namespace Boundwize\StructArmed\Analyser; use Boundwize\StructArmed\LayerResolver\LayerResolverInterface; +use Boundwize\StructArmed\Util\PhpParser\AnonymousClassParentheses; use Boundwize\StructArmed\Util\PhpParser\VisibilityFlagChecker; use PhpParser\ConstExprEvaluationException; use PhpParser\ConstExprEvaluator; @@ -71,6 +72,7 @@ use PhpParser\Node\Stmt\Use_; use PhpParser\Node\Stmt\While_; use PhpParser\NodeVisitorAbstract; +use PhpParser\Token; use function array_keys; use function array_pop; @@ -314,6 +316,9 @@ final class AnalysisNodeCollector extends NodeVisitorAbstract private string $currentFile = ''; + /** @var array */ + private array $currentTokens = []; + /** @var array */ private array $currentNamespaceUses = []; @@ -392,9 +397,11 @@ public function __construct( }); } - public function setCurrentFile(string $file): void + /** @param array $tokens The file's token stream, for the facts its AST does not carry */ + public function setCurrentFile(string $file, array $tokens = []): void { $this->currentFile = $file; + $this->currentTokens = $tokens; $this->currentFileReferences = []; $this->currentFileInstantiations = []; $this->nonCanonicalKeywordConstants = []; @@ -695,12 +702,25 @@ public function leaveNode(Node $node): null // extend, the interfaces they implement, and the traits they use // are still used within the scanned paths. if ($node instanceof Class_) { + // 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), + 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, ); } diff --git a/src/Analyser/AnalysisNodeExtractor.php b/src/Analyser/AnalysisNodeExtractor.php index 56ec98d5..b0974f77 100644 --- a/src/Analyser/AnalysisNodeExtractor.php +++ b/src/Analyser/AnalysisNodeExtractor.php @@ -51,7 +51,7 @@ public function extract( $numericLiterals = []; if ($ast !== null && $ast !== []) { - $analysisNodeCollector->setCurrentFile($file); + $analysisNodeCollector->setCurrentFile($file, $this->fileAnalysisProvider->tokens()); $nodeTraverser->traverse($ast); $nonCanonicalKeywordConstants = $analysisNodeCollector->getNonCanonicalKeywordConstants(); diff --git a/src/Analyser/AnonymousClassNode.php b/src/Analyser/AnonymousClassNode.php index c1f85c57..3e2728ac 100644 --- a/src/Analyser/AnonymousClassNode.php +++ b/src/Analyser/AnonymousClassNode.php @@ -4,20 +4,39 @@ namespace Boundwize\StructArmed\Analyser; +use function array_filter; +use function in_array; + /** * An anonymous class declaration (`new class ... {}`). Anonymous classes never - * become ClassNodes — they cannot be referenced by name and no rule targets - * them directly — but the class they extend, the interfaces they implement, - * and the traits they use are still used within the scanned paths, which - * usage-aware rules must take into account. + * become ClassNodes — they cannot be referenced by name — so one is identified + * by its file and line, plus the named class-like and/or function it is + * declared in, and rules target it through + * {@see \Boundwize\StructArmed\Rule\AnonymousClassRuleInterface}. * - * The usage example is on MustBeFinalRule, which must skip if target class is extended by an anonymous class. + * The class it extends, the interfaces it implements, and the traits it uses + * are still used within the scanned paths, which usage-aware rules must take + * into account: MustBeFinalRule must skip a class extended by an anonymous class. */ final readonly class AnonymousClassNode { /** - * @param string[] $implements Interface names this anonymous class implements - * @param string[] $traits Trait names this anonymous class uses + * Scope label reported by {@see enclosingScopeName()} for an anonymous + * class declared outside any class-like or named function. + */ + public const FILE_SCOPE = 'file scope'; + + /** @var list */ + public 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] */ public function __construct( public string $file, @@ -25,6 +44,27 @@ public function __construct( public ?string $extends, public array $implements = [], public array $traits = [], + public ?string $layer = null, + public ?string $enclosingClassName = null, + public ?string $enclosingFunctionName = null, + public bool $hasEmptyParentheses = false, + array $layers = [], ) { + $this->layers = $layers ?: array_filter([$this->layer]); + } + + public function isInLayer(string $layer): bool + { + return in_array($layer, $this->layers, true); + } + + /** + * Label of the innermost named scope declaring this anonymous class — + * the enclosing class-like, else the enclosing named function — or + * {@see self::FILE_SCOPE} for one declared in top-level procedural code. + */ + public function enclosingScopeName(): string + { + return $this->enclosingClassName ?? $this->enclosingFunctionName ?? self::FILE_SCOPE; } } diff --git a/src/Analyser/FileAnalysisProvider.php b/src/Analyser/FileAnalysisProvider.php index b6afa900..6f036081 100644 --- a/src/Analyser/FileAnalysisProvider.php +++ b/src/Analyser/FileAnalysisProvider.php @@ -45,6 +45,7 @@ use PhpParser\Node\Stmt\Use_; use PhpParser\Parser; use PhpParser\ParserFactory; +use PhpParser\Token; use function array_key_exists; use function array_keys; @@ -202,6 +203,18 @@ public function ast(string $file, bool $retainForAnalysis = true): ?array return $this->parse($file); } + /** + * The token stream of the file {@see ast()} parsed last, for the facts an + * AST does not carry. PHP-Parser keeps it until its next parse, so it is + * read right after ast(); the provider itself retains no token arrays. + * + * @return array + */ + public function tokens(): array + { + return $this->parser->getTokens(); + } + /** * Parses an already normalised file that has neither a cached AST nor an * analysis, recording its AST, validity and invalid PHP tag line in one pass. diff --git a/src/Architecture.php b/src/Architecture.php index 92a5b77e..69566395 100644 --- a/src/Architecture.php +++ b/src/Architecture.php @@ -6,6 +6,7 @@ use Boundwize\StructArmed\Exception\RuleNotFoundException; use Boundwize\StructArmed\Preset\PresetInterface; +use Boundwize\StructArmed\Rule\AnonymousClassRuleInterface; use Boundwize\StructArmed\Rule\AnonymousFunctionRuleInterface; use Boundwize\StructArmed\Rule\FunctionRuleInterface; use Boundwize\StructArmed\Rule\ProjectRuleInterface; @@ -51,8 +52,8 @@ final class Architecture private array $layers = []; /** - * @var array - * key → rule + * @var array key → rule */ private array $rules = []; @@ -357,7 +358,8 @@ public function registerPresetSourcePaths(string $preset, ?array $sourcePaths): */ public function rule( string $key, - RuleInterface|ProjectRuleInterface|FunctionRuleInterface|AnonymousFunctionRuleInterface $rule, + RuleInterface|ProjectRuleInterface|FunctionRuleInterface + |AnonymousFunctionRuleInterface|AnonymousClassRuleInterface $rule, ): self { $this->rules[$key] = $rule; $this->resolvePendingRuleSkip($key); @@ -374,7 +376,8 @@ public function rule( */ public function replaceRule( string $key, - RuleInterface|ProjectRuleInterface|FunctionRuleInterface|AnonymousFunctionRuleInterface $rule, + RuleInterface|ProjectRuleInterface|FunctionRuleInterface + |AnonymousFunctionRuleInterface|AnonymousClassRuleInterface $rule, ): self { if (! isset($this->rules[$key])) { throw new RuleNotFoundException(sprintf( @@ -435,7 +438,8 @@ public function getRulesetSkipPaths(): array } /** - * @return array + * @return array */ public function getRules(): array { diff --git a/src/Baseline/Baseline.php b/src/Baseline/Baseline.php index e3a0fbed..f2417b6d 100644 --- a/src/Baseline/Baseline.php +++ b/src/Baseline/Baseline.php @@ -147,7 +147,7 @@ private function isListArray(Array_ $array): bool private function prettyPrintArray(Array_ $array): string { - return (new class () extends Standard { + return (new class extends Standard { // phpcs:ignore PSR1.Methods.CamelCapsMethodName.NotCamelCaps protected function pExpr_Array(Array_ $node): string { diff --git a/src/Cache/AnalysisResultCache.php b/src/Cache/AnalysisResultCache.php index 9e5df476..7bf76efe 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 = 5; + public const FORMAT_VERSION = 6; private readonly string $cacheDirectory; @@ -568,11 +568,16 @@ private function fileInstantiationsFromPayload(array $payload): ?array private function anonymousClassNodeToArray(AnonymousClassNode $anonymousClassNode): array { return [ - 'file' => $anonymousClassNode->file, - 'line' => $anonymousClassNode->line, - 'extends' => $anonymousClassNode->extends, - 'implements' => $anonymousClassNode->implements, - 'traits' => $anonymousClassNode->traits, + '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, ]; } @@ -595,26 +600,48 @@ private function anonymousClassNodesFromPayload(array $payload): ?array return null; } - $file = $rawNode['file'] ?? null; - $line = $rawNode['line'] ?? null; - $extends = $rawNode['extends'] ?? null; - $implements = $rawNode['implements'] ?? []; - $traits = $rawNode['traits'] ?? []; + $file = $rawNode['file'] ?? null; + $line = $rawNode['line'] ?? null; + $extends = $rawNode['extends'] ?? null; + $implements = $rawNode['implements'] ?? []; + $traits = $rawNode['traits'] ?? []; + $layer = $rawNode['layer'] ?? null; + $enclosingClassName = $rawNode['enclosingClassName'] ?? null; + $enclosingFunctionName = $rawNode['enclosingFunctionName'] ?? null; + $hasEmptyParentheses = $rawNode['hasEmptyParentheses'] ?? false; + $layers = $rawNode['layers'] ?? []; - if (! is_string($file) || ! is_int($line) || ($extends !== null && ! is_string($extends))) { + if ( + ! is_string($file) + || ! is_int($line) + || ($extends !== null && ! is_string($extends)) + || ($layer !== null && ! is_string($layer)) + || ($enclosingClassName !== null && ! is_string($enclosingClassName)) + || ($enclosingFunctionName !== null && ! is_string($enclosingFunctionName)) + || ! is_bool($hasEmptyParentheses) + ) { return null; } - if (! $this->isStringArray($implements) || ! $this->isStringArray($traits)) { + if ( + ! $this->isStringArray($implements) + || ! $this->isStringArray($traits) + || ! $this->isStringArray($layers) + ) { return null; } $anonymousClassNodes[] = new AnonymousClassNode( - file: $file, - line: $line, - extends: $extends, - implements: $implements, - traits: $traits, + file: $file, + line: $line, + extends: $extends, + implements: $implements, + traits: $traits, + layer: $layer, + enclosingClassName: $enclosingClassName, + enclosingFunctionName: $enclosingFunctionName, + hasEmptyParentheses: $hasEmptyParentheses, + layers: array_values($layers), ); } diff --git a/src/Preset/Presets/PerPreset.php b/src/Preset/Presets/PerPreset.php index 4235a1e4..dfb3344a 100644 --- a/src/Preset/Presets/PerPreset.php +++ b/src/Preset/Presets/PerPreset.php @@ -6,6 +6,7 @@ use Boundwize\StructArmed\Architecture; use Boundwize\StructArmed\Preset\PresetInterface; +use Boundwize\StructArmed\Rule\Rules\Class_\AnonymousClassMayNotHaveEmptyParenthesesRule; use Boundwize\StructArmed\Rule\Rules\Class_\EnumCaseNameMustBePascalCaseRule; use Boundwize\StructArmed\Rule\Rules\Class_\EnumConstantMayNotBeProtectedRule; use Boundwize\StructArmed\Rule\Rules\Class_\EnumMethodMayNotBeProtectedRule; @@ -25,6 +26,9 @@ public const ENUM_CONSTANTS_MAY_NOT_BE_PROTECTED = 'per.enum_constants.may_not_be_protected'; + public const ANONYMOUS_CLASSES_MAY_NOT_HAVE_EMPTY_PARENTHESES = + 'per.anonymous_classes.may_not_have_empty_parentheses'; + /** * @param list|null $sourcePaths */ @@ -61,5 +65,10 @@ public function apply(Architecture $architecture): void self::ENUM_CONSTANTS_MAY_NOT_BE_PROTECTED, new EnumConstantMayNotBeProtectedRule($layerName) ); + + $architecture->rule( + self::ANONYMOUS_CLASSES_MAY_NOT_HAVE_EMPTY_PARENTHESES, + new AnonymousClassMayNotHaveEmptyParenthesesRule($layerName) + ); } } diff --git a/src/Rule/AnonymousClassRuleInterface.php b/src/Rule/AnonymousClassRuleInterface.php new file mode 100644 index 00000000..949e0382 --- /dev/null +++ b/src/Rule/AnonymousClassRuleInterface.php @@ -0,0 +1,29 @@ + */ + private array $tokens = []; + + public function __construct( + private readonly int $line, + ) { + } + + public function setTokens(array $tokens): void + { + $this->tokens = $tokens; + } + + public function enterNode(Node $node): ?Node + { + if (! $node instanceof Class_ || $node->name instanceof Identifier || $node->getStartLine() !== $this->line) { + return null; + } + + $range = AnonymousClassParentheses::emptyTokenRange($this->tokens, $node); + + if ($range === null) { + return null; + } + + [$first, $last] = $range; + $hasChanged = false; + + for ($index = $first; $index <= $last; $index++) { + if ($this->tokens[$index]->text === '') { + continue; + } + + $this->tokens[$index]->text = ''; + $hasChanged = true; + } + + // `new class(){}` keeps a space between the keyword and what follows. + if (! isset($this->tokens[$last + 1]) || $this->tokens[$last + 1]->id !== T_WHITESPACE) { + $this->tokens[$last]->text = ' '; + $hasChanged = true; + } + + if (! $hasChanged) { + return null; + } + + return $node; + } +} diff --git a/src/Rule/Fixer/PhpParser/PhpParserFixerProcessor.php b/src/Rule/Fixer/PhpParser/PhpParserFixerProcessor.php index 8d6adf61..13880d6d 100644 --- a/src/Rule/Fixer/PhpParser/PhpParserFixerProcessor.php +++ b/src/Rule/Fixer/PhpParser/PhpParserFixerProcessor.php @@ -52,11 +52,18 @@ public function process(string $file, NodeVisitor|array $nodeVisitors, bool $rem return false; } + $tokens = $parser->getTokens(); $statements = (new NodeTraverser(new CloningVisitor()))->traverse($originalStatements); $statements = (new NodeTraverser(new NameResolver(options: ['replaceNodes' => false]))) ->traverse($statements); foreach ($nodeVisitors as $nodeVisitor) { + // A token edit lands in the output through the same tokens the + // format-preserving printer copies unchanged code from. + if ($nodeVisitor instanceof TokenAwareVisitorInterface) { + $nodeVisitor->setTokens($tokens); + } + $statements = (new NodeTraverser($nodeVisitor))->traverse($statements); } @@ -79,11 +86,7 @@ protected function pScalar_Float(Float_ $node): string return parent::pScalar_Float($node); } }; - $fixedCode = $prettyPrinter->printFormatPreserving( - $statements, - $originalStatements, - $parser->getTokens(), - ); + $fixedCode = $prettyPrinter->printFormatPreserving($statements, $originalStatements, $tokens); return $fixedCode !== $code && file_put_contents($file, $fixedCode) !== false; } diff --git a/src/Rule/Fixer/PhpParser/TokenAwareVisitorInterface.php b/src/Rule/Fixer/PhpParser/TokenAwareVisitorInterface.php new file mode 100644 index 00000000..d4fb8cc7 --- /dev/null +++ b/src/Rule/Fixer/PhpParser/TokenAwareVisitorInterface.php @@ -0,0 +1,21 @@ + $tokens The mutable tokens the file being fixed was parsed into */ + public function setTokens(array $tokens): void; +} diff --git a/src/Rule/Rules/Class_/AnonymousClassMayNotHaveEmptyParenthesesRule.php b/src/Rule/Rules/Class_/AnonymousClassMayNotHaveEmptyParenthesesRule.php new file mode 100644 index 00000000..7b71efd3 --- /dev/null +++ b/src/Rule/Rules/Class_/AnonymousClassMayNotHaveEmptyParenthesesRule.php @@ -0,0 +1,56 @@ +isInLayer($this->layer); + } + + public function evaluate(AnonymousClassNode $anonymousClassNode): ?RuleViolation + { + if (! $anonymousClassNode->hasEmptyParentheses) { + return null; + } + + return new RuleViolation( + message: sprintf( + 'Anonymous class in [%s] may not have empty parentheses after `class`', + $anonymousClassNode->enclosingScopeName() + ), + file: $anonymousClassNode->file, + line: $anonymousClassNode->line, + className: $anonymousClassNode->enclosingScopeName(), + layer: $anonymousClassNode->layer, + ); + } + + protected function createFixerVisitor(RuleViolation $ruleViolation): RemoveAnonymousClassParenthesesVisitor + { + return new RemoveAnonymousClassParenthesesVisitor($ruleViolation->line); + } +} diff --git a/src/Util/PhpParser/AnonymousClassParentheses.php b/src/Util/PhpParser/AnonymousClassParentheses.php new file mode 100644 index 00000000..25d6b69e --- /dev/null +++ b/src/Util/PhpParser/AnonymousClassParentheses.php @@ -0,0 +1,67 @@ + $tokens + * @return array{int, int}|null + */ + public static function emptyTokenRange(array $tokens, Class_ $class): ?array + { + $index = $class->getStartTokenPos(); + + // Attributes and modifiers (`new #[Attr] readonly class`) precede the keyword. + while (isset($tokens[$index]) && $tokens[$index]->id !== T_CLASS) { + $index++; + } + + if (! isset($tokens[$index])) { + return null; + } + + $first = $index + 1; + $index = self::skipWhitespace($tokens, $first); + + if (! isset($tokens[$index]) || $tokens[$index]->text !== '(') { + return null; + } + + $index = self::skipWhitespace($tokens, $index + 1); + + if (! isset($tokens[$index]) || $tokens[$index]->text !== ')') { + return null; + } + + return [$first, $index]; + } + + /** @param array $tokens */ + private static function skipWhitespace(array $tokens, int $index): int + { + while (isset($tokens[$index]) && $tokens[$index]->id === T_WHITESPACE) { + $index++; + } + + return $index; + } +} diff --git a/tests/Analyser/AnalyserTest.php b/tests/Analyser/AnalyserTest.php index a07dea3b..ed05d360 100644 --- a/tests/Analyser/AnalyserTest.php +++ b/tests/Analyser/AnalyserTest.php @@ -27,6 +27,7 @@ use Boundwize\StructArmed\Rule\AnonymousFunctionRuleInterface; use Boundwize\StructArmed\Rule\FileAnalysisRuleInterface; use Boundwize\StructArmed\Rule\FunctionRuleInterface; +use Boundwize\StructArmed\Rule\Rules\Class_\AnonymousClassMayNotHaveEmptyParenthesesRule; use Boundwize\StructArmed\Rule\Rules\Class_\MustBeFinalRule; use Boundwize\StructArmed\Rule\Rules\Composer\Psr4SourcePathsRule; use Boundwize\StructArmed\Rule\Rules\File\Psr1PhpTagsRule; @@ -291,6 +292,87 @@ public function testFunctionRuleViolationsSurviveTheAnalysisNodeCacheWithFileAna $this->assertEquals($coldViolations, $warmViolations); } + /** @return array */ + private function anonymousClassRuleProjectFiles(): array + { + return [ + 'src/Handler.php' => <<<'PHP' + <<<'PHP' + <<<'PHP' + makeTempProject($this->anonymousClassRuleProjectFiles()); + + $architecture = Architecture::define() + ->layer('Source', 'src/') + ->rule('anonymous_classes.no_parentheses', new AnonymousClassMayNotHaveEmptyParenthesesRule('Source')) + ->skip(['anonymous_classes.no_parentheses' => ['src/Skipped/']]); + + foreach ([AnalyserOptions::sequential(), AnalyserOptions::parallel(2)] as $analyserOptions) { + $violations = (new Analyser($basePath)) + ->analyse($architecture, [], null, $analyserOptions) + ->forRule('anonymous_classes.no_parentheses'); + + $messages = array_map( + static fn(RuleViolation $ruleViolation): string => $ruleViolation->message, + $violations + ); + sort($messages); + + $this->assertSame([ + 'Anonymous class in [App\\Handler] may not have empty parentheses after `class`', + 'Anonymous class in [App\\make] may not have empty parentheses after `class`', + ], $messages); + + foreach ($violations as $violation) { + $this->assertSame('anonymous_classes.no_parentheses', $violation->ruleKey); + $this->assertSame('Source', $violation->layer); + $this->assertTrue($violation->fixable); + $this->assertStringNotContainsString('/Skipped/', $this->normalisePath($violation->file)); + } + } + } + + public function testAnonymousClassRuleViolationsSurviveTheAnalysisNodeCache(): void + { + $basePath = $this->makeTempProject($this->anonymousClassRuleProjectFiles()); + $analysisResultCache = new AnalysisResultCache($basePath, new FileHashProvider(), 'cache'); + + $architecture = Architecture::define() + ->layer('Source', 'src/') + ->rule('anonymous_classes.no_parentheses', new AnonymousClassMayNotHaveEmptyParenthesesRule('Source')); + + $coldViolations = (new Analyser($basePath, $analysisResultCache, 'config')) + ->analyse($architecture, [], null, AnalyserOptions::sequential()) + ->forRule('anonymous_classes.no_parentheses'); + $warmViolations = (new Analyser($basePath, $analysisResultCache, 'config')) + ->analyse($architecture, [], null, AnalyserOptions::sequential()) + ->forRule('anonymous_classes.no_parentheses'); + + $this->assertCount(3, $coldViolations); + $this->assertEquals($coldViolations, $warmViolations); + } + public function testSkippedFunctionRuleIsNotEvaluated(): void { $basePath = $this->makeTempProject($this->functionRuleProjectFiles()); diff --git a/tests/Analyser/AnalysisNodeCollectorTest.php b/tests/Analyser/AnalysisNodeCollectorTest.php index e7674804..5b1290f9 100644 --- a/tests/Analyser/AnalysisNodeCollectorTest.php +++ b/tests/Analyser/AnalysisNodeCollectorTest.php @@ -141,7 +141,7 @@ private function makeCollector(string $code): AnalysisNodeCollector $parser = (new ParserFactory())->createForNewestSupportedVersion(); $ast = $parser->parse($code); - $analysisNodeCollector->setCurrentFile('/fake/path/Foo.php'); + $analysisNodeCollector->setCurrentFile('/fake/path/Foo.php', $parser->getTokens()); $nodeTraverser = new NodeTraverser(new NameResolver(), $analysisNodeCollector); $nodeTraverser->traverse($ast ?? []); @@ -654,6 +654,62 @@ public function make(): BaseHandler { return new class extends BaseHandler {}; } $this->assertSame('/fake/path/Foo.php', $anonymousClassNodes[0]->file); } + public function testCollectsAnonymousClassEnclosingScopesAndEmptyParentheses(): void + { + $anonymousClassNodes = $this->collectAnonymousClassNodes(<<<'PHP' + assertCount(4, $anonymousClassNodes); + + $this->assertSame('App\HandlerFactory', $anonymousClassNodes[0]->enclosingClassName); + $this->assertNull($anonymousClassNodes[0]->enclosingFunctionName); + $this->assertSame('App\HandlerFactory', $anonymousClassNodes[0]->enclosingScopeName()); + $this->assertTrue($anonymousClassNodes[0]->hasEmptyParentheses); + + $this->assertNull($anonymousClassNodes[1]->enclosingClassName); + $this->assertSame('App\make', $anonymousClassNodes[1]->enclosingFunctionName); + $this->assertSame('App\make', $anonymousClassNodes[1]->enclosingScopeName()); + $this->assertTrue($anonymousClassNodes[1]->hasEmptyParentheses); + $this->assertSame(['Stringable'], $anonymousClassNodes[1]->implements); + + $this->assertSame(AnonymousClassNode::FILE_SCOPE, $anonymousClassNodes[2]->enclosingScopeName()); + $this->assertFalse($anonymousClassNodes[2]->hasEmptyParentheses); + + $this->assertSame(AnonymousClassNode::FILE_SCOPE, $anonymousClassNodes[3]->enclosingScopeName()); + $this->assertFalse($anonymousClassNodes[3]->hasEmptyParentheses); + + // The fake file is outside every configured layer path. + $this->assertNull($anonymousClassNodes[0]->layer); + $this->assertSame([], $anonymousClassNodes[0]->layers); + $this->assertFalse($anonymousClassNodes[0]->isInLayer('Domain')); + } + + public function testAnonymousClassParenthesesAreUnknownWithoutTokens(): void + { + $namespaceLayerResolver = new NamespaceLayerResolver(['Domain' => 'src/Domain/'], self::BASE_PATH); + $analysisNodeCollector = new AnalysisNodeCollector($namespaceLayerResolver); + $parser = (new ParserFactory())->createForNewestSupportedVersion(); + + $analysisNodeCollector->setCurrentFile('/fake/path/Foo.php'); + (new NodeTraverser(new NameResolver(), $analysisNodeCollector)) + ->traverse($parser->parse('getAnonymousClassNodes(); + + $this->assertCount(1, $anonymousClassNodes); + $this->assertFalse($anonymousClassNodes[0]->hasEmptyParentheses); + } + public function testCollectsTopLevelAnonymousClassNodeInFileWithoutNamedClasses(): void { $anonymousClassNodes = $this->collectAnonymousClassNodes('makeClassNode($sourceFile)]; $anonymousClassNodes = [ new AnonymousClassNode( - file: $sourceFile, - line: 7, - extends: 'App\BaseHandler', - implements: ['App\Contract'], - traits: ['App\Helper'], + file: $sourceFile, + line: 7, + extends: 'App\BaseHandler', + implements: ['App\Contract'], + traits: ['App\Helper'], + layer: 'Source', + enclosingClassName: 'App\HandlerFactory', + hasEmptyParentheses: true, + layers: ['Source', 'Shared'], + ), + new AnonymousClassNode( + file: $sourceFile, + line: 12, + extends: null, + enclosingFunctionName: 'App\make', ), ]; diff --git a/tests/Preset/PresetTest.php b/tests/Preset/PresetTest.php index 26336ed9..77c0b8f6 100644 --- a/tests/Preset/PresetTest.php +++ b/tests/Preset/PresetTest.php @@ -190,6 +190,7 @@ public function testPerPresetAppliesPsr12RulesAndAddsEnumCaseRule(): void $this->assertArrayHasKey(PerPreset::ENUM_CASES_MUST_BE_PASCAL_CASE, $rules); $this->assertArrayHasKey(PerPreset::ENUM_METHODS_MAY_NOT_BE_PROTECTED, $rules); $this->assertArrayHasKey(PerPreset::ENUM_CONSTANTS_MAY_NOT_BE_PROTECTED, $rules); + $this->assertArrayHasKey(PerPreset::ANONYMOUS_CLASSES_MAY_NOT_HAVE_EMPTY_PARENTHESES, $rules); } public function testPerPresetUsesComposerSourcePathsByDefault(): void diff --git a/tests/Rule/Class_/AnonymousClassMayNotHaveEmptyParenthesesRuleTest.php b/tests/Rule/Class_/AnonymousClassMayNotHaveEmptyParenthesesRuleTest.php new file mode 100644 index 00000000..82a0222a --- /dev/null +++ b/tests/Rule/Class_/AnonymousClassMayNotHaveEmptyParenthesesRuleTest.php @@ -0,0 +1,249 @@ +assertTrue($anonymousClassMayNotHaveEmptyParenthesesRule->appliesTo( + $this->makeNode(layer: 'Source') + )); + $this->assertTrue($anonymousClassMayNotHaveEmptyParenthesesRule->appliesTo( + $this->makeNode(layer: 'Other', layers: ['Other', 'Source']) + )); + $this->assertFalse($anonymousClassMayNotHaveEmptyParenthesesRule->appliesTo( + $this->makeNode(layer: 'Other') + )); + $this->assertFalse($anonymousClassMayNotHaveEmptyParenthesesRule->appliesTo( + $this->makeNode(layer: null) + )); + } + + public function testPassesAnonymousClassWithoutEmptyParentheses(): void + { + $anonymousClassMayNotHaveEmptyParenthesesRule = new AnonymousClassMayNotHaveEmptyParenthesesRule('Source'); + + $this->assertNotInstanceOf( + RuleViolation::class, + $anonymousClassMayNotHaveEmptyParenthesesRule->evaluate($this->makeNode(hasEmptyParentheses: false)) + ); + } + + public function testFlagsAnonymousClassWithEmptyParentheses(): void + { + $anonymousClassMayNotHaveEmptyParenthesesRule = new AnonymousClassMayNotHaveEmptyParenthesesRule('Source'); + + $violation = $anonymousClassMayNotHaveEmptyParenthesesRule->evaluate( + $this->makeNode(enclosingClassName: 'App\Factory') + ); + + $this->assertInstanceOf(RuleViolation::class, $violation); + $this->assertSame( + 'Anonymous class in [App\Factory] may not have empty parentheses after `class`', + $violation->message + ); + $this->assertSame('/src/Factory.php', $violation->file); + $this->assertSame(7, $violation->line); + $this->assertSame('App\Factory', $violation->className); + $this->assertSame('Source', $violation->layer); + } + + public function testReportsFileScopeForTopLevelAnonymousClass(): void + { + $anonymousClassMayNotHaveEmptyParenthesesRule = new AnonymousClassMayNotHaveEmptyParenthesesRule('Source'); + + $violation = $anonymousClassMayNotHaveEmptyParenthesesRule->evaluate($this->makeNode()); + + $this->assertInstanceOf(RuleViolation::class, $violation); + $this->assertSame(AnonymousClassNode::FILE_SCOPE, $violation->className); + } + + public function testAnalyseThenFixRemovesOnlyEmptyParentheses(): void + { + $basePath = $this->makeTemporaryDirectory('structarmed-anonymous-class-parentheses'); + mkdir($basePath . '/src'); + + $file = $basePath . '/src/Factory.php'; + + file_put_contents($file, <<<'PHP' + layer('Source', 'src/') + ->rule('source.anonymous_classes', new AnonymousClassMayNotHaveEmptyParenthesesRule(layer: 'Source')); + + $violations = (new Analyser($basePath)) + ->analyse($architecture, [], null, AnalyserOptions::sequential()) + ->forRule('source.anonymous_classes'); + + $this->assertSame( + [11, 25, 26, 27, 27, 28], + array_map(static fn (RuleViolation $ruleViolation): int => $ruleViolation->line, $violations) + ); + $this->assertTrue($violations[0]->fixable); + $this->assertSame('App\Factory', $violations[0]->className); + + $rule = $architecture->getRules()['source.anonymous_classes']; + $this->assertInstanceOf(AnonymousClassMayNotHaveEmptyParenthesesRule::class, $rule); + + // The CLI fixes one file's violations in a single parse-and-write cycle. + $this->assertTrue($rule->fix($violations[0], ...array_slice($violations, 1))); + + // Only the empty parentheses are gone: the class bodies, the brace + // placement, and the blank lines are untouched. + $this->assertSame(<<<'PHP' + assertCount( + 0, + (new Analyser($basePath)) + ->analyse($architecture, [], null, AnalyserOptions::sequential()) + ->forRule('source.anonymous_classes') + ); + $this->assertFalse($rule->fix($violations[0])); + } + + public function testFixLeavesAnonymousClassOnAnotherLineAlone(): void + { + $basePath = $this->makeTemporaryDirectory('structarmed-anonymous-class-parentheses-line'); + $file = $basePath . '/Factory.php'; + + file_put_contents($file, <<<'PHP' + assertTrue( + $anonymousClassMayNotHaveEmptyParenthesesRule->fix(new RuleViolation('message', $file, 4, 'file scope')) + ); + + $this->assertSame(<<<'PHP' + $layers */ + private function makeNode( + ?string $layer = 'Source', + ?string $enclosingClassName = null, + bool $hasEmptyParentheses = true, + array $layers = [], + ): AnonymousClassNode { + return new AnonymousClassNode( + file: '/src/Factory.php', + line: 7, + extends: null, + layer: $layer, + enclosingClassName: $enclosingClassName, + hasEmptyParentheses: $hasEmptyParentheses, + layers: $layers, + ); + } +} diff --git a/tests/Util/PhpParser/AnonymousClassParenthesesTest.php b/tests/Util/PhpParser/AnonymousClassParenthesesTest.php new file mode 100644 index 00000000..3ac952fd --- /dev/null +++ b/tests/Util/PhpParser/AnonymousClassParenthesesTest.php @@ -0,0 +1,65 @@ +createForNewestSupportedVersion(); + $statements = $parser->parse($code); + $tokens = $parser->getTokens(); + + $class = (new NodeFinder())->findFirstInstanceOf($statements ?? [], Class_::class); + $this->assertInstanceOf(Class_::class, $class); + + $range = AnonymousClassParentheses::emptyTokenRange($tokens, $class); + + if ($expectedRangeText === null) { + $this->assertNull($range); + + return; + } + + $this->assertIsArray($range); + [$first, $last] = $range; + + $rangeText = ''; + for ($index = $first; $index <= $last; $index++) { + $rangeText .= $tokens[$index]->text; + } + + $this->assertSame($expectedRangeText, $rangeText); + } + + /** @return iterable */ + public static function anonymousClassProvider(): iterable + { + yield 'no parentheses' => [' [' [' [" [' [' [' [' ['assertNull(AnonymousClassParentheses::emptyTokenRange([], new Class_(null))); + } +} From aa8c1d6c583b4fb55f4cfdf763d9fcbf7c610c64 Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Thu, 3 Sep 2026 18:32:00 +0700 Subject: [PATCH 2/4] add more test --- ...RemoveAnonymousClassParenthesesVisitor.php | 13 ++---------- tests/Analyser/FileAnalysisProviderTest.php | 21 +++++++++++++++++++ 2 files changed, 23 insertions(+), 11 deletions(-) diff --git a/src/Rule/Fixer/PhpParser/Class_/RemoveAnonymousClassParenthesesVisitor.php b/src/Rule/Fixer/PhpParser/Class_/RemoveAnonymousClassParenthesesVisitor.php index c747926e..6f0e3f71 100644 --- a/src/Rule/Fixer/PhpParser/Class_/RemoveAnonymousClassParenthesesVisitor.php +++ b/src/Rule/Fixer/PhpParser/Class_/RemoveAnonymousClassParenthesesVisitor.php @@ -51,26 +51,17 @@ public function enterNode(Node $node): ?Node return null; } + // A range always holds the `(` and `)` tokens with their text, so + // blanking it is always a change; an already-fixed class yields no range. [$first, $last] = $range; - $hasChanged = false; for ($index = $first; $index <= $last; $index++) { - if ($this->tokens[$index]->text === '') { - continue; - } - $this->tokens[$index]->text = ''; - $hasChanged = true; } // `new class(){}` keeps a space between the keyword and what follows. if (! isset($this->tokens[$last + 1]) || $this->tokens[$last + 1]->id !== T_WHITESPACE) { $this->tokens[$last]->text = ' '; - $hasChanged = true; - } - - if (! $hasChanged) { - return null; } return $node; diff --git a/tests/Analyser/FileAnalysisProviderTest.php b/tests/Analyser/FileAnalysisProviderTest.php index d23d925f..a2efe6fa 100644 --- a/tests/Analyser/FileAnalysisProviderTest.php +++ b/tests/Analyser/FileAnalysisProviderTest.php @@ -12,6 +12,7 @@ use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; +use function array_column; use function base64_encode; use function file_put_contents; use function sys_get_temp_dir; @@ -112,6 +113,26 @@ public function testReportsInvalidTagsAndInvalidAstWithoutThrowing(): void $this->assertFalse($fileAnalysis->hasSideEffects); } + public function testExposesTokensOfTheFileParsedLast(): void + { + $fileAnalysisProvider = new FileAnalysisProvider(); + + $this->assertIsArray($fileAnalysisProvider->ast($this->source('tokens() as $token) { + $tokenTexts[] = $token->text; + } + + $this->assertContains('class', $tokenTexts); + $this->assertContains('(', $tokenTexts); + + // The next parse replaces them. + $this->assertIsArray($fileAnalysisProvider->ast($this->source('assertNotContains('class', array_column($fileAnalysisProvider->tokens(), 'text')); + } + public function testParsesAstWithoutRetainingItForAnalysis(): void { $fileAnalysisProvider = new FileAnalysisProvider(); From 04d7ab1076ae1bf1a05bbe2432d7745ae8e1d8fc Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Thu, 3 Sep 2026 18:38:51 +0700 Subject: [PATCH 3/4] more use case --- .../PhpParser/AnonymousClassParentheses.php | 55 +++++++++++++++---- ...lassMayNotHaveEmptyParenthesesRuleTest.php | 8 ++- .../AnonymousClassParenthesesTest.php | 8 ++- 3 files changed, 56 insertions(+), 15 deletions(-) diff --git a/src/Util/PhpParser/AnonymousClassParentheses.php b/src/Util/PhpParser/AnonymousClassParentheses.php index 25d6b69e..70625119 100644 --- a/src/Util/PhpParser/AnonymousClassParentheses.php +++ b/src/Util/PhpParser/AnonymousClassParentheses.php @@ -7,7 +7,11 @@ use PhpParser\Node\Stmt\Class_; use PhpParser\Token; +use function end; + use const T_CLASS; +use const T_COMMENT; +use const T_DOC_COMMENT; use const T_WHITESPACE; /** @@ -19,18 +23,24 @@ final class AnonymousClassParentheses { /** - * The index range, from the token after `class` through `)`, of the empty - * parentheses the anonymous class carries; null when it carries none, - * passes an argument, or has a comment inside the range. + * The index range of the empty parentheses the anonymous class carries, + * including the whitespace separating them from what precedes; null when + * it carries none, passes an argument, or has a comment inside them, + * which removing the parentheses would delete. * * @param array $tokens * @return array{int, int}|null */ public static function emptyTokenRange(array $tokens, Class_ $class): ?array { - $index = $class->getStartTokenPos(); + // An attribute argument may hold `Foo::class`, another T_CLASS token, + // so the keyword is searched for after the last attribute group. + $attrGroups = $class->attrGroups; + $index = $attrGroups === [] + ? $class->getStartTokenPos() + : end($attrGroups)->getEndTokenPos() + 1; - // Attributes and modifiers (`new #[Attr] readonly class`) precede the keyword. + // Modifiers (`new readonly class`) still precede the keyword. while (isset($tokens[$index]) && $tokens[$index]->id !== T_CLASS) { $index++; } @@ -39,20 +49,28 @@ public static function emptyTokenRange(array $tokens, Class_ $class): ?array return null; } - $first = $index + 1; - $index = self::skipWhitespace($tokens, $first); + $keyword = $index; + $open = self::skipWhitespaceAndComments($tokens, $keyword + 1); - if (! isset($tokens[$index]) || $tokens[$index]->text !== '(') { + if (! isset($tokens[$open]) || $tokens[$open]->text !== '(') { return null; } - $index = self::skipWhitespace($tokens, $index + 1); + $close = self::skipWhitespace($tokens, $open + 1); - if (! isset($tokens[$index]) || $tokens[$index]->text !== ')') { + if (! isset($tokens[$close]) || $tokens[$close]->text !== ')') { return null; } - return [$first, $index]; + // The whitespace before `(` goes too, back to the keyword or to a + // comment in between, which stays. + $first = $open; + + while ($first - 1 > $keyword && $tokens[$first - 1]->id === T_WHITESPACE) { + $first--; + } + + return [$first, $close]; } /** @param array $tokens */ @@ -64,4 +82,19 @@ private static function skipWhitespace(array $tokens, int $index): int return $index; } + + /** @param array $tokens */ + private static function skipWhitespaceAndComments(array $tokens, int $index): int + { + while ( + isset($tokens[$index]) + && ($tokens[$index]->id === T_WHITESPACE + || $tokens[$index]->id === T_COMMENT + || $tokens[$index]->id === T_DOC_COMMENT) + ) { + $index++; + } + + return $index; + } } diff --git a/tests/Rule/Class_/AnonymousClassMayNotHaveEmptyParenthesesRuleTest.php b/tests/Rule/Class_/AnonymousClassMayNotHaveEmptyParenthesesRuleTest.php index 82a0222a..c05d25e5 100644 --- a/tests/Rule/Class_/AnonymousClassMayNotHaveEmptyParenthesesRuleTest.php +++ b/tests/Rule/Class_/AnonymousClassMayNotHaveEmptyParenthesesRuleTest.php @@ -121,10 +121,11 @@ public function baz(): int }; $b = new class {}; $c = new readonly class(1) {}; - $d = new #[Attr] class ( ) {}; + $d = new #[Attr(Foo::class)] class ( ) {}; $e = new class(){}; $f = [new class () {}, new class {}, new class ( ) {}]; $g = function () { return new class () {}; }; + $h = new class /* comment */ () {}; return $a; } @@ -141,7 +142,7 @@ public function baz(): int ->forRule('source.anonymous_classes'); $this->assertSame( - [11, 25, 26, 27, 27, 28], + [11, 25, 26, 27, 27, 28, 29], array_map(static fn (RuleViolation $ruleViolation): int => $ruleViolation->line, $violations) ); $this->assertTrue($violations[0]->fixable); @@ -180,10 +181,11 @@ public function baz(): int }; $b = new class {}; $c = new readonly class(1) {}; - $d = new #[Attr] class {}; + $d = new #[Attr(Foo::class)] class {}; $e = new class {}; $f = [new class {}, new class {}, new class {}]; $g = function () { return new class {}; }; + $h = new class /* comment */ {}; return $a; } diff --git a/tests/Util/PhpParser/AnonymousClassParenthesesTest.php b/tests/Util/PhpParser/AnonymousClassParenthesesTest.php index 3ac952fd..b4550fa7 100644 --- a/tests/Util/PhpParser/AnonymousClassParenthesesTest.php +++ b/tests/Util/PhpParser/AnonymousClassParenthesesTest.php @@ -53,8 +53,14 @@ public static function anonymousClassProvider(): iterable yield 'newline inside parentheses' => [" [' [' [' [ + ' [' [' [' [" [' Date: Thu, 3 Sep 2026 18:40:50 +0700 Subject: [PATCH 4/4] use existing ->isAnonymous() --- src/Analyser/AnalysisNodeCollector.php | 50 +++++++++---------- ...RemoveAnonymousClassParenthesesVisitor.php | 3 +- .../PhpParser/AnonymousClassParentheses.php | 5 +- 3 files changed, 27 insertions(+), 31 deletions(-) diff --git a/src/Analyser/AnalysisNodeCollector.php b/src/Analyser/AnalysisNodeCollector.php index 1f1ae362..e2d6ebe8 100644 --- a/src/Analyser/AnalysisNodeCollector.php +++ b/src/Analyser/AnalysisNodeCollector.php @@ -697,32 +697,30 @@ public function leaveNode(Node $node): null array_pop($this->activeClassLikeNames); array_pop($this->functionLikeDepthAtClassLikeEntry); - if (! $node->name instanceof Identifier) { - // 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. - if ($node instanceof Class_) { - // 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, - ); - } + // 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. + 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; } diff --git a/src/Rule/Fixer/PhpParser/Class_/RemoveAnonymousClassParenthesesVisitor.php b/src/Rule/Fixer/PhpParser/Class_/RemoveAnonymousClassParenthesesVisitor.php index 6f0e3f71..9ed91bfe 100644 --- a/src/Rule/Fixer/PhpParser/Class_/RemoveAnonymousClassParenthesesVisitor.php +++ b/src/Rule/Fixer/PhpParser/Class_/RemoveAnonymousClassParenthesesVisitor.php @@ -7,7 +7,6 @@ use Boundwize\StructArmed\Rule\Fixer\PhpParser\TokenAwareVisitorInterface; use Boundwize\StructArmed\Util\PhpParser\AnonymousClassParentheses; use PhpParser\Node; -use PhpParser\Node\Identifier; use PhpParser\Node\Stmt\Class_; use PhpParser\NodeVisitorAbstract; use PhpParser\Token; @@ -41,7 +40,7 @@ public function setTokens(array $tokens): void public function enterNode(Node $node): ?Node { - if (! $node instanceof Class_ || $node->name instanceof Identifier || $node->getStartLine() !== $this->line) { + if (! $node instanceof Class_ || ! $node->isAnonymous() || $node->getStartLine() !== $this->line) { return null; } diff --git a/src/Util/PhpParser/AnonymousClassParentheses.php b/src/Util/PhpParser/AnonymousClassParentheses.php index 70625119..aa4e1a24 100644 --- a/src/Util/PhpParser/AnonymousClassParentheses.php +++ b/src/Util/PhpParser/AnonymousClassParentheses.php @@ -8,6 +8,7 @@ use PhpParser\Token; use function end; +use function in_array; use const T_CLASS; use const T_COMMENT; @@ -88,9 +89,7 @@ private static function skipWhitespaceAndComments(array $tokens, int $index): in { while ( isset($tokens[$index]) - && ($tokens[$index]->id === T_WHITESPACE - || $tokens[$index]->id === T_COMMENT - || $tokens[$index]->id === T_DOC_COMMENT) + && (in_array($tokens[$index]->id, [T_WHITESPACE, T_COMMENT, T_DOC_COMMENT], true)) ) { $index++; }