Skip to content

Commit dcb4b86

Browse files
committed
fix(inflekt): singularize coined -is/-us plurals (apis -> api)
Every word ending in -is/-us was assumed already singular, so an "apis" table round-tripped to "apises". No suffix distinguishes the two cases ("apis" and "iris" are both a vowel plus s), so the real singulars now come from the dictionary-generated exception table instead of a blanket rule, and unknown words fall through to "drop the s".
1 parent 8c56e34 commit dcb4b86

4 files changed

Lines changed: 281 additions & 217 deletions

File tree

packages/inflekt/__tests__/ves-and-exceptions.test.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,57 @@ describe('singularize: suffix classes the inflection library gets wrong', () =>
139139
});
140140
});
141141

142+
describe('singularize: -is and -us words', () => {
143+
it('depluralizes coined identifiers whose stem ends in a vowel', () => {
144+
// The regression that started this: an "apis" table became "apises",
145+
// because every -is/-us word was assumed to be already singular.
146+
expect(singularize('apis')).toBe('api');
147+
expect(singularize('uris')).toBe('uri');
148+
expect(singularize('guis')).toBe('gui');
149+
expect(singularize('cpus')).toBe('cpu');
150+
expect(singularize('gpus')).toBe('gpu');
151+
expect(singularizeLast('ApiSchemas')).toBe('ApiSchema');
152+
expect(pluralize(singularize('apis'))).toBe('apis');
153+
expect(pluralize(singularize('cpus'))).toBe('cpus');
154+
});
155+
156+
it('leaves the dictionary singulars alone', () => {
157+
// "iris" and "apis" have the same shape (a vowel plus "s"), so these are
158+
// dictionary facts, not rules — see SINGULAR_EXCEPTIONS.
159+
for (const word of [
160+
'aegis',
161+
'analysis',
162+
'anxious',
163+
'apparatus',
164+
'bus',
165+
'cactus',
166+
'census',
167+
'chassis',
168+
'corpus',
169+
'genius',
170+
'iris',
171+
'nucleus',
172+
'radius',
173+
'status',
174+
'tennis',
175+
'various',
176+
'virus',
177+
]) {
178+
expect(singularize(word)).toBe(word);
179+
}
180+
});
181+
182+
it('still depluralizes the dictionary plurals of vowel stems', () => {
183+
expect(singularize('menus')).toBe('menu');
184+
expect(singularize('emus')).toBe('emu');
185+
expect(singularize('skis')).toBe('ski');
186+
expect(singularize('taxis')).toBe('taxi');
187+
expect(singularize('alibis')).toBe('alibi');
188+
expect(singularize('statuses')).toBe('status');
189+
expect(singularize('irises')).toBe('iris');
190+
});
191+
});
192+
142193
describe('SINGULAR_EXCEPTIONS table', () => {
143194
it('is applied for every entry', () => {
144195
for (const [plural, singular] of Object.entries(SINGULAR_EXCEPTIONS)) {

packages/inflekt/scripts/generate-exceptions.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,16 @@ for (const singular of [...exceptions.values()]) {
179179
}
180180
}
181181

182+
// Round-trip closure for the singulars that end in "s" (aegis, iris, corpus):
183+
// their plural takes "-es", and the generic rules read that back one letter
184+
// short (aegises -> aegise), because "-ses" is only "-sis" for the Greek nouns.
185+
for (const singular of [...new Set(exceptions.values())]) {
186+
if (!singular.endsWith('s')) continue;
187+
const plural = pluralize(singular);
188+
if (plural === singular || exceptions.has(plural)) continue;
189+
if (singularizeByRules(plural) !== singular) exceptions.set(plural, singular);
190+
}
191+
182192
const entries = [...exceptions.entries()].sort(([a], [b]) => a.localeCompare(b));
183193
const body = entries.map(([plural, singular]) => ` ${plural}: '${singular}',`).join('\n');
184194

0 commit comments

Comments
 (0)