From 6de2f317be5a414bcadc264c279a130359e3ddc7 Mon Sep 17 00:00:00 2001
From: Anatoliy Melnikov <5785276@gmail.com>
Date: Sat, 29 Jun 2024 01:41:34 +0300
Subject: [PATCH 01/28] Implement JsonLexer for inline config

---
 composer.json                                 |   1 +
 src/Dto/InlineConfig/IndexedCollection.php    |  28 +++
 src/Dto/InlineConfig/NamedCollection.php      |  33 +++
 src/Dto/InlineConfig/Token.php                |  30 +++
 .../CollectionDuplicatedKeyException.php      |  13 ++
 .../InvalidInlineConfigFormatException.php    |   9 +
 .../ArrayFromJsonLexerBuilder.php             | 139 ++++++++++++
 .../InlineConfig/CollectionInterface.php      |  13 ++
 src/Service/InlineConfig/JsonLexer.php        | 194 ++++++++++++++++
 tests/Unit/Dto/InlineConfig/TokenTest.php     |  21 ++
 .../ArrayFromJsonLexerBuilderTest.php         |  39 ++++
 .../Service/InlineConfig/JsonLexerTest.php    | 207 ++++++++++++++++++
 12 files changed, 727 insertions(+)
 create mode 100644 src/Dto/InlineConfig/IndexedCollection.php
 create mode 100644 src/Dto/InlineConfig/NamedCollection.php
 create mode 100644 src/Dto/InlineConfig/Token.php
 create mode 100644 src/Exception/CollectionDuplicatedKeyException.php
 create mode 100644 src/Exception/InvalidInlineConfigFormatException.php
 create mode 100644 src/Service/InlineConfig/ArrayFromJsonLexerBuilder.php
 create mode 100644 src/Service/InlineConfig/CollectionInterface.php
 create mode 100644 src/Service/InlineConfig/JsonLexer.php
 create mode 100644 tests/Unit/Dto/InlineConfig/TokenTest.php
 create mode 100644 tests/Unit/Service/InlineConfig/ArrayFromJsonLexerBuilderTest.php
 create mode 100644 tests/Unit/Service/InlineConfig/JsonLexerTest.php

diff --git a/composer.json b/composer.json
index c20887c..59aceec 100644
--- a/composer.json
+++ b/composer.json
@@ -26,6 +26,7 @@
     },
     "require": {
         "php": "^8.2",
+        "ext-mbstring": "*",
         "ext-tokenizer": "*",
         "lesstif/php-jira-rest-client": "^5.8",
         "symfony/finder": "^5.4|^6.0|^7.0"
diff --git a/src/Dto/InlineConfig/IndexedCollection.php b/src/Dto/InlineConfig/IndexedCollection.php
new file mode 100644
index 0000000..8825c91
--- /dev/null
+++ b/src/Dto/InlineConfig/IndexedCollection.php
@@ -0,0 +1,28 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Aeliot\TodoRegistrar\Dto\InlineConfig;
+
+use Aeliot\TodoRegistrar\Service\InlineConfig\CollectionInterface;
+
+final class IndexedCollection implements CollectionInterface
+{
+    /**
+     * @return array<array-key,mixed>
+     */
+    private array $data = [];
+
+    public function add(mixed $value): void
+    {
+        $this->data[] = $value;
+    }
+
+    public function toArray(): array
+    {
+        return array_map(
+            static fn (mixed $value): mixed => $value instanceof CollectionInterface ? $value->toArray() : $value,
+            $this->data,
+        );
+    }
+}
\ No newline at end of file
diff --git a/src/Dto/InlineConfig/NamedCollection.php b/src/Dto/InlineConfig/NamedCollection.php
new file mode 100644
index 0000000..2c2c151
--- /dev/null
+++ b/src/Dto/InlineConfig/NamedCollection.php
@@ -0,0 +1,33 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Aeliot\TodoRegistrar\Dto\InlineConfig;
+
+use Aeliot\TodoRegistrar\Exception\CollectionDuplicatedKeyException;
+use Aeliot\TodoRegistrar\Service\InlineConfig\CollectionInterface;
+
+final class NamedCollection implements CollectionInterface
+{
+    /**
+     * @return array<array-key,mixed>
+     */
+    private array $data = [];
+
+    public function add(string $key, mixed $value): void
+    {
+        if (array_key_exists($key, $this->data)) {
+            throw new CollectionDuplicatedKeyException($key);
+        }
+
+        $this->data[$key] = $value;
+    }
+
+    public function toArray(): array
+    {
+        return array_map(
+            static fn (mixed $value): mixed => $value instanceof CollectionInterface ? $value->toArray() : $value,
+            $this->data,
+        );
+    }
+}
\ No newline at end of file
diff --git a/src/Dto/InlineConfig/Token.php b/src/Dto/InlineConfig/Token.php
new file mode 100644
index 0000000..6eb2ad0
--- /dev/null
+++ b/src/Dto/InlineConfig/Token.php
@@ -0,0 +1,30 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Aeliot\TodoRegistrar\Dto\InlineConfig;
+
+final class Token
+{
+    public function __construct(
+        private string $value,
+        private int $type,
+        private int $position,
+    ) {
+    }
+
+    public function getPosition(): int
+    {
+        return $this->position;
+    }
+
+    public function getType(): int
+    {
+        return $this->type;
+    }
+
+    public function getValue(): string
+    {
+        return $this->value;
+    }
+}
diff --git a/src/Exception/CollectionDuplicatedKeyException.php b/src/Exception/CollectionDuplicatedKeyException.php
new file mode 100644
index 0000000..77763f6
--- /dev/null
+++ b/src/Exception/CollectionDuplicatedKeyException.php
@@ -0,0 +1,13 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Aeliot\TodoRegistrar\Exception;
+
+final class CollectionDuplicatedKeyException extends \DomainException
+{
+    public function __construct(string $key)
+    {
+        parent::__construct(sprintf('Key "%s" is duplicated', $key));
+    }
+}
\ No newline at end of file
diff --git a/src/Exception/InvalidInlineConfigFormatException.php b/src/Exception/InvalidInlineConfigFormatException.php
new file mode 100644
index 0000000..56b74cc
--- /dev/null
+++ b/src/Exception/InvalidInlineConfigFormatException.php
@@ -0,0 +1,9 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Aeliot\TodoRegistrar\Exception;
+
+final class InvalidInlineConfigFormatException extends \DomainException
+{
+}
diff --git a/src/Service/InlineConfig/ArrayFromJsonLexerBuilder.php b/src/Service/InlineConfig/ArrayFromJsonLexerBuilder.php
new file mode 100644
index 0000000..54224c6
--- /dev/null
+++ b/src/Service/InlineConfig/ArrayFromJsonLexerBuilder.php
@@ -0,0 +1,139 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Aeliot\TodoRegistrar\Service\InlineConfig;
+
+use Aeliot\TodoRegistrar\Dto\InlineConfig\IndexedCollection;
+use Aeliot\TodoRegistrar\Dto\InlineConfig\NamedCollection;
+use Aeliot\TodoRegistrar\Dto\InlineConfig\Token;
+use Aeliot\TodoRegistrar\Exception\InvalidInlineConfigFormatException;
+
+final class ArrayFromJsonLexerBuilder
+{
+    /**
+     * @return array<array-key,mixed>
+     */
+    public function build(JsonLexer $lexer): array
+    {
+        $lexer->rewind();
+        if (JsonLexer::T_CURLY_BRACES_OPEN !== $lexer->current()->getType()) {
+            throw new InvalidInlineConfigFormatException('Config must be started with curly braces');
+        }
+
+        $lexer->next();
+
+        $collection = new NamedCollection();
+        $level = 1;
+
+        $this->populate($lexer, $collection, $level);
+
+        if (0 !== $level) {
+            throw new InvalidInlineConfigFormatException('Unexpected end of tags list');
+        }
+
+        return $collection->toArray();
+    }
+
+    private function addValue(CollectionInterface $collection, ?string $key, mixed $value): void
+    {
+        if ($collection instanceof NamedCollection) {
+            if (null === $key) {
+                throw new InvalidInlineConfigFormatException('Undefined key for named collection');
+            }
+            $collection->add($key, $value);
+        }
+
+        if ($collection instanceof IndexedCollection) {
+            if (null !== $key) {
+                throw new InvalidInlineConfigFormatException('Key passed for indexed collection');
+            }
+            $collection->add($value);
+        }
+    }
+
+    /**
+     * TODO: move it into JsonLexer
+     */
+    private function checkPredecessorType(int $current, ?int $predecessor): void
+    {
+        if (JsonLexer::T_COLON === $current && JsonLexer::T_KEY !== $predecessor) {
+            throw new InvalidInlineConfigFormatException(
+                sprintf('Colon must be after key, but passed %d', $predecessor),
+            );
+        }
+
+        if (JsonLexer::T_COMMA === $current && JsonLexer::T_COMMA === $predecessor) {
+            throw new InvalidInlineConfigFormatException('Duplicated comma');
+        }
+
+        if (JsonLexer::T_CURLY_BRACES_OPEN === $current
+            && !(null === $predecessor || JsonLexer::T_COLON === $predecessor)
+        ) {
+            throw new InvalidInlineConfigFormatException('Opening curly braces may be initial or must be after colon');
+        }
+
+        if (JsonLexer::T_SQUARE_BRACKET_OPEN === $current && JsonLexer::T_COLON !== $predecessor) {
+            throw new InvalidInlineConfigFormatException('Opening square bracket must be after colon');
+        }
+
+        if (JsonLexer::T_STRING === $current && JsonLexer::T_STRING === $predecessor) {
+            throw new InvalidInlineConfigFormatException('Duplicated value');
+        }
+
+        if (JsonLexer::T_COLON === $predecessor
+            && \in_array($current, [JsonLexer::T_CURLY_BRACES_CLOSE, JsonLexer::T_SQUARE_BRACKET_CLOSE], true)
+        ) {
+            throw new InvalidInlineConfigFormatException(
+                'Colon cannot be before closing curly braces or closing square brackets',
+            );
+        }
+    }
+
+    private function isCloseCollection(Token $token): bool
+    {
+        return \in_array($token->getType(), [JsonLexer::T_SQUARE_BRACKET_CLOSE, JsonLexer::T_CURLY_BRACES_CLOSE], true);
+    }
+
+    private function isSkippable(Token $token): bool
+    {
+        return \in_array($token->getType(), [JsonLexer::T_COLON, JsonLexer::T_COMMA], true);
+    }
+
+    private function isValue(Token $token): bool
+    {
+        return JsonLexer::T_STRING === $token->getType();
+    }
+
+    private function populate(JsonLexer $lexer, CollectionInterface $collection, int &$level): void
+    {
+        $key = null;
+        do {
+            $token = $lexer->current();
+            $this->checkPredecessorType($token->getType(), $lexer->predecessor()?->getType());
+            $lexer->next();
+
+            if (JsonLexer::T_KEY === $token->getType()) {
+                $key = $token->getValue();
+            } elseif ($this->isValue($token)) {
+                $this->addValue($collection, $key, $token->getValue());
+            } elseif (JsonLexer::T_CURLY_BRACES_OPEN === $token->getType()) {
+                ++$level;
+                $childCollection = new NamedCollection();
+                $this->populate($lexer, $childCollection, $level);
+                $this->addValue($collection, $key, $childCollection);
+                $key = null;
+            } elseif (JsonLexer::T_SQUARE_BRACKET_OPEN === $token->getType()) {
+                ++$level;
+                $childCollection = new IndexedCollection();
+                $this->populate($lexer, $childCollection, $level);
+                $this->addValue($collection, $key, $childCollection);
+            } elseif ($this->isCloseCollection($token)) {
+                --$level;
+                $key = null;
+            } elseif (!$this->isSkippable($token)) {
+                throw new InvalidInlineConfigFormatException('Unexpected token detected');
+            }
+        } while ($lexer->valid());
+    }
+}
diff --git a/src/Service/InlineConfig/CollectionInterface.php b/src/Service/InlineConfig/CollectionInterface.php
new file mode 100644
index 0000000..d66f317
--- /dev/null
+++ b/src/Service/InlineConfig/CollectionInterface.php
@@ -0,0 +1,13 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Aeliot\TodoRegistrar\Service\InlineConfig;
+
+interface CollectionInterface
+{
+    /**
+     * @return array<array-key,mixed>
+     */
+    public function toArray(): array;
+}
diff --git a/src/Service/InlineConfig/JsonLexer.php b/src/Service/InlineConfig/JsonLexer.php
new file mode 100644
index 0000000..2d64a1f
--- /dev/null
+++ b/src/Service/InlineConfig/JsonLexer.php
@@ -0,0 +1,194 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Aeliot\TodoRegistrar\Service\InlineConfig;
+
+use Aeliot\TodoRegistrar\Dto\InlineConfig\Token;
+use Aeliot\TodoRegistrar\Exception\InvalidInlineConfigFormatException;
+
+/**
+ * Inspired by package "doctrine/annotations"
+ *
+ * @implements \Iterator<Token>
+ */
+final class JsonLexer implements \Iterator, \Countable
+{
+    public const T_NONE = 1;
+    public const T_STRING = 2;
+
+    public const T_KEY = 50;
+
+    // All symbol-tokens should be >= 1000 (1000 + decimal code of symbol in ASCII Table)
+    public const T_COMMA = 1044;
+    public const T_COLON = 1058;
+    public const T_AT = 1064;
+    public const T_SQUARE_BRACKET_OPEN = 1091;
+    public const T_SQUARE_BRACKET_CLOSE = 1093;
+    public const T_CURLY_BRACES_OPEN = 1123;
+    public const T_CURLY_BRACES_CLOSE = 1125;
+
+    /** @var array<string, self::T*> */
+    private const TYPE_MAP = [
+        ',' => self::T_COMMA,
+        ':' => self::T_COLON,
+        '[' => self::T_SQUARE_BRACKET_OPEN,
+        ']' => self::T_SQUARE_BRACKET_CLOSE,
+        '{' => self::T_CURLY_BRACES_OPEN,
+        '}' => self::T_CURLY_BRACES_CLOSE,
+    ];
+
+    private int $position = 0;
+    /**
+     * @var Token[]
+     */
+    private array $tokens = [];
+
+    public function __construct(string $input, int $offset = 0)
+    {
+        $this->scan($input, $offset);
+    }
+
+    public function count(): int
+    {
+        return \count($this->tokens);
+    }
+
+    public function current(): Token
+    {
+        if (!$this->valid()) {
+            throw new \BadMethodCallException('Cannot get value of invalid lexer iterator');
+        }
+
+        return $this->tokens[$this->position];
+    }
+
+    public function key(): int
+    {
+        if (!$this->valid()) {
+            throw new \BadMethodCallException('Cannot get position of invalid lexer iterator');
+        }
+
+        return $this->position;
+    }
+
+    public function next(): void
+    {
+        ++$this->position;
+    }
+
+    /**
+     * @internal
+     */
+    public function predecessor(): ?Token
+    {
+        if (!$this->valid()) {
+            throw new \BadMethodCallException('Cannot get value of invalid lexer iterator');
+        }
+
+        return $this->tokens[$this->position - 1] ?? null;
+    }
+
+    public function rewind(): void
+    {
+        $this->position = 0;
+    }
+
+    public function valid(): bool
+    {
+        return isset($this->tokens[$this->position]);
+    }
+
+    private function checkGap(Token $current, Token $predecessor, string $input): void
+    {
+        $nextPosition = $current->getPosition();
+        $endPosition = $predecessor->getPosition() + mb_strlen($predecessor->getValue());
+
+        if ($nextPosition > $endPosition) {
+            $gap = substr($input, $endPosition, $nextPosition - $endPosition);
+            if ('' !== trim($gap)) {
+                throw new InvalidInlineConfigFormatException('Only spaces permitted between tokens');
+            }
+        }
+    }
+
+    /**
+     * @return array<array{0: string, 1: int}>
+     */
+    private function getMatches(string $input, int $offset): array
+    {
+        $regex = sprintf(
+            '/(%s)/%s',
+            implode('|', $this->getRegexCatchablePatterns()),
+            $this->getRegexModifiers(),
+        );
+
+        $flags = PREG_UNMATCHED_AS_NULL | PREG_OFFSET_CAPTURE;
+        if (false === preg_match_all($regex, $input, $matches, $flags, $offset)) {
+            throw new InvalidInlineConfigFormatException('Cannot match config tokens');
+        }
+
+        array_shift($matches);
+        $matches = array_shift($matches);
+        if ($matches) {
+            usort($matches, static fn (array $a, array $b): int => $a[1] <=> $b[1]);
+            $matches = array_values($matches);
+        } else {
+            throw new InvalidInlineConfigFormatException('No one token matched');
+        }
+
+        return $matches;
+    }
+
+    /**
+     * @return string[]
+     */
+    private function getRegexCatchablePatterns(): array
+    {
+        return [
+            '[,:\[\]\{\}]',
+            '[0-9a-z_-]+',
+        ];
+    }
+
+    private function getRegexModifiers(): string
+    {
+        return 'iu';
+    }
+
+    private function getType(?string $value): int
+    {
+        if (null === $value) {
+            return self::T_NONE;
+        }
+
+        return self::TYPE_MAP[$value] ?? self::T_STRING;
+    }
+
+    private function scan(string $input, int $offset): void
+    {
+        $matches = $this->getMatches($input, $offset);
+
+        /** @var Token[] $tokens */
+        $tokens = [];
+        $predecessor = null;
+
+        foreach ($matches as $index => [$value, $position]) {
+            $type = $this->getType($value);
+            $nextMatch = $matches[$index + 1] ?? null;
+            if ($nextMatch && self::T_STRING === $type && self::T_COLON === $this->getType($nextMatch[0])) {
+                $type = self::T_KEY;
+            }
+
+            $tokens[] = $current = new Token($value, $type, $position);
+
+            if ($predecessor) {
+                $this->checkGap($current, $predecessor, $input);
+            }
+
+            $predecessor = $current;
+        }
+
+        $this->tokens = array_values($tokens);
+    }
+}
diff --git a/tests/Unit/Dto/InlineConfig/TokenTest.php b/tests/Unit/Dto/InlineConfig/TokenTest.php
new file mode 100644
index 0000000..8290cb2
--- /dev/null
+++ b/tests/Unit/Dto/InlineConfig/TokenTest.php
@@ -0,0 +1,21 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Aeliot\TodoRegistrar\Test\Unit\Dto\InlineConfig;
+
+use Aeliot\TodoRegistrar\Dto\InlineConfig\Token;
+use PHPUnit\Framework\Attributes\CoversClass;
+use PHPUnit\Framework\TestCase;
+
+#[CoversClass(Token::class)]
+final class TokenTest extends TestCase
+{
+    public function testGettersReturnCorrectValue(): void
+    {
+        $token = new Token('value_of_token', 1, 2);
+        self::assertSame(2, $token->getPosition());
+        self::assertSame(1, $token->getType());
+        self::assertSame('value_of_token', $token->getValue());
+    }
+}
diff --git a/tests/Unit/Service/InlineConfig/ArrayFromJsonLexerBuilderTest.php b/tests/Unit/Service/InlineConfig/ArrayFromJsonLexerBuilderTest.php
new file mode 100644
index 0000000..d30a2b3
--- /dev/null
+++ b/tests/Unit/Service/InlineConfig/ArrayFromJsonLexerBuilderTest.php
@@ -0,0 +1,39 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Aeliot\TodoRegistrar\Test\Unit\Service\InlineConfig;
+
+use Aeliot\TodoRegistrar\Dto\InlineConfig\IndexedCollection;
+use Aeliot\TodoRegistrar\Dto\InlineConfig\NamedCollection;
+use Aeliot\TodoRegistrar\Dto\InlineConfig\Token;
+use Aeliot\TodoRegistrar\Service\InlineConfig\ArrayFromJsonLexerBuilder;
+use Aeliot\TodoRegistrar\Service\InlineConfig\JsonLexer;
+use PHPUnit\Framework\Attributes\CoversClass;
+use PHPUnit\Framework\Attributes\DataProvider;
+use PHPUnit\Framework\Attributes\UsesClass;
+use PHPUnit\Framework\TestCase;
+
+#[CoversClass(ArrayFromJsonLexerBuilder::class)]
+#[UsesClass(JsonLexer::class)]
+#[UsesClass(Token::class)]
+#[UsesClass(NamedCollection::class)]
+#[UsesClass(IndexedCollection::class)]
+final class ArrayFromJsonLexerBuilderTest extends TestCase
+{
+    /**
+     * @return iterable<array{0: array<array-key,mixed>, 1: string}>
+     */
+    public static function getDataForTestPositiveFlow(): iterable
+    {
+        yield [['key' => 'value'], '{key: value}'];
+        yield [['key' => ['value']], '{key: [value]}'];
+    }
+
+    #[DataProvider('getDataForTestPositiveFlow')]
+    public function testPositiveFlow(array $expected, string $input): void
+    {
+        $actual = (new ArrayFromJsonLexerBuilder())->build(new JsonLexer($input));
+        self::assertSame($expected, $actual);
+    }
+}
\ No newline at end of file
diff --git a/tests/Unit/Service/InlineConfig/JsonLexerTest.php b/tests/Unit/Service/InlineConfig/JsonLexerTest.php
new file mode 100644
index 0000000..58cef5d
--- /dev/null
+++ b/tests/Unit/Service/InlineConfig/JsonLexerTest.php
@@ -0,0 +1,207 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Aeliot\TodoRegistrar\Test\Unit\Service\InlineConfig;
+
+use Aeliot\TodoRegistrar\Dto\InlineConfig\Token;
+use Aeliot\TodoRegistrar\Service\InlineConfig\JsonLexer;
+use PHPUnit\Framework\Attributes\CoversClass;
+use PHPUnit\Framework\Attributes\DataProvider;
+use PHPUnit\Framework\Attributes\UsesClass;
+use PHPUnit\Framework\TestCase;
+
+#[CoversClass(JsonLexer::class)]
+#[UsesClass(Token::class)]
+final class JsonLexerTest extends TestCase
+{
+    /**
+     * @return iterable<array{0: array<int>, 1: string}>
+     */
+    public static function getDataForTestStructureMatch(): iterable
+    {
+        yield [
+            [
+                [
+                    't' => JsonLexer::T_CURLY_BRACES_OPEN,
+                    'v' => '{',
+                    'p' => 0,
+                ],
+                [
+                    't' => JsonLexer::T_KEY,
+                    'v' => 'key',
+                    'p' => 1,
+                ],
+                [
+                    't' => JsonLexer::T_COLON,
+                    'v' => ':',
+                    'p' => 4,
+                ],
+                [
+                    't' => JsonLexer::T_STRING,
+                    'v' => 'value',
+                    'p' => 6,
+                ],
+                [
+                    't' => JsonLexer::T_CURLY_BRACES_CLOSE,
+                    'v' => '}',
+                    'p' => 11,
+                ],
+            ],
+            '{key: value}',
+        ];
+
+        yield [
+            [
+                [
+                    't' => JsonLexer::T_CURLY_BRACES_OPEN,
+                    'v' => '{',
+                    'p' => 0,
+                ],
+                [
+                    't' => JsonLexer::T_KEY,
+                    'v' => 'key',
+                    'p' => 1,
+                ],
+                [
+                    't' => JsonLexer::T_COLON,
+                    'v' => ':',
+                    'p' => 4,
+                ],
+                [
+                    't' => JsonLexer::T_SQUARE_BRACKET_OPEN,
+                    'v' => '[',
+                    'p' => 6,
+                ],
+                [
+                    't' => JsonLexer::T_STRING,
+                    'v' => 'value',
+                    'p' => 7,
+                ],
+                [
+                    't' => JsonLexer::T_SQUARE_BRACKET_CLOSE,
+                    'v' => ']',
+                    'p' => 12,
+                ],
+                [
+                    't' => JsonLexer::T_CURLY_BRACES_CLOSE,
+                    'v' => '}',
+                    'p' => 13,
+                ],
+            ],
+            '{key: [value]}',
+        ];
+
+        yield [
+            [
+                [
+                    't' => JsonLexer::T_CURLY_BRACES_OPEN,
+                    'v' => '{',
+                    'p' => 0,
+                ],
+                [
+                    't' => JsonLexer::T_KEY,
+                    'v' => 'key',
+                    'p' => 3,
+                ],
+                [
+                    't' => JsonLexer::T_COLON,
+                    'v' => ':',
+                    'p' => 6,
+                ],
+                [
+                    't' => JsonLexer::T_SQUARE_BRACKET_OPEN,
+                    'v' => '[',
+                    'p' => 8,
+                ],
+                [
+                    't' => JsonLexer::T_STRING,
+                    'v' => 'value',
+                    'p' => 9,
+                ],
+                [
+                    't' => JsonLexer::T_SQUARE_BRACKET_CLOSE,
+                    'v' => ']',
+                    'p' => 14,
+                ],
+                [
+                    't' => JsonLexer::T_CURLY_BRACES_CLOSE,
+                    'v' => '}',
+                    'p' => 15,
+                ],
+            ],
+            '{  key: [value]}',
+        ];
+
+        yield [
+            [
+                [
+                    't' => JsonLexer::T_CURLY_BRACES_OPEN,
+                    'v' => '{',
+                    'p' => 0,
+                ],
+                [
+                    't' => JsonLexer::T_KEY,
+                    'v' => '_key',
+                    'p' => 1,
+                ],
+                [
+                    't' => JsonLexer::T_COLON,
+                    'v' => ':',
+                    'p' => 5,
+                ],
+                [
+                    't' => JsonLexer::T_STRING,
+                    'v' => 'value-parted',
+                    'p' => 8,
+                ],
+                [
+                    't' => JsonLexer::T_CURLY_BRACES_CLOSE,
+                    'v' => '}',
+                    'p' => 21,
+                ],
+            ],
+            '{_key:  value-parted }',
+        ];
+    }
+
+    /**
+     * @return iterable<array{0: int, 1: string}>
+     */
+    public static function getDataForTestSymbolMatch(): iterable
+    {
+        yield [JsonLexer::T_COMMA, ','];
+        yield [JsonLexer::T_COLON, ':'];
+        yield [JsonLexer::T_SQUARE_BRACKET_OPEN, '['];
+        yield [JsonLexer::T_SQUARE_BRACKET_CLOSE, ']'];
+        yield [JsonLexer::T_CURLY_BRACES_OPEN, '{'];
+        yield [JsonLexer::T_CURLY_BRACES_CLOSE, '}'];
+    }
+
+    #[DataProvider('getDataForTestStructureMatch')]
+    public function testStructureMatch(array $expectedTokenValues, string $input): void
+    {
+        $actualTokenValues = array_map(
+            static fn (Token $token): array => [
+                't' => $token->getType(),
+                'v' => $token->getValue(),
+                'p' => $token->getPosition(),
+            ],
+            iterator_to_array(new JsonLexer($input)),
+        );
+
+        self::assertSame($expectedTokenValues, $actualTokenValues);
+    }
+
+    #[DataProvider('getDataForTestSymbolMatch')]
+    public function testSymbolMatch(int $expectedType, string $input): void
+    {
+        $lexer = new JsonLexer($input);
+        self::assertCount(1, $lexer);
+
+        $token = $lexer->current();
+        self::assertNotNull($token);
+
+        self::assertSame($expectedType, $token->getType());
+    }
+}

From 88829b113420595143bb3e195e8e22f78be5061a Mon Sep 17 00:00:00 2001
From: Anatoliy Melnikov <5785276@gmail.com>
Date: Sat, 29 Jun 2024 02:00:33 +0300
Subject: [PATCH 02/28] Implement inline config reader of EXTRAS

---
 src/ApplicationFactory.php                    |  8 +++-
 src/Config.php                                | 13 +++++-
 src/Dto/Registrar/Todo.php                    | 11 ++++-
 src/InlineConfigReaderInterface.php           | 13 ++++++
 src/Service/InlineConfig/ExtrasReader.php     | 38 ++++++++++++++++++
 src/Service/TodoFactory.php                   |  8 ++++
 tests/Unit/Service/CommentRegistrarTest.php   | 10 +++--
 .../Service/InlineConfig/ExtrasReaderTest.php | 40 +++++++++++++++++++
 8 files changed, 133 insertions(+), 8 deletions(-)
 create mode 100644 src/InlineConfigReaderInterface.php
 create mode 100644 src/Service/InlineConfig/ExtrasReader.php
 create mode 100644 tests/Unit/Service/InlineConfig/ExtrasReaderTest.php

diff --git a/src/ApplicationFactory.php b/src/ApplicationFactory.php
index 70d031e..fd4814e 100644
--- a/src/ApplicationFactory.php
+++ b/src/ApplicationFactory.php
@@ -10,6 +10,8 @@
 use Aeliot\TodoRegistrar\Service\File\Saver;
 use Aeliot\TodoRegistrar\Service\File\Tokenizer;
 use Aeliot\TodoRegistrar\Service\FileProcessor;
+use Aeliot\TodoRegistrar\Service\InlineConfig\ArrayFromJsonLexerBuilder;
+use Aeliot\TodoRegistrar\Service\InlineConfig\ExtrasReader;
 use Aeliot\TodoRegistrar\Service\Registrar\RegistrarFactoryInterface;
 use Aeliot\TodoRegistrar\Service\Registrar\RegistrarFactoryRegistry;
 use Aeliot\TodoRegistrar\Service\Registrar\RegistrarInterface;
@@ -32,11 +34,13 @@ public function create(Config $config): Application
 
     private function createCommentRegistrar(RegistrarInterface $registrar, Config $config): CommentRegistrar
     {
+        $inlineConfigReader = $config->getInlineConfigReader() ?? new ExtrasReader(new ArrayFromJsonLexerBuilder());
+
         return new CommentRegistrar(
             new Detector(),
             new Extractor(new TagDetector($config->getTags())),
             $registrar,
-            new TodoFactory(),
+            new TodoFactory($inlineConfigReader),
         );
     }
 
@@ -60,4 +64,4 @@ private function createRegistrar(Config $config): RegistrarInterface
 
         return $registrarFactory->create($config->getRegistrarConfig());
     }
