Skip to content

Commit 1b5279c

Browse files
committed
Add tests for StandardTagFactory and adjust for found bugs
1 parent bcfc836 commit 1b5279c

File tree

4 files changed

+579
-85
lines changed

4 files changed

+579
-85
lines changed

src/DocBlock/StandardTagFactory.php

Lines changed: 200 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -15,20 +15,38 @@
1515
use phpDocumentor\Reflection\DocBlock\Tags\Generic;
1616
use phpDocumentor\Reflection\FqsenResolver;
1717
use phpDocumentor\Reflection\Types\Context;
18+
use Webmozart\Assert\Assert;
1819

20+
/**
21+
* Creates a Tag object given the contents of a tag.
22+
*
23+
* This Factory is capable of determining the appropriate class for a tag and instantiate it using its `create`
24+
* factory method. The `create` factory method of a Tag can have a variable number of arguments; this way you can
25+
* pass the dependencies that you need to construct a tag object.
26+
*
27+
* > Important: each parameter in addition to the body variable for the `create` method must default to null, otherwise
28+
* > it violates the constraint with the interface; it is recommended to use the {@see Assert::notNull()} method to
29+
* > verify that a dependency is actually passed.
30+
*
31+
* This Factory also features a Service Locator component that is used to pass the right dependencies to the
32+
* `create` method of a tag; each dependency should be registered as a service or as a parameter.
33+
*
34+
* When you want to use a Tag of your own with custom handling you need to call the `registerTagHandler` method, pass
35+
* the name of the tag and a Fully Qualified Class Name pointing to a class that implements the Tag interface.
36+
*/
1937
final class StandardTagFactory implements TagFactory
2038
{
2139
/** PCRE regular expression matching a tag name. */
2240
const REGEX_TAGNAME = '[\w\-\_\\\\]+';
2341

2442
/**
25-
* @var array An array with a tag as a key, and an FQCN to a class that handles it as an array value.
43+
* @var string[] An array with a tag as a key, and an FQCN to a class that handles it as an array value.
2644
*/
27-
private $tagHandlerMappings = array(
45+
private $tagHandlerMappings = [
2846
'author' => '\phpDocumentor\Reflection\DocBlock\Tags\Author',
2947
'covers' => '\phpDocumentor\Reflection\DocBlock\Tags\Covers',
3048
'deprecated' => '\phpDocumentor\Reflection\DocBlock\Tags\Deprecated',
31-
'example' => '\phpDocumentor\Reflection\DocBlock\Tags\Example',
49+
// 'example' => '\phpDocumentor\Reflection\DocBlock\Tags\Example',
3250
'link' => '\phpDocumentor\Reflection\DocBlock\Tags\Link',
3351
'method' => '\phpDocumentor\Reflection\DocBlock\Tags\Method',
3452
'param' => '\phpDocumentor\Reflection\DocBlock\Tags\Param',
@@ -44,141 +62,246 @@ final class StandardTagFactory implements TagFactory
4462
'uses' => '\phpDocumentor\Reflection\DocBlock\Tags\Uses',
4563
'var' => '\phpDocumentor\Reflection\DocBlock\Tags\Var_',
4664
'version' => '\phpDocumentor\Reflection\DocBlock\Tags\Version'
47-
);
65+
];
66+
67+
/**
68+
* @var \ReflectionParameter[][] a lazy-loading cache containing parameters for each tagHandler that has been used.
69+
*/
70+
private $tagHandlerParameterCache = [];
4871

49-
/** @var FqsenResolver */
72+
/**
73+
* @var FqsenResolver
74+
*/
5075
private $fqsenResolver;
5176

52-
/** @var mixed[] */
77+
/**
78+
* @var mixed[] an array representing a simple Service Locator where we can store parameters and
79+
* services that can be inserted into the Factory Methods of Tag Handlers.
80+
*/
5381
private $serviceLocator = [];
5482

55-
public function __construct(FqsenResolver $fqsenResolver)
83+
/**
84+
* Initialize this tag factory with the means to resolve an FQSEN and optionally a list of tag handlers.
85+
*
86+
* If no tag handlers are provided than the default list in the {@see self::$tagHandlerMappings} property
87+
* is used.
88+
*
89+
* @param FqsenResolver $fqsenResolver
90+
* @param string[] $tagHandlers
91+
*
92+
* @see self::registerTagHandler() to add a new tag handler to the existing default list.
93+
*/
94+
public function __construct(FqsenResolver $fqsenResolver, array $tagHandlers = null)
5695
{
5796
$this->fqsenResolver = $fqsenResolver;
58-
$this->addService($fqsenResolver);
97+
if ($tagHandlers !== null) {
98+
$this->tagHandlerMappings = $tagHandlers;
99+
}
100+
101+
$this->addService($fqsenResolver, FqsenResolver::class);
102+
}
103+
104+
/**
105+
* {@inheritDoc}
106+
*/
107+
public function create($tagLine, Context $context = null)
108+
{
109+
if (! $context) {
110+
$context = new Context('');
111+
}
112+
113+
list($tagName, $tagBody) = $this->extractTagParts($tagLine);
114+
115+
return $this->createTag($tagBody, $tagName, $context);
59116
}
60117

118+
/**
119+
* {@inheritDoc}
120+
*/
61121
public function addParameter($name, $value)
62122
{
63123
$this->serviceLocator[$name] = $value;
64124
}
65125

66-
public function addService($service)
126+
/**
127+
* {@inheritDoc}
128+
*/
129+
public function addService($service, $alias = null)
67130
{
68-
$this->serviceLocator[get_class($service)] = $service;
131+
$this->serviceLocator[$alias ?: get_class($service)] = $service;
69132
}
70133

71134
/**
72-
* Factory method responsible for instantiating the correct sub type.
73-
*
74-
* @param string $tagLine The text for this tag, including description.
75-
* @param Context $context
135+
* {@inheritDoc}
136+
*/
137+
public function registerTagHandler($tagName, $handler)
138+
{
139+
Assert::stringNotEmpty($tagName);
140+
Assert::stringNotEmpty($handler);
141+
Assert::classExists($handler);
142+
Assert::implementsInterface($handler, Tag::class);
143+
144+
if (strpos($tagName, '\\') && $tagName[0] !== '\\') {
145+
throw new \InvalidArgumentException(
146+
'A namespaced tag must have a leading backslash as it must be fully qualified'
147+
);
148+
}
149+
150+
$this->tagHandlerMappings[$tagName] = $handler;
151+
}
152+
153+
/**
154+
* Extracts all components for a tag.
76155
*
77-
* @throws \InvalidArgumentException if an invalid tag line was presented.
156+
* @param string $tagLine
78157
*
79-
* @return static A new tag object.
158+
* @return string[]
80159
*/
81-
public function create($tagLine, Context $context = null)
160+
private function extractTagParts($tagLine)
82161
{
83-
if (! $context) {
84-
$context = new Context('');
162+
$matches = array();
163+
if (! preg_match('/^@(' . self::REGEX_TAGNAME . ')(?:\s*([^\s].*)|$)?/us', $tagLine, $matches)) {
164+
throw new \InvalidArgumentException(
165+
'The tag "' . $tagLine . '" does not seem to be wellformed, please check it for errors'
166+
);
167+
}
168+
169+
if (count($matches) < 3) {
170+
$matches[] = '';
85171
}
86-
list($tagName, $tagBody) = $this->extractTagParts($tagLine);
87172

88-
$handler = Generic::class;
173+
return array_slice($matches, 1);
174+
}
175+
176+
/**
177+
* Creates a new tag object with the given name and body or returns null if the tag name was recognized but the
178+
* body was invalid.
179+
*
180+
* @param string $body
181+
* @param string $name
182+
* @param Context $context
183+
*
184+
* @return Tag|null
185+
*/
186+
private function createTag($body, $name, Context $context)
187+
{
188+
$handlerClassName = $this->findHandlerClassName($name, $context);
189+
$arguments = $this->getArgumentsForParametersFromWiring(
190+
$this->fetchParametersForHandlerFactoryMethod($handlerClassName),
191+
$this->getServiceLocatorWithDynamicParameters($context, $name, $body)
192+
)
193+
;
194+
195+
return call_user_func_array([$handlerClassName, 'create'], $arguments);
196+
}
197+
198+
/**
199+
* Determines the Fully Qualified Class Name of the Factory or Tag (containing a Factory Method `create`).
200+
*
201+
* @param string $tagName
202+
* @param Context $context
203+
*
204+
* @return string
205+
*/
206+
private function findHandlerClassName($tagName, Context $context)
207+
{
208+
$handlerClassName = Generic::class;
89209
if (isset($this->tagHandlerMappings[$tagName])) {
90-
$handler = $this->tagHandlerMappings[$tagName];
210+
$handlerClassName = $this->tagHandlerMappings[$tagName];
91211
} elseif ($this->isAnnotation($tagName)) {
92-
$tagName = (string)$this->fqsenResolver->resolve($tagName, $context);
93-
if (isset($this->tagHandlerMappings[$tagName])) {
94-
$handler = $this->tagHandlerMappings[$tagName];
95-
}
212+
// TODO: Annotation support is planned for a later stage and as such is disabled for now
213+
// $tagName = (string)$this->fqsenResolver->resolve($tagName, $context);
214+
// if (isset($this->annotationMappings[$tagName])) {
215+
// $handlerClassName = $this->annotationMappings[$tagName];
216+
// }
96217
}
97218

98-
$parameters = (new \ReflectionMethod($handler, 'create'))->getParameters();
99-
100-
$wiring = array_merge(
101-
$this->serviceLocator,
102-
[
103-
'name' => $tagName,
104-
'body' => $tagBody,
105-
Context::class => $context
106-
]
107-
);
219+
return $handlerClassName;
220+
}
108221

222+
/**
223+
* Retrieves the arguments that need to be passed to the Factory Method with the given Parameters.
224+
*
225+
* @param \ReflectionParameter[] $parameters
226+
* @param mixed[] $locator
227+
*
228+
* @return mixed[] A series of values that can be passed to the Factory Method of the tag whose parameters
229+
* is provided with this method.
230+
*/
231+
private function getArgumentsForParametersFromWiring($parameters, $locator)
232+
{
109233
$arguments = [];
110234
foreach ($parameters as $index => $parameter) {
111235
$typeHint = $parameter->getClass() ? $parameter->getClass()->getName() : null;
112-
if (isset($wiring[$typeHint])) {
113-
$arguments[] = $wiring[$typeHint];
236+
if (isset($locator[$typeHint])) {
237+
$arguments[] = $locator[$typeHint];
114238
continue;
115239
}
116240

117241
$parameterName = $parameter->getName();
118-
if (isset($wiring[$parameterName])) {
119-
$arguments[] = $wiring[$parameterName];
242+
if (isset($locator[$parameterName])) {
243+
$arguments[] = $locator[$parameterName];
120244
continue;
121245
}
122246

123247
$arguments[] = null;
124248
}
125249

126-
return call_user_func_array([$handler, 'create'], $arguments);
250+
return $arguments;
127251
}
128252

129253
/**
130-
* Registers a handler for tags.
131-
*
132-
* Registers a handler for tags. The class specified is autoloaded if it's not available. It must inherit from
133-
* this class.
254+
* Retrieves a series of ReflectionParameter objects for the static 'create' method of the given
255+
* tag handler class name.
134256
*
135-
* @param string $tag Name of tag to register a handler for. When registering a namespaced tag, the full
136-
* name, along with a prefixing slash MUST be provided.
137-
* @param string|null $handler FQCN of handler.
257+
* @param string $handlerClassName
138258
*
139-
* @return bool TRUE on success, FALSE on failure.
259+
* @return \ReflectionParameter[]
140260
*/
141-
public function registerTagHandler($tag, $handler)
261+
private function fetchParametersForHandlerFactoryMethod($handlerClassName)
142262
{
143-
$tag = trim((string)$tag);
144-
145-
if ('' !== $tag
146-
&& class_exists($handler)
147-
&& is_subclass_of($handler, Tag::class)
148-
&& ! strpos($tag, '\\') //Accept no slash, and 1st slash at offset 0.
149-
) {
150-
$this->tagHandlerMappings[$tag] = $handler;
151-
152-
return true;
263+
if (! isset($this->tagHandlerParameterCache[$handlerClassName])) {
264+
$methodReflection = new \ReflectionMethod($handlerClassName, 'create');
265+
$this->tagHandlerParameterCache[$handlerClassName] = $methodReflection->getParameters();
153266
}
154267

155-
return false;
268+
return $this->tagHandlerParameterCache[$handlerClassName];
156269
}
157270

158271
/**
159-
* Extracts all components for a tag.
272+
* Returns a copy of this class' Service Locator with added dynamic parameters, such as the tag's name, body and
273+
* Context.
160274
*
161-
* @param string $tagLine
275+
* @param Context $context The Context (namespace and aliasses) that may be passed and is used to resolve FQSENs.
276+
* @param string $tagName The name of the tag that may be passed onto the factory method of the Tag class.
277+
* @param string $tagBody The body of the tag that may be passed onto the factory method of the Tag class.
162278
*
163-
* @return string[]
279+
* @return mixed[]
164280
*/
165-
private function extractTagParts($tagLine)
281+
private function getServiceLocatorWithDynamicParameters(Context $context, $tagName, $tagBody)
166282
{
167-
$matches = array();
168-
if (! preg_match('/^@(' . self::REGEX_TAGNAME . ')(?:\s*([^\s].*)|$)?/us', $tagLine, $matches)) {
169-
throw new \InvalidArgumentException(
170-
'The tag "' . $tagLine . '" does not seem to be wellformed, please check it for errors'
171-
);
172-
}
173-
174-
if (count($matches) < 3) {
175-
$matches[] = '';
176-
}
283+
$locator = array_merge(
284+
$this->serviceLocator,
285+
[
286+
'name' => $tagName,
287+
'body' => $tagBody,
288+
Context::class => $context
289+
]
290+
);
177291

178-
return array_slice($matches, 1);
292+
return $locator;
179293
}
180294

181-
private function isAnnotation($tag)
295+
/**
296+
* Returns whether the given tag belongs to an annotation.
297+
*
298+
* @param string $tagContent
299+
*
300+
* @todo this method should be populated once we implement Annotation notation support.
301+
*
302+
* @return bool
303+
*/
304+
private function isAnnotation($tagContent)
182305
{
183306
// 1. Contains a namespace separator
184307
// 2. Contains parenthesis

0 commit comments

Comments
 (0)