Repository: phpmd/phpmd (php)

Issue:
Simplify load of external custom renderer
## Description

Hello,

I would like to point a problem about using custom class renderer.

As this feature was implemented by booting special file location (@see `$fileName = strtr($this->reportFormat, '_\\', '//') . '.php';`
on lines https://github.com/phpmd/phpmd/blob/2.15.0/src/main/php/PHPMD/TextUI/CommandLineOptions.php#L780-L811

We cannot use any class name.
I'd like to suggest to add a bootstrapping option to include whatever we want before processing PHPMD.
Especially because we cannot specify an autoloader when `vendor/bin/phpmd` command is invoked.

PHPUnit implement such feature with `--bootstrap <file> `
PHPLint implement such feature with `--bootstrap <file> `
PHPStan implement such feature with `--autoload-file <file>`

Something like will be nice 

`vendor/bin/phpmd /path/to/source/code '\Vendor\Ns\ReportClassName' ruleset --bootstrap path/to/my/autoloader.php`

WDYT ?

Pull request description:
Feature/3.x custom renderer
Type: feature / refactoring 
Issue: Resolves #1196 
Breaking change: yes/no (if yes explain why)

- Extract Renderer instance creation from `\PHPMD\TextUI\CommandLineOptions` and add it in `\PHPMD\Renderer\RendererFactory`
- Add `--bootstrap` option on  `\PHPMD\TextUI\CommandLineOptions` to have ability to load whatever we need before running analysis, that include of course external autoloader (in charge to load extra Renderer classes)
- Load any extra Custom Renderer that follow PSR4 rule by new method `\PHPMD\Renderer\RendererFactory::getRenderer()`
- Introduces a `\PHPMD\Renderer\RendererInterface` contract that all renderers should follow, even if they do not or can't extends the `\PHPMD\AbstractRenderer` class



Patch:
diff --git a/src/main/php/PHPMD/AbstractRenderer.php b/src/main/php/PHPMD/AbstractRenderer.php
index 7b967870..0f9e94cc 100644
--- a/src/main/php/PHPMD/AbstractRenderer.php
+++ b/src/main/php/PHPMD/AbstractRenderer.php
@@ -19,11 +19,12 @@
 namespace PHPMD;
 
 use Exception;
+use PHPMD\Renderer\RendererInterface;
 
 /**
  * Abstract base class for PHPMD rendering engines.
  */
-abstract class AbstractRenderer
+abstract class AbstractRenderer implements RendererInterface
 {
     /** The associated output writer instance. */
     private AbstractWriter $writer;
diff --git a/src/main/php/PHPMD/PHPMD.php b/src/main/php/PHPMD/PHPMD.php
index fc33ba16..1babb8e8 100644
--- a/src/main/php/PHPMD/PHPMD.php
+++ b/src/main/php/PHPMD/PHPMD.php
@@ -20,6 +20,7 @@ namespace PHPMD;
 
 use Exception;
 use PHPMD\Cache\ResultCacheEngine;
+use PHPMD\Renderer\RendererInterface;
 
 /**
  * This is the main facade of the PHP PMD application
@@ -193,7 +194,7 @@ class PHPMD
      * argument. The result will be passed to all given renderer instances.
      *
      * @param string[]      $ignorePattern
-     * @param AbstractRenderer[] $renderers
+     * @param RendererInterface[] $renderers
      * @param RuleSet[]          $ruleSetList
      * @throws Exception
      */
diff --git a/src/main/php/PHPMD/Renderer/RendererFactory.php b/src/main/php/PHPMD/Renderer/RendererFactory.php
index 3e3b48b8..a36aeb33 100644
--- a/src/main/php/PHPMD/Renderer/RendererFactory.php
+++ b/src/main/php/PHPMD/Renderer/RendererFactory.php
@@ -2,6 +2,7 @@
 
 namespace PHPMD\Renderer;
 
+use InvalidArgumentException;
 use PHPMD\AbstractWriter;
 
 final class RendererFactory
@@ -14,4 +15,47 @@ final class RendererFactory
 
         return $renderer;
     }
+
+    /**
+     * @throws InvalidArgumentException
+     */
+    public function getRenderer(string $format): RendererInterface
+    {
+        return match ($format) {
+            'ansi' => new AnsiRenderer(),
+            'checkstyle' => new CheckStyleRenderer(),
+            'github' => new GitHubRenderer(),
+            'gitlab' => new GitLabRenderer(),
+            'html' => new HTMLRenderer(),
+            'json' => new JSONRenderer(),
+            'sarif' => new SARIFRenderer(),
+            'text' => new TextRenderer(),
+            'xml' => new XMLRenderer(),
+            default => $this->getCustomRenderer($format),
+        };
+    }
+
+    /**
+     * @throws InvalidArgumentException
+     */
+    private function getCustomRenderer(string $format): RendererInterface
+    {
+        if (!class_exists($format)) {
+            throw new InvalidArgumentException(
+                sprintf('No renderer supports the format "%s".', $format),
+                RendererInterface::INPUT_ERROR
+            );
+        }
+
+        $renderer = new $format();
+
+        if (!$renderer instanceof RendererInterface) {
+            throw new InvalidArgumentException(
+                sprintf('Renderer class "%s" does not implement "%s".', $format, RendererInterface::class),
+                RendererInterface::INPUT_ERROR
+            );
+        }
+
+        return $renderer;
+    }
 }
diff --git a/src/main/php/PHPMD/Renderer/RendererInterface.php b/src/main/php/PHPMD/Renderer/RendererInterface.php
new file mode 100644
index 00000000..7254e178
--- /dev/null
+++ b/src/main/php/PHPMD/Renderer/RendererInterface.php
@@ -0,0 +1,19 @@
+<?php
+
+namespace PHPMD\Renderer;
+
+use PHPMD\AbstractWriter;
+use PHPMD\Report;
+
+interface RendererInterface
+{
+    public const INPUT_ERROR = 23;
+
+    public function setWriter(AbstractWriter $writer): void;
+
+    public function start(): void;
+
+    public function renderReport(Report $report): void;
+
+    public function end(): void;
+}
diff --git a/src/main/php/PHPMD/TextUI/Command.php b/src/main/php/PHPMD/TextUI/Command.php
index e8d6fcee..15a136af 100644
--- a/src/main/php/PHPMD/TextUI/Command.php
+++ b/src/main/php/PHPMD/TextUI/Command.php
@@ -219,6 +219,11 @@ final class Command
                 $output->write($deprecation . PHP_EOL . PHP_EOL);
             }
 
+            $bootstrapFile = $options->getBootstrapFile();
+            if (is_string($bootstrapFile) && file_exists($bootstrapFile)) {
+                require_once $bootstrapFile;
+            }
+
             $exitCode = $command->run($options, $ruleSetFactory);
             unset($errorStream);
         } catch (Exception $e) {
diff --git a/src/main/php/PHPMD/TextUI/CommandLineOptions.php b/src/main/php/PHPMD/TextUI/CommandLineOptions.php
index c0b05243..d42e69ee 100644
--- a/src/main/php/PHPMD/TextUI/CommandLineOptions.php
+++ b/src/main/php/PHPMD/TextUI/CommandLineOptions.php
@@ -23,17 +23,10 @@ use PHPMD\AbstractRenderer;
 use PHPMD\Baseline\BaselineMode;
 use PHPMD\Cache\Model\ResultCacheStrategy;
 use PHPMD\Console\OutputInterface;
-use PHPMD\Renderer\AnsiRenderer;
-use PHPMD\Renderer\CheckStyleRenderer;
-use PHPMD\Renderer\GitHubRenderer;
-use PHPMD\Renderer\GitLabRenderer;
-use PHPMD\Renderer\HTMLRenderer;
-use PHPMD\Renderer\JSONRenderer;
 use PHPMD\Renderer\Option\Color;
 use PHPMD\Renderer\Option\Verbose;
-use PHPMD\Renderer\SARIFRenderer;
-use PHPMD\Renderer\TextRenderer;
-use PHPMD\Renderer\XMLRenderer;
+use PHPMD\Renderer\RendererFactory;
+use PHPMD\Renderer\RendererInterface;
 use PHPMD\Rule;
 use PHPMD\Utility\ArgumentsValidator;
 use TypeError;
@@ -69,6 +62,9 @@ class CommandLineOptions
     /** An optional filename for the generated report. */
     private ?string $reportFile = null;
 
+    /** An optional script to load before running analysis */
+    private ?string $bootstrap = null;
+
     /** An optional filename to collect errors. */
     private ?string $errorFile = null;
 
@@ -225,6 +221,11 @@ class CommandLineOptions
 
                     break;
 
+                case '--bootstrap':
+                    $this->bootstrap = (string) $this->readValue($equalChunk, $args);
+
+                    break;
+
                 case '--error-file':
                 case '--errorfile':
                     $this->errorFile = (string) $this->readValue($equalChunk, $args);
@@ -414,6 +415,14 @@ class CommandLineOptions
         return $this->reportFile;
     }
 