-}
\ No newline at end of file
+}
diff --git a/src/Config.php b/src/Config.php
index c19e2a9..502fcc6 100644
--- a/src/Config.php
+++ b/src/Config.php
@@ -11,6 +11,7 @@
 class Config
 {
     private Finder $finder;
+    private ?InlineConfigReaderInterface $inlineConfigReader = null;
     /**
      * @var array<string,mixed>
      */
@@ -74,4 +75,14 @@ public function setTags(array $tags): self
 
         return $this;
     }
-}
\ No newline at end of file
+
+    public function getInlineConfigReader(): ?InlineConfigReaderInterface
+    {
+        return $this->inlineConfigReader;
+    }
+
+    public function setInlineConfigReader(?InlineConfigReaderInterface $inlineConfigReader): void
+    {
+        $this->inlineConfigReader = $inlineConfigReader;
+    }
+}
diff --git a/src/Dto/Registrar/Todo.php b/src/Dto/Registrar/Todo.php
index 4772fd9..1857794 100644
--- a/src/Dto/Registrar/Todo.php
+++ b/src/Dto/Registrar/Todo.php
@@ -6,11 +6,15 @@
 
 class Todo
 {
+    /**
+     * @param array<array-key,mixed> $inlineConfig
+     */
     public function __construct(
         private string $tag,
         private string $summary,
         private string $description,
         private ?string $assignee,
+        private array $inlineConfig,
     ) {
     }
 
@@ -24,6 +28,11 @@ public function getDescription(): string
         return $this->description;
     }
 
+    public function getInlineConfig(): array
+    {
+        return $this->inlineConfig;
+    }
+
     public function getSummary(): string
     {
         return $this->summary;
@@ -33,4 +42,4 @@ public function getTag(): string
     {
         return $this->tag;
     }
-}
\ No newline at end of file
+}
diff --git a/src/InlineConfigReaderInterface.php b/src/InlineConfigReaderInterface.php
new file mode 100644
index 0000000..19469a1
--- /dev/null
+++ b/src/InlineConfigReaderInterface.php
@@ -0,0 +1,13 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Aeliot\TodoRegistrar;
+
+interface InlineConfigReaderInterface
+{
+    /**
+     * @return array<array-key,mixed>
+     */
+    public function getInlineConfig(string $input): array;
+}
diff --git a/src/Service/InlineConfig/ExtrasReader.php b/src/Service/InlineConfig/ExtrasReader.php
new file mode 100644
index 0000000..30994f7
--- /dev/null
+++ b/src/Service/InlineConfig/ExtrasReader.php
@@ -0,0 +1,38 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Aeliot\TodoRegistrar\Service\InlineConfig;
+
+use Aeliot\TodoRegistrar\Exception\InvalidInlineConfigFormatException;
+use Aeliot\TodoRegistrar\InlineConfigReaderInterface;
+
+final class ExtrasReader implements InlineConfigReaderInterface
+{
+    public function __construct(private ArrayFromJsonLexerBuilder $arrayBuilder)
+    {
+    }
+
+    /**
+     * @return array<array-key,mixed>
+     */
+    public function getInlineConfig(string $input): array
+    {
+        preg_match('/\{\s*EXTRAS\s*:/ui', $input, $matches, PREG_OFFSET_CAPTURE);
+        if (!$matches) {
+            return [];
+        }
+
+        $data = $this->arrayBuilder->build(new JsonLexer($input, (int) $matches[0][1]));
+        if (count($data) !== 1) {
+            throw new InvalidInlineConfigFormatException('EXTRAS must contain one and only one element');
+        }
+
+        $config = reset($data);
+        if (!$config || !\is_array($config) || array_is_list($config)) {
+            throw new InvalidInlineConfigFormatException('EXTRAS must contain object');
+        }
+
+        return $config;
+    }
+}
diff --git a/src/Service/TodoFactory.php b/src/Service/TodoFactory.php
index 8ec1618..8061119 100644
--- a/src/Service/TodoFactory.php
+++ b/src/Service/TodoFactory.php
@@ -6,16 +6,24 @@
 
 use Aeliot\TodoRegistrar\Dto\Comment\CommentPart;
 use Aeliot\TodoRegistrar\Dto\Registrar\Todo;
+use Aeliot\TodoRegistrar\InlineConfigReaderInterface;
 
 class TodoFactory
 {
+    public function __construct(private InlineConfigReaderInterface $inlineConfigReader)
+    {
+    }
+
     public function create(CommentPart $commentPart): Todo
     {
+        $description = $commentPart->getDescription();
+
         return new Todo(
             $commentPart->getTag(),
             $commentPart->getSummary(),
             $commentPart->getDescription(),
             $commentPart->getTagMetadata()?->getAssignee(),
+            $this->inlineConfigReader->getInlineConfig($description),
         );
     }
 }
diff --git a/tests/Unit/Service/CommentRegistrarTest.php b/tests/Unit/Service/CommentRegistrarTest.php
index 81b49d0..8bdfc8e 100644
--- a/tests/Unit/Service/CommentRegistrarTest.php
+++ b/tests/Unit/Service/CommentRegistrarTest.php
@@ -11,6 +11,8 @@
 use Aeliot\TodoRegistrar\Service\Comment\Detector as CommentDetector;
 use Aeliot\TodoRegistrar\Service\Comment\Extractor as CommentExtractor;
 use Aeliot\TodoRegistrar\Service\CommentRegistrar;
+use Aeliot\TodoRegistrar\Service\InlineConfig\ArrayFromJsonLexerBuilder;
+use Aeliot\TodoRegistrar\Service\InlineConfig\ExtrasReader;
 use Aeliot\TodoRegistrar\Service\Registrar\RegistrarInterface;
 use Aeliot\TodoRegistrar\Service\TodoFactory;
 use PHPUnit\Framework\Attributes\CoversClass;
@@ -33,7 +35,7 @@ public function testDontRegisterTwice(): void
         $commentParts = $this->createCommentParts($tokens[2]->text);
         $commentExtractor = $this->mockCommentExtractor($commentParts);
 
-        $todoFactory = new TodoFactory();
+        $todoFactory = new TodoFactory(new ExtrasReader(new ArrayFromJsonLexerBuilder()));
         $todo = $todoFactory->create($commentParts->getTodos()[0]);
 
         $registrar = $this->mockRegistrar($todo, true);
@@ -57,7 +59,7 @@ public function testDontRegisterTwiceWithIssueKey(): void
         $commentParts = $this->createCommentParts($tokens[2]->text, 'X-001');
         $commentExtractor = $this->mockCommentExtractor($commentParts);
 
-        $todoFactory = new TodoFactory();
+        $todoFactory = new TodoFactory(new ExtrasReader(new ArrayFromJsonLexerBuilder()));
 
         $registrar = $this->createMock(RegistrarInterface::class);
         $registrar
@@ -79,7 +81,7 @@ public function testRegisterNewTodos(): void
         $commentExtractor = $this->mockCommentExtractor($commentParts);
 
         $token = $commentParts->getTodos()[0];
-        $todoFactory = new TodoFactory();
+        $todoFactory = new TodoFactory(new ExtrasReader(new ArrayFromJsonLexerBuilder()));
         $todo = $todoFactory->create($token);
 
         $registrar = $this->mockRegistrar($todo, false);
