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

ViewHelperResolver::resolveViewHelperClassName()   B

Complexity

Conditions 9
Paths 26

Size

Total Lines 42

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 9
nc 26
nop 2
dl 0
loc 42
rs 7.6924
c 0
b 0
f 0
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\Rendering\RenderingContextInterface;
14
use TYPO3Fluid\Fluid\ViewHelpers\AtomViewHelper;
15
16
/**
17
 * Class ViewHelperResolver
18
 *
19
 * Responsible for resolving instances of ViewHelpers and for
20
 * interacting with ViewHelpers; to translate ViewHelper names
21
 * into actual class names and resolve their ArgumentDefinitions.
22
 *
23
 * Replacing this class in for example a framework allows that
24
 * framework to be responsible for creating ViewHelper instances
25
 * and detecting possible arguments.
26
 */
27
class ViewHelperResolver
28
{
29
    /**
30
     * @var RenderingContextInterface
31
     */
32
    protected $renderingContext;
33
34
    /**
35
     * @var array
36
     */
37
    protected $resolvedViewHelperClassNames = [];
38
39
    /**
40
     * Atom paths indexed by namespace, in
41
     * [shortname => [path1, path2, ...]] format.
42
     * @var array
43
     */
44
    protected $atoms = [];
45
46
    /**
47
     * Namespaces requested by the template being rendered,
48
     * in [shortname => [phpnamespace1, phpnamespace2, ...]] format.
49
     *
50
     * @var array
51
     */
52
    protected $namespaces = [
53
        'f' => ['TYPO3Fluid\\Fluid\\ViewHelpers']
54
    ];
55
56
    /**
57
     * @var array
58
     */
59
    protected $aliases = [
60
        'html' => ['f', 'html'],
61
        'raw' => ['f', 'format.raw'],
62
    ];
63
64
    public function __construct(RenderingContextInterface $renderingContext)
65
    {
66
        $this->renderingContext = $renderingContext;
67
    }
68
69
    public function addAtomPath(string $namespace, string $path): void
70
    {
71
        if (!in_array($path, $this->atoms[$namespace] ?? [], true)) {
72
            $this->atoms[$namespace][] = $path;
73
        }
74
    }
75
76
    /**
77
     * Add all Atom paths as array-in-array, with the first level key
78
     * being the namespace and the value being an array of paths.
79
     *
80
     * Example:
81
     *
82
     * $resolver->addAtomPaths(
83
     *   [
84
     *     'my' => [
85
     *       'path/first/',
86
     *       'path/second/',
87
     *     ],
88
     *     'other' => [
89
     *       'path/third/',
90
     *     ],
91
     *   ]
92
     * );
93
     *
94
     * @param iterable|string[][] $paths
95
     */
96
    public function addAtomPaths(iterable $paths): void
97
    {
98
        foreach ($paths as $namespace => $collection) {
99
            foreach ($collection as $path) {
100
                $this->addAtomPath($namespace, $path);
101
            }
102
        }
103
    }
104
105
    public function resolveAtom(string $namespace, string $name): ComponentInterface
106
    {
107
        $file = $this->resolveAtomFile($namespace, $name);
108
        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...
109
            $paths = empty($this->atoms[$namespace]) ? 'none' : implode(', ', $this->atoms[$namespace]);
110
            throw new ChildNotFoundException(
111
                'Atom "' . $namespace . ':' . $name . '" could not be resolved. We looked in: ' . $paths,
112
                1564404340
113
            );
114
        }
115
        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\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...
116
    }
117
118
    public function resolveAtomFile(string $namespace, string $name): ?string
119
    {
120
        if (!isset($this->atoms[$namespace])) {
121
            return null;
122
        }
123
        $expectedFileParts = explode('.', $name);
124
        foreach (array_reverse($this->atoms[$namespace]) as $path) {
125
            $parts = $expectedFileParts;
126
            $subPath = $path;
127
            while ($expectedFilePart = array_shift($parts)) {
128
                $subPath .= '/' . $expectedFilePart;
129
                if (!is_dir($subPath)) {
130
                    break;
131
                }
132
            }
133
            $filePathAndFilename = $subPath . '.html';
134
            if (file_exists($filePathAndFilename)) {
135
                return $filePathAndFilename;
136
            }
137
        }
138
        return null;
139
    }
140
141
    /**
142
     * @return array|string[][]
143
     */
144
    public function getAtoms(): array
145
    {
146
        return $this->atoms;
147
    }
148
149
    /**
150
     * @return array
151
     */
152
    public function getNamespaces(): array
