Skip to content

Commit 415a2cd

Browse files
committed
fix(intl): handle snake_case in singularizeLastWord and pluralizeLastWord
The regex in both methods only split on camelCase boundaries, so snake_case strings like product_metadata were passed whole to Doctrine Inflector which produced wrong results (e.g. product_metadatum). Add (_) alternation to the split regex so underscores are captured as word boundaries alongside uppercase transitions.
1 parent 7334926 commit 415a2cd

2 files changed

Lines changed: 116 additions & 8 deletions

File tree

packages/intl/src/Pluralizer/InflectorPluralizer.php

Lines changed: 56 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@
99
use Doctrine\Inflector\InflectorFactory;
1010
use Stringable;
1111

12+
use function Tempest\Support\str;
13+
1214
final class InflectorPluralizer implements Pluralizer
1315
{
1416
private Inflector $inflector;
@@ -39,20 +41,66 @@ public function singularize(Stringable|string $value): string
3941

4042
public function singularizeLastWord(Stringable|string $value): string
4143
{
42-
$string = (string) $value;
43-
$parts = preg_split('/(.)(?=[A-Z])/u', $string, flags: PREG_SPLIT_DELIM_CAPTURE);
44-
$lastWord = array_pop($parts);
44+
$lastWord = $this->extractLastWord(value: (string) $value, prefix: $prefix, suffix: $suffix);
45+
46+
if ($lastWord === null) {
47+
return $suffix;
48+
}
4549

46-
return implode('', $parts) . $this->singularize($lastWord);
50+
return $prefix . $this->singularize(value: $lastWord) . $suffix;
4751
}
4852

4953
public function pluralizeLastWord(Stringable|string $value, int|array|Countable $count = 2): string
5054
{
51-
$string = (string) $value;
52-
$parts = preg_split('/(.)(?=[A-Z])/u', $string, flags: PREG_SPLIT_DELIM_CAPTURE);
53-
$lastWord = array_pop($parts);
55+
$lastWord = $this->extractLastWord(value: (string) $value, prefix: $prefix, suffix: $suffix);
56+
57+
if ($lastWord === null) {
58+
return $suffix;
59+
}
60+
61+
return $prefix . $this->pluralize(value: $lastWord, count: $count) . $suffix;
62+
}
63+
64+
private function extractLastWord(string $value, ?string &$prefix = null, ?string &$suffix = null): ?string
65+
{
66+
$parts = $this->splitWords(value: $value);
67+
68+
$last = end(array: $parts);
69+
$suffix = $last !== false && str(string: $last)->trim(characters: '_')->isEmpty()
70+
? array_pop(array: $parts)
71+
: '';
72+
73+
if (count(value: $parts) === 0) {
74+
return null;
75+
}
5476

55-
return implode('', $parts) . $this->pluralize($lastWord, $count);
77+
$lastWord = array_pop(array: $parts);
78+
$prefix = implode(separator: '', array: $parts);
79+
80+
return $lastWord;
81+
}
82+
83+
/**
84+
* Splits a string into word segments and underscore delimiters.
85+
*
86+
* The regex splits on five boundary types:
87+
* - (_+)(?=[a-zA-Z0-9]) — underscore separators followed by a word character (snake_case, SCREAMING_SNAKE)
88+
* - (_+)$ — trailing underscores at end of string
89+
* - (?<=[a-z0-9])(?=[A-Z]) — lowercase/digit to uppercase transition (camelCase, PascalCase)
90+
* - (?<=[A-Z])(?=[A-Z][a-z]) — end of uppercase run before a new word (HTMLElements → HTML + Elements)
91+
* - (?<=\s)(?=[A-Z]) — whitespace to uppercase transition (Multiple Aircraft)
92+
*
93+
* @return list<string>
94+
*/
95+
private function splitWords(string $value): array
96+
{
97+
return (
98+
preg_split(
99+
pattern: '/(_+)(?=[a-zA-Z0-9])|(_+)$|(?<=[a-z0-9])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])|(?<=\s)(?=[A-Z])/u',
100+
subject: $value,
101+
flags: PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY,
102+
) ?: []
103+
);
56104
}
57105

58106
private function matchCase(Stringable|string $value, Stringable|string $comparison): string

packages/intl/tests/InflectorPluralizerTest.php

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@ final class InflectorPluralizerTest extends TestCase
1919
#[TestWith(['Migration', 'Migrations', 2])]
2020
#[TestWith(['migration', 'migrations', 2])]
2121
#[TestWith(['migration', 'migrations', [1, 2]])]
22+
#[TestWith(['category', 'categories', 2])]
23+
#[TestWith(['status', 'statuses', 2])]
24+
#[TestWith(['child', 'children', 2])]
2225
public function test_that_pluralizer_pluralizes(string $value, string $expected, int|array|Countable $count): void
2326
{
2427
$pluralizer = new InflectorPluralizer();
@@ -28,10 +31,67 @@ public function test_that_pluralizer_pluralizes(string $value, string $expected,
2831

2932
#[TestWith(['Migrations', 'Migration'])]
3033
#[TestWith(['migrations', 'migration'])]
34+
#[TestWith(['categories', 'category'])]
35+
#[TestWith(['statuses', 'status'])]
36+
#[TestWith(['children', 'child'])]
3137
public function test_that_pluralizer_singularizes(string $value, string $expected): void
3238
{
3339
$pluralizer = new InflectorPluralizer();
3440

3541
$this->assertEquals($expected, $pluralizer->singularize($value));
3642
}
43+
44+
#[TestWith(['bookAuthors', 'bookAuthor'])]
45+
#[TestWith(['BookAuthors', 'BookAuthor'])]
46+
#[TestWith(['book_authors', 'book_author'])]
47+
#[TestWith(['product_metadata', 'product_metadata'])]
48+
#[TestWith(['user_book_categories', 'user_book_category'])]
49+
#[TestWith(['authors', 'author'])]
50+
#[TestWith(['', ''])]
51+
#[TestWith(['BOOK_AUTHORS', 'BOOK_AUTHOR'])]
52+
#[TestWith(['USER_STATUSES', 'USER_STATUS'])]
53+
#[TestWith(['BOOK_CATEGORIES', 'BOOK_CATEGORY'])]
54+
#[TestWith(['parseHTMLElements', 'parseHTMLElement'])]
55+
#[TestWith(['HTMLElements', 'HTMLElement'])]
56+
#[TestWith(['getHTTPSResponses', 'getHTTPSResponse'])]
57+
#[TestWith(['_authors', '_author'])]
58+
#[TestWith(['authors_', 'author_'])]
59+
#[TestWith(['authors__', 'author__'])]
60+
#[TestWith(['book__authors', 'book__author'])]
61+
#[TestWith(['_', '_'])]
62+
#[TestWith(['__', '__'])]
63+
#[TestWith(['Multiple Aircraft', 'Multiple Aircraft'])]
64+
public function test_singularize_last_word(string $value, string $expected): void
65+
{
66+
$pluralizer = new InflectorPluralizer();
67+
68+
$this->assertEquals($expected, $pluralizer->singularizeLastWord($value));
69+
}
70+
71+
#[TestWith(['bookAuthor', 'bookAuthors'])]
72+
#[TestWith(['BookAuthor', 'BookAuthors'])]
73+
#[TestWith(['book_author', 'book_authors'])]
74+
#[TestWith(['product_metadata', 'product_metadata'])]
75+
#[TestWith(['user_book_category', 'user_book_categories'])]
76+
#[TestWith(['author', 'authors'])]
77+
#[TestWith(['', ''])]
78+
#[TestWith(['BOOK_AUTHOR', 'BOOK_AUTHORS'])]
79+
#[TestWith(['USER_STATUS', 'USER_STATUSES'])]
80+
#[TestWith(['BOOK_CATEGORY', 'BOOK_CATEGORIES'])]
81+
#[TestWith(['parseHTMLElement', 'parseHTMLElements'])]
82+
#[TestWith(['HTMLElement', 'HTMLElements'])]
83+
#[TestWith(['getHTTPSResponse', 'getHTTPSResponses'])]
84+
#[TestWith(['_author', '_authors'])]
85+
#[TestWith(['author_', 'authors_'])]
86+
#[TestWith(['author__', 'authors__'])]
87+
#[TestWith(['book__author', 'book__authors'])]
88+
#[TestWith(['_', '_'])]
89+
#[TestWith(['__', '__'])]
90+
#[TestWith(['Multiple Aircraft', 'Multiple Aircraft'])]
91+
public function test_pluralize_last_word(string $value, string $expected): void
92+
{
93+
$pluralizer = new InflectorPluralizer();
94+
95+
$this->assertEquals($expected, $pluralizer->pluralizeLastWord($value));
96+
}
3797
}

0 commit comments

Comments
 (0)