Repository: phpactor/phpactor (php)

Issue:
Add option to ignore diagnostics for vendors
I'm using phpactor together with the vscode extension, and every time I open a file from a vendor, many diagnostic messages appear in the "problems" panel of vscode, and they remain there until I reload the project.

Pull request description:
gh-2688: add option to exclude globs from diagnostics
Fixes #2037 and #2688

Adds the option:

```
    "language_server.diagnostic_exclude_paths": [
        "vendor/**/*"
    ],
```

it's empty by default to avoid WTF when actually working on code in `vendor` for whatever reason.

Patch:
diff --git a/lib/Extension/LanguageServer/DiagnosticProvider/PathExcludingDiagnosticsProvider.php b/lib/Extension/LanguageServer/DiagnosticProvider/PathExcludingDiagnosticsProvider.php
new file mode 100644
index 00000000..af815752
--- /dev/null
+++ b/lib/Extension/LanguageServer/DiagnosticProvider/PathExcludingDiagnosticsProvider.php
@@ -0,0 +1,36 @@
+<?php
+
+namespace Phpactor\Extension\LanguageServer\DiagnosticProvider;
+
+use Amp\CancellationToken;
+use Amp\Promise;
+use Amp\Success;
+use Phpactor\LanguageServerProtocol\TextDocumentItem;
+use Phpactor\LanguageServer\Core\Diagnostics\DiagnosticsProvider;
+use Phpactor\TextDocument\TextDocumentUri;
+use Webmozart\Glob\Glob;
+
+class PathExcludingDiagnosticsProvider implements DiagnosticsProvider
+{
+    /**
+     * @param list<string> $paths
+     */
+    public function __construct(private DiagnosticsProvider $innerProvider, private array $paths)
+    {
+    }
+
+    public function provideDiagnostics(TextDocumentItem $textDocument, CancellationToken $cancel): Promise
+    {
+        foreach ($this->paths as $glob) {
+            if (true === Glob::match(TextDocumentUri::fromString($textDocument->uri)->path(), $glob)) {
+                return new Success([]);
+            }
+        }
+        return $this->innerProvider->provideDiagnostics($textDocument, $cancel);
+    }
+
+    public function name(): string
+    {
+        return $this->innerProvider->name();
+    }
+}
diff --git a/lib/Extension/LanguageServer/LanguageServerExtension.php b/lib/Extension/LanguageServer/LanguageServerExtension.php
index 7f3264ac..2b3f0eb0 100644
--- a/lib/Extension/LanguageServer/LanguageServerExtension.php
+++ b/lib/Extension/LanguageServer/LanguageServerExtension.php
@@ -11,6 +11,7 @@ use Phpactor\Extension\FilePathResolver\FilePathResolverExtension;
 use Phpactor\Extension\LanguageServer\CodeAction\ProfilingCodeActionProvider;
 use Phpactor\Extension\LanguageServer\Command\DiagnosticsCommand;
 use Phpactor\Extension\LanguageServer\DiagnosticProvider\OutsourcedDiagnosticsProvider;
+use Phpactor\Extension\LanguageServer\DiagnosticProvider\PathExcludingDiagnosticsProvider;
 use Phpactor\Extension\LanguageServer\Dispatcher\PhpactorDispatcherFactory;
 use Phpactor\Extension\LanguageServer\EventDispatcher\LazyAggregateProvider;
 use Phpactor\Extension\LanguageServer\Handler\DebugHandler;
@@ -72,6 +73,7 @@ use Phpactor\MapResolver\ResolverErrors;
 use Psr\EventDispatcher\EventDispatcherInterface;
 use Psr\Log\LoggerInterface;
 use RuntimeException;
+use Symfony\Component\Filesystem\Path;
 use Webmozart\Assert\Assert;
 use function array_filter;
 use function array_keys;
@@ -116,6 +118,7 @@ class LanguageServerExtension implements Extension
     public const PARAM_SELF_DESTRUCT_TIMEOUT = 'language_server.self_destruct_timeout';
     public const PARAM_PHPACTOR_BIN = 'language_server.phpactor_bin';
     public const PARAM_DIAGNOSTIC_OUTSOURCE_TIMEOUT = 'language_server.diagnostic_outsource_timeout';
+    public const PARAM_DIAGNOSTIC_EXCLUDE_PATHS = 'language_server.diagnostic_exclude_paths';
 
     public function configure(Resolver $schema): void
     {
@@ -130,6 +133,7 @@ class LanguageServerExtension implements Extension
             self::PARAM_DIAGNOSTIC_ON_OPEN => true,
             self::PARAM_DIAGNOSTIC_PROVIDERS => null,
             self::PARAM_DIAGNOSTIC_OUTSOURCE => true,
+            self::PARAM_DIAGNOSTIC_EXCLUDE_PATHS => [],
             self::PARAM_FILE_EVENTS => true,
             self::PARAM_FILE_EVENT_GLOBS => ['**/*.php'],
             self::PARAM_PROFILE => false,
@@ -156,6 +160,7 @@ class LanguageServerExtension implements Extension
             self::PARAM_DIAGNOSTIC_OUTSOURCE => 'If applicable diagnostics should be "outsourced" to a different process',
             self::PARAM_DIAGNOSTIC_OUTSOURCE_TIMEOUT => 'Kill the diagnostics process if it outlives this timeout',
             self::PARAM_FILE_EVENTS => 'Register to receive file events',
+            self::PARAM_DIAGNOSTIC_EXCLUDE_PATHS => 'List of paths to exclude from diagnostics, e.g. `vendor/**/*`',
             self::PARAM_SHUTDOWN_GRACE_PERIOD => 'Amount of time (in milliseconds) to wait before responding to a shutdown notification',
             self::PARAM_SELF_DESTRUCT_TIMEOUT => 'Wait this amount of time (in milliseconds) after a shutdown request before self-destructing',
             self::PARAM_PHPACTOR_BIN => 'Internal use only - name path to Phpactor binary',
@@ -511,6 +516,24 @@ class LanguageServerExtension implements Extension
                 $container,
                 outsourced: $container->parameter(self::PARAM_DIAGNOSTIC_OUTSOURCE)->bool() ? false : null,
             );
