Lines 97.75% 87 / 89
Functions and Methods 90.00% 18 / 20
Classes and Traits 0.00% 0 / 1
Name Lines Functions and Methods CRAP Classes and Traits
Selector 97.75% 87 / 89 90.00% 18 / 20 66 0.00% 0 / 1
 __construct 100.00% 1 / 1 100.00% 1 / 1 1
 exact 100.00% 3 / 3 100.00% 1 / 1 2
 glob 100.00% 3 / 3 100.00% 1 / 1 2
 regex 100.00% 4 / 4 100.00% 1 / 1 2
 mqttFilter 100.00% 3 / 3 100.00% 1 / 1 2
 anyOf 100.00% 3 / 3 100.00% 1 / 1 2
 any 100.00% 1 / 1 100.00% 1 / 1 1
 fromSpec 100.00% 5 / 5 100.00% 1 / 1 7
 fromCollection 100.00% 2 / 2 100.00% 1 / 1 2
 getKind 100.00% 1 / 1 100.00% 1 / 1 1
 getPattern 66.66% 2 / 3 0.00% 0 / 1 2.15
 listMembers 0.00% 0 / 1 0.00% 0 / 1 2
 isAny 100.00% 1 / 1 100.00% 1 / 1 2
 matches 100.00% 6 / 6 100.00% 1 / 1 7
 toCanonicalKey 100.00% 7 / 7 100.00% 1 / 1 3
 hasExactPattern 100.00% 3 / 3 100.00% 1 / 1 3
 findExactPattern 100.00% 4 / 4 100.00% 1 / 1 5
 findRegexCompileError 100.00% 10 / 10 100.00% 1 / 1 3
 compileGlob 100.00% 7 / 7 100.00% 1 / 1 5
 compileMqttFilter 100.00% 21 / 21 100.00% 1 / 1 13
1<?php
2
3declare(strict_types=1);
4
5namespace Stewart\Contracts\Selector;
6
7use LogicException;
8use Stewart\Contracts\Entity\EntityId;
9use Stewart\Contracts\Exception\SelectorError;
10use Stewart\Contracts\Exception\SelectorException;
11use Stewart\Contracts\Selector\Collection\SelectorCollection;
12
13final readonly class Selector
14{
15    private const string ANYTHING = '*';
16
17    private const string MQTT_LEVEL_SEPARATOR = '/';
18
19    private const string MQTT_SINGLE_LEVEL = '+';
20
21    private const string MQTT_MULTI_LEVEL = '#';
22
23    private const int MQTT_MAX_FILTER_BYTES = 65535;
24
25    private SelectorCollection $members;
26
27    private function __construct(
28        private SelectorKind $kind,
29        private string $pattern,
30        private ?string $compiledRegex = null,
31        ?SelectorCollection $members = null,
32    ) {
33        $this->members = $members ?? SelectorCollection::empty();
34    }
35
36    /** @throws SelectorException */
37    public static function exact(string $value): self
38    {
39        if ($value === '') {
40            throw SelectorException::createForReason(SelectorError::Empty, selectorKind: 'An exact selector');
41        }
42
43        return new self(SelectorKind::Exact, $value);
44    }
45
46    /** @throws SelectorException */
47    public static function glob(string $pattern): self
48    {
49        if ($pattern === '') {
50            throw SelectorException::createForReason(SelectorError::Empty, selectorKind: 'A glob selector');
51        }
52
53        return new self(SelectorKind::Glob, $pattern, self::compileGlob($pattern));
54    }
55
56    /** @throws SelectorException */
57    public static function regex(string $regex): self
58    {
59        $compileError = self::findRegexCompileError($regex);
60
61        if ($compileError !== null) {
62            throw SelectorException::createForReason(SelectorError::RegexInvalid, pattern: $regex, compileError: $compileError);
63        }
64
65        return new self(SelectorKind::Regex, $regex);
66    }
67
68    /** @throws SelectorException */
69    public static function mqttFilter(string $filter): self
70    {
71        if ($filter === '') {
72            throw SelectorException::createForReason(SelectorError::Empty, selectorKind: 'An MQTT topic filter');
73        }
74
75        return new self(SelectorKind::MqttFilter, $filter, self::compileMqttFilter($filter));
76    }
77
78    /** @throws SelectorException */
79    public static function anyOf(string|EntityId|self ...$selectors): self
80    {
81        if ($selectors === []) {
82            throw SelectorException::createForReason(SelectorError::Empty, selectorKind: 'An any-of selector');
83        }
84
85        return new self(SelectorKind::AnyOf, '', members: SelectorCollection::fromSpecs(...$selectors));
86    }
87
88    public static function any(): self
89    {
90        return self::glob(self::ANYTHING);
91    }
92
93    /** @throws SelectorException */
94    public static function fromSpec(string|EntityId|self|SelectorCollection $spec): self
95    {
96        return match (true) {
97            $spec instanceof self => $spec,
98            $spec instanceof EntityId => self::exact($spec->value),
99            $spec instanceof SelectorCollection => self::fromCollection($spec),
100            str_contains($spec, '*') || str_contains($spec, '?') => self::glob($spec),
101            default => self::exact($spec),
102        };
103    }
104
105    /** @throws SelectorException */
106    private static function fromCollection(SelectorCollection $selectors): self
107    {
108        $members = $selectors->listValues();
109
110        return \count($members) === 1 ? $members[0] : self::anyOf(...$members);
111    }
112
113    public function getKind(): SelectorKind
114    {
115        return $this->kind;
116    }
117
118    /** @throws LogicException */
119    public function getPattern(): string
120    {
121        if ($this->kind === SelectorKind::AnyOf) {
122            throw new LogicException('An any-of selector has members instead of a pattern.');
123        }
124
125        return $this->pattern;
126    }
127
128    public function listMembers(): SelectorCollection
129    {
130        return $this->members;
131    }
132
133    public function isAny(): bool
134    {
135        return $this->kind === SelectorKind::Glob && $this->pattern === self::ANYTHING;
136    }
137
138    public function matches(string $candidate): bool
139    {
140        return match ($this->kind) {
141            SelectorKind::Exact => $candidate === $this->pattern,
142            SelectorKind::AnyOf => $this->members->anyMatches($candidate),
143            SelectorKind::Glob => $this->isAny() || preg_match($this->compiledRegex ?? '', $candidate) === 1,
144            SelectorKind::MqttFilter => preg_match($this->compiledRegex ?? '', $candidate) === 1,
145            SelectorKind::Regex => preg_match($this->pattern, $candidate) === 1,
146        };
147    }
148
149    public function toCanonicalKey(): string
150    {
151        if ($this->isAny()) {
152            return 'any';
153        }
154
155        if ($this->kind !== SelectorKind::AnyOf) {
156            return $this->kind->value . ':' . $this->pattern;
157        }
158
159        $keys = $this->members->mapToList(static fn(self $member): string => $member->toCanonicalKey());
160        sort($keys);
161
162        return 'any-of:' . json_encode($keys, \JSON_THROW_ON_ERROR);
163    }
164
165    public function hasExactPattern(string $value): bool
166    {
167        if ($this->kind === SelectorKind::AnyOf) {
168            return $this->members->containsWhere(static fn(self $member): bool => $member->hasExactPattern($value));
169        }
170
171        return $this->pattern === $value && $this->kind === SelectorKind::Exact;
172    }
173
174    public function findExactPattern(): ?string
175    {
176        return match ($this->kind) {
177            SelectorKind::Exact => $this->pattern,
178            SelectorKind::AnyOf => $this->members->count() === 1 ? $this->members->getFirst()?->findExactPattern() : null,
179            default => null,
180        };
181    }
182
183    private static function findRegexCompileError(string $regex): ?string
184    {
185        $warning = null;
186
187        set_error_handler(static function (int $level, string $message) use (&$warning): bool {
188            $warning = $message;
189
190            return true;
191        });
192
193        try {
194            $compiled = preg_match($regex, '') !== false;
195        } finally {
196            restore_error_handler();
197        }
198
199        if ($compiled) {
200            return null;
201        }
202
203        return $warning === null ? preg_last_error_msg() : preg_replace('/^preg_match\(\): /', '', $warning);
204    }
205
206    private static function compileGlob(string $pattern): string
207    {
208        $out = '';
209
210        foreach (str_split($pattern) as $char) {
211            $out .= match ($char) {
212                '*' => '.*',
213                '?' => '.',
214                default => preg_quote($char, '#'),
215            };
216        }
217
218        return '#\A' . $out . '\z#';
219    }
220
221    /** @throws SelectorException */
222    private static function compileMqttFilter(string $filter): string
223    {
224        if (\strlen($filter) > self::MQTT_MAX_FILTER_BYTES || str_contains($filter, "\0")) {
225            throw SelectorException::createForReason(SelectorError::MqttFilterInvalid, filter: $filter);
226        }
227
228        $levels = explode(self::MQTT_LEVEL_SEPARATOR, $filter);
229        $lastIndex = \count($levels) - 1;
230        $compiledLevels = [];
231        $multiLevelTail = false;
232
233        foreach ($levels as $index => $level) {
234            if ($level === self::MQTT_MULTI_LEVEL && $index === $lastIndex) {
235                $multiLevelTail = true;
236
237                continue;
238            }
239
240            if ($level === self::MQTT_SINGLE_LEVEL) {
241                $compiledLevels[] = '[^/]*';
242
243                continue;
244            }
245
246            if (str_contains($level, self::MQTT_SINGLE_LEVEL) || str_contains($level, self::MQTT_MULTI_LEVEL)) {
247                throw SelectorException::createForReason(SelectorError::MqttFilterInvalid, filter: $filter);
248            }
249
250            $compiledLevels[] = preg_quote($level, '#');
251        }
252
253        $body = implode(self::MQTT_LEVEL_SEPARATOR, $compiledLevels);
254
255        if ($multiLevelTail) {
256            $body = $compiledLevels === [] ? '.*' : $body . '(?:/.*)?';
257        }
258
259        // MQTT wildcards in the first level never match topics reserved with a leading '$'.
260        $startsWithWildcard = $levels[0] === self::MQTT_SINGLE_LEVEL || $levels[0] === self::MQTT_MULTI_LEVEL;
261
262        return '#\A' . ($startsWithWildcard ? '(?!\$)' : '') . $body . '\z#s';
263    }
264}