diff --git a/README.md b/README.md
index 8a50dee48..0d939a4ce 100644
--- a/README.md
+++ b/README.md
@@ -420,6 +420,27 @@ final class ServiceContainerTest extends AbstractContainerTestCase
+## 12. Detect Duplicated Code
+
+Spot copy-pasted code blocks with a token-based detector, a small clone of phpcpd.
+Add it to CI to fail when a large copy-pasted block is added:
+
+```bash
+vendor/bin/swiss-knife duplicated-code src
+vendor/bin/swiss-knife duplicated-code src rules --min-tokens 150 --min-lines 5
+```
+
+Options:
+
+- `--min-lines` minimum lines of a reported clone (default `5`)
+- `--min-tokens` minimum tokens of a reported clone (default `70`)
+- `--fuzzy` ignore variable names when matching
+- `--skip-file` file paths or masks to skip
+
+Exit code is `1` when clones are found, `0` otherwise.
+
+
+
That's it!
diff --git a/composer.json b/composer.json
index fd0c4fc94..98ca6801b 100644
--- a/composer.json
+++ b/composer.json
@@ -7,6 +7,7 @@
],
"require": {
"php": ">=8.4",
+ "ext-tokenizer": "*",
"entropy/entropy": "^0.4.6",
"nette/robot-loader": "^4.1",
"nette/utils": "^4.1",
diff --git a/ecs.php b/ecs.php
index 5634c5b90..55d17833a 100644
--- a/ecs.php
+++ b/ecs.php
@@ -9,6 +9,6 @@
// invalid syntax test fixture
__DIR__ . '/tests/PhpParser/Finder/ClassConstantFetchFinder/Fixture/Error/ParseError.php',
])
- ->withPreparedSets(psr12: true, common: true, symplify: true)
+ ->withPreparedSets(psr12: true, common: true)
->withPaths([__DIR__ . '/src', __DIR__ . '/tests'])
->withRootFiles();
diff --git a/src/Command/DuplicatedCodeCommand.php b/src/Command/DuplicatedCodeCommand.php
new file mode 100644
index 000000000..c40a82b7e
--- /dev/null
+++ b/src/Command/DuplicatedCodeCommand.php
@@ -0,0 +1,97 @@
+getRealPath();
+ }
+
+ $this->outputPrinter->yellow(sprintf('Scanning %d *.php files for duplicated code', count($filePaths)));
+
+ $cloneDetector = new CloneDetector($minLines, $minTokens, $fuzzy);
+ $clones = $cloneDetector->detect($filePaths);
+
+ if ($clones === []) {
+ $this->outputPrinter->green(sprintf('No duplicates found in %d files', count($filePaths)));
+ return ExitCode::SUCCESS;
+ }
+
+ $duplicatedLines = 0;
+ foreach ($clones as $clone) {
+ $this->outputPrinter->writeln(sprintf(
+ ' * %s:%d-%d (%d lines, %d tokens)',
+ $clone->firstFile->filePath,
+ $clone->firstFile->startLine,
+ $clone->firstFile->endLine,
+ $clone->lines,
+ $clone->tokens
+ ));
+ $this->outputPrinter->writeln(sprintf(
+ ' %s:%d-%d',
+ $clone->secondFile->filePath,
+ $clone->secondFile->startLine,
+ $clone->secondFile->endLine
+ ));
+ $this->outputPrinter->newline();
+
+ $duplicatedLines += $clone->lines;
+ }
+
+ $this->outputPrinter->redBackground(sprintf(
+ 'Found %d clones with %d duplicated lines in %d scanned files',
+ count($clones),
+ $duplicatedLines,
+ count($filePaths)
+ ));
+
+ return ExitCode::ERROR;
+ }
+
+ public function getName(): string
+ {
+ return 'duplicated-code';
+ }
+
+ public function getDescription(): string
+ {
+ return 'Finds duplicated, copy-pasted code blocks via token-based detection';
+ }
+}
diff --git a/src/DuplicatedCode/CloneDetector.php b/src/DuplicatedCode/CloneDetector.php
new file mode 100644
index 000000000..49476dbae
--- /dev/null
+++ b/src/DuplicatedCode/CloneDetector.php
@@ -0,0 +1,196 @@
+
+ */
+ private const array IGNORED_TOKENS = [
+ T_INLINE_HTML => true,
+ T_COMMENT => true,
+ T_DOC_COMMENT => true,
+ T_OPEN_TAG => true,
+ T_OPEN_TAG_WITH_ECHO => true,
+ T_CLOSE_TAG => true,
+ T_WHITESPACE => true,
+ ];
+
+ /**
+ * Bytes contributed by each kept token to the signature: 1 type byte + 4 CRC32 bytes.
+ */
+ private const int BYTES_PER_TOKEN = 5;
+
+ /**
+ * First seen location per window hash: hash => [filePath, tokenIndex].
+ *
+ * @var array
+ */
+ private array $hashes = [];
+
+ /**
+ * Per-file token line numbers, kept for span line lookup.
+ *
+ * @var array
+ */
+ private array $fileLines = [];
+
+ /**
+ * @var CodeClone[]
+ */
+ private array $clones = [];
+
+ public function __construct(
+ private readonly int $minLines,
+ private readonly int $minTokens,
+ private readonly bool $fuzzy
+ ) {
+ }
+
+ /**
+ * @param string[] $filePaths
+ * @return CodeClone[]
+ */
+ public function detect(array $filePaths): array
+ {
+ foreach ($filePaths as $filePath) {
+ $this->processFile($filePath);
+ }
+
+ return $this->clones;
+ }
+
+ private function processFile(string $filePath): void
+ {
+ $source = file_get_contents($filePath);
+ if ($source === false) {
+ return;
+ }
+
+ [$signature, $lines] = $this->tokenize($source);
+ $this->fileLines[$filePath] = $lines;
+
+ $tokenCount = count($lines);
+ if ($tokenCount < $this->minTokens) {
+ return;
+ }
+
+ $lastWindow = $tokenCount - $this->minTokens;
+ $windowBytes = $this->minTokens * self::BYTES_PER_TOKEN;
+
+ $found = false;
+ $firstToken = 0;
+ $originFile = '';
+ $originToken = 0;
+
+ for ($i = 0; $i <= $lastWindow; ++$i) {
+ $hash = substr(md5(substr($signature, $i * self::BYTES_PER_TOKEN, $windowBytes), true), 0, 8);
+
+ if (isset($this->hashes[$hash])) {
+ if (! $found) {
+ $found = true;
+ $firstToken = $i;
+ [$originFile, $originToken] = $this->hashes[$hash];
+ }
+
+ continue;
+ }
+
+ if ($found) {
+ $this->recordClone($originFile, $originToken, $filePath, $firstToken, $i);
+ $found = false;
+ }
+
+ $this->hashes[$hash] = [$filePath, $i];
+ }
+
+ if ($found) {
+ $this->recordClone($originFile, $originToken, $filePath, $firstToken, $lastWindow + 1);
+ }
+ }
+
+ private function recordClone(
+ string $originFile,
+ int $originToken,
+ string $currentFile,
+ int $currentToken,
+ int $mismatchWindow
+ ): void {
+ $windowCount = $mismatchWindow - $currentToken;
+ $tokenSpan = $windowCount + $this->minTokens - 1;
+
+ $originLines = $this->fileLines[$originFile];
+ $currentLines = $this->fileLines[$currentFile];
+
+ $originStartLine = $originLines[$originToken];
+ $originEndLine = $originLines[min($originToken + $tokenSpan - 1, count($originLines) - 1)];
+
+ $currentStartLine = $currentLines[$currentToken];
+ $currentEndLine = $currentLines[min($currentToken + $tokenSpan - 1, count($currentLines) - 1)];
+
+ $numberOfLines = $originEndLine - $originStartLine + 1;
+ if ($numberOfLines < $this->minLines) {
+ return;
+ }
+
+ $this->clones[] = new CodeClone(
+ new CodeCloneFile($originFile, $originStartLine, $originEndLine),
+ new CodeCloneFile($currentFile, $currentStartLine, $currentEndLine),
+ $numberOfLines,
+ $tokenSpan
+ );
+ }
+
+ /**
+ * Builds the token signature and the parallel line map for a source string.
+ *
+ * @return array{string, int[]}
+ */
+ private function tokenize(string $source): array
+ {
+ $signature = '';
+ $lines = [];
+ $currentLine = 1;
+
+ foreach (token_get_all($source) as $token) {
+ if (is_array($token)) {
+ $tokenId = $token[0];
+ $tokenText = $token[1];
+ $currentLine = $token[2];
+
+ if (isset(self::IGNORED_TOKENS[$tokenId])) {
+ $currentLine += substr_count($tokenText, "\n");
+ continue;
+ }
+
+ if ($this->fuzzy && $tokenId === T_VARIABLE) {
+ $tokenText = '$';
+ }
+
+ $signature .= chr($tokenId & 255) . pack('N*', crc32($tokenText));
+ $lines[] = $currentLine;
+ $currentLine += substr_count($tokenText, "\n");
+
+ continue;
+ }
+
+ $signature .= chr(0) . pack('N*', crc32($token));
+ $lines[] = $currentLine;
+ }
+
+ return [$signature, $lines];
+ }
+}
diff --git a/src/DuplicatedCode/ValueObject/CodeClone.php b/src/DuplicatedCode/ValueObject/CodeClone.php
new file mode 100644
index 000000000..c4ad52a6a
--- /dev/null
+++ b/src/DuplicatedCode/ValueObject/CodeClone.php
@@ -0,0 +1,16 @@
+detect([
+ __DIR__ . '/Fixture/first_duplicate.php.inc',
+ __DIR__ . '/Fixture/second_duplicate.php.inc',
+ ]);
+
+ $this->assertCount(1, $clones);
+
+ $codeClone = $clones[0];
+ $this->assertStringEndsWith('first_duplicate.php.inc', $codeClone->firstFile->filePath);
+ $this->assertStringEndsWith('second_duplicate.php.inc', $codeClone->secondFile->filePath);
+ $this->assertGreaterThanOrEqual(3, $codeClone->lines);
+ }
+
+ public function testDoesNotReportUniqueCode(): void
+ {
+ $cloneDetector = new CloneDetector(3, 25, false);
+
+ $clones = $cloneDetector->detect([
+ __DIR__ . '/Fixture/first_duplicate.php.inc',
+ __DIR__ . '/Fixture/unique.php.inc',
+ ]);
+
+ $this->assertSame([], $clones);
+ }
+}
diff --git a/tests/DuplicatedCode/Fixture/first_duplicate.php.inc b/tests/DuplicatedCode/Fixture/first_duplicate.php.inc
new file mode 100644
index 000000000..dded1cbdc
--- /dev/null
+++ b/tests/DuplicatedCode/Fixture/first_duplicate.php.inc
@@ -0,0 +1,13 @@
+