Skip to content

Commit 57e0921

Browse files
committed
Add tag definition builder
1 parent 22f45c9 commit 57e0921

12 files changed

Lines changed: 242 additions & 66 deletions

src/DocBlockParser.php

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
use TypeLang\PhpDoc\Parser\Tag\StringTagParser;
2323
use TypeLang\PhpDoc\Parser\Tag\TagParserInterface;
2424
use TypeLang\PhpDoc\Parser\TagFactory;
25-
use TypeLang\PhpDoc\Parser\TagRegistry;
25+
use TypeLang\PhpDoc\Parser\TagRegistryBuilder;
2626
use TypeLang\PhpDoc\Platform\PhanPlatform;
2727
use TypeLang\PhpDoc\Platform\PhpCodeSnifferPlatform;
2828
use TypeLang\PhpDoc\Platform\PhpDocumentorPlatform;
@@ -121,7 +121,8 @@ private function createTagRegistry(array $platforms): TagRegistryInterface
121121
}
122122
}
123123

124-
return new TagRegistry($definitions, $aliases);
124+
return new TagRegistryBuilder($definitions, $aliases)
125+
->build();
125126
}
126127

127128
/**
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace TypeLang\PhpDoc\Exception;
6+
7+
abstract class InvalidTagAliasException extends LoadingException {}

src/Exception/LoadingException.php

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace TypeLang\PhpDoc\Exception;
6+
7+
abstract class LoadingException extends \LogicException implements
8+
PhpDocExceptionInterface {}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace TypeLang\PhpDoc\Exception;
6+
7+
final class RecursiveTagAliasException extends InvalidTagAliasException
8+
{
9+
/**
10+
* Occurs when a tag alias cannot be resolved to a canonical tag
11+
* definition because its references form a cycle.
12+
*
13+
* @param non-empty-string $alias
14+
* @param list<non-empty-string> $path chain of aliases visited before the cycle was detected
15+
*/
16+
public static function becauseAliasIsRecursive(string $alias, array $path, ?\Throwable $prev = null): self
17+
{
18+
$message = 'Cannot determine canonical tag definition from alias "%s"'
19+
. ' because recursive references [%s] were found';
20+
$pathAsString = \implode(' > ', [...$path, \reset($path)]);
21+
22+
return new self(\sprintf($message, $alias, $pathAsString), previous: $prev);
23+
}
24+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace TypeLang\PhpDoc\Exception;
6+
7+
final class UnknownTagAliasException extends InvalidTagAliasException
8+
{
9+
/**
10+
* Occurs when a registered tag alias cannot be resolved to any
11+
* known tag definition.
12+
*
13+
* @param non-empty-string $alias
14+
* @param list<non-empty-string> $path chain of visited aliases
15+
*/
16+
public static function becauseAliasHasNoDefinition(string $alias, array $path, ?\Throwable $prev = null): self
17+
{
18+
$message = 'Unable to find tag definition for "%s" tag alias in alias chain [%s]';
19+
$pathAsString = \implode(' > ', [$alias, ...$path]);
20+
21+
return new self(\sprintf($message, $alias, $pathAsString), previous: $prev);
22+
}
23+
}