+
+            $projectRoot = $container->parameter(FilePathResolverExtension::PARAM_PROJECT_ROOT)->string();
+
+            /**
+             * @var string[] $excludePaths
+             */
+            $excludePaths = $container->parameter(self::PARAM_DIAGNOSTIC_EXCLUDE_PATHS)->value();
+
+            if (count($excludePaths)) {
+                $providers = array_map(function (DiagnosticsProvider $provider) use ($projectRoot, $excludePaths) {
+                    return new PathExcludingDiagnosticsProvider(
+                        $provider,
+                        // make all the exclude paths absolute before passing to the provider
+                        array_map(fn (string $path) => Path::join($projectRoot, $path), $excludePaths)
+                    );
+                }, $providers);
+            }
+
             return new DiagnosticsEngine(
                 $container->get(ClientApi::class),
                 $this->logger($container, 'LSPDIAG'),


Test patch:
diff --git a/lib/Extension/LanguageServer/Tests/Unit/DiagnosticsProvider/PathExcludingDiagnosticsProviderTest.php b/lib/Extension/LanguageServer/Tests/Unit/DiagnosticsProvider/PathExcludingDiagnosticsProviderTest.php
new file mode 100644
index 00000000..cbf78467
--- /dev/null
+++ b/lib/Extension/LanguageServer/Tests/Unit/DiagnosticsProvider/PathExcludingDiagnosticsProviderTest.php
@@ -0,0 +1,80 @@
+<?php
+
+namespace Phpactor\Extension\LanguageServer\Tests\Unit\DiagnosticsProvider;
+
+use Amp\CancellationTokenSource;
+use Amp\Success;
+use Generator;
+use PHPUnit\Framework\TestCase;
+use Phpactor\Extension\LanguageServer\DiagnosticProvider\PathExcludingDiagnosticsProvider;
+use Phpactor\LanguageServerProtocol\TextDocumentItem;
+use Phpactor\LanguageServer\Core\Diagnostics\ClosureDiagnosticsProvider;
+use Phpactor\LanguageServer\Test\ProtocolFactory;
+use function Amp\Promise\wait;
+
+class PathExcludingDiagnosticsProviderTest extends TestCase
+{
+    /**
+     * @dataProvider provideProvide
+     * @param array<int,string> $excludePatterns
+     */
+    public function testProvide(TextDocumentItem $item, array $excludePatterns, int $expectedCount): void
+    {
+        $cancel = new CancellationTokenSource();
+        $diagnostics = wait((new PathExcludingDiagnosticsProvider(
+            new ClosureDiagnosticsProvider(function () {
+                return new Success([
+                    ProtocolFactory::diagnostic(ProtocolFactory::range(1, 1, 2, 2), 'test'),
+                ]);
+            }),
+            $excludePatterns,
+        ))->provideDiagnostics($item, $cancel->getToken()));
+        self::assertCount($expectedCount, $diagnostics);
+    }
+    /**
+     * @return Generator<string,array{TextDocumentItem,array<int,string>,int}>
+     */
+    public function provideProvide(): Generator
+    {
+        yield 'match pattern' => [
+            ProtocolFactory::textDocumentItem(
+                'file:///home/daniel/www/foobar/barfoo/vendor/dan/test.php',
+                '<?php echo "hello";'
+            ),
+            [
+                '/home/daniel/www/foobar/barfoo/vendor/**/*',
+            ],
+            0,
+        ];
+        yield 'no globs, no exclude' => [
+            ProtocolFactory::textDocumentItem(
+                'file:///home/daniel/www/foobar/barfoo/vendor/dan/test.php',
+                '<?php echo "hello";'
+            ),
+            [
+            ],
+            1,
+        ];
+        yield 'non-matching glob' => [
+            ProtocolFactory::textDocumentItem(
+                'file:///home/daniel/www/foobar/barfoo/vendor/dan/test.php',
+                '<?php echo "hello";'
+            ),
+            [
+                '/home/daniel/www/foobar/barfoo/vendor/**/*.xml',
+            ],
+            1,
+        ];
+        yield 'non-matching and matching glob' => [
+            ProtocolFactory::textDocumentItem(
+                'file:///home/daniel/www/foobar/barfoo/vendor/dan/test.php',
+                '<?php echo "hello";'
+            ),
+            [
+                '/home/daniel/www/foobar/barfoo/vendor/**/*.xml',
+                '/home/daniel/www/foobar/barfoo/vendor/**/*.php',
+            ],
+            0,
+        ];
+    }
+}