hippofish/eslint/locale.js
2024-10-22 12:02:23 +01:00

145 lines
4.2 KiB
JavaScript

/* This is a ESLint rule to report use of the `i18n.ts` and `i18n.tsx`
* objects that reference translation items that don't actually exist
* in the lexicon (the `locale/` files)
*/
/* given a MemberExpression node, collects all the member names
*
* e.g. for a bit of code like `foo=one.two.three`, `collectMembers`
* called on the node for `three` would return `['one', 'two',
* 'three']`
*/
function collectMembers(node) {
if (!node) return [];
if (node.type !== 'MemberExpression') return [];
return [ node.property.name, ...collectMembers(node.parent) ];
}
/* given an object and an array of names, recursively descends the
* object via those names
*
* e.g. `walkDown({one:{two:{three:15}}},['one','two','three'])` would
* return 15
*/
function walkDown(locale, path) {
if (!locale) return null;
if (!path || path.length === 0) return locale;
return walkDown(locale[path[0]], path.slice(1));
}
/* given a MemberExpression node, returns its attached CallExpression
* node if present
*
* e.g. for a bit of code like `foo=one.two.three()`,
* `findCallExpression` called on the node for `three` would return
* the node for function call (which is the parent of the `one` and
* `two` nodes, and holds the nodes for the argument list)
*
* if the code had been `foo=one.two.three`, `findCallExpression`
* would have returned null, because there's no function call attached
* to the MemberExpressions
*/
function findCallExpression(node) {
if (node.type === 'CallExpression') return node
if (node.parent?.type === 'CallExpression') return node.parent;
if (node.parent?.type === 'MemberExpression') return findCallExpression(node.parent);
return null;
}
/* the actual rule body
*/
function theRule(context) {
// we get the locale/translations via the options; it's the data
// that goes into a specific language's JSON file, see
// `scripts/build-assets.mjs`
const locale = context.options[0];
return {
// for all object member access that have an identifier 'i18n'...
'MemberExpression:has(> Identifier[name=i18n])': (node) => {
// sometimes we get MemberExpression nodes that have a
// *descendent* with the right identifier: skip them, we'll get
// the right ones as well
if (node.object?.name != 'i18n') {
return;
}
// `method` is going to be `'ts'` or `'tsx'`, `path` is going to
// be the various translation steps/names
const [ method, ...path ] = collectMembers(node);
const pathStr = `i18n.${method}.${path.join('.')}`;
// does that path point to a real translation?
const matchingNode = walkDown(locale, path);
if (!matchingNode) {
context.report({
node,
message: `translation missing for ${pathStr}`,
});
return;
}
// some more checks on how the translation is called
if (method == 'ts') {
if (matchingNode.match(/\{/)) {
context.report({
node,
message: `translation for ${pathStr} is parametric, but called via 'ts'`,
});
return;
}
if (findCallExpression(node)) {
context.report({
node,
message: `translation for ${pathStr} is not parametric, but is called as a function`,
});
}
}
if (method == 'tsx') {
if (!matchingNode.match(/\{/)) {
context.report({
node,
message: `translation for ${pathStr} is not parametric, but called via 'tsx'`,
});
return;
}
const callExpression = findCallExpression(node);
if (!callExpression) {
context.report({
node,
message: `translation for ${pathStr} is parametric, but not called as a function`,
});
return;
}
const parameterCount = [...matchingNode.matchAll(/\{/g)].length ?? 0;
const argumentCount = callExpression.arguments.length;
if (parameterCount !== argumentCount) {
context.report({
node,
message: `translation for ${pathStr} has ${parameterCount} parameters, but is called with ${argumentCount} arguments`,
});
return;
}
}
},
};
}
module.exports = {
meta: {
type: 'problem',
docs: {
description: 'assert that all translations used are present in the locale files',
},
schema: [
// here we declare that we need the locale/translation as a
// generic object
{ type: 'object', additionalProperties: true },
],
},
create: theRule,
};