Linker::findAlias()   B
last analyzed

Complexity

Conditions 6
Paths 5

Size

Total Lines 32

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 16
CRAP Score 6

Importance

Changes 0
Metric Value
cc 6
nc 5
nop 2
dl 0
loc 32
rs 8.7857
c 0
b 0
f 0
ccs 16
cts 16
cp 1
crap 6
1
<?php
2
declare(strict_types=1);
3
4
/**
5
 * This file is part of phpDocumentor.
6
 *
7
 * For the full copyright and license information, please view the LICENSE
8
 * file that was distributed with this source code.
9
 *
10
 * @author    Mike van Riel <[email protected]>
11
 * @copyright 2010-2018 Mike van Riel / Naenius (http://www.naenius.com)
12
 * @license   http://www.opensource.org/licenses/mit-license.php MIT
13
 * @link      http://phpdoc.org
14
 */
15
16
namespace phpDocumentor\Compiler\Linker;
17
18
use phpDocumentor\Compiler\CompilerPassInterface;
19
use phpDocumentor\Descriptor\ClassDescriptor;
20
use phpDocumentor\Descriptor\DescriptorAbstract;
21
use phpDocumentor\Descriptor\FileDescriptor;
22
use phpDocumentor\Descriptor\InterfaceDescriptor;
23
use phpDocumentor\Descriptor\Interfaces\ProjectInterface;
24
use phpDocumentor\Descriptor\NamespaceDescriptor;
25
use phpDocumentor\Descriptor\ProjectDescriptor;
26
use phpDocumentor\Descriptor\TraitDescriptor;
27
use phpDocumentor\Descriptor\Type\UnknownTypeDescriptor;
28
use Traversable;
29
30
/**
31
 * The linker contains all rules to replace FQSENs in the ProjectDescriptor with aliases to objects.
32
 *
33
 * This object contains a list of class FQCNs for Descriptors and their associated linker rules.
34
 *
35
 * An example scenario should be:
36
 *
37
 *     The Descriptor ``\phpDocumentor\Descriptor\ClassDescriptor`` has a *Substitute* rule determining that the
38
 *     contents of the ``Parent`` field should be substituted with another ClassDescriptor with the FQCN
39
 *     represented by the value of the Parent field. In addition (second element) it has an *Analyse* rule
40
 *     specifying that the contents of the ``Methods`` field should be interpreted by the linker. Because that field
41
 *     contains an array or Descriptor Collection will each element be analysed by the linker.
42
 *
43
 * As can be seen in the above example is it possible to analyse a tree structure and substitute FQSENs where
44
 * encountered.
45
 */
