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