eslint-plugin-formatjs
Version:
ESLint plugin for formatjs
192 lines (191 loc) • 7.76 kB
JavaScript
import { parse, TYPE, } from '@formatjs/icu-messageformat-parser';
import MagicString from 'magic-string';
import { getParserServices } from '../context-compat.js';
import { extractMessages, getSettings, patchMessage } from '../util.js';
function verifyAst(context, messageNode, ast) {
const patches = [];
_verifyAst(ast);
if (patches.length > 0) {
const patchedMessage = patchMessage(messageNode, ast, content => {
return patches
.reduce((magicString, patch) => {
switch (patch.type) {
case 'prependLeft':
return magicString.prependLeft(patch.index, patch.content);
case 'remove':
return magicString.remove(patch.start, patch.end);
case 'update':
return magicString.update(patch.start, patch.end, patch.content);
}
}, new MagicString(content))
.toString();
});
context.report({
node: messageNode,
messageId: 'preferPoundInPlurals',
fix: patchedMessage !== null
? fixer => fixer.replaceText(messageNode, patchedMessage)
: null,
});
}
function _verifyAst(ast) {
for (let i = 0; i < ast.length; i++) {
const current = ast[i];
switch (current.type) {
case TYPE.argument:
case TYPE.number: {
// Applicable to only plain argument or number argument without any style
if (current.type === TYPE.number && current.style) {
break;
}
const next = ast[i + 1];
const nextNext = ast[i + 2];
if (next &&
nextNext &&
next.type === TYPE.literal &&
next.value === ' ' &&
nextNext.type === TYPE.plural &&
nextNext.value === current.value) {
// `{A} {A, plural, one {B} other {Bs}}` => `{A, plural, one {# B} other {# Bs}}`
_removeRangeAndPrependPluralClauses(current.location.start.offset, next.location.end.offset, nextNext, '# ');
}
else if (next &&
next.type === TYPE.plural &&
next.value === current.value) {
// `{A}{A, plural, one {B} other {Bs}}` => `{A, plural, one {#B} other {#Bs}}`
_removeRangeAndPrependPluralClauses(current.location.start.offset, current.location.end.offset, next, '#');
}
break;
}
case TYPE.plural: {
// `{A, plural, one {{A} B} other {{A} Bs}}` => `{A, plural, one {# B} other {# Bs}}`
const name = current.value;
for (const { value } of Object.values(current.options)) {
_replacementArgumentWithPound(name, value);
}
break;
}
case TYPE.select: {
for (const { value } of Object.values(current.options)) {
_verifyAst(value);
}
break;
}
case TYPE.tag:
_verifyAst(current.children);
break;
default:
break;
}
}
}
// Replace plain argument of number argument w/o style option that matches
// the name with a pound sign.
function _replacementArgumentWithPound(name, ast) {
for (const element of ast) {
switch (element.type) {
case TYPE.argument:
case TYPE.number: {
if (element.value === name &&
// Either plain argument or number argument without any style
(element.type !== TYPE.number || !element.style)) {
patches.push({
type: 'update',
start: element.location.start.offset,
end: element.location.end.offset,
content: '#',
});
}
break;
}
case TYPE.tag: {
_replacementArgumentWithPound(name, element.children);
break;
}
case TYPE.select: {
for (const { value } of Object.values(element.options)) {
_replacementArgumentWithPound(name, value);
}
break;
}
default:
break;
}
}
}
// Helper to remove a certain text range and then prepend the specified text to
// each plural clause.
function _removeRangeAndPrependPluralClauses(rangeToRemoveStart, rangeToRemoveEnd, pluralElement, prependText) {
// Delete both the `{A}` and ` `
patches.push({
type: 'remove',
start: rangeToRemoveStart,
end: rangeToRemoveEnd,
});
// Insert `# ` to the beginning of every option clause
for (const { location } of Object.values(pluralElement.options)) {
// location marks the entire clause with the surrounding braces
patches.push({
type: 'prependLeft',
index: location.start.offset + 1,
content: prependText,
});
}
}
}
function checkNode(context, node) {
const msgs = extractMessages(node, getSettings(context));
for (const [{ message: { defaultMessage }, messageNode, },] of msgs) {
if (!defaultMessage || !messageNode) {
continue;
}
let ast;
try {
ast = parse(defaultMessage, { captureLocation: true });
}
catch (e) {
context.report({
node: messageNode,
messageId: 'parseError',
data: { message: e instanceof Error ? e.message : String(e) },
});
return;
}
verifyAst(context, messageNode, ast);
}
}
export const name = 'prefer-pound-in-plural';
export const rule = {
meta: {
type: 'suggestion',
docs: {
description: 'Prefer using # to reference the count in the plural argument.',
url: 'https://formatjs.github.io/docs/tooling/linter#prefer-pound-in-plurals',
},
messages: {
preferPoundInPlurals: 'Prefer using # to reference the count in the plural argument instead of repeating the argument.',
parseError: '{{message}}',
},
fixable: 'code',
schema: [],
},
defaultOptions: [],
// TODO: Vue support
create(context) {
const callExpressionVisitor = (node) => checkNode(context, node);
const parserServices = getParserServices(context);
//@ts-expect-error defineTemplateBodyVisitor exists in Vue parser
if (parserServices?.defineTemplateBodyVisitor) {
//@ts-expect-error
return parserServices.defineTemplateBodyVisitor({
CallExpression: callExpressionVisitor,
}, {
CallExpression: callExpressionVisitor,
});
}
return {
JSXOpeningElement: (node) => checkNode(context, node),
CallExpression: callExpressionVisitor,
};
},
};