@@ -112,7 +114,7 @@ private function getTokens(): array
     {
         $json = file_get_contents(__DIR__ . '/../../fixtures/tokens_of_single_line_php.json');
         $values = json_decode($json, true, 512, JSON_THROW_ON_ERROR);
-        return array_map(static fn(array $value): \PhpToken => new \PhpToken(
+        return array_map(static fn (array $value): \PhpToken => new \PhpToken(
             $value['id'],
             $value['text'],
             $value['line'],
diff --git a/tests/Unit/Service/InlineConfig/ExtrasReaderTest.php b/tests/Unit/Service/InlineConfig/ExtrasReaderTest.php
new file mode 100644
index 0000000..6943c8a
--- /dev/null
+++ b/tests/Unit/Service/InlineConfig/ExtrasReaderTest.php
@@ -0,0 +1,40 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Aeliot\TodoRegistrar\Test\Unit\Service\InlineConfig;
+
+use Aeliot\TodoRegistrar\Dto\InlineConfig\IndexedCollection;
+use Aeliot\TodoRegistrar\Dto\InlineConfig\NamedCollection;
+use Aeliot\TodoRegistrar\Dto\InlineConfig\Token;
+use Aeliot\TodoRegistrar\Service\InlineConfig\ArrayFromJsonLexerBuilder;
+use Aeliot\TodoRegistrar\Service\InlineConfig\ExtrasReader;
+use Aeliot\TodoRegistrar\Service\InlineConfig\JsonLexer;
+use PHPUnit\Framework\Attributes\CoversClass;
+use PHPUnit\Framework\Attributes\DataProvider;
+use PHPUnit\Framework\Attributes\UsesClass;
+use PHPUnit\Framework\TestCase;
+
+#[CoversClass(ExtrasReader::class)]
+#[UsesClass(ArrayFromJsonLexerBuilder::class)]
+#[UsesClass(IndexedCollection::class)]
+#[UsesClass(JsonLexer::class)]
+#[UsesClass(NamedCollection::class)]
+#[UsesClass(Token::class)]
+final class ExtrasReaderTest extends TestCase
+{
+    /**
+     * @return iterable<array{0: array<array-key,mixed>, 1: string}>
+     */
+    public static function getDataForTestPositiveFlow(): iterable
+    {
+        yield [['key' => ['value']], '{EXTRAS:{key:[value]}}'];
+    }
+
+    #[DataProvider('getDataForTestPositiveFlow')]
+    public function testPositiveFlow(array $expected, string $input): void
+    {
+        $actual = (new ExtrasReader(new ArrayFromJsonLexerBuilder()))->getInlineConfig($input);
+        self::assertSame($expected, $actual);
+    }
+}
\ No newline at end of file

From 020feee2b79d65bc305ac0f43cd86ab2fd76c0ff Mon Sep 17 00:00:00 2001
From: Anatoliy Melnikov <5785276@gmail.com>
Date: Sat, 29 Jun 2024 02:03:26 +0300
Subject: [PATCH 03/28] Update composer.lock

---
 composer.lock | 3 ++-
 1 file changed, 2 insertions(+), 1 deletion(-)

diff --git a/composer.lock b/composer.lock
index 504c01b..6df6395 100644
--- a/composer.lock
+++ b/composer.lock
@@ -4,7 +4,7 @@
         "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
         "This file is @generated automatically"
     ],
-    "content-hash": "b0397e2788b99528cee6f5b3a45e821e",
+    "content-hash": "eb82f1dc7b891c02aeb5aa404b0fd878",
     "packages": [
         {
             "name": "lesstif/php-jira-rest-client",
@@ -2984,6 +2984,7 @@
     "prefer-lowest": false,
     "platform": {
         "php": "^8.2",
+        "ext-mbstring": "*",
         "ext-tokenizer": "*"
     },
     "platform-dev": [],

From 167d4e3a09b1de25399c17ec2542355f0ecfa620 Mon Sep 17 00:00:00 2001
From: Anatoliy Melnikov <aeliot-tm@users.noreply.github.com>
Date: Sun, 30 Jun 2024 20:48:43 +0300
Subject: [PATCH 04/28] Apply inline config to JIRA-issue

---
 .../Registrar/JIRA/IssueFieldFactory.php      | 62 +++++++++++++++----
 1 file changed, 51 insertions(+), 11 deletions(-)

diff --git a/src/Service/Registrar/JIRA/IssueFieldFactory.php b/src/Service/Registrar/JIRA/IssueFieldFactory.php
index 0f5ef09..6834213 100644
--- a/src/Service/Registrar/JIRA/IssueFieldFactory.php
+++ b/src/Service/Registrar/JIRA/IssueFieldFactory.php
@@ -19,30 +19,70 @@ public function create(Todo $todo): IssueField
         $issueField = new IssueField();
         $issueField
             ->setProjectKey($this->issueConfig->getProjectKey())
-            ->setIssueTypeAsString($this->issueConfig->getIssueType())
             ->setSummary($todo->getSummary())
-            ->setDescription($todo->getDescription())
-            ->addComponentsAsArray($this->issueConfig->getComponents());
+            ->setDescription($todo->getDescription());
+
+        $this->setIssueType($issueField, $todo);
+        $this->setAssignee($issueField, $todo);
+        $this->setComponents($issueField, $todo);
+        $this->setLabels($issueField, $todo);
+        $this->setPriority($issueField, $todo);
+
+        return $issueField;
+    }
+
+    private function setAssignee(IssueField $issueField, Todo $todo): void
+    {
+        $assignee = $todo->getInlineConfig()['priority']
+            ?? $todo->getAssignee()
+            ?? $this->issueConfig->getAssignee();
 
-        $assignee = $todo->getAssignee() ?? $this->issueConfig->getAssignee();
         if ($assignee) {
             $issueField->setAssigneeNameAsString($assignee);
         }
+    }
 
-        $priority = $this->issueConfig->getPriority();
-        if ($priority) {
-            $issueField->setPriorityNameAsString($priority);
-        }
+    private function setComponents(IssueField $issueField, Todo $todo): void
+    {
+        $component = [
+            ...($todo->getInlineConfig()['components'] ?? []),
+            ...$this->issueConfig->getComponents(),
+        ];
+        $issueField->addComponentsAsArray(array_unique($component));
+    }
+
+    private function setIssueType(IssueField $issueField, Todo $todo): void
+    {
+        $inlineConfig = $todo->getInlineConfig();
+        $issueType = $inlineConfig['issue_type']
+            ?? $this->issueConfig->getIssueType();
+
+        $issueField->setIssueTypeAsString($issueType);
+    }
+
+    private function setLabels(IssueField $issueField, Todo $todo): void
+    {
+        $labels = [
+            ...($todo->getInlineConfig()['labels'] ?? []),
+            ...$this->issueConfig->getLabels(),
+        ];
 
-        $labels = $this->issueConfig->getLabels();
         if ($this->issueConfig->isAddTagToLabels()) {
             $labels[] = strtolower(sprintf('%s%s', $this->issueConfig->getTagPrefix(), $todo->getTag()));
         }
 
-        foreach ($labels as $label) {
+        foreach (array_unique($labels) as $label) {
             $issueField->addLabelAsString($label);
         }
+    }
 
-        return $issueField;
+    private function setPriority(IssueField $issueField, Todo $todo): void
+    {
+        $priority = $todo->getInlineConfig()['priority']
+            ?? $this->issueConfig->getPriority();
+
+        if ($priority) {
+            $issueField->setPriorityNameAsString($priority);
+        }
     }
 }

From ba315526822b94712793bddedfcf31f83f095e8f Mon Sep 17 00:00:00 2001
From: Aeliot <5785276@gmail.com>
Date: Mon, 1 Jul 2024 09:52:06 +0300
Subject: [PATCH 05/28] Fix test after the rebasing of branch

---
 src/ApplicationFactory.php                    |  4 +-
 src/Config.php                                | 31 ++++++++----
 src/Dto/InlineConfig/InlineConfig.php         | 50 +++++++++++++++++++
 src/Dto/Registrar/Todo.php                    |  9 ++--
 src/InlineConfigFactoryInterface.php          | 13 +++++
 src/InlineConfigInterface.php                 |  9 ++++
 .../InlineConfig/InlineConfigFactory.php      | 17 +++++++
 src/Service/TodoFactory.php                   | 14 +++++-
 tests/Unit/Service/CommentRegistrarTest.php   | 14 ++++--
 tests/Unit/Service/TodoFactoryTest.php        | 14 +++++-
 10 files changed, 151 insertions(+), 24 deletions(-)
 create mode 100644 src/Dto/InlineConfig/InlineConfig.php
 create mode 100644 src/InlineConfigFactoryInterface.php
 create mode 100644 src/InlineConfigInterface.php
 create mode 100644 src/Service/InlineConfig/InlineConfigFactory.php

diff --git a/src/ApplicationFactory.php b/src/ApplicationFactory.php
index fd4814e..6871501 100644
--- a/src/ApplicationFactory.php
+++ b/src/ApplicationFactory.php
@@ -12,6 +12,7 @@
 use Aeliot\TodoRegistrar\Service\FileProcessor;
 use Aeliot\TodoRegistrar\Service\InlineConfig\ArrayFromJsonLexerBuilder;
 use Aeliot\TodoRegistrar\Service\InlineConfig\ExtrasReader;
+use Aeliot\TodoRegistrar\Service\InlineConfig\InlineConfigFactory;
 use Aeliot\TodoRegistrar\Service\Registrar\RegistrarFactoryInterface;
 use Aeliot\TodoRegistrar\Service\Registrar\RegistrarFactoryRegistry;
 use Aeliot\TodoRegistrar\Service\Registrar\RegistrarInterface;
@@ -35,12 +36,13 @@ public function create(Config $config): Application
     private function createCommentRegistrar(RegistrarInterface $registrar, Config $config): CommentRegistrar
     {
         $inlineConfigReader = $config->getInlineConfigReader() ?? new ExtrasReader(new ArrayFromJsonLexerBuilder());
+        $inlineConfigFactory = $config->getInlineConfigFactory() ?? new InlineConfigFactory();
 
         return new CommentRegistrar(
             new Detector(),
             new Extractor(new TagDetector($config->getTags())),
             $registrar,
-            new TodoFactory($inlineConfigReader),
+            new TodoFactory($inlineConfigFactory, $inlineConfigReader),
         );
     }
 
diff --git a/src/Config.php b/src/Config.php
index 502fcc6..88c4c96 100644
--- a/src/Config.php
+++ b/src/Config.php
@@ -11,6 +11,7 @@
 class Config
 {
     private Finder $finder;
+    private ?InlineConfigFactoryInterface $InlineConfigFactory = null;
     private ?InlineConfigReaderInterface $inlineConfigReader = null;
     /**
      * @var array<string,mixed>
@@ -34,6 +35,26 @@ public function setFinder(Finder $finder): self
         return $this;
     }
 
+    public function getInlineConfigFactory(): ?InlineConfigFactoryInterface
+    {
+        return $this->InlineConfigFactory;
+    }
+
+    public function setInlineConfigFactory(?InlineConfigFactoryInterface $InlineConfigFactory): void
+    {
+        $this->InlineConfigFactory = $InlineConfigFactory;
+    }
+
+    public function getInlineConfigReader(): ?InlineConfigReaderInterface
+    {
+        return $this->inlineConfigReader;
+    }
+
+    public function setInlineConfigReader(?InlineConfigReaderInterface $inlineConfigReader): void
+    {
+        $this->inlineConfigReader = $inlineConfigReader;
+    }
+
     /**
      * @return array<string,mixed>
      */
@@ -75,14 +96,4 @@ public function setTags(array $tags): self
 
         return $this;
     }
-
-    public function getInlineConfigReader(): ?InlineConfigReaderInterface
-    {
-        return $this->inlineConfigReader;
-    }
-
-    public function setInlineConfigReader(?InlineConfigReaderInterface $inlineConfigReader): void
-    {
-        $this->inlineConfigReader = $inlineConfigReader;
-    }
 }
diff --git a/src/Dto/InlineConfig/InlineConfig.php b/src/Dto/InlineConfig/InlineConfig.php
new file mode 100644
index 0000000..e763093
--- /dev/null
+++ b/src/Dto/InlineConfig/InlineConfig.php
@@ -0,0 +1,50 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Aeliot\TodoRegistrar\Dto\InlineConfig;
+
+use Aeliot\TodoRegistrar\InlineConfigInterface;
+
+class InlineConfig implements InlineConfigInterface
+{
+    /**
+     * @param array<array-key,mixed> $data
+     */
+    public function __construct(
+        private array $data,
+    ) {
+    }
+
+    /**
+     * @param int|string $offset
+     */
+    public function offsetExists(mixed $offset): bool
+    {
+        return array_key_exists($offset, $this->data);
+    }
+
+    /**
+     * @param int|string $offset
+     */
+    public function offsetGet(mixed $offset): mixed
+    {
+        return $this->data[$offset];
+    }
+
+    /**
+     * @param int|string $offset
+     */
+    public function offsetSet(mixed $offset, mixed $value): void
+    {
+        throw new \BadMethodCallException('Setting value is not allowed.');
+    }
+
+    /**
+     * @param int|string $offset
+     */
+    public function offsetUnset(mixed $offset): void
+    {
+        throw new \BadMethodCallException('Unsetting value is not allowed.');
+    }
+}
diff --git a/src/Dto/Registrar/Todo.php b/src/Dto/Registrar/Todo.php
index 1857794..766f960 100644
--- a/src/Dto/Registrar/Todo.php
+++ b/src/Dto/Registrar/Todo.php
@@ -4,17 +4,16 @@
 
 namespace Aeliot\TodoRegistrar\Dto\Registrar;
 
+use Aeliot\TodoRegistrar\InlineConfigInterface;
+
 class Todo
 {
-    /**
-     * @param array<array-key,mixed> $inlineConfig
-     */
     public function __construct(
         private string $tag,
         private string $summary,
         private string $description,
         private ?string $assignee,
-        private array $inlineConfig,
+        private InlineConfigInterface $inlineConfig,
     ) {
     }
 
@@ -28,7 +27,7 @@ public function getDescription(): string
         return $this->description;
     }
 
-    public function getInlineConfig(): array
+    public function getInlineConfig(): InlineConfigInterface
     {
         return $this->inlineConfig;
     }
diff --git a/src/InlineConfigFactoryInterface.php b/src/InlineConfigFactoryInterface.php
new file mode 100644
index 0000000..6949987
--- /dev/null
+++ b/src/InlineConfigFactoryInterface.php
@@ -0,0 +1,13 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Aeliot\TodoRegistrar;
+
+interface InlineConfigFactoryInterface
+{
+    /**
+     * @param array<array-key,mixed> $input
+     */
+    public function getInlineConfig(array $input): InlineConfigInterface;
+}
diff --git a/src/InlineConfigInterface.php b/src/InlineConfigInterface.php
new file mode 100644
index 0000000..4ed1bc5
--- /dev/null
+++ b/src/InlineConfigInterface.php
@@ -0,0 +1,9 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Aeliot\TodoRegistrar;
+
+interface InlineConfigInterface extends \ArrayAccess
+{
+}
diff --git a/src/Service/InlineConfig/InlineConfigFactory.php b/src/Service/InlineConfig/InlineConfigFactory.php
new file mode 100644
index 0000000..babf8e7
--- /dev/null
+++ b/src/Service/InlineConfig/InlineConfigFactory.php
@@ -0,0 +1,17 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Aeliot\TodoRegistrar\Service\InlineConfig;
+
+use Aeliot\TodoRegistrar\Dto\InlineConfig\InlineConfig;
+use Aeliot\TodoRegistrar\InlineConfigFactoryInterface;
+use Aeliot\TodoRegistrar\InlineConfigInterface;
+
+final class InlineConfigFactory implements InlineConfigFactoryInterface
+{
+    public function getInlineConfig(array $input): InlineConfigInterface
+    {
+        return new InlineConfig($input);
+    }
+}
diff --git a/src/Service/TodoFactory.php b/src/Service/TodoFactory.php
index 8061119..e8701e4 100644
--- a/src/Service/TodoFactory.php
+++ b/src/Service/TodoFactory.php
@@ -6,11 +6,16 @@
 
 use Aeliot\TodoRegistrar\Dto\Comment\CommentPart;
 use Aeliot\TodoRegistrar\Dto\Registrar\Todo;
+use Aeliot\TodoRegistrar\InlineConfigFactoryInterface;
+use Aeliot\TodoRegistrar\InlineConfigInterface;
 use Aeliot\TodoRegistrar\InlineConfigReaderInterface;
 
 class TodoFactory
 {
-    public function __construct(private InlineConfigReaderInterface $inlineConfigReader)
+    public function __construct(
+        private InlineConfigFactoryInterface $inlineConfigFactory,
+        private InlineConfigReaderInterface $inlineConfigReader,
+    )
     {
     }
 
@@ -23,7 +28,12 @@ public function create(CommentPart $commentPart): Todo
             $commentPart->getSummary(),
             $commentPart->getDescription(),
             $commentPart->getTagMetadata()?->getAssignee(),
-            $this->inlineConfigReader->getInlineConfig($description),
+            $this->getInlineConfig($description),
         );
     }
+
+    private function getInlineConfig(string $description): InlineConfigInterface
+    {
+        return $this->inlineConfigFactory->getInlineConfig($this->inlineConfigReader->getInlineConfig($description));
+    }
 }
diff --git a/tests/Unit/Service/CommentRegistrarTest.php b/tests/Unit/Service/CommentRegistrarTest.php
index 8bdfc8e..d86c8b9 100644
--- a/tests/Unit/Service/CommentRegistrarTest.php
+++ b/tests/Unit/Service/CommentRegistrarTest.php
@@ -13,6 +13,7 @@
 use Aeliot\TodoRegistrar\Service\CommentRegistrar;
 use Aeliot\TodoRegistrar\Service\InlineConfig\ArrayFromJsonLexerBuilder;
 use Aeliot\TodoRegistrar\Service\InlineConfig\ExtrasReader;
+use Aeliot\TodoRegistrar\Service\InlineConfig\InlineConfigFactory;
 use Aeliot\TodoRegistrar\Service\Registrar\RegistrarInterface;
 use Aeliot\TodoRegistrar\Service\TodoFactory;
 use PHPUnit\Framework\Attributes\CoversClass;
@@ -35,7 +36,7 @@ public function testDontRegisterTwice(): void
         $commentParts = $this->createCommentParts($tokens[2]->text);
         $commentExtractor = $this->mockCommentExtractor($commentParts);
 
-        $todoFactory = new TodoFactory(new ExtrasReader(new ArrayFromJsonLexerBuilder()));
+        $todoFactory = $this->createTodoFactory();
         $todo = $todoFactory->create($commentParts->getTodos()[0]);
 
         $registrar = $this->mockRegistrar($todo, true);
@@ -59,7 +60,7 @@ public function testDontRegisterTwiceWithIssueKey(): void
         $commentParts = $this->createCommentParts($tokens[2]->text, 'X-001');
         $commentExtractor = $this->mockCommentExtractor($commentParts);
 
-        $todoFactory = new TodoFactory(new ExtrasReader(new ArrayFromJsonLexerBuilder()));
+        $todoFactory = $this->createTodoFactory();
 
         $registrar = $this->createMock(RegistrarInterface::class);
         $registrar
@@ -81,7 +82,7 @@ public function testRegisterNewTodos(): void
         $commentExtractor = $this->mockCommentExtractor($commentParts);
 
         $token = $commentParts->getTodos()[0];
-        $todoFactory = new TodoFactory(new ExtrasReader(new ArrayFromJsonLexerBuilder()));
+        $todoFactory = $this->createTodoFactory();
         $todo = $todoFactory->create($token);
 
         $registrar = $this->mockRegistrar($todo, false);
@@ -160,4 +161,9 @@ private function mockRegistrar(Todo $todo, bool $isRegistered): RegistrarInterfa
 
         return $registrar;
     }
-}
\ No newline at end of file
+
+    private function createTodoFactory(): TodoFactory
+    {
+        return new TodoFactory(new InlineConfigFactory(), new ExtrasReader(new ArrayFromJsonLexerBuilder()));
+    }
+}
diff --git a/tests/Unit/Service/TodoFactoryTest.php b/tests/Unit/Service/TodoFactoryTest.php
index 6f95c2b..e4849cd 100644
--- a/tests/Unit/Service/TodoFactoryTest.php
+++ b/tests/Unit/Service/TodoFactoryTest.php
@@ -6,6 +6,9 @@
 
 use Aeliot\TodoRegistrar\Dto\Comment\CommentPart;
 use Aeliot\TodoRegistrar\Dto\Tag\TagMetadata;
+use Aeliot\TodoRegistrar\Service\InlineConfig\ArrayFromJsonLexerBuilder;
+use Aeliot\TodoRegistrar\Service\InlineConfig\ExtrasReader;
+use Aeliot\TodoRegistrar\Service\InlineConfig\InlineConfigFactory;
 use Aeliot\TodoRegistrar\Service\TodoFactory;
 use PHPUnit\Framework\Attributes\CoversClass;
 use PHPUnit\Framework\TestCase;
@@ -24,7 +27,8 @@ public function testMappingOfBaseFields(): void
         $commentPart->method('getTag')->willReturn('a-tag');
         $commentPart->method('getTagMetadata')->willReturn($tagMetadata);
 
-        $todo = (new TodoFactory())->create($commentPart);
+        $todoFactory = $this->createTodoFactory();
+        $todo = $todoFactory->create($commentPart);
 
         self::assertSame('a-tag', $todo->getTag());
         self::assertSame('summary', $todo->getSummary());
@@ -40,8 +44,14 @@ public function testMappingOfAssigneeWithoutTagMetadata(): void
         $commentPart->method('getTag')->willReturn('a-tag');
         $commentPart->method('getTagMetadata')->willReturn(null);
 
-        $todo = (new TodoFactory())->create($commentPart);
+        $todoFactory = $this->createTodoFactory();
+        $todo = $todoFactory->create($commentPart);
 
         self::assertNull($todo->getAssignee());
     }
+
+    private function createTodoFactory(): TodoFactory
+    {
+        return new TodoFactory(new InlineConfigFactory(), new ExtrasReader(new ArrayFromJsonLexerBuilder()));
+    }
 }

From 59c0dd190f3b2cfe60896f3dc47b49eaf3220864 Mon Sep 17 00:00:00 2001
From: Aeliot <5785276@gmail.com>
Date: Mon, 1 Jul 2024 14:36:10 +0300
Subject: [PATCH 06/28] Rename JsonLexer to JsonLikeLexer to avoid problems
 with names in future

---
 src/ApplicationFactory.php                    |  4 +-
 ....php => ArrayFromJsonLikeLexerBuilder.php} | 38 +++++-----
 src/Service/InlineConfig/ExtrasReader.php     |  4 +-
 .../{JsonLexer.php => JsonLikeLexer.php}      |  2 +-
 tests/Unit/Service/CommentRegistrarTest.php   |  4 +-
 ... => ArrayFromJsonLikeLexerBuilderTest.php} | 14 ++--
 .../Service/InlineConfig/ExtrasReaderTest.php | 12 ++--
 ...sonLexerTest.php => JsonLikeLexerTest.php} | 70 +++++++++----------
 tests/Unit/Service/TodoFactoryTest.php        |  4 +-
 9 files changed, 76 insertions(+), 76 deletions(-)
 rename src/Service/InlineConfig/{ArrayFromJsonLexerBuilder.php => ArrayFromJsonLikeLexerBuilder.php} (70%)
 rename src/Service/InlineConfig/{JsonLexer.php => JsonLikeLexer.php} (98%)
 rename tests/Unit/Service/InlineConfig/{ArrayFromJsonLexerBuilderTest.php => ArrayFromJsonLikeLexerBuilderTest.php} (72%)
 rename tests/Unit/Service/InlineConfig/{JsonLexerTest.php => JsonLikeLexerTest.php} (68%)

diff --git a/src/ApplicationFactory.php b/src/ApplicationFactory.php
index 6871501..6ec2beb 100644
--- a/src/ApplicationFactory.php
+++ b/src/ApplicationFactory.php
@@ -10,7 +10,7 @@
 use Aeliot\TodoRegistrar\Service\File\Saver;
 use Aeliot\TodoRegistrar\Service\File\Tokenizer;
 use Aeliot\TodoRegistrar\Service\FileProcessor;
-use Aeliot\TodoRegistrar\Service\InlineConfig\ArrayFromJsonLexerBuilder;
+use Aeliot\TodoRegistrar\Service\InlineConfig\ArrayFromJsonLikeLexerBuilder;
 use Aeliot\TodoRegistrar\Service\InlineConfig\ExtrasReader;
 use Aeliot\TodoRegistrar\Service\InlineConfig\InlineConfigFactory;
 use Aeliot\TodoRegistrar\Service\Registrar\RegistrarFactoryInterface;
