Repository: tighten/tlint (php)

Issue:
Feature Request: `UseAnonymousMigrations` lint
Would be awesome if tlint could prefer anonymous class migrations in Laravel:

https://laravel.com/docs/8.x/migrations#anonymous-migrations

Pull request:
Add "Use Anonymous Migrations" Linter and Formatter
This PR closes #238.

Patch:
diff --git a/readme.md b/readme.md
index 32bb665..fe5d7a2 100644
--- a/readme.md
+++ b/readme.md
@@ -223,10 +223,12 @@ The default configuration is "tighten", but you may change this by adding a `tfo
 | `SpaceAfterSoleNotOperator` | There should be a space after sole `!` operators |
 | `SpacesAroundBladeRenderContent` | Spaces around blade rendered content:`{{1 + 1}}` -> `{{ 1 + 1 }}` |
 | `TrailingCommasOnArrays` | Multiline arrays should have trailing commas |
+| `UseAnonymousMigrations` | Prefer returning an anonymous class from migration files |
 | `UseAuthHelperOverFacade` | Prefer the `auth()` helper function over the `Auth` Facade. |
 | `UseConfigOverEnv` | Don’t use environment variables directly; instead, use them in config files and call config vars from code |
 | `ViewWithOverArrayParameters` | Prefer `view(...)->with(...)` over `view(..., [...])`. |
 
+
 ### General PHP
 
 - `AlphabeticalImports`
@@ -264,6 +266,7 @@ The default configuration is "tighten", but you may change this by adding a `tfo
 - `RequestValidation`
 - `RestControllersMethodOrder`
 - `SpacesAroundBladeRenderContent`
+- `UseAnonymousMigrations`
 - `UseAuthHelperOverFacade`
 - `UseConfigOverEnv`
 - `ViewWithOverArrayParameters`
@@ -284,6 +287,7 @@ The default configuration is "tighten", but you may change this by adding a `tfo
 | `NoDatesPropertyOnModels` | Use `$casts` instead of `$dates` on Eloquent models. |
 | `NoDocBlocksForMigrationUpDown` | Removes doc blocks from the up and down method in migrations. |
 | `UnusedImports` | Removes unused import statements. |
+| `UseAnonymousMigrations` | Prefer returning an anonymous class from migration files. |
 
 ### General PHP
 
@@ -297,6 +301,7 @@ The default configuration is "tighten", but you may change this by adding a `tfo
 - `ImportFacades`
 - `NoDatesPropertyOnModels`
 - `NoDocBlocksForMigrationUpDown`
+- `UseAnonymousMigrations`
 
 ## Contributing
 
diff --git a/src/Formatters/UseAnonymousMigrations.php b/src/Formatters/UseAnonymousMigrations.php
new file mode 100644
index 0000000..1fb8ca3
--- /dev/null
+++ b/src/Formatters/UseAnonymousMigrations.php
@@ -0,0 +1,54 @@
+<?php
+
+namespace Tighten\TLint\Formatters;
+
+use PhpParser\Lexer;
+use PhpParser\Node;
+use PhpParser\Node\Stmt\Class_;
+use PhpParser\NodeTraverser;
+use PhpParser\NodeVisitor\CloningVisitor;
+use PhpParser\Parser;
+use Tighten\TLint\BaseFormatter;
+use Tighten\TLint\Linters\Concerns\LintsMigrations;
+
+class UseAnonymousMigrations extends BaseFormatter
+{
+    use LintsMigrations;
+
+    public const description = 'Prefer anonymous class migrations.';
+
+    public function format(Parser $parser, Lexer $lexer)
+    {
+        $traverser = new NodeTraverser;
+        $traverser->addVisitor(new CloningVisitor);
+
+        $className = null;
+        array_map(function (Node $node) use (&$className) {
+            if (
+                $node instanceof Class_
+                    && $node->extends->toString() === 'Migration'
+                    && $node->name
+            ) {
+                $className = $node->name->toString();
+            }
+        }, $traverser->traverse($parser->parse($this->code)));
+
+        if ($className) {
+            $this->code = str_replace("class {$className}", 'return new class', $this->code);
+            $this->code = $this->str_replace_last('}', '};', $this->code);
+        }
+
+        return $this->code;
+    }
+
+    public function str_replace_last($search, $replace, $subject)
+    {
+        $pos = strrpos($subject, $search);
+
+        if ($pos !== false) {
+            $subject = substr_replace($subject, $replace, $pos, strlen($search));
+        }
+
+        return $subject;
+    }
+}
diff --git a/src/Linters/UseAnonymousMigrations.php b/src/Linters/UseAnonymousMigrations.php
new file mode 100644
index 0000000..6c8582e
--- /dev/null
+++ b/src/Linters/UseAnonymousMigrations.php
@@ -0,0 +1,35 @@
+<?php
+
+namespace Tighten\TLint\Linters;
+
+use PhpParser\Node;
+use PhpParser\Node\Stmt\Class_;
+use PhpParser\NodeTraverser;
+use PhpParser\NodeVisitor\FindingVisitor;
+use PhpParser\Parser;
+use Tighten\TLint\BaseLinter;
+use Tighten\TLint\Linters\Concerns\LintsMigrations;
+
+class UseAnonymousMigrations extends BaseLinter
+{
+    use LintsMigrations;
+
+    public const description = 'Prefer anonymous class migrations.';
+
+    public function lint(Parser $parser)
+    {
+        $traverser = new NodeTraverser;
+
+        $visitor = new FindingVisitor(function (Node $node) {
+            return $node instanceof Class_
+                && $node->extends->toString() === 'Migration'
+                && $node->name;
+        });
+
+        $traverser->addVisitor($visitor);
+
+        $traverser->traverse($parser->parse($this->code));
+
+        return $visitor->getFoundNodes();
+    }
+}


