diff --git a/docs/assets/no-violation.svg b/docs/assets/no-violation.svg
index 48f9759b..d439da80 100644
--- a/docs/assets/no-violation.svg
+++ b/docs/assets/no-violation.svg
@@ -14,7 +14,7 @@
➜ prj-ddd vendor/bin/structarmed analyze
-
+
===============================================
diff --git a/docs/assets/structarmed-showoff.svg b/docs/assets/structarmed-showoff.svg
index 22c4198f..0011c093 100644
--- a/docs/assets/structarmed-showoff.svg
+++ b/docs/assets/structarmed-showoff.svg
@@ -15,7 +15,7 @@
➜ prj-ddd vendor/bin/structarmed analyze
-
+
===============================================
diff --git a/docs/available-rules.md b/docs/available-rules.md
index daed7056..6b0bad45 100644
--- a/docs/available-rules.md
+++ b/docs/available-rules.md
@@ -61,7 +61,8 @@ Namespace: `Boundwize\StructArmed\Rule\Rules\File`.
|---|---|---|
| `Psr1PhpTagsRule` | `new Psr1PhpTagsRule(sourcePaths: ['src/'])` | PHP files use only `layer($layerName, $this->sourcePaths ?? []);
$architecture->rule(self::FILES_MUST_USE_VALID_TAGS, new Psr1PhpTagsRule($this->sourcePaths));
+ $architecture->rule(self::FILES_MUST_USE_VALID_UTF8, new Psr1ValidUtf8Rule($this->sourcePaths));
$architecture->rule(self::FILES_MUST_USE_UTF8_WITHOUT_BOM, new Psr1Utf8WithoutBomRule($this->sourcePaths));
$architecture->rule(
self::FILES_SHOULD_DECLARE_SYMBOLS_OR_SIDE_EFFECTS,
diff --git a/src/Rule/Rules/File/Psr1Utf8WithoutBomRule.php b/src/Rule/Rules/File/Psr1Utf8WithoutBomRule.php
index f37ae54e..9a2e0aba 100644
--- a/src/Rule/Rules/File/Psr1Utf8WithoutBomRule.php
+++ b/src/Rule/Rules/File/Psr1Utf8WithoutBomRule.php
@@ -7,11 +7,17 @@
use Boundwize\StructArmed\Analyser\FileAnalysisProvider;
use Boundwize\StructArmed\Architecture;
use Boundwize\StructArmed\Rule\FileAnalysisRuleInterface;
+use Boundwize\StructArmed\Rule\FixableInterface;
use Boundwize\StructArmed\Rule\RuleViolation;
+use function file_get_contents;
+use function file_put_contents;
+use function is_file;
use function sprintf;
+use function str_starts_with;
+use function substr;
-final readonly class Psr1Utf8WithoutBomRule implements FileAnalysisRuleInterface
+final readonly class Psr1Utf8WithoutBomRule implements FileAnalysisRuleInterface, FixableInterface
{
/**
* @param list|null $sourcePaths
@@ -62,19 +68,24 @@ public function evaluateProjectAllWithProvider(
line: 1,
className: '',
);
- continue;
- }
-
- if (! $fileAnalysisProvider->hasValidUtf8($file)) {
- $violations[] = new RuleViolation(
- message: sprintf('File [%s] must use valid UTF-8 encoding', $file),
- file: $file,
- line: 1,
- className: '',
- );
}
}
return $violations;
}
+
+ public function fix(RuleViolation $ruleViolation): bool
+ {
+ if (! is_file($ruleViolation->file)) {
+ return false;
+ }
+
+ $code = (string) file_get_contents($ruleViolation->file);
+
+ if (! str_starts_with($code, "\xEF\xBB\xBF")) {
+ return false;
+ }
+
+ return file_put_contents($ruleViolation->file, substr($code, 3)) !== false;
+ }
}
diff --git a/src/Rule/Rules/File/Psr1ValidUtf8Rule.php b/src/Rule/Rules/File/Psr1ValidUtf8Rule.php
new file mode 100644
index 00000000..974b9826
--- /dev/null
+++ b/src/Rule/Rules/File/Psr1ValidUtf8Rule.php
@@ -0,0 +1,70 @@
+|null $sourcePaths
+ */
+ public function __construct(
+ private ?array $sourcePaths = null,
+ private ?PhpFileFinder $phpFileFinder = null,
+ ) {
+ }
+
+ public function evaluateProject(string $basePath, Architecture $architecture, array $skipPaths = []): ?RuleViolation
+ {
+ return $this->evaluateProjectAll($basePath, $architecture, $skipPaths)[0] ?? null;
+ }
+
+ /**
+ * @param list $skipPaths
+ * @return RuleViolation[]
+ */
+ public function evaluateProjectAll(string $basePath, Architecture $architecture, array $skipPaths = []): array
+ {
+ return $this->evaluateProjectAllWithProvider(
+ $basePath,
+ $architecture,
+ new FileAnalysisProvider(),
+ $skipPaths,
+ );
+ }
+
+ /**
+ * @param list $skipPaths
+ * @return RuleViolation[]
+ */
+ public function evaluateProjectAllWithProvider(
+ string $basePath,
+ Architecture $architecture,
+ FileAnalysisProvider $fileAnalysisProvider,
+ array $skipPaths = [],
+ ): array {
+ $phpFileFinder = $this->phpFileFinder ?? new PhpFileFinder($this->sourcePaths);
+ $violations = [];
+
+ foreach ($phpFileFinder->files($basePath, $skipPaths) as $file) {
+ if (! $fileAnalysisProvider->hasValidUtf8($file)) {
+ $violations[] = new RuleViolation(
+ message: sprintf('File [%s] must use valid UTF-8 encoding', $file),
+ file: $file,
+ line: 1,
+ className: '',
+ );
+ }
+ }
+
+ return $violations;
+ }
+}
diff --git a/tests/Preset/PresetTest.php b/tests/Preset/PresetTest.php
index 901cdd42..82b041a2 100644
--- a/tests/Preset/PresetTest.php
+++ b/tests/Preset/PresetTest.php
@@ -38,6 +38,7 @@ public function testPsr1PresetRegistersSourceLayerAndRules(): void
$rules = $architecture->getRules();
$this->assertArrayHasKey(Psr1Preset::FILES_MUST_USE_VALID_TAGS, $rules);
+ $this->assertArrayHasKey(Psr1Preset::FILES_MUST_USE_VALID_UTF8, $rules);
$this->assertArrayHasKey(Psr1Preset::FILES_MUST_USE_UTF8_WITHOUT_BOM, $rules);
$this->assertArrayHasKey(Psr1Preset::FILES_SHOULD_DECLARE_SYMBOLS_OR_SIDE_EFFECTS, $rules);
$this->assertArrayHasKey(Psr4Preset::CLASSES_MUST_MATCH_COMPOSER, $rules);
@@ -60,6 +61,7 @@ public function testPsr12PresetAppliesPsr1RulesAndAddsVisibilityRules(): void
$rules = $architecture->getRules();
$this->assertArrayHasKey(Psr1Preset::FILES_MUST_USE_VALID_TAGS, $rules);
+ $this->assertArrayHasKey(Psr1Preset::FILES_MUST_USE_VALID_UTF8, $rules);
$this->assertArrayHasKey(Psr1Preset::FILES_MUST_USE_UTF8_WITHOUT_BOM, $rules);
$this->assertArrayHasKey(Psr1Preset::FILES_SHOULD_DECLARE_SYMBOLS_OR_SIDE_EFFECTS, $rules);
$this->assertArrayHasKey(Psr4Preset::CLASSES_MUST_MATCH_COMPOSER, $rules);
@@ -220,9 +222,10 @@ public function testPsr1AndPsr12BothEnabledDoNotDuplicatePsr1Rules(): void
$rules = $architecture->getRules();
- $this->assertCount(14, $rules);
+ $this->assertCount(15, $rules);
$this->assertArrayHasKey(Psr1Preset::FILES_MUST_USE_VALID_TAGS, $rules);
+ $this->assertArrayHasKey(Psr1Preset::FILES_MUST_USE_VALID_UTF8, $rules);
$this->assertArrayHasKey(Psr1Preset::FILES_MUST_USE_UTF8_WITHOUT_BOM, $rules);
$this->assertArrayHasKey(Psr1Preset::FILES_SHOULD_DECLARE_SYMBOLS_OR_SIDE_EFFECTS, $rules);
$this->assertArrayHasKey(Psr4Preset::CLASSES_MUST_MATCH_COMPOSER, $rules);
diff --git a/tests/Rule/File/Psr1Utf8WithoutBomRuleTest.php b/tests/Rule/File/Psr1Utf8WithoutBomRuleTest.php
index bb94acfc..a8c9a2f2 100644
--- a/tests/Rule/File/Psr1Utf8WithoutBomRuleTest.php
+++ b/tests/Rule/File/Psr1Utf8WithoutBomRuleTest.php
@@ -5,6 +5,7 @@
namespace Boundwize\StructArmed\Tests\Rule\File;
use Boundwize\StructArmed\Architecture;
+use Boundwize\StructArmed\Rule\FixableInterface;
use Boundwize\StructArmed\Rule\Rules\File\PhpFileFinder;
use Boundwize\StructArmed\Rule\Rules\File\Psr1Utf8WithoutBomRule;
use Boundwize\StructArmed\Rule\RuleViolation;
@@ -12,6 +13,7 @@
use PHPUnit\Framework\TestCase;
use function bin2hex;
+use function file_get_contents;
use function file_put_contents;
use function mkdir;
use function random_bytes;
@@ -41,7 +43,7 @@ public function testViolatesUtf8Bom(): void
}
}
- public function testViolatesInvalidUtf8(): void
+ public function testPassesInvalidUtf8WithoutBom(): void
{
$basePath = $this->makeTempDir();
@@ -51,8 +53,7 @@ public function testViolatesInvalidUtf8(): void
$violations = (new Psr1Utf8WithoutBomRule(['src/']))->evaluateProjectAll($basePath, Architecture::define());
- $this->assertCount(1, $violations);
- $this->assertStringContainsString('valid UTF-8', $violations[0]->message);
+ $this->assertSame([], $violations);
} finally {
unlink($basePath . '/src/Foo.php');
rmdir($basePath . '/src');
@@ -60,7 +61,7 @@ public function testViolatesInvalidUtf8(): void
}
}
- public function testPassesValidUtf8AndMissingPaths(): void
+ public function testPassesFilesWithoutBomAndMissingPaths(): void
{
$basePath = $this->makeTempDir();
@@ -102,6 +103,66 @@ public function testEvaluateProjectReturnsFirstViolation(): void
}
}
+ public function testIsFixable(): void
+ {
+ $this->assertInstanceOf(FixableInterface::class, new Psr1Utf8WithoutBomRule(['src/']));
+ }
+
+ public function testFixesUtf8Bom(): void
+ {
+ $basePath = $this->makeTempDir();
+
+ try {
+ mkdir($basePath . '/src');
+ file_put_contents($basePath . '/src/Foo.php', "\xEF\xBB\xBFevaluateProject($basePath, Architecture::define());
+
+ $this->assertInstanceOf(RuleViolation::class, $violation);
+ $this->assertTrue($psr1Utf8WithoutBomRule->fix($violation));
+ $this->assertSame('makeTempDir();
+
+ try {
+ mkdir($basePath . '/src');
+ file_put_contents($basePath . '/src/Foo.php', 'assertFalse($psr1Utf8WithoutBomRule->fix(new RuleViolation(
+ message: 'File must use UTF-8 without BOM',
+ file: $basePath . '/src/Foo.php',
+ line: 1,
+ className: '',
+ )));
+ $this->assertSame('assertFalse((new Psr1Utf8WithoutBomRule(['src/']))->fix(new RuleViolation(
+ message: 'File must use UTF-8 without BOM',
+ file: sys_get_temp_dir() . '/structarmed-missing-file-' . bin2hex(random_bytes(6)) . '.php',
+ line: 1,
+ className: '',
+ )));
+ }
+
public function testReturnsAllViolationsWhenMultipleFilesViolate(): void
{
$basePath = $this->makeTempDir();
diff --git a/tests/Rule/File/Psr1ValidUtf8RuleTest.php b/tests/Rule/File/Psr1ValidUtf8RuleTest.php
new file mode 100644
index 00000000..80e59a8e
--- /dev/null
+++ b/tests/Rule/File/Psr1ValidUtf8RuleTest.php
@@ -0,0 +1,114 @@
+makeTempDir();
+
+ try {
+ mkdir($basePath . '/src');
+ file_put_contents($basePath . '/src/Foo.php', "evaluateProjectAll($basePath, Architecture::define());
+
+ $this->assertCount(1, $violations);
+ $this->assertStringContainsString('valid UTF-8', $violations[0]->message);
+ } finally {
+ unlink($basePath . '/src/Foo.php');
+ rmdir($basePath . '/src');
+ rmdir($basePath);
+ }
+ }
+
+ public function testPassesValidUtf8WithBomAndMissingPaths(): void
+ {
+ $basePath = $this->makeTempDir();
+
+ try {
+ mkdir($basePath . '/src');
+ file_put_contents($basePath . '/src/Foo.php', "\xEF\xBB\xBFassertSame(
+ [],
+ (new Psr1ValidUtf8Rule(['src/']))->evaluateProjectAll($basePath, Architecture::define())
+ );
+ $this->assertSame(
+ [],
+ (new Psr1ValidUtf8Rule(['missing/']))->evaluateProjectAll($basePath, Architecture::define())
+ );
+ } finally {
+ unlink($basePath . '/src/Foo.php');
+ rmdir($basePath . '/src');
+ rmdir($basePath);
+ }
+ }
+
+ public function testEvaluateProjectReturnsFirstViolation(): void
+ {
+ $basePath = $this->makeTempDir();
+
+ try {
+ mkdir($basePath . '/src');
+ file_put_contents($basePath . '/src/Foo.php', "evaluateProject($basePath, Architecture::define());
+
+ $this->assertInstanceOf(RuleViolation::class, $violation);
+ $this->assertStringContainsString('Foo.php', $violation->message);
+ } finally {
+ unlink($basePath . '/src/Foo.php');
+ rmdir($basePath . '/src');
+ rmdir($basePath);
+ }
+ }
+
+ public function testReturnsAllViolationsWhenMultipleFilesViolate(): void
+ {
+ $basePath = $this->makeTempDir();
+
+ try {
+ mkdir($basePath . '/src');
+ file_put_contents($basePath . '/src/Foo.php', "evaluateProjectAll($basePath, Architecture::define());
+
+ $this->assertCount(2, $violations);
+ } finally {
+ unlink($basePath . '/src/Foo.php');
+ unlink($basePath . '/src/Bar.php');
+ rmdir($basePath . '/src');
+ rmdir($basePath);
+ }
+ }
+
+ private function makeTempDir(): string
+ {
+ $path = sys_get_temp_dir() . '/structarmed-psr1-' . bin2hex(random_bytes(6));
+ mkdir($path);
+
+ return $path;
+ }
+}