Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
45 / 45
100.00% covered (success)
100.00%
8 / 8
CRAP
100.00% covered (success)
100.00%
1 / 1
Wrapper
100.00% covered (success)
100.00%
45 / 45
100.00% covered (success)
100.00%
8 / 8
26
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 withTrusted
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 wrap
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
11
 unwrap
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
4
 escape
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 filter
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 filters
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isTrusted
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
1<?php
2
3declare(strict_types=1);
4
5namespace Celema\Boiler;
6
7use Celema\Boiler\Escaper\Html;
8use Celema\Boiler\Exception\UnexpectedValueException;
9use Celema\Boiler\Proxy\ArrayProxy;
10use Celema\Boiler\Proxy\IteratorProxy;
11use Celema\Boiler\Proxy\ObjectProxy;
12use Celema\Boiler\Proxy\Proxy;
13use Celema\Boiler\Proxy\StringProxy;
14use Override;
15use Traversable;
16
17/** @api */
18final class Wrapper implements Contract\Wrapper
19{
20    private readonly Contract\Escapers $escapers;
21    private ?Contract\Filters $filters;
22    private readonly Contract\Escaper $defaultEscaper;
23    private readonly bool $isBuiltinEscaper;
24
25    /** @var list<class-string> */
26    private array $trusted = [];
27
28    public function __construct(
29        ?Contract\Escapers $escapers = null,
30        ?Contract\Filters $filters = null,
31    ) {
32        $this->escapers = $escapers ?? new Escapers();
33        $this->defaultEscaper = $this->escapers->get($this->escapers->default);
34        $this->isBuiltinEscaper = $this->defaultEscaper instanceof Html;
35        $this->filters = $filters;
36    }
37
38    #[Override]
39    public function withTrusted(array $trusted): static
40    {
41        if ($trusted === $this->trusted) {
42            return $this;
43        }
44
45        $clone = clone $this;
46        $clone->trusted = $trusted;
47
48        return $clone;
49    }
50
51    #[Override]
52    public function wrap(mixed $value): mixed
53    {
54        if (is_string($value)) {
55            return new StringProxy($value, $this);
56        }
57
58        if (is_array($value)) {
59            return new ArrayProxy($value, $this);
60        }
61
62        /** @psalm-suppress DocblockTypeContradiction Psalm does not narrow a template type to resource */
63        if (
64            $value === null
65            || is_int($value)
66            || is_float($value)
67            || is_bool($value)
68            || is_resource($value)
69        ) {
70            return $value;
71        }
72
73        if ($value instanceof Proxy) {
74            return $value;
75        }
76
77        if ($value instanceof Traversable) {
78            return new IteratorProxy($value, $this, safe: $this->isTrusted($value));
79        }
80
81        if (is_object($value)) {
82            return new ObjectProxy($value, $this, safe: $this->isTrusted($value));
83        }
84
85        throw new UnexpectedValueException('Unsupported template value type');
86    }
87
88    #[Override]
89    public function unwrap(mixed $value): mixed
90    {
91        // Repeats unwrap() instead of calling it: the call would cost every template method and filter call.
92        if ($value instanceof Proxy) {
93            /** @var ArrayProxy|IteratorProxy|ObjectProxy|StringProxy $value */
94            return $value->unwrap();
95        }
96
97        if (!is_array($value)) {
98            return $value;
99        }
100
101        $unwrapped = [];
102
103        /** @var mixed $item */
104        foreach ($value as $key => $item) {
105            /** @psalm-suppress MixedAssignment unwrapping returns mixed by design */
106            $unwrapped[$key] = $this->unwrap($item);
107        }
108
109        return $unwrapped;
110    }
111
112    #[Override]
113    public function escape(
114        string $value,
115        ?string $escaper = null,
116    ): string {
117        if ($escaper === null) {
118            return $this->isBuiltinEscaper
119                ? htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8')
120                : $this->defaultEscaper->escape($value);
121        }
122
123        return $this->escapers->get($escaper)->escape($value);
124    }
125
126    #[Override]
127    public function filter(string $name): Contract\Filter
128    {
129        return $this->filters()->get($name);
130    }
131
132    private function filters(): Contract\Filters
133    {
134        return $this->filters ??= new Filters();
135    }
136
137    private function isTrusted(object $value): bool
138    {
139        foreach ($this->trusted as $class) {
140            if ($value instanceof $class) {
141                return true;
142            }
143        }
144
145        return false;
146    }
147}