153
    {
154
        return $this->namespaces;
155
    }
156
157
    /**
158
     * Adds an alias of a ViewHelper, allowing you to call for example
159
     *
160
     * @param string $alias
161
     * @param string $namespace
162
     * @param string $identifier
163
     */
164
    public function addViewHelperAlias(string $alias, string $namespace, string $identifier)
165
    {
166
        $this->aliases[$alias] = [$namespace, $identifier];
167
    }
168
169
    public function isAliasRegistered(string $alias): bool
170
    {
171
        return isset($this->aliases[$alias]);
172
    }
173
174
    /**
175
     * Add a PHP namespace where ViewHelpers can be found and give
176
     * it an alias/identifier.
177
     *
178
     * The provided namespace can be either a single namespace or
179
     * an array of namespaces, as strings. The identifier/alias is
180
     * always a single, alpha-numeric ASCII string.
181
     *
182
     * Calling this method multiple times with different PHP namespaces
183
     * for the same alias causes that namespace to be *extended*,
184
     * meaning that the PHP namespace you provide second, third etc.
185
     * are also used in lookups and are used *first*, so that if any
186
     * of the namespaces you add contains a class placed and named the
187
     * same way as one that exists in an earlier namespace, then your
188
     * class gets used instead of the earlier one.
189
     *
190
     * Example:
191
     *
192
     * $resolver->addNamespace('my', 'My\Package\ViewHelpers');
193
     * // Any ViewHelpers under this namespace can now be accessed using for example {my:example()}
194
     * // Now, assuming you also have an ExampleViewHelper class in a different
195
     * // namespace and wish to make that ExampleViewHelper override the other:
196
     * $resolver->addNamespace('my', 'My\OtherPackage\ViewHelpers');
197
     * // Now, since ExampleViewHelper exists in both places but the
198
     * // My\OtherPackage\ViewHelpers namespace was added *last*, Fluid
199
     * // will find and use My\OtherPackage\ViewHelpers\ExampleViewHelper.
200
     *
201
     * Alternatively, setNamespaces() can be used to reset and redefine
202
     * all previously added namespaces - which is great for cases where
203
     * you need to remove or replace previously added namespaces. Be aware
204
     * that setNamespaces() also removes the default "f" namespace, so
205
     * when you use this method you should always include the "f" namespace.
206
     *
207
     * @param string $identifier
208
     * @param string|array $phpNamespace
209
     * @return void
210
     */
211
    public function addNamespace(string $identifier, $phpNamespace): void
212
    {
213
        if (!array_key_exists($identifier, $this->namespaces) || $this->namespaces[$identifier] === null) {
214
            $this->namespaces[$identifier] = $phpNamespace === null ? null : (array) $phpNamespace;
215
        } elseif (is_array($phpNamespace)) {
216
            $this->namespaces[$identifier] = array_unique(array_merge($this->namespaces[$identifier], $phpNamespace));
217
        } elseif (isset($this->namespaces[$identifier]) && !in_array($phpNamespace, $this->namespaces[$identifier], true)) {
218
            $this->namespaces[$identifier][] = $phpNamespace;
219
        }
220
        $this->resolvedViewHelperClassNames = [];
221
    }
222
223
    /**
224
     * Wrapper to allow adding namespaces in bulk *without* first
225
     * clearing the already added namespaces. Utility method mainly
226
     * used in compiled templates, where some namespaces can be added
227
     * from outside and some can be added from compiled values.
228
     *
229
     * @param array $namespaces
230
     * @return void
231
     */
232
    public function addNamespaces(array $namespaces): void
233
    {
234
        foreach ($namespaces as $identifier => $namespace) {
235
            $this->addNamespace($identifier, $namespace);
236
        }
237
    }
238
239
    public function removeNamespace(string $identifier, $phpNamespace): void
240
    {
241
        if (($key = array_search($phpNamespace, $this->namespaces[$identifier], true)) !== false) {
242
            unset($this->namespaces[$identifier][$key]);
243
            if (empty($this->namespaces[$identifier])) {
244
                unset($this->namespaces[$identifier]);
245
            }
246
        }
247
    }
248
249
    /**
250
     * Resolves the PHP namespace based on the Fluid xmlns namespace,
251
     * which can be either a URL matching the Patterns::NAMESPACEPREFIX
252
     * and Patterns::NAMESPACESUFFIX rules, or a PHP namespace. When
253
     * namespace is a PHP namespace it is optional to suffix it with
254
     * the "\ViewHelpers" segment, e.g. "My\Package" is as valid to
255
     * use as "My\Package\ViewHelpers" is.
256
     *
257
     * @param string $fluidNamespace
258
     * @return string
259
     */