src/Parser/TagFactory.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@
3636
* @param iterable<non-empty-string, CombinatorType> $combinators
3737
*/
3838
public function __construct(
39-
private TagRegistryInterface $registry = new TagRegistry(),
39+
private TagRegistryInterface $registry,
4040
iterable $combinators = [],
4141
) {
4242
$this->parser = new TagSpecificationParser($combinators);

src/Parser/TagRegistry.php

Lines changed: 10 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44

55
namespace TypeLang\PhpDoc\Parser;
66

7-
use TypeLang\PhpDoc\DocBlock\Tag\GenericTagDefinition;
87
use TypeLang\PhpDoc\DocBlock\TagDefinition\TagDefinitionInterface;
98
use TypeLang\PhpDoc\Parser\Grammar\CombinatorInterface;
109
use TypeLang\PhpDoc\TagRegistryInterface;
@@ -29,69 +28,22 @@
2928
*/
3029
final readonly class TagRegistry implements TagRegistryInterface, \IteratorAggregate
3130
{
32-
/**
33-
* @var array<non-empty-lowercase-string, TagDefinitionInterface>
34-
*/
35-
private array $definitions;
36-
37-
/**
38-
* @var array<non-empty-lowercase-string, non-empty-lowercase-string>
39-
*/
40-
private array $aliases;
41-
42-
/**
43-
* @param iterable<non-empty-string, TagDefinitionInterface> $definitions
44-
* @param iterable<non-empty-string, non-empty-string> $aliases
45-
*/
4631
public function __construct(
47-
iterable $definitions = [],
48-
iterable $aliases = [],
49-
private TagDefinitionInterface $genericTagDefinition = new GenericTagDefinition(),
50-
) {
51-
$this->definitions = $this->formatDefinitions($definitions);
52-
$this->aliases = $this->formatAliases($aliases);
53-
}
54-
55-
/**
56-
* @param iterable<non-empty-string, TagDefinitionInterface> $definitions
57-
* @return array<non-empty-lowercase-string, TagDefinitionInterface>
58-
*/
59-
private function formatDefinitions(iterable $definitions): array
32+
/**
33+
* @var array<non-empty-lowercase-string, TagDefinitionInterface>
34+
*/
35+
private array $definitions,
36+
private TagDefinitionInterface $genericTagDefinition,
37+
) {}
38+
39+
public function has(string $name): bool
6040
{
61-
$result = [];
62-
63-
foreach ($definitions as $name => $definition) {
64-
$result[\strtolower($name)] = $definition;
65-
}
66-
67-
\ksort($result);
68-
69-
return $result;
70-
}
71-
72-
/**
73-
* @param iterable<non-empty-string, non-empty-string> $aliases
74-
* @return array<non-empty-lowercase-string, non-empty-lowercase-string>
75-
*/
76-
private function formatAliases(iterable $aliases): array
77-
{
78-
$result = [];
79-
80-
foreach ($aliases as $alias => $name) {
81-
$result[\strtolower($alias)] = \strtolower($name);
82-
}
83-
84-
return $result;
41+
return isset($this->definitions[\strtolower($name)]);
8542
}
8643

8744
public function get(string $name): TagDefinitionInterface
8845
{
89-
// normalize
90-
$name = \strtolower($name);
91-
// canonicalize
92-
$name = $this->aliases[$name] ?? $name;
93-
94-
return $this->definitions[$name]
46+
return $this->definitions[\strtolower($name)]
9547
?? $this->genericTagDefinition;
9648
}
9749

src/Parser/TagRegistryBuilder.php

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace TypeLang\PhpDoc\Parser;
6+
7+
use TypeLang\PhpDoc\DocBlock\Tag\GenericTagDefinition;
8+
use TypeLang\PhpDoc\DocBlock\TagDefinition\TagDefinitionInterface;
9+
use TypeLang\PhpDoc\Exception\RecursiveTagAliasException;
10+
use TypeLang\PhpDoc\Exception\UnknownTagAliasException;
11+
use TypeLang\PhpDoc\TagRegistryInterface;
12+
13+
final class TagRegistryBuilder
14+
{
15+
/**
16+
* @var array<non-empty-lowercase-string, TagDefinitionInterface>
17+
*/
18+
private array $definitions = [];
19+
20+
/**
21+
* @var array<non-empty-lowercase-string, non-empty-lowercase-string>
22+
*/
23+
private array $aliases = [];
24+
25+
/**
26+
* @param iterable<non-empty-string, TagDefinitionInterface> $definitions
27+
* @param iterable<non-empty-string, non-empty-string> $aliases
28+
*/
29+
public function __construct(
30+
iterable $definitions = [],
31+
iterable $aliases = [],
32+
private TagDefinitionInterface $genericTagDefinition = new GenericTagDefinition(),
33+
) {
34+
$this->addTagDefinitions($definitions);
35+
$this->addTagAliases($aliases);
36+
}
37+
38+
private function setGenericTagDefinition(TagDefinitionInterface $genericTagDefinition): void
39+
{
40+
$this->genericTagDefinition = $genericTagDefinition;
41+
}
42+
43+
public function withGenericTagDefinition(TagDefinitionInterface $definition): self
44+
{
45+
$self = clone $this;
46+
$self->setGenericTagDefinition($definition);
47+
48+
return $self;
49+
}
50+
51+
/**
52+
* @param iterable<non-empty-string, TagDefinitionInterface> $definitions
53+
*/
54+
private function addTagDefinitions(iterable $definitions): void
55+
{
56+
foreach ($definitions as $name => $definition) {
57+
$this->definitions[\strtolower($name)] = $definition;
58+
}
59+
}
60+
61+
/**
62+
* @param iterable<non-empty-string, TagDefinitionInterface> $definitions
63+
*/
64+
public function withAddedTagDefinitions(iterable $definitions): self
65+
{
66+
$self = clone $this;
67+
$self->addTagDefinitions($definitions);
68+
69+
return $self;
70+
}
71+
72+
/**
73+
* @param iterable<non-empty-string, non-empty-string> $aliases
74+
*/
75+
private function addTagAliases(iterable $aliases): void
76+
{
77+
foreach ($aliases as $alias => $name) {
78+
$this->aliases[\strtolower($alias)] = \strtolower($name);
79+
}
80+
}
81+
82+
/**
83+
* @param iterable<non-empty-string, non-empty-string> $aliases
84+
*/
85+
public function withAddedTagAliases(iterable $aliases): self
86+
{
87+
$self = clone $this;
88+
$self->addTagAliases($aliases);
89+
90+
return $self;
91+
}
92+
93+
/**
94+
* @return array<non-empty-lowercase-string, TagDefinitionInterface>
95+
* @throws UnknownTagAliasException if the alias cannot be resolved to a definition
96+
* @throws RecursiveTagAliasException if the alias references form a cycle
97+
*/
98+
private function getAllTagDefinitions(): array
99+
{
100+
$result = $this->definitions;
101+
102+
foreach ($this->aliases as $alias => $_) {
103+
$result[$alias] = $this->definitions[$this->getCanonicalName($alias)];
104+
}
105+
106+
\ksort($result);
107+
108+
return $result;
109+
}
110+
111+
/**
112+
* Follows the alias chain until a registered tag definition is reached
113+
* and returns its canonical (definition) name.
114+
*
115+
* @param non-empty-lowercase-string $alias
116+
* @return non-empty-lowercase-string
117+
* @throws UnknownTagAliasException if a link in the chain has neither a definition nor an alias
118+
* @throws RecursiveTagAliasException if the chain references itself
119+
*/
120+
private function getCanonicalName(string $alias): string
121+
{
122+
$current = $alias;
123+
124+
/** @var list<non-empty-lowercase-string> $visited */
125+
$visited = [];
126+
127+
while (!isset($this->definitions[$current])) {
128+
$canonical = $this->aliases[$current] ?? null;
129+
130+
if ($canonical === null) {
131+
throw UnknownTagAliasException::becauseAliasHasNoDefinition($alias, $visited);
132+
}
133+
134+
if (\in_array($canonical, $visited, true)) {
135+
throw RecursiveTagAliasException::becauseAliasIsRecursive($alias, $visited);
136+
}
137+
138+
$visited[] = $current = $canonical;
139+
}
140+
141+
return $current;
142+
}
143+
144+
public function build(): TagRegistryInterface
145+
{
146+
return new TagRegistry(
147+
definitions: $this->getAllTagDefinitions(),
148+
genericTagDefinition: $this->genericTagDefinition,
149+
);
150+
}
151+
}

src/TagRegistryInterface.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,11 @@ interface TagRegistryInterface extends \Traversable, \Countable
1616
*/
1717
public function get(string $name): TagDefinitionInterface;
1818

19+
/**
20+
* @param non-empty-string $name
21+
*/
22+
public function has(string $name): bool;
23+
1924
/**
2025
* @return int<0, max>
2126
*/

tests/DocBlock/Grammar/DescriptionGrammarRuleTest.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ protected function rule(): DescriptionCombinator
2424
return new \ReflectionClass(DescriptionCombinator::class)
2525
->newLazyProxy(function (DescriptionCombinator $proxy) {
2626
$registry = new TagRegistry(
27+
definitions: [],
2728
genericTagDefinition: new GenericTagDefinition(),
2829
);
2930

0 commit comments

Comments
 (0)