Test patch:
diff --git a/tests/Formatting/Formatters/UseAnonymousMigrationsTest.php b/tests/Formatting/Formatters/UseAnonymousMigrationsTest.php
new file mode 100644
index 0000000..6a0ea69
--- /dev/null
+++ b/tests/Formatting/Formatters/UseAnonymousMigrationsTest.php
@@ -0,0 +1,90 @@
+<?php
+
+namespace Tests\Formatting\Formatters;
+
+use PHPUnit\Framework\TestCase;
+use Tighten\TLint\Formatters\UseAnonymousMigrations;
+use Tighten\TLint\TFormat;
+
+class UseAnonymousMigrationsTest extends TestCase
+{
+    /** @test */
+    public function it_converts_named_migration_to_anonymous_migration()
+    {
+        $file = <<<file
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+
+class CreateBuyRequestsTable extends Migration
+{
+    public function up()
+    {
+        //
+    }
+
+    public function down()
+    {
+        //
+    }
+}
+
+file;
+
+        $formatted = (new TFormat)->format(
+            new UseAnonymousMigrations($file)
+        );
+
+        $correctlyFormatted = <<<file
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+
+return new class extends Migration
+{
+    public function up()
+    {
+        //
+    }
+
+    public function down()
+    {
+        //
+    }
+};
+
+file;
+
+        $this->assertEquals($correctlyFormatted, $formatted);
+    }
+
+    /** @test */
+    public function it_ignores_anonymous_migrations()
+    {
+        $file = <<<file
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+
+return new class extends Migration
+{
+    public function up()
+    {
+        //
+    }
+
+    public function down()
+    {
+        //
+    }
+};
+
+file;
+
+        $formatted = (new TFormat)->format(
+            new UseAnonymousMigrations($file)
+        );
+
+        $this->assertEquals($file, $formatted);
+    }
+}
diff --git a/tests/Linting/Linters/UseAnonymousMigrationsTest.php b/tests/Linting/Linters/UseAnonymousMigrationsTest.php
new file mode 100644
index 0000000..21dd4cd
--- /dev/null
+++ b/tests/Linting/Linters/UseAnonymousMigrationsTest.php
@@ -0,0 +1,70 @@
+<?php
+
+namespace Tests\Linting\Linters;
+
+use PHPUnit\Framework\TestCase;
+use Tighten\TLint\Linters\UseAnonymousMigrations;
+use Tighten\TLint\TLint;
+
+class UseAnonymousMigrationsTest extends TestCase
+{
+    /** @test */
+    public function it_catches_named_class_migrations()
+    {
+        $file = <<<file
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+
+class CreateBuyRequestsTable extends Migration
+{
+    public function up()
+    {
+        //
+    }
+
+    public function down()
+    {
+        //
+    }
+}
+
+file;
+
+        $lints = (new TLint)->lint(
+            new UseAnonymousMigrations($file)
+        );
+
+        $this->assertEquals(5, $lints[0]->getNode()->getLine());
+    }
+
+    /** @test */
+    public function it_allows_anonymous_migrations()
+    {
+        $file = <<<file
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+
+return new class extends Migration
+{
+    public function up()
+    {
+        //
+    }
+
+    public function down()
+    {
+        //
+    }
+};
+
+file;
+
+        $lints = (new TLint)->lint(
+            new UseAnonymousMigrations($file)
+        );
+
+        $this->assertEmpty($lints);
+    }
+}