Skip to content

Commit

Permalink
Updated custom eslint i18n rule/package to lint useEuiI18n usages
Browse files Browse the repository at this point in the history
  • Loading branch information
chandlerprall committed Jul 16, 2020
1 parent fb79af3 commit 0eeefba
Show file tree
Hide file tree
Showing 2 changed files with 302 additions and 6 deletions.
198 changes: 194 additions & 4 deletions scripts/eslint-plugin/i18n.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@ function attributesArrayToLookup(attributesArray) {
}

function getDefinedValues(valuesNode) {
if (valuesNode == null || valuesNode.expression.properties == null) return new Set();
return valuesNode.expression.properties.reduce(
if (valuesNode == null || valuesNode.properties == null) return new Set();
return valuesNode.properties.reduce(
(valueNames, property) => {
valueNames.add(property.key.name);
return valueNames;
Expand Down Expand Up @@ -223,7 +223,9 @@ module.exports = {
}

// validate default string interpolation matches values
const valueNames = getDefinedValues(attributes.values);
const valueNames = getDefinedValues(
attributes.values && attributes.values.expression
);

if (attributes.default.type === 'Literal') {
// default is a string literal
Expand Down Expand Up @@ -319,7 +321,195 @@ module.exports = {
}

// debugger;
}
},
CallExpression(node) {
// Only process calls to useEuiI18n
if (
!node.callee ||
node.callee.type !== 'Identifier' ||
node.callee.name !== 'useEuiI18n'
)
return;

const arguments = node.arguments;

const isSingleToken = arguments[0].type === 'Literal';

// validate argument types
if (isSingleToken) {
// default must be either a Literal of an ArrowFunctionExpression
const defaultArg = arguments[1];
const isLiteral = defaultArg.type === 'Literal';
const isArrowExpression =
defaultArg.type === 'ArrowFunctionExpression';
if (!isLiteral && !isArrowExpression) {
context.report({
node,
loc: defaultArg.loc,
messageId: 'invalidDefaultType',
data: { type: defaultArg.type },
});
return;
}
} else {
const tokensArg = arguments[0];
const defaultsArg = arguments[1];

// tokens must be an array of Literals
if (tokensArg.type !== 'ArrayExpression') {
context.report({
node,
loc: tokensArg.loc,
messageId: 'invalidTokensType',
data: { type: tokensArg.type },
});
return;
}

for (let i = 0; i < tokensArg.elements.length; i++) {
const tokenNode = tokensArg.elements[i];
if (
tokenNode.type !== 'Literal' ||
typeof tokenNode.value !== 'string'
) {
context.report({
node,
loc: tokenNode.loc,
messageId: 'invalidTokensType',
data: { type: tokenNode.type }
});
return;
}
}

// defaults must be an array of either Literals or ArrowFunctionExpressions
if (defaultsArg.type !== 'ArrayExpression') {
context.report({
node,
loc: defaultsArg.loc,
messageId: 'invalidDefaultsType',
data: { type: defaultsArg.type }
});
return;
}

for (let i = 0; i < defaultsArg.elements.length; i++) {
const defaultNode = defaultsArg.elements[i];
if (
defaultNode.type !== 'Literal' ||
typeof defaultNode.value !== 'string'
) {
context.report({
node,
loc: defaultNode.loc,
messageId: 'invalidDefaultsType',
data: { type: defaultNode.type }
});
return;
}
}
}

if (isSingleToken) {
const tokenArgument = arguments[0];
const defaultArgument = arguments[1];
const valuesArgument = arguments[2];

// validate token format
const tokenParts = tokenArgument.value.split('.');
if (
tokenParts.length <= 1 ||
tokenParts[0] !== expectedTokenNamespace
) {
context.report({
node,
loc: tokenArgument.loc,
messageId: 'invalidToken',
data: {
tokenValue: tokenArgument.value,
tokenNamespace: expectedTokenNamespace,
},
});
}

// validate default string interpolation matches values
const valueNames = getDefinedValues(valuesArgument);

if (defaultArgument.type === 'Literal') {
// default is a string literal
const expectedNames = getExpectedValueNames(defaultArgument.value);
if (areSetsEqual(expectedNames, valueNames) === false) {
context.report({
node,
loc: valuesArgument.loc,
messageId: 'mismatchedValues',
data: {
expected: formatSet(expectedNames),
provided: formatSet(valueNames),
},
});
}
} else {
// default is a function
// validate the destructured param defined by default function match the values
const defaultFn = defaultArgument;
const objProperties =
defaultFn.params && defaultFn.params[0]
? defaultFn.params[0].properties
: [];
const expectedNames = new Set(
objProperties.map(property => property.key.name)
);
if (areSetsEqual(valueNames, expectedNames) === false) {
context.report({
node,
loc: valuesArgument.loc,
messageId: 'mismatchedValues',
data: {
expected: formatSet(expectedNames),
provided: formatSet(valueNames),
},
});
}
}
} else {
// has multiple tokens
const tokensArgument = arguments[0];
const defaultsArgument = arguments[1];

// validate their names
const tokens = tokensArgument.elements;
for (let i = 0; i < tokens.length; i++) {
const token = tokens[i];
const tokenParts = token.value.split('.');
if (
tokenParts.length <= 1 ||
tokenParts[0] !== expectedTokenNamespace
) {
context.report({
node,
loc: token.loc,
messageId: 'invalidToken',
data: { tokenValue: token.value, tokenNamespace: expectedTokenNamespace }
});
}
}

// validate the number of tokens equals the number of defaults
const defaults = defaultsArgument.elements;
if (tokens.length !== defaults.length) {
context.report({
node,
loc: node.loc,
messageId: 'mismatchedTokensAndDefaults',
data: {
tokenLength: tokens.length,
defaultsLength: defaults.length,
},
});
}
}
},
// callback functions
};
}
Expand Down
110 changes: 108 additions & 2 deletions scripts/eslint-plugin/i18n.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,13 @@ const rule = require('./i18n');
const RuleTester = require('eslint').RuleTester;

const ruleTester = new RuleTester({
parser: 'babel-eslint'
parser: require.resolve('babel-eslint')
});

const valid = [
/** EuiI18n **/
// nothing to validate against
'<EuiI18n token="euiFooBar.tokenName" default="Some default value"/>',
'<I18n token="euiFooBar.tokenName" default="Some default value"/>',

// values agree with default string
`<EuiI18n token="euiFooBar.tokenName" default="{value}, {value2}" values={{ value: 'Hello', value2: 'World' }}/>`,
Expand All @@ -30,8 +31,22 @@ const valid = [

// default callback params match values
`<EuiI18n token="euiFooBar.token" values={{ name: 'John' }} default={({ name }) => name}/>`,

/** useEuiI18n **/
// nothing to validate against
`useI18n('euiFooBar.tokenName', 'Some default value')`,

// values agree with default string
`useEuiI18n('euiFooBar.tokenName', '{value}, {value2}', { value: 'Hello', value2: 'World' })`,

// valid tokens
`useEuiI18n(['euiFooBar.token1', 'euiFooBar.token2'], ['value1', 'value 2'])`,

// default callback params match values
`useEuiI18n('euiFooBar.token', ({ name }) => name, { name: 'John' })`,
];
const invalid = [
/** EuiI18n **/
// token doesn't match file name
{
code: '<EuiI18n token="euiFooeyBar.tokenName" default="Some default value"/>',
Expand Down Expand Up @@ -157,6 +172,97 @@ const invalid = [
code: `<EuiI18n tokens={['euiFooBar.token']} defaults={[5]}/>`,
errors: [{ messageId: 'invalidDefaultsType', data: { type: 'Literal' } }]
},

// /** useEuiI18n **/
// token doesn't match file name
{
code: `useEuiI18n('euiFooeyBar.tokenName', 'Some default value')`,
errors: [{ messageId: 'invalidToken', data: { tokenValue: 'euiFooeyBar.tokenName', tokenNamespace: 'euiFooBar' } }]
},

// token doesn't have at least two parts
{
code: `useEuiI18n('euiFooBar', 'Some default value')`,
errors: [{ messageId: 'invalidToken', data: { tokenValue: 'euiFooBar', tokenNamespace: 'euiFooBar' } }]
},
{
code: `useEuiI18n('tokenName', 'Some default value')`,
errors: [{ messageId: 'invalidToken', data: { tokenValue: 'tokenName', tokenNamespace: 'euiFooBar' } }]
},

// invalid tokens
{
code: `useEuiI18n(['euiFooBar.token1', 'token2'], ['value1', 'value 2'])`,
errors: [{ messageId: 'invalidToken', data: { tokenValue: 'token2', tokenNamespace: 'euiFooBar' } }]
},
{
code: `useEuiI18n(['euiFooeyBar.token1', 'euiFooBar.token2'], ['value1', 'value 2'])`,
errors: [{ messageId: 'invalidToken', data: { tokenValue: 'euiFooeyBar.token1', tokenNamespace: 'euiFooBar' } }]
},
{
code: `useEuiI18n(['euiFooBar.token1'], ['value1', 'value 2'])`,
errors: [{ messageId: 'mismatchedTokensAndDefaults', data: { tokenLength: 1, defaultsLength: 2 } }]
},

// values not in agreement with default string
{
code: `useEuiI18n('euiFooBar.tokenName', '{value}, {value2}', { valuee: 'Hello', value2: 'World' })`,
errors: [{
messageId: 'mismatchedValues',
data: {
expected: 'value, value2',
provided: 'value2, valuee'
}
}]
},
{
code: `useEuiI18n('euiFooBar.tokenName', '{valuee}, {value2}', { value: 'Hello', value2: 'World' })`,
errors: [{
messageId: 'mismatchedValues',
data: {
expected: 'value2, valuee',
provided: 'value, value2'
}
}]
},

// default callback params don't match values
{
code: `useEuiI18n('euiFooBar.token', ({ name }) => name, { nare: 'John' })`,
errors: [{
messageId: 'mismatchedValues',
data: {
expected: 'name',
provided: 'nare'
}
}]
},

// invalid attribute types
{
code: `useEuiI18n('euiFooBar.token', ['value'])`,
errors: [{ messageId: 'invalidDefaultType', data: { type: 'ArrayExpression' } }]
},
{
code: `useEuiI18n(5, ['value'])`,
errors: [{ messageId: 'invalidDefaultType', data: { type: 'ArrayExpression' } }]
},
{
code: `useEuiI18n([5], ['value'])`,
errors: [{ messageId: 'invalidTokensType', data: { type: 'Literal' } }]
},
{
code: `useEuiI18n(['euiFooBar.token'], 'value')`,
errors: [{ messageId: 'invalidDefaultsType', data: { type: 'Literal' } }]
},
{
code: `useEuiI18n(['euiFooBar.token'], 5)`,
errors: [{ messageId: 'invalidDefaultsType', data: { type: 'Literal' } }]
},
{
code: `useEuiI18n(['euiFooBar.token'], [5])`,
errors: [{ messageId: 'invalidDefaultsType', data: { type: 'Literal' } }]
},
];

function withFilename(ruleset) {
Expand Down

0 comments on commit 0eeefba

Please sign in to comment.