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