Lines 100.00% 19 / 19
Methods 100.00% 3 / 3
Classes 100.00% 1 / 1
Name Lines Methods CRAP
 __construct 100.00% 5 / 5 100.00% 1 / 1 5
 apply 100.00% 11 / 11 100.00% 1 / 1 6
 matches 100.00% 3 / 3 100.00% 1 / 1 3
20final class ModelFilter
21{
22    private readonly string|null $filter;
23    private readonly string|null $exclude;
24
25    /**
26     * @param string|null $filter Include pattern (null = no filter)
27     * @param string|null $exclude Exclude pattern (null = no filter)
28     * @throws \InvalidArgumentException When a pattern is empty
29     */
30    public function __construct(string|null $filter, string|null $exclude)
31    {
32        $this->filter = $filter === null ? null : trim($filter, '\'"');
33        $this->exclude = $exclude === null ? null : trim($exclude, '\'"');
34
35        foreach (['filter' => $this->filter, 'exclude' => $this->exclude] as $name => $value) {
36            if ($value === '') {
37                throw new \InvalidArgumentException("Invalid {$name}: value must not be empty.");
38            }
39        }
40    }
41
42    /**
43     * Split models into active and excluded buckets (exclude wins).
44     *
45     * @param list<class-string> $models
46     * @return array{active: list<class-string>, excluded: list<class-string>}
47     */
48    public function apply(array $models): array
49    {
50        $active = [];
51        $excluded = [];
52
53        foreach ($models as $model) {
54            if ($this->exclude !== null && self::matches($model, $this->exclude)) {
55                $excluded[] = $model;
56                continue;
57            }
58
59            if ($this->filter !== null && !self::matches($model, $this->filter)) {
60                $excluded[] = $model;
61                continue;
62            }
63
64            $active[] = $model;
65        }
66
67        return ['active' => $active, 'excluded' => $excluded];
68    }
69
70    /**
71     * Match a class name against a filter pattern.
72     *
73     * A pattern that starts with an alphanumeric character, or that is not
74     * a valid regular expression, is treated as a literal substring match
75     * (case-insensitive) — so backslashes in fully-qualified class names
76     * (e.g. App\Models\Rsvp) are treated literally rather than as invalid
77     * regex escapes. Any other pattern is used verbatim as a regex.
78     */
79    public static function matches(string $class, string $pattern): bool
80    {
81        if (preg_match('/[a-zA-Z0-9]/', substr($pattern, 0, 1)) === 1 || @preg_match($pattern, '') === false) {
82            // Literal substring mode.
83            $pattern = sprintf('{%s}i', preg_quote($pattern, '/'));
84        }
85
86        return @preg_match($pattern, $class) === 1;
87    }
88}