mirror of
				https://codeberg.org/yeentown/barkey.git
				synced 2025-11-04 07:24:13 +00:00 
			
		
		
		
	
		
			
				
	
	
		
			251 lines
		
	
	
	
		
			7.4 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
			
		
		
	
	
			251 lines
		
	
	
	
		
			7.4 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
/*
 | 
						|
 * 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 (`<I18n :src="i18n.ts.foo">`)
 | 
						|
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 `<I18n> 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
 | 
						|
		// `<I18n>` 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 <template> bits, needs work
 | 
						|
			'MemberExpression:has(Identifier[name=i18n])': (node) => theRuleBody(context, node),
 | 
						|
		},
 | 
						|
		{
 | 
						|
			// this is for normal code
 | 
						|
			'MemberExpression:has(Identifier[name=i18n])': (node) => theRuleBody(context, node),
 | 
						|
		},
 | 
						|
	);
 | 
						|
}
 | 
						|
 | 
						|
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,
 | 
						|
};
 |