From 8c9c01686acca9f65ab45f7e98545ceb88b3b502 Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Thu, 6 Aug 2026 02:47:46 +0000 Subject: [PATCH 1/2] fix(inflekt): audit pluralize and singularize against the dictionary in both directions Both directions now go through a dictionary-proven exception table in front of suffix rules, and both are idempotent: pluralize of a plural and singularize of a singular return the word unchanged. --- .../inflekt/__tests__/four-properties.test.ts | 199 +++++++++ packages/inflekt/package.json | 2 + packages/inflekt/scripts/audit.ts | 110 +++++ .../inflekt/scripts/generate-exceptions.ts | 199 ++++++++- packages/inflekt/src/exceptions.ts | 390 ++++++++++++++++-- packages/inflekt/src/pluralize.ts | 110 ++--- packages/inflekt/src/rules.ts | 161 +++++++- 7 files changed, 1072 insertions(+), 99 deletions(-) create mode 100644 packages/inflekt/__tests__/four-properties.test.ts create mode 100644 packages/inflekt/scripts/audit.ts diff --git a/packages/inflekt/__tests__/four-properties.test.ts b/packages/inflekt/__tests__/four-properties.test.ts new file mode 100644 index 0000000..e0323da --- /dev/null +++ b/packages/inflekt/__tests__/four-properties.test.ts @@ -0,0 +1,199 @@ +/** + * The four properties every singular/plural pair must satisfy, because callers + * hand us words in both numbers and ask for either one: + * + * singularize(singular) === singular + * pluralize(singular) === plural + * pluralize(plural) === plural + * singularize(plural) === singular + * + * The corpora here are the hand-picked ones — identifiers a schema actually + * contains, and the English shapes that break naive suffix rules. The + * exhaustive check is scripts/audit.ts, which runs these same four properties + * over every singular/plural pair in the system dictionary. + */ +import { pluralize, singularize } from '../src/pluralize'; + +/** Identifiers, as a developer writes them in a schema. */ +const IDENTIFIERS: Array<[string, string]> = [ + // coined acronyms and abbreviations: never in a dictionary, and the reason + // "drop the s" has to be the fallback for -is/-us + ['api', 'apis'], ['uri', 'uris'], ['url', 'urls'], ['uuid', 'uuids'], + ['guid', 'guids'], ['id', 'ids'], ['ip', 'ips'], ['cpu', 'cpus'], + ['gpu', 'gpus'], ['ram', 'rams'], ['acl', 'acls'], ['sdk', 'sdks'], + ['cli', 'clis'], ['ui', 'uis'], ['ux', 'uxes'], ['dag', 'dags'], + ['pid', 'pids'], ['fd', 'fds'], ['csr', 'csrs'], ['sha', 'shas'], + ['jwt', 'jwts'], ['saml', 'samls'], ['totp', 'totps'], ['otp', 'otps'], + ['mfa', 'mfas'], ['orm', 'orms'], ['crud', 'cruds'], + // formats and languages + ['json', 'jsons'], ['yaml', 'yamls'], ['toml', 'tomls'], ['csv', 'csvs'], + ['sql', 'sqls'], ['html', 'htmls'], ['css', 'csses'], + // -x words, where Latin and English disagree + ['vertex', 'vertices'], ['matrix', 'matrices'], ['mutex', 'mutexes'], + ['regex', 'regexes'], + // -a words the inflection library mistakes for Latin plurals + ['schema', 'schemas'], ['lambda', 'lambdas'], ['delta', 'deltas'], + ['beta', 'betas'], ['alpha', 'alphas'], ['quota', 'quotas'], + ['replica', 'replicas'], ['persona', 'personas'], ['formula', 'formulas'], + ['antenna', 'antennas'], + // -us/-is words that are singular, not plural + ['status', 'statuses'], ['radius', 'radiuses'], ['focus', 'focuses'], + ['syllabus', 'syllabuses'], ['bus', 'buses'], ['alias', 'aliases'], + ['bias', 'biases'], ['axis', 'axes'], ['analysis', 'analyses'], + ['basis', 'bases'], + // genuine Latin, where singularize and pluralize must agree with each other + ['datum', 'data'], ['metadatum', 'metadata'], ['criterion', 'criteria'], + ['phenomenon', 'phenomena'], ['medium', 'media'], ['stratum', 'strata'], + ['corpus', 'corpora'], ['forum', 'forums'], + // -o words: "s", not "es" + ['repo', 'repos'], ['proto', 'protos'], ['photo', 'photos'], + ['video', 'videos'], ['macro', 'macros'], ['micro', 'micros'], + // GraphQL and database vocabulary + ['mutation', 'mutations'], ['subscription', 'subscriptions'], + ['resolver', 'resolvers'], ['directive', 'directives'], + ['fragment', 'fragments'], ['migration', 'migrations'], + ['snapshot', 'snapshots'], ['cursor', 'cursors'], ['policy', 'policies'], + ['query', 'queries'], ['tenant', 'tenants'], ['grant', 'grants'], + ['role', 'roles'], ['scope', 'scopes'], ['claim', 'claims'], + ['session', 'sessions'], ['token', 'tokens'], ['webhook', 'webhooks'], + ['endpoint', 'endpoints'], ['payload', 'payloads'], ['directory', 'directories'], + ['proxy', 'proxies'], ['gateway', 'gateways'], ['queue', 'queues'], + ['cache', 'caches'], ['class', 'classes'], ['process', 'processes'], + ['hash', 'hashes'], ['branch', 'branches'], ['patch', 'patches'], + ['batch', 'batches'], ['match', 'matches'], ['checksum', 'checksums'], + ['digest', 'digests'], ['bucket', 'buckets'], ['volume', 'volumes'], + ['partition', 'partitions'], ['shard', 'shards'], ['cluster', 'clusters'], + ['node', 'nodes'], ['pod', 'pods'], ['disk', 'disks'], ['edge', 'edges'], + ['graph', 'graphs'], ['heap', 'heaps'], ['stack', 'stacks'], + ['kernel', 'kernels'], ['daemon', 'daemons'], ['socket', 'sockets'], + ['thread', 'threads'], ['codec', 'codecs'], ['cron', 'crons'], + ['config', 'configs'], ['env', 'envs'], ['plugin', 'plugins'], + ['addon', 'addons'], ['secret', 'secrets'], ['certificate', 'certificates'], + ['tuple', 'tuples'], ['struct', 'structs'], ['union', 'unions'], + ['enum', 'enums'], ['bool', 'bools'], ['byte', 'bytes'], ['bit', 'bits'], + ['blob', 'blobs'], ['file', 'files'], ['key', 'keys'], ['value', 'values'], + ['object', 'objects'], ['array', 'arrays'], ['vector', 'vectors'], + ['tensor', 'tensors'], ['embedding', 'embeddings'], ['mock', 'mocks'], + ['stub', 'stubs'], ['offset', 'offsets'], + // snake_case compounds: the last word decides + ['api_key', 'api_keys'], ['access_token', 'access_tokens'], + ['user_profile', 'user_profiles'], ['schema_migration', 'schema_migrations'], + ['oauth_client', 'oauth_clients'], ['ssh_key', 'ssh_keys'], + ['index_stat', 'index_stats'], ['full_text_search', 'full_text_searches'], + ['api_status', 'api_statuses'], ['user_analysis', 'user_analyses'], +]; + +/** The English shapes that break naive suffix rules. */ +const ENGLISH: Array<[string, string]> = [ + // irregulars + ['person', 'people'], ['child', 'children'], ['man', 'men'], + ['woman', 'women'], ['foot', 'feet'], ['tooth', 'teeth'], + ['goose', 'geese'], ['mouse', 'mice'], ['louse', 'lice'], ['ox', 'oxen'], + // -f/-fe: only the f-stem nouns take -ves + ['knife', 'knives'], ['life', 'lives'], ['wife', 'wives'], + ['shelf', 'shelves'], ['leaf', 'leaves'], ['thief', 'thieves'], + ['bookshelf', 'bookshelves'], ['cafe', 'cafes'], ['safe', 'safes'], + ['roof', 'roofs'], ['belief', 'beliefs'], ['chief', 'chiefs'], + // -ves that is not an f-stem plural at all + ['olive', 'olives'], ['drive', 'drives'], ['archive', 'archives'], + // -is/-us singulars, and the Greek/Latin -sis family + ['iris', 'irises'], ['chassis', 'chassis'], ['atlas', 'atlases'], + ['virus', 'viruses'], ['census', 'censuses'], ['genius', 'geniuses'], + ['campus', 'campuses'], ['cactus', 'cactuses'], ['thesis', 'theses'], + ['crisis', 'crises'], ['hypothesis', 'hypotheses'], ['diagnosis', 'diagnoses'], + ['parenthesis', 'parentheses'], ['synopsis', 'synopses'], ['oasis', 'oases'], + // -ss and -s stems + ['class', 'classes'], ['address', 'addresses'], ['lens', 'lenses'], + ['princess', 'princesses'], ['business', 'businesses'], + // -y and -ie + ['city', 'cities'], ['company', 'companies'], ['cookie', 'cookies'], + ['movie', 'movies'], ['day', 'days'], ['key', 'keys'], + // -o + ['potato', 'potatoes'], ['tomato', 'tomatoes'], ['hero', 'heroes'], + ['piano', 'pianos'], ['zero', 'zeros'], ['echo', 'echoes'], + // -ch/-sh/-x/-z + ['church', 'churches'], ['dish', 'dishes'], ['box', 'boxes'], + ['quiz', 'quizzes'], ['buzz', 'buzzes'], ['waltz', 'waltzes'], + // Latin and Greek. -ex/-ix follows the inflection library exactly, because + // PostGraphile names its fields with it (indices, vertices, matrices, but + // appendixes and mutexes). Elsewhere the English plural wins wherever the + // dictionary attests one (alumnuses, aquariums), and the Latin plural is kept + // only where English has none (curricula, memoranda, genera). + ['appendix', 'appendixes'], ['index', 'indices'], ['alumnus', 'alumnuses'], + ['curriculum', 'curricula'], ['memorandum', 'memoranda'], + ['aquarium', 'aquariums'], ['genus', 'genera'], + // -o: "es" for the nouns that take it, "s" for the rest + ['veto', 'vetoes'], ['fish', 'fishes'], + // -ice, which the inflection library reads as -ouse + ['police', 'polices'], ['service', 'services'], ['chalice', 'chalices'], + // compounds where the last word is irregular + ['salesperson', 'salespeople'], ['grandchild', 'grandchildren'], +]; + +/** Words with no distinct plural: both numbers are the same word. */ +const INVARIANT = [ + 'sheep', 'series', 'species', 'aircraft', 'news', 'equipment', + 'information', 'software', 'chassis', +]; + +describe.each([ + ['identifiers', IDENTIFIERS], + ['english', ENGLISH], +])('%s', (_name, corpus) => { + it.each(corpus)('%s <-> %s', (singular, plural) => { + expect(singularize(singular)).toBe(singular); + expect(pluralize(singular)).toBe(plural); + expect(pluralize(plural)).toBe(plural); + expect(singularize(plural)).toBe(singular); + }); +}); + +describe('invariant words', () => { + it.each(INVARIANT)('%s', (word) => { + expect(pluralize(word)).toBe(word); + expect(singularize(word)).toBe(word); + }); +}); + +describe('casing is preserved in both directions', () => { + it.each([ + ['ApiSchema', 'ApiSchemas'], + ['UserStatus', 'UserStatuses'], + ['AnalysisResult', 'AnalysisResults'], + ['PersonAddress', 'PersonAddresses'], + ['API_KEY', 'API_KEYS'], + ['USER_STATUS', 'USER_STATUSES'], + ])('%s <-> %s', (singular, plural) => { + expect(singularize(singular)).toBe(singular); + expect(pluralize(singular)).toBe(plural); + expect(pluralize(plural)).toBe(plural); + expect(singularize(plural)).toBe(singular); + }); +}); + +/** + * Words no algorithm can get right without knowing what the author meant. + * Pinned so a change of behaviour is a deliberate decision, not a surprise. + */ +describe('inherently ambiguous', () => { + it('reads -ies as the dictionary does, not as an identifier', () => { + // "tries" is the plural of "try" in every dictionary; that a codebase also + // spells the plural of "trie" that way is invisible here. + expect(singularize('tries')).toBe('try'); + // Same shape, opposite direction: "tty" is not a word, so the -y rule for + // words (city -> cities) applies to it. + expect(pluralize('tty')).toBe('tties'); + }); + + it('treats a consonant-only acronym as a word, since nothing marks it', () => { + // "dns" has the shape of a plural (and "cds", "urls" and "ids" really are + // plurals), so it loses the "s" like any other unknown -s word. + expect(singularize('dns')).toBe('dn'); + }); + + it('follows the dictionary even where the dictionary is odd', () => { + // wamerican lists "deers", so that is the plural we emit, though "deer" is + // the one most people write. + expect(pluralize('deer')).toBe('deers'); + }); +}); diff --git a/packages/inflekt/package.json b/packages/inflekt/package.json index 37b7ac3..8818a08 100644 --- a/packages/inflekt/package.json +++ b/packages/inflekt/package.json @@ -19,6 +19,8 @@ "build": "makage build", "lint": "eslint . --fix", "test": "jest", + "audit": "ts-node scripts/audit.ts", + "generate": "ts-node scripts/generate-exceptions.ts", "test:watch": "jest --watch" }, "repository": { diff --git a/packages/inflekt/scripts/audit.ts b/packages/inflekt/scripts/audit.ts new file mode 100644 index 0000000..cd461ca --- /dev/null +++ b/packages/inflekt/scripts/audit.ts @@ -0,0 +1,110 @@ +/** + * Scratch audit (not shipped): measure the four inflection properties against + * the dictionary-attested singular/plural pairs. + * + * npx ts-node scripts/audit.ts [--sample N] + * + * A singular may have several attested plurals (apexes and apices, cactuses and + * cacti): any of them counts as correct, since which one to prefer is a style + * choice, not a correctness one. + */ +import { readFileSync, writeFileSync } from 'fs'; + +import { pluralize, singularize } from '../src/pluralize'; + +const words = readFileSync('/usr/share/dict/words', 'utf8') + .split('\n') + .map((w) => w.trim()) + .filter((w) => /^[a-z]{3,}$/.test(w)); +const dictionary = new Set(words); + +/** Words that look like plurals but are not (or have no singular). */ +const NOT_PLURALS = new Set([ + 'acoustics', 'aerobics', 'athletics', 'crossroads', 'economics', 'ethics', + 'genetics', 'gymnastics', 'linguistics', 'logistics', 'mathematics', + 'mechanics', 'news', 'obstetrics', 'optics', 'physics', 'politics', 'series', + 'species', 'statistics', 'summons', 'hes', 'hises', 'ses', 'shes', 'sis', + 'sises', 'these', 'shucks', 'biceps', 'triceps', 'forceps', +]); + +function candidates(word: string): string[] { + const out: string[] = []; + if (word.endsWith('s')) out.push(word.slice(0, -1)); + if (word.endsWith('es')) out.push(word.slice(0, -2), word.slice(0, -2) + 'is'); + if (word.endsWith('ies')) out.push(word.slice(0, -3) + 'y'); + if (word.endsWith('ses')) out.push(word.slice(0, -2)); + if (word.endsWith('ves')) out.push(word.slice(0, -3) + 'f', word.slice(0, -3) + 'fe'); + if (word.endsWith('ices')) out.push(word.slice(0, -4) + 'ex', word.slice(0, -4) + 'ix'); + return out; +} + +// singular -> every attested plural of it +const attested = new Map>(); +for (const word of words) { + if (!word.endsWith('s') || word.endsWith('ss') || NOT_PLURALS.has(word)) continue; + const valid = [...new Set(candidates(word))].filter( + (c) => c.length > 1 && dictionary.has(c) && c !== word + ); + if (valid.length !== 1) continue; + const plurals = attested.get(valid[0]) ?? new Set(); + plurals.add(word); + attested.set(valid[0], plurals); +} + +const buckets: Record = { + 'singularize(singular) != singular': [], + 'pluralize(singular) not attested': [], + 'pluralize(plural) != plural': [], + 'singularize(plural) != singular': [], +}; + +let pairCount = 0; +for (const [singular, plurals] of attested) { + pairCount += plurals.size; + const want = [...plurals].join('/'); + + if (singularize(singular) !== singular) { + buckets['singularize(singular) != singular'].push( + `${singular} -> ${singularize(singular)}` + ); + } + const got = pluralize(singular); + if (!plurals.has(got)) { + buckets['pluralize(singular) not attested'].push( + `${singular} -> ${got} (attested ${want})` + ); + } + for (const plural of plurals) { + if (pluralize(plural) !== plural) { + buckets['pluralize(plural) != plural'].push(`${plural} -> ${pluralize(plural)}`); + } + if (singularize(plural) !== singular) { + buckets['singularize(plural) != singular'].push( + `${plural} -> ${singularize(plural)} (want ${singular})` + ); + } + } +} + +const sampleFlag = process.argv.indexOf('--sample'); +const sample = sampleFlag === -1 ? 15 : Number(process.argv[sampleFlag + 1]); + +let report = `singulars: ${attested.size}, attested plurals: ${pairCount}\n`; +for (const [name, failures] of Object.entries(buckets)) { + const rate = ((failures.length / attested.size) * 100).toFixed(2); + report += `\n${name}: ${failures.length} (${rate}%)\n`; + report += failures.map((f) => ` ${f}`).join('\n') + '\n'; +} +writeFileSync('/tmp/audit.txt', report); +console.log( + report + .split('\n') + .filter((line) => !line.startsWith(' ')) + .join('\n') +); +for (const [name, failures] of Object.entries(buckets)) { + if (!failures.length) continue; + console.log(`\n${name} (first ${sample}):`); + console.log(failures.slice(0, sample).map((f) => ` ${f}`).join('\n')); +} +console.log('\nfull: /tmp/audit.txt'); diff --git a/packages/inflekt/scripts/generate-exceptions.ts b/packages/inflekt/scripts/generate-exceptions.ts index 3ddfcca..4e8eef9 100644 --- a/packages/inflekt/scripts/generate-exceptions.ts +++ b/packages/inflekt/scripts/generate-exceptions.ts @@ -6,19 +6,32 @@ * * Method: a dictionary word is treated as a plural only when some * dictionary-valid singular candidate pluralizes back to it exactly. That - * candidate is the expected singular; whenever the suffix rules alone disagree, - * the pair is emitted as an exception (the rules-only path keeps this script - * idempotent — rerunning it reproduces the same table). - * Ambiguous noun/verb homographs (calves = - * plural of "calf" and third person of "calve") are resolved in favour of the - * noun via F_STEM_NOUNS, and Greek/Latin `-sis` nouns via SIS_NOUNS, because - * those are the senses schema and API names mean. + * candidate is the expected singular, and the pair is then held to all four + * properties, with an exception emitted for each one the suffix rules miss: + * + * singularize(singular) === singular (SINGULAR_EXCEPTIONS, self-mapped) + * singularize(plural) === singular (SINGULAR_EXCEPTIONS) + * pluralize(singular) === plural (PLURAL_EXCEPTIONS) + * pluralize(plural) === plural (rules: see isRulesPlural) + * + * Only the rules-only entry points are used here, so the tables cannot mask the + * very defects they record and rerunning the script reproduces them exactly. + * Ambiguous noun/verb homographs (calves = plural of "calf" and third person of + * "calve") are resolved in favour of the noun via F_STEM_NOUNS, and Greek/Latin + * `-sis` nouns via SIS_NOUNS, because those are the senses schema and API names + * mean. */ import { readFileSync, writeFileSync } from 'fs'; import { join } from 'path'; -import { pluralize } from '../src/pluralize'; -import { F_STEM_PLURALS, singularizeByRules } from '../src/rules'; +import { + F_STEM_PLURALS, + isRulesPlural, + pluralizeByRules, + singularizeByRules, +} from '../src/rules'; + +const pluralize = pluralizeByRules; const DICTIONARY = '/usr/share/dict/words'; @@ -50,6 +63,16 @@ const AMBIGUOUS: Record = { penes: 'penis', // vs "pen" }; +/** + * The -o nouns whose standard plural is "-oes", for the cases where the + * dictionary attests both spellings and the frequency list ranks neither. The + * rest of "-o" takes a plain "s" (photos, pianos, repos, protos, zeros). + */ +const OES_NOUNS = new Set([ + 'cargo', 'domino', 'echo', 'embargo', 'mosquito', 'motto', 'tornado', + 'torpedo', 'veto', 'volcano', +]); + /** Plural-looking words that are not plurals (or are uncountable). */ const NOT_PLURALS = new Set([ 'acoustics', 'aerobics', 'athletics', 'crossroads', 'economics', 'ethics', @@ -58,12 +81,26 @@ const NOT_PLURALS = new Set([ 'hes', 'hises', 'ses', 'shes', 'sis', 'sises', 'species', 'statistics', 'summons', 'these', ]); -const words = readFileSync(DICTIONARY, 'utf8') +const dictionaryLines = readFileSync(DICTIONARY, 'utf8') .split('\n') - .map((word) => word.trim()) - .filter((word) => /^[a-z]{3,}$/.test(word)); + .map((word) => word.trim()); + +/** The words to audit. */ +const words = dictionaryLines.filter((word) => /^[a-z]{3,}$/.test(word)); + const dictionary = new Set(words); +/** + * The two-letter words, kept apart from `dictionary` so they can answer "does + * this -s word have a real singular?" without being offered as singulars + * themselves: "ids" is the plural of "id" and must not be self-mapped the way + * "bus" and "gas" are (whose "bu" and "ga" are not words), while "uses" must + * still singularize to "use" rather than to the word "us". + */ +const shortWords = new Set( + dictionaryLines.filter((word) => /^[a-z]{2}$/.test(word)) +); + const frequency = new Map( readFileSync(FREQUENCY_LIST, 'utf8') .split('\n') @@ -81,6 +118,7 @@ function candidates(word: string): string[] { if (word.endsWith('es')) out.push(word.slice(0, -2), word.slice(0, -2) + 'is'); if (word.endsWith('ies')) out.push(word.slice(0, -3) + 'y'); if (word.endsWith('ses')) out.push(word.slice(0, -2)); + if (word.endsWith('zzes')) out.push(word.slice(0, -3)); // quizzes -> quiz if (word.endsWith('ves')) { out.push(word.slice(0, -3) + 'f', word.slice(0, -3) + 'fe'); } @@ -143,7 +181,9 @@ for (const word of words) { if ( word.endsWith('s') && !word.endsWith('ss') && - !candidates(word).some((candidate) => dictionary.has(candidate)) && + !candidates(word).some( + (candidate) => dictionary.has(candidate) || shortWords.has(candidate) + ) && singularizeByRules(word) !== word && !dictionary.has(singularizeByRules(word)) ) { @@ -189,9 +229,116 @@ for (const singular of [...new Set(exceptions.values())]) { if (singularizeByRules(plural) !== singular) exceptions.set(plural, singular); } +// --------------------------------------------------------------------------- +// Plural side: pluralize(singular) must be the dictionary's plural. +// --------------------------------------------------------------------------- + +/** + * The dictionary's plural for each singular, inverted from the pairs the + * singular pass proved. A singular with two attested plurals (indexes and + * indices, cactuses and cacti) is decided by frequency, and by whichever the + * rules already produce when the frequency list knows neither. + */ +const attested = new Map>(); +for (const word of words) { + if (!word.endsWith('s') || word.endsWith('ss') || NOT_PLURALS.has(word)) continue; + const singular = expectedSingular(word); + if (!singular || singular === word) continue; + const plurals = attested.get(singular) ?? new Set(); + plurals.add(word); + attested.set(singular, plurals); +} + +/** Of several attested plurals, the one to emit. */ +function preferredPlural(singular: string, plurals: Set): string { + if (OES_NOUNS.has(singular) && plurals.has(`${singular}es`)) { + return `${singular}es`; + } + return [...plurals].sort((a, b) => { + if (rank(a) !== rank(b)) return rank(a) - rank(b); + // Neither is common enough to rank: keep whichever the rules already + // produce, so the table stays as small as the rules allow. + const byRules = pluralize(singular); + if (a === byRules) return -1; + if (b === byRules) return 1; + return a.localeCompare(b); + })[0]; +} + +/** + * Whether the plural the rules produce is a dictionary word that singularizes + * back to `singular` — an attested plural of a shape the audit cannot see, + * since it does not end in "s" (criterion -> criteria, corpus -> corpora). + * Those keep the rules' Latin answer instead of the dictionary's regular + * alternative ("criterions"). + */ +function rulesPluralIsAttested(singular: string): boolean { + // -ex/-ix nouns keep the library's Latin plural (index -> indices, appendix -> + // appendices), even where the dictionary also lists "indexes": PostGraphile + // names its own fields that way, and inflekt exists to agree with it. + if (/[ei]x$/.test(singular)) return true; + const plural = pluralize(singular); + return ( + plural !== singular && + !plural.endsWith('s') && + dictionary.has(plural) && + singularizeByRules(plural) === singular + ); +} + +const pluralExceptions = new Map(); +for (const [singular, plurals] of attested) { + const plural = preferredPlural(singular, plurals); + if (pluralize(singular) === plural || rulesPluralIsAttested(singular)) continue; + pluralExceptions.set(singular, plural); +} + +/** + * Idempotence of pluralize, mirroring the singular pass: a plural whose + * singular is a table entry (bias -> biases) is not recognizable as a plural by + * the rules, since by definition the rules do not produce it. Those are + * self-mapped so pluralize returns them untouched. + */ +function tableAwarePluralIsStable(plural: string): boolean { + const singular = exceptions.get(plural); + if (singular !== undefined ? singular !== plural : isRulesPlural(plural)) { + return true; + } + const mapped = pluralExceptions.get(plural); + return (mapped ?? pluralize(plural)) === plural; +} + +/** + * Singulars ending in "s" whose plural the dictionary does not list, because it + * is spelled the same (chassis, corps, series). Left to the rules they get a + * coined plural that does not even singularize back ("chasses"), so the + * unchanged word is the better answer. + */ +for (const word of words) { + if (!word.endsWith('s') || attested.has(word)) continue; + const isSingular = (exceptions.get(word) ?? singularizeByRules(word)) === word; + if (!isSingular) continue; + const rulesPlural = pluralize(word); + if (rulesPlural === word) continue; + if (singularizeByRules(rulesPlural) !== word) pluralExceptions.set(word, word); +} + +for (const plurals of attested.values()) { + for (const plural of plurals) { + if (!tableAwarePluralIsStable(plural)) pluralExceptions.set(plural, plural); + } +} + const entries = [...exceptions.entries()].sort(([a], [b]) => a.localeCompare(b)); const body = entries.map(([plural, singular]) => ` ${plural}: '${singular}',`).join('\n'); +const pluralEntries = [...pluralExceptions.entries()].sort(([a], [b]) => + a.localeCompare(b) +); +const pluralBody = pluralEntries + .map(([singular, plural]) => ` ${singular}: '${plural}',`) + .join('\n'); + writeFileSync( join(__dirname, '..', 'src', 'exceptions.ts'), `/** @@ -214,7 +361,31 @@ writeFileSync( export const SINGULAR_EXCEPTIONS: Record = { ${body} }; + +/** + * Singular -> plural pairs that no generic suffix rule gets right. + * + * Pluralization is not rule-decidable either: \`-o\` takes "es" for "potatoes" + * but only "s" for "photos"; \`-us\` is "-uses" for "buses" but "-i" for "fungi"; + * \`-um\` is "-ums" for "forums" but "-a" for "data". The inflection library also + * holds many countable nouns uncountable (bias, atlas, aid), which for an + * identifier means the plural silently equals the singular — those are + * corrected here. + * + * Entries whose key is itself a plural (\`biases: 'biases'\`) exist so that + * pluralize is idempotent for the plurals the rules cannot re-derive. + * + * GENERATED — do not hand-edit. Regenerate with: + * + * sudo apt-get install -y wamerican + * npx ts-node scripts/generate-exceptions.ts + */ +export const PLURAL_EXCEPTIONS: Record = { +${pluralBody} +}; ` ); -console.log(`wrote ${entries.length} exception pairs`); +console.log( + `wrote ${entries.length} singular pairs, ${pluralEntries.length} plural pairs` +); diff --git a/packages/inflekt/src/exceptions.ts b/packages/inflekt/src/exceptions.ts index e1247da..3d174e9 100644 --- a/packages/inflekt/src/exceptions.ts +++ b/packages/inflekt/src/exceptions.ts @@ -25,7 +25,7 @@ export const SINGULAR_EXCEPTIONS: Record = { accoutrements: 'accoutrements', accuses: 'accuse', aches: 'ache', - ads: 'ads', + actinia: 'actinium', aegis: 'aegis', aegises: 'aegis', aerobatics: 'aerobatics', @@ -81,6 +81,7 @@ export const SINGULAR_EXCEPTIONS: Record = { backwoods: 'backwoods', badlands: 'badlands', banns: 'banns', + baria: 'barium', bassis: 'bassi', bathos: 'bathos', bayous: 'bayou', @@ -90,6 +91,7 @@ export const SINGULAR_EXCEPTIONS: Record = { belies: 'belie', bellyaches: 'bellyache', bemuses: 'bemuse', + beryllia: 'beryllium', bias: 'bias', biases: 'bias', biceps: 'biceps', @@ -125,7 +127,10 @@ export const SINGULAR_EXCEPTIONS: Record = { butterfingers: 'butterfingers', caches: 'cache', cactus: 'cactus', + cadmia: 'cadmium', caduceus: 'caduceus', + caduceuses: 'caduceus', + calcia: 'calcium', calculus: 'calculus', callisthenics: 'callisthenics', callouses: 'callous', @@ -146,14 +151,17 @@ export const SINGULAR_EXCEPTIONS: Record = { caucus: 'caucus', census: 'census', cervices: 'cervix', + cesia: 'cesium', chamois: 'chamois', chamoises: 'chamois', chateaus: 'chateaus', + chateauses: 'chateaus', chilies: 'chili', chitlings: 'chitlings', chitlins: 'chitlins', chitterlings: 'chitterlings', chorus: 'chorus', + chromia: 'chromium', chrysalides: 'chrysalides', chrysalis: 'chrysalis', chrysalises: 'chrysalis', @@ -181,6 +189,7 @@ export const SINGULAR_EXCEPTIONS: Record = { colones: 'colon', colossis: 'colossi', colossus: 'colossus', + condominia: 'condominium', confuses: 'confuse', conjunctivitis: 'conjunctivitis', conjunctivitises: 'conjunctivitis', @@ -210,7 +219,6 @@ export const SINGULAR_EXCEPTIONS: Record = { curies: 'curie', dais: 'dais', daises: 'dais', - dds: 'dds', deaves: 'deaf', debases: 'debase', debris: 'debris', @@ -225,6 +233,7 @@ export const SINGULAR_EXCEPTIONS: Record = { dermises: 'dermis', detentes: 'detentes', detritus: 'detritus', + deuteria: 'deuterium', diabetes: 'diabetes', dialyzes: 'dialyzes', diereses: 'dieresis', @@ -240,7 +249,6 @@ export const SINGULAR_EXCEPTIONS: Record = { does: 'doe', dogies: 'dogie', doldrums: 'doldrums', - dos: 'dos', douches: 'douche', downstairs: 'downstairs', dreadlocks: 'dreadlocks', @@ -249,7 +257,6 @@ export const SINGULAR_EXCEPTIONS: Record = { dwarves: 'dwarf', earaches: 'earache', edgeways: 'edgeways', - eds: 'eds', eeries: 'eerie', egis: 'egis', egises: 'egis', @@ -258,7 +265,6 @@ export const SINGULAR_EXCEPTIONS: Record = { elves: 'elf', emeritus: 'emeritus', emphases: 'emphasis', - ems: 'ems', encephalitis: 'encephalitis', encephalitises: 'encephalitis', endways: 'endways', @@ -270,6 +276,7 @@ export const SINGULAR_EXCEPTIONS: Record = { epiglottides: 'epiglottides', epiglottis: 'epiglottis', epiglottises: 'epiglottis', + equilibria: 'equilibrium', esophagus: 'esophagus', ethos: 'ethos', eucalyptus: 'eucalyptus', @@ -277,14 +284,14 @@ export const SINGULAR_EXCEPTIONS: Record = { excuses: 'excuse', executrices: 'executrix', exegeses: 'exegesis', - exes: 'exes', exodus: 'exodus', + factota: 'factotum', faeces: 'faeces', fairings: 'fairings', feces: 'feces', fetus: 'fetus', fezes: 'fez', - fezzes: 'fezzes', + fezzes: 'fez', fiches: 'fiche', fiddlesticks: 'fiddlesticks', fies: 'fie', @@ -310,6 +317,7 @@ export const SINGULAR_EXCEPTIONS: Record = { ftpers: 'ftpers', fungus: 'fungus', fuses: 'fuse', + gallia: 'gallium', gallowses: 'gallows', gas: 'gas', gases: 'gas', @@ -320,11 +328,14 @@ export const SINGULAR_EXCEPTIONS: Record = { gauches: 'gauche', geneses: 'genesis', genies: 'genie', + genitalias: 'genitalia', genius: 'genius', geniuses: 'genius', geocaches: 'geocache', geophysics: 'geophysics', geopolitics: 'geopolitics', + gerania: 'geranium', + germania: 'germanium', gingivitis: 'gingivitis', gingivitises: 'gingivitis', gladiolus: 'gladiolus', @@ -332,18 +343,18 @@ export const SINGULAR_EXCEPTIONS: Record = { glottis: 'glottis', glottises: 'glottis', goalies: 'goalie', - goes: 'goes', gratis: 'gratis', gratises: 'gratis', groupies: 'groupie', haemorrhoids: 'haemorrhoids', + hafnia: 'hafnium', halves: 'half', handkerchieves: 'handkerchief', - has: 'has', headaches: 'headache', headwaters: 'headwaters', heartaches: 'heartache', heartstrings: 'heartstrings', + helia: 'helium', helices: 'helix', hepatitis: 'hepatitis', hepatitises: 'hepatitis', @@ -354,17 +365,13 @@ export const SINGULAR_EXCEPTIONS: Record = { hies: 'hie', himselves: 'himself', hippopotamus: 'hippopotamus', - his: 'his', - hises: 'his', hoes: 'hoe', hoodies: 'hoodie', hooves: 'hoof', horseshoes: 'horseshoe', - hos: 'hos', houmus: 'houmus', housewares: 'housewares', housewives: 'housewife', - hrs: 'hrs', hubris: 'hubris', hubrises: 'hubris', humerus: 'humerus', @@ -377,22 +384,18 @@ export const SINGULAR_EXCEPTIONS: Record = { ibices: 'ibex', ibis: 'ibis', ibises: 'ibis', - ides: 'ides', - ids: 'ids', - ifs: 'ifs', ignoramus: 'ignoramus', illus: 'illus', impasses: 'impasse', impetus: 'impetus', infuses: 'infuse', innards: 'innards', - ins: 'ins', intravenouses: 'intravenous', + iridia: 'iridium', iris: 'iris', irises: 'iris', isosceles: 'isosceles', isthmus: 'isthmus', - its: 'its', itselves: 'itself', jackknives: 'jackknife', jaggies: 'jaggies', @@ -413,8 +416,8 @@ export const SINGULAR_EXCEPTIONS: Record = { laryngitis: 'laryngitis', laryngitises: 'laryngitis', lassies: 'lassie', + lawrencia: 'lawrencium', lazybones: 'lazybones', - lbs: 'lbs', leaves: 'leaf', leftwards: 'leftwards', lengthways: 'lengthways', @@ -424,6 +427,7 @@ export const SINGULAR_EXCEPTIONS: Record = { lies: 'lie', lifeforms: 'lifeforms', lingeries: 'lingerie', + lithia: 'lithium', litmus: 'litmus', lives: 'life', loaves: 'loaf', @@ -439,9 +443,9 @@ export const SINGULAR_EXCEPTIONS: Record = { mantis: 'mantis', mantises: 'mantis', marabous: 'marabou', + marginalias: 'marginalia', marquis: 'marquis', marquises: 'marquis', - mas: 'mas', matins: 'matins', meatloaves: 'meatloaf', megalopolis: 'megalopolis', @@ -451,7 +455,6 @@ export const SINGULAR_EXCEPTIONS: Record = { meningitises: 'meningitis', menservants: 'menservants', menses: 'menses', - mes: 'mes', mesdames: 'mesdames', mesdemoiselles: 'mesdemoiselles', messieurs: 'messieurs', @@ -473,8 +476,8 @@ export const SINGULAR_EXCEPTIONS: Record = { misuses: 'misuse', modulus: 'modulus', molasses: 'molasses', + momenta: 'momentum', monies: 'monies', - mos: 'mos', mousses: 'mousse', moustaches: 'moustache', mucus: 'mucus', @@ -486,12 +489,15 @@ export const SINGULAR_EXCEPTIONS: Record = { naiades: 'naiad', narcissis: 'narcissi', narcissus: 'narcissus', + nasturtia: 'nasturtium', nautilus: 'nautilus', neckerchieves: 'neckerchief', neckties: 'necktie', nemeses: 'nemesis', + neodymia: 'neodymium', nephritis: 'nephritis', nephritises: 'nephritis', + neptunia: 'neptunium', neuritis: 'neuritis', neuritises: 'neuritis', neuroses: 'neurosis', @@ -501,25 +507,24 @@ export const SINGULAR_EXCEPTIONS: Record = { niches: 'niche', nightclothes: 'nightclothes', nightlives: 'nightlife', - noes: 'noes', nonplus: 'nonplus', nonplusses: 'nonplusses', - nos: 'nos', nowadays: 'nowadays', nucleus: 'nucleus', nucleuses: 'nucleus', oases: 'oasis', oboes: 'oboe', obtuses: 'obtuse', + odia: 'odium', oftentimes: 'oftentimes', - ohs: 'ohs', oldies: 'oldie', omnibusses: 'omnibusses', oneselves: 'oneself', onus: 'onus', oodles: 'oodles', oops: 'oops', - ops: 'ops', + opia: 'opium', + opprobria: 'opprobrium', opus: 'opus', organdies: 'organdie', ourselves: 'ourselves', @@ -527,14 +532,16 @@ export const SINGULAR_EXCEPTIONS: Record = { overshoes: 'overshoe', overuses: 'overuse', pajamas: 'pajamas', + palladia: 'palladium', pampas: 'pampas', panaches: 'panache', pancreas: 'pancreas', pancreases: 'pancreas', + pandemonia: 'pandemonium', pantaloons: 'pantaloons', papyrus: 'papyrus', + paraphernalias: 'paraphernalia', paratroops: 'paratroops', - pas: 'pas', pastiches: 'pastiche', pathos: 'pathos', patois: 'patois', @@ -553,6 +560,7 @@ export const SINGULAR_EXCEPTIONS: Record = { peritonitis: 'peritonitis', peritonitises: 'peritonitis', peruses: 'peruse', + petrolata: 'petrolatum', phalanges: 'phalanges', phallus: 'phallus', pharynges: 'pharynges', @@ -561,22 +569,25 @@ export const SINGULAR_EXCEPTIONS: Record = { phosphorus: 'phosphorus', piazzes: 'piazze', pies: 'pie', - pis: 'pis', - pises: 'pis', plainclothes: 'plainclothes', platypus: 'platypus', plexus: 'plexus', pliers: 'pliers', plus: 'plus', plusses: 'plusses', + plutonia: 'plutonium', pocketknives: 'pocketknife', poliomyelitis: 'poliomyelitis', poliomyelitises: 'poliomyelitis', politesses: 'politesse', + polonia: 'polonium', portcullis: 'portcullis', portcullises: 'portcullis', + postparta: 'postpartum', + potassia: 'potassium', potpies: 'potpie', prairies: 'prairie', + premia: 'premium', pres: 'pres', proboscides: 'proboscides', proboscis: 'proboscis', @@ -592,8 +603,9 @@ export const SINGULAR_EXCEPTIONS: Record = { quiches: 'quiche', quickies: 'quickie', quietus: 'quietus', - quizzes: 'quizzes', + quizzes: 'quiz', rabies: 'rabies', + radia: 'radium', radius: 'radius', radiuses: 'radius', reales: 'real', @@ -601,11 +613,13 @@ export const SINGULAR_EXCEPTIONS: Record = { refocus: 'refocus', refocusses: 'refocusses', refuses: 'refuse', + regalias: 'regalia', reis: 'reis', reises: 'reis', rendezvouses: 'rendezvous', rhinoceros: 'rhinoceros', rhinoceroses: 'rhinoceros', + rhodia: 'rhodium', rickets: 'rickets', roes: 'roe', rookies: 'rookie', @@ -625,6 +639,7 @@ export const SINGULAR_EXCEPTIONS: Record = { scarves: 'scarf', schnapps: 'schnapps', schooldays: 'schooldays', + selenia: 'selenium', selfies: 'selfie', selves: 'self', semiotics: 'semiotics', @@ -643,14 +658,16 @@ export const SINGULAR_EXCEPTIONS: Record = { smoothes: 'smooth', snowshoes: 'snowshoe', soapsuds: 'soapsuds', + sodia: 'sodium', sorties: 'sortie', - sos: 'sos', spareribs: 'spareribs', sphinges: 'sphinges', + sputa: 'sputum', staphylococcus: 'staphylococcus', stimulus: 'stimulus', stomachaches: 'stomachache', streptococcus: 'streptococcus', + strontia: 'strontium', stylus: 'stylus', stymies: 'stymie', suds: 'suds', @@ -671,6 +688,7 @@ export const SINGULAR_EXCEPTIONS: Record = { teargases: 'teargas', teargasses: 'teargasses', tectonics: 'tectonics', + tedia: 'tedium', tendinitis: 'tendinitis', tendinitises: 'tendinitis', tendonitis: 'tendonitis', @@ -678,6 +696,7 @@ export const SINGULAR_EXCEPTIONS: Record = { terminus: 'terminus', tetanus: 'tetanus', thalamus: 'thalamus', + thallia: 'thallium', themselves: 'themselves', thermos: 'thermos', thermoses: 'thermos', @@ -686,6 +705,7 @@ export const SINGULAR_EXCEPTIONS: Record = { this: 'this', thises: 'this', thoraces: 'thoraces', + thoria: 'thorium', thous: 'thou', throes: 'throe', thromboses: 'thrombosis', @@ -695,6 +715,7 @@ export const SINGULAR_EXCEPTIONS: Record = { tiddlywinks: 'tiddlywinks', ties: 'tie', tiptoes: 'tiptoe', + titania: 'titanium', toes: 'toe', tonsillitis: 'tonsillitis', tonsillitises: 'tonsillitis', @@ -725,9 +746,10 @@ export const SINGULAR_EXCEPTIONS: Record = { undershorts: 'undershorts', undies: 'undies', unties: 'untie', - ups: 'ups', upstairs: 'upstairs', + urania: 'uranium', uterus: 'uterus', + vanadia: 'vanadium', veggies: 'veggie', verdigris: 'verdigris', verdigrises: 'verdigris', @@ -749,14 +771,314 @@ export const SINGULAR_EXCEPTIONS: Record = { willies: 'willies', wives: 'wife', wizes: 'wiz', - wizzes: 'wizzes', + wizzes: 'wiz', woes: 'woe', wolves: 'wolf', yes: 'yes', yeses: 'yes', yourselves: 'yourself', yous: 'you', - yrs: 'yrs', + yttria: 'yttrium', zebus: 'zebu', + zirconia: 'zirconium', zombies: 'zombie', }; + +/** + * Singular -> plural pairs that no generic suffix rule gets right. + * + * Pluralization is not rule-decidable either: `-o` takes "es" for "potatoes" + * but only "s" for "photos"; `-us` is "-uses" for "buses" but "-i" for "fungi"; + * `-um` is "-ums" for "forums" but "-a" for "data". The inflection library also + * holds many countable nouns uncountable (bias, atlas, aid), which for an + * identifier means the plural silently equals the singular — those are + * corrected here. + * + * Entries whose key is itself a plural (`biases: 'biases'`) exist so that + * pluralize is idempotent for the plurals the rules cannot re-derive. + * + * GENERATED — do not hand-edit. Regenerate with: + * + * sudo apt-get install -y wamerican + * npx ts-node scripts/generate-exceptions.ts + */ +export const PLURAL_EXCEPTIONS: Record = { + accommodation: 'accommodations', + accomplice: 'accomplices', + aegis: 'aegis', + aid: 'aids', + air: 'airs', + alcohol: 'alcohols', + alluvium: 'alluviums', + ambergris: 'ambergris', + anger: 'angers', + antihero: 'antiheroes', + appendicitis: 'appendicitis', + aquarium: 'aquariums', + arboretum: 'arboretums', + arteriosclerosis: 'arteriosclerosis', + arthritis: 'arthritis', + atherosclerosis: 'atherosclerosis', + atlas: 'atlases', + atrium: 'atriums', + auditorium: 'auditoriums', + avoirdupois: 'avoirdupois', + bias: 'biases', + biceps: 'biceps', + blitz: 'blitzes', + blood: 'bloods', + blouse: 'blouses', + bourgeois: 'bourgeois', + bread: 'breads', + bronchitis: 'bronchitis', + burnous: 'burnouses', + bursitis: 'bursitis', + butter: 'butters', + buzz: 'buzzes', + caduceus: 'caduceus', + callous: 'callouses', + cambium: 'cambiums', + carbon: 'carbons', + cargo: 'cargoes', + cash: 'cashes', + chairperson: 'chairpersons', + chalice: 'chalices', + chalk: 'chalks', + chamois: 'chamois', + chassis: 'chassis', + chateaus: 'chateaus', + cirrhosis: 'cirrhosis', + cis: 'cis', + colitis: 'colitis', + colloquium: 'colloquiums', + compendium: 'compendiums', + condominium: 'condominiums', + conjunctivitis: 'conjunctivitis', + cons: 'cons', + consortium: 'consortiums', + cosmos: 'cosmoses', + countryside: 'countrysides', + cranium: 'craniums', + crematorium: 'crematoriums', + deaf: 'deaves', + debris: 'debris', + deer: 'deers', + delirium: 'deliriums', + delphinium: 'delphiniums', + dermatitis: 'dermatitis', + dermis: 'dermis', + dictum: 'dictums', + digitalis: 'digitalis', + dignity: 'dignities', + dingo: 'dingoes', + dis: 'dis', + domino: 'dominoes', + dust: 'dusts', + echo: 'echoes', + education: 'educations', + egis: 'egis', + embargo: 'embargoes', + emporium: 'emporiums', + encephalitis: 'encephalitis', + enjoyment: 'enjoyments', + envy: 'envies', + epoch: 'epochs', + ersatz: 'ersatzes', + eunuch: 'eunuchs', + evidence: 'evidences', + factotum: 'factotums', + fez: 'fezes', + fiction: 'fictions', + fizz: 'fizzes', + flour: 'flours', + flyby: 'flybys', + food: 'foods', + forego: 'foregoes', + forgo: 'forgoes', + fracas: 'fracases', + frizz: 'frizzes', + fuel: 'fuels', + futz: 'futzes', + fuzz: 'fuzzes', + gallows: 'gallowses', + gas: 'gases', + gastritis: 'gastritis', + geranium: 'geraniums', + gingivitis: 'gingivitis', + gold: 'golds', + goodby: 'goodbys', + gossip: 'gossips', + gratis: 'gratis', + gymnasium: 'gymnasiums', + halitosis: 'halitosis', + harm: 'harms', + hatred: 'hatreds', + heat: 'heats', + help: 'helps', + hepatitis: 'hepatitis', + hero: 'heroes', + hertz: 'hertzes', + honey: 'honeys', + honorarium: 'honorariums', + hubris: 'hubris', + hunger: 'hungers', + hysteresis: 'hysteresis', + ice: 'ices', + intravenous: 'intravenouses', + iron: 'irons', + irony: 'ironies', + jam: 'jams', + jazz: 'jazzes', + jewelry: 'jewelries', + kibitz: 'kibitzes', + kilohertz: 'kilohertzes', + klutz: 'klutzes', + kohlrabi: 'kohlrabies', + lack: 'lacks', + laryngitis: 'laryngitis', + leather: 'leathers', + lens: 'lenses', + lightning: 'lightnings', + litter: 'litters', + luck: 'lucks', + macaroni: 'macaronies', + macaronis: 'macaronis', + madras: 'madrases', + magic: 'magics', + management: 'managements', + marble: 'marbles', + marquis: 'marquis', + matriarch: 'matriarchs', + media: 'medias', + megahertz: 'megahertzes', + meningitis: 'meningitis', + metamorphosis: 'metamorphosis', + milk: 'milks', + millennium: 'millenniums', + misdo: 'misdoes', + mitosis: 'mitosis', + monarch: 'monarchs', + mononucleosis: 'mononucleosis', + moratorium: 'moratoriums', + mosquito: 'mosquitoes', + motto: 'mottoes', + music: 'musics', + narcosis: 'narcosis', + nasturtium: 'nasturtiums', + nature: 'natures', + necrosis: 'necrosis', + nephritis: 'nephritis', + neuritis: 'neuritis', + nurture: 'nurtures', + oligarch: 'oligarchs', + osmosis: 'osmosis', + osteoporosis: 'osteoporosis', + outdo: 'outdoes', + outgo: 'outgoes', + overdo: 'overdoes', + pancreas: 'pancreases', + paramecium: 'parameciums', + parthenogenesis: 'parthenogenesis', + patois: 'patois', + patriarch: 'patriarchs', + people: 'peoples', + pericardium: 'pericardiums', + peritonitis: 'peritonitis', + phlebitis: 'phlebitis', + planetarium: 'planetariums', + podium: 'podiums', + police: 'polices', + poliomyelitis: 'poliomyelitis', + premium: 'premiums', + pride: 'prides', + prophylaxis: 'prophylaxis', + proscenium: 'prosceniums', + psoriasis: 'psoriasis', + psych: 'psychs', + psychokinesis: 'psychokinesis', + psychology: 'psychologies', + pumice: 'pumices', + quadriceps: 'quadricepses', + razz: 'razzes', + rectum: 'rectums', + redo: 'redoes', + reis: 'reis', + relaxation: 'relaxations', + rendezvous: 'rendezvouses', + research: 'researches', + respect: 'respects', + revenge: 'revenges', + rhinoceros: 'rhinoceroses', + rice: 'rices', + rubbish: 'rubbishes', + sad: 'sades', + safety: 'safeties', + sanatorium: 'sanatoriums', + sanctum: 'sanctums', + sanitarium: 'sanitariums', + sassafras: 'sassafrases', + scampi: 'scampies', + sclerosis: 'sclerosis', + scoliosis: 'scoliosis', + scrotum: 'scrotums', + seaside: 'seasides', + sepsis: 'sepsis', + septum: 'septums', + shucks: 'shucks', + silicosis: 'silicosis', + sinusitis: 'sinusitis', + sis: 'sis', + sleep: 'sleeps', + slice: 'slices', + smoke: 'smokes', + snow: 'snows', + soap: 'soaps', + soil: 'soils', + solarium: 'solariums', + splice: 'splices', + spritz: 'spritzes', + stadium: 'stadiums', + standby: 'standbys', + steam: 'steams', + stomach: 'stomachs', + stupidity: 'stupidities', + surplice: 'surplices', + symmetry: 'symmetries', + symposium: 'symposiums', + syphilis: 'syphilis', + teargas: 'teargases', + tech: 'techs', + telekinesis: 'telekinesis', + tendinitis: 'tendinitis', + tendonitis: 'tendonitis', + tennis: 'tennis', + terrarium: 'terrariums', + testis: 'testis', + thermos: 'thermoses', + thirst: 'thirsts', + this: 'this', + thunder: 'thunders', + timber: 'timbers', + tonsillitis: 'tonsillitis', + topaz: 'topazes', + tornado: 'tornadoes', + torpedo: 'torpedoes', + traffic: 'traffics', + triceps: 'tricepses', + triceratops: 'triceratopses', + triptych: 'triptychs', + trust: 'trusts', + tuberculosis: 'tuberculosis', + ultimatum: 'ultimatums', + undergo: 'undergoes', + undo: 'undoes', + unity: 'unities', + veto: 'vetoes', + volcano: 'volcanoes', + waltz: 'waltzes', + weather: 'weathers', + why: 'whys', + wiz: 'wizes', + yes: 'yeses', + zinc: 'zincs', +}; diff --git a/packages/inflekt/src/pluralize.ts b/packages/inflekt/src/pluralize.ts index 4ab17c4..b9f67c5 100644 --- a/packages/inflekt/src/pluralize.ts +++ b/packages/inflekt/src/pluralize.ts @@ -1,18 +1,17 @@ /** - * Pluralization utilities with PostGraphile-compatible Latin suffix handling + * Inflection with PostGraphile-compatible Latin suffix handling. * - * Uses the 'inflection' package with custom overrides for Latin plural suffixes - * that PostGraphile handles differently than standard English pluralization, - * plus a dictionary-proven exception table for the words where no suffix rule - * is right (see exceptions.ts). + * Both directions are the same shape: an exception table proven against a + * system dictionary (exceptions.ts) in front of the suffix rules (rules.ts), + * so dictionary words are right by construction and coined identifiers fall + * through to the rules. Both are idempotent — `singularize` of a singular and + * `pluralize` of a plural return the word unchanged. */ -import * as inflection from 'inflection'; - -import { SINGULAR_EXCEPTIONS } from './exceptions'; +import { PLURAL_EXCEPTIONS, SINGULAR_EXCEPTIONS } from './exceptions'; import { - enforceDoubleSPlural, + isRulesPlural, normalizeMalformedDoubleS, - pluralizeFStem, + pluralizeByRules, restoreWordCase, singularizeByRules, } from './rules'; @@ -37,12 +36,36 @@ function splitLastSegment( return { prefix: str.slice(0, index), word: str.slice(index) }; } -function lookupException(word: string): string | null { +function lookup( + table: Record, + word: string +): string | null { const key = word.toLowerCase(); - if (!Object.prototype.hasOwnProperty.call(SINGULAR_EXCEPTIONS, key)) { + if (!Object.prototype.hasOwnProperty.call(table, key)) { + return null; + } + return restoreWordCase(word, table[key]); +} + +/** + * Apply an exception table to a word, or to the final segment of a compound + * name ("user_cookies" -> "cookies") so compounds inflect like the word they + * end in. + */ +function applyTable( + table: Record, + word: string +): string | null { + const whole = lookup(table, word); + if (whole) { + return whole; + } + const segment = splitLastSegment(word); + if (!segment) { return null; } - return restoreWordCase(word, SINGULAR_EXCEPTIONS[key]); + const segmentException = lookup(table, segment.word); + return segmentException ? segment.prefix + segmentException : null; } /** @@ -52,55 +75,46 @@ function lookupException(word: string): string | null { export function singularize(word: string): string { const normalizedWord = normalizeMalformedDoubleS(word); - const exception = lookupException(normalizedWord); + const exception = applyTable(SINGULAR_EXCEPTIONS, normalizedWord); if (exception) { return exception; } - const segment = splitLastSegment(normalizedWord); - if (segment) { - const segmentException = lookupException(segment.word); - if (segmentException) { - return segment.prefix + segmentException; - } - } - return singularizeByRules(normalizedWord); } -const F_OR_FE_REGEX = /(?:f|fe)$/i; +/** + * Whether `word` is already a plural, and so must be returned unchanged. + * + * SINGULAR_EXCEPTIONS answers this for the dictionary (its keys are plurals, + * except the self-mapped singulars it carries to protect them from the rules), + * and the rules answer it for everything else, coined identifiers included. + */ +function isPlural(word: string): boolean { + const singular = applyTable(SINGULAR_EXCEPTIONS, word); + if (singular) { + return singular !== word; + } + return isRulesPlural(word); +} -function pluralizeCanonical(word: string): string { +/** + * Convert a word to its plural form. Already-plural input is returned as-is. + * @example "User" -> "Users", "Person" -> "People", "Users" -> "Users" + */ +export function pluralize(word: string): string { const normalizedWord = normalizeMalformedDoubleS(word); - // -f/-fe words: only the f-stem nouns take -ves; the rest take a plain "s". - const fStem = pluralizeFStem(normalizedWord); - if (fStem) { - return fStem; + if (isPlural(normalizedWord)) { + return normalizedWord; } - if (F_OR_FE_REGEX.test(normalizedWord)) { - return `${normalizedWord}s`; - } - - const pluralWord = normalizeMalformedDoubleS( - inflection.pluralize(normalizedWord) - ); - // -is nouns the inflection library leaves untouched (iris, chassis) rather - // than treating as Greek/Latin (analysis -> analyses). - if (pluralWord === normalizedWord && /is$/i.test(normalizedWord)) { - return `${normalizedWord}es`; + const exception = applyTable(PLURAL_EXCEPTIONS, normalizedWord); + if (exception) { + return exception; } - return enforceDoubleSPlural(singularize(normalizedWord), pluralWord); -} - -/** - * Convert a word to its plural form - * @example "User" -> "Users", "Person" -> "People" - */ -export function pluralize(word: string): string { - return pluralizeCanonical(word); + return pluralizeByRules(normalizedWord); } /** diff --git a/packages/inflekt/src/rules.ts b/packages/inflekt/src/rules.ts index 0004e83..1eb0c68 100644 --- a/packages/inflekt/src/rules.ts +++ b/packages/inflekt/src/rules.ts @@ -1,9 +1,9 @@ /** - * Rule-based singularization: the suffix rules alone, with no exception table. + * Rule-based inflection: the suffix rules alone, with no exception tables. * * Kept separate from pluralize.ts so that scripts/generate-exceptions.ts can - * diff the rules against a dictionary without the exception table masking the - * very defects it is meant to record. Not part of the public API. + * diff the rules against a dictionary without the exception tables masking the + * very defects they are meant to record. Not part of the public API. */ import * as inflection from 'inflection'; @@ -17,6 +17,8 @@ import * as inflection from 'inflection'; */ const LATIN_SUFFIX_OVERRIDES: Array<[string, string]> = [ ['schemata', 'schema'], + ['corpora', 'corpus'], + ['genera', 'genus'], ['criteria', 'criterion'], ['phenomena', 'phenomenon'], ['media', 'medium'], @@ -26,6 +28,37 @@ const LATIN_SUFFIX_OVERRIDES: Array<[string, string]> = [ ['data', 'datum'], ]; +/** + * The `-a` plurals of LATIN_SUFFIX_OVERRIDES, as a set: these are the only + * words the rules may read as an existing plural rather than pluralizing them. + * + * Everything else ending in "a" is treated as a singular that takes "-as" + * (deltas, betas, quotas, schemas, replicas). The inflection library assumes + * the opposite — that "-a" is Latin — and so leaves every one of them + * unchanged, which for an identifier means the plural silently equals the + * singular. + */ +const LATIN_A_PLURALS = LATIN_SUFFIX_OVERRIDES.map(([plural]) => plural); + +/** Whether `lowerWord` is (or ends in) one of the Latin `-a` plurals. */ +function isLatinAPlural(lowerWord: string): boolean { + return LATIN_A_PLURALS.some((plural) => lowerWord.endsWith(plural)); +} + +/** + * The Latin plurals to *produce*, the inverse of LATIN_SUFFIX_OVERRIDES minus + * "schema" (whose plural is "schemas" here, per PostGraphile). Matched as a + * suffix so compounds follow: metadatum -> metadata. + * + * Without this, pluralize and singularize disagree: "criteria" singularizes to + * "criterion", but "criterion" would pluralize to "criterions". + * + * Format: [singularSuffix, pluralSuffix] + */ +const LATIN_PLURAL_SUFFIXES: Array<[string, string]> = LATIN_SUFFIX_OVERRIDES + .filter(([plural]) => plural !== 'schemata') + .map(([plural, singular]) => [singular, plural]); + /** * Compound words ending in "base" that the inflection library incorrectly * singularizes via its (b)a branch in the ses$ rule (e.g. codebases -> codebasis). @@ -91,6 +124,15 @@ export const F_STEM_PLURALS: Array<[string, string]> = [ */ const ADJECTIVE_OUS_REGEX = /ous$/i; +/** + * Words ending in "a" are singular (delta, schema, replica, quota, aorta): the + * only English plurals spelled that way are the Latin ones in + * LATIN_SUFFIX_OVERRIDES. The inflection library reads the suffix as Latin + * regardless and rewrites it to "-um", coining "deltum" and "schemum", which + * then reads back as a plural and leaves pluralize with nothing to do. + */ +const A_SINGULAR_REGEX = /a$/i; + /** * The only -ice plurals. The inflection library's rule is written `([m|l])ice`, * whose character class also matches "|", and it fires on any word ending in @@ -215,6 +257,115 @@ function applyGenericSingularRules(word: string): string | null { * Singularize using suffix rules only — the fallback for words absent from the * exception table (including coined identifiers that no dictionary knows). */ +const US_REGEX = /us$/i; +const A_REGEX = /a$/i; +const UM_REGEX = /um$/i; +const F_OR_FE_REGEX = /(?:f|fe)$/i; + +/** + * Nouns ending in "is" that are not the Greek/Latin `-sis`/`-xis` family: their + * plural adds "es" (iris -> irises, chassis -> chassises). Only the Greek ones + * rewrite the "is" itself (analysis -> analyses, axis -> axes), which is the + * rule the inflection library applies to all of them, coining "chasses". + */ +const NON_GREEK_IS_REGEX = /(? radiuses (not radius) + * delta -> deltas (not delta) + * forum -> forums (not fora) + * + * The genuine Latin plurals are a closed set of dictionary words, carried by + * LATIN_SUFFIX_OVERRIDES and by the generated PLURAL_EXCEPTIONS table. + * + * Words the inflection library holds uncountable (sheep, series, aircraft) are + * left alone here: "no plural" is a dictionary fact like any other, and the + * ones it gets wrong (bias, atlas, aid) are corrected by PLURAL_EXCEPTIONS. + */ +export function pluralizeByRules(word: string): string { + const normalizedWord = normalizeMalformedDoubleS(word); + + // Adjectives have no plural, so leave them be rather than coining + // "anxiouses"; see ADJECTIVE_OUS_REGEX. + if (ADJECTIVE_OUS_REGEX.test(normalizedWord)) { + return normalizedWord; + } + + // -f/-fe words: only the f-stem nouns take -ves; the rest take a plain "s". + const fStem = pluralizeFStem(normalizedWord); + if (fStem) { + return fStem; + } + if (F_OR_FE_REGEX.test(normalizedWord)) { + return matchWordCase(normalizedWord, `${normalizedWord}s`); + } + + const lowerWord = normalizedWord.toLowerCase(); + for (const [singularSuffix, pluralSuffix] of LATIN_PLURAL_SUFFIXES) { + if (!lowerWord.endsWith(singularSuffix)) { + continue; + } + const stem = normalizedWord.slice(0, -singularSuffix.length); + const matched = normalizedWord.slice(-singularSuffix.length); + return stem + matchCase(matched, pluralSuffix); + } + + if (NON_GREEK_IS_REGEX.test(normalizedWord)) { + return matchWordCase(normalizedWord, `${normalizedWord}es`); + } + + const pluralWord = normalizeMalformedDoubleS( + inflection.pluralize(normalizedWord) + ); + + if (pluralWord === normalizedWord) { + // Latin-looking shapes it declines to pluralize, and the uncountable list + // it applies to countable nouns (bias, atlas, aid, air). + if (US_REGEX.test(normalizedWord)) { + return matchWordCase(normalizedWord, `${normalizedWord}es`); + } + if (UM_REGEX.test(normalizedWord)) { + return matchWordCase(normalizedWord, `${normalizedWord}s`); + } + if (A_REGEX.test(normalizedWord) && !isLatinAPlural(lowerWord)) { + return matchWordCase(normalizedWord, `${normalizedWord}s`); + } + } + + return matchWordCase( + normalizedWord, + enforceDoubleSPlural(singularizeByRules(normalizedWord), pluralWord) + ); +} + +/** + * Keep an all-caps word all-caps: the suffix rules and the inflection library + * both append a lowercase "s", turning API_KEY into API_KEYs. + */ +function matchWordCase(word: string, plural: string): string { + const isAllCaps = word === word.toUpperCase() && /[A-Z]/.test(word); + return isAllCaps ? plural.toUpperCase() : plural; +} + +/** + * Whether the rules already read `word` as a plural: it singularizes to + * something else, and that singular pluralizes back to exactly `word`. + * + * This is what keeps pluralize idempotent — `pluralize('apis')` must be `apis`, + * not `apises` — without a table of every plural in the language. + */ +export function isRulesPlural(word: string): boolean { + const singular = singularizeByRules(word); + return singular !== word && pluralizeByRules(singular) === word; +} + export function singularizeByRules(word: string): string { const normalizedWord = normalizeMalformedDoubleS(word); const lowerWord = normalizedWord.toLowerCase(); @@ -223,6 +374,10 @@ export function singularizeByRules(word: string): string { return normalizedWord; } + if (A_SINGULAR_REGEX.test(normalizedWord) && !isLatinAPlural(lowerWord)) { + return normalizedWord; + } + if (ICE_REGEX.test(normalizedWord)) { const mouse = Object.prototype.hasOwnProperty.call(MOUSE_PLURALS, lowerWord) ? MOUSE_PLURALS[lowerWord] From 8cff192e8c5ee7957bae6784fc9a86374d5ad9c9 Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Thu, 6 Aug 2026 03:02:27 +0000 Subject: [PATCH 2/2] fix(inflekt): match PostGraphile's inflector on -ex/-ix and uncountables PostGraphile v5 inflects with the pluralize package, not inflection: it returns indices and appendices, and treats deer/fish/moose as invariant. Follow it for both, while keeping the English plural for -us (radiuses, not radii). --- .../inflekt/__tests__/four-properties.test.ts | 26 +++++++++-------- .../inflekt/scripts/generate-exceptions.ts | 28 ++++++++++++++++--- packages/inflekt/src/exceptions.ts | 22 ++++++++++++++- 3 files changed, 59 insertions(+), 17 deletions(-) diff --git a/packages/inflekt/__tests__/four-properties.test.ts b/packages/inflekt/__tests__/four-properties.test.ts index e0323da..85cbef6 100644 --- a/packages/inflekt/__tests__/four-properties.test.ts +++ b/packages/inflekt/__tests__/four-properties.test.ts @@ -114,16 +114,18 @@ const ENGLISH: Array<[string, string]> = [ // -ch/-sh/-x/-z ['church', 'churches'], ['dish', 'dishes'], ['box', 'boxes'], ['quiz', 'quizzes'], ['buzz', 'buzzes'], ['waltz', 'waltzes'], - // Latin and Greek. -ex/-ix follows the inflection library exactly, because - // PostGraphile names its fields with it (indices, vertices, matrices, but - // appendixes and mutexes). Elsewhere the English plural wins wherever the - // dictionary attests one (alumnuses, aquariums), and the Latin plural is kept - // only where English has none (curricula, memoranda, genera). - ['appendix', 'appendixes'], ['index', 'indices'], ['alumnus', 'alumnuses'], + // Latin and Greek. -ex/-ix takes the Latin plural wherever the dictionary + // attests one, which is also what PostGraphile's inflector (the `pluralize` + // package) does: indices, appendices, vertices, matrices. Elsewhere the + // English plural wins wherever the dictionary attests one (alumnuses, + // aquariums) — deliberately unlike `pluralize`, which coins "radii" and + // "cacti" for identifiers — and the Latin plural is kept only where English + // has none (curricula, memoranda, genera). + ['appendix', 'appendices'], ['index', 'indices'], ['alumnus', 'alumnuses'], ['curriculum', 'curricula'], ['memorandum', 'memoranda'], ['aquarium', 'aquariums'], ['genus', 'genera'], // -o: "es" for the nouns that take it, "s" for the rest - ['veto', 'vetoes'], ['fish', 'fishes'], + ['veto', 'vetoes'], // -ice, which the inflection library reads as -ouse ['police', 'polices'], ['service', 'services'], ['chalice', 'chalices'], // compounds where the last word is irregular @@ -133,7 +135,7 @@ const ENGLISH: Array<[string, string]> = [ /** Words with no distinct plural: both numbers are the same word. */ const INVARIANT = [ 'sheep', 'series', 'species', 'aircraft', 'news', 'equipment', - 'information', 'software', 'chassis', + 'information', 'software', 'chassis', 'deer', 'fish', 'moose', 'salmon', ]; describe.each([ @@ -191,9 +193,9 @@ describe('inherently ambiguous', () => { expect(singularize('dns')).toBe('dn'); }); - it('follows the dictionary even where the dictionary is odd', () => { - // wamerican lists "deers", so that is the plural we emit, though "deer" is - // the one most people write. - expect(pluralize('deer')).toBe('deers'); + it('reads a word that is also a plural as the word', () => { + // wamerican lists "cons" as a word in its own right (with the plural + // "conses"), so it is left alone rather than read as the plural of "con". + expect(singularize('cons')).toBe('cons'); }); }); diff --git a/packages/inflekt/scripts/generate-exceptions.ts b/packages/inflekt/scripts/generate-exceptions.ts index 4e8eef9..d47b7d7 100644 --- a/packages/inflekt/scripts/generate-exceptions.ts +++ b/packages/inflekt/scripts/generate-exceptions.ts @@ -73,6 +73,16 @@ const OES_NOUNS = new Set([ 'torpedo', 'veto', 'volcano', ]); +/** + * Nouns whose plural is the same word. The dictionary lists "deers" and + * "fishes", but a schema means the invariant plural, which is also what the + * `pluralize` package PostGraphile uses returns. + */ +const UNCOUNTABLE = new Set([ + 'aircraft', 'bison', 'deer', 'elk', 'fish', 'grouse', 'moose', 'offspring', + 'salmon', 'sheep', 'shrimp', 'squid', 'swine', 'trout', +]); + /** Plural-looking words that are not plurals (or are uncountable). */ const NOT_PLURALS = new Set([ 'acoustics', 'aerobics', 'athletics', 'crossroads', 'economics', 'ethics', @@ -242,6 +252,7 @@ for (const singular of [...new Set(exceptions.values())]) { const attested = new Map>(); for (const word of words) { if (!word.endsWith('s') || word.endsWith('ss') || NOT_PLURALS.has(word)) continue; + if (UNCOUNTABLE.has(word.slice(0, -1))) continue; const singular = expectedSingular(word); if (!singular || singular === word) continue; const plurals = attested.get(singular) ?? new Set(); @@ -254,6 +265,15 @@ function preferredPlural(singular: string, plurals: Set): string { if (OES_NOUNS.has(singular) && plurals.has(`${singular}es`)) { return `${singular}es`; } + // -ex/-ix takes the Latin "-ices" wherever the dictionary attests it, so the + // family is self-consistent (indices, appendices, vertices, matrices) and + // agrees with the `pluralize` package PostGraphile's own inflector uses. + // Coined identifiers (mutex, regex) have no attested plural and fall through + // to the rules, which give "mutexes". + const ices = `${singular.slice(0, -2)}ices`; + if (/[ei]x$/.test(singular) && plurals.has(ices)) { + return ices; + } return [...plurals].sort((a, b) => { if (rank(a) !== rank(b)) return rank(a) - rank(b); // Neither is common enough to rank: keep whichever the rules already @@ -273,10 +293,6 @@ function preferredPlural(singular: string, plurals: Set): string { * alternative ("criterions"). */ function rulesPluralIsAttested(singular: string): boolean { - // -ex/-ix nouns keep the library's Latin plural (index -> indices, appendix -> - // appendices), even where the dictionary also lists "indexes": PostGraphile - // names its own fields that way, and inflekt exists to agree with it. - if (/[ei]x$/.test(singular)) return true; const plural = pluralize(singular); return ( plural !== singular && @@ -323,6 +339,10 @@ for (const word of words) { if (singularizeByRules(rulesPlural) !== word) pluralExceptions.set(word, word); } +for (const word of UNCOUNTABLE) { + if (pluralize(word) !== word) pluralExceptions.set(word, word); +} + for (const plurals of attested.values()) { for (const plural of plurals) { if (!tableAwarePluralIsStable(plural)) pluralExceptions.set(plural, plural); diff --git a/packages/inflekt/src/exceptions.ts b/packages/inflekt/src/exceptions.ts index 3d174e9..1036f0e 100644 --- a/packages/inflekt/src/exceptions.ts +++ b/packages/inflekt/src/exceptions.ts @@ -813,7 +813,9 @@ export const PLURAL_EXCEPTIONS: Record = { ambergris: 'ambergris', anger: 'angers', antihero: 'antiheroes', + apex: 'apices', appendicitis: 'appendicitis', + appendix: 'appendices', aquarium: 'aquariums', arboretum: 'arboretums', arteriosclerosis: 'arteriosclerosis', @@ -822,9 +824,11 @@ export const PLURAL_EXCEPTIONS: Record = { atlas: 'atlases', atrium: 'atriums', auditorium: 'auditoriums', + aviatrix: 'aviatrices', avoirdupois: 'avoirdupois', bias: 'biases', biceps: 'biceps', + bison: 'bison', blitz: 'blitzes', blood: 'bloods', blouse: 'blouses', @@ -841,6 +845,7 @@ export const PLURAL_EXCEPTIONS: Record = { carbon: 'carbons', cargo: 'cargoes', cash: 'cashes', + cervix: 'cervices', chairperson: 'chairpersons', chalice: 'chalices', chalk: 'chalks', @@ -849,6 +854,7 @@ export const PLURAL_EXCEPTIONS: Record = { chateaus: 'chateaus', cirrhosis: 'cirrhosis', cis: 'cis', + codex: 'codices', colitis: 'colitis', colloquium: 'colloquiums', compendium: 'compendiums', @@ -856,13 +862,13 @@ export const PLURAL_EXCEPTIONS: Record = { conjunctivitis: 'conjunctivitis', cons: 'cons', consortium: 'consortiums', + cortex: 'cortices', cosmos: 'cosmoses', countryside: 'countrysides', cranium: 'craniums', crematorium: 'crematoriums', deaf: 'deaves', debris: 'debris', - deer: 'deers', delirium: 'deliriums', delphinium: 'delphiniums', dermatitis: 'dermatitis', @@ -877,6 +883,7 @@ export const PLURAL_EXCEPTIONS: Record = { echo: 'echoes', education: 'educations', egis: 'egis', + elk: 'elk', embargo: 'embargoes', emporium: 'emporiums', encephalitis: 'encephalitis', @@ -886,9 +893,11 @@ export const PLURAL_EXCEPTIONS: Record = { ersatz: 'ersatzes', eunuch: 'eunuchs', evidence: 'evidences', + executrix: 'executrices', factotum: 'factotums', fez: 'fezes', fiction: 'fictions', + fish: 'fish', fizz: 'fizzes', flour: 'flours', flyby: 'flybys', @@ -909,11 +918,13 @@ export const PLURAL_EXCEPTIONS: Record = { goodby: 'goodbys', gossip: 'gossips', gratis: 'gratis', + grouse: 'grouse', gymnasium: 'gymnasiums', halitosis: 'halitosis', harm: 'harms', hatred: 'hatreds', heat: 'heats', + helix: 'helices', help: 'helps', hepatitis: 'hepatitis', hero: 'heroes', @@ -923,6 +934,7 @@ export const PLURAL_EXCEPTIONS: Record = { hubris: 'hubris', hunger: 'hungers', hysteresis: 'hysteresis', + ibex: 'ibices', ice: 'ices', intravenous: 'intravenouses', iron: 'irons', @@ -959,6 +971,7 @@ export const PLURAL_EXCEPTIONS: Record = { mitosis: 'mitosis', monarch: 'monarchs', mononucleosis: 'mononucleosis', + moose: 'moose', moratorium: 'moratoriums', mosquito: 'mosquitoes', motto: 'mottoes', @@ -970,6 +983,7 @@ export const PLURAL_EXCEPTIONS: Record = { nephritis: 'nephritis', neuritis: 'neuritis', nurture: 'nurtures', + offspring: 'offspring', oligarch: 'oligarchs', osmosis: 'osmosis', osteoporosis: 'osteoporosis', @@ -1013,6 +1027,7 @@ export const PLURAL_EXCEPTIONS: Record = { rubbish: 'rubbishes', sad: 'sades', safety: 'safeties', + salmon: 'salmon', sanatorium: 'sanatoriums', sanctum: 'sanctums', sanitarium: 'sanitariums', @@ -1024,6 +1039,7 @@ export const PLURAL_EXCEPTIONS: Record = { seaside: 'seasides', sepsis: 'sepsis', septum: 'septums', + shrimp: 'shrimp', shucks: 'shucks', silicosis: 'silicosis', sinusitis: 'sinusitis', @@ -1037,12 +1053,14 @@ export const PLURAL_EXCEPTIONS: Record = { solarium: 'solariums', splice: 'splices', spritz: 'spritzes', + squid: 'squid', stadium: 'stadiums', standby: 'standbys', steam: 'steams', stomach: 'stomachs', stupidity: 'stupidities', surplice: 'surplices', + swine: 'swine', symmetry: 'symmetries', symposium: 'symposiums', syphilis: 'syphilis', @@ -1067,6 +1085,7 @@ export const PLURAL_EXCEPTIONS: Record = { triceps: 'tricepses', triceratops: 'triceratopses', triptych: 'triptychs', + trout: 'trout', trust: 'trusts', tuberculosis: 'tuberculosis', ultimatum: 'ultimatums', @@ -1075,6 +1094,7 @@ export const PLURAL_EXCEPTIONS: Record = { unity: 'unities', veto: 'vetoes', volcano: 'volcanoes', + vortex: 'vortices', waltz: 'waltzes', weather: 'weathers', why: 'whys',