diff --git a/eslint/locale.js b/eslint/locale.js new file mode 100644 index 0000000000..dbb807b714 --- /dev/null +++ b/eslint/locale.js @@ -0,0 +1,251 @@ +/* + * SPDX-FileCopyrightText: dakkar and other Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only +*/ + +/* 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 []; + // this is something like `foo[bar]` + if (node.computed) 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 || !path[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.parent) return null; + + // the second half of this guard protects from cases like + // `foo(one.two.three)` where the CallExpression is parent of the + // MemberExpressions, but via `arguments`, not `callee` + if (node.parent.type === 'CallExpression' && node.parent.callee === node) return node.parent; + if (node.parent.type === 'MemberExpression') return findCallExpression(node.parent); + return null; +} + +// same, but for Vue expressions (``) +function findVueExpression(node) { + if (!node.parent) return null; + + if (node.parent.type.match(/^VExpr/) && node.parent.expression === node) return node.parent; + if (node.parent.type === 'MemberExpression') return findVueExpression(node.parent); + return null; +} + +function areArgumentsOneObject(node) { + return node.arguments.length === 1 && + node.arguments[0].type === 'ObjectExpression'; +} + +// only call if `areArgumentsOneObject(node)` is true +function getArgumentObjectProperties(node) { + return new Set(node.arguments[0].properties.map( + p => { + if (p.key && p.key.type === 'Identifier') return p.key.name; + return null; + }, + )); +} + +function getTranslationParameters(translation) { + return new Set(Array.from(translation.matchAll(/\{(\w+)\}/g)).map( m => m[1] )); +} + +function setDifference(a,b) { + const result = []; + for (const element of a.values()) { + if (!b.has(element)) { + result.push(element); + } + } + + return result; +} + +/* the actual rule body + */ +function theRuleBody(context,node) { + // 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]; + + // 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 translation = walkDown(locale, path); + if (!translation) { + context.report({ + node, + message: `translation missing for ${pathStr}`, + }); + return; + } + + // we hit something weird, assume the programmers know what + // they're doing (this is usually some complicated slicing of + // the translation structure) + if (typeof(translation) !== 'string') return; + + const callExpression = findCallExpression(node); + const vueExpression = findVueExpression(node); + + // some more checks on how the translation is called + if (method === 'ts') { + // the ` component gets parametric translations via + // `i18n.ts.*`, but we error out elsewhere + if (translation.match(/\{/) && !vueExpression) { + context.report({ + node, + message: `translation for ${pathStr} is parametric, but called via 'ts'`, + }); + return; + } + + if (callExpression) { + context.report({ + node, + message: `translation for ${pathStr} is not parametric, but is called as a function`, + }); + } + } + + if (method === 'tsx') { + if (!translation.match(/\{/)) { + context.report({ + node, + message: `translation for ${pathStr} is not parametric, but called via 'tsx'`, + }); + return; + } + + if (!callExpression && !vueExpression) { + context.report({ + node, + message: `translation for ${pathStr} is parametric, but not called as a function`, + }); + return; + } + + // we're not currently checking arguments when used via the + // `` component, because it's too complicated (also, it + // would have to be done inside the `if (method === 'ts')`) + if (!callExpression) return; + + if (!areArgumentsOneObject(callExpression)) { + context.report({ + node, + message: `translation for ${pathStr} should be called with a single object as argument`, + }); + return; + } + + const translationParameters = getTranslationParameters(translation); + const parameterCount = translationParameters.size; + const callArguments = getArgumentObjectProperties(callExpression); + const argumentCount = callArguments.size; + + if (parameterCount !== argumentCount) { + context.report({ + node, + message: `translation for ${pathStr} has ${parameterCount} parameters, but is called with ${argumentCount} arguments`, + }); + } + + // node 20 doesn't have `Set.difference`... + const extraArguments = setDifference(callArguments, translationParameters); + const missingArguments = setDifference(translationParameters, callArguments); + + if (extraArguments.length > 0) { + context.report({ + node, + message: `translation for ${pathStr} passes unused arguments ${extraArguments.join(' ')}`, + }); + } + + if (missingArguments.length > 0) { + context.report({ + node, + message: `translation for ${pathStr} does not pass arguments ${missingArguments.join(' ')}`, + }); + } + } +} + +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]; + + // for all object member access that have an identifier 'i18n'... + return context.getSourceCode().parserServices.defineTemplateBodyVisitor( + { + // this is for