260
    public function resolvePhpNamespaceFromFluidNamespace(string $fluidNamespace): string
261
    {
262
        $prefix = 'http://typo3.org/ns/';
263
        $suffix = '/ViewHelpers';
264
        $namespace = $fluidNamespace;
265
        $suffixLength = strlen($suffix);
266
        $phpNamespaceSuffix = str_replace('/', '\\', $suffix);
267
        $extractedSuffix = substr($fluidNamespace, 0 - $suffixLength);
268
        if (strpos($fluidNamespace, $prefix) === 0 && $extractedSuffix === $suffix) {
269
            // convention assumed: URL starts with prefix and ends with suffix
270
            $namespace = substr($fluidNamespace, strlen($prefix));
271
        }
272
        $namespace = str_replace('/', '\\', $namespace);
273
        if (substr($namespace, 0 - strlen($phpNamespaceSuffix)) !== $phpNamespaceSuffix) {
274
            $namespace .= $phpNamespaceSuffix;
275
        }
276
        return $namespace;
277
    }
278
279
    /**
280
     * Set all namespaces as an array of ['identifier' => ['Php\Namespace1', 'Php\Namespace2']]
281
     * namespace definitions. For convenience and legacy support, a
282
     * format of ['identifier' => 'Only\Php\Namespace'] is allowed,
283
     * but will internally convert the namespace to an array and
284
     * allow it to be extended by addNamespace().
285
     *
286
     * Note that when using this method the default "f" namespace is
287
     * also removed and so must be included in $namespaces or added
288
     * after using addNamespace(). Or, add the PHP namespaces that
289
     * belonged to "f" as a new alias and use that in your templates.
290
     *
291
     * Use getNamespaces() to get an array of currently added namespaces.
292
     *
293
     * @param array $namespaces
294
     * @return void
295
     */
296
    public function setNamespaces(array $namespaces): void
297
    {
298
        $this->namespaces = [];
299
        foreach ($namespaces as $identifier => $phpNamespace) {
300
            $this->namespaces[$identifier] = $phpNamespace === null ? null : (array) $phpNamespace;
301
        }
302
    }
303
304
    /**
305
     * Validates the given namespaceIdentifier and returns FALSE
306
     * if the namespace is unknown, causing the tag to be rendered
307
     * without processing.
308
     *
309
     * @param string $namespaceIdentifier
310
     * @return boolean TRUE if the given namespace is valid, otherwise FALSE
311
     */
312
    public function isNamespaceValid(string $namespaceIdentifier): bool
313
    {
314
        if (!array_key_exists($namespaceIdentifier, $this->namespaces)) {
315
            return false;
316
        }
317
318
        return $this->namespaces[$namespaceIdentifier] !== null;
319
    }
320
321
    /**
322
     * Validates the given namespaceIdentifier and returns FALSE
323
     * if the namespace is unknown and not ignored
324
     *
325
     * @param string $namespaceIdentifier
326
     * @return boolean TRUE if the given namespace is valid, otherwise FALSE
327
     */
328
    public function isNamespaceValidOrIgnored(string $namespaceIdentifier): bool
329
    {
330
        if ($this->isNamespaceValid($namespaceIdentifier)) {
331
            return true;
332
        }
333
334
        if (array_key_exists($namespaceIdentifier, $this->namespaces) || array_key_exists($namespaceIdentifier, $this->atoms)) {
335
            return true;
336
        }
337
        return $this->isNamespaceIgnored($namespaceIdentifier);
338
    }
339
340
    /**
341
     * @param string $namespaceIdentifier
342
     * @return boolean
343
     */
344
    public function isNamespaceIgnored(string $namespaceIdentifier): bool
345
    {
346
        if (array_key_exists($namespaceIdentifier, $this->namespaces) && $this->namespaces[$namespaceIdentifier] === null) {
347
            return true;
348
        }
349
        foreach (array_keys($this->namespaces) as $existingNamespaceIdentifier) {
350
            if (strpos($existingNamespaceIdentifier, '*') === false) {
351
                continue;
352
            }
353
            $pattern = '/' . str_replace(['.', '*'], ['\\.', '[a-zA-Z0-9\.]*'], $existingNamespaceIdentifier) . '/';
354
            if (preg_match($pattern, $namespaceIdentifier) === 1) {
355
                return true;
356
            }
357
        }
358
        return false;
359
    }
