diff --git a/docs/rules/README.md b/docs/rules/README.md index 16ca3874..bd462e96 100644 --- a/docs/rules/README.md +++ b/docs/rules/README.md @@ -16,6 +16,7 @@ | Rule ID | Description | | |:--------|:------------|:---| +| [@intlify/vue-i18n/no-duplicate-keys-in-locale](./no-duplicate-keys-in-locale.html) | disallow duplicate localization keys within the same locale | | | [@intlify/vue-i18n/no-dynamic-keys](./no-dynamic-keys.html) | disallow localization dynamic keys at localization methods | | | [@intlify/vue-i18n/no-unused-keys](./no-unused-keys.html) | disallow unused localization keys | :black_nib: | diff --git a/docs/rules/no-duplicate-keys-in-locale.md b/docs/rules/no-duplicate-keys-in-locale.md new file mode 100644 index 00000000..a308a89b --- /dev/null +++ b/docs/rules/no-duplicate-keys-in-locale.md @@ -0,0 +1,68 @@ +# @intlify/vue-i18n/no-duplicate-keys-in-locale + +> disallow duplicate localization keys within the same locale + +If you manage localization messages in multiple files, duplicate localization keys across multiple files can cause unexpected problems. + +## :book: Rule Details + +This rule reports duplicate localization keys within the same locale. + +:-1: Examples of **incorrect** code for this rule: + +locale messages: + +- `en.1.json` + +```json5 +// ✗ BAD +{ + "hello": "Hello! DIO!", // duplicate. + "hello": "Hello! DIO!", // duplicate. + "good-bye": "Good bye! DIO!" +} +``` + +- `en.2.json` + +```json5 +// ✗ BAD +{ + "good-bye": "Good bye! DIO!" // This same key exists in `en.1.json`. +} +``` + +:+1: Examples of **correct** code for this rule: + +locale messages: + +- `en.1.json` + +```json5 +// ✓ GOOD +{ + "hello": "Hello! DIO!", + "hi": "Hi! DIO!" +} +``` + +- `en.2.json` + +```json5 +// ✓ GOOD +{ + "good-bye": "Good bye! DIO!" // This same key exists in `en.1.json`. +} +``` + +## :gear: Options + +```json +{ + "@intlify/vue-i18n/no-duplicate-keys-in-locale": ["error", { + "ignoreI18nBlock": false + }] +} +``` + +- `ignoreI18nBlock`: If `true`, do not report key duplication between `` blocks and other files, it set to `false` as default. diff --git a/lib/rules.ts b/lib/rules.ts index 85eed308..7771ae90 100644 --- a/lib/rules.ts +++ b/lib/rules.ts @@ -1,4 +1,5 @@ /** DON'T EDIT THIS FILE; was created by scripts. */ +import noDuplicateKeysInLocale from './rules/no-duplicate-keys-in-locale' import noDynamicKeys from './rules/no-dynamic-keys' import noHtmlMessages from './rules/no-html-messages' import noMissingKeys from './rules/no-missing-keys' @@ -7,6 +8,7 @@ import noUnusedKeys from './rules/no-unused-keys' import noVHtml from './rules/no-v-html' export = { + 'no-duplicate-keys-in-locale': noDuplicateKeysInLocale, 'no-dynamic-keys': noDynamicKeys, 'no-html-messages': noHtmlMessages, 'no-missing-keys': noMissingKeys, diff --git a/lib/rules/no-duplicate-keys-in-locale.ts b/lib/rules/no-duplicate-keys-in-locale.ts new file mode 100644 index 00000000..ebae3625 --- /dev/null +++ b/lib/rules/no-duplicate-keys-in-locale.ts @@ -0,0 +1,436 @@ +/** + * @author Yosuke Ota + */ +import type { AST as VAST } from 'vue-eslint-parser' +import type { AST as JSONAST } from 'eslint-plugin-jsonc' +import type { AST as YAMLAST } from 'yaml-eslint-parser' +import { extname } from 'path' +import { getLocaleMessages } from '../utils/index' +import debugBuilder from 'debug' +import type { LocaleMessage } from '../utils/locale-messages' +import type { + I18nLocaleMessageDictionary, + RuleContext, + RuleListener, + SourceCode +} from '../types' +import { joinPath } from '../utils/key-path' +const debug = debugBuilder('eslint-plugin-vue-i18n:no-duplicate-keys-in-locale') + +interface DictData { + dict: I18nLocaleMessageDictionary + source: LocaleMessage +} + +interface PathStack { + otherDictionaries: DictData[] + keyPath: string + locale: string | null + node?: JSONAST.JSONNode | YAMLAST.YAMLNode + upper?: PathStack +} + +function getMessageFilepath(fullPath: string) { + if (fullPath.startsWith(process.cwd())) { + return fullPath.replace(process.cwd(), '.') + } + return fullPath +} + +function create(context: RuleContext): RuleListener { + const filename = context.getFilename() + const options = (context.options && context.options[0]) || {} + const ignoreI18nBlock = Boolean(options.ignoreI18nBlock) + + /** + * Create node visitor + */ + function createVisitor( + targetLocaleMessage: LocaleMessage, + otherLocaleMessages: LocaleMessage[], + { + skipNode, + resolveKey, + resolveReportNode + }: { + skipNode: (node: N) => boolean + resolveKey: (node: N) => string | number | null + resolveReportNode: (node: N) => N + } + ) { + let pathStack: PathStack + if (targetLocaleMessage.localeKey === 'file') { + const locale = targetLocaleMessage.locales[0] + pathStack = { + keyPath: '', + locale, + otherDictionaries: otherLocaleMessages.map(lm => { + return { + dict: lm.getMessagesFromLocale(locale), + source: lm + } + }) + } + } else { + pathStack = { + keyPath: '', + locale: null, + otherDictionaries: [] + } + } + const existsKeyNodes: { + [locale: string]: { [key: string]: { node: N; reported: boolean }[] } + } = {} + const existsLocaleNodes: { + [key: string]: { node: N; reported: boolean }[] + } = {} + return { + enterNode(node: N) { + if (skipNode(node)) { + return + } + + const key = resolveKey(node) + if (key == null) { + return + } + if (pathStack.locale == null) { + // locale is resolved + const locale = key as string + verifyDupeKey(existsLocaleNodes, locale, node) + pathStack = { + upper: pathStack, + node, + keyPath: '', + locale, + otherDictionaries: otherLocaleMessages.map(lm => { + return { + dict: lm.getMessagesFromLocale(locale), + source: lm + } + }) + } + return + } + const keyOtherValues = pathStack.otherDictionaries + .filter(dict => dict.dict[key] != null) + .map(dict => { + return { + value: dict.dict[key], + source: dict.source + } + }) + const keyPath = joinPath(pathStack.keyPath, key) + const nextOtherDictionaries: DictData[] = [] + for (const value of keyOtherValues) { + if (typeof value.value === 'string') { + const reportNode = resolveReportNode(node) + context.report({ + message: `duplicate key '${keyPath}' in '${ + pathStack.locale + }'. "${getMessageFilepath( + value.source.fullpath + )}" has the same key`, + loc: reportNode.loc + }) + } else { + nextOtherDictionaries.push({ + dict: value.value, + source: value.source + }) + } + } + + verifyDupeKey( + existsKeyNodes[pathStack.locale] || + (existsKeyNodes[pathStack.locale] = {}), + keyPath, + node + ) + + pathStack = { + upper: pathStack, + node, + keyPath, + locale: pathStack.locale, + otherDictionaries: nextOtherDictionaries + } + }, + leaveNode(node: N) { + if (pathStack.node === node) { + pathStack = pathStack.upper! + } + } + } + + function verifyDupeKey( + exists: { + [key: string]: { node: N; reported: boolean }[] + }, + key: string, + node: N + ) { + const keyNodes = exists[key] || (exists[key] = []) + keyNodes.push({ + node, + reported: false + }) + if (keyNodes.length > 1) { + for (const keyNode of keyNodes.filter(e => !e.reported)) { + const reportNode = resolveReportNode(keyNode.node) + context.report({ + message: `duplicate key '${key}'`, + loc: reportNode.loc + }) + keyNode.reported = true + } + } + } + } + /** + * Create node visitor for JSON + */ + function createVisitorForJson( + _sourceCode: SourceCode, + targetLocaleMessage: LocaleMessage, + otherLocaleMessages: LocaleMessage[] + ) { + return createVisitor( + targetLocaleMessage, + otherLocaleMessages, + { + skipNode(node) { + if ( + node.type === 'Program' || + node.type === 'JSONExpressionStatement' || + node.type === 'JSONProperty' + ) { + return true + } + const parent = node.parent! + if (parent.type === 'JSONProperty' && parent.key === node) { + return true + } + return false + }, + resolveKey(node) { + const parent = node.parent! + if (parent.type === 'JSONProperty') { + return parent.key.type === 'JSONLiteral' + ? `${parent.key.value}` + : parent.key.name + } else if (parent.type === 'JSONArrayExpression') { + return parent.elements.indexOf(node as never) + } + return null + }, + + resolveReportNode(node) { + const parent = node.parent! + return parent.type === 'JSONProperty' ? parent.key : node + } + } + ) + } + + /** + * Create node visitor for YAML + */ + function createVisitorForYaml( + sourceCode: SourceCode, + targetLocaleMessage: LocaleMessage, + otherLocaleMessages: LocaleMessage[] + ) { + const yamlKeyNodes = new Set() + return createVisitor( + targetLocaleMessage, + otherLocaleMessages, + { + skipNode(node) { + if ( + node.type === 'Program' || + node.type === 'YAMLDocument' || + node.type === 'YAMLDirective' || + node.type === 'YAMLAnchor' || + node.type === 'YAMLTag' + ) { + return true + } + + if (node.type === 'YAMLPair') { + yamlKeyNodes.add(node.key) + return true + } + if (yamlKeyNodes.has(node)) { + // within key node + return true + } + const parent = node.parent + if (yamlKeyNodes.has(parent)) { + // within key node + yamlKeyNodes.add(node) + return true + } + return false + }, + resolveKey(node) { + const parent = node.parent! + if (parent.type === 'YAMLPair' && parent.key) { + const key = + parent.key.type !== 'YAMLScalar' + ? sourceCode.getText(parent.key) + : parent.key.value + return typeof key === 'boolean' || key === null ? String(key) : key + } else if (parent.type === 'YAMLSequence') { + return parent.entries.indexOf(node as never) + } + + return null + }, + resolveReportNode(node) { + const parent = node.parent! + return parent.type === 'YAMLPair' ? parent.key || parent : node + } + } + ) + } + + if (extname(filename) === '.vue') { + return { + Program() { + const documentFragment = + context.parserServices.getDocumentFragment && + context.parserServices.getDocumentFragment() + /** @type {VElement[]} */ + const i18nBlocks = + (documentFragment && + documentFragment.children.filter( + (node): node is VAST.VElement => + node.type === 'VElement' && node.name === 'i18n' + )) || + [] + if (!i18nBlocks.length) { + return + } + const localeMessages = getLocaleMessages(context) + + for (const block of i18nBlocks) { + if ( + block.startTag.attributes.some( + attr => !attr.directive && attr.key.name === 'src' + ) + ) { + continue + } + + const targetLocaleMessage = localeMessages.findBlockLocaleMessage( + block + ) + if (!targetLocaleMessage) { + continue + } + const sourceCode = targetLocaleMessage.getSourceCode() + if (!sourceCode) { + continue + } + + const otherLocaleMessages: LocaleMessage[] = ignoreI18nBlock + ? [] + : localeMessages.localeMessages.filter( + lm => lm !== targetLocaleMessage + ) + const parserLang = targetLocaleMessage.getParserLang() + + let visitor + if (parserLang === 'json') { + visitor = createVisitorForJson( + sourceCode, + targetLocaleMessage, + otherLocaleMessages + ) + } else if (parserLang === 'yaml') { + visitor = createVisitorForYaml( + sourceCode, + targetLocaleMessage, + otherLocaleMessages + ) + } + + if (visitor == null) { + return + } + + targetLocaleMessage.traverseNodes({ + enterNode: visitor.enterNode, + leaveNode: visitor.leaveNode + }) + } + } + } + } else if (context.parserServices.isJSON || context.parserServices.isYAML) { + const localeMessages = getLocaleMessages(context) + const targetLocaleMessage = localeMessages.findExistLocaleMessage(filename) + if (!targetLocaleMessage) { + debug(`ignore ${filename} in no-duplicate-keys-in-locale`) + return {} + } + + const sourceCode = context.getSourceCode() + const otherLocaleMessages: LocaleMessage[] = localeMessages.localeMessages.filter( + lm => lm !== targetLocaleMessage + ) + + if (context.parserServices.isJSON) { + const { enterNode, leaveNode } = createVisitorForJson( + sourceCode, + targetLocaleMessage, + otherLocaleMessages + ) + + return { + '[type=/^JSON/]': enterNode, + '[type=/^JSON/]:exit': leaveNode + } + } else if (context.parserServices.isYAML) { + const { enterNode, leaveNode } = createVisitorForYaml( + sourceCode, + targetLocaleMessage, + otherLocaleMessages + ) + + return { + '[type=/^YAML/]': enterNode, + '[type=/^YAML/]:exit': leaveNode + } + } + return {} + } else { + debug(`ignore ${filename} in no-duplicate-keys-in-locale`) + return {} + } +} + +export = { + meta: { + type: 'problem', + docs: { + description: + 'disallow duplicate localization keys within the same locale', + category: 'Best Practices', + recommended: false + }, + fixable: null, + schema: [ + { + type: 'object', + properties: { + ignoreI18nBlock: { + type: 'boolean' + } + }, + additionalProperties: false + } + ] + }, + create +} diff --git a/lib/rules/no-unused-keys.ts b/lib/rules/no-unused-keys.ts index 2e6d811b..ebd98929 100644 --- a/lib/rules/no-unused-keys.ts +++ b/lib/rules/no-unused-keys.ts @@ -19,6 +19,7 @@ import type { SourceCode, Range } from '../types' +import { joinPath } from '../utils/key-path' const debug = debugBuilder('eslint-plugin-vue-i18n:no-unused-keys') type UsedKeys = { @@ -87,16 +88,13 @@ function create(context: RuleContext): RuleListener { usedKeys: UsedKeys, { skipNode, - resolveKeysForNode, + resolveKey, resolveReportNode, buildFixer, buildAllFixer }: { skipNode: (node: N) => boolean - resolveKeysForNode: ( - parentPath: string, - node: N - ) => { path: string; key: string | number } | null + resolveKey: (node: N) => string | number | null resolveReportNode: (node: N) => N | null buildFixer: ( node: N @@ -120,18 +118,18 @@ function create(context: RuleContext): RuleListener { return } - const keys = resolveKeysForNode(pathStack.keyPath, node) - if (keys == null) { + const key = resolveKey(node) + if (key == null) { return } + const keyPath = joinPath(pathStack.keyPath, key) pathStack = { upper: pathStack, node, usedKeys: - (pathStack.usedKeys && - (pathStack.usedKeys[keys.key] as UsedKeys)) || + (pathStack.usedKeys && (pathStack.usedKeys[key] as UsedKeys)) || false, - keyPath: keys.path + keyPath } const isUnused = !pathStack.usedKeys if (isUnused) { @@ -142,7 +140,7 @@ function create(context: RuleContext): RuleListener { } reports.push({ node: reportNode, - keyPath: keys.path + keyPath }) } }, @@ -203,26 +201,16 @@ function create(context: RuleContext): RuleListener { return false }, /** - * @param {string} parentPath * @param {JSONNode} node */ - resolveKeysForNode(parentPath, node) { + resolveKey(node) { const parent = node.parent! if (parent.type === 'JSONProperty') { - const key = - parent.key.type === 'JSONLiteral' - ? `${parent.key.value}` - : parent.key.name - return { - path: parentPath ? `${parentPath}.${key}` : key, - key - } + return parent.key.type === 'JSONLiteral' + ? `${parent.key.value}` + : parent.key.name } else if (parent.type === 'JSONArrayExpression') { - const key = parent.elements.indexOf(node as never) - return { - path: parentPath ? `${parentPath}[${key}]` : `[${key}]`, - key - } + return parent.elements.indexOf(node as never) } return null @@ -350,26 +338,18 @@ function create(context: RuleContext): RuleListener { return false }, /** - * @param {string} parentPath * @param {YAMLContent | YAMLWithMeta} node */ - resolveKeysForNode(parentPath, node) { + resolveKey(node) { const parent = node.parent! if (parent.type === 'YAMLPair' && parent.key) { const key = parent.key.type !== 'YAMLScalar' ? sourceCode.getText(parent.key) : parent.key.value - return { - path: parentPath ? `${parentPath}.${key}` : String(key), - key: typeof key === 'boolean' || key === null ? String(key) : key - } + return typeof key === 'boolean' || key === null ? String(key) : key } else if (parent.type === 'YAMLSequence') { - const key = parent.entries.indexOf(node as never) - return { - path: parentPath ? `${parentPath}[${key}]` : `[${key}]`, - key - } + return parent.entries.indexOf(node as never) } return null diff --git a/lib/utils.ts b/lib/utils.ts index 313fea6e..8aeb5e04 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -8,6 +8,7 @@ import * as globSync from './utils/glob-sync' import * as globUtils from './utils/glob-utils' import * as ignoredPaths from './utils/ignored-paths' import * as index from './utils/index' +import * as keyPath from './utils/key-path' import * as localeMessages from './utils/locale-messages' import * as parsers from './utils/parsers' import * as pathUtils from './utils/path-utils' @@ -23,6 +24,7 @@ export = { 'glob-utils': globUtils, 'ignored-paths': ignoredPaths, index, + 'key-path': keyPath, 'locale-messages': localeMessages, parsers, 'path-utils': pathUtils, diff --git a/lib/utils/collect-keys.ts b/lib/utils/collect-keys.ts index 6af837a5..fd15ad8c 100644 --- a/lib/utils/collect-keys.ts +++ b/lib/utils/collect-keys.ts @@ -232,9 +232,13 @@ export function collectKeysFromAST( } class UsedKeysCache { - private _targetFilesLoader: CacheLoader<[string[], string[]], string[]> + private _targetFilesLoader: CacheLoader< + [string[], string[], string], + string[] + > private _collectKeyResourcesFromFiles: ( - fileNames: string[] + fileNames: string[], + cwd: string ) => ResourceLoader[] constructor() { this._targetFilesLoader = new CacheLoader((files, extensions) => { @@ -266,8 +270,9 @@ class UsedKeysCache { * @returns {ResourceLoader[]} */ _getKeyResources(files: string[], extensions: string[]) { - const fileNames = this._targetFilesLoader.get(files, extensions) - return this._collectKeyResourcesFromFiles(fileNames) + const cwd = process.cwd() + const fileNames = this._targetFilesLoader.get(files, extensions, cwd) + return this._collectKeyResourcesFromFiles(fileNames, cwd) } } diff --git a/lib/utils/index.ts b/lib/utils/index.ts index 7c38b91b..35bd5767 100644 --- a/lib/utils/index.ts +++ b/lib/utils/index.ts @@ -103,10 +103,11 @@ export function getLocaleMessages(context: RuleContext): LocaleMessages { } class LocaleDirLocaleMessagesCache { - private _targetFilesLoader: CacheLoader<[string], string[]> + private _targetFilesLoader: CacheLoader<[string, string], string[]> private _loadLocaleMessages: ( files: string[], - localeKey: LocaleKeyType + localeKey: LocaleKeyType, + cwd: string ) => FileLocaleMessage[] constructor() { this._targetFilesLoader = new CacheLoader(pattern => glob.sync(pattern)) @@ -120,17 +121,18 @@ class LocaleDirLocaleMessagesCache { * @returns {LocaleMessage[]} */ getLocaleMessagesFromLocaleDir(localeDir: SettingsVueI18nLocaleDir) { + const cwd = process.cwd() const targetFilesLoader = this._targetFilesLoader let files let localeKey: LocaleKeyType if (typeof localeDir === 'string') { - files = targetFilesLoader.get(localeDir) + files = targetFilesLoader.get(localeDir, cwd) localeKey = 'file' } else { - files = targetFilesLoader.get(localeDir.pattern) + files = targetFilesLoader.get(localeDir.pattern, cwd) localeKey = String(localeDir.localeKey ?? 'file') as LocaleKeyType } - return this._loadLocaleMessages(files, localeKey) + return this._loadLocaleMessages(files, localeKey, cwd) } } diff --git a/lib/utils/key-path.ts b/lib/utils/key-path.ts new file mode 100644 index 00000000..75a7862b --- /dev/null +++ b/lib/utils/key-path.ts @@ -0,0 +1,19 @@ +/** + * @fileoverview Utility for localization keys + * @author Yosuke Ota + */ +export function joinPath(base: string, ...paths: (string | number)[]): string { + let result = base + for (const p of paths) { + if (typeof p === 'number') { + result += `[${p}]` + } else if (/^[^\s,.[\]]+$/iu.test(p)) { + result = result ? `${result}.${p}` : p + } else if (/^(?:0|[1-9]\d*)*$/iu.test(p)) { + result += `[${p}]` + } else { + result += `[${JSON.stringify(p)}]` + } + } + return result +} diff --git a/lib/utils/locale-messages.ts b/lib/utils/locale-messages.ts index 884feef2..8061b4b3 100644 --- a/lib/utils/locale-messages.ts +++ b/lib/utils/locale-messages.ts @@ -84,23 +84,6 @@ export abstract class LocaleMessage { return (this._locales = []) } - findMissingPath(locale: string, key: string): string | null { - const paths = key.split('.') - const length = paths.length - let last: I18nLocaleMessageValue = this._getMessagesFromLocale(locale) - let i = 0 - while (i < length) { - const value: I18nLocaleMessageValue | undefined = - last && typeof last !== 'string' ? last[paths[i]] : undefined - if (value == null) { - return paths.slice(0, i + 1).join('.') - } - last = value - i++ - } - return null - } - /** * Check if the message with the given key exists. * @param {string} locale The locale name @@ -120,7 +103,7 @@ export abstract class LocaleMessage { getMessage(locale: string, key: string): I18nLocaleMessageValue | null { const paths = key.split('.') const length = paths.length - let last: I18nLocaleMessageValue = this._getMessagesFromLocale(locale) + let last: I18nLocaleMessageValue = this.getMessagesFromLocale(locale) let i = 0 while (i < length) { const value: I18nLocaleMessageValue | undefined = @@ -134,12 +117,18 @@ export abstract class LocaleMessage { return last ?? null } - _getMessagesFromLocale(locale: string): I18nLocaleMessageDictionary { + /** + * Gets messages for the given locale. + */ + getMessagesFromLocale(locale: string): I18nLocaleMessageDictionary { if (this.localeKey === 'file') { + if (!this.locales.includes(locale)) { + return {} + } return this.messages } if (this.localeKey === 'key') { - return this.messages[locale] as I18nLocaleMessageDictionary + return (this.messages[locale] || {}) as I18nLocaleMessageDictionary } return {} } @@ -298,6 +287,16 @@ export class LocaleMessages { this.localeMessages = localeMessages } + get locales(): string[] { + const locales = new Set() + for (const localeMessage of this.localeMessages) { + for (const locale of localeMessage.locales) { + locales.add(locale) + } + } + return [...locales] + } + /** * Checks whether it is empty. */ @@ -336,14 +335,31 @@ export class LocaleMessages { */ findMissingPaths(key: string): { path: string; locale: string }[] { const missings: { path: string; locale: string }[] = [] - this.localeMessages.forEach(localeMessage => { - localeMessage.locales.forEach(locale => { - const missingPath = localeMessage.findMissingPath(locale, key) - if (missingPath) { - missings.push({ path: missingPath, locale }) + for (const locale of this.locales) { + const paths = key.split('.') + const length = paths.length + let lasts: I18nLocaleMessageValue[] = this.localeMessages.map(lm => + lm.getMessagesFromLocale(locale) + ) + let i = 0 + while (i < length) { + const values: I18nLocaleMessageValue[] = lasts + .map(last => { + return last && typeof last !== 'string' ? last[paths[i]] : undefined + }) + .filter((val): val is I18nLocaleMessageValue => val != null) + + if (values.length === 0) { + missings.push({ + locale, + path: paths.slice(0, i + 1).join('.') + }) + break } - }) - }) + lasts = values + i++ + } + } return missings.sort(({ locale: localeA }, { locale: localeB }) => localeA > localeB ? 1 : localeA < localeB ? -1 : 0 ) diff --git a/package.json b/package.json index 5efd337f..7de92296 100644 --- a/package.json +++ b/package.json @@ -103,7 +103,7 @@ "release:trigger": "shipjs trigger", "test": "mocha --require ts-node/register \"./tests/**/*.ts\"", "test:debug": "mocha --require ts-node/register --inspect \"./tests/**/*.ts\"", - "test:coverage": "nyc mocha --require ts-node/register ./tests/**/*.ts", + "test:coverage": "nyc mocha --require ts-node/register \"./tests/**/*.ts\"", "test:integrations": "mocha ./tests-integrations/*.js --timeout 60000" } } diff --git a/tests/fixtures/no-duplicate-keys-in-locale/invalid/constructor-option-format/locales/index.1.json b/tests/fixtures/no-duplicate-keys-in-locale/invalid/constructor-option-format/locales/index.1.json new file mode 100644 index 00000000..28306f6a --- /dev/null +++ b/tests/fixtures/no-duplicate-keys-in-locale/invalid/constructor-option-format/locales/index.1.json @@ -0,0 +1,21 @@ +{ + "en": { + "hello": "hello", + "messages": { + "foo": "foo", + "bar": "bar", + "baz": "baz", + "dupe": "dupe" + }, + "dupe": "dupe" + }, + "ja": { + "hello": "こんにちは", + "messages": { + "foo": "foo", + "bar": "bar", + "baz": "baz" + }, + "dupe": "dupe" + } +} diff --git a/tests/fixtures/no-duplicate-keys-in-locale/invalid/constructor-option-format/locales/index.2.yaml b/tests/fixtures/no-duplicate-keys-in-locale/invalid/constructor-option-format/locales/index.2.yaml new file mode 100644 index 00000000..2f0d4cc9 --- /dev/null +++ b/tests/fixtures/no-duplicate-keys-in-locale/invalid/constructor-option-format/locales/index.2.yaml @@ -0,0 +1,16 @@ +'en': + 'good-bye': 'good bye' + 'messages': + 'qux': 'qux' + 'quux': 'quux' + 'corge': 'corge' + 'dupe': 'dupe' + 'dupe': 'dupe' +'ja': + 'good-bye': 'さようなら' + 'messages': + 'qux': 'qux' + 'quux': 'quux' + 'corge': 'corge' + 'dupe': 'dupe' + 'dupe': 'dupe' diff --git a/tests/fixtures/no-duplicate-keys-in-locale/invalid/constructor-option-format/src/App.vue b/tests/fixtures/no-duplicate-keys-in-locale/invalid/constructor-option-format/src/App.vue new file mode 100644 index 00000000..fff17a90 --- /dev/null +++ b/tests/fixtures/no-duplicate-keys-in-locale/invalid/constructor-option-format/src/App.vue @@ -0,0 +1,33 @@ + +en: + block: block + nest: + foo +ja: + block: block + nest: + foo: bar + + +en: + nest: + foo: bar +ja: + dupe: dupe + + +{ + "en": { + "json-dupe": "dupe", + "nest": { + "json-dupe": "dupe" + }, + "nest": { + "json-dupe": "dupe" + }, + "json-dupe": "dupe" + } +} + + + diff --git a/tests/fixtures/no-duplicate-keys-in-locale/invalid/vue-cli-format/locales/en.1.json b/tests/fixtures/no-duplicate-keys-in-locale/invalid/vue-cli-format/locales/en.1.json new file mode 100644 index 00000000..97c6c57e --- /dev/null +++ b/tests/fixtures/no-duplicate-keys-in-locale/invalid/vue-cli-format/locales/en.1.json @@ -0,0 +1,10 @@ +{ + "hello": "hello", + "messages": { + "foo": "foo", + "bar": "bar", + "baz": "baz", + "dupe": "dupe" + }, + "dupe": "dupe" +} diff --git a/tests/fixtures/no-duplicate-keys-in-locale/invalid/vue-cli-format/locales/en.2.json b/tests/fixtures/no-duplicate-keys-in-locale/invalid/vue-cli-format/locales/en.2.json new file mode 100644 index 00000000..e428e817 --- /dev/null +++ b/tests/fixtures/no-duplicate-keys-in-locale/invalid/vue-cli-format/locales/en.2.json @@ -0,0 +1,10 @@ +{ + "good-bye": "good bye", + "messages": { + "qux": "qux", + "quux": "quux", + "corge": "corge", + "dupe": "dupe" + }, + "dupe": "dupe" +} diff --git a/tests/fixtures/no-duplicate-keys-in-locale/invalid/vue-cli-format/locales/ja.1.json b/tests/fixtures/no-duplicate-keys-in-locale/invalid/vue-cli-format/locales/ja.1.json new file mode 100644 index 00000000..32b49d45 --- /dev/null +++ b/tests/fixtures/no-duplicate-keys-in-locale/invalid/vue-cli-format/locales/ja.1.json @@ -0,0 +1,10 @@ +{ + "hello": "こんにちは", + "messages": { + "foo": "foo", + "bar": "bar", + "baz": "baz", + "dupe": "dupe" + }, + "dupe": "dupe" +} diff --git a/tests/fixtures/no-duplicate-keys-in-locale/invalid/vue-cli-format/locales/ja.2.json b/tests/fixtures/no-duplicate-keys-in-locale/invalid/vue-cli-format/locales/ja.2.json new file mode 100644 index 00000000..57afb5cf --- /dev/null +++ b/tests/fixtures/no-duplicate-keys-in-locale/invalid/vue-cli-format/locales/ja.2.json @@ -0,0 +1,9 @@ +{ + "good-bye": "さようなら", + "messages": { + "qux": "qux", + "quux": "quux", + "corge": "corge" + }, + "dupe": "dupe" +} diff --git a/tests/fixtures/no-duplicate-keys-in-locale/invalid/vue-cli-format/src/App.vue b/tests/fixtures/no-duplicate-keys-in-locale/invalid/vue-cli-format/src/App.vue new file mode 100644 index 00000000..676e32f2 --- /dev/null +++ b/tests/fixtures/no-duplicate-keys-in-locale/invalid/vue-cli-format/src/App.vue @@ -0,0 +1,30 @@ + +block: block +nest: + foo: bar + + +block: block +nest: + foo: bar +dupe: dupe + + +block: block +nest: + foo: bar + + +{ + "json-dupe": "dupe", + "nest": { + "json-dupe": "dupe" + }, + "nest": { + "json-dupe": "dupe" + }, + "json-dupe": "dupe" +} + + + diff --git a/tests/fixtures/no-duplicate-keys-in-locale/valid/constructor-option-format/locales/index.1.json b/tests/fixtures/no-duplicate-keys-in-locale/valid/constructor-option-format/locales/index.1.json new file mode 100644 index 00000000..8a1eb9d3 --- /dev/null +++ b/tests/fixtures/no-duplicate-keys-in-locale/valid/constructor-option-format/locales/index.1.json @@ -0,0 +1,18 @@ +{ + "en": { + "hello": "hello", + "messages": { + "foo": "foo", + "bar": "bar", + "baz": "baz" + } + }, + "ja": { + "hello": "こんにちは", + "messages": { + "foo": "foo", + "bar": "bar", + "baz": "baz" + } + } +} diff --git a/tests/fixtures/no-duplicate-keys-in-locale/valid/constructor-option-format/locales/index.2.json b/tests/fixtures/no-duplicate-keys-in-locale/valid/constructor-option-format/locales/index.2.json new file mode 100644 index 00000000..ee980e11 --- /dev/null +++ b/tests/fixtures/no-duplicate-keys-in-locale/valid/constructor-option-format/locales/index.2.json @@ -0,0 +1,18 @@ +{ + "en": { + "good-bye": "good bye", + "messages": { + "qux": "qux", + "quux": "quux", + "corge": "corge" + } + }, + "ja": { + "good-bye": "さようなら", + "messages": { + "qux": "qux", + "quux": "quux", + "corge": "corge" + } + } +} diff --git a/tests/fixtures/no-duplicate-keys-in-locale/valid/constructor-option-format/src/App.vue b/tests/fixtures/no-duplicate-keys-in-locale/valid/constructor-option-format/src/App.vue new file mode 100644 index 00000000..a27e22e4 --- /dev/null +++ b/tests/fixtures/no-duplicate-keys-in-locale/valid/constructor-option-format/src/App.vue @@ -0,0 +1,8 @@ + +en: + block: block +ja: + block: block + + + diff --git a/tests/fixtures/no-duplicate-keys-in-locale/valid/vue-cli-format/locales/en.1.json b/tests/fixtures/no-duplicate-keys-in-locale/valid/vue-cli-format/locales/en.1.json new file mode 100644 index 00000000..f5235b23 --- /dev/null +++ b/tests/fixtures/no-duplicate-keys-in-locale/valid/vue-cli-format/locales/en.1.json @@ -0,0 +1,8 @@ +{ + "hello": "hello", + "messages": { + "foo": "foo", + "bar": "bar", + "baz": "baz" + } +} diff --git a/tests/fixtures/no-duplicate-keys-in-locale/valid/vue-cli-format/locales/en.2.json b/tests/fixtures/no-duplicate-keys-in-locale/valid/vue-cli-format/locales/en.2.json new file mode 100644 index 00000000..47c60c0f --- /dev/null +++ b/tests/fixtures/no-duplicate-keys-in-locale/valid/vue-cli-format/locales/en.2.json @@ -0,0 +1,8 @@ +{ + "good-bye": "good bye", + "messages": { + "qux": "qux", + "quux": "quux", + "corge": "corge" + } +} diff --git a/tests/fixtures/no-duplicate-keys-in-locale/valid/vue-cli-format/locales/ja.1.json b/tests/fixtures/no-duplicate-keys-in-locale/valid/vue-cli-format/locales/ja.1.json new file mode 100644 index 00000000..a5d4cc7d --- /dev/null +++ b/tests/fixtures/no-duplicate-keys-in-locale/valid/vue-cli-format/locales/ja.1.json @@ -0,0 +1,8 @@ +{ + "hello": "こんにちは", + "messages": { + "foo": "foo", + "bar": "bar", + "baz": "baz" + } +} diff --git a/tests/fixtures/no-duplicate-keys-in-locale/valid/vue-cli-format/locales/ja.2.json b/tests/fixtures/no-duplicate-keys-in-locale/valid/vue-cli-format/locales/ja.2.json new file mode 100644 index 00000000..cccef091 --- /dev/null +++ b/tests/fixtures/no-duplicate-keys-in-locale/valid/vue-cli-format/locales/ja.2.json @@ -0,0 +1,8 @@ +{ + "good-bye": "さようなら", + "messages": { + "qux": "qux", + "quux": "quux", + "corge": "corge" + } +} diff --git a/tests/fixtures/no-duplicate-keys-in-locale/valid/vue-cli-format/src/App.vue b/tests/fixtures/no-duplicate-keys-in-locale/valid/vue-cli-format/src/App.vue new file mode 100644 index 00000000..9a2d0cb8 --- /dev/null +++ b/tests/fixtures/no-duplicate-keys-in-locale/valid/vue-cli-format/src/App.vue @@ -0,0 +1,8 @@ + +block: block + + +block: block + + + diff --git a/tests/fixtures/no-missing-keys/constructor-option-format/locales/index.json b/tests/fixtures/no-missing-keys/constructor-option-format/locales/index.json index 5699f357..62354108 100644 --- a/tests/fixtures/no-missing-keys/constructor-option-format/locales/index.json +++ b/tests/fixtures/no-missing-keys/constructor-option-format/locales/index.json @@ -6,7 +6,8 @@ "link": "@:message.hello", "nested": { "hello": "hi jojo!" - } + }, + "en-only": "en-only" }, "hello_dio": "hello underscore DIO!", "hello {name}": "hello {name}!", diff --git a/tests/fixtures/no-missing-keys/vue-cli-format/locales/en.2.json b/tests/fixtures/no-missing-keys/vue-cli-format/locales/en.2.json new file mode 100644 index 00000000..df23e30f --- /dev/null +++ b/tests/fixtures/no-missing-keys/vue-cli-format/locales/en.2.json @@ -0,0 +1,5 @@ +{ + "hello_dio": "hello underscore DIO!", + "hello {name}": "hello {name}!", + "hello-dio": "hello hyphen DIO!" +} diff --git a/tests/fixtures/no-missing-keys/vue-cli-format/locales/en.json b/tests/fixtures/no-missing-keys/vue-cli-format/locales/en.json index da032bfa..078fe33c 100644 --- a/tests/fixtures/no-missing-keys/vue-cli-format/locales/en.json +++ b/tests/fixtures/no-missing-keys/vue-cli-format/locales/en.json @@ -5,9 +5,7 @@ "link": "@:message.hello", "nested": { "hello": "hi jojo!" - } - }, - "hello_dio": "hello underscore DIO!", - "hello {name}": "hello {name}!", - "hello-dio": "hello hyphen DIO!" + }, + "en-only": "en-only" + } } diff --git a/tests/lib/rules/no-duplicate-keys-in-locale.ts b/tests/lib/rules/no-duplicate-keys-in-locale.ts new file mode 100644 index 00000000..89f1b9d3 --- /dev/null +++ b/tests/lib/rules/no-duplicate-keys-in-locale.ts @@ -0,0 +1,528 @@ +/** + * @author Yosuke Ota + */ +import { RuleTester } from 'eslint' +import { join } from 'path' + +import rule = require('../../../lib/rules/no-duplicate-keys-in-locale') +import { testOnFixtures } from '../test-utils' + +new RuleTester({ + parser: require.resolve('vue-eslint-parser'), + parserOptions: { ecmaVersion: 2015, sourceType: 'module' } +}).run('no-duplicate-keys-in-locale', rule as never, { + valid: [ + { + filename: 'test.vue', + code: ` + + { + "foo": "foo", + "bar": "bar" + } + + + { + "foo": "foo", + "bar": "bar" + } + + + ` + }, + { + filename: 'test.vue', + code: ` + + { + "en": { + "foo": "foo", + "bar": "bar" + }, + "ja": { + "foo": "foo", + "bar": "bar" + } + } + + + ` + }, + { + filename: 'test.vue', + code: ` + + foo: foo + bar: bar + + + foo: foo + bar: bar + + + ` + } + ], + invalid: [ + { + filename: 'test.vue', + code: ` + + { + "foo": "foo", + "foo": "bar" + } + + + { + "bar": "foo", + "bar": "bar" + } + + + `, + errors: [ + { + message: "duplicate key 'foo'", + line: 4 + }, + { + message: "duplicate key 'foo'", + line: 5 + }, + { + message: "duplicate key 'bar'", + line: 10 + }, + { + message: "duplicate key 'bar'", + line: 11 + } + ] + } + ] +}) + +describe('no-duplicate-keys-in-locale with fixtures', () => { + const cwdRoot = join(__dirname, '../../fixtures/no-duplicate-keys-in-locale') + + describe('valid', () => { + it('should be not detected dupe keys', () => { + testOnFixtures( + { + cwd: join(cwdRoot, './valid/vue-cli-format'), + localeDir: `./locales/*.{json,yaml,yml}`, + ruleName: '@intlify/vue-i18n/no-duplicate-keys-in-locale' + }, + {} + ) + }) + + it('should be not detected dupe keys for constructor-option-format', () => { + testOnFixtures( + { + cwd: join(cwdRoot, './valid/constructor-option-format'), + localeDir: { + pattern: `./locales/*.{json,yaml,yml}`, + localeKey: 'key' + }, + ruleName: '@intlify/vue-i18n/no-duplicate-keys-in-locale' + }, + {} + ) + }) + }) + + describe('invalid', () => { + it('should be detected dupe keys', () => { + testOnFixtures( + { + cwd: join(cwdRoot, './invalid/vue-cli-format'), + localeDir: `./locales/*.{json,yaml,yml}`, + ruleName: '@intlify/vue-i18n/no-duplicate-keys-in-locale' + }, + { + 'locales/en.1.json': { + errors: [ + { + line: 7, + message: + "duplicate key 'messages.dupe' in 'en'. \"./locales/en.2.json\" has the same key" + }, + { + line: 9, + message: + "duplicate key 'dupe' in 'en'. \"./locales/en.2.json\" has the same key" + } + ] + }, + 'locales/en.2.json': { + errors: [ + { + line: 7, + message: + "duplicate key 'messages.dupe' in 'en'. \"./locales/en.1.json\" has the same key" + }, + { + line: 9, + message: + "duplicate key 'dupe' in 'en'. \"./locales/en.1.json\" has the same key" + } + ] + }, + 'locales/ja.1.json': { + errors: [ + { + line: 9, + message: + "duplicate key 'dupe' in 'ja'. \"./locales/ja.2.json\" has the same key" + } + ] + }, + 'locales/ja.2.json': { + errors: [ + { + line: 8, + message: + "duplicate key 'dupe' in 'ja'. \"./locales/ja.1.json\" has the same key" + } + ] + }, + 'src/App.vue': { + errors: [ + { + line: 2, + message: + "duplicate key 'block' in 'en'. \"./src/App.vue\" has the same key" + }, + { + line: 4, + message: + "duplicate key 'nest.foo' in 'en'. \"./src/App.vue\" has the same key" + }, + { + line: 10, + message: + "duplicate key 'dupe' in 'ja'. \"./locales/ja.1.json\" has the same key" + }, + { + line: 10, + message: + "duplicate key 'dupe' in 'ja'. \"./locales/ja.2.json\" has the same key" + }, + { + line: 13, + message: + "duplicate key 'block' in 'en'. \"./src/App.vue\" has the same key" + }, + { + line: 15, + message: + "duplicate key 'nest.foo' in 'en'. \"./src/App.vue\" has the same key" + }, + { + line: 19, + message: "duplicate key 'json-dupe'" + }, + { + line: 20, + message: "duplicate key 'nest'" + }, + { + line: 21, + message: "duplicate key 'nest.json-dupe'" + }, + { + line: 23, + message: "duplicate key 'nest'" + }, + { + line: 24, + message: "duplicate key 'nest.json-dupe'" + }, + { + line: 26, + message: "duplicate key 'json-dupe'" + } + ] + } + } + ) + }) + + it('should be detected dupe keys for constructor-option-format', () => { + testOnFixtures( + { + cwd: join(cwdRoot, './invalid/constructor-option-format'), + localeDir: { + pattern: `./locales/*.{json,yaml,yml}`, + localeKey: 'key' + }, + ruleName: '@intlify/vue-i18n/no-duplicate-keys-in-locale' + }, + { + 'locales/index.1.json': { + errors: [ + { + line: 8, + message: + "duplicate key 'messages.dupe' in 'en'. \"./locales/index.2.yaml\" has the same key" + }, + { + line: 10, + message: + "duplicate key 'dupe' in 'en'. \"./locales/index.2.yaml\" has the same key" + }, + { + line: 19, + message: + "duplicate key 'dupe' in 'ja'. \"./locales/index.2.yaml\" has the same key" + } + ] + }, + 'locales/index.2.yaml': { + errors: [ + { + line: 7, + message: + "duplicate key 'messages.dupe' in 'en'. \"./locales/index.1.json\" has the same key" + }, + { + line: 8, + message: + "duplicate key 'dupe' in 'en'. \"./locales/index.1.json\" has the same key" + }, + { + line: 16, + message: + "duplicate key 'dupe' in 'ja'. \"./locales/index.1.json\" has the same key" + } + ] + }, + 'src/App.vue': { + errors: [ + { + line: 13, + message: + "duplicate key 'nest' in 'en'. \"./src/App.vue\" has the same key" + }, + { + line: 16, + message: + "duplicate key 'dupe' in 'ja'. \"./locales/index.1.json\" has the same key" + }, + { + line: 16, + message: + "duplicate key 'dupe' in 'ja'. \"./locales/index.2.yaml\" has the same key" + }, + { + line: 21, + message: "duplicate key 'json-dupe'" + }, + { + line: 22, + message: "duplicate key 'nest'" + }, + { + line: 22, + message: + "duplicate key 'nest' in 'en'. \"./src/App.vue\" has the same key" + }, + { + line: 23, + message: "duplicate key 'nest.json-dupe'" + }, + { + line: 25, + message: "duplicate key 'nest'" + }, + { + line: 25, + message: + "duplicate key 'nest' in 'en'. \"./src/App.vue\" has the same key" + }, + { + line: 26, + message: "duplicate key 'nest.json-dupe'" + }, + { + line: 28, + message: "duplicate key 'json-dupe'" + } + ] + } + } + ) + }) + + it('should be detected dupe keys with ignoreI18nBlock', () => { + testOnFixtures( + { + cwd: join(cwdRoot, './invalid/vue-cli-format'), + localeDir: `./locales/*.{json,yaml,yml}`, + ruleName: '@intlify/vue-i18n/no-duplicate-keys-in-locale', + options: [{ ignoreI18nBlock: true }] + }, + { + 'locales/en.1.json': { + errors: [ + { + line: 7, + message: + "duplicate key 'messages.dupe' in 'en'. \"./locales/en.2.json\" has the same key" + }, + { + line: 9, + message: + "duplicate key 'dupe' in 'en'. \"./locales/en.2.json\" has the same key" + } + ] + }, + 'locales/en.2.json': { + errors: [ + { + line: 7, + message: + "duplicate key 'messages.dupe' in 'en'. \"./locales/en.1.json\" has the same key" + }, + { + line: 9, + message: + "duplicate key 'dupe' in 'en'. \"./locales/en.1.json\" has the same key" + } + ] + }, + 'locales/ja.1.json': { + errors: [ + { + line: 9, + message: + "duplicate key 'dupe' in 'ja'. \"./locales/ja.2.json\" has the same key" + } + ] + }, + 'locales/ja.2.json': { + errors: [ + { + line: 8, + message: + "duplicate key 'dupe' in 'ja'. \"./locales/ja.1.json\" has the same key" + } + ] + }, + 'src/App.vue': { + errors: [ + { + line: 19, + message: "duplicate key 'json-dupe'" + }, + { + line: 20, + message: "duplicate key 'nest'" + }, + { + line: 21, + message: "duplicate key 'nest.json-dupe'" + }, + { + line: 23, + message: "duplicate key 'nest'" + }, + { + line: 24, + message: "duplicate key 'nest.json-dupe'" + }, + { + line: 26, + message: "duplicate key 'json-dupe'" + } + ] + } + } + ) + }) + + it('should be detected dupe keys with ignoreI18nBlock for constructor-option-format', () => { + testOnFixtures( + { + cwd: join(cwdRoot, './invalid/constructor-option-format'), + localeDir: { + pattern: `./locales/*.{json,yaml,yml}`, + localeKey: 'key' + }, + ruleName: '@intlify/vue-i18n/no-duplicate-keys-in-locale', + options: [{ ignoreI18nBlock: true }] + }, + { + 'locales/index.1.json': { + errors: [ + { + line: 8, + message: + "duplicate key 'messages.dupe' in 'en'. \"./locales/index.2.yaml\" has the same key" + }, + { + line: 10, + message: + "duplicate key 'dupe' in 'en'. \"./locales/index.2.yaml\" has the same key" + }, + { + line: 19, + message: + "duplicate key 'dupe' in 'ja'. \"./locales/index.2.yaml\" has the same key" + } + ] + }, + 'locales/index.2.yaml': { + errors: [ + { + line: 7, + message: + "duplicate key 'messages.dupe' in 'en'. \"./locales/index.1.json\" has the same key" + }, + { + line: 8, + message: + "duplicate key 'dupe' in 'en'. \"./locales/index.1.json\" has the same key" + }, + { + line: 16, + message: + "duplicate key 'dupe' in 'ja'. \"./locales/index.1.json\" has the same key" + } + ] + }, + 'src/App.vue': { + errors: [ + { + line: 21, + message: "duplicate key 'json-dupe'" + }, + { + line: 22, + message: "duplicate key 'nest'" + }, + { + line: 23, + message: "duplicate key 'nest.json-dupe'" + }, + { + line: 25, + message: "duplicate key 'nest'" + }, + { + line: 26, + message: "duplicate key 'nest.json-dupe'" + }, + { + line: 28, + message: "duplicate key 'json-dupe'" + } + ] + } + } + ) + }) + }) +}) diff --git a/tests/lib/rules/no-missing-keys.ts b/tests/lib/rules/no-missing-keys.ts index 61f1703c..e3e02568 100644 --- a/tests/lib/rules/no-missing-keys.ts +++ b/tests/lib/rules/no-missing-keys.ts @@ -210,6 +210,10 @@ tester.run('no-missing-keys', rule as never, { `'messages.missing' does not exist in 'en'`, `'messages.missing' does not exist in 'ja'` ] + }, + { + code: `$t('messages.en-only')`, + errors: ["'messages.en-only' does not exist in 'ja'"] } ], [ diff --git a/tests/lib/test-utils.ts b/tests/lib/test-utils.ts index 5766cb63..218218ac 100644 --- a/tests/lib/test-utils.ts +++ b/tests/lib/test-utils.ts @@ -1,6 +1,14 @@ import fs from 'fs' import path from 'path' +import assert from 'assert' +import { CLIEngine } from 'eslint' +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore +import linter = require('eslint/lib/linter') import base = require('../../lib/configs/base') +import plugin = require('../../lib/index') +import { SettingsVueI18nLocaleDir } from '../../lib/types' +const { SourceCodeFixer } = linter function buildBaseConfigPath() { const configPath = path.join( @@ -13,3 +21,205 @@ function buildBaseConfigPath() { } export const baseConfigPath = buildBaseConfigPath() + +export function testOnFixtures( + testOptions: { + cwd: string + ruleName: string + } & ( + | { + localeDir?: SettingsVueI18nLocaleDir + options?: unknown[] + useEslintrc?: false + } + | { + useEslintrc: true + } + ), + expectedMessages: { + [file: string]: { + output?: string | null + errors: + | string[] + | { + message: string + line: number + suggestions?: { desc: string; output: string }[] + }[] + } + }, + assertOptions?: { messageOnly?: boolean } +): void { + const originalCwd = process.cwd() + try { + process.chdir(testOptions.cwd) + const linter = new CLIEngine( + testOptions.useEslintrc + ? { + cwd: testOptions.cwd, + useEslintrc: true + } + : { + cwd: testOptions.cwd, + baseConfig: { + extends: [baseConfigPath], + settings: { + 'vue-i18n': { + localeDir: testOptions.localeDir + } + } + }, + useEslintrc: false, + parserOptions: { + ecmaVersion: 2020, + sourceType: 'module' + }, + rules: { + [testOptions.ruleName]: ['error', ...(testOptions.options || [])] + }, + extensions: ['.js', '.vue', '.json', '.json5', '.yaml', '.yml'] + } + ) + linter.addPlugin('@intlify/vue-i18n', plugin) + + const messages = linter.executeOnFiles(['.']) + const filePaths = Object.keys(expectedMessages).map(file => + path.join(testOptions.cwd, file) + ) + for (const lintResult of messages.results) { + if (lintResult.errorCount > 0) { + if (!filePaths.includes(lintResult.filePath)) { + assert.fail( + 'Expected ' + + lintResult.filePath.replace(testOptions.cwd, '') + + ' values are required.' + ) + } + } + } + + let count = 0 + for (const filePath of Object.keys(expectedMessages)) { + const fileMessages = getResult( + testOptions.ruleName, + messages, + path.resolve(testOptions.cwd, filePath), + assertOptions + ) + + assert.strictEqual( + stringify(fileMessages), + stringify(expectedMessages[filePath]), + 'Unexpected messages in ' + filePath + ) + count += fileMessages.errors.length + } + assert.equal(messages.errorCount, count) + } finally { + process.chdir(originalCwd) + } +} + +function getResult( + ruleName: string, + messages: CLIEngine.LintReport, + fullPath: string, + options?: { messageOnly?: boolean } +): { + output?: string + errors: + | string[] + | { + message: string + line: number + suggestions?: { desc: string; output: string }[] + }[] +} { + const result = messages.results.find(result => result.filePath === fullPath) + if (!result) { + assert.fail('not found lint results at ' + fullPath) + } + const messageOnly = options?.messageOnly ?? false + if (messageOnly) { + return { + errors: result.messages.map(message => { + assert.equal(message.ruleId, ruleName) + return message.message + }) + } + } + const rule = + plugin.rules[ + ruleName.replace(/^@intlify\/vue-i18n\//u, '') as 'no-unused-keys' + ] + const sortedMessages = [...result.messages].sort( + (problemA, problemB) => + problemA.line - problemB.line || + problemA.column - problemB.column || + (problemA.endLine ?? 0) - (problemB.endLine ?? 0) || + (problemA.endColumn ?? 0) - (problemB.endColumn ?? 0) || + compareStr(problemA.ruleId || '', problemB.ruleId || '') || + compareStr(problemA.messageId || '', problemB.messageId || '') || + compareStr(problemA.message, problemB.message) + ) + return { + ...(rule.meta.fixable != null + ? { + output: (() => { + const output = SourceCodeFixer.applyFixes( + result.source, + sortedMessages + ).output + return output === result.source ? null : output + })() + } + : {}), + errors: sortedMessages.map(message => { + assert.equal(message.ruleId, ruleName) + + return { + message: message.message, + line: message.line, + ...(message.suggestions + ? { + suggestions: message.suggestions!.map(suggest => { + const output = SourceCodeFixer.applyFixes(result.source, [ + suggest + ]).output + return { + desc: suggest.desc, + output + } + }) + } + : {}) + } + }) + } +} + +function compareStr(a: string, b: string) { + return a > b ? 1 : a < b ? -1 : 0 +} + +function stringify(obj: unknown) { + return JSON.stringify(sortKeysObject(obj), null, 2) +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function sortKeysObject(obj: any): unknown { + if (obj == null) { + return obj + } + if (Array.isArray(obj)) { + return obj.map(sortKeysObject) + } + if (typeof obj !== 'object') { + return obj + } + const res: { [key: string]: unknown } = {} + for (const key of Object.keys(obj).sort()) { + res[key] = sortKeysObject(obj[key]) + } + return res +}