Skip to content

Commit a5bcd16

Browse files
committed
[Utils] Add token-based duplicate code detector with CI gate
Adds utils/duplicates, a minimal phpcpd clone (token_get_all + Rabin-Karp), a Duplicated Code CI job failing on copy-paste >= 300 tokens, and extracts the shared ForeachToArrayFind logic into a factory so src/rules stays under the gate.
1 parent 222bbf2 commit a5bcd16

14 files changed

Lines changed: 649 additions & 284 deletions
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
name: Duplicated Code
2+
3+
on:
4+
pull_request: null
5+
push:
6+
branches:
7+
- "main"
8+
9+
env:
10+
COMPOSER_ROOT_VERSION: "dev-main"
11+
12+
jobs:
13+
duplicated_code:
14+
runs-on: ubuntu-latest
15+
timeout-minutes: 5
16+
17+
steps:
18+
- uses: actions/checkout@v5
19+
20+
-
21+
uses: shivammathur/setup-php@v2
22+
with:
23+
php-version: 8.4
24+
coverage: none
25+
26+
- run: composer install --no-progress --ansi
27+
28+
# fails when a large copy-pasted block (>= 300 tokens) is added
29+
- run: composer duplicated-code

‎composer.json‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,8 @@
7070
"src"
7171
],
7272
"Rector\\Utils\\": "utils",
73-
"Rector\\Utils\\PHPStan\\": "utils/phpstan/src"
73+
"Rector\\Utils\\PHPStan\\": "utils/phpstan/src",
74+
"Rector\\Utils\\Duplicates\\": "utils/duplicates/src"
7475
},
7576
"files": [
7677
"src/functions/node_helper.php"
@@ -83,6 +84,7 @@
8384
"tests"
8485
],
8586
"Rector\\Utils\\PHPStan\\Tests\\": "utils/phpstan/tests",
87+
"Rector\\Utils\\Duplicates\\Tests\\": "utils/duplicates/tests",
8688
"E2e\\Parallel\\Reflection\\Resolver\\": "e2e/parallel-reflection-resolver/src/",
8789
"Rector\\Scripts\\": "scripts/src"
8890
},
@@ -104,11 +106,12 @@
104106
"@phpstan",
105107
"@test"
106108
],
107-
"test": "vendor/bin/fastunit tests rules-tests utils/phpstan/tests",
109+
"test": "vendor/bin/fastunit tests rules-tests utils/phpstan/tests utils/duplicates/tests",
108110
"check-cs": "vendor/bin/ecs check --ansi",
109111
"fix-cs": "vendor/bin/ecs check --fix --ansi",
110112
"phpstan": "vendor/bin/phpstan analyse --ansi --memory-limit=512M",
111113
"rector": "bin/rector process --ansi",
114+
"duplicated-code": "php utils/duplicates/bin/duplicates.php --min-lines 5 --min-tokens 300 src rules",
112115
"preload": "php build/build-preload.php .",
113116
"release": "vendor/bin/rng --from-commit X --to-commit Y --remote-repository rectorphp/rector-symfony --remote-repository rectorphp/rector-doctrine --remote-repository rectorphp/rector-phpunit"
114117
},
Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Rector\Php84\NodeAnalyzer;
6+
7+
use PhpParser\Node;
8+
use PhpParser\Node\Expr;
9+
use PhpParser\Node\Expr\ArrowFunction;
10+
use PhpParser\Node\Expr\Assign;
11+
use PhpParser\Node\Expr\Variable;
12+
use PhpParser\Node\Param;
13+
use PhpParser\Node\Stmt\Break_;
14+
use PhpParser\Node\Stmt\Expression;
15+
use PhpParser\Node\Stmt\Foreach_;
16+
use PhpParser\Node\Stmt\If_;
17+
use Rector\NodeManipulator\StmtsManipulator;
18+
use Rector\NodeNameResolver\NodeNameResolver;
19+
use Rector\NodeTypeResolver\NodeTypeResolver;
20+
use Rector\PhpParser\Comparing\NodeComparator;
21+
use Rector\PhpParser\Node\NodeFactory;
22+
use Rector\PhpParser\Node\Value\ValueResolver;
23+
24+
/**
25+
* Shared logic for ForeachToArrayFindRector and ForeachToArrayFindKeyRector.
26+
*/
27+
final readonly class ForeachToArrayFindFactory
28+
{
29+
public function __construct(
30+
private ValueResolver $valueResolver,
31+
private StmtsManipulator $stmtsManipulator,
32+
private ForeachKeyUsedInConditionalAnalyzer $foreachKeyUsedInConditionalAnalyzer,
33+
private NodeComparator $nodeComparator,
34+
private NodeTypeResolver $nodeTypeResolver,
35+
private NodeFactory $nodeFactory,
36+
private NodeNameResolver $nodeNameResolver
37+
) {
38+
}
39+
40+
/**
41+
* @param StmtsAware $node
42+
*/
43+
public function createArrayFindAssign(Node $node, string $functionName, bool $compareKey): ?Node
44+
{
45+
if ($node->stmts === null) {
46+
return null;
47+
}
48+
49+
foreach ($node->stmts as $key => $stmt) {
50+
if (! $stmt instanceof Foreach_) {
51+
continue;
52+
}
53+
54+
$prevStmt = $node->stmts[$key - 1] ?? null;
55+
if (! $prevStmt instanceof Expression) {
56+
continue;
57+
}
58+
59+
if (! $prevStmt->expr instanceof Assign) {
60+
continue;
61+
}
62+
63+
$foreach = $stmt;
64+
$prevAssign = $prevStmt->expr;
65+
66+
if (! $this->valueResolver->isNull($prevAssign->expr)) {
67+
continue;
68+
}
69+
70+
if (! $prevAssign->var instanceof Variable) {
71+
continue;
72+
}
73+
74+
$assignedVariable = $prevAssign->var;
75+
76+
if (! $this->isValidForeachStructure($foreach, $assignedVariable, $compareKey)) {
77+
continue;
78+
}
79+
80+
if ($this->stmtsManipulator->isVariableUsedInNextStmt(
81+
$node,
82+
$key + 1,
83+
(string) $this->nodeNameResolver->getName($foreach->valueVar)
84+
)) {
85+
continue;
86+
}
87+
88+
/** @var If_ $firstNodeInsideForeach */
89+
$firstNodeInsideForeach = $foreach->stmts[0];
90+
91+
$condition = $firstNodeInsideForeach->cond;
92+
$valueParam = $foreach->valueVar;
93+
94+
if (! $valueParam instanceof Variable) {
95+
continue;
96+
}
97+
98+
$params = [new Param($valueParam)];
99+
100+
if ($foreach->keyVar instanceof Variable && $this->foreachKeyUsedInConditionalAnalyzer->isUsed(
101+
$foreach->keyVar,
102+
$condition
103+
)) {
104+
$params[] = new Param(new Variable((string) $this->nodeNameResolver->getName($foreach->keyVar)));
105+
}
106+
107+
$arrowFunction = new ArrowFunction([
108+
'params' => $params,
109+
'expr' => $condition,
110+
]);
111+
112+
$funcCall = $this->nodeFactory->createFuncCall($functionName, [$foreach->expr, $arrowFunction]);
113+
114+
$newAssign = new Assign($assignedVariable, $funcCall);
115+
$newExpression = new Expression($newAssign);
116+
117+
unset($node->stmts[$key - 1]);
118+
$node->stmts[$key] = $newExpression;
119+
120+
$node->stmts = array_values($node->stmts);
121+
122+
return $node;
123+
}
124+
125+
return null;
126+
}
127+
128+
private function isValidForeachStructure(Foreach_ $foreach, Variable $assignedVariable, bool $compareKey): bool
129+
{
130+
if (count($foreach->stmts) !== 1) {
131+
return false;
132+
}
133+
134+
$comparedExpr = $compareKey ? $foreach->keyVar : $foreach->valueVar;
135+
if (! $comparedExpr instanceof Expr) {
136+
return false;
137+
}
138+
139+
$firstStmt = $foreach->stmts[0];
140+
if (
141+
! $firstStmt instanceof If_ ||
142+
count($firstStmt->stmts) !== 2
143+
) {
144+
return false;
145+
}
146+
147+
$assignmentStmt = $firstStmt->stmts[0];
148+
$breakStmt = $firstStmt->stmts[1];
149+
150+
if (
151+
! $assignmentStmt instanceof Expression ||
152+
! $assignmentStmt->expr instanceof Assign ||
153+
! $breakStmt instanceof Break_
154+
) {
155+
return false;
156+
}
157+
158+
$assignment = $assignmentStmt->expr;
159+
160+
if (! $this->nodeComparator->areNodesEqual($assignment->var, $assignedVariable)) {
161+
return false;
162+
}
163+
164+
if (! $this->nodeComparator->areNodesEqual($assignment->expr, $comparedExpr)) {
165+
return false;
166+
}
167+
168+
if (! $foreach->valueVar instanceof Variable) {
169+
return false;
170+
}
171+
172+
$type = $this->nodeTypeResolver->getNativeType($foreach->expr);
173+
return $type->isArray()
174+
->yes();
175+
}
176+
}

0 commit comments

Comments
 (0)