360
361
    /**
362
     * Resolves a ViewHelper class name by namespace alias and
363
     * Fluid-format identity, e.g. "f" and "format.htmlspecialchars".
364
     *
365
     * Looks in all PHP namespaces which have been added for the
366
     * provided alias, starting in the last added PHP namespace. If
367
     * a ViewHelper class exists in multiple PHP namespaces Fluid
368
     * will detect and use whichever one was added last.
369
     *
370
     * If no ViewHelper class can be detected in any of the added
371
     * PHP namespaces a Fluid Parser Exception is thrown.
372
     *
373
     * @param string|null $namespaceIdentifier
374
     * @param string $methodIdentifier
375
     * @return string|null
376
     * @throws Exception
377
     */
378
    public function resolveViewHelperClassName(?string $namespaceIdentifier, string $methodIdentifier): ?string
379
    {
380
        if (empty($namespaceIdentifier) && isset($this->aliases[$methodIdentifier])) {
381
            list ($namespaceIdentifier, $methodIdentifier) = $this->aliases[$methodIdentifier];
382
        }
383
        if (!isset($this->resolvedViewHelperClassNames[$namespaceIdentifier][$methodIdentifier])) {
384
            $actualViewHelperClassName = false;
385
386
            $explodedViewHelperName = explode('.', $methodIdentifier);
387
            $className = implode('\\', array_map('ucfirst', $explodedViewHelperName));
388
            $className .= 'ViewHelper';
389
390
            if (!empty($this->namespaces[$namespaceIdentifier])) {
391
                foreach (array_reverse($this->namespaces[$namespaceIdentifier]) as $namespace) {
392
                    $name = $namespace . '\\' . $className;
393
                    if (class_exists($name)) {
394
                        $actualViewHelperClassName = $name;
395
                        break;
396
                    }
397
                }
398
            }
399
400
            if ($actualViewHelperClassName === false) {
401
402
                // If namespace and method match an Atom, return AtomViewHelper's class name. Otherwise, error out.
403
                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...
404
                    return AtomViewHelper::class;
405
                }
406
407
                throw new Exception(sprintf(
408
                    'The ViewHelper "<%s:%s>" could not be resolved.' . chr(10) .
409
                    'We looked in the following namespaces: %s.',
410
                    $namespaceIdentifier,
411
                    $methodIdentifier,
412
                    implode(', ', $this->namespaces[$namespaceIdentifier] ?? ['none'])
413
                ), 1407060572);
414
            }
415
416
            $this->resolvedViewHelperClassNames[$namespaceIdentifier][$methodIdentifier] = $actualViewHelperClassName;
417
        }
418
        return $this->resolvedViewHelperClassNames[$namespaceIdentifier][$methodIdentifier];
419
    }
420
421
    /**
422
     * Can be overridden by custom implementations to change the way
423
     * classes are loaded when the class is a ViewHelper - for
424
     * example making it possible to use a DI-aware class loader.
425
     *
426
     * If null is passed as namespace, only registered ViewHelper
427
     * aliases are checked against the $viewHelperShortName.
428
     *
429
     * @param string|null $namespace
430
     * @param string $viewHelperShortName
431
     * @return ComponentInterface
432
     */
433
    public function createViewHelperInstance(?string $namespace, string $viewHelperShortName): ComponentInterface
434
    {
435
        if (!empty($namespace) && isset($this->atoms[$namespace])) {
436
            $atomFile = $this->resolveAtomFile($namespace, $viewHelperShortName);
437
            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...
438
                $atom = $this->renderingContext->getTemplateParser()->parseFile($atomFile)->setName($namespace . ':' . $viewHelperShortName);
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\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...
439
                $instance = $this->createViewHelperInstanceFromClassName(AtomViewHelper::class);
440
                $instance->getArguments()->setDefinitions($atom->getArguments()->getDefinitions())['file'] = $atomFile;
441
                return $instance;
442
            }
443
        }
444
        $className = $this->resolveViewHelperClassName($namespace, $viewHelperShortName);
445
        $instance = $this->createViewHelperInstanceFromClassName($className);
446
        return $instance;
447
    }
448
449
    /**
450
     * Wrapper to create a ViewHelper instance by class name. This is
451
     * the final method called when creating ViewHelper classes -
452
     * overriding this method allows custom constructors, dependency
453
     * injections etc. to be performed on the ViewHelper instance.
454
     *
455
     * @param string $viewHelperClassName
456
     * @return ComponentInterface
457
     */
458
    public function createViewHelperInstanceFromClassName(string $viewHelperClassName): ComponentInterface
459
    {
460
        return new $viewHelperClassName();
461
    }
462
}
463