Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
35 / 35
100.00% covered (success)
100.00%
11 / 11
CRAP
100.00% covered (success)
100.00%
1 / 1
StringProxy
100.00% covered (success)
100.00%
35 / 35
100.00% covered (success)
100.00%
11 / 11
25
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 __call
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 __toString
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 escape
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 unwrap
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 is
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 matches
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
4
 contains
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 startsWith
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 endsWith
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 in
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
1<?php
2
3declare(strict_types=1);
4
5namespace Celema\Boiler\Proxy;
6
7use Celema\Boiler\Contract\PreservesSafety;
8use Celema\Boiler\Contract\Wrapper;
9use Celema\Boiler\Exception\UnexpectedValueException;
10use JsonSerializable;
11use Override;
12
13/**
14 * @api
15 *
16 * @implements Proxy<string>
17 */
18final class StringProxy implements JsonSerializable, Proxy
19{
20    use RefusesJson;
21
22    private ?string $escaped = null;
23
24    /** @param bool $safe whether the string is safe HTML and skips escaping */
25    public function __construct(
26        private readonly string $value,
27        private readonly Wrapper $wrapper,
28        private readonly bool $safe = false,
29    ) {}
30
31    /**
32     * Dispatch filters as virtual methods: $title->sanitize(), $title->stripTags('<b>'), etc.
33     * A filter gets the original values of wrapped arguments, as it gets the string itself.
34     *
35     * @param array<array-key, mixed> $args
36     */
37    public function __call(string $name, array $args): self
38    {
39        $filter = $this->wrapper->filter($name);
40
41        $args = $this->wrapper->unwrap($args);
42        $safe = $filter->safe() || $this->safe && $filter instanceof PreservesSafety;
43
44        return new self($filter->apply($this->value, ...$args), $this->wrapper, $safe);
45    }
46
47    public function __toString(): string
48    {
49        if ($this->safe) {
50            return $this->value;
51        }
52
53        return $this->escaped ??= $this->wrapper->escape($this->value);
54    }
55
56    public function escape(self|string|null $escaper = null): string
57    {
58        return $this->wrapper->escape($this->value, $escaper instanceof self ? $escaper->value : $escaper);
59    }
60
61    #[Override]
62    public function unwrap(): string
63    {
64        return $this->value;
65    }
66
67    #[Override]
68    public function is(mixed $other): bool
69    {
70        return $this->value === ($other instanceof Proxy ? $other->unwrap() : $other);
71    }
72
73    public function matches(string|self $pattern): bool
74    {
75        $pattern = $pattern instanceof self ? $pattern->value : $pattern;
76
77        if ($pattern === '') {
78            throw new UnexpectedValueException('Empty regex pattern');
79        }
80
81        // preg_match() warns on compile errors instead of throwing; capture the
82        // message, which is more specific than preg_last_error_msg() there.
83        $warning = null;
84        set_error_handler(static function (int $severity, string $message) use (&$warning): bool {
85            $warning = $message;
86
87            return true;
88        });
89
90        try {
91            $result = preg_match($pattern, $this->value);
92        } finally {
93            restore_error_handler();
94        }
95
96        if ($result === false) {
97            throw new UnexpectedValueException(
98                "Regex error for pattern '{$pattern}': " . ($warning ?? preg_last_error_msg()),
99            );
100        }
101
102        return $result === 1;
103    }
104
105    public function contains(string|self $needle): bool
106    {
107        return str_contains($this->value, $needle instanceof self ? $needle->value : $needle);
108    }
109
110    public function startsWith(string|self $needle): bool
111    {
112        return str_starts_with($this->value, $needle instanceof self ? $needle->value : $needle);
113    }
114
115    public function endsWith(string|self $needle): bool
116    {
117        return str_ends_with($this->value, $needle instanceof self ? $needle->value : $needle);
118    }
119
120    /** @param ArrayProxy|array<array-key, mixed> $haystack */
121    #[Override]
122    public function in(ArrayProxy|array $haystack): bool
123    {
124        if ($haystack instanceof ArrayProxy) {
125            $haystack = $haystack->unwrap();
126        }
127
128        /** @var mixed $item */
129        foreach ($haystack as $item) {
130            if ($this->is($item)) {
131                return true;
132            }
133        }
134
135        return false;
136    }
137}