46
class Linker implements CompilerPassInterface
47
{
48
    const COMPILER_PRIORITY = 10000;
49
50
    const CONTEXT_MARKER = '@context';
51
52
    /** @var DescriptorAbstract[] */
53
    protected $elementList = [];
54
55
    /** @var string[][] */
56
    protected $substitutions = [];
57
58
    /** @var string[] Prevent cycles by tracking which objects have been analyzed */
59
    protected $processedObjects = [];
60
61 1
    public function getDescription(): string
62
    {
63 1
        return 'Replace textual FQCNs with object aliases';
64
    }
65
66
    /**
67
     * Initializes the linker with a series of Descriptors to link to.
68
     *
69
     * @param string[][] $substitutions
70
     */
71 4
    public function __construct(array $substitutions)
72
    {
73 4
        $this->substitutions = $substitutions;
74 4
    }
75
76 1
    public function execute(ProjectDescriptor $project): void
77
    {
78 1
        $this->setObjectAliasesList($project->getIndexes()->elements->getAll());
0 ignored issues
show
Documentation introduced by
The property elements does not exist on object<phpDocumentor\Descriptor\Collection>. Since you implemented __get, maybe consider adding a @property annotation.

Since your code implements the magic getter _get, this function will be called for any read access on an undefined variable. You can add the @property annotation to your class or interface to document the existence of this variable.

<?php

/**
 * @property int $x
 * @property int $y
 * @property string $text
 */
class MyLabel
{
    private $properties;

    private $allowedProperties = array('x', 'y', 'text');

    public function __get($name)
    {
        if (isset($properties[$name]) && in_array($name, $this->allowedProperties)) {
            return $properties[$name];
        } else {
            return null;
        }
    }

    public function __set($name, $value)
    {
        if (in_array($name, $this->allowedProperties)) {
            $properties[$name] = $value;
        } else {
            throw new \LogicException("Property $name is not defined.");
        }
    }

}

If the property has read access only, you can use the @property-read annotation instead.

Of course, you may also just have mistyped another name, in which case you should fix the error.

See also the PhpDoc documentation for @property.

Loading history...
79 1
        $this->substitute($project);
80 1
    }
81
82
    /**
83
     * Returns the list of substitutions for the linker.
84
     *
85
     * @return string[][]
86
     */
87 1
    public function getSubstitutions(): array
88
    {
89 1
        return $this->substitutions;
90
    }
91
92
    /**
93
     * Sets the list of object aliases to resolve the FQSENs with.
94
     *
95
     * @param DescriptorAbstract[] $elementList
96
     */
97 3
    public function setObjectAliasesList(array $elementList): void
98
    {
99 3
        $this->elementList = $elementList;
100 3
    }
101
102
    /**
103
     * Substitutes the given item or its children's FQCN with an object alias.
104
     *
105
     * This method may do either of the following depending on the item's type
106
     *
107
     * String
108
     *     If the given item is a string then this method will attempt to find an appropriate Class, Interface or
109
     *     TraitDescriptor object and return that. See {@see findAlias()} for more information on the normalization
110
     *     of these strings.
111
     *
112
     * Array or Traversable
113
     *     Iterate through each item, pass each key's contents to a new call to substitute and replace the key's
114
     *     contents if the contents is not an object (objects automatically update and saves performance).
115
     *
116
     * Object
117
     *     Determines all eligible substitutions using the substitutions property, construct a getter and retrieve
118
     *     the field's contents. Pass these contents to a new call of substitute and use a setter to replace the field's
119
     *     contents if anything other than null is returned.
120
     *
121
     * This method will return null if no substitution was possible and all of the above should not update the parent
122
     * item when null is passed.
123
     *
124
     * @param string|object|Traversable|array $item
125
     * @param DescriptorAbstract|null         $container A descriptor that acts as container for all elements
126
     *                                        underneath or null if there is no current container.
127
     *
128
     * @return null|string|DescriptorAbstract
129
     */
130 7
    public function substitute($item, $container = null)
131
    {
132 7
        $result = null;
133
134 7
        if (is_string($item)) {
135 5
            $result = $this->findAlias($item, $container);
136 7
        } elseif (is_array($item) || ($item instanceof Traversable && ! $item instanceof ProjectInterface)) {
137 2
            $isModified = false;
138 2
            foreach ($item as $key => $element) {
139 2
                $isModified = true;
140
141 2
                $element = $this->substitute($element, $container);
142 2
                if ($element !== null) {
143 2
                    $item[$key] = $element;
144
                }
145
            }
146
147 2
            if ($isModified) {
148 2
                $result = $item;
149
            }
150 6
        } elseif (is_object($item) && $item instanceof UnknownTypeDescriptor) {
151 1
            $alias = $this->findAlias($item->getName());
152 1
            $result = $alias ?: $item;
153 5
        } elseif (is_object($item)) {
154 5
            $hash = spl_object_hash($item);
155 5
            if (isset($this->processedObjects[$hash])) {
156
                // if analyzed; just return
157 1
                return null;
158
            }
159
160 5
            $newContainer = ($this->isDescriptorContainer($item)) ? $item : $container;
161
162 5
            $this->processedObjects[$hash] = $hash;
163
164 5
            $objectClassName = get_class($item);
165 5
            $fieldNames = $this->substitutions[$objectClassName] ?? [];
166
167 5
            foreach ($fieldNames as $fieldName) {
168 4
                $fieldValue = $this->findFieldValue($item, $fieldName);
169 4
                $response = $this->substitute($fieldValue, $newContainer);
0 ignored issues
show
Bug introduced by
It seems like $newContainer defined by $this->isDescriptorConta...m) ? $item : $container on line 160 can also be of type object; however, phpDocumentor\Compiler\Linker\Linker::substitute() does only seem to accept object<phpDocumentor\Des...escriptorAbstract>|null, maybe add an additional type check?

If a method or function can return multiple different values and unless you are sure that you only can receive a single value in this context, we recommend to add an additional type check:

/**
 * @return array|string
 */
function returnsDifferentValues($x) {
    if ($x) {
        return 'foo';
    }

    return array();
}

$x = returnsDifferentValues($y);
if (is_array($x)) {
    // $x is an array.
}

If this a common case that PHP Analyzer should handle natively, please let us know by opening an issue.

Loading history...
170
171
                // if the returned response is not an object it must be grafted on the calling object
172 4
                if ($response !== null) {
173 4
                    $setter = 'set' . ucfirst($fieldName);
174 4
                    $item->{$setter}($response);
175
                }
176
            }
177
        }
178
179 7
        return $result;
180
    }
