Completed
Pull Request — master (#470)
by Claus
01:32
created

ViewHelperResolver::resolveViewHelperClassName()   B

Complexity

Conditions 10
Paths 27

Size

Total Lines 45

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 10
nc 27
nop 2
dl 0
loc 45
rs 7.3333
c 0
b 0
f 0

How to fix   Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

1
<?php
2
declare(strict_types=1);
3
namespace TYPO3Fluid\Fluid\Core\ViewHelper;
4
5
/*
6
 * This file belongs to the package "TYPO3 Fluid".
7
 * See LICENSE.txt that was shipped with this package.
8
 */
9
10
use TYPO3Fluid\Fluid\Component\ComponentInterface;
11
use TYPO3Fluid\Fluid\Component\Error\ChildNotFoundException;
12
use TYPO3Fluid\Fluid\Core\Parser\Exception;
0 ignored issues
show
Bug introduced by
This use statement conflicts with another class in this namespace, TYPO3Fluid\Fluid\Core\ViewHelper\Exception.

Let’s assume that you have a directory layout like this:

.
|-- OtherDir
|   |-- Bar.php
|   `-- Foo.php
`-- SomeDir
    `-- Foo.php

and let’s assume the following content of Bar.php:

// Bar.php
namespace OtherDir;

use SomeDir\Foo; // This now conflicts the class OtherDir\Foo

If both files OtherDir/Foo.php and SomeDir/Foo.php are loaded in the same runtime, you will see a PHP error such as the following:

PHP Fatal error:  Cannot use SomeDir\Foo as Foo because the name is already in use in OtherDir/Foo.php

However, as OtherDir/Foo.php does not necessarily have to be loaded and the error is only triggered if it is loaded before OtherDir/Bar.php, this problem might go unnoticed for a while. In order to prevent this error from surfacing, you must import the namespace with a different alias:

// Bar.php
namespace OtherDir;

use SomeDir\Foo as SomeDirFoo; // There is no conflict anymore.
Loading history...
13
use TYPO3Fluid\Fluid\Core\Parser\SyntaxTree\AtomNode;
14
use TYPO3Fluid\Fluid\Core\Parser\SyntaxTree\ReferenceNode;
15
use TYPO3Fluid\Fluid\Core\Rendering\RenderingContextInterface;
16
17
/**
18
 * Class ViewHelperResolver
19
 *
20
 * Responsible for resolving instances of ViewHelpers and for
21
 * interacting with ViewHelpers; to translate ViewHelper names
22
 * into actual class names and resolve their ArgumentDefinitions.
23
 *
24
 * Replacing this class in for example a framework allows that
25
 * framework to be responsible for creating ViewHelper instances
26
 * and detecting possible arguments.
27
 */
28
class ViewHelperResolver
29
{
30
    /**
31
     * @var RenderingContextInterface
32
     */
33
    protected $renderingContext;
34
35
    /**
36
     * @var array
37
     */
38
    protected $resolvedViewHelperClassNames = [];
39
40
    /**
41
     * Atom paths indexed by namespace, in
42
     * [shortname => [path1, path2, ...]] format.
43
     * @var array
44
     */
45
    protected $atoms = [];
46
47
    /**
48
     * Namespaces requested by the template being rendered,
49
     * in [shortname => [phpnamespace1, phpnamespace2, ...]] format.
50
     *
51
     * @var array
52
     */
53
    protected $namespaces = [
54
        'f' => ['TYPO3Fluid\\Fluid\\ViewHelpers']
55
    ];
56
57
    /**
58
     * @var array
59
     */
60
    protected $aliases = [
61
        'html' => ['f', 'html'],
62
        'raw' => ['f', 'format.raw'],
63
    ];
64
65
    public function __construct(RenderingContextInterface $renderingContext)
66
    {
67
        $this->renderingContext = $renderingContext;
68
    }
69
70
    public function addAtomPath(string $namespace, string $path): void
71
    {
72
        if (!in_array($path, $this->atoms[$namespace] ?? [], true)) {
73
            $this->atoms[$namespace][] = $path;
74
        }
75
    }
76
77
    /**
78
     * Add all Atom paths as array-in-array, with the first level key
79
     * being the namespace and the value being an array of paths.
80
     *
81
     * Example:
82
     *
83
     * $resolver->addAtomPaths(
84
     *   [
85
     *     'my' => [
86
     *       'path/first/',
87
     *       'path/second/',
88
     *     ],
89
     *     'other' => [
90
     *       'path/third/',
91
     *     ],
92
     *   ]
93
     * );
94
     *
95
     * @param iterable|string[][] $paths
96
     */
97
    public function addAtomPaths(iterable $paths): void
98
    {
99
        foreach ($paths as $namespace => $collection) {
100
            foreach ($collection as $path) {
101
                $this->addAtomPath($namespace, $path);
102
            }
103
        }
104
    }
105
106
    public function resolveAtom(string $namespace, string $name): ComponentInterface
107
    {
108
        $file = $this->resolveAtomFile($namespace, $name);
109
        if (!$file) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $file of type null|string is loosely compared to false; this is ambiguous if the string can be empty. You might want to explicitly use === null instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For string values, the empty string '' is a special case, in particular the following results might be unexpected:

''   == false // true
''   == null  // true
'ab' == false // false
'ab' == null  // false

// It is often better to use strict comparison
'' === false // false
'' === null  // false
Loading history...
110
            $paths = empty($this->atoms[$namespace]) ? 'none' : implode(', ', $this->atoms[$namespace]);
111
            throw new ChildNotFoundException(
112
                'Atom "' . $namespace . ':' . $name . '" could not be resolved. We looked in: ' . $paths,
113
                1564404340
114
            );
115
        }
116
        return $this->renderingContext->getTemplateParser()->parseFile($file)->setName($namespace . ':' . $name);
0 ignored issues
show
Bug introduced by
It seems like you code against a concrete implementation and not the interface TYPO3Fluid\Fluid\Component\ComponentInterface as the method setName() does only exist in the following implementations of said interface: TYPO3Fluid\Fluid\Core\Parser\SyntaxTree\AtomNode, TYPO3Fluid\Fluid\Core\Parser\SyntaxTree\EntryNode.

Let’s take a look at an example:

interface User
{
    /** @return string */
    public function getPassword();
}

class MyUser implements User
{
    public function getPassword()
    {
        // return something
    }

    public function getDisplayName()
    {
        // return some name.
    }
}

class AuthSystem
{
    public function authenticate(User $user)
    {
        $this->logger->info(sprintf('Authenticating %s.', $user->getDisplayName()));
        // do something.
    }
}

In the above example, the authenticate() method works fine as long as you just pass instances of MyUser. However, if you now also want to pass a different implementation of User which does not have a getDisplayName() method, the code will break.

Available Fixes

  1. Change the type-hint for the parameter:

    class AuthSystem
    {
        public function authenticate(MyUser $user) { /* ... */ }
    }
    
  2. Add an additional type-check:

    class AuthSystem
    {
        public function authenticate(User $user)
        {
            if ($user instanceof MyUser) {
                $this->logger->info(/** ... */);
            }
    
            // or alternatively
            if ( ! $user instanceof MyUser) {
                throw new \LogicException(
                    '$user must be an instance of MyUser, '
                   .'other instances are not supported.'
                );
            }
    
        }
    }
    
Note: PHP Analyzer uses reverse abstract interpretation to narrow down the types inside the if block in such a case.
  1. Add the method to the interface:

    interface User
    {
        /** @return string */
        public function getPassword();
    
        /** @return string */
        public function getDisplayName();
    }
    
Loading history...
117
    }
118
119
    public function resolveAtomFile(string $namespace, string $name): ?string
120
    {
121
        if (!isset($this->atoms[$namespace])) {
122
            return null;
123
        }
124
        $expectedFileParts = explode('.', $name);
125
        foreach (array_reverse($this->atoms[$namespace]) as $path) {
126
            $parts = $expectedFileParts;
127
            $subPath = $path;
128
            while ($expectedFilePart = array_shift($parts)) {
129
                $subPath .= '/' . $expectedFilePart;
130
                if (!is_dir($subPath)) {
131
                    break;
132
                }
133
            }
134
            $filePathAndFilename = $subPath . '.html';
135
            if (file_exists($filePathAndFilename)) {
136
                return $filePathAndFilename;
137
            }
138
        }
139
        return null;
140
    }
141
142
    /**
143
     * @return array|string[][]
144
     */
145
    public function getAtoms(): array
146
    {
147
        return $this->atoms;
148
    }
149
150
    /**
151
     * @return array
152
     */
153
    public function getNamespaces(): array
154
    {
155
        return $this->namespaces;
156
    }
157
158
    /**
159
     * Adds an alias of a ViewHelper, allowing you to call for example
160
     *
161
     * @param string $alias
162
     * @param string $namespace
163
     * @param string $identifier
164
     */
165
    public function addViewHelperAlias(string $alias, string $namespace, string $identifier)
166
    {
167
        $this->aliases[$alias] = [$namespace, $identifier];
168
    }
169
170
    public function isAliasRegistered(string $alias): bool
171
    {
172
        return isset($this->aliases[$alias]);
173
    }
174
175
    /**
176
     * Add a PHP namespace where ViewHelpers can be found and give
177
     * it an alias/identifier.
178
     *
179
     * The provided namespace can be either a single namespace or
180
     * an array of namespaces, as strings. The identifier/alias is
181
     * always a single, alpha-numeric ASCII string.
182
     *
183
     * Calling this method multiple times with different PHP namespaces
184
     * for the same alias causes that namespace to be *extended*,
185
     * meaning that the PHP namespace you provide second, third etc.
186
     * are also used in lookups and are used *first*, so that if any
187
     * of the namespaces you add contains a class placed and named the
188
     * same way as one that exists in an earlier namespace, then your
189
     * class gets used instead of the earlier one.
190
     *
191
     * Example:
192
     *
193
     * $resolver->addNamespace('my', 'My\Package\ViewHelpers');
194
     * // Any ViewHelpers under this namespace can now be accessed using for example {my:example()}
195
     * // Now, assuming you also have an ExampleViewHelper class in a different
196
     * // namespace and wish to make that ExampleViewHelper override the other:
197
     * $resolver->addNamespace('my', 'My\OtherPackage\ViewHelpers');
198
     * // Now, since ExampleViewHelper exists in both places but the
199
     * // My\OtherPackage\ViewHelpers namespace was added *last*, Fluid
200
     * // will find and use My\OtherPackage\ViewHelpers\ExampleViewHelper.
201
     *
202
     * Alternatively, setNamespaces() can be used to reset and redefine
203
     * all previously added namespaces - which is great for cases where
204
     * you need to remove or replace previously added namespaces. Be aware
205
     * that setNamespaces() also removes the default "f" namespace, so
206
     * when you use this method you should always include the "f" namespace.
207
     *
208
     * @param string $identifier
209
     * @param string|array $phpNamespace
210
     * @return void
211
     */
212
    public function addNamespace(string $identifier, $phpNamespace): void
213
    {
214
        if (!array_key_exists($identifier, $this->namespaces) || $this->namespaces[$identifier] === null) {
215
            $this->namespaces[$identifier] = $phpNamespace === null ? null : (array) $phpNamespace;
216
        } elseif (is_array($phpNamespace)) {
217
            $this->namespaces[$identifier] = array_unique(array_merge($this->namespaces[$identifier], $phpNamespace));
218
        } elseif (isset($this->namespaces[$identifier]) && !in_array($phpNamespace, $this->namespaces[$identifier], true)) {
219
            $this->namespaces[$identifier][] = $phpNamespace;
220
        }
221
        $this->resolvedViewHelperClassNames = [];
222
    }
223
224
    /**
225
     * Wrapper to allow adding namespaces in bulk *without* first
226
     * clearing the already added namespaces. Utility method mainly
227
     * used in compiled templates, where some namespaces can be added
228
     * from outside and some can be added from compiled values.
229
     *
230
     * @param array $namespaces
231
     * @return void
232
     */
233
    public function addNamespaces(array $namespaces): void
234
    {
235
        foreach ($namespaces as $identifier => $namespace) {
236
            $this->addNamespace($identifier, $namespace);
237
        }
238
    }
239
240
    public function removeNamespace(string $identifier, $phpNamespace): void
241
    {
242
        if (($key = array_search($phpNamespace, $this->namespaces[$identifier], true)) !== false) {
243
            unset($this->namespaces[$identifier][$key]);
244
            if (empty($this->namespaces[$identifier])) {
245
                unset($this->namespaces[$identifier]);
246
            }
247
        }
248
    }
249
250
    /**
251
     * Resolves the PHP namespace based on the Fluid xmlns namespace,
252
     * which can be either a URL matching the Patterns::NAMESPACEPREFIX
253
     * and Patterns::NAMESPACESUFFIX rules, or a PHP namespace. When
254
     * namespace is a PHP namespace it is optional to suffix it with
255
     * the "\ViewHelpers" segment, e.g. "My\Package" is as valid to
256
     * use as "My\Package\ViewHelpers" is.
257
     *
258
     * @param string $fluidNamespace
259
     * @return string
260
     */
261
    public function resolvePhpNamespaceFromFluidNamespace(string $fluidNamespace): string
262
    {
263
        $prefix = 'http://typo3.org/ns/';
264
        $suffix = '/ViewHelpers';
265
        $namespace = $fluidNamespace;
266
        $suffixLength = strlen($suffix);
267
        $phpNamespaceSuffix = str_replace('/', '\\', $suffix);
268
        $extractedSuffix = substr($fluidNamespace, 0 - $suffixLength);
269
        if (strpos($fluidNamespace, $prefix) === 0 && $extractedSuffix === $suffix) {
270
            // convention assumed: URL starts with prefix and ends with suffix
271
            $namespace = substr($fluidNamespace, strlen($prefix));
272
        }
273
        $namespace = str_replace('/', '\\', $namespace);
274
        if (substr($namespace, 0 - strlen($phpNamespaceSuffix)) !== $phpNamespaceSuffix) {
275
            $namespace .= $phpNamespaceSuffix;
276
        }
277
        return $namespace;
278
    }
279
280
    /**
281
     * Set all namespaces as an array of ['identifier' => ['Php\Namespace1', 'Php\Namespace2']]
282
     * namespace definitions. For convenience and legacy support, a
283
     * format of ['identifier' => 'Only\Php\Namespace'] is allowed,
284
     * but will internally convert the namespace to an array and
285
     * allow it to be extended by addNamespace().
286
     *
287
     * Note that when using this method the default "f" namespace is
288
     * also removed and so must be included in $namespaces or added
289
     * after using addNamespace(). Or, add the PHP namespaces that
290
     * belonged to "f" as a new alias and use that in your templates.
291
     *
292
     * Use getNamespaces() to get an array of currently added namespaces.
293
     *
294
     * @param array $namespaces
295
     * @return void
296
     */
297
    public function setNamespaces(array $namespaces): void
298
    {
299
        $this->namespaces = [];
300
        foreach ($namespaces as $identifier => $phpNamespace) {
301
            $this->namespaces[$identifier] = $phpNamespace === null ? null : (array) $phpNamespace;
302
        }
303
    }
304
305
    /**
306
     * Validates the given namespaceIdentifier and returns FALSE
307
     * if the namespace is unknown, causing the tag to be rendered
308
     * without processing.
309
     *
310
     * @param string $namespaceIdentifier
311
     * @return boolean TRUE if the given namespace is valid, otherwise FALSE
312
     */
313
    public function isNamespaceValid(string $namespaceIdentifier): bool
314
    {
315
        if (!array_key_exists($namespaceIdentifier, $this->namespaces)) {
316
            return false;
317
        }
318
319
        return $this->namespaces[$namespaceIdentifier] !== null && $namespaceIdentifier !== 'this';
320
    }
321
322
    /**
323
     * Validates the given namespaceIdentifier and returns FALSE
324
     * if the namespace is unknown and not ignored
325
     *
326
     * @param string $namespaceIdentifier
327
     * @return boolean TRUE if the given namespace is valid, otherwise FALSE
328
     */
329
    public function isNamespaceValidOrIgnored(string $namespaceIdentifier): bool
330
    {
331
        if ($this->isNamespaceValid($namespaceIdentifier)) {
332
            return true;
333
        }
334
335
        if (array_key_exists($namespaceIdentifier, $this->namespaces) || array_key_exists($namespaceIdentifier, $this->atoms)) {
336
            return true;
337
        }
338
        return $this->isNamespaceIgnored($namespaceIdentifier);
339
    }
340
341
    /**
342
     * @param string $namespaceIdentifier
343
     * @return boolean
344
     */
345
    public function isNamespaceIgnored(string $namespaceIdentifier): bool
346
    {
347
        if (array_key_exists($namespaceIdentifier, $this->namespaces) && $this->namespaces[$namespaceIdentifier] === null) {
348
            return true;
349
        }
350
        foreach (array_keys($this->namespaces) as $existingNamespaceIdentifier) {
351
            if (strpos($existingNamespaceIdentifier, '*') === false) {
352
                continue;
353
            }
354
            $pattern = '/' . str_replace(['.', '*'], ['\\.', '[a-zA-Z0-9\.]*'], $existingNamespaceIdentifier) . '/';
355
            if (preg_match($pattern, $namespaceIdentifier) === 1) {
356
                return true;
357
            }
358
        }
359
        return false;
360
    }
361
362
    /**
363
     * Resolves a ViewHelper class name by namespace alias and
364
     * Fluid-format identity, e.g. "f" and "format.htmlspecialchars".
365
     *
366
     * Looks in all PHP namespaces which have been added for the
367
     * provided alias, starting in the last added PHP namespace. If
368
     * a ViewHelper class exists in multiple PHP namespaces Fluid
369
     * will detect and use whichever one was added last.
370
     *
371
     * If no ViewHelper class can be detected in any of the added
372
     * PHP namespaces a Fluid Parser Exception is thrown.
373
     *
374
     * @param string|null $namespaceIdentifier
375
     * @param string $methodIdentifier
376
     * @return string|null
377
     * @throws Exception
378
     */
379
    public function resolveViewHelperClassName(?string $namespaceIdentifier, string $methodIdentifier): ?string
380
    {
381
        if ($namespaceIdentifier === 'this') {
382
            return ReferenceNode::class;
383
        }
384
        if (empty($namespaceIdentifier) && isset($this->aliases[$methodIdentifier])) {
385
            list ($namespaceIdentifier, $methodIdentifier) = $this->aliases[$methodIdentifier];
386
        }
387
        if (!isset($this->resolvedViewHelperClassNames[$namespaceIdentifier][$methodIdentifier])) {
388
            $actualViewHelperClassName = false;
389
390
            $explodedViewHelperName = explode('.', $methodIdentifier);
391
            $className = implode('\\', array_map('ucfirst', $explodedViewHelperName));
392
            $className .= 'ViewHelper';
393
394
            if (!empty($this->namespaces[$namespaceIdentifier])) {
395
                foreach (array_reverse($this->namespaces[$namespaceIdentifier]) as $namespace) {
396
                    $name = $namespace . '\\' . $className;
397
                    if (class_exists($name)) {
398
                        $actualViewHelperClassName = $name;
399
                        break;
400
                    }
401
                }
402
            }
403
404
            if ($actualViewHelperClassName === false) {
405
406
                // If namespace and method match an Atom, return AtomNode's class name. Otherwise, error out.
407
                if ($this->resolveAtomFile($namespaceIdentifier, $methodIdentifier)) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $this->resolveAtomFile($...ier, $methodIdentifier) of type null|string is loosely compared to true; this is ambiguous if the string can be empty. You might want to explicitly use !== null instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For string values, the empty string '' is a special case, in particular the following results might be unexpected:

''   == false // true
''   == null  // true
'ab' == false // false
'ab' == null  // false

// It is often better to use strict comparison
'' === false // false
'' === null  // false
Loading history...
408
                    return AtomNode::class;
409
                }
410
411
                throw new Exception(sprintf(
412
                    'A Component named "<%s:%s>" could not be resolved.' . chr(10) .
413
                    'We looked in the following namespaces: %s.',
414
                    $namespaceIdentifier,
415
                    $methodIdentifier,
416
                    implode(', ', $this->namespaces[$namespaceIdentifier] ?? ['none'])
417
                ), 1407060572);
418
            }
419
420
            $this->resolvedViewHelperClassNames[$namespaceIdentifier][$methodIdentifier] = $actualViewHelperClassName;
421
        }
422
        return $this->resolvedViewHelperClassNames[$namespaceIdentifier][$methodIdentifier];
423
    }
424
425
    /**
426
     * Can be overridden by custom implementations to change the way
427
     * classes are loaded when the class is a ViewHelper - for
428
     * example making it possible to use a DI-aware class loader.
429
     *
430
     * If null is passed as namespace, only registered ViewHelper
431
     * aliases are checked against the $viewHelperShortName.
432
     *
433
     * @param string|null $namespace
434
     * @param string $viewHelperShortName
435
     * @return ComponentInterface
436
     */
437
    public function createViewHelperInstance(?string $namespace, string $viewHelperShortName): ComponentInterface
438
    {
439
        if ($namespace === 'this') {
440
            return new ReferenceNode($viewHelperShortName);
441
        }
442
        if (!empty($namespace) && isset($this->atoms[$namespace])) {
443
            $atomFile = $this->resolveAtomFile($namespace, $viewHelperShortName);
444
            if ($atomFile) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $atomFile of type null|string is loosely compared to true; this is ambiguous if the string can be empty. You might want to explicitly use !== null instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For string values, the empty string '' is a special case, in particular the following results might be unexpected:

''   == false // true
''   == null  // true
'ab' == false // false
'ab' == null  // false

// It is often better to use strict comparison
'' === false // false
'' === null  // false
Loading history...
445
                return (new AtomNode())
446
                    ->setArguments(clone $this->renderingContext->getTemplateParser()->parseFile($atomFile)->getArguments())
447
                    ->setFile($atomFile)
448
                    ->setName($namespace . ':' . $viewHelperShortName);
449
            }
450
        }
451
        $className = $this->resolveViewHelperClassName($namespace, $viewHelperShortName);
452
        $instance = $this->createViewHelperInstanceFromClassName($className);
453
        return $instance;
454
    }
455
456
    /**
457
     * Wrapper to create a ViewHelper instance by class name. This is
458
     * the final method called when creating ViewHelper classes -
459
     * overriding this method allows custom constructors, dependency
460
     * injections etc. to be performed on the ViewHelper instance.
461
     *
462
     * @param string $viewHelperClassName
463
     * @return ComponentInterface
464
     */
465
    public function createViewHelperInstanceFromClassName(string $viewHelperClassName): ComponentInterface
466
    {
467
        return new $viewHelperClassName();
468
    }
469
}
470