Lines 98.66% 74 / 75
Methods 94.44% 17 / 18
Classes 0.00% 0 / 1
Name Lines Methods CRAP
 addError 100.00% 2 / 2 100.00% 1 / 1 1
 set 100.00% 1 / 1 100.00% 1 / 1 1
 setSegments 100.00% 15 / 15 100.00% 1 / 1 6
 ensureContainer 100.00% 7 / 7 100.00% 1 / 1 4
 errors 100.00% 1 / 1 100.00% 1 / 1 1
 snapshotErrors 100.00% 1 / 1 100.00% 1 / 1 1
 restoreErrors 100.00% 1 / 1 100.00% 1 / 1 1
 merge 100.00% 5 / 5 100.00% 1 / 1 3
 mergeValues 85.71% 6 / 7 0.00% 0 / 1 5.07
 hasErrors 100.00% 1 / 1 100.00% 1 / 1 1
 values 100.00% 1 / 1 100.00% 1 / 1 1
 hasValue 100.00% 1 / 1 100.00% 1 / 1 2
 value 100.00% 4 / 4 100.00% 1 / 1 3
 valueAs 100.00% 10 / 10 100.00% 1 / 1 10
 requireValueAs 100.00% 10 / 10 100.00% 1 / 1 4
 tryValue 100.00% 1 / 1 100.00% 1 / 1 1
 [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
27final class Result
28{
29    use ResolvesPaths;
30
31    /**
32     * Validation errors keyed by dotted field path.
33     *
34     * @var array<string, list<string>>
35     */
36    private array $errors = [];
37
38    /**
39     * Validated values.
40     *
41     * Seeded with each present field's raw value and overwritten by any
42     * normalization that occurs during validation. Usually a nested array
43     * keyed by field name, but a top-level scalar constraint (e.g.
44     * `new Validator(new Length(min: 3))->validate('abc')`) stores a scalar
45     * at the root.
46     *
47     * @var mixed
48     */
49    private mixed $values = [];
50
51    /**
52     * Record a validation error for a field.
53     *
54     * Multiple errors may be recorded for the same field.
55     *
56     * @param string $field The dotted path of the field that failed.
57     * @param string $message The error message to record.
58     * @return void
59     */
60    public function addError(string $field, string $message): void
61    {
62        $this->errors[$field] ??= [];
63        $this->errors[$field][] = $message;
64    }
65
66    /**
67     * Store a value at a dotted path.
68     *
69     * Called by the {@see Validator} to seed each present field's raw value,
70     * and by {@see FieldContext::normalize()} to overwrite it with a
71     * normalized value. Intermediate arrays are created as needed.
72     *
73     * @param string $path The dotted path of the field (e.g. `user.name`).
74     * @param mixed $value The value to store.
75     * @return void
76     */
77    public function set(string $path, mixed $value): void
78    {
79        $this->setSegments($this->segments($path), $value);
80    }
81
82    /**
83     * Store a value at a pre-split dotted path.
84     *
85     * The hot path for storing values: {@see FieldContext} caches its path
86     * segments and passes them here directly, avoiding a re-split on every
87     * call. {@see set()} is a thin wrapper that splits the path first.
88     *
89     * Intermediate segments that do not yet exist are created as arrays. If an
90     * intermediate segment already holds a non-array value, that value would
91     * otherwise be silently destroyed; this throws instead so the conflict is
92     * surfaced rather than corrupting the result.
93     *
94     * @param list<string> $segments The dotted path segments (e.g. `['user', 'name']`).
95     * @param mixed $value The value to store.
96     * @return void
97     * @throws \LogicException If an intermediate segment holds a non-array value.
98     */
99    public function setSegments(array $segments, mixed $value): void
100    {
101        if ($segments === []) {
102            $this->values = $value;
103            return;
104        }
105
106        $ref = &$this->values;
107        $last = array_pop($segments);
108
109        // Descend through the intermediate segments, creating arrays as
110        // needed. A non-array intermediate would otherwise be silently
111        // destroyed, so throw instead of corrupting the result.
112        foreach ($segments as $segment) {
113            if (array_key_exists($segment, $ref) && !is_array($ref[$segment])) {
114                throw new \LogicException(
115                    "Cannot store value at '" . implode('.', [...$segments, $last]) . "': segment '$segment' already holds a non-array value.",
116                );
117            }
118            if (!isset($ref[$segment])) {
119                $ref[$segment] = [];
120            }
121            $ref = &$ref[$segment];
122        }
123
124        // The final segment is overwritten (e.g. seedRaw then normalize).
125        $ref[$last] = $value;
126        unset($ref);
127    }
128
129    /**
130     * Ensure a container (array) exists at a dotted path.
131     *
132     * Creates an empty array at the path if none exists, creating any
133     * intermediate arrays as needed. Used by container constraints
134     * ({@see \Lucent\Validation\Combinators\Shape} and
135     * {@see \Lucent\Validation\Combinators\Each}) to declare the field as a
136     * structure whose children will be seeded individually, so undeclared
137     * keys from the raw input are never stored.
138     *
139     * At the root (empty path) this is a no-op, since the values store is
140     * always an array. An existing array at the path is left untouched; a
141     * non-array value is replaced with an empty array.
142     *
143     * @param string $path The dotted path of the container field.
144     * @return void
145     */
146    public function ensureContainer(string $path): void
147    {
148        $segments = $this->segments($path);
149
150        $ref = &$this->values;
151        foreach ($segments as $segment) {
152            if (!isset($ref[$segment]) || !is_array($ref[$segment])) {
153                $ref[$segment] = [];
154            }
155            $ref = &$ref[$segment];
156        }
157        unset($ref);
158    }
159
160    /**
161     * Get all validation errors.
162     *
163     * @return array<string, list<string>> Errors keyed by dotted field path.
164     */
165    public function errors(): array
166    {
167        return $this->errors;
168    }
169
170    /**
171     * Capture the current error state.
172     *
173     * Used by combinators such as {@see \Lucent\Validation\Combinators\Any}
174     * to validate alternatives in isolation and roll back a failed branch's
175     * errors via {@see restoreErrors()}.
176     *
177     * @return array<string, list<string>> A snapshot of the current errors.
178     */
179    public function snapshotErrors(): array
180    {
181        return $this->errors;
182    }
183
184    /**
185     * Restore a previously captured error state.
186     *
187     * Replaces the current errors with the given snapshot, discarding any
188     * errors recorded since it was captured.
189     *
190     * @param array<string, list<string>> $errors The error snapshot to restore.
191     * @return void
192     */
193    public function restoreErrors(array $errors): void
194    {
195        $this->errors = $errors;
196    }
197
198    /**
199     * Merge another result's errors and values into this one.
200     *
201     * Commits the errors and validated values of a branch result into this
202     * result. Used by combinators such as
203     * {@see \Lucent\Validation\Combinators\One} to validate each alternative
204     * against a fresh, throwaway {@see Result} and then commit only the
205     * winning branch â€” so a losing branch's errors *and* values never leak
206     * into the final result.
207     *
208     * Errors are appended to any existing errors at the same field path.
209     * Values are merged recursively: nested arrays are merged key-by-key, and
210     * scalar values overwrite whatever is currently stored at that path.
211     *
212     * @param Result $other The branch result to commit into this one.
213     * @return void
214     */
215    public function merge(Result $other): void
216    {
217        foreach ($other->errors as $field => $messages) {
218            $this->errors[$field] ??= [];
219            foreach ($messages as $message) {
220                $this->errors[$field][] = $message;
221            }
222        }
223
224        $this->values = $this->mergeValues($this->values, $other->values);
225    }
226
227    /**
228     * Recursively merge two value trees.
229     *
230     * When both sides are arrays, the result is a key-by-key merge (the
231     * right-hand side wins on scalar conflicts). Otherwise the right-hand
232     * value replaces the left-hand value.
233     *
234     * @param mixed $left The current value tree.
235     * @param mixed $right The branch value tree to merge in.
236     * @return mixed The merged value tree.
237     */
238    private function mergeValues(mixed $left, mixed $right): mixed
239    {
240        if (is_array($left) && is_array($right)) {
241            foreach ($right as $key => $value) {
242                $left[$key] = array_key_exists($key, $left)
243                    ? $this->mergeValues($left[$key], $value)
244                    : $value;
245            }
246            return $left;
247        }
248
249        return $right;
250    }
251
252    /**
253     * Determine whether any validation errors were recorded.
254     *
255     * @return bool True if at least one error exists.
256     */
257    public function hasErrors(): bool
258    {
259        return count($this->errors) > 0;
260    }
261
262    /**
263     * Get all validated values.
264     *
265     * @return mixed The validated values. Usually a nested array keyed by
266     *         top-level field name, but a scalar when a top-level scalar
267     *         constraint normalized a plain value.
268     */
269    public function values(): mixed
270    {
271        return $this->values;
272    }
273
274    /**
275     * Determine whether a value exists at a dotted path.
276     *
277     * @param string $path The dotted path of the field.
278     * @return bool True if a value is stored at the path.
279     */
280    public function hasValue(string $path): bool
281    {
282        return $path === '' || $this->tryValueAtPath($this->values, $path)[0];
283    }
284
285    /**
286     * Get the value at a dotted path, or a default if absent.
287     *
288     * @param string $path The dotted path of the field.
289     * @param mixed $default The value to return when the path has no stored value.
290     * @return mixed The stored value, or $default.
291     */
292    public function value(string $path, mixed $default = null): mixed
293    {
294        if ($path === '') {
295            return $this->values;
296        }
297
298        [$found, $value] = $this->tryValue($path);
299
300        return $found ? $value : $default;
301    }
302
303    /**
304     * Get a value at a dotted path cast to a given type.
305     *
306     * A typed getter: the type is given as a class-string, so userland
307     * classes work via `User::class`. Built-in scalar types are passed as
308     * string literals (`'int'`, `'string'`, `'bool'`, `'float'`, `'array'`) â€”
309     * `int::class` is not valid PHP. The stored value is cast to the
310     * requested type, falling back to the default when the path is absent or
311     * the value cannot be cast.
312     *
313     * ```php
314     * $age = $result->valueAs('age', 'int');          // (int) value
315     * $name = $result->valueAs('name', 'string');     // (string) value
316     * $user = $result->valueAs('user', User::class);  // value, or $default
317     * ```
318     *
319     * @template T
320     * @param string $path The dotted path of the field.
321     * @param class-string<T>|string $type The type to cast to (e.g. `'int'` or `User::class`).
322     * @param T|null $default The value to return when the path is absent or the
323     *        value cannot be cast to the requested type.
324     * @return ($default is null ? T|null : T) The value cast to the requested
325     *         type, or $default. When $default is non-null the return is always T.
326     */
327    public function valueAs(string $path, string $type, mixed $default = null): mixed
328    {
329        $value = $this->value($path, $default);
330
331        if ($value === $default) {
332            return $default;
333        }
334
335        return match ($type) {
336            'int'    => (int) $value,
337            'string' => (string) $value,
338            'bool'   => (bool) $value,
339            'float'  => (float) $value,
340            'array'  => is_array($value) ? $value : $default,
341            default  => $value instanceof $type ? $value : $default,
342        };
343    }
344
345    /**
346     * Get a value at a dotted path cast to a given type, or throw if absent.
347     *
348     * The fail-fast counterpart to {@see valueAs()}: intended for values the
349     * caller genuinely cannot proceed without. Instead of returning a default
350     * and forcing a null-check, it throws a descriptive
351     * {@see \RuntimeException} so a missing or wrongly-typed value surfaces at
352     * the point of use with a clear message.
353     *
354     * Scalar casts always succeed, so this throws only when the path is
355     * absent or the value cannot satisfy a class/array type. It does not
356     * throw on a scalar cast (e.g. `(int) 'abc'` â†’ `0`).
357     *
358     * @template T
359     * @param string $path The dotted path of the field.
360     * @param class-string<T>|string $type The type to cast to (e.g. `'int'` or `User::class`).
361     * @return T The value cast to the requested type.
362     * @throws \RuntimeException When the path is absent or the value cannot
363     *         satisfy the requested class/array type.
364     */
365    public function requireValueAs(string $path, string $type): mixed
366    {
367        if (!$this->hasValue($path)) {
368            throw new \RuntimeException(
369                sprintf('Result has no value at path "%s".', $path)
370            );
371        }
372
373        $value = $this->valueAs($path, $type);
374
375        if ($value === null && !in_array($type, ['int', 'string', 'bool', 'float'], true)) {
376            throw new \RuntimeException(
377                sprintf('Result value at path "%s" cannot be cast to type "%s".', $path, $type)
378            );
379        }
380
381        return $value;
382    }
383
384    /**
385     * Get the value at a dotted path in a single traversal.
386     *
387     * Distinguishes a key that is present with a `null` value from a key that
388     * is absent entirely, without traversing the path twice. Delegates to the
389     * shared {@see ResolvesPaths} helper.
390     *
391     * @param string $path The dotted path of the field.
392     * @return array{0: bool, 1: mixed} `[true, value]` when the path resolves,
393     *         `[false, null]` when it does not.
394     */
395    public function tryValue(string $path): array
396    {
397        return $this->tryValueAtPath($this->values, $path);
398    }
399}

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}