@@ -35,7 +35,7 @@ public function create(Config $config): Application
 
     private function createCommentRegistrar(RegistrarInterface $registrar, Config $config): CommentRegistrar
     {
-        $inlineConfigReader = $config->getInlineConfigReader() ?? new ExtrasReader(new ArrayFromJsonLexerBuilder());
+        $inlineConfigReader = $config->getInlineConfigReader() ?? new ExtrasReader(new ArrayFromJsonLikeLexerBuilder());
         $inlineConfigFactory = $config->getInlineConfigFactory() ?? new InlineConfigFactory();
 
         return new CommentRegistrar(
diff --git a/src/Service/InlineConfig/ArrayFromJsonLexerBuilder.php b/src/Service/InlineConfig/ArrayFromJsonLikeLexerBuilder.php
similarity index 70%
rename from src/Service/InlineConfig/ArrayFromJsonLexerBuilder.php
rename to src/Service/InlineConfig/ArrayFromJsonLikeLexerBuilder.php
index 54224c6..48e7490 100644
--- a/src/Service/InlineConfig/ArrayFromJsonLexerBuilder.php
+++ b/src/Service/InlineConfig/ArrayFromJsonLikeLexerBuilder.php
@@ -9,15 +9,15 @@
 use Aeliot\TodoRegistrar\Dto\InlineConfig\Token;
 use Aeliot\TodoRegistrar\Exception\InvalidInlineConfigFormatException;
 
-final class ArrayFromJsonLexerBuilder
+final class ArrayFromJsonLikeLexerBuilder
 {
     /**
      * @return array<array-key,mixed>
      */
-    public function build(JsonLexer $lexer): array
+    public function build(JsonLikeLexer $lexer): array
     {
         $lexer->rewind();
-        if (JsonLexer::T_CURLY_BRACES_OPEN !== $lexer->current()->getType()) {
+        if (JsonLikeLexer::T_CURLY_BRACES_OPEN !== $lexer->current()->getType()) {
             throw new InvalidInlineConfigFormatException('Config must be started with curly braces');
         }
 
@@ -53,36 +53,36 @@ private function addValue(CollectionInterface $collection, ?string $key, mixed $
     }
 
     /**
-     * TODO: move it into JsonLexer
+     * TODO: move it into JsonLikeLexer
      */
     private function checkPredecessorType(int $current, ?int $predecessor): void
     {
-        if (JsonLexer::T_COLON === $current && JsonLexer::T_KEY !== $predecessor) {
+        if (JsonLikeLexer::T_COLON === $current && JsonLikeLexer::T_KEY !== $predecessor) {
             throw new InvalidInlineConfigFormatException(
                 sprintf('Colon must be after key, but passed %d', $predecessor),
             );
         }
 
-        if (JsonLexer::T_COMMA === $current && JsonLexer::T_COMMA === $predecessor) {
+        if (JsonLikeLexer::T_COMMA === $current && JsonLikeLexer::T_COMMA === $predecessor) {
             throw new InvalidInlineConfigFormatException('Duplicated comma');
         }
 
-        if (JsonLexer::T_CURLY_BRACES_OPEN === $current
-            && !(null === $predecessor || JsonLexer::T_COLON === $predecessor)
+        if (JsonLikeLexer::T_CURLY_BRACES_OPEN === $current
+            && !(null === $predecessor || JsonLikeLexer::T_COLON === $predecessor)
         ) {
             throw new InvalidInlineConfigFormatException('Opening curly braces may be initial or must be after colon');
         }
 
-        if (JsonLexer::T_SQUARE_BRACKET_OPEN === $current && JsonLexer::T_COLON !== $predecessor) {
+        if (JsonLikeLexer::T_SQUARE_BRACKET_OPEN === $current && JsonLikeLexer::T_COLON !== $predecessor) {
             throw new InvalidInlineConfigFormatException('Opening square bracket must be after colon');
         }
 
-        if (JsonLexer::T_STRING === $current && JsonLexer::T_STRING === $predecessor) {
+        if (JsonLikeLexer::T_STRING === $current && JsonLikeLexer::T_STRING === $predecessor) {
             throw new InvalidInlineConfigFormatException('Duplicated value');
         }
 
-        if (JsonLexer::T_COLON === $predecessor
-            && \in_array($current, [JsonLexer::T_CURLY_BRACES_CLOSE, JsonLexer::T_SQUARE_BRACKET_CLOSE], true)
+        if (JsonLikeLexer::T_COLON === $predecessor
+            && \in_array($current, [JsonLikeLexer::T_CURLY_BRACES_CLOSE, JsonLikeLexer::T_SQUARE_BRACKET_CLOSE], true)
         ) {
             throw new InvalidInlineConfigFormatException(
                 'Colon cannot be before closing curly braces or closing square brackets',
@@ -92,20 +92,20 @@ private function checkPredecessorType(int $current, ?int $predecessor): void
 
     private function isCloseCollection(Token $token): bool
     {
-        return \in_array($token->getType(), [JsonLexer::T_SQUARE_BRACKET_CLOSE, JsonLexer::T_CURLY_BRACES_CLOSE], true);
+        return \in_array($token->getType(), [JsonLikeLexer::T_SQUARE_BRACKET_CLOSE, JsonLikeLexer::T_CURLY_BRACES_CLOSE], true);
     }
 
     private function isSkippable(Token $token): bool
     {
-        return \in_array($token->getType(), [JsonLexer::T_COLON, JsonLexer::T_COMMA], true);
+        return \in_array($token->getType(), [JsonLikeLexer::T_COLON, JsonLikeLexer::T_COMMA], true);
     }
 
     private function isValue(Token $token): bool
     {
-        return JsonLexer::T_STRING === $token->getType();
+        return JsonLikeLexer::T_STRING === $token->getType();
     }
 
-    private function populate(JsonLexer $lexer, CollectionInterface $collection, int &$level): void
+    private function populate(JsonLikeLexer $lexer, CollectionInterface $collection, int &$level): void
     {
         $key = null;
         do {
@@ -113,17 +113,17 @@ private function populate(JsonLexer $lexer, CollectionInterface $collection, int
             $this->checkPredecessorType($token->getType(), $lexer->predecessor()?->getType());
             $lexer->next();
 
-            if (JsonLexer::T_KEY === $token->getType()) {
+            if (JsonLikeLexer::T_KEY === $token->getType()) {
                 $key = $token->getValue();
             } elseif ($this->isValue($token)) {
                 $this->addValue($collection, $key, $token->getValue());
-            } elseif (JsonLexer::T_CURLY_BRACES_OPEN === $token->getType()) {
+            } elseif (JsonLikeLexer::T_CURLY_BRACES_OPEN === $token->getType()) {
                 ++$level;
                 $childCollection = new NamedCollection();
                 $this->populate($lexer, $childCollection, $level);
                 $this->addValue($collection, $key, $childCollection);
                 $key = null;
-            } elseif (JsonLexer::T_SQUARE_BRACKET_OPEN === $token->getType()) {
+            } elseif (JsonLikeLexer::T_SQUARE_BRACKET_OPEN === $token->getType()) {
                 ++$level;
                 $childCollection = new IndexedCollection();
                 $this->populate($lexer, $childCollection, $level);
diff --git a/src/Service/InlineConfig/ExtrasReader.php b/src/Service/InlineConfig/ExtrasReader.php
index 30994f7..932fec4 100644
--- a/src/Service/InlineConfig/ExtrasReader.php
+++ b/src/Service/InlineConfig/ExtrasReader.php
@@ -9,7 +9,7 @@
 
 final class ExtrasReader implements InlineConfigReaderInterface
 {
-    public function __construct(private ArrayFromJsonLexerBuilder $arrayBuilder)
+    public function __construct(private ArrayFromJsonLikeLexerBuilder $arrayBuilder)
     {
     }
 
@@ -23,7 +23,7 @@ public function getInlineConfig(string $input): array
             return [];
         }
 
-        $data = $this->arrayBuilder->build(new JsonLexer($input, (int) $matches[0][1]));
+        $data = $this->arrayBuilder->build(new JsonLikeLexer($input, (int) $matches[0][1]));
         if (count($data) !== 1) {
             throw new InvalidInlineConfigFormatException('EXTRAS must contain one and only one element');
         }
diff --git a/src/Service/InlineConfig/JsonLexer.php b/src/Service/InlineConfig/JsonLikeLexer.php
similarity index 98%
rename from src/Service/InlineConfig/JsonLexer.php
rename to src/Service/InlineConfig/JsonLikeLexer.php
index 2d64a1f..95921d5 100644
--- a/src/Service/InlineConfig/JsonLexer.php
+++ b/src/Service/InlineConfig/JsonLikeLexer.php
@@ -12,7 +12,7 @@
  *
  * @implements \Iterator<Token>
  */
-final class JsonLexer implements \Iterator, \Countable
+final class JsonLikeLexer implements \Iterator, \Countable
 {
     public const T_NONE = 1;
     public const T_STRING = 2;
diff --git a/tests/Unit/Service/CommentRegistrarTest.php b/tests/Unit/Service/CommentRegistrarTest.php
index d86c8b9..1c49e3e 100644
--- a/tests/Unit/Service/CommentRegistrarTest.php
+++ b/tests/Unit/Service/CommentRegistrarTest.php
@@ -11,7 +11,7 @@
 use Aeliot\TodoRegistrar\Service\Comment\Detector as CommentDetector;
 use Aeliot\TodoRegistrar\Service\Comment\Extractor as CommentExtractor;
 use Aeliot\TodoRegistrar\Service\CommentRegistrar;
-use Aeliot\TodoRegistrar\Service\InlineConfig\ArrayFromJsonLexerBuilder;
+use Aeliot\TodoRegistrar\Service\InlineConfig\ArrayFromJsonLikeLexerBuilder;
 use Aeliot\TodoRegistrar\Service\InlineConfig\ExtrasReader;
 use Aeliot\TodoRegistrar\Service\InlineConfig\InlineConfigFactory;
 use Aeliot\TodoRegistrar\Service\Registrar\RegistrarInterface;
@@ -164,6 +164,6 @@ private function mockRegistrar(Todo $todo, bool $isRegistered): RegistrarInterfa
 
     private function createTodoFactory(): TodoFactory
     {
-        return new TodoFactory(new InlineConfigFactory(), new ExtrasReader(new ArrayFromJsonLexerBuilder()));
+        return new TodoFactory(new InlineConfigFactory(), new ExtrasReader(new ArrayFromJsonLikeLexerBuilder()));
     }
 }
diff --git a/tests/Unit/Service/InlineConfig/ArrayFromJsonLexerBuilderTest.php b/tests/Unit/Service/InlineConfig/ArrayFromJsonLikeLexerBuilderTest.php
similarity index 72%
rename from tests/Unit/Service/InlineConfig/ArrayFromJsonLexerBuilderTest.php
rename to tests/Unit/Service/InlineConfig/ArrayFromJsonLikeLexerBuilderTest.php
index d30a2b3..2d379c3 100644
--- a/tests/Unit/Service/InlineConfig/ArrayFromJsonLexerBuilderTest.php
+++ b/tests/Unit/Service/InlineConfig/ArrayFromJsonLikeLexerBuilderTest.php
@@ -7,19 +7,19 @@
 use Aeliot\TodoRegistrar\Dto\InlineConfig\IndexedCollection;
 use Aeliot\TodoRegistrar\Dto\InlineConfig\NamedCollection;
 use Aeliot\TodoRegistrar\Dto\InlineConfig\Token;
-use Aeliot\TodoRegistrar\Service\InlineConfig\ArrayFromJsonLexerBuilder;
-use Aeliot\TodoRegistrar\Service\InlineConfig\JsonLexer;
+use Aeliot\TodoRegistrar\Service\InlineConfig\ArrayFromJsonLikeLexerBuilder;
+use Aeliot\TodoRegistrar\Service\InlineConfig\JsonLikeLexer;
 use PHPUnit\Framework\Attributes\CoversClass;
 use PHPUnit\Framework\Attributes\DataProvider;
 use PHPUnit\Framework\Attributes\UsesClass;
 use PHPUnit\Framework\TestCase;
 
-#[CoversClass(ArrayFromJsonLexerBuilder::class)]
-#[UsesClass(JsonLexer::class)]
+#[CoversClass(ArrayFromJsonLikeLexerBuilder::class)]
+#[UsesClass(JsonLikeLexer::class)]
 #[UsesClass(Token::class)]
 #[UsesClass(NamedCollection::class)]
 #[UsesClass(IndexedCollection::class)]
-final class ArrayFromJsonLexerBuilderTest extends TestCase
+final class ArrayFromJsonLikeLexerBuilderTest extends TestCase
 {
     /**
      * @return iterable<array{0: array<array-key,mixed>, 1: string}>
@@ -33,7 +33,7 @@ public static function getDataForTestPositiveFlow(): iterable
     #[DataProvider('getDataForTestPositiveFlow')]
     public function testPositiveFlow(array $expected, string $input): void
     {
-        $actual = (new ArrayFromJsonLexerBuilder())->build(new JsonLexer($input));
+        $actual = (new ArrayFromJsonLikeLexerBuilder())->build(new JsonLikeLexer($input));
         self::assertSame($expected, $actual);
     }
-}
\ No newline at end of file
+}
diff --git a/tests/Unit/Service/InlineConfig/ExtrasReaderTest.php b/tests/Unit/Service/InlineConfig/ExtrasReaderTest.php
index 6943c8a..001b4bd 100644
--- a/tests/Unit/Service/InlineConfig/ExtrasReaderTest.php
+++ b/tests/Unit/Service/InlineConfig/ExtrasReaderTest.php
@@ -7,18 +7,18 @@
 use Aeliot\TodoRegistrar\Dto\InlineConfig\IndexedCollection;
 use Aeliot\TodoRegistrar\Dto\InlineConfig\NamedCollection;
 use Aeliot\TodoRegistrar\Dto\InlineConfig\Token;
-use Aeliot\TodoRegistrar\Service\InlineConfig\ArrayFromJsonLexerBuilder;
+use Aeliot\TodoRegistrar\Service\InlineConfig\ArrayFromJsonLikeLexerBuilder;
 use Aeliot\TodoRegistrar\Service\InlineConfig\ExtrasReader;
-use Aeliot\TodoRegistrar\Service\InlineConfig\JsonLexer;
+use Aeliot\TodoRegistrar\Service\InlineConfig\JsonLikeLexer;
 use PHPUnit\Framework\Attributes\CoversClass;
 use PHPUnit\Framework\Attributes\DataProvider;
 use PHPUnit\Framework\Attributes\UsesClass;
 use PHPUnit\Framework\TestCase;
 
 #[CoversClass(ExtrasReader::class)]
-#[UsesClass(ArrayFromJsonLexerBuilder::class)]
+#[UsesClass(ArrayFromJsonLikeLexerBuilder::class)]
 #[UsesClass(IndexedCollection::class)]
-#[UsesClass(JsonLexer::class)]
+#[UsesClass(JsonLikeLexer::class)]
 #[UsesClass(NamedCollection::class)]
 #[UsesClass(Token::class)]
 final class ExtrasReaderTest extends TestCase
@@ -34,7 +34,7 @@ public static function getDataForTestPositiveFlow(): iterable
     #[DataProvider('getDataForTestPositiveFlow')]
     public function testPositiveFlow(array $expected, string $input): void
     {
-        $actual = (new ExtrasReader(new ArrayFromJsonLexerBuilder()))->getInlineConfig($input);
+        $actual = (new ExtrasReader(new ArrayFromJsonLikeLexerBuilder()))->getInlineConfig($input);
         self::assertSame($expected, $actual);
     }
-}
\ No newline at end of file
+}
diff --git a/tests/Unit/Service/InlineConfig/JsonLexerTest.php b/tests/Unit/Service/InlineConfig/JsonLikeLexerTest.php
similarity index 68%
rename from tests/Unit/Service/InlineConfig/JsonLexerTest.php
rename to tests/Unit/Service/InlineConfig/JsonLikeLexerTest.php
index 58cef5d..9e8b4d6 100644
--- a/tests/Unit/Service/InlineConfig/JsonLexerTest.php
+++ b/tests/Unit/Service/InlineConfig/JsonLikeLexerTest.php
@@ -5,15 +5,15 @@
 namespace Aeliot\TodoRegistrar\Test\Unit\Service\InlineConfig;
 
 use Aeliot\TodoRegistrar\Dto\InlineConfig\Token;
-use Aeliot\TodoRegistrar\Service\InlineConfig\JsonLexer;
+use Aeliot\TodoRegistrar\Service\InlineConfig\JsonLikeLexer;
 use PHPUnit\Framework\Attributes\CoversClass;
 use PHPUnit\Framework\Attributes\DataProvider;
 use PHPUnit\Framework\Attributes\UsesClass;
 use PHPUnit\Framework\TestCase;
 
-#[CoversClass(JsonLexer::class)]
+#[CoversClass(JsonLikeLexer::class)]
 #[UsesClass(Token::class)]
-final class JsonLexerTest extends TestCase
+final class JsonLikeLexerTest extends TestCase
 {
     /**
      * @return iterable<array{0: array<int>, 1: string}>
@@ -23,27 +23,27 @@ public static function getDataForTestStructureMatch(): iterable
         yield [
             [
                 [
-                    't' => JsonLexer::T_CURLY_BRACES_OPEN,
+                    't' => JsonLikeLexer::T_CURLY_BRACES_OPEN,
                     'v' => '{',
                     'p' => 0,
                 ],
                 [
-                    't' => JsonLexer::T_KEY,
+                    't' => JsonLikeLexer::T_KEY,
                     'v' => 'key',
                     'p' => 1,
                 ],
                 [
-                    't' => JsonLexer::T_COLON,
+                    't' => JsonLikeLexer::T_COLON,
                     'v' => ':',
                     'p' => 4,
                 ],
                 [
-                    't' => JsonLexer::T_STRING,
+                    't' => JsonLikeLexer::T_STRING,
                     'v' => 'value',
                     'p' => 6,
                 ],
                 [
-                    't' => JsonLexer::T_CURLY_BRACES_CLOSE,
+                    't' => JsonLikeLexer::T_CURLY_BRACES_CLOSE,
                     'v' => '}',
                     'p' => 11,
                 ],
@@ -54,37 +54,37 @@ public static function getDataForTestStructureMatch(): iterable
         yield [
             [
                 [
-                    't' => JsonLexer::T_CURLY_BRACES_OPEN,
+                    't' => JsonLikeLexer::T_CURLY_BRACES_OPEN,
                     'v' => '{',
                     'p' => 0,
                 ],
                 [
-                    't' => JsonLexer::T_KEY,
+                    't' => JsonLikeLexer::T_KEY,
                     'v' => 'key',
                     'p' => 1,
                 ],
                 [
-                    't' => JsonLexer::T_COLON,
+                    't' => JsonLikeLexer::T_COLON,
                     'v' => ':',
                     'p' => 4,
                 ],
                 [
-                    't' => JsonLexer::T_SQUARE_BRACKET_OPEN,
+                    't' => JsonLikeLexer::T_SQUARE_BRACKET_OPEN,
                     'v' => '[',
                     'p' => 6,
                 ],
                 [
-                    't' => JsonLexer::T_STRING,
+                    't' => JsonLikeLexer::T_STRING,
                     'v' => 'value',
                     'p' => 7,
                 ],
                 [
-                    't' => JsonLexer::T_SQUARE_BRACKET_CLOSE,
+                    't' => JsonLikeLexer::T_SQUARE_BRACKET_CLOSE,
                     'v' => ']',
                     'p' => 12,
                 ],
                 [
-                    't' => JsonLexer::T_CURLY_BRACES_CLOSE,
+                    't' => JsonLikeLexer::T_CURLY_BRACES_CLOSE,
                     'v' => '}',
                     'p' => 13,
                 ],
@@ -95,37 +95,37 @@ public static function getDataForTestStructureMatch(): iterable
         yield [
             [
                 [
-                    't' => JsonLexer::T_CURLY_BRACES_OPEN,
+                    't' => JsonLikeLexer::T_CURLY_BRACES_OPEN,
                     'v' => '{',
                     'p' => 0,
                 ],
                 [
-                    't' => JsonLexer::T_KEY,
+                    't' => JsonLikeLexer::T_KEY,
                     'v' => 'key',
                     'p' => 3,
                 ],
                 [
-                    't' => JsonLexer::T_COLON,
+                    't' => JsonLikeLexer::T_COLON,
                     'v' => ':',
                     'p' => 6,
                 ],
                 [
-                    't' => JsonLexer::T_SQUARE_BRACKET_OPEN,
+                    't' => JsonLikeLexer::T_SQUARE_BRACKET_OPEN,
                     'v' => '[',
                     'p' => 8,
                 ],
                 [
-                    't' => JsonLexer::T_STRING,
+                    't' => JsonLikeLexer::T_STRING,
                     'v' => 'value',
                     'p' => 9,
                 ],
                 [
-                    't' => JsonLexer::T_SQUARE_BRACKET_CLOSE,
+                    't' => JsonLikeLexer::T_SQUARE_BRACKET_CLOSE,
                     'v' => ']',
                     'p' => 14,
                 ],
                 [
-                    't' => JsonLexer::T_CURLY_BRACES_CLOSE,
+                    't' => JsonLikeLexer::T_CURLY_BRACES_CLOSE,
                     'v' => '}',
                     'p' => 15,
                 ],
@@ -136,27 +136,27 @@ public static function getDataForTestStructureMatch(): iterable
         yield [
             [
                 [
-                    't' => JsonLexer::T_CURLY_BRACES_OPEN,
+                    't' => JsonLikeLexer::T_CURLY_BRACES_OPEN,
                     'v' => '{',
                     'p' => 0,
                 ],
                 [
-                    't' => JsonLexer::T_KEY,
+                    't' => JsonLikeLexer::T_KEY,
                     'v' => '_key',
                     'p' => 1,
                 ],
                 [
-                    't' => JsonLexer::T_COLON,
+                    't' => JsonLikeLexer::T_COLON,
                     'v' => ':',
                     'p' => 5,
                 ],
                 [
-                    't' => JsonLexer::T_STRING,
+                    't' => JsonLikeLexer::T_STRING,
                     'v' => 'value-parted',
                     'p' => 8,
                 ],
                 [
-                    't' => JsonLexer::T_CURLY_BRACES_CLOSE,
+                    't' => JsonLikeLexer::T_CURLY_BRACES_CLOSE,
                     'v' => '}',
                     'p' => 21,
                 ],
@@ -170,12 +170,12 @@ public static function getDataForTestStructureMatch(): iterable
      */
     public static function getDataForTestSymbolMatch(): iterable
     {
-        yield [JsonLexer::T_COMMA, ','];
-        yield [JsonLexer::T_COLON, ':'];
-        yield [JsonLexer::T_SQUARE_BRACKET_OPEN, '['];
-        yield [JsonLexer::T_SQUARE_BRACKET_CLOSE, ']'];
-        yield [JsonLexer::T_CURLY_BRACES_OPEN, '{'];
-        yield [JsonLexer::T_CURLY_BRACES_CLOSE, '}'];
+        yield [JsonLikeLexer::T_COMMA, ','];
+        yield [JsonLikeLexer::T_COLON, ':'];
+        yield [JsonLikeLexer::T_SQUARE_BRACKET_OPEN, '['];
+        yield [JsonLikeLexer::T_SQUARE_BRACKET_CLOSE, ']'];
+        yield [JsonLikeLexer::T_CURLY_BRACES_OPEN, '{'];
+        yield [JsonLikeLexer::T_CURLY_BRACES_CLOSE, '}'];
     }
 
     #[DataProvider('getDataForTestStructureMatch')]
@@ -187,7 +187,7 @@ public function testStructureMatch(array $expectedTokenValues, string $input): v
                 'v' => $token->getValue(),
                 'p' => $token->getPosition(),
             ],
-            iterator_to_array(new JsonLexer($input)),
+            iterator_to_array(new JsonLikeLexer($input)),
         );
 
         self::assertSame($expectedTokenValues, $actualTokenValues);
@@ -196,7 +196,7 @@ public function testStructureMatch(array $expectedTokenValues, string $input): v
     #[DataProvider('getDataForTestSymbolMatch')]
     public function testSymbolMatch(int $expectedType, string $input): void
     {
-        $lexer = new JsonLexer($input);
+        $lexer = new JsonLikeLexer($input);
         self::assertCount(1, $lexer);
 
         $token = $lexer->current();
diff --git a/tests/Unit/Service/TodoFactoryTest.php b/tests/Unit/Service/TodoFactoryTest.php
index e4849cd..284e83a 100644
--- a/tests/Unit/Service/TodoFactoryTest.php
+++ b/tests/Unit/Service/TodoFactoryTest.php
@@ -6,7 +6,7 @@
 
 use Aeliot\TodoRegistrar\Dto\Comment\CommentPart;
 use Aeliot\TodoRegistrar\Dto\Tag\TagMetadata;
-use Aeliot\TodoRegistrar\Service\InlineConfig\ArrayFromJsonLexerBuilder;
+use Aeliot\TodoRegistrar\Service\InlineConfig\ArrayFromJsonLikeLexerBuilder;
 use Aeliot\TodoRegistrar\Service\InlineConfig\ExtrasReader;
 use Aeliot\TodoRegistrar\Service\InlineConfig\InlineConfigFactory;
 use Aeliot\TodoRegistrar\Service\TodoFactory;
@@ -52,6 +52,6 @@ public function testMappingOfAssigneeWithoutTagMetadata(): void
 
     private function createTodoFactory(): TodoFactory
     {
-        return new TodoFactory(new InlineConfigFactory(), new ExtrasReader(new ArrayFromJsonLexerBuilder()));
+        return new TodoFactory(new InlineConfigFactory(), new ExtrasReader(new ArrayFromJsonLikeLexerBuilder()));
     }
 }

From d327cce5ecd15f10f3cd0b5d0e6b0d28d9098664 Mon Sep 17 00:00:00 2001
From: Aeliot <5785276@gmail.com>
Date: Mon, 1 Jul 2024 15:28:15 +0300
Subject: [PATCH 07/28] Base implementation of linking of related issues

---
 src/Service/Registrar/JIRA/JiraRegistrar.php  | 37 +++++++++++++++++--
 .../Registrar/JIRA/JiraRegistrarFactory.php   | 11 +-----
 src/Service/Registrar/JIRA/ServiceFactory.php | 36 ++++++++++++++++++
 3 files changed, 71 insertions(+), 13 deletions(-)
 create mode 100644 src/Service/Registrar/JIRA/ServiceFactory.php

diff --git a/src/Service/Registrar/JIRA/JiraRegistrar.php b/src/Service/Registrar/JIRA/JiraRegistrar.php
index c7f7546..5a6c64a 100644
--- a/src/Service/Registrar/JIRA/JiraRegistrar.php
+++ b/src/Service/Registrar/JIRA/JiraRegistrar.php
@@ -5,14 +5,15 @@
 namespace Aeliot\TodoRegistrar\Service\Registrar\JIRA;
 
 use Aeliot\TodoRegistrar\Dto\Registrar\Todo;
+use Aeliot\TodoRegistrar\Exception\InvalidInlineConfigFormatException;
 use Aeliot\TodoRegistrar\Service\Registrar\RegistrarInterface;
-use JiraRestApi\Issue\IssueService;
+use JiraRestApi\IssueLink\IssueLink;
 
 class JiraRegistrar implements RegistrarInterface
 {
     public function __construct(
         private IssueFieldFactory $issueFieldFactory,
-        private IssueService $issueService,
+        private ServiceFactory $serviceFactory,
     ) {
     }
 
@@ -25,6 +26,34 @@ public function register(Todo $todo): string
     {
         $issueField = $this->issueFieldFactory->create($todo);
 
-        return $this->issueService->create($issueField)->key;
+        $issueKey = $this->serviceFactory->createIssueService()->create($issueField)->key;
+        $this->registerLinks($issueKey, $todo);
+
+        return $issueKey;
+    }
+
+    private function registerLinks(string $inwardIssueKey, Todo $todo): void
+    {
+        $linkedIssues = $todo->getInlineConfig()['linked_issues'] ?? [];
+        if (!$linkedIssues) {
+            return;
+        }
+
+        if (!array_is_list($linkedIssues)
+            || array_reduce($linkedIssues, static fn (mixed $x): int => (int) !is_string($x), $linkedIssues, 0) > 0
+        ) {
+            throw new InvalidInlineConfigFormatException('List of liked issues must be indexed array of strings');
+        }
+
+        $service = $this->serviceFactory->createIssueLinkService();
+
+        foreach ($linkedIssues as $linkedIssue) {
+            $issueLink = (new IssueLink())
+                ->setInwardIssue($inwardIssueKey)
+                ->setOutwardIssue($linkedIssue)
+                ->setLinkTypeName('Relates');
+
+            $service->addIssueLink($issueLink);
+        }
     }
-}
\ No newline at end of file
+}
diff --git a/src/Service/Registrar/JIRA/JiraRegistrarFactory.php b/src/Service/Registrar/JIRA/JiraRegistrarFactory.php
index 1f5cc36..3ea6402 100644
--- a/src/Service/Registrar/JIRA/JiraRegistrarFactory.php
+++ b/src/Service/Registrar/JIRA/JiraRegistrarFactory.php
@@ -16,14 +16,7 @@ public function create(array $config): RegistrarInterface
         $issueConfig = ($config['issue'] ?? []) + ['projectKey' => $config['projectKey']];
         return new JiraRegistrar(
             new IssueFieldFactory(new IssueConfig($issueConfig)),
-            $this->createIssueService($config['service']),
+            new ServiceFactory($config['service']),
         );
     }
-
-    private function createIssueService(array $config): IssueService
-    {
-        $serviceConfig = (new IssueServiceArrayConfigPreparer())->prepare($config);
-
-        return new IssueService(new ArrayConfiguration($serviceConfig));
-    }
-}
\ No newline at end of file
+}
diff --git a/src/Service/Registrar/JIRA/ServiceFactory.php b/src/Service/Registrar/JIRA/ServiceFactory.php
new file mode 100644
index 0000000..2d8f7e3
--- /dev/null
+++ b/src/Service/Registrar/JIRA/ServiceFactory.php
@@ -0,0 +1,36 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Aeliot\TodoRegistrar\Service\Registrar\JIRA;
+
+use JiraRestApi\Configuration\ArrayConfiguration;
+use JiraRestApi\Issue\IssueService;
+use JiraRestApi\IssueLink\IssueLinkService;
+
+class ServiceFactory
+{
+    /**
+     * @param array<string,mixed> $config
+     */
+    public function __construct(private array $config)
+    {
+    }
+
+    public function createIssueLinkService(): IssueLinkService
+    {
+        return new IssueLinkService($this->getServiceConfig());
+    }
+
+    public function createIssueService(): IssueService
+    {
+        return new IssueService($this->getServiceConfig());
+    }
+
+    private function getServiceConfig(): ArrayConfiguration
+    {
+        $serviceConfig = (new IssueServiceArrayConfigPreparer())->prepare($this->config);
+
+        return new ArrayConfiguration($serviceConfig);
+    }
+}

From 70d67b2f35a776106595b762b3208ff1161f6e94 Mon Sep 17 00:00:00 2001
From: Aeliot <5785276@gmail.com>
Date: Mon, 1 Jul 2024 19:17:35 +0300
Subject: [PATCH 08/28] Add support of different issue link types

---
 .../JIRANotSupportedLinkTypeException.php     |  13 ++
 .../Registrar/JIRA/IssueLinkRegistrar.php     |  43 ++++++
 .../Registrar/JIRA/IssueLinkTypeProvider.php  |  68 +++++++++
 src/Service/Registrar/JIRA/JiraRegistrar.php  |  30 +---
 .../Registrar/JIRA/JiraRegistrarFactory.php   |  15 +-
 .../Registrar/JIRA/LinkedIssueNormalizer.php  |  53 +++++++
 .../JIRA/IssueLinkTypeProviderTest.php        | 141 ++++++++++++++++++
 tests/fixtures/jira_issue_link_types.json     | 109 ++++++++++++++
 8 files changed, 441 insertions(+), 31 deletions(-)
 create mode 100644 src/Service/InlineConfig/JIRANotSupportedLinkTypeException.php
 create mode 100644 src/Service/Registrar/JIRA/IssueLinkRegistrar.php
 create mode 100644 src/Service/Registrar/JIRA/IssueLinkTypeProvider.php
 create mode 100644 src/Service/Registrar/JIRA/LinkedIssueNormalizer.php
 create mode 100644 tests/Unit/Service/Registrar/JIRA/IssueLinkTypeProviderTest.php
 create mode 100644 tests/fixtures/jira_issue_link_types.json

diff --git a/src/Service/InlineConfig/JIRANotSupportedLinkTypeException.php b/src/Service/InlineConfig/JIRANotSupportedLinkTypeException.php
new file mode 100644
index 0000000..def0ed6
--- /dev/null
+++ b/src/Service/InlineConfig/JIRANotSupportedLinkTypeException.php
@@ -0,0 +1,13 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Aeliot\TodoRegistrar\Service\InlineConfig;
+
+final class JIRANotSupportedLinkTypeException extends \DomainException
+{
+    public function __construct(string $alias)
+    {
+        parent::__construct(sprintf('"%s" is not supported type of issue link for JIRA', $alias));
+    }
+}
diff --git a/src/Service/Registrar/JIRA/IssueLinkRegistrar.php b/src/Service/Registrar/JIRA/IssueLinkRegistrar.php
new file mode 100644
index 0000000..c024876
--- /dev/null
+++ b/src/Service/Registrar/JIRA/IssueLinkRegistrar.php
@@ -0,0 +1,43 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Aeliot\TodoRegistrar\Service\Registrar\JIRA;
+
+use Aeliot\TodoRegistrar\Dto\Registrar\Todo;
+use JiraRestApi\IssueLink\IssueLink;
+
+final class IssueLinkRegistrar
+{
+    public function __construct(
+        private LinkedIssueNormalizer $linkedIssueNormalizer,
+        private ServiceFactory $serviceFactory,
+    ) {
+    }
+
+    public function registerLinks(string $inwardIssueKey, Todo $todo): void
+    {
+        $linkedIssues = $todo->getInlineConfig()['linked_issues'] ?? [];
+        if (!$linkedIssues) {
+            return;
+        }
+
+        $linkedIssues = $this->linkedIssueNormalizer->normalizeLinkedIssues($linkedIssues);
+        $service = $this->serviceFactory->createIssueLinkService();
+
+        foreach ($linkedIssues as $issueLinkType => $iterateLinkedIssuesGroup) {
+            foreach ($iterateLinkedIssuesGroup as $linkedIssue) {
+                $issueLink = $this->createIssueLink($inwardIssueKey, $linkedIssue, $issueLinkType);
+                $service->addIssueLink($issueLink);
+            }
+        }
+    }
+
+    private function createIssueLink(string $inwardIssueKey, string $linkedIssue, string $issueLinkType): IssueLink
+    {
+        return (new IssueLink())
+            ->setInwardIssue($inwardIssueKey)
+            ->setOutwardIssue($linkedIssue)
+            ->setLinkTypeName($issueLinkType);
+    }
+}
diff --git a/src/Service/Registrar/JIRA/IssueLinkTypeProvider.php b/src/Service/Registrar/JIRA/IssueLinkTypeProvider.php
new file mode 100644
index 0000000..9efae19
--- /dev/null
+++ b/src/Service/Registrar/JIRA/IssueLinkTypeProvider.php
@@ -0,0 +1,68 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Aeliot\TodoRegistrar\Service\Registrar\JIRA;
+
+use Aeliot\TodoRegistrar\Service\InlineConfig\JIRANotSupportedLinkTypeException;
+use JiraRestApi\IssueLink\IssueLinkType;
+
+final class IssueLinkTypeProvider
+{
+    /**
+     * @var IssueLinkType[]|null
+     */
+    private ?array $supportedLinkTypes = null;
+
+    public function __construct(private readonly ServiceFactory $serviceFactory)
+    {
+    }
+
+    public function getLinkType(string $alias): IssueLinkType
+    {
+        foreach ($this->getSupportedLinkTypes() as $issueLinkType) {
+            if ($this->isMatch($issueLinkType, $alias)) {
+                return $issueLinkType;
+            }
+        }
+
+        throw new JIRANotSupportedLinkTypeException($alias);
+    }
+
+    /**
+     * @return IssueLinkType[]
+     */
+    private function getSupportedLinkTypes(): array
+    {
+        if (null === $this->supportedLinkTypes) {
+            /** @var \ArrayObject<int,IssueLinkType>|IssueLinkType[] $issueLinkTypes */
+            $issueLinkTypes = $this->serviceFactory->createIssueLinkService()->getIssueLinkTypes();
+            if ($issueLinkTypes instanceof \ArrayObject) {
+                $issueLinkTypes = $issueLinkTypes->getArrayCopy();
+            }
+            if ($issueLinkTypes instanceof \IteratorAggregate) {
+                $issueLinkTypes = $issueLinkTypes->getIterator();
+            }
+            $this->supportedLinkTypes = $issueLinkTypes instanceof \Traversable
+                ? iterator_to_array($issueLinkTypes)
+                : $issueLinkTypes;
+        }
+
+        return $this->supportedLinkTypes;
+    }
+
+    private function isMatch(IssueLinkType $lintType, string $alias): bool
+    {
+        $quotedAlis = preg_quote($alias, '/');
+        $regex1 = sprintf('/^%s/i', str_replace('_', '[^0-9a-z]+', $quotedAlis));
+        $regex2 = sprintf('/^%s/i', str_replace('_', '[^0-9a-z]+', str_replace('_to_', ' -> ', $quotedAlis)));
+
+        return $alias === $lintType->name
+            || $alias === $lintType->inward
+            || $alias === $lintType->outward
+            || preg_match($regex1, $lintType->name)
+            || preg_match($regex1, $lintType->inward)
+            || preg_match($regex1, $lintType->outward)
+            || preg_match($regex2, $lintType->name);
+    }
+}
diff --git a/src/Service/Registrar/JIRA/JiraRegistrar.php b/src/Service/Registrar/JIRA/JiraRegistrar.php
index 5a6c64a..568dca3 100644
--- a/src/Service/Registrar/JIRA/JiraRegistrar.php
+++ b/src/Service/Registrar/JIRA/JiraRegistrar.php
@@ -5,15 +5,14 @@
 namespace Aeliot\TodoRegistrar\Service\Registrar\JIRA;
 
 use Aeliot\TodoRegistrar\Dto\Registrar\Todo;
-use Aeliot\TodoRegistrar\Exception\InvalidInlineConfigFormatException;
 use Aeliot\TodoRegistrar\Service\Registrar\RegistrarInterface;
-use JiraRestApi\IssueLink\IssueLink;
 
 class JiraRegistrar implements RegistrarInterface
 {
     public function __construct(
         private IssueFieldFactory $issueFieldFactory,
         private ServiceFactory $serviceFactory,
+        private IssueLinkRegistrar $issueLinkRegistrar,
     ) {
     }
 
@@ -27,33 +26,8 @@ public function register(Todo $todo): string
         $issueField = $this->issueFieldFactory->create($todo);
 
         $issueKey = $this->serviceFactory->createIssueService()->create($issueField)->key;
-        $this->registerLinks($issueKey, $todo);
+        $this->issueLinkRegistrar->registerLinks($issueKey, $todo);
 
         return $issueKey;
     }
-
-    private function registerLinks(string $inwardIssueKey, Todo $todo): void
-    {
-        $linkedIssues = $todo->getInlineConfig()['linked_issues'] ?? [];
-        if (!$linkedIssues) {
-            return;
-        }
-
-        if (!array_is_list($linkedIssues)
-            || array_reduce($linkedIssues, static fn (mixed $x): int => (int) !is_string($x), $linkedIssues, 0) > 0
-        ) {
-            throw new InvalidInlineConfigFormatException('List of liked issues must be indexed array of strings');
-        }
-
-        $service = $this->serviceFactory->createIssueLinkService();
-
-        foreach ($linkedIssues as $linkedIssue) {
-            $issueLink = (new IssueLink())
-                ->setInwardIssue($inwardIssueKey)
-                ->setOutwardIssue($linkedIssue)
-                ->setLinkTypeName('Relates');
-
-            $service->addIssueLink($issueLink);
-        }
-    }
 }
diff --git a/src/Service/Registrar/JIRA/JiraRegistrarFactory.php b/src/Service/Registrar/JIRA/JiraRegistrarFactory.php
index 3ea6402..da18694 100644
--- a/src/Service/Registrar/JIRA/JiraRegistrarFactory.php
+++ b/src/Service/Registrar/JIRA/JiraRegistrarFactory.php
@@ -6,17 +6,26 @@
 
 use Aeliot\TodoRegistrar\Service\Registrar\RegistrarFactoryInterface;
 use Aeliot\TodoRegistrar\Service\Registrar\RegistrarInterface;
-use JiraRestApi\Configuration\ArrayConfiguration;
-use JiraRestApi\Issue\IssueService;
 
 class JiraRegistrarFactory implements RegistrarFactoryInterface
 {
     public function create(array $config): RegistrarInterface
     {
         $issueConfig = ($config['issue'] ?? []) + ['projectKey' => $config['projectKey']];
+        $defaultIssueLinkType = $config['issueLinkType'] ?? 'Relates';
+
+        $serviceFactory = new ServiceFactory($config['service']);
+
         return new JiraRegistrar(
             new IssueFieldFactory(new IssueConfig($issueConfig)),
-            new ServiceFactory($config['service']),
+            $serviceFactory,
+            new IssueLinkRegistrar(
+                new LinkedIssueNormalizer(
+                    $defaultIssueLinkType,
+                    new IssueLinkTypeProvider($serviceFactory)
+                ),
+                $serviceFactory,
+            ),
         );
     }
 }
diff --git a/src/Service/Registrar/JIRA/LinkedIssueNormalizer.php b/src/Service/Registrar/JIRA/LinkedIssueNormalizer.php
new file mode 100644
index 0000000..9afa06e
--- /dev/null
+++ b/src/Service/Registrar/JIRA/LinkedIssueNormalizer.php
@@ -0,0 +1,53 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Aeliot\TodoRegistrar\Service\Registrar\JIRA;
+
+use Aeliot\TodoRegistrar\Exception\InvalidInlineConfigFormatException;
+use Aeliot\TodoRegistrar\Service\InlineConfig\JIRANotSupportedLinkTypeException;
+use JiraRestApi\IssueLink\IssueLinkType;
+
+final class LinkedIssueNormalizer
+{
+    public function __construct(
+        private string $defaultIssueLinkType,
+        private IssueLinkTypeProvider $issueLinkTypeProvider,
+    ) {
+    }
+
+    /**
+     * @param array<string>|array<string,array<string>> $linkedIssues
+     *
+     * @return array<string,array<string>>
+     */
+    public function normalizeLinkedIssues(array $linkedIssues): array
+    {
+        if (array_is_list($linkedIssues)) {
+            $linkedIssues = [$this->defaultIssueLinkType => $linkedIssues];
+        }
+
+        $result = [];
+
+        foreach ($linkedIssues as $issueLinkTypeAlias => $issueKeys) {
+            if (!array_is_list($issueKeys)) {
+                throw new InvalidInlineConfigFormatException('List of liked issues must be indexed array of strings');
+            }
+            $issueLinkType = $this->getIssueLinkType($issueLinkTypeAlias);
+            $result[$issueLinkType->name] = $issueKeys;
+        }
+
+        return $result;
+    }
+
+    private function getIssueLinkType(string $alias): IssueLinkType
+    {
+        try {
+            $issueLinkType = $this->issueLinkTypeProvider->getLinkType($alias);
+        } catch (JIRANotSupportedLinkTypeException) {
+            throw new InvalidInlineConfigFormatException(sprintf('Not supported issue link type "%s"', $alias));
+        }
+
+        return $issueLinkType;
+    }
+}
diff --git a/tests/Unit/Service/Registrar/JIRA/IssueLinkTypeProviderTest.php b/tests/Unit/Service/Registrar/JIRA/IssueLinkTypeProviderTest.php
new file mode 100644
index 0000000..f9b459e
--- /dev/null
+++ b/tests/Unit/Service/Registrar/JIRA/IssueLinkTypeProviderTest.php
@@ -0,0 +1,141 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Aeliot\TodoRegistrar\Test\Unit\Service\Registrar\JIRA;
+
+use Aeliot\TodoRegistrar\Service\Registrar\JIRA\IssueLinkTypeProvider;
+use Aeliot\TodoRegistrar\Service\Registrar\JIRA\ServiceFactory;
+use JiraRestApi\IssueLink\IssueLinkService;
+use JiraRestApi\IssueLink\IssueLinkType;
+use PHPUnit\Framework\Attributes\CoversClass;
+use PHPUnit\Framework\Attributes\DataProvider;
+use PHPUnit\Framework\TestCase;
+
+#[CoversClass(IssueLinkTypeProvider::class)]
+final class IssueLinkTypeProviderTest extends TestCase
+{
+    /**
+     * @return iterable<array{0: string, 1: string}>
+     */
+    public static function getDataForTestPositiveFlow(): iterable
+    {
+        yield ['10000', 'Blocks'];
+        yield ['10000', 'is blocked by'];
+        yield ['10000', 'is_blocked_by'];
+        yield ['10000', 'blocks'];
+
+        yield ['10001', 'Cloners'];
+        yield ['10001', 'is cloned by'];
+        yield ['10001', 'is_cloned_by'];
+        yield ['10001', 'clones'];
+
+        yield ['10002', 'Duplicate'];
+        yield ['10002', 'is duplicated by'];
+        yield ['10002', 'is_duplicated_by'];
+        yield ['10002', 'duplicates'];
+
+        yield ['10300', 'End -> End [Gantt]'];
+        yield ['10300', 'end_end_gantt_'];
+        yield ['10300', 'end_to_end_gantt_'];
+        yield ['10300', 'has to be finished together with'];
+        yield ['10300', 'has_to_be_finished_together_with'];
+
+        yield ['10604', 'Gantt End to End'];
+        yield ['10604', 'gantt_end_to_end'];
+
+        yield ['10603', 'Gantt End to Start'];
+        yield ['10603', 'gantt_end_to_start'];
+        yield ['10603', 'has to be done after'];
+        yield ['10603', 'has_to_be_done_after'];
+        yield ['10603', 'has to be done before'];
+        yield ['10603', 'has_to_be_done_before'];
+
+        yield ['10601', 'Gantt Start to End'];
+        yield ['10601', 'gantt_start_to_end'];
+        yield ['10601', 'earliest end is start of'];
+        yield ['10601', 'earliest_end_is_start_of'];
+        yield ['10601', 'start is earliest end of'];
+        yield ['10601', 'start_is_earliest_end_of'];
+
+        yield ['10602', 'Gantt Start to Start'];
+        yield ['10602', 'gantt_start_to_start'];
+        yield ['10602', 'has to be started together with'];
+        yield ['10602', 'has_to_be_started_together_with'];
+
+        yield ['10303', 'Hierarchy [Gantt]'];
+        yield ['10303', 'Hierarchy_Gantt_'];
+        yield ['10303', 'is parent of'];
+        yield ['10303', 'is_parent_of'];
+        yield ['10303', 'is child of'];
+        yield ['10303', 'is_child_of'];
+
+        yield ['10500', 'Issue split'];
+        yield ['10500', 'issue_split'];
+        yield ['10500', 'split from'];
+        yield ['10500', 'split_from'];
+        yield ['10500', 'split to'];
+        yield ['10500', 'split_to'];
+
+        yield ['10600', 'Parent-Child'];
+
+        yield ['10200', 'Problem/Incident'];
+        yield ['10200', 'problem_incident'];
+        yield ['10200', 'is caused by'];
+        yield ['10200', 'is_caused_by'];
+        yield ['10200', 'causes'];
+
+        yield ['10003', 'Relates'];
+        yield ['10003', 'relates to'];
+        yield ['10003', 'relates_to'];
+
+        yield ['10301', 'Start -> End [Gantt]'];
+        yield ['10301', 'start_to_end_gantt_'];
+        yield ['10301', 'start_end_gantt_'];
+
+        yield ['10302', 'Start -> Start [Gantt]'];
+        yield ['10302', 'start_to_start_gantt_'];
+        yield ['10302', 'start_start_gantt_'];
+    }
+
+    #[DataProvider('getDataForTestPositiveFlow')]
+    public function testPositiveFlow(string $expectedId, string $alias): void
+    {
+        $provider = $this->createLinkTypeProvider();
+        $issueLinkType = $provider->getLinkType($alias);
+
+        self::assertSame($expectedId, $issueLinkType->id);
+    }
+
+
+    private function createLinkTypeProvider(): IssueLinkTypeProvider
+    {
+        $service = $this->createMock(IssueLinkService::class);
+        $service->method('getIssueLinkTypes')->willReturn($this->createIssueLinkTypes());
+        $serviceFactory = $this->createMock(ServiceFactory::class);
+        $serviceFactory->method('createIssueLinkService')->willReturn($service);
+
+        return new IssueLinkTypeProvider($serviceFactory);
+    }
+
+    /**
+     * @return \ArrayObject<int,IssueLinkType>
+     */
+    private function createIssueLinkTypes(): \ArrayObject
+    {
+        $linkTypes = [];
+        $contents = file_get_contents(__DIR__ . '/../../../../fixtures/jira_issue_link_types.json');
+        $data = json_decode($contents, true, 5, JSON_THROW_ON_ERROR);
+        foreach ($data['issueLinkTypes'] as $datum) {
+            $linkType = new IssueLinkType();
+            $linkType->id = $datum['id'];
+            $linkType->name = $datum['name'];
+            $linkType->inward = $datum['inward'];
+            $linkType->outward = $datum['outward'];
+            $linkType->self = $datum['self'];
+            $linkTypes[] = $linkType;
+        }
+
+        return new \ArrayObject($linkTypes);
+    }
+}
diff --git a/tests/fixtures/jira_issue_link_types.json b/tests/fixtures/jira_issue_link_types.json
new file mode 100644
index 0000000..2ec4b07
--- /dev/null
+++ b/tests/fixtures/jira_issue_link_types.json
@@ -0,0 +1,109 @@
+{
+    "issueLinkTypes": [
+        {
+            "id": "10000",
+            "name": "Blocks",
+            "inward": "is blocked by",
+            "outward": "blocks",
+            "self": "https://jira.work/rest/api/2/issueLinkType/10000"
+        },
+        {
+            "id": "10001",
+            "name": "Cloners",
+            "inward": "is cloned by",
+            "outward": "clones",
+            "self": "https://jira.work/rest/api/2/issueLinkType/10001"
+        },
+        {
+            "id": "10002",
+            "name": "Duplicate",
+            "inward": "is duplicated by",
+            "outward": "duplicates",
+            "self": "https://jira.work/rest/api/2/issueLinkType/10002"
+        },
+        {
+            "id": "10300",
+            "name": "End -> End [Gantt]",
+            "inward": "has to be finished together with",
+            "outward": "has to be finished together with",
+            "self": "https://jira.work/rest/api/2/issueLinkType/10300"
+        },
+        {
+            "id": "10604",
+            "name": "Gantt End to End",
+            "inward": "has to be finished together with",
+            "outward": "has to be finished together with",
+            "self": "https://jira.work/rest/api/2/issueLinkType/10604"
+        },
+        {
+            "id": "10603",
+            "name": "Gantt End to Start",
+            "inward": "has to be done after",
+            "outward": "has to be done before",
+            "self": "https://jira.work/rest/api/2/issueLinkType/10603"
+        },
+        {
+            "id": "10601",
+            "name": "Gantt Start to End",
+            "inward": "earliest end is start of",
+            "outward": "start is earliest end of",
+            "self": "https://jira.work/rest/api/2/issueLinkType/10601"
+        },
+        {
+            "id": "10602",
+            "name": "Gantt Start to Start",
+            "inward": "has to be started together with",
+            "outward": "has to be started together with",
+            "self": "https://jira.work/rest/api/2/issueLinkType/10602"
+        },
+        {
+            "id": "10303",
+            "name": "Hierarchy [Gantt]",
+            "inward": "is parent of",
+            "outward": "is child of",
+            "self": "https://jira.work/rest/api/2/issueLinkType/10303"
+        },
+        {
+            "id": "10500",
+            "name": "Issue split",
+            "inward": "split from",
+            "outward": "split to",
+            "self": "https://jira.work/rest/api/2/issueLinkType/10500"
+        },
+        {
+            "id": "10600",
+            "name": "Parent-Child",
+            "inward": "is child of",
+            "outward": "is parent of",
+            "self": "https://jira.work/rest/api/2/issueLinkType/10600"
+        },
+        {
+            "id": "10200",
+            "name": "Problem/Incident",
+            "inward": "is caused by",
+            "outward": "causes",
+            "self": "https://jira.work/rest/api/2/issueLinkType/10200"
+        },
+        {
+            "id": "10003",
+            "name": "Relates",
+            "inward": "relates to",
+            "outward": "relates to",
+            "self": "https://jira.work/rest/api/2/issueLinkType/10003"
+        },
+        {
+            "id": "10301",
+            "name": "Start -> End [Gantt]",
+            "inward": "start is earliest end of",
+            "outward": "earliest end is start of",
+            "self": "https://jira.work/rest/api/2/issueLinkType/10301"
+        },
+        {
+            "id": "10302",
+            "name": "Start -> Start [Gantt]",
+            "inward": "has to be started together with",
+            "outward": "has to be started together with",
+            "self": "https://jira.work/rest/api/2/issueLinkType/10302"
+        }
+    ]
+}

From 7ef8e13220cc5bfb469cefb88f0cf1be133ec953 Mon Sep 17 00:00:00 2001
From: Aeliot <5785276@gmail.com>
Date: Mon, 1 Jul 2024 19:18:09 +0300
Subject: [PATCH 09/28] Config support of xdebug by PHPStorm

---
 docker-compose.yml | 8 ++++++++
 1 file changed, 8 insertions(+)

diff --git a/docker-compose.yml b/docker-compose.yml
index 289e7bb..86c2882 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -4,5 +4,13 @@ services:
             context: ./
             dockerfile: .docker/php/Dockerfile
         entrypoint: [ '/app/docker-entrypoint.sh' ]
+        environment:
+            PHP_IDE_CONFIG: serverName=todo-registrar.local
+            XDEBUG_CONFIG: "client_host=host.docker.internal"
+            XDEBUG_ENABLE: 1
+        expose:
+            - '9003'
+        extra_hosts:
+            - "host.docker.internal:host-gateway"
         volumes:
             - .:/app

From 2a6d831f88fc6fe3a0a15aed1e63b94e489279fb Mon Sep 17 00:00:00 2001
From: Aeliot <5785276@gmail.com>
Date: Tue, 2 Jul 2024 08:57:09 +0300
Subject: [PATCH 10/28] Test and fix parsing of EXTRAS

---
 .../ArrayFromJsonLikeLexerBuilder.php         |  5 +-
 .../Service/InlineConfig/ExtrasReaderTest.php | 99 ++++++++++++++++++-
 2 files changed, 100 insertions(+), 4 deletions(-)

diff --git a/src/Service/InlineConfig/ArrayFromJsonLikeLexerBuilder.php b/src/Service/InlineConfig/ArrayFromJsonLikeLexerBuilder.php
index 48e7490..3d4a420 100644
--- a/src/Service/InlineConfig/ArrayFromJsonLikeLexerBuilder.php
+++ b/src/Service/InlineConfig/ArrayFromJsonLikeLexerBuilder.php
@@ -28,7 +28,7 @@ public function build(JsonLikeLexer $lexer): array
 
         $this->populate($lexer, $collection, $level);
 
-        if (0 !== $level) {
+        if (0 !== $level || $lexer->valid()) {
             throw new InvalidInlineConfigFormatException('Unexpected end of tags list');
         }
 
@@ -122,7 +122,6 @@ private function populate(JsonLikeLexer $lexer, CollectionInterface $collection,
                 $childCollection = new NamedCollection();
                 $this->populate($lexer, $childCollection, $level);
                 $this->addValue($collection, $key, $childCollection);
-                $key = null;
             } elseif (JsonLikeLexer::T_SQUARE_BRACKET_OPEN === $token->getType()) {
                 ++$level;
                 $childCollection = new IndexedCollection();
@@ -130,7 +129,7 @@ private function populate(JsonLikeLexer $lexer, CollectionInterface $collection,
                 $this->addValue($collection, $key, $childCollection);
             } elseif ($this->isCloseCollection($token)) {
                 --$level;
-                $key = null;
+                break;
             } elseif (!$this->isSkippable($token)) {
                 throw new InvalidInlineConfigFormatException('Unexpected token detected');
             }
diff --git a/tests/Unit/Service/InlineConfig/ExtrasReaderTest.php b/tests/Unit/Service/InlineConfig/ExtrasReaderTest.php
index 001b4bd..6c8c977 100644
--- a/tests/Unit/Service/InlineConfig/ExtrasReaderTest.php
+++ b/tests/Unit/Service/InlineConfig/ExtrasReaderTest.php
@@ -28,7 +28,104 @@ final class ExtrasReaderTest extends TestCase
      */
     public static function getDataForTestPositiveFlow(): iterable
     {
-        yield [['key' => ['value']], '{EXTRAS:{key:[value]}}'];
+        yield 'simple value' => [
+            ['key' => 'value'],
+            '{EXTRAS:{key:value}}',
+        ];
+
+        yield 'indexed array with one element as value' => [
+            ['key' => ['value']],
+            '{EXTRAS:{key:[value]}}',
+        ];
+
+        yield 'indexed array with two elements as value' => [
+            ['key' => ['value1', 'value2']],
+            '{EXTRAS:{key:[value1, value2]}}',
+        ];
+
+        yield 'indexed array with two elements on second level' => [
+            ['level_1' => ['level_2' => ['v1', 'v2']]],
+            '{EXTRAS:{level_1:{level_2:[v1,v2]}}}',
+        ];
+
+        yield 'indexed array with two elements on third level' => [
+            ['level_1' => ['level_2' => ['level_3' => ['v1', 'v2']]]],
+            '{EXTRAS:{level_1:{level_2:{level_3:[v1,v2]}}}}',
+        ];
+
+        yield 'two keys with simple values' => [
+            ['key1' => 'value1', 'key2' => 'value2'],
+            '{EXTRAS:{key1:value1,key2:value2}}',
+        ];
+
+        yield 'some complex collection' => [
+            ['level_1' => ['level_2' => ['level_3' => ['v1', 'v2'], 'level_3p2' => 'v3']]],
+            '{EXTRAS:{level_1:{level_2:{level_3:[v1,v2],level_3p2: v3}}}}',
+        ];
+
+        yield 'some complex collection with multi-line formatting' => [
+            ['level_1' => ['level_2' => ['level_3' => ['v1', 'v2'], 'level_3p2' => 'v3']]],
+            <<<COMMENT
+            {
+                EXTRAS: {
+                    level_1: {
+                        level_2: {
+                            level_3: [v1,v2],
+                            level_3p2: v3,
+                        }
+                    }
+                }
+            }
+            COMMENT,
+        ];
+
+        yield 'strange indents and spaces, but nevertheless it may be for some reason' => [
+            [
+                'level_1' => [
+                    'level_2' => [
+                        'level_3' => ['v1', 'v2'],
+                        'level_3p2' => 'v3',
+                    ],
+                    'level_2p2' => 'v4',
+                ],
+                'level_1p2' => 'v5',
+            ],
+            <<<COMMENT
+            {
+                EXTRAS
+                :
+                 {
+                    level_1
+                    :
+                     {
+                        level_2
+                        :
+                         {
+                            level_3
+                            : 
+                            [
+                                v1
+                                ,
+                                v2
+                            ]
+                            ,
+                            level_3p2 
+                            : 
+                            v3,
+                        }
+                        ,
+                            level_2p2 
+                            : 
+                            v4
+                    }
+                    ,
+                        level_1p2 
+                        : 
+                        v5
+                }
+            }
+            COMMENT,
+        ];
     }
 
     #[DataProvider('getDataForTestPositiveFlow')]

From 9ddaf2b4eb27e34a410a6663d27d1daada8c8191 Mon Sep 17 00:00:00 2001
From: Aeliot <5785276@gmail.com>
Date: Tue, 2 Jul 2024 18:58:17 +0500
Subject: [PATCH 11/28] Config tests metadata

---
 tests/Unit/Service/CommentRegistrarTest.php | 12 +++++++++++-
 tests/Unit/Service/TodoFactoryTest.php      | 11 +++++++++++
 2 files changed, 22 insertions(+), 1 deletion(-)

diff --git a/tests/Unit/Service/CommentRegistrarTest.php b/tests/Unit/Service/CommentRegistrarTest.php
index 1c49e3e..14d06e5 100644
--- a/tests/Unit/Service/CommentRegistrarTest.php
+++ b/tests/Unit/Service/CommentRegistrarTest.php
@@ -6,6 +6,9 @@
 
 use Aeliot\TodoRegistrar\Dto\Comment\CommentPart;
 use Aeliot\TodoRegistrar\Dto\Comment\CommentParts;
+use Aeliot\TodoRegistrar\Dto\InlineConfig\IndexedCollection;
+use Aeliot\TodoRegistrar\Dto\InlineConfig\NamedCollection;
+use Aeliot\TodoRegistrar\Dto\InlineConfig\Token;
 use Aeliot\TodoRegistrar\Dto\Registrar\Todo;
 use Aeliot\TodoRegistrar\Dto\Tag\TagMetadata;
 use Aeliot\TodoRegistrar\Service\Comment\Detector as CommentDetector;
@@ -14,6 +17,7 @@
 use Aeliot\TodoRegistrar\Service\InlineConfig\ArrayFromJsonLikeLexerBuilder;
 use Aeliot\TodoRegistrar\Service\InlineConfig\ExtrasReader;
 use Aeliot\TodoRegistrar\Service\InlineConfig\InlineConfigFactory;
+use Aeliot\TodoRegistrar\Service\InlineConfig\JsonLikeLexer;
 use Aeliot\TodoRegistrar\Service\Registrar\RegistrarInterface;
 use Aeliot\TodoRegistrar\Service\TodoFactory;
 use PHPUnit\Framework\Attributes\CoversClass;
@@ -22,11 +26,17 @@
 use PHPUnit\Framework\TestCase;
 
 #[CoversClass(CommentRegistrar::class)]
+#[UsesClass(ArrayFromJsonLikeLexerBuilder::class)]
 #[UsesClass(CommentPart::class)]
 #[UsesClass(CommentParts::class)]
-#[UsesClass(Todo::class)]
+#[UsesClass(ExtrasReader::class)]
+#[UsesClass(IndexedCollection::class)]
+#[UsesClass(JsonLikeLexer::class)]
+#[UsesClass(NamedCollection::class)]
 #[UsesClass(TagMetadata::class)]
+#[UsesClass(Todo::class)]
 #[UsesClass(TodoFactory::class)]
+#[UsesClass(Token::class)]
 final class CommentRegistrarTest extends TestCase
 {
     public function testDontRegisterTwice(): void
diff --git a/tests/Unit/Service/TodoFactoryTest.php b/tests/Unit/Service/TodoFactoryTest.php
index 284e83a..422b62e 100644
--- a/tests/Unit/Service/TodoFactoryTest.php
+++ b/tests/Unit/Service/TodoFactoryTest.php
@@ -5,15 +5,26 @@
 namespace Aeliot\TodoRegistrar\Test\Unit\Service;
 
 use Aeliot\TodoRegistrar\Dto\Comment\CommentPart;
+use Aeliot\TodoRegistrar\Dto\InlineConfig\IndexedCollection;
+use Aeliot\TodoRegistrar\Dto\InlineConfig\NamedCollection;
+use Aeliot\TodoRegistrar\Dto\InlineConfig\Token;
 use Aeliot\TodoRegistrar\Dto\Tag\TagMetadata;
 use Aeliot\TodoRegistrar\Service\InlineConfig\ArrayFromJsonLikeLexerBuilder;
 use Aeliot\TodoRegistrar\Service\InlineConfig\ExtrasReader;
 use Aeliot\TodoRegistrar\Service\InlineConfig\InlineConfigFactory;
+use Aeliot\TodoRegistrar\Service\InlineConfig\JsonLikeLexer;
 use Aeliot\TodoRegistrar\Service\TodoFactory;
 use PHPUnit\Framework\Attributes\CoversClass;
+use PHPUnit\Framework\Attributes\UsesClass;
 use PHPUnit\Framework\TestCase;
 
 #[CoversClass(TodoFactory::class)]
+#[UsesClass(ArrayFromJsonLikeLexerBuilder::class)]
+#[UsesClass(ExtrasReader::class)]
+#[UsesClass(IndexedCollection::class)]
+#[UsesClass(JsonLikeLexer::class)]
+#[UsesClass(NamedCollection::class)]
+#[UsesClass(Token::class)]
 final class TodoFactoryTest extends TestCase
 {
     public function testMappingOfBaseFields(): void

From 1c0d75555f25b0c3d447090edfb048fe938427d5 Mon Sep 17 00:00:00 2001
From: Aeliot <5785276@gmail.com>
Date: Tue, 2 Jul 2024 19:02:50 +0500
Subject: [PATCH 12/28] Fix code style

---
 src/Service/TodoFactory.php | 5 ++---
 1 file changed, 2 insertions(+), 3 deletions(-)

diff --git a/src/Service/TodoFactory.php b/src/Service/TodoFactory.php
index e8701e4..97d2d2a 100644
--- a/src/Service/TodoFactory.php
+++ b/src/Service/TodoFactory.php
@@ -15,8 +15,7 @@ class TodoFactory
     public function __construct(
         private InlineConfigFactoryInterface $inlineConfigFactory,
         private InlineConfigReaderInterface $inlineConfigReader,
-    )
-    {
+    ) {
     }
 
     public function create(CommentPart $commentPart): Todo
@@ -26,7 +25,7 @@ public function create(CommentPart $commentPart): Todo
         return new Todo(
             $commentPart->getTag(),
             $commentPart->getSummary(),
-            $commentPart->getDescription(),
+            $description,
             $commentPart->getTagMetadata()?->getAssignee(),
             $this->getInlineConfig($description),
         );

From bda9b8163e50ef36f02d213ef43625d844876156 Mon Sep 17 00:00:00 2001
From: Aeliot <5785276@gmail.com>
Date: Tue, 2 Jul 2024 20:47:44 +0600
Subject: [PATCH 13/28] Split readme file

---
 README.md                             | 97 +++++++--------------------
 docs/config.md                        | 14 ++++
 docs/supported_patters_of_comments.md | 71 ++++++++++++++++++++
 3 files changed, 108 insertions(+), 74 deletions(-)
 create mode 100644 docs/config.md
 create mode 100644 docs/supported_patters_of_comments.md

diff --git a/README.md b/README.md
index 8d6126d..f59ae71 100644
--- a/README.md
+++ b/README.md
@@ -21,92 +21,41 @@ Package responsible for registration of issues in Issue Trackers.
    ```shell
    vendor/bin/todo-registrar
    ```
-   You may pass option with it `--config=/custom/path/to/config`. Otherwise, it tries to use one of default files. 
+   You may pass option with path to config `--config=/custom/path/to/config`.
+   Otherwise, it tries to use one of default paths to config file.
 2. Commit updated files. You may config your pipeline/job on CI which commits updates.
 
 ## Configuration file
 
-Config file is php-file which returns instance of class `\Aeliot\TodoRegistrar\Config`. See [example](.todo-registrar.dist.php).
+It expects that file `.todo-registrar.php` or `.todo-registrar.dist.php` added in the root directory of project.
+It may be put in any other place, but you have to define path to it with option `--config=/custom/path/to/cofig`
+while call the script. Config file is php-file which returns instance of class `\Aeliot\TodoRegistrar\Config`.
 
-It has setters:
-1. `setFinder` - accepts instance of configured finder of php-files.
-2. `setRegistrar` - responsible for configuration of registrar factory. It accepts as type of registrar with its config
-   as instance of custom registrar factory.
-3. `setTags` - array of detected tags. It supports "todo" and "fixme" by default. 
-   You don't need to configure it when you want to use only this tags. Nevertheless, you have to set them 
-   when you want to use them together with your custom tags.
+[See full documentation about config](docs/config.md)
 
-### Supported patters of comments (examples):
+## Supported todo-tags
+
+It detects `TODO` and `FIXME` by default. But you may config your custom set of tags in config file.
+Whey will be detected case insensitively.
+
+## Supported formats of comments:
 
 It detects TODO-tags in single-line comments started with both `//` and `#` symbols
 and multiple-line comments `/* ... */` and phpDoc `/** ... **/`.
 
-1. Tag and comment separated by colon
-   ```php
-   // TODO: comment summary
-   ```
-2. Tag and comment does not separated by colon
-   ```php
-   // TODO comment summary
-   ```
-3. Tag with assignee and comment separated by colon
-   ```php
-   // TODO@assigne: comment summary
-   ```
-4. Tag with assignee and comment does not separated by colon
-   ```php
-   // TODO@assigne comment summary
-   ```
-5. Multiline comment with complex description. All lines after the first one with tag MUST have indentation
-   same to the text of the first line. So, all af them will be detected af part of description of TODO.
-   Multiple line comments may have assignee and colon same as single-line comments/.
-   ```php
-   /**
-    * TODO: comment summary
-    *       and some complex description
-    *       which must have indentation same as end of one presented:
-    *       - colon
-    *       - assignee
-    *       - tag
-    *       So, all this text will be passed to registrar as description
-    *       without not meaning indentations (" *      " in this case).
-    * This line (and all after) will not be detected as part (description) of "TODO"
-    * case they don't have expected indentation.
-    */
-   ```
-
-As a result of processing of such comments, ID of ISSUE will be injected before comment summary
-and after colon and assignee when they are presented. For example:
-1. Tag and comment separated by colon
-   ```php
-   // TODO: XX-001 comment summary
-   ```
-2. Tag and comment does not separated by colon
-   ```php
-   // TODO XX-001 comment summary
-   ```
-3. Tag with assignee and comment separated by colon
-   ```php
-   // TODO@assigne: XX-001 comment summary
-   ```
-4. Tag with assignee and comment does not separated by colon
-   ```php
-   // TODO@assigne XX-001 comment summary
-   ```
-5. Multiline comment with complex description. All lines after the first one with tag MUST have indentation
-   same to the text of the first line. So, all af them will be detected af part of description of TODO.
-   Multiple line comments may have assignee and colon same as single-line comments/.
-   ```php
-   /**
-    * TODO: XX-001 comment summary
-    *       and some complex description
-    */
-   ```
+Which can be formatted differently:
+```php
+// TODO: comment summary
+// TODO comment summary
+// TODO@assigne: comment summary
 
-### Assignee-part
+/**
+ * TODO: XX-001 comment summary
+ *       with some complex description
+ */
+```
 
-It is some "username" which separated of tag by symbol "@". It sticks to pattern `/[a-z0-9._-]+/i`.
-System pass it in payload to registrar with aim to be used as "identifier" of assignee in issue tracker.
+And others. [See all supported formats](docs/supported_patters_of_comments.md).
 
 ## Supported Issue Trackers
 
diff --git a/docs/config.md b/docs/config.md
new file mode 100644
index 0000000..4a17cc9
--- /dev/null
+++ b/docs/config.md
@@ -0,0 +1,14 @@
+# Configuration file
+
+It expects that file `.todo-registrar.php` or `.todo-registrar.dist.php` added in the root directory of project.
+It may be put in any other place, but you have to define path to it when call the script with option `--config=/path/to/cofig`.
+Config file is php-file which returns instance of class `\Aeliot\TodoRegistrar\Config`. See [example](../.todo-registrar.dist.php).
+
+It has setters:
+1. `setFinder` - accepts instance of configured finder of php-files.
+2. `setRegistrar` - responsible for configuration of registrar factory. It accepts as type of registrar with its config
+   as instance of custom registrar factory.
+3. `setTags` - array of detected tags. It supports "todo" and "fixme" by default.
+   You don't need to configure it when you want to use only this tags. Nevertheless, you have to set them
+   when you want to use them together with your custom tags.
+
diff --git a/docs/supported_patters_of_comments.md b/docs/supported_patters_of_comments.md
new file mode 100644
index 0000000..a6837e2
--- /dev/null
+++ b/docs/supported_patters_of_comments.md
@@ -0,0 +1,71 @@
+# Supported formats and patterns of comments
+
+It detects TODO-tags in single-line comments started with both `//` and `#` symbols
+and multiple-line comments `/* ... */` and phpDoc `/** ... **/`.
+
+1. Tag and comment separated by colon
+   ```php
+   // TODO: comment summary
+   ```
+2. Tag and comment does not separated by colon
+   ```php
+   // TODO comment summary
+   ```
+3. Tag with assignee and comment separated by colon
+   ```php
+   // TODO@assigne: comment summary
+   ```
+4. Tag with assignee and comment does not separated by colon
+   ```php
+   // TODO@assigne comment summary
+   ```
+5. Multiline comment with complex description. All lines after the first one with tag MUST have indentation
+   same to the text of the first line. So, all af them will be detected af part of description of TODO.
+   Multiple line comments may have assignee and colon same as single-line comments/.
+   ```php
+   /**
+    * TODO: comment summary
+    *       and some complex description
+    *       which must have indentation same as end of one presented:
+    *       - colon
+    *       - assignee
+    *       - tag
+    *       So, all this text will be passed to registrar as description
+    *       without not meaning indentations (" *      " in this case).
+    * This line (and all after) will not be detected as part (description) of "TODO"
+    * case they don't have expected indentation.
+    */
+   ```
+
+As a result of processing of such comments, ID of ISSUE will be injected before comment summary
+and after colon and assignee when they are presented. For example:
+1. Tag and comment separated by colon
+   ```php
+   // TODO: XX-001 comment summary
+   ```
+2. Tag and comment does not separated by colon
+   ```php
+   // TODO XX-001 comment summary
+   ```
+3. Tag with assignee and comment separated by colon
+   ```php
+   // TODO@assigne: XX-001 comment summary
+   ```
+4. Tag with assignee and comment does not separated by colon
+   ```php
+   // TODO@assigne XX-001 comment summary
+   ```
+5. Multiline comment with complex description. All lines after the first one with tag MUST have indentation
+   same to the text of the first line. So, all af them will be detected af part of description of TODO.
+   Multiple line comments may have assignee and colon same as single-line comments/.
+   ```php
+   /**
+    * TODO: XX-001 comment summary
+    *       and some complex description
+    */
+   ```
+
+### Assignee-part
+
+It is some "username" which separated of tag by symbol "@". It sticks to pattern `/[a-z0-9._-]+/i`.
+System pass it in payload to registrar with aim to be used as "identifier" of assignee in issue tracker.

From d1049d1e7b0edd034b8979f5e7bb41401cf02633 Mon Sep 17 00:00:00 2001
From: Aeliot <5785276@gmail.com>
Date: Tue, 2 Jul 2024 20:48:07 +0600
Subject: [PATCH 14/28] Update summary description of script

---
 README.md | 9 ++++++++-
 1 file changed, 8 insertions(+), 1 deletion(-)

diff --git a/README.md b/README.md
index f59ae71..c05f9d9 100644
--- a/README.md
+++ b/README.md
@@ -5,7 +5,14 @@
 [![WFS](https://github.com/Aeliot-Tm/todo-registrar/actions/workflows/automated_testing.yml/badge.svg?branch=main)](https://github.com/Aeliot-Tm/todo-registrar/actions)
 [![GitHub Issues or Pull Requests](https://img.shields.io/github/issues/Aeliot-Tm/todo-registrar?labelColor=black&label=Issues)](https://github.com/Aeliot-Tm/todo-registrar/issues)
 
-Package responsible for registration of issues in Issue Trackers.
+Package is responsible for registration of your TODO/FIXME and other notes in code as issues in Issue Trackers like
+JIRA.
+It injects IDs/Keys of created issues into proper comments in code. So, they will not be created twice when you commit
+changes.
+
+So, you don't spend time to fill lots of fields in issue-tracking system and lots of times for each issue.
+After that you may use all power of management to plan solving of lacks of your code.
+And injected marks helps to find proper places in code quickly.
 
 ## Installation
 

From 9ae640765799fb40eade58bb77b2d40fc1134147 Mon Sep 17 00:00:00 2001
From: Aeliot <5785276@gmail.com>
Date: Tue, 2 Jul 2024 20:48:59 +0600
Subject: [PATCH 15/28] Reorganize readme

---
 README.md | 16 ++++++++--------
 1 file changed, 8 insertions(+), 8 deletions(-)

diff --git a/README.md b/README.md
index c05f9d9..ee2b279 100644
--- a/README.md
+++ b/README.md
@@ -32,14 +32,6 @@ And injected marks helps to find proper places in code quickly.
    Otherwise, it tries to use one of default paths to config file.
 2. Commit updated files. You may config your pipeline/job on CI which commits updates.
 
-## Configuration file
-
-It expects that file `.todo-registrar.php` or `.todo-registrar.dist.php` added in the root directory of project.
-It may be put in any other place, but you have to define path to it with option `--config=/custom/path/to/cofig`
-while call the script. Config file is php-file which returns instance of class `\Aeliot\TodoRegistrar\Config`.
-
-[See full documentation about config](docs/config.md)
-
 ## Supported todo-tags
 
 It detects `TODO` and `FIXME` by default. But you may config your custom set of tags in config file.
@@ -71,3 +63,11 @@ Currently, todo-registrar supports the following issue trackers:
 | Issue Tracker                                   | Description                                                                                 |
 |-------------------------------------------------|---------------------------------------------------------------------------------------------|
 | [Jira](https://www.atlassian.com/software/jira) | Supported via API tokens. See [description of configuration](docs/registrar/jira/config.md) |
+
+## Configuration file
+
+It expects that file `.todo-registrar.php` or `.todo-registrar.dist.php` added in the root directory of project.
+It may be put in any other place, but you have to define path to it with option `--config=/custom/path/to/cofig`
+while call the script. Config file is php-file which returns instance of class `\Aeliot\TodoRegistrar\Config`.
+
+[See full documentation about config](docs/config.md)

From 948db2dd25159d0c7c8fdc7d37518a5d455ec29f Mon Sep 17 00:00:00 2001
From: Aeliot <5785276@gmail.com>
Date: Tue, 2 Jul 2024 21:30:16 +0600
Subject: [PATCH 16/28] Describe EXTRAS

---
 README.md             |  7 ++++++
 docs/inline_config.md | 54 +++++++++++++++++++++++++++++++++++++++++++
 2 files changed, 61 insertions(+)
 create mode 100644 docs/inline_config.md

diff --git a/README.md b/README.md
index ee2b279..d4293de 100644
--- a/README.md
+++ b/README.md
@@ -71,3 +71,10 @@ It may be put in any other place, but you have to define path to it with option
 while call the script. Config file is php-file which returns instance of class `\Aeliot\TodoRegistrar\Config`.
 
 [See full documentation about config](docs/config.md)
+
+## Inline Configuration
+
+Script supports inline configuration of each TODO-comment. It helps flexibly configure different aspects of created issues.
+Like relations to other issues, labels, components and so on. So, it becomes very powerful instrument. 😊
+
+[See documentation about inline config](docs/inline_config.md)
diff --git a/docs/inline_config.md b/docs/inline_config.md
new file mode 100644
index 0000000..fa70166
--- /dev/null
+++ b/docs/inline_config.md
@@ -0,0 +1,54 @@
+## Inline Configuration
+
+Script supports inline configuration of each TODO-comment. It helps flexibly configure different aspects of created issues.
+Like relations to other issues, labels, components and so on.
+
+Very flexible tool. A format similar to js objects or JSON, only without quotes.
+It is so-called "EXTRAS" case they started with `{EXTRAS: ...`. It should be a part of multi-line commit
+and indented a description ([see supported formats](supported_patters_of_comments.md)).
+The system expects no more than one inline config per TODO. And it would be nice to keep them as last part of TODO-commit.
+
+It may be split to multiple lines. But don't forget about indents of each line.
+
+```php
+/**
+ * TODO@an.assignee: summary of issue (title)
+ *                   And some complex description on second line and below.
+ *                   Fell you free to add any complex description as you wish.
+ *                        But don't forget to start each line on same column as summary (title) or later.
+ *                   And suit same rule for EXTRAS, which can be split to multiple lines with any spaces. 
+ *                   See below.
+ *                   {EXTRAS: {
+ *                      someKey: [value1, value2],
+ *                      moreComplexData: {key1: [v3], key2: {
+ *                            neverMindHowManyLevels: [v4]
+ *                      }}
+ *                   }}        
+ */
+```
+
+### Example
+
+Below are examples of settings supported by the implemented JIRA-registrar.
+
+1. Do you need to provide a related ticket? Easily.
+   ```
+   {EXTRAS: {linkedIssues: XX-123}}
+   ```
+2. Do you need to link multiple tickets? So:
+   ```
+   {EXTRAS: {linkedIssues: [XX-123, XX-234]}}
+   ```
+3. Do you need to link tickets with different link types? Easily.
+   ```
+   {EXTRAS: {linkedIssues: {child_of: XX-123, is_blocked_by: [XX-234, YY-567]}}}
+   ```
+4. Labels and components can be provided in the same way.
+   ```
+   {EXTRAS: {labels: [label-a, label-b], components: [component-a, component-b]}}
+   ```
+
+## The order of applying of configs
+1. `@assignee` joined to TODO-tag.
+2. `EXTRAS`
+3. Then the [general config](config.md) of the JIRA registrar.

From d61b5fb8489bfd737a43183873262a53889742b3 Mon Sep 17 00:00:00 2001
From: Aeliot <5785276@gmail.com>
Date: Tue, 2 Jul 2024 20:37:50 +0300
Subject: [PATCH 17/28] Fix expected style of inline config for JIRA

---
 src/Service/Registrar/JIRA/IssueLinkRegistrar.php | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/src/Service/Registrar/JIRA/IssueLinkRegistrar.php b/src/Service/Registrar/JIRA/IssueLinkRegistrar.php
index c024876..14d5ae1 100644
--- a/src/Service/Registrar/JIRA/IssueLinkRegistrar.php
+++ b/src/Service/Registrar/JIRA/IssueLinkRegistrar.php
@@ -17,7 +17,7 @@ public function __construct(
 
     public function registerLinks(string $inwardIssueKey, Todo $todo): void
     {
-        $linkedIssues = $todo->getInlineConfig()['linked_issues'] ?? [];
+        $linkedIssues = $todo->getInlineConfig()['linkedIssues'] ?? [];
         if (!$linkedIssues) {
             return;
         }

From 3f39d89d81cecfb3ce087bce5af5b617f0a29620 Mon Sep 17 00:00:00 2001
From: Aeliot <5785276@gmail.com>
Date: Tue, 2 Jul 2024 21:31:07 +0300
Subject: [PATCH 18/28] Describe main config

---
 README.md      |  4 ++--
 docs/config.md | 56 +++++++++++++++++++++++++++++++++++++++++++-------
 2 files changed, 51 insertions(+), 9 deletions(-)

diff --git a/README.md b/README.md
index d4293de..bca6e44 100644
--- a/README.md
+++ b/README.md
@@ -20,7 +20,7 @@ And injected marks helps to find proper places in code quickly.
    ```shell
    composer require --dev aeliot/todo-registrar
    ```
-2. Create configuration file. It expects ".todo-registrar.php" or ".todo-registrar.dist.php" at the root of the project.
+2. Create [configuration file](docs/config.md). It expects ".todo-registrar.php" or ".todo-registrar.dist.php" at the root of the project.
 
 ## Using
 
@@ -29,7 +29,7 @@ And injected marks helps to find proper places in code quickly.
    vendor/bin/todo-registrar
    ```
    You may pass option with path to config `--config=/custom/path/to/config`.
-   Otherwise, it tries to use one of default paths to config file.
+   Otherwise, it tries to use one of default paths to [config file](docs/config.md).
 2. Commit updated files. You may config your pipeline/job on CI which commits updates.
 
 ## Supported todo-tags
diff --git a/docs/config.md b/docs/config.md
index 4a17cc9..3149229 100644
--- a/docs/config.md
+++ b/docs/config.md
@@ -2,13 +2,55 @@
 
 It expects that file `.todo-registrar.php` or `.todo-registrar.dist.php` added in the root directory of project.
 It may be put in any other place, but you have to define path to it when call the script with option `--config=/path/to/cofig`.
+
 Config file is php-file which returns instance of class `\Aeliot\TodoRegistrar\Config`. See [example](../.todo-registrar.dist.php).
 
-It has setters:
-1. `setFinder` - accepts instance of configured finder of php-files.
-2. `setRegistrar` - responsible for configuration of registrar factory. It accepts as type of registrar with its config
-   as instance of custom registrar factory.
-3. `setTags` - array of detected tags. It supports "todo" and "fixme" by default.
-   You don't need to configure it when you want to use only this tags. Nevertheless, you have to set them
-   when you want to use them together with your custom tags.
+## Methods
+
+| Method                 | Is Required |
+|------------------------|-------------|
+| setFinder              | yes         |
+| setInlineConfigFactory | no          |
+| setInlineConfigReader  | no          |
+| setRegistrar           | yes         |
+| setTags                | no          |
+
+
+### setFinder
+
+Accepts instance of configured finder (`\Aeliot\TodoRegistrar\Service\File\Finder`) responsible for finding of php-files.
+Very similar to configuration of Finder for "PHP CS Fixer".
+
+### setInlineConfigFactory
+
+Accepts instance of `\Aeliot\TodoRegistrar\InlineConfigFactoryInterface`
+
+You can implement and expects instance of your custom inline config in your registrar.
+This method permits to provide factory for it.
+
+### setInlineConfigReader
+
+Accepts instance of `\Aeliot\TodoRegistrar\InlineConfigReaderInterface`. 
+
+So, you can use your own reader of inline config which support your preferred format or relay on build-in.  
+
+### setRegistrar
+
+Responsible for configuration of registrar factory. 
+
+It accepts two arguments:
+1. First one is registrar type (`\Aeliot\TodoRegistrar\Enum\RegistrarType`)
+   or instance of registrar factory (`\Aeliot\TodoRegistrar\Service\Registrar\RegistrarFactoryInterface`).
+2. Second one is array of config for registrar. See [example for JIRA](registrar/jira/config.md).
+
+So, you can use build-in registrar or pass your own.
+
+### setTags
+
+Permit to define array of tags to be detected. 
+
+Script supports `TODO` and `FIXME` by default.
+You don't need to configure it when you want to use only this tags. Nevertheless, you have to set them
+when you want to use them together with your custom tags.
 
+Don't wary about case of tags. They will be found in case-insensitive mode. 

From 4cbb08b0cca41139043c958c3f639c593865cca2 Mon Sep 17 00:00:00 2001
From: Aeliot <5785276@gmail.com>
Date: Tue, 2 Jul 2024 21:43:12 +0300
Subject: [PATCH 19/28] Explain compatibility of formats

---
 README.md                             | 2 +-
 docs/supported_patters_of_comments.md | 7 +++++++
 2 files changed, 8 insertions(+), 1 deletion(-)

diff --git a/README.md b/README.md
index bca6e44..58f6860 100644
--- a/README.md
+++ b/README.md
@@ -42,7 +42,7 @@ Whey will be detected case insensitively.
 It detects TODO-tags in single-line comments started with both `//` and `#` symbols
 and multiple-line comments `/* ... */` and phpDoc `/** ... **/`.
 
-Which can be formatted differently:
+Comments can be formatted differently:
 ```php
 // TODO: comment summary
 // TODO comment summary
diff --git a/docs/supported_patters_of_comments.md b/docs/supported_patters_of_comments.md
index a6837e2..f8bea81 100644
--- a/docs/supported_patters_of_comments.md
+++ b/docs/supported_patters_of_comments.md
@@ -69,3 +69,10 @@ and after colon and assignee when they are presented. For example:
 
 It is some "username" which separated of tag by symbol "@". It sticks to pattern `/[a-z0-9._-]+/i`.
 System pass it in payload to registrar with aim to be used as "identifier" of assignee in issue tracker.
+
+## Compatibility
+
+Script implemented to support formats of "comments with expiration" provided by [staabm/phpstan-todo-by](https://github.com/staabm/phpstan-todo-by).
+On my point of view, it is cool feature which I'd like to use in my projects.
+
+So, script ignores comments which already have mark of "expiration".

From bed02417c6b0eb1f403ed88f550425a91594b109 Mon Sep 17 00:00:00 2001
From: Aeliot <5785276@gmail.com>
Date: Tue, 2 Jul 2024 22:03:45 +0300
Subject: [PATCH 20/28] Fix casting of data type

---
 src/Service/Registrar/JIRA/IssueFieldFactory.php  | 2 +-
 src/Service/Registrar/JIRA/IssueLinkRegistrar.php | 2 +-
 2 files changed, 2 insertions(+), 2 deletions(-)

diff --git a/src/Service/Registrar/JIRA/IssueFieldFactory.php b/src/Service/Registrar/JIRA/IssueFieldFactory.php
index 6834213..449b0b5 100644
--- a/src/Service/Registrar/JIRA/IssueFieldFactory.php
+++ b/src/Service/Registrar/JIRA/IssueFieldFactory.php
@@ -63,7 +63,7 @@ private function setIssueType(IssueField $issueField, Todo $todo): void
     private function setLabels(IssueField $issueField, Todo $todo): void
     {
         $labels = [
-            ...($todo->getInlineConfig()['labels'] ?? []),
+            ...(array) ($todo->getInlineConfig()['labels'] ?? []),
             ...$this->issueConfig->getLabels(),
         ];
 
diff --git a/src/Service/Registrar/JIRA/IssueLinkRegistrar.php b/src/Service/Registrar/JIRA/IssueLinkRegistrar.php
index 14d5ae1..75174e1 100644
--- a/src/Service/Registrar/JIRA/IssueLinkRegistrar.php
+++ b/src/Service/Registrar/JIRA/IssueLinkRegistrar.php
@@ -17,7 +17,7 @@ public function __construct(
 
     public function registerLinks(string $inwardIssueKey, Todo $todo): void
     {
-        $linkedIssues = $todo->getInlineConfig()['linkedIssues'] ?? [];
+        $linkedIssues = (array) ($todo->getInlineConfig()['linkedIssues'] ?? []);
         if (!$linkedIssues) {
             return;
         }

From 6114e18788c3eeca9a14cf81ccdaf7221a6d10ed Mon Sep 17 00:00:00 2001
From: Aeliot <5785276@gmail.com>
Date: Tue, 2 Jul 2024 22:05:38 +0300
Subject: [PATCH 21/28] Add TODOs

---
 src/Service/InlineConfig/InlineConfigFactory.php | 3 +++
 src/Service/Registrar/JIRA/IssueConfig.php       | 3 ++-
 2 files changed, 5 insertions(+), 1 deletion(-)

diff --git a/src/Service/InlineConfig/InlineConfigFactory.php b/src/Service/InlineConfig/InlineConfigFactory.php
index babf8e7..2199919 100644
--- a/src/Service/InlineConfig/InlineConfigFactory.php
+++ b/src/Service/InlineConfig/InlineConfigFactory.php
@@ -10,6 +10,9 @@
 
 final class InlineConfigFactory implements InlineConfigFactoryInterface
 {
+    /**
+     * TODO: create config specific for configured registrar
+     */
     public function getInlineConfig(array $input): InlineConfigInterface
     {
         return new InlineConfig($input);
diff --git a/src/Service/Registrar/JIRA/IssueConfig.php b/src/Service/Registrar/JIRA/IssueConfig.php
index e7c504e..e7b3f8c 100644
--- a/src/Service/Registrar/JIRA/IssueConfig.php
+++ b/src/Service/Registrar/JIRA/IssueConfig.php
@@ -112,10 +112,11 @@ public function normalizeConfig(array $config): array
         $config['labels'] = (array) $config['labels'];
 
         if (array_key_exists('type', $config)) {
+            // TODO: throw exception when exists key "issueType"
             $config['issueType'] = $config['type'];
             unset($config['type']);
         }
 
         return $config;
     }
-}
\ No newline at end of file
+}

From d018af6014a8b05a01c9af69e58e95878897a18e Mon Sep 17 00:00:00 2001
From: Aeliot <5785276@gmail.com>
Date: Tue, 2 Jul 2024 22:30:37 +0300
Subject: [PATCH 22/28] Fix getting of assignee from inline config

---
 src/Service/Registrar/JIRA/IssueFieldFactory.php | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/src/Service/Registrar/JIRA/IssueFieldFactory.php b/src/Service/Registrar/JIRA/IssueFieldFactory.php
index 449b0b5..f252628 100644
--- a/src/Service/Registrar/JIRA/IssueFieldFactory.php
+++ b/src/Service/Registrar/JIRA/IssueFieldFactory.php
@@ -33,7 +33,7 @@ public function create(Todo $todo): IssueField
 
     private function setAssignee(IssueField $issueField, Todo $todo): void
     {
-        $assignee = $todo->getInlineConfig()['priority']
+        $assignee = $todo->getInlineConfig()['assignee']
             ?? $todo->getAssignee()
             ?? $this->issueConfig->getAssignee();
 

From 0220b809e9222033658e7adbd1c50e3f8817da65 Mon Sep 17 00:00:00 2001
From: Anatoliy Melnikov <Aeliot-Tm@users.noreply.github.com>
Date: Sun, 14 Jul 2024 00:04:09 +0300
Subject: [PATCH 23/28] Describe motivation of creating of this script

---
 README.md | 20 +++++++++++++-------
 1 file changed, 13 insertions(+), 7 deletions(-)

diff --git a/README.md b/README.md
index 58f6860..ffeac65 100644
--- a/README.md
+++ b/README.md
@@ -1,18 +1,24 @@
 # TODO Registrar
 
+It takes TODO/FIXME and other comments from your php-code and register them as issues in Issue Trackers like
+JIRA. With all necessary labels, linked issues and so on.
+
 [![GitHub Release](https://img.shields.io/github/v/release/Aeliot-Tm/todo-registrar?label=Release&labelColor=black)](https://packagist.org/packages/aeliot/todo-registrar)
 [![GitHub License](https://img.shields.io/github/license/Aeliot-Tm/todo-registrar?label=License&labelColor=black)](LICENSE)
 [![WFS](https://github.com/Aeliot-Tm/todo-registrar/actions/workflows/automated_testing.yml/badge.svg?branch=main)](https://github.com/Aeliot-Tm/todo-registrar/actions)
 [![GitHub Issues or Pull Requests](https://img.shields.io/github/issues/Aeliot-Tm/todo-registrar?labelColor=black&label=Issues)](https://github.com/Aeliot-Tm/todo-registrar/issues)
 
-Package is responsible for registration of your TODO/FIXME and other notes in code as issues in Issue Trackers like
-JIRA.
-It injects IDs/Keys of created issues into proper comments in code. So, they will not be created twice when you commit
-changes.
+## Motivation
+
+Time to time developers left notes in code to not forget to do something. And they forget to do it. One of the most reason is that it is difficult to manage them.
+
+Why do developers left comment in code instead of registering of isdues? It is convenient. You don't need to deal with UI of Issue Tracker and to fill lots of field. And lots of times to register each issue. It takes time. The second reason, comment in code permit to mark exact place which have to be modified. And many other reasons. No matter why they do it. They do it and leave this comments for years.
+
+Somebody have to manage it.
+
+So, we need in tool which will be responsible for registering of issues and save time of developers. After that you may use all power of management to plan solving of lacks of your code.
 
-So, you don't spend time to fill lots of fields in issue-tracking system and lots of times for each issue.
-After that you may use all power of management to plan solving of lacks of your code.
-And injected marks helps to find proper places in code quickly.
+This script do it for you. It registers issues with all necessary params. Then injects IDs/Keys of created issues into comment in code. This prevents creating of issues twice and injected marks helps to find proper places in code quickly.
 
 ## Installation
 

From c17a2091928da2615078eaa19fdf37e1a804aa1e Mon Sep 17 00:00:00 2001
From: Anatoliy Melnikov <Aeliot-Tm@users.noreply.github.com>
Date: Sun, 14 Jul 2024 00:07:21 +0300
Subject: [PATCH 24/28] Orders blocks of README.md

---
 README.md | 29 +++++++++++++++--------------
 1 file changed, 15 insertions(+), 14 deletions(-)

diff --git a/README.md b/README.md
index ffeac65..6175a96 100644
--- a/README.md
+++ b/README.md
@@ -38,6 +38,21 @@ This script do it for you. It registers issues with all necessary params. Then i
    Otherwise, it tries to use one of default paths to [config file](docs/config.md).
 2. Commit updated files. You may config your pipeline/job on CI which commits updates.
 
+## Configuration file
+
+It expects that file `.todo-registrar.php` or `.todo-registrar.dist.php` added in the root directory of project.
+It may be put in any other place, but you have to define path to it with option `--config=/custom/path/to/cofig`
+while call the script. Config file is php-file which returns instance of class `\Aeliot\TodoRegistrar\Config`.
+
+[See full documentation about config](docs/config.md)
+
+## Inline Configuration
+
+Script supports inline configuration of each TODO-comment. It helps flexibly configure different aspects of created issues.
+Like relations to other issues, labels, components and so on. So, it becomes very powerful instrument. 😊
+
+[See documentation about inline config](docs/inline_config.md)
+
 ## Supported todo-tags
 
 It detects `TODO` and `FIXME` by default. But you may config your custom set of tags in config file.
@@ -70,17 +85,3 @@ Currently, todo-registrar supports the following issue trackers:
 |-------------------------------------------------|---------------------------------------------------------------------------------------------|
 | [Jira](https://www.atlassian.com/software/jira) | Supported via API tokens. See [description of configuration](docs/registrar/jira/config.md) |
 
-## Configuration file
-
-It expects that file `.todo-registrar.php` or `.todo-registrar.dist.php` added in the root directory of project.
-It may be put in any other place, but you have to define path to it with option `--config=/custom/path/to/cofig`
-while call the script. Config file is php-file which returns instance of class `\Aeliot\TodoRegistrar\Config`.
-
-[See full documentation about config](docs/config.md)
-
-## Inline Configuration
-
-Script supports inline configuration of each TODO-comment. It helps flexibly configure different aspects of created issues.
-Like relations to other issues, labels, components and so on. So, it becomes very powerful instrument. 😊
-
-[See documentation about inline config](docs/inline_config.md)

From 29ee5ff7ade0d5fa6f9dab79623c6c29764a74e5 Mon Sep 17 00:00:00 2001
From: Anatoliy Melnikov <5785276@gmail.com>
Date: Sat, 20 Jul 2024 02:19:44 +0300
Subject: [PATCH 25/28] Permit dots in words for JsonLexer

---
 src/Service/InlineConfig/JsonLikeLexer.php | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/src/Service/InlineConfig/JsonLikeLexer.php b/src/Service/InlineConfig/JsonLikeLexer.php
index 95921d5..95a4888 100644
--- a/src/Service/InlineConfig/JsonLikeLexer.php
+++ b/src/Service/InlineConfig/JsonLikeLexer.php
@@ -147,7 +147,7 @@ private function getRegexCatchablePatterns(): array
     {
         return [
             '[,:\[\]\{\}]',
-            '[0-9a-z_-]+',
+            '[0-9a-z_.-]+',
         ];
     }
 

From cac286972fd91d74e3d6d4193441d9b68aa851fe Mon Sep 17 00:00:00 2001
From: Anatoliy Melnikov <5785276@gmail.com>
Date: Sat, 20 Jul 2024 15:44:06 +0300
Subject: [PATCH 26/28] Fix capturing of comment line EOL

---
 src/Service/Comment/Extractor.php            | 21 +++++++++++++++++++-
 tests/Unit/Service/Comment/ExtractorTest.php | 19 ++++++++++++++++++
 2 files changed, 39 insertions(+), 1 deletion(-)

diff --git a/src/Service/Comment/Extractor.php b/src/Service/Comment/Extractor.php
index 1e8b7ce..8a41369 100644
--- a/src/Service/Comment/Extractor.php
+++ b/src/Service/Comment/Extractor.php
@@ -63,6 +63,25 @@ private function registerPart(string $line, CommentParts $parts): CommentPart
      */
     private function splitLines(string $comment): array
     {
-        return preg_split("/[\r\n]+/", $comment, -1, PREG_SPLIT_DELIM_CAPTURE);
+        $lines = preg_split("/([\r\n]+)/", $comment, -1, PREG_SPLIT_DELIM_CAPTURE);
+        $count = count($lines);
+        $currentLineIndex = 0;
+        for ($i = 0; $i < $count;) {
+            $nextLineIndex = $i + 1;
+            if (!array_key_exists($nextLineIndex, $lines)) {
+                break;
+            }
+            $nextLine = $lines[$nextLineIndex];
+            if (preg_match("/^[\r\n]+$/", $nextLine)) {
+                $lines[$currentLineIndex] .= $nextLine;
+                // skip next line
+                unset($lines[$nextLineIndex]);
+                ++$i;
+            } else {
+                $currentLineIndex = $i = $nextLineIndex;
+            }
+        }
+
+        return array_values($lines);
     }
 }
\ No newline at end of file
diff --git a/tests/Unit/Service/Comment/ExtractorTest.php b/tests/Unit/Service/Comment/ExtractorTest.php
index 8b11019..41d371d 100644
--- a/tests/Unit/Service/Comment/ExtractorTest.php
+++ b/tests/Unit/Service/Comment/ExtractorTest.php
@@ -21,6 +21,18 @@
 #[UsesClass(TagMetadata::class)]
 final class ExtractorTest extends TestCase
 {
+    public static function getDataForTestCatchLineSeparator(): iterable
+    {
+        yield [
+            <<<CONT
+/*
+ * TODO: multi line comment
+ *       with some extra description.
+ */
+CONT,
+        ];
+    }
+
     public static function getDataForTestCountOfParts(): iterable
     {
         yield [
@@ -66,6 +78,13 @@ public static function getDataForTestCountOfParts(): iterable
         ];
     }
 
+    #[DataProvider('getDataForTestCatchLineSeparator')]
+    public function testCatchLineSeparator(string $comment): void
+    {
+        $parts = (new Extractor(new TagDetector()))->extract($comment);
+        self::assertSame($comment, $parts->getContent());
+    }
+
     #[DataProvider('getDataForTestCountOfParts')]
     public function testCountOfParts(int $expectedTotalCount, int $expectedTodoCount, string $comment): void
     {

From 8e74856bef6531ffe89884764a647386707761f9 Mon Sep 17 00:00:00 2001
From: Anatoliy Melnikov <5785276@gmail.com>
Date: Sat, 20 Jul 2024 16:11:42 +0300
Subject: [PATCH 27/28] Reorder badges

---
 README.md | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/README.md b/README.md
index 6175a96..de27475 100644
--- a/README.md
+++ b/README.md
@@ -4,9 +4,9 @@ It takes TODO/FIXME and other comments from your php-code and register them as i
 JIRA. With all necessary labels, linked issues and so on.
 
 [![GitHub Release](https://img.shields.io/github/v/release/Aeliot-Tm/todo-registrar?label=Release&labelColor=black)](https://packagist.org/packages/aeliot/todo-registrar)
-[![GitHub License](https://img.shields.io/github/license/Aeliot-Tm/todo-registrar?label=License&labelColor=black)](LICENSE)
 [![WFS](https://github.com/Aeliot-Tm/todo-registrar/actions/workflows/automated_testing.yml/badge.svg?branch=main)](https://github.com/Aeliot-Tm/todo-registrar/actions)
 [![GitHub Issues or Pull Requests](https://img.shields.io/github/issues/Aeliot-Tm/todo-registrar?labelColor=black&label=Issues)](https://github.com/Aeliot-Tm/todo-registrar/issues)
+[![GitHub License](https://img.shields.io/github/license/Aeliot-Tm/todo-registrar?label=License&labelColor=black)](LICENSE)
 
 ## Motivation
 

From b49450cec3695623b6a96204b795a60f1381fdcb Mon Sep 17 00:00:00 2001
From: Anatoliy Melnikov <5785276@gmail.com>
Date: Sat, 20 Jul 2024 16:13:12 +0300
Subject: [PATCH 28/28] Reorder blocks of README.md

---
 README.md | 6 +++---
 1 file changed, 3 insertions(+), 3 deletions(-)

diff --git a/README.md b/README.md
index de27475..d83e5f4 100644
--- a/README.md
+++ b/README.md
@@ -1,13 +1,13 @@
 # TODO Registrar
 
-It takes TODO/FIXME and other comments from your php-code and register them as issues in Issue Trackers like
-JIRA. With all necessary labels, linked issues and so on.
-
 [![GitHub Release](https://img.shields.io/github/v/release/Aeliot-Tm/todo-registrar?label=Release&labelColor=black)](https://packagist.org/packages/aeliot/todo-registrar)
 [![WFS](https://github.com/Aeliot-Tm/todo-registrar/actions/workflows/automated_testing.yml/badge.svg?branch=main)](https://github.com/Aeliot-Tm/todo-registrar/actions)
 [![GitHub Issues or Pull Requests](https://img.shields.io/github/issues/Aeliot-Tm/todo-registrar?labelColor=black&label=Issues)](https://github.com/Aeliot-Tm/todo-registrar/issues)
 [![GitHub License](https://img.shields.io/github/license/Aeliot-Tm/todo-registrar?label=License&labelColor=black)](LICENSE)
 
+It takes TODO/FIXME and other comments from your php-code and register them as issues in Issue Trackers like
+JIRA. With all necessary labels, linked issues and so on.
+
 ## Motivation
 
 Time to time developers left notes in code to not forget to do something. And they forget to do it. One of the most reason is that it is difficult to manage them.