From 41911190225887b21ecf84737c1cf434c6117d4a Mon Sep 17 00:00:00 2001 From: Ilia Alshanetsky Date: Mon, 12 Oct 2026 01:46:37 -0400 Subject: [PATCH] Clarify surname-first boundaries and comma-tail behavior --- README.md | 29 +++++++++++++++++++++++++++-- src/Parser.php | 13 ++++++++----- tests/SurnameFirstTest.php | 32 ++++++++++++++++++++++++++++++++ 3 files changed, 67 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index de94137..74c4b17 100644 --- a/README.md +++ b/README.md @@ -274,8 +274,9 @@ Schmidt` parses as a joint name with `isJoint()` true. The English `and` and Fluent setters on `Parser`: -- `setSurnameFirst(true)` reads comma-less space-separated names in CJK order - (`Mao Zedong` → last `Mao`). Opt-in; romanized order cannot be auto-detected. +- `setSurnameFirst(true)` opts into first-token surname order for space-separated + names (`Mao Zedong` → last `Mao`); see [Surname-first input](#surname-first-input) + for comma handling and multi-token surnames. Romanized order cannot be auto-detected. - `setNicknameDelimiters(['<<' => '>>'])` **replaces** the default pairs (`()[]{}<>` and quotes). An empty array restores the defaults; it does not disable nicknames. At most 32 valid pairs are used; opener and closer strings @@ -290,6 +291,30 @@ Fluent setters on `Parser`: always build their own mapper lists from the language dictionaries. Pass `[]` to restore the default pipeline. +### Surname-first input + +`setSurnameFirst(true)` takes the first token after any recognized leading +salutation as the surname when at least two tokens remain. It does not infer +multi-token surnames: `van Gogh Vincent` gives last name `Van`, first name `Gogh`, +and middle name `Vincent`. Use an explicit given-name segment for those surnames: + +```php +$parser = (new Parser())->setSurnameFirst(true); +$name = $parser->parse('van Gogh, Vincent'); +$name->getLastname(); // "van Gogh" +$name->getFirstname(); // "Vincent" +``` + +A comma takes precedence when its tail supplies a given name, as above. An +empty tail (`Kim Jong Un,`) or credential-only tail (`Kim Jong Un, MD`) still +uses first-token surname order, giving last name `Kim`, first name `Jong`, and +middle name `Un`; the latter also keeps suffix `MD`. Merely appending a comma +or credential does not establish a multi-token surname boundary. + +When fewer than two tokens remain after leading salutations, this split is not +applied and ordinary mapping decides the fields: `Kim` stays a first name, +while `Dr. Kim` has salutation `Dr.` and last name `Kim`. + ### Parsing limits `parse()` accepts at most 1,048,576 input bytes and 65,536 non-empty tokens. diff --git a/src/Parser.php b/src/Parser.php index c21e2b6..93cf3a4 100644 --- a/src/Parser.php +++ b/src/Parser.php @@ -210,7 +210,7 @@ private function parseSplitNameSegments(string $surname, string $given, array $t { // Avoid empty Firstname parts from trailing commas. if (trim($given) === '') { - // Credential-only tails still honor caller-asserted surname-first order. + // Empty comma tails still honor caller-asserted surname-first order. if ($this->surnameFirst) { $surnameTokens = $this->tokenizeWords(trim($surname)); $taken = $this->takeSurnameFirst($surnameTokens); @@ -887,7 +887,7 @@ private function assertInputTokenBudget(string $name): void /** * peel leading salutations and take the next token as the surname. Returns - * null when fewer than two name tokens remain after peeling. + * null when fewer than two tokens remain after peeling. * * @param list $tokens * @return array{0: string, 1: list}|null @@ -1131,9 +1131,12 @@ public function isSurnameFirst(): bool } /** - * read space-separated input surname-first (CJK order). Only affects names - * without a comma. This path uses the comma-form sub-parsers, so a custom - * setMappers() list does not apply. + * Read space-separated input in first-token surname order after leading + * salutations. Empty and credential-only comma tails also use this order; + * an explicit comma given-name segment takes precedence. Multi-token + * surnames need that explicit boundary. With fewer than two tokens after + * salutations, ordinary mapping applies. The surname-first split uses the + * comma-form sub-parsers, so a custom setMappers() list does not apply there. */ public function setSurnameFirst(bool $surnameFirst): Parser { diff --git a/tests/SurnameFirstTest.php b/tests/SurnameFirstTest.php index 75e2278..e23441e 100644 --- a/tests/SurnameFirstTest.php +++ b/tests/SurnameFirstTest.php @@ -176,6 +176,38 @@ public function testParticleLeadingInputHitsFirstTokenLimitation(): void $this->assertSame('Vincent', $name->getMiddlename()); } + /** + * @return iterable + */ + public static function documentedBoundaryRows(): iterable + { + // input, salutation, first, middle, last, suffix + yield 'explicit multi-token surname' => ['van Gogh, Vincent', '', 'Vincent', '', 'van Gogh', '']; + yield 'explicit surname with credential' => ['van Gogh, Vincent, MD', '', 'Vincent', '', 'van Gogh', 'MD']; + yield 'empty comma tail' => ['Kim Jong Un,', '', 'Jong', 'Un', 'Kim', '']; + yield 'comma alone does not establish surname boundary' => ['van Gogh Vincent,', '', 'Gogh', 'Vincent', 'Van', '']; + yield 'credential alone does not establish surname boundary' => ['van Gogh Vincent, MD', '', 'Gogh', 'Vincent', 'Van', 'MD']; + yield 'one token after salutation' => ['Dr. Kim', 'Dr.', '', '', 'Kim', '']; + } + + #[DataProvider('documentedBoundaryRows')] + public function testDocumentedSurnameBoundaries( + string $input, + string $salutation, + string $first, + string $middle, + string $last, + string $suffix, + ): void { + $name = (new Parser())->setSurnameFirst(true)->parse($input); + + $this->assertSame($salutation, $name->getSalutation()); + $this->assertSame($first, $name->getFirstname()); + $this->assertSame($middle, $name->getMiddlename()); + $this->assertSame($last, $name->getLastname()); + $this->assertSame($suffix, $name->getSuffix()); + } + public function testIsSurnameFirstGetterRoundTrips(): void { $parser = new Parser();