Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
243 / 243
100.00% covered (success)
100.00%
15 / 15
CRAP
100.00% covered (success)
100.00%
1 / 1
NameParser
100.00% covered (success)
100.00%
243 / 243
100.00% covered (success)
100.00%
15 / 15
92
100.00% covered (success)
100.00%
1 / 1
 parse
100.00% covered (success)
100.00%
39 / 39
100.00% covered (success)
100.00%
1 / 1
8
 clean
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 tokenize
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 normalizeCase
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
3
 normalizeWords
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 parseCommaMode
100.00% covered (success)
100.00%
25 / 25
100.00% covered (success)
100.00%
1 / 1
6
 absorbLeftovers
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 finalizeInitials
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
4
 extractNickname
100.00% covered (success)
100.00%
24 / 24
100.00% covered (success)
100.00%
1 / 1
8
 extractSalutation
100.00% covered (success)
100.00%
29 / 29
100.00% covered (success)
100.00%
1 / 1
11
 extractSuffix
100.00% covered (success)
100.00%
32 / 32
100.00% covered (success)
100.00%
1 / 1
12
 extractInitials
100.00% covered (success)
100.00%
24 / 24
100.00% covered (success)
100.00%
1 / 1
14
 extractLastname
100.00% covered (success)
100.00%
33 / 33
100.00% covered (success)
100.00%
1 / 1
16
 extractFirstname
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 extractMiddlename
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2declare(strict_types=1);
3/**
4 * Pop PHP Framework (https://www.popphp.org/)
5 *
6 * @link       https://github.com/popphp/popphp-framework
7 * @author     Nick Sagona, III <nick@popphp.org>
8 * @copyright  Copyright (c) 2009-2026 Nick Sagona, III
9 * @license    https://www.popphp.org/license     New BSD License
10 */
11
12/**
13 * @namespace
14 */
15namespace Pop\Parser\Name;
16
17use Pop\Parser\AbstractParser;
18use Pop\Parser\Exception;
19
20/**
21 * Name parser class
22 *
23 * @category   Pop
24 * @package    Pop\Parser
25 * @author     Nick Sagona, III <nick@popphp.org>
26 * @copyright  Copyright (c) 2009-2026 Nick Sagona, III
27 * @license    https://www.popphp.org/license     New BSD License
28 * @version    1.0.0
29 */
30class NameParser extends AbstractParser
31{
32
33    /**
34     * Parse method
35     *
36     * Builds up the parsed fields in a local $fields array (and a local $initialsQueue),
37     * threaded by reference through each extraction step, rather than on $this - the
38     * parser holds no parsed-field state of its own. The very last step wraps that array
39     * into an immutable NameResult, which is what's actually returned.
40     *
41     * @param  ?string $name
42     * @throws Exception
43     * @return NameResult
44     */
45    public function parse(?string $name = null): NameResult
46    {
47        if (empty($this->data) && empty($name)) {
48            throw new Exception('Error: You must pass a name string to the parser object.');
49        }
50
51        if ((null === $name) && !empty($this->data)) {
52            $name = $this->data;
53        } else if (null !== $name) {
54            $this->data = $name;
55        }
56
57        $name = $this->clean($name);
58
59        if ($name === '') {
60            throw new Exception('Error: You must pass a name string to the parser object.');
61        }
62
63        $fields = [
64            'salutation'     => null,
65            'firstname'      => null,
66            'middlename'     => null,
67            'nickname'       => null,
68            'initials'       => null,
69            'lastnamePrefix' => null,
70            'lastname'       => null,
71            'suffix'         => null,
72            'credentials'    => null,
73        ];
74        $initialsQueue = [];
75
76        $nameValues = new NameValues();
77
78        if (str_contains($name, ',')) {
79            $this->parseCommaMode($name, $nameValues, $fields, $initialsQueue);
80        } else {
81            $tokens        = $this->tokenize($name);
82            $originalCount = count($tokens);
83            $tokens        = $this->extractNickname($tokens, $nameValues, $fields);
84            $tokens        = $this->extractSalutation($tokens, $nameValues, $fields);
85            $tokens        = $this->extractSuffix($tokens, $nameValues, $fields, 2);
86            $tokens        = $this->extractInitials($tokens, false, $nameValues, $initialsQueue);
87            $tokens        = $this->extractLastname($tokens, $nameValues, $fields, false, $originalCount);
88            $tokens        = $this->extractFirstname($tokens, $fields);
89            $tokens        = $this->extractMiddlename($tokens, $fields);
90            $this->absorbLeftovers($tokens, $fields);
91        }
92
93        $this->finalizeInitials($fields, $initialsQueue);
94
95        $fields['confidence'] = $this->calculateConfidence($fields['confidenceSignalCount'] ?? 0);
96        unset($fields['confidenceSignalCount']);
97
98        $this->result = new NameResult($fields);
99
100        return $this->result;
101    }
102
103    /**
104     * Clean method
105     *
106     * @param  string $name
107     * @return string
108     */
109    public function clean(string $name): string
110    {
111        return trim(preg_replace('/\s+/', ' ', $name));
112    }
113
114    /**
115     * Tokenize method
116     *
117     * Splits on whitespace and drops any resulting empty tokens - not just the ordinary "no
118     * internal double spaces" case, but the edge case where $name is itself empty (e.g. an
119     * empty comma-mode segment from a leading/trailing/doubled comma, like ",John Smith,"),
120     * where preg_split would otherwise yield a single spurious "" token instead of no tokens
121     * at all, which downstream steps would then treat as a real (blank) word to claim.
122     *
123     * @param  string $name
124     * @return array
125     */
126    protected function tokenize(string $name): array
127    {
128        return array_values(array_filter(preg_split('/\s+/', trim($name)), fn($token) => $token !== ''));
129    }
130
131    /**
132     * Normalize the case of a single word: an all-uppercase or all-lowercase word gets
133     * title-cased ("MACDONALD" / "macdonald" -> "Macdonald"); a word with any existing
134     * mixed case (e.g. "MacDonald", "McDonald", "O'Brien") is left exactly as typed, since
135     * that mixed case is almost always deliberate. "O'Brien"-style words are already handled
136     * correctly by the title-casing above (the apostrophe splits it into two letter-runs,
137     * each capitalized independently) with no extra logic needed. "Mc" gets one further,
138     * explicit fix-up: capitalize the letter right after it ("Mcdonald" -> "McDonald"), since
139     * it's a reliable surname-prefix marker in English with very few false positives. "Mac" is
140     * deliberately NOT special-cased the same way - it's also the start of many ordinary
141     * names/words ("Macy", "Mack", "Macon") where blindly capitalizing the next letter would
142     * be wrong more often than right.
143     *
144     * @param  string $word
145     * @return string
146     */
147    protected function normalizeCase(string $word): string
148    {
149        $stripped = str_replace('.', '', $word);
150
151        if (($stripped === mb_strtoupper($stripped)) || ($stripped === mb_strtolower($stripped))) {
152            $titled = preg_replace_callback('/\p{L}+/u', function ($matches) {
153                return mb_convert_case($matches[0], MB_CASE_TITLE);
154            }, $word);
155
156            return preg_replace_callback('/\bMc(\p{L})/u', function ($matches) {
157                return 'Mc' . mb_strtoupper($matches[1]);
158            }, $titled);
159        }
160
161        return $word;
162    }
163
164    /**
165     * Normalize the case of each word in an array and join them with a space
166     *
167     * @param  array $words
168     * @return string
169     */
170    protected function normalizeWords(array $words): string
171    {
172        return implode(' ', array_map([$this, 'normalizeCase'], $words));
173    }
174
175    /**
176     * Parse comma-separated "Last, First Middle[, Suffix]" format
177     *
178     * @param  string     $name
179     * @param  NameValues $nameValues
180     * @param  array      $fields
181     * @param  array      $initialsQueue
182     * @return void
183     */
184    protected function parseCommaMode(string $name, NameValues $nameValues, array &$fields, array &$initialsQueue): void
185    {
186        $segments = array_map('trim', explode(',', $name));
187
188        // Segment 1 (before the first comma): the lastname segment. Salutation, suffix and
189        // lastname(-with-prefix) extraction run here. Whatever's left over (e.g. "Garcia" in
190        // "Garcia Marquez, Gabriel", or a lastname-prefix word extractLastname's ordinary
191        // guards wouldn't fold at position 0) is deliberately NOT assigned to firstname here
192        // - it's carried forward and absorbed only after segment 2 runs, so it can never
193        // silently overwrite or lose to segment 2's own firstname; absorbLeftovers() merges
194        // it into middlename once firstname is already set instead.
195        $segment1       = $this->tokenize($segments[0]);
196        $originalCount1 = count($segment1);
197        $segment1       = $this->extractSalutation($segment1, $nameValues, $fields);
198        $segment1       = $this->extractSuffix($segment1, $nameValues, $fields, 0, true);
199        $segment1       = $this->extractLastname($segment1, $nameValues, $fields, true, $originalCount1);
200
201        // Segment 2 (between commas, or everything after the first comma): the given-name segment
202        if (isset($segments[1]) && ($segments[1] !== '')) {
203            $segment2 = $this->tokenize($segments[1]);
204            $segment2 = $this->extractSalutation($segment2, $nameValues, $fields);
205            $segment2 = $this->extractSuffix($segment2, $nameValues, $fields, 0, true, true);
206            $segment2 = $this->extractNickname($segment2, $nameValues, $fields);
207            $segment2 = $this->extractInitials($segment2, true, $nameValues, $initialsQueue);
208            $segment2 = $this->extractFirstname($segment2, $fields);
209            $segment2 = $this->extractMiddlename($segment2, $fields);
210            $this->absorbLeftovers($segment2, $fields);
211        }
212
213        // Now that segment 2 has had its chance to claim the firstname slot: if it didn't
214        // (firstname is still null), segment 1's leftover IS the given name, so split it the
215        // normal way (first word -> firstname, the rest -> middlename) rather than joining it
216        // into one string. If segment 2 already provided a firstname, segment 1's leftover is
217        // secondary content - absorbLeftovers() merges the whole thing into middlename.
218        if ($fields['firstname'] === null) {
219            $segment1 = $this->extractFirstname($segment1, $fields);
220            $segment1 = $this->extractMiddlename($segment1, $fields);
221        }
222        $this->absorbLeftovers($segment1, $fields);
223
224        // Segment 3 onward (after a second comma, if present): suffix only per segment, with
225        // any non-suffix leftover absorbed rather than discarded (e.g. "Smith, John, Michael"
226        // must not lose "Michael" just because it isn't a recognized suffix). Looping over
227        // every remaining segment - not just $segments[2] - means a name with more than 3
228        // comma-separated parts (e.g. "Smith, John, PhD, Esq") doesn't silently drop
229        // anything past the third.
230        foreach (array_slice($segments, 2) as $segment) {
231            if ($segment === '') {
232                continue;
233            }
234            $extraSegment = $this->tokenize($segment);
235            $extraSegment = $this->extractSuffix($extraSegment, $nameValues, $fields, 0, true);
236            $this->absorbLeftovers($extraSegment, $fields);
237        }
238    }
239
240    /**
241     * Absorb any tokens no extraction step claimed: the first time this is called with a
242     * non-empty leftover, it becomes firstname (since every name needs one); after that,
243     * leftovers are appended to middlename. This is a deliberate difference from
244     * theiconic/name-parser, which can silently drop an unrecognized leading word (e.g. "The"
245     * in "The Rev. Mark Williams") - here nothing is ever discarded. That second case (a
246     * leftover appended to middlename) is a confidence signal - it means content exists that
247     * didn't fit any recognized category and had to be defaulted into middlename, e.g. the
248     * "Garcia" in "Garcia Marquez, Gabriel" not being clearly lastname or given-name content.
249     *
250     * @param  array $tokens
251     * @param  array $fields
252     * @return void
253     */
254    protected function absorbLeftovers(array $tokens, array &$fields): void
255    {
256        if (empty($tokens)) {
257            return;
258        }
259
260        $text = $this->normalizeWords($tokens);
261
262        if ($fields['firstname'] === null) {
263            $fields['firstname'] = $text;
264        } else {
265            $fields['middlename']            = trim(($fields['middlename'] ?? '') . ' ' . $text);
266            $fields['confidenceSignalCount'] = ($fields['confidenceSignalCount'] ?? 0) + 1;
267        }
268    }
269
270    /**
271     * If no raw token ever became firstname but one or more initials were set aside, promote
272     * the FIRST claimed initial back to firstname - handles "J. B. Hunt" (firstname="J.",
273     * initials="B.", lastname="Hunt"), since a name consisting only of initials plus a
274     * lastname still needs a firstname. That promotion is itself a confidence signal - the
275     * name never actually supplied a real firstname token, just an initial standing in for one.
276     *
277     * @param  array $fields
278     * @param  array $initialsQueue
279     * @return void
280     */
281    protected function finalizeInitials(array &$fields, array &$initialsQueue): void
282    {
283        if (empty($initialsQueue)) {
284            return;
285        }
286
287        if ($fields['firstname'] === null) {
288            $promoted                        = array_shift($initialsQueue);
289            $fields['firstname']             = $this->normalizeCase($promoted);
290            $fields['confidenceSignalCount'] = ($fields['confidenceSignalCount'] ?? 0) + 1;
291        }
292
293        if (!empty($initialsQueue)) {
294            $fields['initials'] = trim(($fields['initials'] ?? '') . ' ' . $this->normalizeWords($initialsQueue));
295        }
296
297        $initialsQueue = [];
298    }
299
300    /**
301     * Extract nickname
302     *
303     * Scans for a token starting with an opening delimiter and collects tokens until one
304     * ends with the matching closing delimiter (supports multi-word nicknames).
305     *
306     * @param  array      $tokens
307     * @param  NameValues $nameValues
308     * @param  array      $fields
309     * @return array
310     */
311    protected function extractNickname(array $tokens, NameValues $nameValues, array &$fields): array
312    {
313        $delimiters = $nameValues->getNicknameDelimiters();
314        $openChars  = array_keys($delimiters);
315
316        foreach ($tokens as $start => $token) {
317            $firstChar = mb_substr($token, 0, 1);
318            if (!in_array($firstChar, $openChars, true)) {
319                continue;
320            }
321
322            $closeChar = $delimiters[$firstChar];
323            $end       = null;
324
325            for ($i = $start; $i < count($tokens); $i++) {
326                if (str_ends_with($tokens[$i], $closeChar) && (($i > $start) || (strlen($tokens[$i]) > 1))) {
327                    $end = $i;
328                    break;
329                }
330            }
331
332            if ($end === null) {
333                continue;
334            }
335
336            $span             = array_slice($tokens, $start, $end - $start + 1);
337            $span[0]          = ltrim($span[0], $firstChar);
338            $lastIndex        = count($span) - 1;
339            $span[$lastIndex] = rtrim($span[$lastIndex], $closeChar);
340            $span             = array_map(fn($word) => trim($word, '\'"'), $span);
341            $span             = array_filter($span, fn($word) => $word !== '');
342
343            $fields['nickname'] = $this->normalizeWords($span);
344            array_splice($tokens, $start, $end - $start + 1);
345            break;
346        }
347
348        return array_values($tokens);
349    }
350
351    /**
352     * Extract salutation
353     *
354     * Scans from the start of the tokens (bounded to roughly the first half) for matches
355     * against the salutation list, checked as both single tokens and multi-word phrases.
356     * Multiple consecutive salutations (e.g. "Rev. Dr John Doe") are all claimed.
357     *
358     * @param  array      $tokens
359     * @param  NameValues $nameValues
360     * @param  array      $fields
361     * @return array
362     */
363    protected function extractSalutation(array $tokens, NameValues $nameValues, array &$fields): array
364    {
365        $salutations = [];
366        foreach ($nameValues->getSalutations() as $key => $display) {
367            $keyWords      = explode(' ', $key);
368            $salutations[] = ['keyWords' => $keyWords, 'length' => count($keyWords), 'display' => $display];
369        }
370
371        $claimed = [];
372        $index   = 0;
373
374        while (true) {
375            $max = !empty($tokens) ? max(1, (int) floor(count($tokens) / 2)) : 0;
376            if (($index >= $max) || ($index >= count($tokens))) {
377                break;
378            }
379
380            $matchedLength = null;
381            $matchedValue  = null;
382
383            foreach ($salutations as ['keyWords' => $keyWords, 'length' => $length, 'display' => $display]) {
384                if (($index + $length) > count($tokens)) {
385                    continue;
386                }
387                $subset     = array_slice($tokens, $index, $length);
388                $subsetKeys = array_map(fn($word) => strtolower(str_replace('.', '', $word)), $subset);
389                if ($subsetKeys === $keyWords) {
390                    $matchedLength = $length;
391                    $matchedValue  = $display;
392                    break;
393                }
394            }
395
396            if ($matchedLength === null) {
397                $index++;
398                continue;
399            }
400
401            $claimed[] = $matchedValue;
402            array_splice($tokens, $index, $matchedLength);
403        }
404
405        if (!empty($claimed)) {
406            $fields['salutation'] = trim(($fields['salutation'] ?? '') . ' ' . implode(' ', $claimed));
407        }
408
409        return array_values($tokens);
410    }
411
412    /**
413     * Extract suffix
414     *
415     * Scans from the end backward while trailing tokens keep matching either the generational
416     * suffix list or the professional credentials list, stopping before it would eat into the
417     * reserved leading tokens (or, in single-part mode, matches only when exactly one token
418     * remains). The two are kept in separate fields ("Von Fange III, PhD" -> suffix "III",
419     * credentials "PhD"), each preserving its own relative order even when the two are
420     * interleaved among the trailing tokens.
421     *
422     * @param  array      $tokens
423     * @param  NameValues $nameValues
424     * @param  array      $fields
425     * @param  int        $reservedParts
426     * @param  bool       $matchSinglePart
427     * @param  bool       $reserveLastToken
428     * @return array
429     */
430    protected function extractSuffix(
431        array $tokens,
432        NameValues $nameValues,
433        array &$fields,
434        int $reservedParts = 2,
435        bool $matchSinglePart = false,
436        bool $reserveLastToken = false
437    ): array
438    {
439        $suffixes    = $nameValues->getSuffixes();
440        $credentials = $nameValues->getCredentials();
441
442        if ($matchSinglePart && (count($tokens) === 1)) {
443            $key = strtolower(str_replace('.', '', $tokens[0]));
444            if (isset($suffixes[$key])) {
445                $fields['suffix'] = trim(($fields['suffix'] ?? '') . ' ' . $suffixes[$key]);
446                return [];
447            }
448            if (isset($credentials[$key])) {
449                $fields['credentials'] = trim(($fields['credentials'] ?? '') . ' ' . $credentials[$key]);
450                return [];
451            }
452            return $tokens;
453        }
454
455        $claimed = [];
456        $stop    = $reserveLastToken ? 1 : $reservedParts;
457        $index   = count($tokens) - 1;
458
459        while ($index >= $stop) {
460            $key = strtolower(str_replace('.', '', $tokens[$index]));
461            if (isset($suffixes[$key])) {
462                array_unshift($claimed, ['type' => 'suffix', 'value' => $suffixes[$key]]);
463            } else if (isset($credentials[$key])) {
464                array_unshift($claimed, ['type' => 'credentials', 'value' => $credentials[$key]]);
465            } else {
466                break;
467            }
468            $index--;
469        }
470
471        if (!empty($claimed)) {
472            $count = count($claimed);
473            array_splice($tokens, count($tokens) - $count, $count);
474
475            $suffixParts = array_column(array_filter($claimed, fn($claim) => $claim['type'] === 'suffix'), 'value');
476            if (!empty($suffixParts)) {
477                $fields['suffix'] = trim(($fields['suffix'] ?? '') . ' ' . implode(' ', $suffixParts));
478            }
479
480            $credentialParts = array_column(array_filter($claimed, fn($claim) => $claim['type'] === 'credentials'), 'value');
481            if (!empty($credentialParts)) {
482                $fields['credentials'] = trim(($fields['credentials'] ?? '') . ' ' . implode(' ', $credentialParts));
483            }
484        }
485
486        return array_values($tokens);
487    }
488
489    /**
490     * Extract initials
491     *
492     * A remaining single letter (optionally with a trailing period) is an initial. An
493     * all-caps 2-letter run (e.g. "JR") is split into two separate initials first - unless
494     * that run is also a recognized lastname prefix ("DE", "LA", "ST", ...), in which case
495     * it's left alone so extractLastname() can fold it as a prefix; without this guard,
496     * all-caps input like "JAMES DE LUCA" would have "DE" shredded into two fake initials
497     * before extractLastname() ever saw it. The very last remaining token is never treated
498     * as an initial unless $matchLastPart is true.
499     *
500     * @param  array      $tokens
501     * @param  bool       $matchLastPart
502     * @param  NameValues $nameValues
503     * @param  array      $initialsQueue
504     * @return array
505     */
506    protected function extractInitials(array $tokens, bool $matchLastPart, NameValues $nameValues, array &$initialsQueue): array
507    {
508        $prefixes = $nameValues->getLastnamePrefixes();
509
510        $last = count($tokens) - 1;
511        for ($i = 0; $i < count($tokens); $i++) {
512            if (!$matchLastPart && ($i === $last)) {
513                continue;
514            }
515            $stripped = str_replace('.', '', $tokens[$i]);
516            if ((strlen($stripped) === 2) && ($stripped === strtoupper($stripped)) && ctype_alpha($stripped)
517                && !isset($prefixes[strtolower($stripped)])) {
518                array_splice($tokens, $i, 1, [$stripped[0], $stripped[1]]);
519                $last = count($tokens) - 1;
520                $i++;
521            }
522        }
523
524        $last           = count($tokens) - 1;
525        $claimedIndexes = [];
526
527        foreach ($tokens as $i => $token) {
528            if (!$matchLastPart && ($i === $last)) {
529                continue;
530            }
531            $stripped = str_replace('.', '', $token);
532            if (strlen($stripped) === 1) {
533                $claimedIndexes[] = $i;
534            }
535        }
536
537        foreach ($claimedIndexes as $i) {
538            $initialsQueue[] = $tokens[$i];
539        }
540        foreach (array_reverse($claimedIndexes) as $i) {
541            array_splice($tokens, $i, 1);
542        }
543
544        return array_values($tokens);
545    }
546
547    /**
548     * Extract lastname (with prefix folding)
549     *
550     * Scans remaining tokens from the end backward, claiming them as lastname. A claimed run
551     * immediately preceded by a recognized lastname-prefix word, with at least one unclaimed
552     * token still before it, folds the prefix into lastnamePrefix. Stops once it hits a word
553     * long enough to look like a complete lastname on its own with more still unclaimed
554     * before it - what keeps a middle name from being swallowed into the lastname.
555     *
556     * $originalCount is the token count BEFORE any earlier extraction step ran; it (not the
557     * current, shrunk token count) determines whether there was ever more than one word in
558     * this name to begin with, since a name reduced to a single remaining token by earlier
559     * steps (e.g. "J. B. Hunt" -> "Hunt" once both initials are claimed) should still have
560     * that token claimed as lastname.
561     *
562     * @param  array      $tokens
563     * @param  NameValues $nameValues
564     * @param  array      $fields
565     * @param  bool       $singlePartOk
566     * @param  ?int       $originalCount
567     * @return array
568     */
569    protected function extractLastname(
570        array $tokens,
571        NameValues $nameValues,
572        array &$fields,
573        bool $singlePartOk = false,
574        ?int $originalCount = null
575    ): array
576    {
577        $originalCount ??= count($tokens);
578
579        if ((!$singlePartOk && ($originalCount < 2)) || empty($tokens)) {
580            return $tokens;
581        }
582
583        $prefixes        = $nameValues->getLastnamePrefixes();
584        $lastnameWords   = [];
585        $prefixWords     = [];
586        $index           = count($tokens) - 1;
587        $claimedAny      = false;
588        $lastClaimedWord = null;
589
590        while ($index >= 0) {
591            $word = $tokens[$index];
592            $key  = strtolower(str_replace('.', '', $word));
593
594            // The "must be at index > 0" guard exists to always leave at least one token
595            // unclaimed for a firstname - but in singlePartOk mode (comma-mode segment 1),
596            // firstname comes from segment 2, not this segment, so there's nothing to
597            // reserve: allow folding and continued lastname-claiming all the way to index 0.
598            if ($claimedAny && isset($prefixes[$key]) && (($index > 0) || $singlePartOk)) {
599                array_unshift($prefixWords, $prefixes[$key]);
600                array_splice($tokens, $index, 1);
601                $index--;
602                continue;
603            }
604
605            if ($claimedAny) {
606                if (!$singlePartOk && ($index < 1)) {
607                    break;
608                }
609                if (strlen($lastClaimedWord) >= 3) {
610                    break;
611                }
612            }
613
614            array_unshift($lastnameWords, $word);
615            array_splice($tokens, $index, 1);
616            $claimedAny      = true;
617            $lastClaimedWord = $word;
618            $index--;
619
620            if (!$singlePartOk && ($index < 0)) {
621                break;
622            }
623        }
624
625        $fields['lastname'] = $this->normalizeWords($lastnameWords);
626        if (!empty($prefixWords)) {
627            $fields['lastnamePrefix'] = implode(' ', $prefixWords);
628        }
629
630        return array_values($tokens);
631    }
632
633    /**
634     * Extract firstname
635     *
636     * If exactly one raw token remains, it's the firstname outright; otherwise the first
637     * remaining token becomes firstname.
638     *
639     * @param  array $tokens
640     * @param  array $fields
641     * @return array
642     */
643    protected function extractFirstname(array $tokens, array &$fields): array
644    {
645        if (empty($tokens)) {
646            return $tokens;
647        }
648
649        $fields['firstname'] = $this->normalizeCase($tokens[0]);
650        array_splice($tokens, 0, 1);
651
652        return array_values($tokens);
653    }
654
655    /**
656     * Extract middlename
657     *
658     * Whatever raw tokens remain after firstname extraction join as middlename.
659     *
660     * @param  array $tokens
661     * @param  array $fields
662     * @return array
663     */
664    protected function extractMiddlename(array $tokens, array &$fields): array
665    {
666        if (empty($tokens)) {
667            return $tokens;
668        }
669
670        $fields['middlename'] = trim(($fields['middlename'] ?? '') . ' ' . $this->normalizeWords($tokens));
671
672        return [];
673    }
674
675}