Lines
97.75%
87 / 89
Methods
90.00%
18 / 20
Classes
0.00%
0 / 1
| Name | Lines | Methods | CRAP | ||||
|---|---|---|---|---|---|---|---|
| __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 | ||
| 13 | final 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 | } |