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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Fixed

- Surname particles remain attached after given-name initials: `J. van Gogh` now keeps first name `J.` and surname `van Gogh`, rather than promoting `van` to the first name. This also applies with a salutation, nickname, or trailing credential. See the [parser regression cases](tests/InitialSurnamePrefixTest.php).

## [1.6.0] - 2026-10-16

### Security
Expand Down
18 changes: 17 additions & 1 deletion src/Mapper/LastnameMapper.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
namespace Iliaal\NameParser\Mapper;

use Iliaal\NameParser\Part\AbstractPart;
use Iliaal\NameParser\Part\GivenNamePart;
use Iliaal\NameParser\Part\Ignored;
use Iliaal\NameParser\Part\Lastname;
use Iliaal\NameParser\Part\LastnamePrefix;
Expand Down Expand Up @@ -262,7 +263,22 @@ protected function isApplicablePrefix(array $parts, int $index): bool
return true;
}

return $this->hasUnmappedPartsBefore($parts, $index);
if ($this->hasUnmappedPartsBefore($parts, $index)) {
return true;
}

// InitialMapper may already have mapped the only given-name evidence.
foreach ($parts as $k => $previous) {
if ($k === $index) {
break;
}

if ($previous instanceof GivenNamePart && $previous->normalize() !== '') {
return true;
}
}

return false;
}

protected function isPrefix(string $word): bool
Expand Down
60 changes: 60 additions & 0 deletions tests/InitialSurnamePrefixTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
<?php

namespace Tests\Iliaal\NameParser;

use Iliaal\NameParser\Parser;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;

class InitialSurnamePrefixTest extends TestCase
{
/**
* @return iterable<string, array{string, array<string, string>, bool}>
*/
public static function nameRows(): iterable
{
$dutch = ['firstname' => 'J.', 'initials' => '', 'lastname' => 'van Gogh', 'lastname_prefix' => 'van', 'middlename' => ''];

yield 'dotted initial' => ['J. van Gogh', $dutch, false];
yield 'bare initial' => ['J van Gogh', array_replace($dutch, ['firstname' => 'J']), false];
yield 'two initials' => ['J. R. van Gogh', array_replace($dutch, ['initials' => 'R.']), false];
yield 'combined initials' => ['JR van Gogh', array_replace($dutch, ['firstname' => 'J', 'initials' => 'R']), false];
yield 'salutation' => ['Dr. J. van Gogh', $dutch + ['salutation' => 'Dr.'], false];
yield 'nickname before particle' => ['J. (Jack) van Gogh', $dutch + ['nickname' => 'Jack'], false];
yield 'nickname after particle' => ['J. van (Jack) Gogh', $dutch + ['nickname' => 'Jack'], false];
yield 'credential' => ['J. van Gogh MD', $dutch + ['suffix' => 'MD'], false];
yield 'empty comma tail' => ['J. van Gogh,', $dutch, false];
yield 'credential comma tail' => ['J. van Gogh, MD', $dutch + ['suffix' => 'MD'], false];
yield 'explicit surname segment' => ['van Gogh, J.', array_replace($dutch, ['firstname' => '', 'initials' => 'J.']), false];
yield 'surname-first explicit comma' => ['van Gogh, J.', array_replace($dutch, ['firstname' => '', 'initials' => 'J.']), true];
yield 'surname-first token' => ['Gogh J.', array_replace($dutch, ['firstname' => '', 'initials' => 'J.', 'lastname' => 'Gogh', 'lastname_prefix' => '']), true];
yield 'uniform uppercase' => ['J. VAN GOGH', $dutch, false];
yield 'two-letter particle' => ['J. de Vries', array_replace($dutch, ['lastname' => 'de Vries', 'lastname_prefix' => 'de']), false];
yield 'Irish particle' => ['É. Ó Cuív', array_replace($dutch, ['firstname' => 'É.', 'lastname' => 'Ó Cuív', 'lastname_prefix' => 'Ó']), false];
yield 'multiple particles' => ['J. van der Berg', array_replace($dutch, ['lastname' => 'van der Berg', 'lastname_prefix' => 'van der']), false];
yield 'unmapped given name' => ['John van Gogh', array_replace($dutch, ['firstname' => 'John']), false];
yield 'particle-like given name' => ['Della Smith', ['firstname' => 'Della', 'lastname' => 'Smith', 'lastname_prefix' => '', 'initials' => ''], false];
yield 'nickname alone is not a given name' => ['(Jack) Della Smith', ['firstname' => 'Della', 'lastname' => 'Smith', 'lastname_prefix' => '', 'nickname' => 'Jack'], false];
}

/**
* @param array<string, string> $expected
*/
#[DataProvider('nameRows')]
public function testInitialKeepsFollowingSurnameParticle(string $input, array $expected, bool $surnameFirst): void
{
$fresh = (new Parser())->setSurnameFirst($surnameFirst)->parse($input);
$parser = (new Parser())->setSurnameFirst($surnameFirst);
$parser->parse('Smith, JO, MD');
$parser->parse('John (Bob) Jones');
$reused = $parser->parse($input);

foreach ($expected as $field => $value) {
$this->assertSame($value, $fresh->toArray()[$field], $field . ' for ' . $input);
}

$this->assertSame($fresh->toArray(), $reused->toArray());
$this->assertSame($fresh->getConfidence(), $reused->getConfidence());
$this->assertSame($input, $fresh->getSource());
}
}
38 changes: 38 additions & 0 deletions tests/Mapper/LastnameMapperTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,18 @@

