Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
98.85% covered (success)
98.85%
259 / 262
91.43% covered (success)
91.43%
32 / 35
CRAP
0.00% covered (danger)
0.00%
0 / 1
NameParser
98.85% covered (success)
98.85%
259 / 262
91.43% covered (success)
91.43%
32 / 35
112
0.00% covered (danger)
0.00%
0 / 1
 getSalutation
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getFirstname
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getMiddlename
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getNickname
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 getInitials
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getLastnamePrefix
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getLastname
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getSuffix
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getGivenName
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 getFullName
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 hasSalutation
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 hasFirstname
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 hasMiddlename
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 hasNickname
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 hasInitials
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 hasLastnamePrefix
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 hasLastname
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 hasSuffix
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 parse
100.00% covered (success)
100.00%
33 / 33
100.00% covered (success)
100.00%
1 / 1
8
 toArray
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
1
 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%
6 / 6
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
96.00% covered (success)
96.00%
24 / 25
0.00% covered (danger)
0.00%
0 / 1
6
 absorbLeftovers
83.33% covered (success)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
3.04
 finalizeInitials
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
4
 extractNickname
95.83% covered (success)
95.83%
23 / 24
0.00% covered (danger)
0.00%
0 / 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%
21 / 21
100.00% covered (success)
100.00%
1 / 1
8
 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
 __toString
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
1
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     * Salutation
35     * @var ?string
36     * */
37    protected ?string $salutation = null;
38
39    /**
40     * First name
41     * @var ?string
42     * */
43    protected ?string $firstname = null;
44
45    /**
46     * Middle name
47     * @var ?string
48     * */
49    protected ?string $middlename = null;
50
51    /**
52     * Nickname
53     * @var ?string
54     * */
55    protected ?string $nickname = null;
56
57    /**
58     * Initials
59     * @var ?string
60     * */
61    protected ?string $initials = null;
62
63    /**
64     * Lastname prefix
65     * @var ?string
66     * */
67    protected ?string $lastnamePrefix = null;
68
69    /**
70     * Last name
71     * @var ?string
72     * */
73    protected ?string $lastname = null;
74
75    /**
76     * Suffix
77     * @var ?string
78     * */
79    protected ?string $suffix = null;
80
81    /**
82     * Queue of raw tokens claimed as initials during extraction, before the first one is
83     * (potentially) promoted back to firstname by finalizeInitials()
84     * @var array
85     * */
86    protected array $initialsQueue = [];
87
88    /**
89     * Method to get salutation
90     *
91     * @return ?string
92     */
93    public function getSalutation(): ?string
94    {
95        return $this->salutation;
96    }
97
98    /**
99     * Method to get first name
100     *
101     * @return ?string
102     */
103    public function getFirstname(): ?string
104    {
105        return $this->firstname;
106    }
107
108    /**
109     * Method to get middle name
110     *
111     * @return ?string
112     */
113    public function getMiddlename(): ?string
114    {
115        return $this->middlename;
116    }
117
118    /**
119     * Method to get nickname
120     *
121     * @param  bool $wrap
122     * @return ?string
123     */
124    public function getNickname(bool $wrap = false): ?string
125    {
126        if (($this->nickname !== null) && $wrap) {
127            return '(' . $this->nickname . ')';
128        }
129
130        return $this->nickname;
131    }
132
133    /**
134     * Method to get initials
135     *
136     * @return ?string
137     */
138    public function getInitials(): ?string
139    {
140        return $this->initials;
141    }
142
143    /**
144     * Method to get lastname prefix
145     *
146     * @return ?string
147     */
148    public function getLastnamePrefix(): ?string
149    {
150        return $this->lastnamePrefix;
151    }
152
153    /**
154     * Method to get last name
155     *
156     * @return ?string
157     */
158    public function getLastname(): ?string
159    {
160        return $this->lastname;
161    }
162
163    /**
164     * Method to get suffix
165     *
166     * @return ?string
167     */
168    public function getSuffix(): ?string
169    {
170        return $this->suffix;
171    }
172
173    /**
174     * Method to get the given name (first name, initials and middle name, in that order)
175     *
176     * @return ?string
177     */
178    public function getGivenName(): ?string
179    {
180        $parts = array_filter([$this->firstname, $this->initials, $this->middlename]);
181        return !empty($parts) ? implode(' ', $parts) : null;
182    }
183
184    /**
185     * Method to get the full name (given name plus lastname prefix and lastname)
186     *
187     * @return ?string
188     */
189    public function getFullName(): ?string
190    {
191        $parts = array_filter([$this->getGivenName(), $this->lastnamePrefix, $this->lastname]);
192        return !empty($parts) ? implode(' ', $parts) : null;
193    }
194
195    /**
196     * Has salutation
197     *
198     * @return bool
199     */
200    public function hasSalutation(): bool
201    {
202        return !empty($this->salutation);
203    }
204
205    /**
206     * Has first name
207     *
208     * @return bool
209     */
210    public function hasFirstname(): bool
211    {
212        return !empty($this->firstname);
213    }
214
215    /**
216     * Has middle name
217     *
218     * @return bool
219     */
220    public function hasMiddlename(): bool
221    {
222        return !empty($this->middlename);
223    }
224
225    /**
226     * Has nickname
227     *
228     * @return bool
229     */
230    public function hasNickname(): bool
231    {
232        return !empty($this->nickname);
233    }
234
235    /**
236     * Has initials
237     *
238     * @return bool
239     */
240    public function hasInitials(): bool
241    {
242        return !empty($this->initials);
243    }
244
245    /**
246     * Has lastname prefix
247     *
248     * @return bool
249     */
250    public function hasLastnamePrefix(): bool
251    {
252        return !empty($this->lastnamePrefix);
253    }
254
255    /**
256     * Has last name
257     *
258     * @return bool
259     */
260    public function hasLastname(): bool
261    {
262        return !empty($this->lastname);
263    }
264
265    /**
266     * Has suffix
267     *
268     * @return bool
269     */
270    public function hasSuffix(): bool
271    {
272        return !empty($this->suffix);
273    }
274
275    /**
276     * Parse method
277     *
278     * @param  ?string $name
279     * @throws Exception
280     * @return static
281     */
282    public function parse(?string $name = null): static
283    {
284        if (empty($this->data) && empty($name)) {
285            throw new Exception('Error: You must pass a name string to the parser object.');
286        }
287
288        if ((null === $name) && !empty($this->data)) {
289            $name = $this->data;
290        } else if (null !== $name) {
291            $this->data = $name;
292        }
293
294        $name = $this->clean($name);
295
296        if ($name === '') {
297            throw new Exception('Error: You must pass a name string to the parser object.');
298        }
299
300        // Several fields below are built by concatenation as extraction proceeds (e.g.
301        // consecutive salutations, multiple trailing suffixes). Reset them here so a second
302        // parse() call on the same instance starts clean rather than appending onto results
303        // from a previous call.
304        $this->salutation     = null;
305        $this->firstname      = null;
306        $this->middlename     = null;
307        $this->nickname       = null;
308        $this->initials       = null;
309        $this->lastnamePrefix = null;
310        $this->lastname       = null;
311        $this->suffix         = null;
312        $this->initialsQueue  = [];
313
314        $nameValues = new NameValues();
315
316        if (str_contains($name, ',')) {
317            $this->parseCommaMode($name, $nameValues);
318        } else {
319            $tokens        = $this->tokenize($name);
320            $originalCount = count($tokens);
321            $tokens        = $this->extractNickname($tokens, $nameValues);
322            $tokens        = $this->extractSalutation($tokens, $nameValues);
323            $tokens        = $this->extractSuffix($tokens, $nameValues, 2);
324            $tokens        = $this->extractInitials($tokens, false, $nameValues);
325            $tokens        = $this->extractLastname($tokens, $nameValues, false, $originalCount);
326            $tokens        = $this->extractFirstname($tokens);
327            $tokens        = $this->extractMiddlename($tokens);
328            $this->absorbLeftovers($tokens);
329        }
330
331        $this->finalizeInitials();
332
333        return $this;
334    }
335
336    /**
337     * To array method
338     *
339     * @return array
340     */
341    public function toArray(): array
342    {
343        return [
344            'salutation'     => $this->salutation,
345            'firstname'      => $this->firstname,
346            'initials'       => $this->initials,
347            'middlename'     => $this->middlename,
348            'nickname'       => $this->nickname,
349            'lastnamePrefix' => $this->lastnamePrefix,
350            'lastname'       => $this->lastname,
351            'suffix'         => $this->suffix,
352        ];
353    }
354
355    /**
356     * Clean method
357     *
358     * @param  string $name
359     * @return string
360     */
361    public function clean(string $name): string
362    {
363        return trim(preg_replace('/\s+/', ' ', $name));
364    }
365
366    /**
367     * Tokenize method
368     *
369     * @param  string $name
370     * @return array
371     */
372    protected function tokenize(string $name): array
373    {
374        return preg_split('/\s+/', trim($name));
375    }
376
377    /**
378     * Normalize the case of a single word: an all-uppercase or all-lowercase word gets
379     * title-cased ("MACDONALD" / "macdonald" -> "Macdonald"); a word with any existing
380     * mixed case (e.g. "MacDonald", "McDonald", "O'Brien") is left exactly as typed, since
381     * that mixed case is almost always deliberate.
382     *
383     * @param  string $word
384     * @return string
385     */
386    protected function normalizeCase(string $word): string
387    {
388        $stripped = str_replace('.', '', $word);
389
390        if (($stripped === mb_strtoupper($stripped)) || ($stripped === mb_strtolower($stripped))) {
391            return preg_replace_callback('/\p{L}+/u', function ($matches) {
392                return mb_convert_case($matches[0], MB_CASE_TITLE);
393            }, $word);
394        }
395
396        return $word;
397    }
398
399    /**
400     * Normalize the case of each word in an array and join them with a space
401     *
402     * @param  array $words
403     * @return string
404     */
405    protected function normalizeWords(array $words): string
406    {
407        return implode(' ', array_map([$this, 'normalizeCase'], $words));
408    }
409
410    /**
411     * Parse comma-separated "Last, First Middle[, Suffix]" format
412     *
413     * @param  string      $name
414     * @param  NameValues  $nameValues
415     * @return void
416     */
417    protected function parseCommaMode(string $name, NameValues $nameValues): void
418    {
419        $segments = array_map('trim', explode(',', $name));
420
421        // Segment 1 (before the first comma): the lastname segment. Salutation, suffix and
422        // lastname(-with-prefix) extraction run here. Whatever's left over (e.g. "Garcia" in
423        // "Garcia Marquez, Gabriel", or a lastname-prefix word extractLastname's ordinary
424        // guards wouldn't fold at position 0) is deliberately NOT assigned to firstname here
425        // - it's carried forward and absorbed only after segment 2 runs, so it can never
426        // silently overwrite or lose to segment 2's own firstname; absorbLeftovers() merges
427        // it into middlename once firstname is already set instead.
428        $segment1       = $this->tokenize($segments[0]);
429        $originalCount1 = count($segment1);
430        $segment1       = $this->extractSalutation($segment1, $nameValues);
431        $segment1       = $this->extractSuffix($segment1, $nameValues, 0, true);
432        $segment1       = $this->extractLastname($segment1, $nameValues, true, $originalCount1);
433
434        // Segment 2 (between commas, or everything after the first comma): the given-name segment
435        if (isset($segments[1]) && ($segments[1] !== '')) {
436            $segment2 = $this->tokenize($segments[1]);
437            $segment2 = $this->extractSalutation($segment2, $nameValues);
438            $segment2 = $this->extractSuffix($segment2, $nameValues, 0, true, true);
439            $segment2 = $this->extractNickname($segment2, $nameValues);
440            $segment2 = $this->extractInitials($segment2, true, $nameValues);
441            $segment2 = $this->extractFirstname($segment2);
442            $segment2 = $this->extractMiddlename($segment2);
443            $this->absorbLeftovers($segment2);
444        }
445
446        // Now that segment 2 has had its chance to claim the firstname slot: if it didn't
447        // (firstname is still null), segment 1's leftover IS the given name, so split it the
448        // normal way (first word -> firstname, the rest -> middlename) rather than joining it
449        // into one string. If segment 2 already provided a firstname, segment 1's leftover is
450        // secondary content - absorbLeftovers() merges the whole thing into middlename.
451        if ($this->firstname === null) {
452            $segment1 = $this->extractFirstname($segment1);
453            $segment1 = $this->extractMiddlename($segment1);
454        }
455        $this->absorbLeftovers($segment1);
456
457        // Segment 3 onward (after a second comma, if present): suffix only per segment, with
458        // any non-suffix leftover absorbed rather than discarded (e.g. "Smith, John, Michael"
459        // must not lose "Michael" just because it isn't a recognized suffix). Looping over
460        // every remaining segment - not just $segments[2] - means a name with more than 3
461        // comma-separated parts (e.g. "Smith, John, PhD, Esq") doesn't silently drop
462        // anything past the third.
463        foreach (array_slice($segments, 2) as $segment) {
464            if ($segment === '') {
465                continue;
466            }
467            $extraSegment = $this->tokenize($segment);
468            $extraSegment = $this->extractSuffix($extraSegment, $nameValues, 0, true);
469            $this->absorbLeftovers($extraSegment);
470        }
471    }
472
473    /**
474     * Absorb any tokens no extraction step claimed: the first time this is called with a
475     * non-empty leftover, it becomes firstname (since every name needs one); after that,
476     * leftovers are appended to middlename. This is a deliberate difference from
477     * theiconic/name-parser, which can silently drop an unrecognized leading word (e.g. "The"
478     * in "The Rev. Mark Williams") - here nothing is ever discarded.
479     *
480     * @param  array $tokens
481     * @return void
482     */
483    protected function absorbLeftovers(array $tokens): void
484    {
485        if (empty($tokens)) {
486            return;
487        }
488
489        $text = $this->normalizeWords($tokens);
490
491        if ($this->firstname === null) {
492            $this->firstname = $text;
493        } else {
494            $this->middlename = trim(($this->middlename ?? '') . ' ' . $text);
495        }
496    }
497
498    /**
499     * If no raw token ever became firstname but one or more initials were set aside, promote
500     * the FIRST claimed initial back to firstname - handles "J. B. Hunt" (firstname="J.",
501     * initials="B.", lastname="Hunt"), since a name consisting only of initials plus a
502     * lastname still needs a firstname.
503     *
504     * @return void
505     */
506    protected function finalizeInitials(): void
507    {
508        if (empty($this->initialsQueue)) {
509            return;
510        }
511
512        if ($this->firstname === null) {
513            $promoted        = array_shift($this->initialsQueue);
514            $this->firstname = $this->normalizeCase($promoted);
515        }
516
517        if (!empty($this->initialsQueue)) {
518            $this->initials = trim(($this->initials ?? '') . ' ' . $this->normalizeWords($this->initialsQueue));
519        }
520
521        $this->initialsQueue = [];
522    }
523
524    /**
525     * Extract nickname
526     *
527     * Scans for a token starting with an opening delimiter and collects tokens until one
528     * ends with the matching closing delimiter (supports multi-word nicknames).
529     *
530     * @param  array      $tokens
531     * @param  NameValues $nameValues
532     * @return array
533     */
534    protected function extractNickname(array $tokens, NameValues $nameValues): array
535    {
536        $delimiters = $nameValues->getNicknameDelimiters();
537        $openChars  = array_keys($delimiters);
538
539        foreach ($tokens as $start => $token) {
540            $firstChar = mb_substr($token, 0, 1);
541            if (!in_array($firstChar, $openChars, true)) {
542                continue;
543            }
544
545            $closeChar = $delimiters[$firstChar];
546            $end       = null;
547
548            for ($i = $start; $i < count($tokens); $i++) {
549                if (str_ends_with($tokens[$i], $closeChar) && (($i > $start) || (strlen($tokens[$i]) > 1))) {
550                    $end = $i;
551                    break;
552                }
553            }
554
555            if ($end === null) {
556                continue;
557            }
558
559            $span             = array_slice($tokens, $start, $end - $start + 1);
560            $span[0]          = ltrim($span[0], $firstChar);
561            $lastIndex        = count($span) - 1;
562            $span[$lastIndex] = rtrim($span[$lastIndex], $closeChar);
563            $span             = array_map(fn($word) => trim($word, '\'"'), $span);
564            $span             = array_filter($span, fn($word) => $word !== '');
565
566            $this->nickname = $this->normalizeWords($span);
567            array_splice($tokens, $start, $end - $start + 1);
568            break;
569        }
570
571        return array_values($tokens);
572    }
573
574    /**
575     * Extract salutation
576     *
577     * Scans from the start of the tokens (bounded to roughly the first half) for matches
578     * against the salutation list, checked as both single tokens and multi-word phrases.
579     * Multiple consecutive salutations (e.g. "Rev. Dr John Doe") are all claimed.
580     *
581     * @param  array      $tokens
582     * @param  NameValues $nameValues
583     * @return array
584     */
585    protected function extractSalutation(array $tokens, NameValues $nameValues): array
586    {
587        $salutations = [];
588        foreach ($nameValues->getSalutations() as $key => $display) {
589            $keyWords      = explode(' ', $key);
590            $salutations[] = ['keyWords' => $keyWords, 'length' => count($keyWords), 'display' => $display];
591        }
592
593        $claimed = [];
594        $index   = 0;
595
596        while (true) {
597            $max = !empty($tokens) ? max(1, (int) floor(count($tokens) / 2)) : 0;
598            if (($index >= $max) || ($index >= count($tokens))) {
599                break;
600            }
601
602            $matchedLength = null;
603            $matchedValue  = null;
604
605            foreach ($salutations as ['keyWords' => $keyWords, 'length' => $length, 'display' => $display]) {
606                if (($index + $length) > count($tokens)) {
607                    continue;
608                }
609                $subset     = array_slice($tokens, $index, $length);
610                $subsetKeys = array_map(fn($word) => strtolower(str_replace('.', '', $word)), $subset);
611                if ($subsetKeys === $keyWords) {
612                    $matchedLength = $length;
613                    $matchedValue  = $display;
614                    break;
615                }
616            }
617
618            if ($matchedLength === null) {
619                $index++;
620                continue;
621            }
622
623            $claimed[] = $matchedValue;
624            array_splice($tokens, $index, $matchedLength);
625        }
626
627        if (!empty($claimed)) {
628            $this->salutation = trim(($this->salutation ?? '') . ' ' . implode(' ', $claimed));
629        }
630
631        return array_values($tokens);
632    }
633
634    /**
635     * Extract suffix
636     *
637     * Scans from the end backward while trailing tokens keep matching the suffix list,
638     * stopping before it would eat into the reserved leading tokens (or, in single-part
639     * mode, matches only when exactly one token remains).
640     *
641     * @param  array      $tokens
642     * @param  NameValues $nameValues
643     * @param  int        $reservedParts
644     * @param  bool       $matchSinglePart
645     * @param  bool       $reserveLastToken
646     * @return array
647     */
648    protected function extractSuffix(
649        array $tokens,
650        NameValues $nameValues,
651        int $reservedParts = 2,
652        bool $matchSinglePart = false,
653        bool $reserveLastToken = false
654    ): array
655    {
656        $suffixes = $nameValues->getSuffixes();
657
658        if ($matchSinglePart && (count($tokens) === 1)) {
659            $key = strtolower(str_replace('.', '', $tokens[0]));
660            if (isset($suffixes[$key])) {
661                $this->suffix = trim(($this->suffix ?? '') . ' ' . $suffixes[$key]);
662                return [];
663            }
664            return $tokens;
665        }
666
667        $claimed = [];
668        $stop    = $reserveLastToken ? 1 : $reservedParts;
669        $index   = count($tokens) - 1;
670
671        while ($index >= $stop) {
672            $key = strtolower(str_replace('.', '', $tokens[$index]));
673            if (!isset($suffixes[$key])) {
674                break;
675            }
676            array_unshift($claimed, $suffixes[$key]);
677            $index--;
678        }
679
680        if (!empty($claimed)) {
681            $count = count($claimed);
682            array_splice($tokens, count($tokens) - $count, $count);
683            $this->suffix = trim(($this->suffix ?? '') . ' ' . implode(' ', $claimed));
684        }
685
686        return array_values($tokens);
687    }
688
689    /**
690     * Extract initials
691     *
692     * A remaining single letter (optionally with a trailing period) is an initial. An
693     * all-caps 2-letter run (e.g. "JR") is split into two separate initials first - unless
694     * that run is also a recognized lastname prefix ("DE", "LA", "ST", ...), in which case
695     * it's left alone so extractLastname() can fold it as a prefix; without this guard,
696     * all-caps input like "JAMES DE LUCA" would have "DE" shredded into two fake initials
697     * before extractLastname() ever saw it. The very last remaining token is never treated
698     * as an initial unless $matchLastPart is true.
699     *
700     * @param  array      $tokens
701     * @param  bool       $matchLastPart
702     * @param  NameValues $nameValues
703     * @return array
704     */
705    protected function extractInitials(array $tokens, bool $matchLastPart, NameValues $nameValues): array
706    {
707        $prefixes = $nameValues->getLastnamePrefixes();
708
709        $last = count($tokens) - 1;
710        for ($i = 0; $i < count($tokens); $i++) {
711            if (!$matchLastPart && ($i === $last)) {
712                continue;
713            }
714            $stripped = str_replace('.', '', $tokens[$i]);
715            if ((strlen($stripped) === 2) && ($stripped === strtoupper($stripped)) && ctype_alpha($stripped)
716                && !isset($prefixes[strtolower($stripped)])) {
717                array_splice($tokens, $i, 1, [$stripped[0], $stripped[1]]);
718                $last = count($tokens) - 1;
719                $i++;
720            }
721        }
722
723        $last           = count($tokens) - 1;
724        $claimedIndexes = [];
725
726        foreach ($tokens as $i => $token) {
727            if (!$matchLastPart && ($i === $last)) {
728                continue;
729            }
730            $stripped = str_replace('.', '', $token);
731            if (strlen($stripped) === 1) {
732                $claimedIndexes[] = $i;
733            }
734        }
735
736        foreach ($claimedIndexes as $i) {
737            $this->initialsQueue[] = $tokens[$i];
738        }
739        foreach (array_reverse($claimedIndexes) as $i) {
740            array_splice($tokens, $i, 1);
741        }
742
743        return array_values($tokens);
744    }
745
746    /**
747     * Extract lastname (with prefix folding)
748     *
749     * Scans remaining tokens from the end backward, claiming them as lastname. A claimed run
750     * immediately preceded by a recognized lastname-prefix word, with at least one unclaimed
751     * token still before it, folds the prefix into lastnamePrefix. Stops once it hits a word
752     * long enough to look like a complete lastname on its own with more still unclaimed
753     * before it - what keeps a middle name from being swallowed into the lastname.
754     *
755     * $originalCount is the token count BEFORE any earlier extraction step ran; it (not the
756     * current, shrunk token count) determines whether there was ever more than one word in
757     * this name to begin with, since a name reduced to a single remaining token by earlier
758     * steps (e.g. "J. B. Hunt" -> "Hunt" once both initials are claimed) should still have
759     * that token claimed as lastname.
760     *
761     * @param  array      $tokens
762     * @param  NameValues $nameValues
763     * @param  bool       $singlePartOk
764     * @param  ?int       $originalCount
765     * @return array
766     */
767    protected function extractLastname(
768        array $tokens,
769        NameValues $nameValues,
770        bool $singlePartOk = false,
771        ?int $originalCount = null
772    ): array
773    {
774        $originalCount ??= count($tokens);
775
776        if ((!$singlePartOk && ($originalCount < 2)) || empty($tokens)) {
777            return $tokens;
778        }
779
780        $prefixes        = $nameValues->getLastnamePrefixes();
781        $lastnameWords   = [];
782        $prefixWords     = [];
783        $index           = count($tokens) - 1;
784        $claimedAny      = false;
785        $lastClaimedWord = null;
786
787        while ($index >= 0) {
788            $word = $tokens[$index];
789            $key  = strtolower(str_replace('.', '', $word));
790
791            // The "must be at index > 0" guard exists to always leave at least one token
792            // unclaimed for a firstname - but in singlePartOk mode (comma-mode segment 1),
793            // firstname comes from segment 2, not this segment, so there's nothing to
794            // reserve: allow folding and continued lastname-claiming all the way to index 0.
795            if ($claimedAny && isset($prefixes[$key]) && (($index > 0) || $singlePartOk)) {
796                array_unshift($prefixWords, $prefixes[$key]);
797                array_splice($tokens, $index, 1);
798                $index--;
799                continue;
800            }
801
802            if ($claimedAny) {
803                if (!$singlePartOk && ($index < 1)) {
804                    break;
805                }
806                if (strlen($lastClaimedWord) >= 3) {
807                    break;
808                }
809            }
810
811            array_unshift($lastnameWords, $word);
812            array_splice($tokens, $index, 1);
813            $claimedAny      = true;
814            $lastClaimedWord = $word;
815            $index--;
816
817            if (!$singlePartOk && ($index < 0)) {
818                break;
819            }
820        }
821
822        $this->lastname = $this->normalizeWords($lastnameWords);
823        if (!empty($prefixWords)) {
824            $this->lastnamePrefix = implode(' ', $prefixWords);
825        }
826
827        return array_values($tokens);
828    }
829
830    /**
831     * Extract firstname
832     *
833     * If exactly one raw token remains, it's the firstname outright; otherwise the first
834     * remaining token becomes firstname.
835     *
836     * @param  array $tokens
837     * @return array
838     */
839    protected function extractFirstname(array $tokens): array
840    {
841        if (empty($tokens)) {
842            return $tokens;
843        }
844
845        $this->firstname = $this->normalizeCase($tokens[0]);
846        array_splice($tokens, 0, 1);
847
848        return array_values($tokens);
849    }
850
851    /**
852     * Extract middlename
853     *
854     * Whatever raw tokens remain after firstname extraction join as middlename.
855     *
856     * @param  array $tokens
857     * @return array
858     */
859    protected function extractMiddlename(array $tokens): array
860    {
861        if (empty($tokens)) {
862            return $tokens;
863        }
864
865        $this->middlename = trim(($this->middlename ?? '') . ' ' . $this->normalizeWords($tokens));
866
867        return [];
868    }
869
870    /**
871     * To string method
872     *
873     * @return string
874     */
875    public function __toString(): string
876    {
877        $parts = array_filter([
878            $this->salutation,
879            $this->getGivenName(),
880            $this->getNickname(true),
881            $this->lastnamePrefix,
882            $this->lastname,
883            $this->suffix,
884        ]);
885
886        return implode(' ', $parts);
887    }
888
889}