// @ts-check // ***************************************************************************** // Copyright (C) 2021 TypeFox and others. // // This program and the accompanying materials are made available under the // terms of the Eclipse Public License v. 2.0 which is available at // http://www.eclipse.org/legal/epl-2.0. // // This Source Code may also be made available under the following Secondary // Licenses when the conditions for such availability set forth in the Eclipse // Public License v. 2.0 are satisfied: GNU General Public License, version 2 // with the GNU Classpath Exception which is available at // https://www.gnu.org/software/classpath/license.html. // // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 // ***************************************************************************** const levenshtein = require('js-levenshtein'); // eslint-disable-next-line import/no-extraneous-dependencies const metadata = require('@theia/core/src/common/i18n/nls.metadata.json'); const messages = new Set(Object.values(metadata.messages) .reduceRight((prev, curr) => prev.concat(curr), []) .map(e => e.replace(/&&/g, ''))); /** @type {import('eslint').Rule.RuleModule} */ module.exports = { meta: { type: 'problem', fixable: 'code', docs: { description: 'prevent incorrect use of \'nls.localize\'.', }, }, create(context) { return { CallExpression(node) { const callee = node.callee; if (callee.type === 'Super') { return; } const localizeResults = evaluateLocalize(node); for (const { value, byDefault, node: localizeNode } of localizeResults) { if (value !== undefined && localizeNode) { if (byDefault && !messages.has(value)) { let lowestDistance = Number.MAX_VALUE; let lowestMessage = ''; for (const message of messages) { const distance = levenshtein(value, message); if (distance < lowestDistance) { lowestDistance = distance; lowestMessage = message; } } if (lowestMessage) { const replacementValue = `'${lowestMessage.replace(/'/g, "\\'").replace(/\n/g, '\\n')}'`; context.report({ node: localizeNode, message: `'${value}' is not a valid default value. Did you mean ${replacementValue}?`, fix: function (fixer) { return fixer.replaceText(localizeNode, replacementValue); } }); } else { context.report({ node: localizeNode, message: `'${value}' is not a valid default value.` }); } } else if (!byDefault && messages.has(value)) { context.report({ node, message: `'${value}' can be translated using the 'nls.localizeByDefault' function.`, fix: function (fixer) { const code = context.getSourceCode(); const args = node.arguments.slice(1); const argsCode = args.map(e => code.getText(e)).join(', '); const updatedCall = `nls.localizeByDefault(${argsCode})`; return fixer.replaceText(node, updatedCall); } }); } } } } }; /** * Evaluates a call expression and returns localization info. * @param {import('estree').CallExpression} node * @returns {Array<{value?: string, byDefault: boolean, node?: import('estree').Node}>} */ function evaluateLocalize(/** @type {import('estree').CallExpression} */ node) { const callee = node.callee; if ('object' in callee && 'name' in callee.object && 'property' in callee && 'name' in callee.property && callee.object.name === 'nls') { if (callee.property.name === 'localize') { const defaultTextNode = node.arguments[1]; // The default text node is the second argument for `nls.localize` if (defaultTextNode && defaultTextNode.type === 'Literal' && typeof defaultTextNode.value === 'string') { return [{ node: defaultTextNode, value: defaultTextNode.value, byDefault: false }]; } } else if (callee.property.name === 'localizeByDefault') { const defaultTextNode = node.arguments[0]; // The default text node is the first argument for `nls.localizeByDefault` if (defaultTextNode && defaultTextNode.type === 'Literal' && typeof defaultTextNode.value === 'string') { return [{ node: defaultTextNode, value: defaultTextNode.value, byDefault: true }]; } } } // Check for Command.toDefaultLocalizedCommand if ('object' in callee && 'name' in callee.object && 'property' in callee && 'name' in callee.property && callee.object.name === 'Command' && callee.property.name === 'toDefaultLocalizedCommand') { const commandArg = node.arguments[0]; if (commandArg && commandArg.type === 'ObjectExpression') { return extractDefaultLocalizedProperties(commandArg); } } return []; } /** * Extracts label and category properties from a Command object that will be passed to localizeByDefault. * @param {import('estree').ObjectExpression} objectNode * @returns {Array<{value?: string, byDefault: boolean, node?: import('estree').Node}>} */ function extractDefaultLocalizedProperties(objectNode) { const results = []; for (const property of objectNode.properties) { if (property.type === 'Property' && property.key.type === 'Identifier') { const keyName = property.key.name; if ((keyName === 'label' || keyName === 'category') && property.value.type === 'Literal' && typeof property.value.value === 'string') { results.push({ node: property.value, value: property.value.value, byDefault: true }); } } } return results; } } };