181
182
    /**
183
     * Attempts to find a Descriptor object alias with the FQSEN of the element it represents.
184
     *
185
     * This method will try to fetch an element after normalizing the provided FQSEN. The FQSEN may contain references
186
     * (bindings) that can only be resolved during linking (such as `self`) or it may contain a context marker
187
     * {@see CONTEXT_MARKER}.
188
     *
189
     * If there is a context marker then this method will see if a child of the given container exists that matches the
190
     * element following the marker. If such a child does not exist in the current container then the namespace is
191
     * queried if a child exists there that matches.
192
     *
193
     * For example:
194
     *
195
     *     Given the Fqsen `@context::myFunction()` and the lastContainer `\My\Class` will this method first check
196
     *     to see if `\My\Class::myFunction()` exists; if it doesn't it will then check if `\My\myFunction()` exists.
197
     *
198
     * If neither element exists then this method assumes it is an undocumented class/trait/interface and change the
199
     * given FQSEN by returning the namespaced element name (thus in the example above that would be
200
     * `\My\myFunction()`). The calling method {@see substitute()} will then replace the value of the field containing
201
     * the context marker with this normalized string.
202
     *
203
     * @param DescriptorAbstract|null $container
204
     *
205
     * @return DescriptorAbstract|string|null
206
     */
207 5
    public function findAlias(string $fqsen, $container = null)
208
    {
209 5
        $fqsen = $this->replacePseudoTypes($fqsen, $container);
210
211 5
        if ($this->isContextMarkerInFqsen($fqsen) && $container instanceof DescriptorAbstract) {
212
            // first exchange `@context::element` for `\My\Class::element` and if it exists, return that
213 4
            $classMember = $this->fetchElementByFqsen($this->getTypeWithClassAsContext($fqsen, $container));
214 4
            if ($classMember) {
215 1
                return $classMember;
216
            }
217
218
            // otherwise exchange `@context::element` for `\My\element` and if it exists, return that
219 3
            $namespaceContext = $this->getTypeWithNamespaceAsContext($fqsen, $container);
220 3
            $namespaceMember = $this->fetchElementByFqsen($namespaceContext);
0 ignored issues
show
Bug introduced by
Are you sure the assignment to $namespaceMember is correct as $this->fetchElementByFqsen($namespaceContext) (which targets phpDocumentor\Compiler\L...::fetchElementByFqsen()) seems to always return null.

This check looks for function or method calls that always return null and whose return value is assigned to a variable.

class A
{
    function getObject()
    {
        return null;
    }

}

$a = new A();
$object = $a->getObject();

The method getObject() can return nothing but null, so it makes no sense to assign that value to a variable.

The reason is most likely that a function or method is imcomplete or has been reduced for debug purposes.

Loading history...
221 3
            if ($namespaceMember) {
222 1
                return $namespaceMember;
223
            }
224
225
            // otherwise check if the element exists in the global namespace and if it exists, return that
226 2
            $globalNamespaceContext = $this->getTypeWithGlobalNamespaceAsContext($fqsen);
227 2
            $globalNamespaceMember = $this->fetchElementByFqsen($globalNamespaceContext);
0 ignored issues
show
Bug introduced by
Are you sure the assignment to $globalNamespaceMember is correct as $this->fetchElementByFqs...globalNamespaceContext) (which targets phpDocumentor\Compiler\L...::fetchElementByFqsen()) seems to always return null.

This check looks for function or method calls that always return null and whose return value is assigned to a variable.

class A
{
    function getObject()
    {
        return null;
    }

}

$a = new A();
$object = $a->getObject();

The method getObject() can return nothing but null, so it makes no sense to assign that value to a variable.

The reason is most likely that a function or method is imcomplete or has been reduced for debug purposes.

Loading history...
228 2
            if ($globalNamespaceMember) {
229 1
                return $globalNamespaceMember;
230
            }
231
232
            // Otherwise we assume it is an undocumented class/interface/trait and return `\My\element` so
233
            // that the name containing the marker may be replaced by the class reference as string
234 1
            return $namespaceContext;
235
        }
