Skip to content

Instantly share code, notes, and snippets.

@devlato
Created April 25, 2024 13:11
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save devlato/ced9f5d803f9d4baeb7f6561d7036c40 to your computer and use it in GitHub Desktop.
Save devlato/ced9f5d803f9d4baeb7f6561d7036c40 to your computer and use it in GitHub Desktop.
Pluralizer/string formatter
export const format = (format: string, ...args: Serializable[]): string => {
args.forEach((arg, i) => {
assert(
typeof arg === 'string' ||
(typeof arg === 'number' && !isNaN(arg)) ||
(Array.isArray(arg) && arg.every((v) => typeof v === 'string' || !isNaN(v))),
`The argument #${i + 1} must be a valid number, a string, or an array of valid numbers or strings, got a ${typeof arg === 'number' ? 'NaN' : typeof arg} instead (${String(arg)})`,
);
});
let number = -1;
return format.replace(/(?<!\\){(.+?)(?<!\\)}/g, (match) => {
number++;
let matched = match;
const numMatch = matched.match(/^{(\d+?)}$/);
if (numMatch != null) {
const index = parseInt(numMatch[1], 10);
assert(
!isNaN(index) && typeof args[index] !== 'undefined',
`Invalid replacement format: ${matched}. Argument #${number} is missing.`,
);
return stringifyArg(args[index]);
}
if (matched.includes(';')) {
let index = number;
const pipeMatch = matched.match(/^{(\d+?)\|.+?}$/);
if (pipeMatch != null) {
index = parseInt(pipeMatch[1], 10);
matched = pipeMatch[0].replace(/^{(\d+)\|/gi, '{');
}
assert(!isNaN(index), `Argument #${index} must be a number, got ${typeof index} instead: ${index}`);
const arg = args[index];
assert(typeof arg === 'number', `Argument #${index} must be a number, got ${typeof arg} instead: ${arg}`);
const replacements = matched
.replace(/^{/, '')
.replace(/}$/, '')
.split(/(?<!\\);/gi)
.filter(exists)
.map((s) => s.trim())
.map<[number | 'default', string]>((r) => {
const parts = r.split('=');
assert(
parts.length === 2 && typeof parts[1] === 'string',
`Invalid replacement format: ${r}. Expected 'amount=replacement' format. Got '${r}' instead.`,
);
const amount = parts[0] === 'default' ? 'default' : parseInt(parts[0], 10);
assert(
amount === 'default' || !isNaN(amount),
`Invalid replacement format: ${r}. Expected a number or 'default'. Got '${parts[0]}' instead.`,
);
return [typeof amount === 'string' ? ('default' as const) : amount, parts[1]];
})
.sort(([a0, _a1], [b0, _b1]) => {
if (a0 === 'default') {
return 1;
}
if (b0 === 'default') {
return -1;
}
return b0 - a0;
});
const replacement = unwrap(
replacements.find(([amount]) => {
if (amount === 'default') {
return true;
}
return arg >= amount;
}),
`Invalid replacement format: ${matched}`,
);
if (replacement[1].includes('$')) {
return replacement[1].replace('$', stringifyArg(arg));
}
return replacement[1];
}
throw new AssertionError(`Invalid replacement format: ${matched}. Expected a number or a list of replacements.`);
});
};
const stringifyArg = (arg: Serializable): string => {
switch (typeof arg) {
case 'string':
return arg;
case 'number':
return String(arg);
case 'object':
assert(Array.isArray(arg), 'Argument must be an array');
return arg.map((v) => (typeof v === 'string' ? `'${v}'` : String(v))).join(', ');
}
};
export const unwrap = <T>(value: T | null | undefined, message?: string): T => {
if (!exists(value)) {
throw new AssertionError(message ?? `Value is ${value}`);
}
return value;
};
export const exists = <T>(value: T | null | undefined): value is T => value != null;
export function assert(condition: boolean, message?: string): asserts condition {
if (!condition) {
throw new AssertionError(message ?? 'Assertion failed');
}
}
export class AssertionError extends Error {
constructor(message?: string) {
super(message ?? 'Precondition failed');
this.name = 'PreconditionError';
}
}
type Serializable = number[] | string[] | number | string;
// Tests
const test = (expectedResult: string, fmt: string, ...args: unknown[]) => {
console.log('\n' + '-'.repeat(20) + '\n');
try {
const result = format(fmt, ...args);
if (result === expectedResult) {
console.log(`✅ '${fmt}' -> '${expectedResult}'`);
} else {
console.log(`❌ '${fmt}' -> '${result}' (expected '${expectedResult}')`);
}
} catch (e) {
if (e instanceof AssertionError) {
console.log(`❌ '${fmt}' (expected '${expectedResult}')`);
console.log(e.stack);
} else {
throw e;
}
}
};
test('10 cats', '{0} cats', 10);
test('10 \\{0\\} cats', '{0} ' + '\\' + '{0' + '\\' + '} cats', 10);
test('10 \\{0\\} cats', '{0} \\{0\\} cats', 10);
test('10 cats were jumping on the grass', '{0} cats were {1} on the grass', 10, 'jumping');
test('10 cats were jumping on the grass', '{1} cats were {0} on the grass', 'jumping', 10);
test('10 cats were jumping on the grass', '{0=no cats;1=cat;2=$ cats} were {1} on the grass', 10, 'jumping');
test('no cats were jumping on the grass', '{1=cat;2=cats;default=no cats} were {1} on the grass', 0, 'jumping');
test('no cats were jumping on the grass', '{1=cat;default=no cats;2=$ cats} were {1} on the grass', 0, 'jumping');
test('20 cats were jumping on the grass', '{1=cat;default=no cats;2=$ cats} were {1} on the grass', 20, 'jumping');
test('10 cats were jumping on the grass', '{0|0=no cats;1=cat;2=$ cats} were {1} on the grass', 10, 'jumping');
test('no cats were jumping on the grass', '{0|1=cat;2=cats;default=no cats} were {1} on the grass', 0, 'jumping');
test('no cats were jumping on the grass', '{0|1=cat;default=no cats;2=$ cats} were {1} on the grass', 0, 'jumping');
test('20 cats were jumping on the grass', '{0|1=cat;default=no cats;2=$ cats} were {1} on the grass', 20, 'jumping');
test('10 cats were jumping on the grass', '{1|0=no cats;1=cat;2=$ cats} were {0} on the grass', 'jumping', 10);
test('no cats were jumping on the grass', '{1|1=cat;2=cats;default=no cats} were {0} on the grass', 'jumping', 0);
test('no cats were jumping on the grass', '{1|1=cat;default=no cats;2=$ cats} were {0} on the grass', 'jumping', 0);
test('20 cats were jumping on the grass', '{1|1=cat;default=no cats;2=$ cats} were {0} on the grass', 'jumping', 20);
test('0 cats were jumping on the grass', '{1|1=cat\\;default=no cats}', 'jumping', 0);
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment