From d6bc87d79710387958d88f7b13d090b0bc3a1cdc Mon Sep 17 00:00:00 2001
From: Michele Locati <michele@locati.it>
Date: Wed, 31 Jul 2024 14:51:47 +0200
Subject: [PATCH] Allow extending parsers and dynamic parsers

---
 .gitattributes                   |  6 ++-
 .github/workflows/tests.yml      | 53 +++++++++++++++++++++++++
 .gitignore                       |  4 +-
 composer.json                    | 14 +++++++
 phpunit.xml                      | 20 ++++++++++
 src/Parser.php                   | 52 ++++++++++++++----------
 src/Parser/Dynamic.php           | 45 +++++++++++++++++++--
 src/ParserFactory.php            | 68 ++++++++++++++++++++++++++++++++
 src/Util/ConfigFile.php          |  1 +
 test/bootstrap.php               | 11 ++++++
 test/helpers/TestCase4.php       | 16 ++++++++
 test/helpers/TestCase6.php       |  7 ++++
 test/helpers/TestCase8.php       | 26 ++++++++++++
 test/helpers/TestCaseBase.php    | 24 +++++++++++
 test/tests/DynamicParserTest.php | 53 +++++++++++++++++++++++++
 test/tests/FactoryTest.php       | 41 +++++++++++++++++++
 16 files changed, 412 insertions(+), 29 deletions(-)
 create mode 100644 phpunit.xml
 create mode 100644 src/ParserFactory.php
 create mode 100644 test/bootstrap.php
 create mode 100644 test/helpers/TestCase4.php
 create mode 100644 test/helpers/TestCase6.php
 create mode 100644 test/helpers/TestCase8.php
 create mode 100644 test/helpers/TestCaseBase.php
 create mode 100644 test/tests/DynamicParserTest.php
 create mode 100644 test/tests/FactoryTest.php

diff --git a/.gitattributes b/.gitattributes
index 9da29e7..d629b64 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -1,2 +1,4 @@
-.* export-ignore
-README.md export-ignore
+/test/ export-ignore
+/phpunit.xml export-ignore
+/README.md export-ignore
+/.* export-ignore
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index 29c671d..b643716 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -32,6 +32,7 @@ jobs:
         with:
           exclude: |
             .php-cs-fixer.php
+            test
 
   php-cs:
     name: PHP Coding Style
@@ -50,3 +51,55 @@ jobs:
       -
         name: Run PHP-CS-Fixer
         run: php-cs-fixer check --ansi --no-interaction --using-cache=no --diff --show-progress=none
+
+
+  phpunit:
+    name: PHPUnit
+    strategy:
+      matrix:
+        os:
+          - ubuntu-latest
+        php-version:
+          - "5.3"
+          - "5.4"
+          - "5.5"
+          - "5.6"
+          - "7.0"
+          - "7.1"
+          - "7.2"
+          - "7.3"
+          - "7.4"
+          - "8.0"
+          - "8.1"
+          - "8.2"
+          - "8.3"
+        include:
+          -
+            os: windows-latest
+            php-version: "5.6"
+          -
+            os: windows-latest
+            php-version: "7.4"
+          -
+            os: windows-latest
+            php-version: "8.3"
+    runs-on: ${{ matrix.os }}
+    steps:
+      -
+        name: Setup PHP
+        uses: shivammathur/setup-php@v2
+        with:
+          php-version: ${{ matrix.php-version }}
+          tools: composer
+          coverage: none
+      -
+        name: Checkout
+        uses: actions/checkout@v4
+        with:
+          fetch-depth: 1
+      -
+        name: Install Composer dependencies
+        run: composer --ansi --no-interaction --no-progress update
+      -
+        name: Run PHPUnit
+        run:  composer --ansi --no-interaction run-script test -- -v
diff --git a/.gitignore b/.gitignore
index 35389b3..696aa73 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,5 +1,3 @@
-/.settings/
 /vendor/
-/.buildpath
-/.project
+/.phpunit.result.cache
 /composer.lock
diff --git a/composer.json b/composer.json
index 5793e15..23ce5a1 100644
--- a/composer.json
+++ b/composer.json
@@ -36,8 +36,22 @@
             "C5TL\\": "src/"
         }
     },
+    "require-dev": {
+        "phpunit/phpunit": "^4.8.36 || ^6.5.14 || ^8.5.39"
+    },
+    "autoload-dev": {
+        "psr-4": {
+            "C5TL\\Test\\": [
+                "test/helpers/",
+                "test/tests/"
+            ]
+        }
+    },
     "config": {
         "preferred-install": "dist",
         "optimize-autoloader": true
+    },
+    "scripts": {
+        "test": "phpunit"
     }
 }
diff --git a/phpunit.xml b/phpunit.xml
new file mode 100644
index 0000000..332e5a4
--- /dev/null
+++ b/phpunit.xml
@@ -0,0 +1,20 @@
+<phpunit
+    bootstrap="./test/bootstrap.php"
+    backupGlobals="false"
+    backupStaticAttributes="false"
+    colors="true"
+    convertErrorsToExceptions="true"
+    convertNoticesToExceptions="true"
+    convertWarningsToExceptions="true"
+>
+    <testsuites>
+        <testsuite name="Tests">
+            <directory>./test/tests</directory>
+        </testsuite>
+    </testsuites>
+    <filter>
+        <whitelist processUncoveredFilesFromWhitelist="true">
+            <directory suffix=".php">./src</directory>
+        </whitelist>
+    </filter>
+</phpunit>
diff --git a/src/Parser.php b/src/Parser.php
index 29dd9ab..90dba9b 100644
--- a/src/Parser.php
+++ b/src/Parser.php
@@ -14,6 +14,13 @@ abstract class Parser
      */
     private static $cache = array();
 
+    /**
+     * The parser factory.
+     *
+     * @var \C5TL\ParserFactory|null
+     */
+    private static $factory;
+
     /**
      * Returns the parser name.
      *
@@ -248,37 +255,42 @@ private static function getDirectoryStructureDo($relativePath, $rootDirectory, $
         return $result;
     }
 
+    final public static function getParserFactory()
+    {
+        if (self::$factory === null) {
+            self::$factory = new ParserFactory();
+        }
+
+        return self::$factory;
+    }
+
+    final public static function setParserFactory(ParserFactory $value)
+    {
+        self::$factory = $value;
+    }
+
     /**
-     * Retrieves all the available parsers.
+     * @deprecated Use ParserFactory
      *
-     * @return array[\C5TL\Parser]
+     * @return \C5TL\Parser[]
      */
     final public static function getAllParsers()
     {
-        $result = array();
-        $dir = __DIR__ . '/Parser';
-        if (is_dir($dir) && is_readable($dir)) {
-            $matches = null;
-            foreach (scandir($dir) as $item) {
-                if (($item[0] !== '.') && preg_match('/^(.+)\.php$/i', $item, $matches)) {
-                    $fqClassName = '\\' . __NAMESPACE__ . '\\Parser\\' . $matches[1];
-                    $result[] = new $fqClassName();
-                }
-            }
-        }
+        $factory = self::getParserFactory();
 
-        return $result;
+        return $factory->getParsers();
     }
 
+    /**
+     * @deprecated Use ParserFactory
+     *
+     * @return \C5TL\Parser|null
+     */
     final public static function getByHandle($parserHandle)
     {
-        $parser = null;
-        $fqClassName = '\\' . __NAMESPACE__ . '\\Parser\\' . static::camelifyString($parserHandle);
-        if (class_exists($fqClassName, true)) {
-            $parser = new $fqClassName();
-        }
+        $factory = self::getParserFactory();
 
-        return $parser;
+        return $factory->getParserByHandle($parserHandle);
     }
 
     /**
diff --git a/src/Parser/Dynamic.php b/src/Parser/Dynamic.php
index b06bd24..d8ffc1a 100644
--- a/src/Parser/Dynamic.php
+++ b/src/Parser/Dynamic.php
@@ -2,11 +2,22 @@
 
 namespace C5TL\Parser;
 
+use C5TL\Parser\DynamicItem\DynamicItem;
+
 /**
  * Extract translatable strings from block type templates.
  */
 class Dynamic extends \C5TL\Parser
 {
+    private $subParsers = array();
+
+    public function __construct()
+    {
+        foreach ($this->getDefaultSubParsers() as $subParser) {
+            $this->registerSubParser($subParser);
+        }
+    }
+
     /**
      * {@inheritdoc}
      *
@@ -27,6 +38,16 @@ public function canParseRunningConcrete5()
         return true;
     }
 
+    /**
+     * @return $this
+     */
+    public function registerSubParser(DynamicItem $subParser)
+    {
+        $this->subParsers[$subParser->getDynamicItemsParserHandler()] = $subParser;
+
+        return $this;
+    }
+
     /**
      * {@inheritdoc}
      *
@@ -44,9 +65,27 @@ protected function parseRunningConcrete5Do(\Gettext\Translations $translations,
     /**
      * Returns the fully-qualified class names of all the sub-parsers.
      *
-     * @return array[\C5TL\Parser\DynamicItem\DynamicItem]
+     * @return \C5TL\Parser\DynamicItem\DynamicItem[]
      */
     public function getSubParsers()
+    {
+        return array_values($this->subParsers);
+    }
+
+    /**
+     * @param string|mixed $handle
+     *
+     * @return \C5TL\Parser\DynamicItem\DynamicItem|null
+     */
+    public function getSubParserByHandle($handle)
+    {
+        return is_string($handle) && isset($this->subParsers[$handle]) ? $this->subParsers[$handle] : null;
+    }
+
+    /**
+     * @return \C5TL\Parser\DynamicItem\DynamicItem[]
+     */
+    private function getDefaultSubParsers()
     {
         $result = array();
         $dir = __DIR__ . '/DynamicItem';
@@ -55,9 +94,7 @@ public function getSubParsers()
             foreach (scandir($dir) as $item) {
                 if (($item[0] !== '.') && preg_match('/^(.+)\.php$/i', $item, $matches) && ($matches[1] !== 'DynamicItem')) {
                     $fqClassName = '\\' . __NAMESPACE__ . '\\DynamicItem\\' . $matches[1];
-                    $instance = new $fqClassName();
-                    /* @var $instance \C5TL\Parser\DynamicItem\DynamicItem */
-                    $result[$instance->getDynamicItemsParserHandler()] = $instance;
+                    $result[] = new $fqClassName();
                 }
             }
         }
diff --git a/src/ParserFactory.php b/src/ParserFactory.php
new file mode 100644
index 0000000..7aa969b
--- /dev/null
+++ b/src/ParserFactory.php
@@ -0,0 +1,68 @@
+<?php
+
+namespace C5TL;
+
+/**
+ * A class that provides parsers.
+ */
+class ParserFactory
+{
+    /**
+     * @var \C5TL\Parser[]
+     */
+    private $parsers = array();
+
+    public function __construct()
+    {
+        foreach ($this->getDefaultParsers() as $parser) {
+            $this->registerParser($parser);
+        }
+    }
+    /**
+     * * @return \C5TL\Parser[]
+     */
+    public function getParsers()
+    {
+        return array_values($this->parsers);
+    }
+
+    /**
+     * @param string|mixed $handle
+     *
+     * @return \C5TL\Parser|null
+     */
+    public function getParserByHandle($handle)
+    {
+        return is_string($handle) && isset($this->parsers[$handle]) ? $this->parsers[$handle] : null;
+    }
+
+    /**
+     * @return $this
+     */
+    public function registerParser(Parser $parser)
+    {
+        $this->parsers[$parser->getParserHandle()] = $parser;
+
+        return $this;
+    }
+
+    /**
+     * @return \C5TL\Parser[]
+     */
+    private function getDefaultParsers()
+    {
+        $result = array();
+        $dir = __DIR__ . '/Parser';
+        if (is_dir($dir) && is_readable($dir)) {
+            $matches = null;
+            foreach (scandir($dir) as $item) {
+                if (($item[0] !== '.') && preg_match('/^(.+)\.php$/i', $item, $matches)) {
+                    $fqClassName = '\\' . __NAMESPACE__ . '\\Parser\\'.$matches[1];
+                    $result[] = new $fqClassName();
+                }
+            }
+        }
+
+        return $result;
+    }
+}
diff --git a/src/Util/ConfigFile.php b/src/Util/ConfigFile.php
index b9548c2..873469c 100644
--- a/src/Util/ConfigFile.php
+++ b/src/Util/ConfigFile.php
@@ -103,6 +103,7 @@ private function readFile($filename)
      */
     private function setCustomizersPosition()
     {
+        $m = null;
         if (!preg_match('/^\s*return[\n\s]+(?:\[|array[\s\n]*\()/ims', $this->contents, $m)) {
             throw new Exception('Failed to determine the start of the return array');
         }
diff --git a/test/bootstrap.php b/test/bootstrap.php
new file mode 100644
index 0000000..a999bf1
--- /dev/null
+++ b/test/bootstrap.php
@@ -0,0 +1,11 @@
+<?php
+
+require_once __DIR__ . '/../vendor/autoload.php';
+
+if (class_exists('PHPUnit\\Runner\\Version') && version_compare(PHPUnit\Runner\Version::id(), '8') >= 0) {
+    class_alias('C5TL\\Test\\TestCase8', 'C5TL\\Test\\TestCase');
+} elseif (class_exists('PHPUnit\\Runner\\Version') && version_compare(PHPUnit\Runner\Version::id(), '6') >= 0) {
+    class_alias('C5TL\\Test\\TestCase6', 'C5TL\\Test\\TestCase');
+} else {
+    class_alias('C5TL\\Test\\TestCase4', 'C5TL\\Test\\TestCase');
+}
diff --git a/test/helpers/TestCase4.php b/test/helpers/TestCase4.php
new file mode 100644
index 0000000..d8855e7
--- /dev/null
+++ b/test/helpers/TestCase4.php
@@ -0,0 +1,16 @@
+<?php
+
+namespace C5TL\Test;
+
+abstract class TestCase4 extends TestCaseBase
+{
+    final public static function setupBeforeClass()
+    {
+        static::doSetUpBeforeClass();
+    }
+
+    final public function setUp()
+    {
+        static::doSetUp();
+    }
+}
diff --git a/test/helpers/TestCase6.php b/test/helpers/TestCase6.php
new file mode 100644
index 0000000..fcec388
--- /dev/null
+++ b/test/helpers/TestCase6.php
@@ -0,0 +1,7 @@
+<?php
+
+namespace C5TL\Test;
+
+abstract class TestCase6 extends TestCase4
+{
+}
diff --git a/test/helpers/TestCase8.php b/test/helpers/TestCase8.php
new file mode 100644
index 0000000..20b0fa4
--- /dev/null
+++ b/test/helpers/TestCase8.php
@@ -0,0 +1,26 @@
+<?php
+
+namespace C5TL\Test;
+
+abstract class TestCase8 extends TestCaseBase
+{
+    /**
+     * {@inheritdoc}
+     *
+     * @see \PHPUnit\Framework\TestCase::setupBeforeClass()
+     */
+    final public static function setupBeforeClass(): void
+    {
+        static::doSetUpBeforeClass();
+    }
+    
+    /**
+     * {@inheritdoc}
+     *
+     * @see \PHPUnit\Framework\TestCase::setUp()
+     */
+    final public function setUp(): void
+    {
+        static::doSetUp();
+    }
+}
diff --git a/test/helpers/TestCaseBase.php b/test/helpers/TestCaseBase.php
new file mode 100644
index 0000000..c1a67c4
--- /dev/null
+++ b/test/helpers/TestCaseBase.php
@@ -0,0 +1,24 @@
+<?php
+
+namespace C5TL\Test;
+
+use PHPUnit\Framework\TestCase as PHPUnitTestCase;
+
+abstract class TestCaseBase extends PHPUnitTestCase
+{
+    /**
+     * This method is called before the first test of this test class is run.
+     * Override it instead of setUpBeforeClass().
+     */
+    protected static function doSetUpBeforeClass()
+    {
+    }
+
+    /**
+     * This method is called before each test.
+     * Override it instead of setUp().
+     */
+    protected function doSetUp()
+    {
+    }
+}
diff --git a/test/tests/DynamicParserTest.php b/test/tests/DynamicParserTest.php
new file mode 100644
index 0000000..68097e1
--- /dev/null
+++ b/test/tests/DynamicParserTest.php
@@ -0,0 +1,53 @@
+<?php
+
+namespace C5TL\Test;
+
+use C5TL\ParserFactory;
+
+class DynamicParserTest extends TestCase
+{
+    public function testDynamicParser()
+    {
+        $factory = new ParserFactory();
+        $parser = $factory->getParserByHandle('dynamic');
+        $this->assertNotNull($parser);
+        $this->assertInstanceOf('C5TL\Parser\Dynamic', $parser);
+        $subParsers = $parser->getSubParsers();
+        $this->assertSame(array_values($subParsers), $subParsers);
+        $this->assertNull($parser->getSubParserByHandle('this does not exist'));
+    }
+    
+    public static function provideRequiredParserHandles()
+    {
+        return array(
+            array('area'),
+            array('attribute_key'),
+            array('attribute_key_category'),
+            array('attribute_set'),
+            array('attribute_type'),
+            array('authentication_type'),
+            array('express_form_field_set'),
+            array('group'),
+            array('group_set'),
+            array('job_set'),
+            array('permission_access_entity_type'),
+            array('permission_key'),
+            array('permission_key_category'),
+            array('select_attribute_value'),
+            array('tree'),
+        );
+    }
+
+    /**
+     * @dataProvider provideRequiredParserHandles
+     */
+    public function testRequiredParsers($handle)
+    {
+        $factory = new ParserFactory();
+        $parser = $factory->getParserByHandle('dynamic');
+        $subParser = $parser->getSubParserByHandle($handle);
+        $this->assertNotNull($subParser);
+        $this->assertInstanceOf('C5TL\Parser\DynamicItem\DynamicItem', $subParser);
+        $this->assertSame($handle, $subParser->getDynamicItemsParserHandler());
+    }
+}
diff --git a/test/tests/FactoryTest.php b/test/tests/FactoryTest.php
new file mode 100644
index 0000000..d17ff07
--- /dev/null
+++ b/test/tests/FactoryTest.php
@@ -0,0 +1,41 @@
+<?php
+
+namespace C5TL\Test;
+
+use C5TL\ParserFactory;
+
+class FactoryTest extends TestCase
+{
+    public function testFactory()
+    {
+        $factory = new ParserFactory();
+        $parsers = $factory->getParsers();
+        $this->assertSame('array', gettype($parsers));
+        $this->assertNotSame(array(), $parsers);
+        $this->assertSame(array_values($parsers), $parsers);
+        $this->assertNull($factory->getParserByHandle('this does not exist'));
+    }
+
+    public static function provideRequiredParserHandles()
+    {
+        return array(
+            array('block_templates'),
+            array('cif'),
+            array('config_files'),
+            array('dynamic'),
+            array('php'),
+            array('theme_presets'),
+        );
+    }
+
+    /**
+     * @dataProvider provideRequiredParserHandles
+     */
+    public function testRequiredParsers($handle)
+    {
+        $factory = new ParserFactory();
+        $parser = $factory->getParserByHandle($handle);
+        $this->assertNotNull($parser);
+        $this->assertSame($handle, $parser->getParserHandle());
+    }
+}