Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
7 changes: 4 additions & 3 deletions src/Mapper/LastnameMapper.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand All @@ -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;
}

Expand Down
89 changes: 89 additions & 0 deletions tests/NicknameSurnamePrefixTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
<?php

namespace Tests\Iliaal\NameParser;

use Iliaal\NameParser\Language\English;
use Iliaal\NameParser\Mapper\LastnameMapper;
use Iliaal\NameParser\Name;
use Iliaal\NameParser\Parser;
use Iliaal\NameParser\Part\Nickname;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;

class NicknameSurnamePrefixTest extends TestCase
{
/**
* @return iterable<string, array{string, string, string, string}>
*/
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 <<Bob>> 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());
}
}
Loading