+    /**
+     * Returns the current bootstrap file (if available) to load extra resources before analysis.
+     */
+    public function getBootstrapFile(): ?string
+    {
+        return $this->bootstrap;
+    }
+
     /**
      * Returns the output filename for the errors or <b>null</b> when
      * the report should be displayed in STDERR.
@@ -585,17 +594,9 @@ class CommandLineOptions
      * Creates a report renderer instance based on the user's command line
      * argument.
      *
-     * Valid renderers are:
-     * <ul>
-     *   <li>xml</li>
-     *   <li>html</li>
-     *   <li>text</li>
-     *   <li>json</li>
-     * </ul>
-     *
      * @throws InvalidArgumentException When the specified renderer does not exist.
      */
-    public function createRenderer(?string $reportFormat = null): AbstractRenderer
+    public function createRenderer(?string $reportFormat = null): RendererInterface
     {
         $renderer = $this->createRendererWithoutOptions($reportFormat);
 
@@ -613,103 +614,11 @@ class CommandLineOptions
     /**
      * @throws InvalidArgumentException When the specified renderer does not exist.
      */
-    private function createRendererWithoutOptions(?string $reportFormat = null): AbstractRenderer
-    {
-        $reportFormat = $reportFormat ?: $this->reportFormat;
-
-        return match ($reportFormat) {
-            'ansi' => $this->createAnsiRenderer(),
-            'checkstyle' => $this->createCheckStyleRenderer(),
-            'gitlab' => $this->createGitLabRenderer(),
-            'github' => $this->createGitHubRenderer(),
-            'html' => $this->createHtmlRenderer(),
-            'json' => $this->createJsonRenderer(),
-            'sarif' => $this->createSarifRenderer(),
-            'text' => $this->createTextRenderer(),
-            'xml' => $this->createXmlRenderer(),
-            default => $this->createCustomRenderer(),
-        };
-    }
-
-    private function createXmlRenderer(): XMLRenderer
-    {
-        return new XMLRenderer();
-    }
-
-    private function createTextRenderer(): TextRenderer
-    {
-        return new TextRenderer();
-    }
-
-    private function createAnsiRenderer(): AnsiRenderer
-    {
-        return new AnsiRenderer();
-    }
-
-    private function createGitLabRenderer(): GitLabRenderer
-    {
-        return new GitLabRenderer();
-    }
-
-    private function createGitHubRenderer(): GitHubRenderer
-    {
-        return new GitHubRenderer();
-    }
-
-    private function createHtmlRenderer(): HTMLRenderer
-    {
-        return new HTMLRenderer($this->extraLineInExcerpt);
-    }
-
-    private function createJsonRenderer(): JSONRenderer
-    {
-        return new JSONRenderer();
-    }
-
-    private function createCheckStyleRenderer(): CheckStyleRenderer
-    {
-        return new CheckStyleRenderer();
-    }
-
-    private function createSarifRenderer(): SARIFRenderer
-    {
-        return new SARIFRenderer();
-    }
-
-    /**
-     * @throws InvalidArgumentException
-     */
-    private function createCustomRenderer(): AbstractRenderer
+    private function createRendererWithoutOptions(?string $reportFormat = null): RendererInterface
     {
-        if (!$this->reportFormat) {
-            throw new InvalidArgumentException(
-                'Can\'t create report with empty format.',
-                self::INPUT_ERROR
-            );
-        }
-
-        if (class_exists($this->reportFormat)) {
-            return new $this->reportFormat();
-        }
-
-        // Try to load a custom renderer
-        $fileName = strtr($this->reportFormat, '_\\', '//') . '.php';
-
-        $fileHandle = @fopen($fileName, 'rb', true);
-        if (!is_resource($fileHandle)) {
-            throw new InvalidArgumentException(
-                sprintf(
-                    'Can\'t find the custom report class: %s',
-                    $this->reportFormat
-                ),
-                self::INPUT_ERROR
-            );
-        }
-        @fclose($fileHandle);
-
-        include_once $fileName;
+        $reportFormat = $reportFormat ?: $this->reportFormat ?: '';
 
-        return new $this->reportFormat();
+        return (new RendererFactory())->getRenderer($reportFormat);
     }
 
     /**


Test patch:
diff --git a/src/test/php/PHPMD/AbstractStaticTestCase.php b/src/test/php/PHPMD/AbstractStaticTestCase.php
index 3d4dc1a5..576d42c2 100644
--- a/src/test/php/PHPMD/AbstractStaticTestCase.php
+++ b/src/test/php/PHPMD/AbstractStaticTestCase.php
@@ -29,7 +29,7 @@ use Throwable;
 abstract class AbstractStaticTestCase extends TestCase
 {
     /** Directory with test files. */
-    private static string $filesDirectory;
+    protected static string $filesDirectory;
 
     /**
      * Original directory is used to reset a changed working directory.
diff --git a/src/test/php/PHPMD/TextUI/CommandLineOptionsTest.php b/src/test/php/PHPMD/TextUI/CommandLineOptionsTest.php
index c020deac..5af8e576 100644
--- a/src/test/php/PHPMD/TextUI/CommandLineOptionsTest.php
+++ b/src/test/php/PHPMD/TextUI/CommandLineOptionsTest.php
@@ -30,6 +30,7 @@ use PHPMD\Renderer\GitHubRenderer;
 use PHPMD\Renderer\GitLabRenderer;
 use PHPMD\Renderer\HTMLRenderer;
 use PHPMD\Renderer\JSONRenderer;
+use PHPMD\Renderer\RendererInterface;
 use PHPMD\Renderer\SARIFRenderer;
 use PHPMD\Renderer\TextRenderer;
 use PHPMD\Renderer\XMLRenderer;
@@ -66,12 +67,12 @@ class CommandLineOptionsTest extends AbstractTestCase
     {
         $args = ['foo.php', __FILE__, 'text', 'design', '-vvv'];
         $opts = new CommandLineOptions($args);
-        $renbderer = $opts->createRenderer();
+        $renderer = $opts->createRenderer();
 
         $verbosityExtractor = new ReflectionProperty(TextRenderer::class, 'verbosityLevel');
         $verbosityExtractor->setAccessible(true);
 
-        $verbosityLevel = $verbosityExtractor->getValue($renbderer);
+        $verbosityLevel = $verbosityExtractor->getValue($renderer);
 
         static::assertSame(OutputInterface::VERBOSITY_DEBUG, $verbosityLevel);
     }
@@ -702,11 +703,15 @@ class CommandLineOptionsTest extends AbstractTestCase
     /**
      * @param class-string $expectedClass
      * @throws Throwable
-     * @throws Throwable
      * @dataProvider dataProviderCreateRenderer
+     * @covers \PHPMD\Renderer\RendererFactory::getRenderer
      */
     public function testCreateRenderer(string $reportFormat, $expectedClass): void
     {
+        require_once self::$filesDirectory . '/PHPMD/Test/Renderer/NamespaceRenderer.php';
+
+        require_once self::$filesDirectory . '/PHPMD/Test/Renderer/PEARRenderer.php';
+
         $args = [__FILE__, __FILE__, $reportFormat, 'codesize'];
         $opts = new CommandLineOptions($args);
 
@@ -736,17 +741,19 @@ class CommandLineOptionsTest extends AbstractTestCase
     }
 
     /**
-     * @throws Throwable
      * @throws Throwable
      * @dataProvider dataProviderCreateRendererThrowsException
+     * @covers \PHPMD\Renderer\RendererFactory::getCustomRenderer
      */
-    public function testCreateRendererThrowsException(string $reportFormat): void
+    public function testCreateRendererThrowsException(string $reportFormat, string $expectedExceptionMessage): void
     {
         self::expectExceptionObject(new InvalidArgumentException(
-            "Can't",
-            code: 23,
+            $expectedExceptionMessage,
+            code: RendererInterface::INPUT_ERROR,
         ));
 
+        require_once self::$filesDirectory . '/PHPMD/Test/Renderer/InvalidRenderer.php';
+
         $args = [__FILE__, __FILE__, $reportFormat, 'codesize'];
         $opts = new CommandLineOptions($args);
         $opts->createRenderer();
@@ -757,9 +764,22 @@ class CommandLineOptionsTest extends AbstractTestCase
      */
     public static function dataProviderCreateRendererThrowsException(): array
     {
+        $defaultExceptionMessage = 'No renderer supports the format "%s".';
+
+        $notExistsRendererClass = 'PHPMD\\Test\\Renderer\\NotExistsRenderer';
+        $invalidRendererClass = 'PHPMD\\Test\\Renderer\\InvalidRenderer';
+
         return [
-            [''],
-            ['PHPMD\\Test\\Renderer\\NotExistsRenderer'],
+            ['', sprintf($defaultExceptionMessage, '')],
+            [$notExistsRendererClass, sprintf($defaultExceptionMessage, $notExistsRendererClass)],
+            [
+                $invalidRendererClass,
+                sprintf(
+                    'Renderer class "%s" does not implement "%s".',
+                    $invalidRendererClass,
+                    RendererInterface::class
+                ),
+            ],
         ];
     }
 
diff --git a/src/test/php/PHPMD/TextUI/CommandTest.php b/src/test/php/PHPMD/TextUI/CommandTest.php
index f1fddcdc..edbb6385 100644
--- a/src/test/php/PHPMD/TextUI/CommandTest.php
+++ b/src/test/php/PHPMD/TextUI/CommandTest.php
@@ -329,17 +329,18 @@ class CommandTest extends AbstractTestCase
         static::assertIsResource($stream);
         $this->stderrStreamFilter = $stream;
 
+        $format = 'FORMAT';
         Command::main(
             [
                 __FILE__,
                 self::createFileUri('source/source_with_npath_violation.php'),
-                "''",
+                $format,
                 'naming',
             ]
         );
 
         static::assertStringContainsString(
-            'Can\'t find the custom report class: ',
+            sprintf('No renderer supports the format "%s"', $format),
             StreamFilter::$streamHandle
         );
     }
@@ -352,11 +353,12 @@ class CommandTest extends AbstractTestCase
         $file = tempnam(sys_get_temp_dir(), 'err');
         static::assertIsString($file);
 
+        $format = 'FORMAT';
         Command::main(
             [
                 __FILE__,
                 self::createFileUri('source/source_with_npath_violation.php'),
-                "''",
+                $format,
                 'naming',
                 '--error-file',
                 $file,
@@ -366,7 +368,7 @@ class CommandTest extends AbstractTestCase
         $errors = (string) file_get_contents($file);
         unlink($file);
 
-        static::assertSame("Can't find the custom report class: ''" . PHP_EOL, $errors);
+        static::assertSame(sprintf('No renderer supports the format "%s".' . PHP_EOL, $format), $errors);
 
         $file = tempnam(sys_get_temp_dir(), 'err');
         static::assertIsString($file);
@@ -375,7 +377,7 @@ class CommandTest extends AbstractTestCase
             [
                 __FILE__,
                 self::createFileUri('source/source_with_npath_violation.php'),
-                "''",
+                $format,
                 'naming',
                 '--error-file',
                 $file,
@@ -386,23 +388,7 @@ class CommandTest extends AbstractTestCase
         $errors = (string) file_get_contents($file);
         unlink($file);
 
-        static::assertStringStartsWith("Can't find the custom report class: ''" . PHP_EOL, $errors);
-        static::assertMatchesRegularExpression(
-            '`' . preg_quote(str_replace(
-                '/',
-                DIRECTORY_SEPARATOR,
-                'src/main/php/PHPMD/TextUI/CommandLineOptions.php:'
-            ), '`') . '\d+' . PHP_EOL . '`',
-            $errors
-        );
-        static::assertMatchesRegularExpression(
-            '`' . preg_quote(str_replace(
-                '/',
-                DIRECTORY_SEPARATOR,
-                'src/main/php/PHPMD/TextUI/CommandLineOptions.php'
-            ), '`') . '\(\d+\): PHPMD\\\\TextUI\\\\CommandLineOptions->createCustomRenderer\(\)`',
-            $errors
-        );
+        static::assertStringStartsWith(sprintf('No renderer supports the format "%s"', $format), $errors);
     }
 
     /**
diff --git a/src/test/resources/files/PHPMD/Test/Renderer/InvalidRenderer.php b/src/test/resources/files/PHPMD/Test/Renderer/InvalidRenderer.php
new file mode 100644
index 00000000..2ded4696
--- /dev/null
+++ b/src/test/resources/files/PHPMD/Test/Renderer/InvalidRenderer.php
@@ -0,0 +1,12 @@
+<?php
+
+namespace PHPMD\Test\Renderer;
+
+use PHPMD\Report;
+
+class InvalidRenderer
+{
+    public function renderReport(Report $report): void
+    {
+    }
+}
diff --git a/src/test/resources/files/PHPMD/Test/Renderer/NamespaceRenderer.php b/src/test/resources/files/PHPMD/Test/Renderer/NamespaceRenderer.php
index e81604b0..2f01367d 100644
--- a/src/test/resources/files/PHPMD/Test/Renderer/NamespaceRenderer.php
+++ b/src/test/resources/files/PHPMD/Test/Renderer/NamespaceRenderer.php
@@ -18,9 +18,10 @@
 namespace PHPMD\Test\Renderer;
 
 use PHPMD\AbstractRenderer;
+use PHPMD\Renderer\RendererInterface;
 use PHPMD\Report;
 
-class NamespaceRenderer extends AbstractRenderer
+class NamespaceRenderer extends AbstractRenderer implements RendererInterface
 {
     public function renderReport(Report $report): void
     {
diff --git a/src/test/resources/files/PHPMD/Test/Renderer/PEARRenderer.php b/src/test/resources/files/PHPMD/Test/Renderer/PEARRenderer.php
index 53d55663..e7cb328b 100644
--- a/src/test/resources/files/PHPMD/Test/Renderer/PEARRenderer.php
+++ b/src/test/resources/files/PHPMD/Test/Renderer/PEARRenderer.php
@@ -17,7 +17,7 @@
 
 use PHPMD\Report;
 
-class PHPMD_Test_Renderer_PEARRenderer extends PHPMD\AbstractRenderer
+class PHPMD_Test_Renderer_PEARRenderer extends PHPMD\AbstractRenderer implements PHPMD\Renderer\RendererInterface
 {
     public function renderReport(Report $report): void
     {