236
237 1
        return $this->fetchElementByFqsen($fqsen);
238
    }
239
240
    /**
241
     * Returns the value of a field in the given object.
242
     *
243
     * @param object $object
244
     * @return string|object
245
     */
246 1
    public function findFieldValue($object, string $fieldName)
247
    {
248 1
        $getter = 'get' . ucfirst($fieldName);
249
250 1
        return $object->{$getter}();
251
    }
252
253
    /**
254
     * Returns true if the given Descriptor is a container type.
255
     *
256
     * @param DescriptorAbstract|mixed $item
257
     */
258 1
    protected function isDescriptorContainer($item): bool
259
    {
260 1
        return $item instanceof FileDescriptor
261 1
            || $item instanceof NamespaceDescriptor
262 1
            || $item instanceof ClassDescriptor
263 1
            || $item instanceof TraitDescriptor
264 1
            || $item instanceof InterfaceDescriptor;
265
    }
266
267
    /**
268
     * Replaces pseudo-types, such as `self`, into a normalized version based on the last container that was
269
     * encountered.
270
     *
271
     * @param DescriptorAbstract|null $container
272
     */
273 2
    protected function replacePseudoTypes(string $fqsen, $container): string
274
    {
275 2
        $pseudoTypes = ['self', '$this'];
276 2
        foreach ($pseudoTypes as $pseudoType) {
277 2
            if ((strpos($fqsen, $pseudoType . '::') === 0 || $fqsen === $pseudoType) && $container) {
278 2
                $fqsen = $container->getFullyQualifiedStructuralElementName()
279 2
                    . substr($fqsen, strlen($pseudoType));
280
            }
281
        }
282
283 2
        return $fqsen;
284
    }
285
286
    /**
287
     * Returns true if the context marker is found in the given FQSEN.
288
     */
289 4
    protected function isContextMarkerInFqsen(string $fqsen): bool
290
    {
291 4
        return strpos($fqsen, self::CONTEXT_MARKER) !== false;
292
    }
293
294
    /**
295
     * Normalizes the given FQSEN as if the context marker represents a class/interface/trait as parent.
296
     */
297 2
    protected function getTypeWithClassAsContext(string $fqsen, DescriptorAbstract $container): string
298
    {
299 2
        if (!$container instanceof ClassDescriptor
300 2
            && !$container instanceof InterfaceDescriptor
301 2
            && !$container instanceof TraitDescriptor
302
        ) {
303 1
            return $fqsen;
304
        }
305
306 1
        $containerFqsen = $container->getFullyQualifiedStructuralElementName();
307
308 1
        return str_replace(self::CONTEXT_MARKER . '::', $containerFqsen . '::', $fqsen);
309
    }
310
311
    /**
312
     * Normalizes the given FQSEN as if the context marker represents a class/interface/trait as parent.
313
     */
314 2
    protected function getTypeWithNamespaceAsContext(string $fqsen, DescriptorAbstract $container): string
315
    {
316 2
        $namespace = $container instanceof NamespaceDescriptor ? $container : $container->getNamespace();
317 2
        $fqnn = $namespace instanceof NamespaceDescriptor
318 1
            ? $namespace->getFullyQualifiedStructuralElementName()
319 2
            : $namespace;
320
321 2
        return str_replace(self::CONTEXT_MARKER . '::', $fqnn . '\\', $fqsen);
322
    }
323
324
    /**
325
     * Normalizes the given FQSEN as if the context marker represents the global namespace as parent.
326
     */
327 2
    protected function getTypeWithGlobalNamespaceAsContext(string $fqsen): string
328
    {
329 2
        return str_replace(self::CONTEXT_MARKER . '::', '\\', $fqsen);
330
    }
331
332
    /**
333
     * Attempts to find an element with the given Fqsen in the list of elements for this project and returns null if
334
     * it cannot find it.
335
     *
336
     * @return DescriptorAbstract|null
337
     */
338 4
    protected function fetchElementByFqsen(string $fqsen)
339
    {
340 4
        return $this->elementList[$fqsen] ?? null;
341
    }
342
}
343