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
29 changes: 27 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down
13 changes: 8 additions & 5 deletions src/Parser.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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<string> $tokens
* @return array{0: string, 1: list<string>}|null
Expand Down Expand Up @@ -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
{
Expand Down
32 changes: 32 additions & 0 deletions tests/SurnameFirstTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,38 @@ public function testParticleLeadingInputHitsFirstTokenLimitation(): void
$this->assertSame('Vincent', $name->getMiddlename());
}

/**
* @return iterable<string, array{string, string, string, string, string, string}>
*/
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();
Expand Down
Loading