Lines 100.00% 27 / 27
Methods 100.00% 11 / 11
Classes 100.00% 1 / 1
Name Lines Methods CRAP
 mobile 100.00% 2 / 2 100.00% 1 / 1 1
 password 100.00% 2 / 2 100.00% 1 / 1 1
 alpha 100.00% 2 / 2 100.00% 1 / 1 1
 alphanumeric 100.00% 2 / 2 100.00% 1 / 1 1
 hexColor 100.00% 2 / 2 100.00% 1 / 1 1
 __construct 100.00% 1 / 1 100.00% 1 / 1 1
 safePattern 100.00% 6 / 6 100.00% 1 / 1 3
 defaultMessage 100.00% 1 / 1 100.00% 1 / 1 1
 validate 100.00% 1 / 1 100.00% 1 / 1 2
 [Lucent\Validation\Constraint] withMessage 100.00% 2 / 2 100.00% 1 / 1 1
 [Lucent\Validation\Constraint] message 100.00% 6 / 6 100.00% 1 / 1 3
26final class Matches extends Constraint
27{
28    /**
29     * Create a constraint matching an international phone number (E.164).
30     *
31     * @return self A new Matches instance.
32     */
33    public static function mobile(): self
34    {
35        return (new self('/^\+?[1-9]\d{1,14}$/'))   // E.164
36            ->withMessage('Phone number must be in a valid international format.');
37    }
38
39    /**
40     * Create a constraint matching a strong password.
41     *
42     * Requires at least one lowercase letter, one uppercase letter, and a
43     * minimum length of 8 characters.
44     *
45     * @return self A new Matches instance.
46     */
47    public static function password(): self
48    {
49        return (new self('/^(?=.*[a-z])(?=.*[A-Z]).{8,}$/'))
50            ->withMessage('Password must contain at least one lowercase letter, one uppercase letter, and be at least 8 characters long.');
51    }
52
53    /**
54     * Create a constraint matching a string of letters only.
55     *
56     * @return self A new Matches instance.
57     */
58    public static function alpha(): self
59    {
60        return (new self('/^[a-zA-Z]+$/'))
61            ->withMessage('Must contain only letters.');
62    }
63
64    /**
65     * Create a constraint matching a string of letters and numbers only.
66     *
67     * @return self A new Matches instance.
68     */
69    public static function alphanumeric(): self
70    {
71        return (new self('/^[a-zA-Z0-9]+$/'))
72            ->withMessage('Must contain only letters and numbers.');
73    }
74
75    /**
76     * Create a constraint matching a HEX color code.
77     *
78     * Accepts 3- or 6-digit codes with an optional leading `#`.
79     *
80     * @return self A new Matches instance.
81     */
82    public static function hexColor(): self
83    {
84        return (new self('/^#?([a-fA-F0-9]{6}|[a-fA-F0-9]{3})$/'))
85            ->withMessage('Must be a valid HEX color code (e.g., #FFF or #FFFFFF).');
86    }
87
88    /**
89     * Create a regex-based match constraint.
90     *
91     * @param string $pattern The PCRE pattern to match against.
92     * @param int $flags Optional `preg_match` flags.
93     */
94    public function __construct(private readonly string $pattern, private readonly int $flags = 0) {}
95
96    /**
97     * Create a regex-based match constraint from a ReDoS-safe pattern.
98     *
99     * Rejects patterns containing nested quantifiers (e.g. `(a+)+`), which
100     * are the classic source of catastrophic backtracking, and verifies the
101     * pattern compiles. Use this when the pattern may come from an untrusted
102     * source (config, user input) rather than a hard-coded developer constant.
103     *
104     * PHP has no built-in ReDoS detector, so this is a best-effort guard: it
105     * catches the most common catastrophic-backtracking shape (a quantified
106     * group containing a quantifier) and rejects patterns that fail to
107     * compile (via {@see preg_last_error()}). It cannot prove a pattern is
108     * safe, so patterns should still be reviewed.
109     *
110     * @param string $pattern The PCRE pattern to match against.
111     * @param int $flags Optional `preg_match` flags.
112     * @return self A new Matches instance.
113     * @throws \InvalidArgumentException If the pattern contains a nested quantifier or does not compile.
114     */
115    public static function safePattern(string $pattern, int $flags = 0): self
116    {
117        if (preg_match('/\([^()]*[+*{][^()]*\)[+*?]/', $pattern)) {
118            throw new \InvalidArgumentException('Pattern contains a nested quantifier and is not ReDoS-safe.');
119        }
120
121        // Verify the pattern compiles. The subject string is arbitrary — it only
122        // exists to trigger compilation. A compile failure sets a PREG_*_ERROR
123        // regardless of the subject, and a valid pattern against a single
124        // character can never hit a backtrack/recursion limit, so 'x' is safe
125        // and unambiguous. preg_last_error() distinguishes a compile error
126        // from a legitimate no-match.
127        @preg_match($pattern, 'x');
128        if (preg_last_error() !== PREG_NO_ERROR) {
129            throw new \InvalidArgumentException('Pattern is not a valid PCRE pattern.');
130        }
131
132        return new self($pattern, $flags);
133    }
134
135    /**
136     * @return string|Closure(FieldContext): string The default error message.
137     */
138    #[Override]
139    protected function defaultMessage(): string|Closure|null
140    {
141        return fn(FieldContext $ctx) => "The {$ctx->field} field didn't match the expected pattern.";
142    }
143
144    /**
145     * Validate that the value matches the configured regex pattern.
146     *
147     * @param FieldContext $ctx The context of the field being validated.
148     * @return bool True if the value matches the pattern, false otherwise.
149     */
150    #[Override]
151    public function validate(FieldContext $ctx): bool
152    {
153        return is_string($ctx->value) && preg_match($this->pattern, $ctx->value, flags: $this->flags);
154    }
155}

Inherited from Lucent\Validation\Constraint

38    final public function withMessage(string|\Closure $message): static
39    {
40        $this->customMessage = $message;
41        return $this;
42    }
56    final public function message(FieldContext $ctx): ?string
57    {
58        $message = $this->customMessage ?? $this->defaultMessage();
59
60        if ($message === null) {
61            return null;
62        }
63
64        if ($message instanceof \Closure) {
65            return call_user_func($message, $ctx);
66        }
67
68        return $message;
69    }