From 312f0f1d5b38a7dded52b65126390492c8fee594 Mon Sep 17 00:00:00 2001 From: Ilia Alshanetsky Date: Sun, 11 Oct 2026 12:47:26 -0400 Subject: [PATCH] Keep surname particles together across nicknames --- CHANGELOG.md | 3 +- src/Mapper/LastnameMapper.php | 7 ++- tests/NicknameSurnamePrefixTest.php | 89 +++++++++++++++++++++++++++++ 3 files changed, 95 insertions(+), 4 deletions(-) create mode 100644 tests/NicknameSurnamePrefixTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index a51887a..9decd2d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,9 +8,10 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Fixed +- Nicknames between surname particles no longer invent a given name: + `van (Bob) der Berg` keeps the complete surname `van der Berg`. - Combined initials no longer split digits, punctuation, or caseless characters out of a token, so `Smith, A1 John` keeps `A1` as the first name. - - Keep confidence tokenization, delimiter handling, and comma credential tails linear and bounded for hostile input while preserving parser/config parity. diff --git a/src/Mapper/LastnameMapper.php b/src/Mapper/LastnameMapper.php index d71f284..16819de 100644 --- a/src/Mapper/LastnameMapper.php +++ b/src/Mapper/LastnameMapper.php @@ -238,7 +238,7 @@ protected function isFollowedByLastnamePart(array $parts, int $index): bool /** * Prefer a firstname over a lone prefix unless surname-only context or an - * adjacent prefix resolves the ambiguity. Indexes use the original order. + * adjacent prefix (ignoring nicknames) resolves the ambiguity. * * @param PartArray $parts */ @@ -250,8 +250,9 @@ protected function isApplicablePrefix(array $parts, int $index): bool return false; } - // Adjacent particles establish a surname even without a firstname. - if (($parts[$index + 1] ?? null) instanceof LastnamePrefix) { + // Nicknames do not interrupt the adjacent particles establishing a surname. + $next = $this->skipNicknameParts($parts, $index + 1); + if (($parts[$next] ?? null) instanceof LastnamePrefix) { return true; } diff --git a/tests/NicknameSurnamePrefixTest.php b/tests/NicknameSurnamePrefixTest.php new file mode 100644 index 0000000..83bb838 --- /dev/null +++ b/tests/NicknameSurnamePrefixTest.php @@ -0,0 +1,89 @@ + + */ + public static function surnameRows(): iterable + { + yield 'bare surname' => ['van (Bob) der Berg', '', 'Bob', '']; + yield 'multiword nickname' => ['van (Bobby Joe) der Berg', '', 'Bobby Joe', '']; + yield 'multiple nicknames' => ['van (Bob) (Rob) der Berg', '', 'Bob Rob', '']; + yield 'salutation' => ['Mr. van (Bob) der Berg', '', 'Bob', '']; + yield 'credential' => ['van (Bob) der Berg MD', '', 'Bob', 'MD']; + yield 'empty comma tail' => ['van (Bob) der Berg,', '', 'Bob', '']; + yield 'credential comma tail' => ['van (Bob) der Berg, MD', '', 'Bob', 'MD']; + yield 'unknown comma tail' => ['van (Bob) der Berg, XYZ', 'Xyz', 'Bob', '']; + yield 'generational comma tail' => ['van (Bob) der Berg, Jr', 'Jr', 'Bob', '']; + yield 'given name' => ['John van (Bob) der Berg', 'John', 'Bob', '']; + yield 'explicit surname segment' => ['van (Bob) der Berg, John', 'John', 'Bob', '']; + yield 'no nickname' => ['van der Berg', '', '', '']; + } + + #[DataProvider('surnameRows')] + public function testSurnameParticlesBindAcrossNicknames(string $input, string $first, string $nickname, string $suffix): void + { + $name = (new Parser())->parse($input); + + $this->assertSame($first, $name->getFirstname()); + $this->assertSame('van der Berg', $name->getLastname()); + $this->assertSame('van der', $name->getLastnamePrefix()); + $this->assertSame($nickname, $name->getNickname()); + $this->assertSame($suffix, $name->getSuffix()); + $this->assertSame('', $name->getMiddlename()); + } + + public function testDirectMapperPreservesNicknameObjects(): void + { + $nickname = new Nickname('Bob'); + $mapper = new LastnameMapper((new English())->getLastnamePrefixes()); + $parts = $mapper->map(['van', $nickname, 'der', 'Berg']); + $name = new Name($parts); + + $this->assertSame('van der Berg', $name->getLastname()); + $this->assertSame($nickname, $parts[1]); + } + + public function testPromotedMapperPipelineKeepsNicknameAwareBinding(): void + { + $parser = new Parser(); + $parser->setMappers($parser->getMappers())->setNicknameDelimiters(['<<' => '>>']); + $name = $parser->parse('van <> der Berg'); + + $this->assertSame('', $name->getFirstname()); + $this->assertSame('van der Berg', $name->getLastname()); + $this->assertSame('Bob', $name->getNickname()); + } + + public function testJointNamePartnerKeepsWholeSurname(): void + { + $name = (new Parser())->parse('Mr. and Mrs. van (Bob) der Berg'); + $partner = $name->getPartner(); + + $this->assertNotNull($partner); + $this->assertSame('van der Berg', $partner->getLastname()); + $this->assertSame('', $name->getFirstname()); + $this->assertSame('Mrs.', $partner->getSalutation()); + } + + public function testSingleParticleBeforeNicknameStillAllowsGivenName(): void + { + $name = (new Parser())->parse('Della (Bob) Smith'); + + $this->assertSame('Della', $name->getFirstname()); + $this->assertSame('Smith', $name->getLastname()); + $this->assertSame('', $name->getLastnamePrefix()); + $this->assertSame('Bob', $name->getNickname()); + } +}