diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 0000000..33ec9e9
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,8 @@
+[*]
+charset = utf-8
+end_of_line = lf
+trim_trailing_whitespace = true
+insert_final_newline = true
+indent_style = space
+indent_size = 4
+
diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..5d6519e
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,11 @@
+* text=auto
+
+/cache export-ignore
+/tests export-ignore
+/.editorconfig export-ignore
+/.gitattributes export-ignore
+/.github export-ignore
+/.gitignore export-ignore
+/phpcs.xml.dist export-ignore
+/phpstan.neon.dist export-ignore
+/phpunit.xml.dist export-ignore
diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml
new file mode 100644
index 0000000..0170af2
--- /dev/null
+++ b/.github/workflows/checks.yml
@@ -0,0 +1,50 @@
+name: Checks
+on:
+ pull_request:
+ push:
+ branches:
+ - master
+jobs:
+ checks:
+ runs-on: ubuntu-latest
+ strategy:
+ fail-fast: false
+ steps:
+ -
+ name: Checkout code
+ uses: actions/checkout@v4
+ -
+ name: Setup PHP
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: 8.4
+ -
+ name: Install dependencies
+ run: composer install --no-progress --prefer-dist --no-interaction
+
+ -
+ name: Run checks
+ run: composer check
+
+ tests:
+ runs-on: ubuntu-latest
+ strategy:
+ fail-fast: false
+ matrix:
+ php-version: [ '8.1', '8.2', '8.3', '8.4' ]
+ dependency-version: [ prefer-lowest, prefer-stable ]
+ steps:
+ -
+ name: Checkout code
+ uses: actions/checkout@v4
+ -
+ name: Setup PHP
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: ${{ matrix.php-version }}
+ -
+ name: Update dependencies
+ run: composer update --no-progress --${{ matrix.dependency-version }} --no-interaction
+ -
+ name: Run tests
+ run: composer check:tests
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..0b2b006
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,6 @@
+/vendor
+/cache/*
+!/cache/.gitkeep
+/.idea
+/phpstan.neon
+/composer.lock
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..72b3054
--- /dev/null
+++ b/README.md
@@ -0,0 +1,47 @@
+# PHPStan error ignore inliner
+
+Allows you to easily **inline ignore your PHPStan errors** via `@phpstan-ignore` comment.
+
+So instead of:
+
+```neon
+parameters:
+ ignoreErrors:
+ -
+ message: '#^Construct empty\(\) is not allowed\. Use more strict comparison\.$#'
+ identifier: empty.notAllowed
+ path: ../src/App/User.php
+ count: 1
+```
+
+You will have the ignored error directly in the source code `src/App/User.php`:
+
+```php
+class User {
+
+ public function updateSurname(string $surname): void
+ {
+ if (empty($surname)) { // @phpstan-ignore empty.notAllowed
+ throw new EmptyNameException();
+ }
+ }
+
+}
+```
+
+## Installation:
+
+```sh
+composer require --dev shipmonk/phpstan-ignore-inliner
+```
+
+## Usage
+
+```sh
+vendor/bin/phpstan --error-format=json | vendor/bin/inline-phpstan-ignores
+```
+
+## Contributing
+- Check your code by `composer check`
+- Autofix coding-style by `composer fix:cs`
+- All functionality must be tested
diff --git a/bin/inline-phpstan-ignores b/bin/inline-phpstan-ignores
new file mode 100755
index 0000000..2bd7005
--- /dev/null
+++ b/bin/inline-phpstan-ignores
@@ -0,0 +1,36 @@
+#!/usr/bin/env php
+readInput();
+ $errorsData = json_decode($input, associative: true, flags: JSON_THROW_ON_ERROR);
+ $errors = $errorsData['files'] ?? throw new FailureException('No \'files\' key found on input JSON.');
+
+ $inliner = new InlineIgnoreInliner($io);
+ $inliner->inlineErrors($errors);
+
+ $errorsCount = count($errors);
+ echo "Done, $errorsCount errors processed.\n";
+
+} catch (JsonException | FailureException $e) {
+ echo $e->getMessage() . $usage;
+}
diff --git a/cache/.gitkeep b/cache/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/composer.json b/composer.json
new file mode 100644
index 0000000..f5adc0f
--- /dev/null
+++ b/composer.json
@@ -0,0 +1,68 @@
+{
+ "name": "shipmonk/phpstan-ignore-inliner",
+ "description": "Inline your PHPStan error ignores into the source files via @phpstan-ignore comments.",
+ "license": [
+ "MIT"
+ ],
+ "type": "phpstan-extension",
+ "keywords": [
+ "dev",
+ "phpstan",
+ "phpstan errors",
+ "phpstan extension",
+ "error identifier"
+ ],
+ "require": {
+ "php": "^8.1",
+ "phpstan/phpstan": "^2"
+ },
+ "require-dev": {
+ "editorconfig-checker/editorconfig-checker": "^10.7.0",
+ "ergebnis/composer-normalize": "2.47.0",
+ "phpstan/extension-installer": "^1.4.3",
+ "phpstan/phpstan-phpunit": "^2.0.6",
+ "phpstan/phpstan-strict-rules": "^2.0.4",
+ "phpunit/phpunit": "^10.5.46",
+ "shipmonk/phpstan-rules": "^4.1.2",
+ "slevomat/coding-standard": "^8.18.0"
+ },
+ "autoload": {
+ "psr-4": {
+ "ShipMonk\\PHPStan\\Errors\\": "src/"
+ }
+ },
+ "autoload-dev": {
+ "psr-4": {
+ "ShipMonk\\PHPStan\\Errors\\": "tests/"
+ }
+ },
+ "bin": [
+ "bin/inline-phpstan-ignores"
+ ],
+ "config": {
+ "allow-plugins": {
+ "dealerdirect/phpcodesniffer-composer-installer": false,
+ "ergebnis/composer-normalize": true,
+ "phpstan/extension-installer": true
+ },
+ "sort-packages": true
+ },
+ "scripts": {
+ "check": [
+ "@check:composer",
+ "@check:ec",
+ "@check:cs",
+ "@check:types",
+ "@check:tests"
+ ],
+ "check:composer": [
+ "composer normalize --dry-run --no-check-lock --no-update-lock",
+ "composer validate --strict"
+ ],
+ "check:cs": "phpcs",
+ "check:ec": "ec src tests",
+ "check:tests": "phpunit tests",
+ "check:types": "phpstan analyse -vv --ansi",
+ "fix:cs": "phpcbf"
+ }
+}
diff --git a/phpcs.xml.dist b/phpcs.xml.dist
new file mode 100644
index 0000000..e7c73b8
--- /dev/null
+++ b/phpcs.xml.dist
@@ -0,0 +1,442 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+ src/
+ tests/
+
+ tests/data/*
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Exception type missing for @throws annotation
+
+
+ Only 1 @return annotation is allowed in a function comment
+
+
+ Extra @param annotation
+
+
+ @param annotation for parameter "%s" missing
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 5
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/phpstan.neon.dist b/phpstan.neon.dist
new file mode 100644
index 0000000..c486be5
--- /dev/null
+++ b/phpstan.neon.dist
@@ -0,0 +1,31 @@
+includes:
+ - phar://phpstan.phar/conf/bleedingEdge.neon
+
+parameters:
+ level: 8
+ paths:
+ - bin/inline-phpstan-ignores
+ - src
+ - tests
+ excludePaths:
+ analyseAndScan:
+ - tests/data/*
+ tmpDir: cache/phpstan/
+ checkMissingCallableSignature: true
+ checkUninitializedProperties: true
+ checkBenevolentUnionTypes: true
+ checkImplicitMixed: true
+ checkTooWideReturnTypesInProtectedAndPublicMethods: true
+ reportAnyTypeWideningInVarTag: true
+ exceptions:
+ check:
+ missingCheckedExceptionInThrows: true
+ tooWideThrowType: true
+ implicitThrows: false
+ uncheckedExceptionClasses:
+ - LogicException
+
+ ignoreErrors:
+ - # allow uncatched exceptions in tests
+ identifier: missingType.checkedException
+ path: tests/*
diff --git a/phpunit.xml.dist b/phpunit.xml.dist
new file mode 100644
index 0000000..7e2b669
--- /dev/null
+++ b/phpunit.xml.dist
@@ -0,0 +1,16 @@
+
+
+
+
+
+
diff --git a/src/FailureException.php b/src/FailureException.php
new file mode 100644
index 0000000..497fa1c
--- /dev/null
+++ b/src/FailureException.php
@@ -0,0 +1,10 @@
+}> $errors
+ * @throws FailureException
+ */
+ public function inlineErrors(
+ array $errors
+ ): void
+ {
+ foreach ($errors as $filePath => $fileErrors) {
+ foreach ($fileErrors['messages'] as $error) {
+ $line = $error['line'] ?? null;
+ $identifier = $error['identifier'] ?? null;
+ $ignorable = $error['ignorable'] ?? null;
+
+ if ($line === null || $identifier === null || $ignorable === false) {
+ continue;
+ }
+
+ [$trueFilePath] = explode(' (in context of class', $filePath, 2); // solve trait "filepath" in format "src/App/MyTrait.php (in context of class App\Clazz)"
+
+ $fileContent = $this->io->readFile($trueFilePath);
+ $lines = explode("\n", $fileContent);
+
+ $lineContent = $lines[$line - 1];
+
+ $append = str_contains($lineContent, '// @phpstan-ignore ')
+ ? ', ' . $identifier
+ : ' // @phpstan-ignore ' . $identifier;
+
+ $lines[$line - 1] .= $append;
+ $this->io->writeFile($trueFilePath, implode("\n", $lines));
+ }
+ }
+ }
+
+}
diff --git a/src/Io.php b/src/Io.php
new file mode 100644
index 0000000..a2d8e0f
--- /dev/null
+++ b/src/Io.php
@@ -0,0 +1,55 @@
+createMock(Io::class);
+ $ioMock->expects(self::exactly(2))
+ ->method('writeFile')
+ ->willReturnCallback(static function (string $filePath, string $contents) use ($tmpFilePath): void {
+ self::assertNotFalse(file_put_contents($tmpFilePath, $contents));
+ });
+ $ioMock->expects(self::exactly(2))
+ ->method('readFile')
+ ->willReturnCallback(static function (string $filePath) use ($tmpFilePath): string|false {
+ return file_get_contents($tmpFilePath);
+ });
+
+ $testJson = file_get_contents(__DIR__ . '/data/errors.json');
+ $testData = json_decode($testJson, associative: true)['files']; // @phpstan-ignore argument.type
+
+ $inliner = new InlineIgnoreInliner($ioMock);
+ $inliner->inlineErrors($testData);
+
+ self::assertFileEquals(__DIR__ . '/data/test.fixed.php', $tmpFilePath);
+ }
+
+}
diff --git a/tests/data/errors.json b/tests/data/errors.json
new file mode 100644
index 0000000..c8dc886
--- /dev/null
+++ b/tests/data/errors.json
@@ -0,0 +1,26 @@
+{
+ "totals": {
+ "errors": 0,
+ "file_errors": 2
+ },
+ "files": {
+ "tests/data/test.php": {
+ "errors": 2,
+ "messages": [
+ {
+ "message": "Method Dummy::test() has parameter $b with no type specified.",
+ "line": 6,
+ "ignorable": true,
+ "identifier": "missingType.parameter"
+ },
+ {
+ "message": "Method Dummy::test() with return type void returns null but should not return anything.",
+ "line": 8,
+ "ignorable": true,
+ "identifier": "return.void"
+ }
+ ]
+ }
+ },
+ "errors": []
+}
diff --git a/tests/data/test.fixed.php b/tests/data/test.fixed.php
new file mode 100644
index 0000000..b44c76d
--- /dev/null
+++ b/tests/data/test.fixed.php
@@ -0,0 +1,11 @@
+