From 29e6d578906573cb986ade9f6fe6abb37ffbfb36 Mon Sep 17 00:00:00 2001 From: Ilia Alshanetsky Date: Wed, 21 Oct 2026 17:39:41 -0400 Subject: [PATCH] Fix numeric symmetric nickname delimiter handling --- CHANGELOG.md | 1 + src/Confidence.php | 1 + src/StructuralCommaSplitter.php | 1 + tests/ConfidenceTest.php | 39 +++++++++++++++++++++ tests/NumericNicknameDelimiterTest.php | 47 ++++++++++++++++++++++++++ 5 files changed, 89 insertions(+) create mode 100644 tests/NumericNicknameDelimiterTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 01a566a..442f083 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### 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). +- Numeric symmetric nickname delimiters now obey token boundaries when splitting names and shield nickname commas during standalone confidence assessment. ## [1.6.0] - 2026-10-16 diff --git a/src/Confidence.php b/src/Confidence.php index fbcfb7d..b97ab80 100644 --- a/src/Confidence.php +++ b/src/Confidence.php @@ -284,6 +284,7 @@ private static function tokenizeAsciiDelimiters(string $normalized, array $delim $closerStart = $position - 1; foreach ($symmetric as $delimiter => $_) { + $delimiter = (string) $delimiter; if ($closerStart < $tokenStart || $normalized[$closerStart] !== $delimiter) { continue; } diff --git a/src/StructuralCommaSplitter.php b/src/StructuralCommaSplitter.php index c0de13b..8d1a4c5 100644 --- a/src/StructuralCommaSplitter.php +++ b/src/StructuralCommaSplitter.php @@ -115,6 +115,7 @@ private static function splitDelimiters(array $delimiters): array /** @var array $symmetric */ $symmetric = []; foreach ($delimiters as $open => $close) { + $open = (string) $open; if ($open === '' || $close === '') { continue; } diff --git a/tests/ConfidenceTest.php b/tests/ConfidenceTest.php index 524bb92..c4a0f36 100644 --- a/tests/ConfidenceTest.php +++ b/tests/ConfidenceTest.php @@ -306,6 +306,45 @@ public function testEmptyWhitespacePreservesTabsBeforeConfidenceTokenization(): ); } + /** + * @return array + */ + public static function numericNicknameDelimiterProvider(): array + { + $cases = []; + for ($digit = 0; $digit <= 9; ++$digit) { + $cases['digit ' . $digit] = [(string) $digit]; + } + + return $cases; + } + + #[DataProvider('numericNicknameDelimiterProvider')] + public function testNumericNicknameDelimitersShieldCommas(string $delimiter): void + { + $delimiters = [$delimiter => $delimiter]; + $input = 'John ' . $delimiter . 'foo,do,bar' . $delimiter . ' Smith'; + $name = (new Parser())->setNicknameDelimiters($delimiters)->parse($input); + + $this->assertSame('Foo,Do,Bar', $name->getNickname()); + $this->assertSame(['ambiguous' => false, 'notes' => []], $name->getConfidence()); + $this->assertSame( + $name->getConfidence(), + Confidence::assess($input, nicknameDelimiters: $delimiters), + ); + + // The comma after the closed nickname must remain structural. + $this->assertTrue(Confidence::assess( + $input . ', do', + nicknameDelimiters: $delimiters, + )['ambiguous']); + // An unmatched opener must not hide the lowercase credential. + $this->assertTrue(Confidence::assess( + 'John ' . $delimiter . 'foo,do,bar Smith', + nicknameDelimiters: $delimiters, + )['ambiguous']); + } + public function testManyConfiguredDelimitersStayWithinMemoryBudget(): void { $input = 'X ' . str_repeat('a', (1024 * 1024) - 10) . ', Y'; diff --git a/tests/NumericNicknameDelimiterTest.php b/tests/NumericNicknameDelimiterTest.php new file mode 100644 index 0000000..1013ad0 --- /dev/null +++ b/tests/NumericNicknameDelimiterTest.php @@ -0,0 +1,47 @@ + + */ + public static function delimiterProvider(): array + { + $cases = []; + foreach (['0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '10', '-1'] as $delimiter) { + $cases['delimiter ' . $delimiter] = [$delimiter]; + } + + return $cases; + } + + #[DataProvider('delimiterProvider')] + public function testSymmetricNumericDelimitersRequireTokenBoundaries(string $delimiter): void + { + $delimiters = [$delimiter => $delimiter]; + $input = 'Smith' . $delimiter . ', John' . $delimiter; + + $this->assertSame( + ['Smith' . $delimiter, ' John' . $delimiter], + StructuralCommaSplitter::split($input, $delimiters), + ); + $name = (new Parser())->setNicknameDelimiters($delimiters)->parse($input); + $this->assertSame('John' . $delimiter, $name->getFirstname()); + $this->assertSame('Smith' . $delimiter, $name->getLastname()); + $this->assertSame('', $name->getNickname()); + + $this->assertSame( + ['John ' . $delimiter . 'foo', 'bar' . $delimiter . 'x Smith'], + StructuralCommaSplitter::split('John ' . $delimiter . 'foo,bar' . $delimiter . 'x Smith', $delimiters), + ); + $closed = 'John ' . $delimiter . 'foo,bar' . $delimiter . ' Smith'; + $this->assertSame([$closed], StructuralCommaSplitter::split($closed, $delimiters)); + } +}