use Iliaal\NameParser\Language\English;
use Iliaal\NameParser\Mapper\LastnameMapper;
use Iliaal\NameParser\Part\AbstractPart;
use Iliaal\NameParser\Part\Firstname;
use Iliaal\NameParser\Part\GivenNamePart;
use Iliaal\NameParser\Part\Ignored;
use Iliaal\NameParser\Part\Initial;
use Iliaal\NameParser\Part\Lastname;
use Iliaal\NameParser\Part\LastnamePrefix;
use Iliaal\NameParser\Part\Middlename;
use Iliaal\NameParser\Part\Nickname;
use Iliaal\NameParser\Part\Salutation;
use Iliaal\NameParser\Part\Suffix;
use PHPUnit\Framework\Attributes\DataProvider;

class LastnameMapperTest extends AbstractMapperTestCase
{
Expand Down Expand Up @@ -119,6 +127,36 @@ public static function provider(): array
];
}

/**
* @return iterable<string, array{AbstractPart, bool}>
*/
public static function precedingPartProvider(): iterable
{
yield 'initial' => [new Initial('J.'), true];
yield 'firstname' => [new Firstname('John'), true];
yield 'middlename' => [new Middlename('Robert'), true];
yield 'custom given name' => [new class ('Jean') extends GivenNamePart {}, true];
yield 'empty initial' => [new Initial(''), false];
yield 'empty firstname' => [new Firstname(''), false];
yield 'salutation' => [new Salutation('Dr.'), false];
yield 'nickname' => [new Nickname('Jack'), false];
yield 'suffix' => [new Suffix('MD'), false];
yield 'ignored' => [new Ignored('and'), false];
}

#[DataProvider('precedingPartProvider')]
public function testMappedGivenNamesAllowFollowingSurnameParticle(AbstractPart $previous, bool $binds): void
{
$parts = $this->getMapper()->map([$previous, 'van', 'Gogh']);

$this->assertSame($previous, $parts[0]);
$this->assertSame($binds, $parts[1] instanceof LastnamePrefix);
$this->assertSame(
self::canonicalParts([$previous, $binds ? new LastnamePrefix('van', 'van') : 'van', new Lastname('Gogh')]),
self::canonicalParts($parts),
);
}

protected function getMapper(bool $matchSingle = false): LastnameMapper
{
$english = new English();
Expand Down
Loading