mirror of
				https://codeberg.org/yeentown/barkey.git
				synced 2025-11-03 23:14:13 +00:00 
			
		
		
		
	lint Vue templates as well
the argument detection doesn't work inside templates when invoked via the `<I18n>` component, because it's too complicated for me now
This commit is contained in:
		
							parent
							
								
									f11536c927
								
							
						
					
					
						commit
						b0bc24f01b
					
				
					 2 changed files with 168 additions and 120 deletions
				
			
		
							
								
								
									
										241
									
								
								eslint/locale.js
									
										
									
									
									
								
							
							
						
						
									
										241
									
								
								eslint/locale.js
									
										
									
									
									
								
							| 
						 | 
				
			
			@ -50,6 +50,15 @@ function findCallExpression(node) {
 | 
			
		|||
	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';
 | 
			
		||||
| 
						 | 
				
			
			@ -82,117 +91,141 @@ function setDifference(a,b) {
 | 
			
		|||
 | 
			
		||||
/* 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];
 | 
			
		||||
	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 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;
 | 
			
		||||
 | 
			
		||||
			// some more checks on how the translation is called
 | 
			
		||||
			if (method == 'ts') {
 | 
			
		||||
				if (translation.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 (!translation.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;
 | 
			
		||||
				}
 | 
			
		||||
 | 
			
		||||
				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(' ')}`,
 | 
			
		||||
					});
 | 
			
		||||
				}
 | 
			
		||||
			}
 | 
			
		||||
	// 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 = {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -3,31 +3,46 @@ const localeRule = require("./locale");
 | 
			
		|||
 | 
			
		||||
const locale = { foo: { bar: 'ok', baz: 'good {x}' }, top: '123' };
 | 
			
		||||
 | 
			
		||||
const ruleTester = new RuleTester();
 | 
			
		||||
const ruleTester = new RuleTester({
 | 
			
		||||
  languageOptions: {
 | 
			
		||||
    parser: require('vue-eslint-parser'),
 | 
			
		||||
    ecmaVersion: 2015,
 | 
			
		||||
  },
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
function testCase(code,errors) {
 | 
			
		||||
  return { code, errors, options: [ locale ], filename: 'test.ts' };
 | 
			
		||||
}
 | 
			
		||||
function testCaseVue(code,errors) {
 | 
			
		||||
  return { code, errors, options: [ locale ], filename: 'test.vue' };
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
ruleTester.run(
 | 
			
		||||
  'sharkey-locale',
 | 
			
		||||
  localeRule,
 | 
			
		||||
  {
 | 
			
		||||
    valid: [
 | 
			
		||||
      {code: 'i18n.ts.foo.bar', options: [locale] },
 | 
			
		||||
      testCase('i18n.ts.foo.bar'),
 | 
			
		||||
      // we don't detect the problem here, but should still accept it
 | 
			
		||||
      {code: 'i18n.ts.foo["something"]', options: [locale] },
 | 
			
		||||
      {code: 'i18n.ts.top', options: [locale] },
 | 
			
		||||
      {code: 'i18n.tsx.foo.baz({x:1})', options: [locale] },
 | 
			
		||||
      {code: 'whatever.i18n.ts.blah.blah', options: [locale] },
 | 
			
		||||
      {code: 'whatever.i18n.tsx.does.not.matter', options: [locale] },
 | 
			
		||||
      {code: 'whatever(i18n.ts.foo.bar)', options: [locale] },
 | 
			
		||||
      testCase('i18n.ts.foo["something"]'),
 | 
			
		||||
      testCase('i18n.ts.top'),
 | 
			
		||||
      testCase('i18n.tsx.foo.baz({x:1})'),
 | 
			
		||||
      testCase('whatever.i18n.ts.blah.blah'),
 | 
			
		||||
      testCase('whatever.i18n.tsx.does.not.matter'),
 | 
			
		||||
      testCase('whatever(i18n.ts.foo.bar)'),
 | 
			
		||||
      testCaseVue('<template><p>{{ i18n.ts.foo.bar }}</p></template>'),
 | 
			
		||||
      testCaseVue('<template><I18n :src="i18n.ts.foo.baz"/></template>'),
 | 
			
		||||
    ],
 | 
			
		||||
    invalid: [
 | 
			
		||||
      {code: 'i18n.ts.not', options: [locale], errors: 1 },
 | 
			
		||||
      {code: 'i18n.tsx.deep.not', options: [locale], errors: 1 },
 | 
			
		||||
      {code: 'i18n.tsx.deep.not({x:12})', options: [locale], errors: 1 },
 | 
			
		||||
      {code: 'i18n.tsx.top({x:1})', options: [locale], errors: 1 },
 | 
			
		||||
      {code: 'i18n.ts.foo.baz', options: [locale], errors: 1 },
 | 
			
		||||
      {code: 'i18n.tsx.foo.baz', options: [locale], errors: 1 },
 | 
			
		||||
      {code: 'i18n.tsx.foo.baz({y:2})', options: [locale], errors: 2 },
 | 
			
		||||
      testCase('i18n.ts.not', 1),
 | 
			
		||||
      testCase('i18n.tsx.deep.not', 1),
 | 
			
		||||
      testCase('i18n.tsx.deep.not({x:12})', 1),
 | 
			
		||||
      testCase('i18n.tsx.top({x:1})', 1),
 | 
			
		||||
      testCase('i18n.ts.foo.baz', 1),
 | 
			
		||||
      testCase('i18n.tsx.foo.baz', 1),
 | 
			
		||||
      testCase('i18n.tsx.foo.baz({y:2})', 2),
 | 
			
		||||
      testCaseVue('<template><p>{{ i18n.ts.not }}</p></template>', 1),
 | 
			
		||||
      testCaseVue('<template><I18n :src="i18n.ts.not"/></template>', 1),
 | 
			
		||||
    ],
 | 
			
		||||
  },
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
		Loading…
	
	Add table
		
		Reference in a new issue