Lines 97.61% 82 / 84
Methods 92.30% 12 / 13
Classes 0.00% 0 / 1
Name Lines Methods CRAP
 __construct 100.00% 4 / 4 100.00% 1 / 1 2
 child 100.00% 14 / 14 100.00% 1 / 1 2
 branch 100.00% 4 / 4 100.00% 1 / 1 1
 withResult 100.00% 12 / 12 100.00% 1 / 1 1
 context 80.00% 8 / 10 0.00% 0 / 1 10.80
 requireContext 100.00% 10 / 10 100.00% 1 / 1 4
 file 100.00% 4 / 4 100.00% 1 / 1 4
 seedRaw 100.00% 3 / 3 100.00% 1 / 1 3
 valueOf 100.00% 8 / 8 100.00% 1 / 1 4
 normalize 100.00% 2 / 2 100.00% 1 / 1 1
 resolveSibling 100.00% 6 / 6 100.00% 1 / 1 3
 [Lucent\Validation\Concerns\ResolvesPaths] segments 100.00% 1 / 1 100.00% 1 / 1 2
 [Lucent\Validation\Concerns\ResolvesPaths] tryValueAtPath 100.00% 6 / 6 100.00% 1 / 1 4
33final class FieldContext
34{
35    use ResolvesPaths;
36
37    /**
38     * Arbitrary per-validation values keyed by name.
39     *
40     * Resolved once at construction and read via {@see context()}. Stored on the
41     * context — created fresh per validation call — so it is coroutine-safe
42     * and never shared across requests.
43     *
44     * @var array<string, mixed>
45     */
46    private readonly array $context;
47
48    /**
49     * Create a new field context.
50     *
51     * @param string $field The dotted path of the field being validated (e.g. `user.name`).
52     * @param mixed $value The raw value of the field.
53     * @param bool $present Whether the field key was present in the data.
54     * @param Result $result The result object that collects errors and normalized values.
55     * @param array<string, UploadedFileInterface>|null $files The uploaded files, or null if none.
56     * @param mixed $body The data payload being validated, or null if none.
57     * @param array<string, mixed> $context Per-validation values (e.g. the originating
58     *        ServerRequest, the authenticated user) exposed to constraints via {@see get()}.
59     * @param string $name The leaf field name, used for file lookups. Defaults to $field.
60     */
61    /**
62     * Whether a child constraint failed during validation of this field.
63     *
64     * Used by the shape combinators ({@see \Lucent\Validation\Combinators\Shape}
65     * and {@see \Lucent\Validation\Combinators\Each}) to suppress their generic
66     * error message when a child already recorded a specific error. Stored on
67     * the context — which is created fresh for every validation call — rather
68     * than on the constraint instance, so it cannot bleed across requests
69     * under long-running runtimes (Octane/Swoole/RoadRunner).
70     */
71    public bool $childFailed = false;
72
73    /**
74     * The constraint that failed, when this field's validation is a composite
75     * ({@see \Lucent\Validation\Combinators\All}).
76     *
77     * Used to delegate the error message to the failing constraint. Stored on
78     * the context for the same reason as {@see $childFailed}: it is
79     * per-validation and never shared across requests.
80     */
81    public ?Constraint $failedConstraint = null;
82
83    /**
84     * The dotted path split into segments, cached to avoid re-splitting on
85     * every {@see Result} write.
86     *
87     * @var list<string>
88     */
89    public array $segments;
90
91    public function __construct(
92        public readonly string $field,
93        public private(set) mixed $value,
94        public readonly bool $present,
95        public readonly Result $result,
96        private readonly array|null $files,
97        private readonly mixed $body,
98        array $context = [],
99        public string $name = '',
100    ) {
101        $this->context = $context;
102
103        if ($this->name === '') {
104            $this->name = $this->field;
105        }
106
107        $this->segments = $this->segments($this->field);
108    }
109
110    /**
111     * Derive a child context for a sub-field.
112     *
113     * Extends the current dotted path with the sub-field name so errors and
114     * normalized values are namespaced. Used by {@see \Lucent\Validation\Combinators\Shape}
115     * and {@see \Lucent\Validation\Combinators\Each}.
116     *
117     * @param int|string $name The sub-field name (leaf). Integer keys (e.g.
118     *        tuple indices) are cast to a string for the dotted path.
119     * @param mixed $value The sub-field's raw value.
120     * @param bool $present Whether the sub-field key was present.
121     * @return self A new context for the sub-field.
122     */
123    public function child(int|string $name, mixed $value, bool $present): self
124    {
125        $name = (string) $name;
126        $path = $this->field === '' ? $name : $this->field . '.' . $name;
127
128        $child = new self(
129            $path,
130            $value,
131            $present,
132            $this->result,
133            $this->files,
134            $this->body,
135            $this->context,
136            $name,
137        );
138
139        // Reuse the parent's cached segments to avoid re-splitting the shared
140        // prefix of the dotted path. The leaf name is split through the same
141        // segments() helper so the cached segments always agree with the
142        // dotted-path string. (Dotted keys are not supported — see
143        // ResolvesPaths — so a leaf name is normally a single segment.)
144        $child->segments = [...$this->segments, ...$this->segments($name)];
145
146        return $child;
147    }
148
149    /**
150     * Validate a constraint in isolation against a throwaway result.
151     *
152     * Runs the constraint against a fresh {@see Result}, so its errors and
153     * normalized values are captured in isolation and never touch the shared
154     * result. Used by combinators such as {@see \Lucent\Validation\Combinators\One}
155     * and {@see \Lucent\Validation\Combinators\Any} to try each alternative
156     * independently, then commit only the winning branch via
157     * {@see Result::merge()} — so a losing branch's errors *and* values never
158     * leak into the final result.
159     *
160     * The pass/fail is the constraint's {@see Constraint::validate()} return
161     * value, not whether it recorded an error: most constraints return false
162     * without recording an error themselves (that is done by the caller via
163     * {@see Constraint::message()}). The branch result therefore holds the
164     * constraint's normalized values and any errors it recorded directly
165     * (e.g. child errors from a {@see \Lucent\Validation\Combinators\Shape}).
166     *
167     * @param Constraint $constraint The constraint to validate in isolation.
168     * @return array{0: bool, 1: Result} `[passed, branch]` where `passed` is
169     *         the constraint's validate() result and `branch` holds its
170     *         normalized values and recorded errors.
171     */
172    public function branch(Constraint $constraint): array
173    {
174        $branch = new Result();
175        $branchCtx = $this->withResult($branch);
176
177        $passed = $constraint->validate($branchCtx);
178
179        return [$passed, $branch];
180    }
181
182    /**
183     * Derive a copy of this context that writes to a different result.
184     *
185     * Returns a context with the same field, value, presence, files, body,
186     * and context bag, but a fresh {@see Result}. Used by combinators such as
187     * {@see \Lucent\Validation\Combinators\One} to validate each alternative
188     * in isolation against a throwaway result, then commit only the winning
189     * branch via {@see Result::merge()} — so a losing branch's errors *and*
190     * values never leak into the final result.
191     *
192     * @param Result $result The result the new context should write to.
193     * @return self A new context sharing everything but the result.
194     */
195    public function withResult(Result $result): self
196    {
197        $copy = new self(
198            $this->field,
199            $this->value,
200            $this->present,
201            $result,
202            $this->files,
203            $this->body,
204            $this->context,
205            $this->name,
206        );
207
208        $copy->segments = $this->segments;
209
210        return $copy;
211    }
212
213    /**
214     * Get a value from the context bag, cast to a given type.
215     *
216     * A typed getter mirroring {@see Result::valueAs()}: scalar types are
217     * passed as string literals (`'int'`, `'string'`, `'bool'`, `'float'`,
218     * `'array'`), userland classes via `::class`. Falls back to the default
219     * when the key is absent or the value cannot be cast.
220     *
221     * ```php
222     * $request = $ctx->context('request', ServerRequestInterface::class);
223     * $userId  = $ctx->context('user_id', 'int');
224     * ```
225     *
226     * @template T
227     * @param string $key The name of the value.
228     * @param class-string<T>|string $type The type to cast to (e.g. `'int'` or `User::class`).
229     * @param T|null $default The value to return when the key is absent or the
230     *        value cannot be cast to the requested type.
231     * @return ($default is null ? T|null : T) The value cast to the requested
232     *         type, or $default. When $default is non-null the return is always T.
233     */
234    public function context(string $key, string $type, mixed $default = null): mixed
235    {
236        $value = $this->context[$key] ?? $default;
237
238        if ($value === $default) {
239            return $default;
240        }
241
242        return match ($type) {
243            'int'    => (int) $value,
244            'string' => (string) $value,
245            'bool'   => (bool) $value,
246            'float'  => (float) $value,
247            'array'  => is_array($value) ? $value : $default,
248            default  => $value instanceof $type ? $value : $default,
249        };
250    }
251
252    /**
253     * Get a value from the context bag cast to a given type, or throw if absent.
254     *
255     * The fail-fast counterpart to {@see context()}: intended for values the
256     * constraint genuinely cannot run without (the originating request, the
257     * authenticated user, a tenant id). Instead of returning a default and
258     * forcing a null-check, it throws a descriptive {@see \RuntimeException}
259     * so a missing or wrongly-typed context value surfaces at the point of
260     * use with a clear message.
261     *
262     * Scalar casts always succeed, so this throws only when the key is absent
263     * or the value cannot satisfy a class/array type. It does not throw on a
264     * scalar cast (e.g. `(int) 'abc'` → `0`).
265     *
266     * @template T
267     * @param string $key The name of the value.
268     * @param class-string<T>|string $type The type to cast to (e.g. `'int'` or `User::class`).
269     * @return T The value cast to the requested type.
270     * @throws \RuntimeException When the key is absent or the value cannot
271     *         satisfy the requested class/array type.
272     */
273    public function requireContext(string $key, string $type): mixed
274    {
275        if (!array_key_exists($key, $this->context)) {
276            throw new \RuntimeException(
277                sprintf('Context key "%s" is missing.', $key)
278            );
279        }
280
281        $value = $this->context($key, $type);
282
283        if ($value === null && !in_array($type, ['int', 'string', 'bool', 'float'], true)) {
284            throw new \RuntimeException(
285                sprintf('Context value "%s" cannot be cast to type "%s".', $key, $type)
286            );
287        }
288
289        return $value;
290    }
291
292    /**
293     * Get the uploaded file for the current field, if any.
294     *
295     * @return UploadedFileInterface|null The uploaded file, or null if the
296     *         field has no file or the file is not a valid upload.
297     */
298    public function file(): ?UploadedFileInterface
299    {
300        if ($this->files === null) {
301            return null;
302        }
303
304        $file = array_key_exists($this->name, $this->files) ? $this->files[$this->name] : null;
305        return $file instanceof UploadedFileInterface ? $file : null;
306    }
307
308    /**
309     * Store the field's raw value in the result.
310     *
311     * Called by container constraints for each present child before running
312     * its constraint, so the raw value is retrievable even when the child
313     * constraint only validates and does not normalize. A child constraint
314     * that does normalize overwrites this via {@see normalize()}.
315     *
316     * Only scalar values are seeded. Arrays and objects are containers: a
317     * nested {@see \Lucent\Validation\Combinators\Shape} or
318     * {@see \Lucent\Validation\Combinators\Each} declares them via
319     * {@see Result::ensureContainer()} and seeds their declared sub-fields, so
320     * seeding the whole raw value here would leak undeclared keys.
321     *
322     * @return void
323     */
324    public function seedRaw(): void
325    {
326        if (is_array($this->value) || is_object($this->value)) {
327            return;
328        }
329
330        $this->result->setSegments($this->segments, $this->value);
331    }
332
333    /**
334     * Get the value of another field.
335     *
336     * Returns the current field's value when the requested field is this one.
337     * Otherwise, resolves the requested field relative to the current field's
338     * parent (so a sibling lookup inside a nested shape works), prefers a
339     * normalized value already stored in the result, and falls back to the
340     * raw request body.
341     *
342     * @param string $field The name of the field to read.
343     * @return mixed The value of the requested field, or null if absent.
344     */
345    public function valueOf(string $field): mixed
346    {
347        if ($field === $this->field) {
348            return $this->value;
349        }
350
351        $target = $this->resolveSibling($field);
352
353        [$found, $value] = $this->result->tryValue($target);
354        if ($found) {
355            return $value;
356        }
357
358        [$found, $value] = $this->tryValueAtPath($this->body, $target);
359
360        return $found ? $value : null;
361    }
362
363    /**
364     * Normalize the current field's value.
365     *
366     * Updates the context's value and stores the normalized value in the
367     * result at the field's dotted path so it can be retrieved later via
368     * {@see Result::value()}.
369     *
370     * @param mixed $value The normalized value to store.
371     * @return void
372     */
373    public function normalize(mixed $value): void
374    {
375        $this->value = $value;
376        $this->result->setSegments($this->segments, $value);
377    }
378
379    /**
380     * Resolve a sibling field name against the current field's parent path.
381     *
382     * The resolution algorithm is:
383     *
384     * 1. A field name containing a dot is treated as an **absolute** dotted
385     *    path and returned unchanged (e.g. `user.password_confirmation` stays
386     *    `user.password_confirmation`). This lets a nested shape reference a
387     *    field anywhere in the tree, not just a sibling.
388     * 2. A bare leaf name is resolved relative to the current field's parent
389     *    (e.g. inside `user.password`, `password_confirmation` resolves to
390     *    `user.password_confirmation`).
391     * 3. At the top level (no dot in the current field), a bare leaf name is
392     *    returned unchanged.
393     *
394     * @param string $field The leaf name or absolute dotted path of the field.
395     * @return string The resolved dotted path.
396     */
397    private function resolveSibling(string $field): string
398    {
399        if (str_contains($field, '.')) {
400            return $field;
401        }
402
403        $dot = strrpos($this->field, '.');
404
405        if ($dot === false) {
406            return $field;
407        }
408
409        return substr($this->field, 0, $dot) . '.' . $field;
410    }
411}

From Lucent\Validation\Concerns\ResolvesPaths

22trait ResolvesPaths
23{
24    /**
25     * Split a dotted path into its segments.
26     *
27     * The `.` character is always a separator; there is no escape mechanism
28     * for a literal dot in a key.
29     *
30     * @param string $path The dotted path to split.
31     * @return list<string> The path segments.
32     */
33    protected function segments(string $path): array
34    {
35        return $path === '' ? [] : explode('.', $path);
36    }
37
38    /**
39     * Read a value from a nested array by dotted path in a single traversal.
40     *
41     * Distinguishes a key that is present with a `null` value from a key that
42     * is absent entirely, without traversing the path twice.
43     *
44     * @param mixed $array The nested array to read from.
45     * @param string $path The dotted path to read.
46     * @return array{0: bool, 1: mixed} `[true, value]` when the path resolves,
47     *         `[false, null]` when it does not.
48     */
49    protected function tryValueAtPath(mixed $array, string $path): array
50    {
51        $ref = $array;
52
53        foreach ($this->segments($path) as $segment) {
54            if (!is_array($ref) || !array_key_exists($segment, $ref)) {
55                return [false, null];
56            }
57            $ref = $ref[$segment];
58        }
59
60        return [true, $ref];
61    }
62}