diff --git a/.eslintrc.js b/.eslintrc.js index 27ccdaac..32e66262 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -17,7 +17,8 @@ module.exports = { ecmaVersion: 2015 }, rules: { - 'object-shorthand': 'error' + 'object-shorthand': 'error', + 'no-debugger': 'error' }, overrides: [ { diff --git a/docs/started.md b/docs/started.md index ab7a795c..c6e01655 100644 --- a/docs/started.md +++ b/docs/started.md @@ -38,7 +38,7 @@ module.export = { }, settings: { 'vue-i18n': { - localeDir: './path/to/locales/*.{json,json5,yaml,yml}' // extension is glob formatting! + localeDir: './path/to/locales/*.{json,json5,yaml,yml}', // extension is glob formatting! // or // localeDir: { // pattern: './path/to/locales/*.{json,json5,yaml,yml}', // extension is glob formatting! @@ -55,6 +55,10 @@ module.export = { // localeKey: 'file' // or 'key' // }, // ] + + // Specify the version of `vue-i18n` you are using. + // If not specified, the message will be parsed twice. + messageSyntaxVersion: '^9.0.0' } } } @@ -72,6 +76,7 @@ See [the rule list](../rules/) - `'file'` ... Determine the locale name from the filename. The resource file should only contain messages for that locale. Use this option if you use `vue-cli-plugin-i18n`. This option is also used when String option is specified. - `'key'` ... Determine the locale name from the root key name of the file contents. The value of that key should only contain messages for that locale. Used when the resource file is in the format given to the `messages` option of the `VueI18n` constructor option. - Array option ... An array of String option and Object option. Useful if you have multiple locale directories. +- `messageSyntaxVersion` (Optional) ... Specify the version of `vue-i18n` you are using. If not specified, the message will be parsed twice. ### Running ESLint from command line diff --git a/lib/rules/no-unused-keys.ts b/lib/rules/no-unused-keys.ts index 06753a2b..e3a3dd7c 100644 --- a/lib/rules/no-unused-keys.ts +++ b/lib/rules/no-unused-keys.ts @@ -17,7 +17,8 @@ import type { RuleFixer, Fix, SourceCode, - Range + Range, + CustomBlockVisitorFactory } from '../types' import { joinPath } from '../utils/key-path' const debug = debugBuilder('eslint-plugin-vue-i18n:no-unused-keys') @@ -46,12 +47,13 @@ function isDef(v: V | null | undefined): v is V { function getUsedKeysMap( targetLocaleMessage: LocaleMessage, values: I18nLocaleMessageDictionary, - usedkeys: string[] + usedkeys: string[], + context: RuleContext ): UsedKeys { /** @type {UsedKeys} */ const usedKeysMap: UsedKeys = {} - for (const key of [...usedkeys, ...collectLinkedKeys(values)]) { + for (const key of [...usedkeys, ...collectLinkedKeys(values, context)]) { const paths = key.split('.') let map = usedKeysMap while (paths.length) { @@ -481,29 +483,13 @@ function create(context: RuleContext): RuleListener { } if (extname(filename) === '.vue') { - return defineCustomBlocksVisitor( - context, - ctx => { - const localeMessages = getLocaleMessages(context) - const usedLocaleMessageKeys = collectKeysFromAST( - context.getSourceCode().ast as VAST.ESLintProgram, - context.getSourceCode().visitorKeys - ) - const targetLocaleMessage = localeMessages.findBlockLocaleMessage( - ctx.parserServices.customBlock - ) - if (!targetLocaleMessage) { - return {} - } - const usedKeys = getUsedKeysMap( - targetLocaleMessage, - targetLocaleMessage.messages, - usedLocaleMessageKeys - ) - - return createVisitorForJson(ctx.getSourceCode(), usedKeys) - }, - ctx => { + const createCustomBlockRule = ( + createVisitor: ( + sourceCode: SourceCode, + usedKeys: UsedKeys + ) => RuleListener + ): CustomBlockVisitorFactory => { + return ctx => { const localeMessages = getLocaleMessages(context) const usedLocaleMessageKeys = collectKeysFromAST( context.getSourceCode().ast as VAST.ESLintProgram, @@ -518,11 +504,17 @@ function create(context: RuleContext): RuleListener { const usedKeys = getUsedKeysMap( targetLocaleMessage, targetLocaleMessage.messages, - usedLocaleMessageKeys + usedLocaleMessageKeys, + context ) - return createVisitorForYaml(ctx.getSourceCode(), usedKeys) + return createVisitor(ctx.getSourceCode(), usedKeys) } + } + return defineCustomBlocksVisitor( + context, + createCustomBlockRule(createVisitorForJson), + createCustomBlockRule(createVisitorForYaml) ) } else if (context.parserServices.isJSON || context.parserServices.isYAML) { const localeMessages = getLocaleMessages(context) @@ -543,7 +535,8 @@ function create(context: RuleContext): RuleListener { const usedKeys = getUsedKeysMap( targetLocaleMessage, targetLocaleMessage.messages, - usedLocaleMessageKeys + usedLocaleMessageKeys, + context ) if (context.parserServices.isJSON) { return createVisitorForJson(sourceCode, usedKeys) diff --git a/lib/types/eslint.ts b/lib/types/eslint.ts index 5a7ddd82..909512d5 100644 --- a/lib/types/eslint.ts +++ b/lib/types/eslint.ts @@ -32,6 +32,7 @@ export interface RuleContext { settings: { 'vue-i18n'?: { localeDir?: SettingsVueI18nLocaleDir + messageSyntaxVersion?: string } } parserPath: string diff --git a/lib/utils/collect-linked-keys.ts b/lib/utils/collect-linked-keys.ts index de3cf774..7015f00b 100644 --- a/lib/utils/collect-linked-keys.ts +++ b/lib/utils/collect-linked-keys.ts @@ -2,13 +2,14 @@ * @fileoverview Collect the keys used by the linked messages. * @author Yosuke Ota */ -// Note: If vue-i18n@next parser is separated from vue plugin, change it to use that. - -import type { I18nLocaleMessageDictionary } from '../types' - -const linkKeyMatcher = /(?:@(?:\.[a-z]+)?:(?:[\w\-_|.]+|\([\w\-_|.]+\)))/g -const linkKeyPrefixMatcher = /^@(?:\.([a-z]+))?:/ -const bracketsMatcher = /[()]/g +import type { ResourceNode } from '@intlify/message-compiler' +import { NodeTypes } from '@intlify/message-compiler' +import { traverseNode } from './message-compiler/traverser' +import type { I18nLocaleMessageDictionary, RuleContext } from '../types' +import { parse } from './message-compiler/parser' +import { parse as parseForV8 } from './message-compiler/parser-v8' +import type { MessageSyntaxVersions } from './message-compiler/utils' +import { getMessageSyntaxVersions } from './message-compiler/utils' /** * Extract the keys used by the linked messages. @@ -16,29 +17,21 @@ const bracketsMatcher = /[()]/g * @returns {IterableIterator} */ function* extractUsedKeysFromLinks( - object: I18nLocaleMessageDictionary + object: I18nLocaleMessageDictionary, + messageSyntaxVersions: MessageSyntaxVersions ): IterableIterator { for (const value of Object.values(object)) { if (!value) { continue } if (typeof value === 'object') { - yield* extractUsedKeysFromLinks(value) + yield* extractUsedKeysFromLinks(value, messageSyntaxVersions) } else if (typeof value === 'string') { - if (value.indexOf('@:') >= 0 || value.indexOf('@.') >= 0) { - // see https://github.com/kazupon/vue-i18n/blob/c07d1914dcac186291b658a8b9627732010f6848/src/index.js#L435 - const matches = value.match(linkKeyMatcher)! - for (const idx in matches) { - const link = matches[idx] - const linkKeyPrefixMatches = link.match(linkKeyPrefixMatcher)! - const [linkPrefix] = linkKeyPrefixMatches - - // Remove the leading @:, @.case: and the brackets - const linkPlaceholder = link - .replace(linkPrefix, '') - .replace(bracketsMatcher, '') - yield linkPlaceholder - } + if (messageSyntaxVersions.v9) { + yield* extractUsedKeysFromAST(parse(value).ast) + } + if (messageSyntaxVersions.v8) { + yield* extractUsedKeysFromAST(parseForV8(value).ast) } } } @@ -50,7 +43,28 @@ function* extractUsedKeysFromLinks( * @returns {string[]} */ export function collectLinkedKeys( - object: I18nLocaleMessageDictionary + object: I18nLocaleMessageDictionary, + context: RuleContext ): string[] { - return [...new Set(extractUsedKeysFromLinks(object))] + return [ + ...new Set( + extractUsedKeysFromLinks(object, getMessageSyntaxVersions(context)) + ) + ].filter(s => !!s) +} + +function extractUsedKeysFromAST(ast: ResourceNode): Set { + const keys = new Set() + traverseNode(ast, node => { + if (node.type === NodeTypes.Linked) { + if (node.key.type === NodeTypes.LinkedKey) { + keys.add(node.key.value) + } else if (node.key.type === NodeTypes.Literal) { + keys.add(node.key.value) + } else if (node.key.type === NodeTypes.List) { + keys.add(String(node.key.index)) + } + } + }) + return keys } diff --git a/lib/utils/message-compiler/parser-v8.ts b/lib/utils/message-compiler/parser-v8.ts new file mode 100644 index 00000000..0706294c --- /dev/null +++ b/lib/utils/message-compiler/parser-v8.ts @@ -0,0 +1,328 @@ +/** + * A simplified version of the message parser that handles messages like vue-i18n v8. + * This parser probably has poor performance. + */ +import type { + CompileError, + MessageNode, + NamedNode, + PluralNode, + TextNode, + ResourceNode, + SourceLocation, + ListNode, + LinkedNode, + LinkedModifierNode, + LinkedKeyNode +} from '@intlify/message-compiler' +import { NodeTypes } from '@intlify/message-compiler' +import lodash from 'lodash' + +export function parse( + code: string +): { + ast: ResourceNode + errors: CompileError[] +} { + const errors: CompileError[] = [] + const ast = parseAST(code, errors) + return { + ast, + errors + } +} + +class CodeContext { + public code: string + public buff: string + public offset: number + private lineStartIndices: number[] + private lines: string[] + constructor(code: string) { + this.code = code + this.buff = code + this.offset = 0 + this.lines = [] + this.lineStartIndices = [0] + const lineEndingPattern = /\r\n|[\r\n\u2028\u2029]/gu + let match + while ((match = lineEndingPattern.exec(this.code))) { + this.lines.push( + this.code.slice( + this.lineStartIndices[this.lineStartIndices.length - 1], + match.index + ) + ) + this.lineStartIndices.push(match.index + match[0].length) + } + this.lines.push( + this.code.slice(this.lineStartIndices[this.lineStartIndices.length - 1]) + ) + } + setOffset(offset: number) { + this.offset = offset + this.buff = this.code.slice(offset) + } + + getLocFromIndex(index: number) { + if (index === this.code.length) { + return { + line: this.lines.length, + column: this.lines[this.lines.length - 1].length + 1 + } + } + const lineNumber = lodash.sortedLastIndex(this.lineStartIndices, index) + return { + line: lineNumber, + column: index - this.lineStartIndices[lineNumber - 1] + 1 + } + } + + getNodeLoc(start: number, end: number) { + const startLoc = this.getLocFromIndex(start) + const endLoc = this.getLocFromIndex(end) + return { + start, + end, + loc: { + start: { ...startLoc, offset: start }, + end: { ...endLoc, offset: end } + } + } + } + setEndLoc( + node: { + end: number + loc?: SourceLocation + }, + end: number + ) { + const endLoc = this.getLocFromIndex(end) + node.end = end + node.loc!.end = { ...endLoc, offset: end } + } + createCompileError(message: string, offset: number) { + const loc = this.getLocFromIndex(offset) + const error: CompileError = new SyntaxError( + errorMessages[message] || message + ) as never + error.code = errorCodes[message] || errorCodes.UNEXPECTED_LEXICAL_ANALYSIS + error.location = { + start: { ...loc, offset }, + end: { ...loc, offset } + } + error.domain = 'parser' + return error + } +} + +const errorCodes: Record = { + UNTERMINATED_CLOSING_BRACE: 6, + EMPTY_PLACEHOLDER: 7, + UNEXPECTED_LEXICAL_ANALYSIS: 11 +} + +const errorMessages: Record = { + UNTERMINATED_CLOSING_BRACE: `Unterminated closing brace`, + EMPTY_PLACEHOLDER: `Empty placeholder`, + UNEXPECTED_LEXICAL_ANALYSIS: `Unexpected lexical analysis in token: '{0}'` +} + +function parseAST(code: string, errors: CompileError[]): ResourceNode { + const ctx = new CodeContext(code) + const regexp = /%?\{|@[\.:]|\s*\|\s*/u + let re + const node: ResourceNode = { + type: NodeTypes.Resource, + body: undefined as never, + ...ctx.getNodeLoc(0, code.length) + } + let messageNode: MessageNode = { + type: NodeTypes.Message, + items: [], + ...ctx.getNodeLoc(0, code.length) + } + let body: MessageNode | PluralNode = messageNode + while ((re = regexp.exec(ctx.buff))) { + const key = re[0] + const startOffset = ctx.offset + re.index + const endOffset = startOffset + key.length + if (ctx.offset < startOffset) { + const textNode: TextNode = { + type: NodeTypes.Text, + value: ctx.code.slice(ctx.offset, startOffset), + ...ctx.getNodeLoc(ctx.offset, startOffset) + } + messageNode.items.push(textNode) + } + if (key.trim() === '|') { + ctx.setEndLoc(messageNode, startOffset) + + if (body.type === NodeTypes.Message) { + const pluralNode: PluralNode = { + type: NodeTypes.Plural, + cases: [body], + start: body.start, + end: body.end, + loc: { + start: { ...body.loc!.start }, + end: { ...body.loc!.end } + } + } + body = pluralNode + } + messageNode = { + type: NodeTypes.Message, + items: [], + ...ctx.getNodeLoc(endOffset, endOffset) + } + body.cases.push(messageNode) + ctx.setOffset(endOffset) + continue + } + if (key === '{' || key === '%{') { + const endIndex = ctx.code.indexOf('}', endOffset) + let keyValue: string + if (endIndex > -1) { + keyValue = ctx.code.slice(endOffset, endIndex) + } else { + errors.push( + ctx.createCompileError('UNTERMINATED_CLOSING_BRACE', endOffset) + ) + keyValue = ctx.code.slice(endOffset) + } + + const placeholderEndOffset = endOffset + keyValue.length + 1 + + let node: NamedNode | ListNode | null = null + const trimmedKeyValue = keyValue.trim() + if (trimmedKeyValue) { + if (/^-?\d+$/u.test(trimmedKeyValue)) { + const listNode: ListNode = { + type: NodeTypes.List, + index: Number(trimmedKeyValue), + ...ctx.getNodeLoc(endOffset - 1, placeholderEndOffset) + } + node = listNode + } + if (!node) { + const namedNode: NamedNode = { + type: NodeTypes.Named, + key: trimmedKeyValue, + ...ctx.getNodeLoc(endOffset - 1, placeholderEndOffset) + } + if (!/^[a-zA-Z][a-zA-Z0-9_$]*$/.test(namedNode.key)) { + errors.push( + ctx.createCompileError('Unexpected placeholder key', endOffset) + ) + } + node = namedNode + } + + messageNode.items.push(node) + } else { + errors.push( + ctx.createCompileError('EMPTY_PLACEHOLDER', placeholderEndOffset - 1) + ) + } + + ctx.setOffset(placeholderEndOffset) + continue + } + if (key[0] === '@') { + ctx.setOffset(endOffset) + + messageNode.items.push(parseLiked(ctx, errors)) + continue + } + } + if (ctx.buff) { + const textNode: TextNode = { + type: NodeTypes.Text, + value: ctx.buff, + ...ctx.getNodeLoc(ctx.offset, code.length) + } + messageNode.items.push(textNode) + } + ctx.setEndLoc(messageNode, code.length) + ctx.setEndLoc(body, code.length) + node.body = body + return node +} + +function parseLiked(ctx: CodeContext, errors: CompileError[]) { + const linked: LinkedNode = { + type: NodeTypes.Linked, + key: undefined as never, + ...ctx.getNodeLoc(ctx.offset - 2, ctx.offset) + } + const mark = ctx.code[ctx.offset - 1] + if (mark === '.') { + const modifierValue = /^[a-z]*/u.exec(ctx.buff)![0] + const modifierEndOffset = ctx.offset + modifierValue.length + const modifier: LinkedModifierNode = { + type: NodeTypes.LinkedModifier, + value: modifierValue, + ...ctx.getNodeLoc(ctx.offset - 1, modifierEndOffset) + } + // empty modifier... + if (!modifier.value) { + errors.push( + ctx.createCompileError( + 'Expected linked modifier value', + modifier.loc!.start.offset + ) + ) + } + ctx.setOffset(modifierEndOffset) + linked.modifier = modifier + if (ctx.code[ctx.offset] !== ':') { + // empty key... + errors.push( + ctx.createCompileError('Expected linked key value', ctx.offset) + ) + const key: LinkedKeyNode = { + type: NodeTypes.LinkedKey, + value: '', + ...ctx.getNodeLoc(ctx.offset, ctx.offset) + } + linked.key = key + ctx.setEndLoc(linked, ctx.offset) + return linked + } + ctx.setOffset(ctx.offset + 1) + } + let paren = false + if (ctx.buff[0] === '(') { + ctx.setOffset(ctx.offset + 1) + paren = true + } + // see https://github.com/kazupon/vue-i18n/blob/96a676cca51b592f3f8718b149ef26b3c8e70a64/src/index.js#L28 + const keyValue = /^[\w\-_|.]*/u.exec(ctx.buff)![0] + const keyEndOffset = ctx.offset + keyValue.length + const key: LinkedKeyNode = { + type: NodeTypes.LinkedKey, + value: keyValue, + ...ctx.getNodeLoc(ctx.offset, keyEndOffset) + } + // empty key... + if (!key.value) { + errors.push( + ctx.createCompileError('Expected linked key value', key.loc!.start.offset) + ) + } + linked.key = key + ctx.setOffset(keyEndOffset) + if (paren) { + if (ctx.buff[0] === ')') { + ctx.setOffset(ctx.offset + 1) + } else { + errors.push( + ctx.createCompileError('Unterminated closing paren', ctx.offset) + ) + } + } + + ctx.setEndLoc(linked, ctx.offset) + return linked +} diff --git a/lib/utils/message-compiler/parser.ts b/lib/utils/message-compiler/parser.ts new file mode 100644 index 00000000..dbaf877f --- /dev/null +++ b/lib/utils/message-compiler/parser.ts @@ -0,0 +1,21 @@ +import type { CompileError, ResourceNode } from '@intlify/message-compiler' +import { createParser } from '@intlify/message-compiler' + +export function parse( + code: string +): { + ast: ResourceNode + errors: CompileError[] +} { + const errors: CompileError[] = [] + const parser = createParser({ + onError(error: CompileError) { + errors.push(error) + } + }) + const ast = parser.parse(code) + return { + ast, + errors + } +} diff --git a/lib/utils/message-compiler/traverser.ts b/lib/utils/message-compiler/traverser.ts new file mode 100644 index 00000000..d427abdb --- /dev/null +++ b/lib/utils/message-compiler/traverser.ts @@ -0,0 +1,56 @@ +import type { + LinkedKeyNode, + LinkedModifierNode, + LinkedNode, + ListNode, + LiteralNode, + MessageNode, + NamedNode, + PluralNode, + ResourceNode, + TextNode +} from '@intlify/message-compiler' +import { NodeTypes } from '@intlify/message-compiler' + +type MessageElementNode = + | TextNode + | NamedNode + | ListNode + | LiteralNode + | LinkedNode +type MessageASTNode = + | ResourceNode + | PluralNode + | MessageNode + | MessageElementNode + | LinkedKeyNode + | LinkedModifierNode + +function traverseNodes( + nodes: MessageASTNode[], + visit: (node: MessageASTNode) => void +): void { + for (let i = 0; i < nodes.length; i++) { + traverseNode(nodes[i], visit) + } +} +export function traverseNode( + node: MessageASTNode, + visit: (node: MessageASTNode) => void +): void { + if (!node) { + return + } + visit(node) + if (node.type === NodeTypes.Resource) { + traverseNode(node.body, visit) + } else if (node.type === NodeTypes.Plural) { + traverseNodes((node as PluralNode).cases, visit) + } else if (node.type === NodeTypes.Message) { + traverseNodes((node as MessageNode).items, visit) + } else if (node.type === NodeTypes.Linked) { + const linked = node as LinkedNode + if (linked.modifier) traverseNode(linked.modifier, visit) + traverseNode(linked.key, visit) + } +} diff --git a/lib/utils/message-compiler/utils.ts b/lib/utils/message-compiler/utils.ts new file mode 100644 index 00000000..60477ea6 --- /dev/null +++ b/lib/utils/message-compiler/utils.ts @@ -0,0 +1,26 @@ +import type { RuleContext } from '../../types' +import semver from 'semver' + +export type MessageSyntaxVersions = { + v8: boolean + v9: boolean + isNotSet: boolean +} + +export function getMessageSyntaxVersions( + context: RuleContext +): MessageSyntaxVersions { + const { settings } = context + const messageSyntaxVersion = + settings['vue-i18n'] && settings['vue-i18n'].messageSyntaxVersion + + if (!messageSyntaxVersion) { + return { v8: true, v9: true, isNotSet: true } + } + const range = new semver.Range(messageSyntaxVersion) + return { + v8: semver.intersects(range, '^8.0.0 || <=8.0.0'), + v9: semver.intersects(range, '>=9.0.0-0'), + isNotSet: false + } +} diff --git a/package.json b/package.json index e951c34b..31072cdc 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ } }, "dependencies": { + "@intlify/message-compiler": "^9.0.0-beta.15", "glob": "^7.1.3", "ignore": "^5.0.5", "js-yaml": "^3.14.0", @@ -31,6 +32,7 @@ "jsonc-eslint-parser": "^0.6.0", "lodash": "^4.17.11", "parse5": "^6.0.0", + "semver": "^7.3.4", "vue-eslint-parser": "^7.3.0", "yaml-eslint-parser": "^0.2.0" }, @@ -44,6 +46,7 @@ "@types/lodash": "^4.14.159", "@types/mocha": "^8.0.1", "@types/parse5": "^5.0.3", + "@types/semver": "^7.3.4", "@typescript-eslint/eslint-plugin": "^4.10.0", "@typescript-eslint/parser": "^4.10.0", "eslint": "^5.15.0 || ^6.0.0 || ^7.0.0", diff --git a/tests/lib/utils/collect-linked-keys.ts b/tests/lib/utils/collect-linked-keys.ts index d2823f84..a07a5d70 100644 --- a/tests/lib/utils/collect-linked-keys.ts +++ b/tests/lib/utils/collect-linked-keys.ts @@ -2,8 +2,19 @@ * @author Yosuke Ota */ import assert from 'assert' +import type { RuleContext } from '../../../lib/types' import { collectLinkedKeys } from '../../../lib/utils/collect-linked-keys' +function createContext(messageSyntaxVersion?: string): RuleContext { + return { + settings: { + 'vue-i18n': { + messageSyntaxVersion + } + } + } as RuleContext +} + describe('collectLinkedKeys', () => { it('should be get the keys used in the plain linked message.', () => { const object = { @@ -15,7 +26,7 @@ describe('collectLinkedKeys', () => { } const expected = ['message.dio', 'message.the_world'] - assert.deepStrictEqual(collectLinkedKeys(object), expected) + assert.deepStrictEqual(collectLinkedKeys(object, createContext()), expected) }) it('should be get the keys used in the formatting linked message.', () => { const object = { @@ -26,7 +37,7 @@ describe('collectLinkedKeys', () => { } const expected = ['message.homeAddress'] - assert.deepStrictEqual(collectLinkedKeys(object), expected) + assert.deepStrictEqual(collectLinkedKeys(object, createContext()), expected) }) it('should be get the keys used in the linked message with brackets.', () => { const object = { @@ -37,23 +48,36 @@ describe('collectLinkedKeys', () => { } const expected = ['message.dio'] - assert.deepStrictEqual(collectLinkedKeys(object), expected) + assert.deepStrictEqual(collectLinkedKeys(object, createContext()), expected) + }) + it('should be get the keys used in the linked message for v9.', () => { + const object = { + message: { + dio: 'DIO', + linked: "There's a reason, you lost, @:{'message.dio'}.", + list_linked: 'hi @:{42}!' + } + } + + const expected = ['message.dio', '42'] + assert.deepStrictEqual(collectLinkedKeys(object, createContext()), expected) }) - it('should be get the keys used in the linked message.', () => { + describe('should be get the keys used in the linked message.', () => { const object = { foo: { a: 'Hi', b: '@:foo.a lorem ipsum @:bar.a !!!!', c: { - a: '@:(bar.a) @:bar.b.a' + a: '@:(bar.b) @:bar.c.a', + b: "@:{'bar.d'}" }, d: 'Hello' }, bar: { a: 'Yes', b: { - a: '@.lower:foo.d', + a: '@.lower:foo.b', // invaid values b: null, c: 123, @@ -66,7 +90,33 @@ describe('collectLinkedKeys', () => { } } - const expected = ['foo.a', 'bar.a', 'bar.b.a', 'foo.d'] - assert.deepStrictEqual(collectLinkedKeys(object as never), expected) + it('v9', () => { + const expected = ['bar.a', 'bar.c.a', 'bar.d', 'foo.a', 'foo.b'] + assert.deepStrictEqual( + collectLinkedKeys(object as never, createContext('^9.0.0')).sort(), + expected + ) + }) + it('v8', () => { + const expected = ['bar.a', 'bar.b', 'bar.c.a', 'foo.a', 'foo.b'] + assert.deepStrictEqual( + collectLinkedKeys(object as never, createContext('^8.0.0')).sort(), + expected + ) + }) + it('default', () => { + const expected = ['bar.a', 'bar.b', 'bar.c.a', 'bar.d', 'foo.a', 'foo.b'] + assert.deepStrictEqual( + collectLinkedKeys(object as never, createContext()).sort(), + expected + ) + }) + it('>=v8', () => { + const expected = ['bar.a', 'bar.b', 'bar.c.a', 'bar.d', 'foo.a', 'foo.b'] + assert.deepStrictEqual( + collectLinkedKeys(object as never, createContext('>=8.0.0')).sort(), + expected + ) + }) }) }) diff --git a/tests/lib/utils/message-compiler/parser-v8-data.ts b/tests/lib/utils/message-compiler/parser-v8-data.ts new file mode 100644 index 00000000..e0a14fda --- /dev/null +++ b/tests/lib/utils/message-compiler/parser-v8-data.ts @@ -0,0 +1,449 @@ +export const errorsFixtures: { + code: string + expected: Record +}[] = [ + { + code: '@: empty key', + expected: { + ast: { + type: 'Resource', + body: { + type: 'Message', + items: [ + { + type: 'Linked', + key: { + type: 'LinkedKey', + value: '', + loc: [2, 2] + }, + loc: [0, 2] + }, + { + type: 'Text', + value: ' empty key', + loc: [2, 12] + } + ], + loc: [0, 12] + }, + loc: [0, 12] + }, + errors: [ + { + message: 'Expected linked key value', + code: 11, + location: [2, 2] + } + ] + } + }, + { + code: '@. empty mod', + expected: { + ast: { + type: 'Resource', + body: { + type: 'Message', + items: [ + { + type: 'Linked', + key: { + type: 'LinkedKey', + value: '', + loc: [2, 2] + }, + loc: [0, 2], + modifier: { + type: 'LinkedModifier', + value: '', + loc: [1, 2] + } + }, + { + type: 'Text', + value: ' empty mod', + loc: [2, 12] + } + ], + loc: [0, 12] + }, + loc: [0, 12] + }, + errors: [ + { + message: 'Expected linked modifier value', + code: 11, + location: [1, 1] + }, + { + message: 'Expected linked key value', + code: 11, + location: [2, 2] + } + ] + } + }, + { + code: '@.: empty key and mod', + expected: { + ast: { + type: 'Resource', + body: { + type: 'Message', + items: [ + { + type: 'Linked', + key: { + type: 'LinkedKey', + value: '', + loc: [3, 3] + }, + loc: [0, 3], + modifier: { + type: 'LinkedModifier', + value: '', + loc: [1, 2] + } + }, + { + type: 'Text', + value: ' empty key and mod', + loc: [3, 21] + } + ], + loc: [0, 21] + }, + loc: [0, 21] + }, + errors: [ + { + message: 'Expected linked modifier value', + code: 11, + location: [1, 1] + }, + { + message: 'Expected linked key value', + code: 11, + location: [3, 3] + } + ] + } + }, + { + code: '@.mod: empty key', + expected: { + ast: { + type: 'Resource', + body: { + type: 'Message', + items: [ + { + type: 'Linked', + key: { + type: 'LinkedKey', + value: '', + loc: [6, 6] + }, + loc: [0, 6], + modifier: { + type: 'LinkedModifier', + value: 'mod', + loc: [1, 5] + } + }, + { + type: 'Text', + value: ' empty key', + loc: [6, 16] + } + ], + loc: [0, 16] + }, + loc: [0, 16] + }, + errors: [ + { + message: 'Expected linked key value', + code: 11, + location: [6, 6] + } + ] + } + }, + { + code: '@.mod only mod', + expected: { + ast: { + type: 'Resource', + body: { + type: 'Message', + items: [ + { + type: 'Linked', + key: { + type: 'LinkedKey', + value: '', + loc: [5, 5] + }, + loc: [0, 5], + modifier: { + type: 'LinkedModifier', + value: 'mod', + loc: [1, 5] + } + }, + { + type: 'Text', + value: ' only mod', + loc: [5, 14] + } + ], + loc: [0, 14] + }, + loc: [0, 14] + }, + errors: [ + { + message: 'Expected linked key value', + code: 11, + location: [5, 5] + } + ] + } + }, + { + code: '@: empty key', + expected: { + ast: { + type: 'Resource', + body: { + type: 'Message', + items: [ + { + type: 'Linked', + key: { + type: 'LinkedKey', + value: '', + loc: [2, 2] + }, + loc: [0, 2] + }, + { + type: 'Text', + value: ' empty key', + loc: [2, 12] + } + ], + loc: [0, 12] + }, + loc: [0, 12] + }, + errors: [ + { + message: 'Expected linked key value', + code: 11, + location: [2, 2] + } + ] + } + }, + { + code: '@:() key with paren', + expected: { + ast: { + type: 'Resource', + body: { + type: 'Message', + items: [ + { + type: 'Linked', + key: { + type: 'LinkedKey', + value: '', + loc: [3, 3] + }, + loc: [0, 4] + }, + { + type: 'Text', + value: ' key with paren', + loc: [4, 19] + } + ], + loc: [0, 19] + }, + loc: [0, 19] + }, + errors: [ + { + message: 'Expected linked key value', + code: 11, + location: [3, 3] + } + ] + } + }, + { + code: '@:(foo) key with paren v8', + expected: { + ast: { + type: 'Resource', + body: { + type: 'Message', + items: [ + { + type: 'Linked', + key: { + type: 'LinkedKey', + value: 'foo', + loc: [3, 6] + }, + loc: [0, 7] + }, + { + type: 'Text', + value: ' key with paren v8', + loc: [7, 25] + } + ], + loc: [0, 25] + }, + loc: [0, 25] + }, + errors: [] + } + }, + { + code: 'unclose paren for v8 @:(foo.bar', + expected: { + ast: { + type: 'Resource', + body: { + type: 'Message', + items: [ + { + type: 'Text', + value: 'unclose paren for v8 ', + loc: [0, 21] + }, + { + type: 'Linked', + key: { + type: 'LinkedKey', + value: 'foo.bar', + loc: [24, 31] + }, + loc: [21, 31] + } + ], + loc: [0, 31] + }, + loc: [0, 31] + }, + errors: [ + { + message: 'Unterminated closing paren', + code: 11, + location: [31, 31] + } + ] + } + }, + { + code: 'unclose brace { foo', + expected: { + ast: { + type: 'Resource', + body: { + type: 'Message', + items: [ + { + type: 'Text', + value: 'unclose brace ', + loc: [0, 14] + }, + { + type: 'Named', + key: 'foo', + loc: [14, 20] + } + ], + loc: [0, 19] + }, + loc: [0, 19] + }, + errors: [ + { + message: 'Unterminated closing brace', + code: 6, + location: [15, 15] + } + ] + } + }, + { + code: 'error placeholder {foo.bar}', + expected: { + ast: { + type: 'Resource', + body: { + type: 'Message', + items: [ + { + type: 'Text', + value: 'error placeholder ', + loc: [0, 18] + }, + { + type: 'Named', + key: 'foo.bar', + loc: [18, 27] + } + ], + loc: [0, 27] + }, + loc: [0, 27] + }, + errors: [ + { + message: 'Unexpected placeholder key', + code: 11, + location: [19, 19] + } + ] + } + }, + { + code: 'error placeholder { foo.bar }', + expected: { + ast: { + type: 'Resource', + body: { + type: 'Message', + items: [ + { + type: 'Text', + value: 'error placeholder ', + loc: [0, 18] + }, + { + type: 'Named', + key: 'foo.bar', + loc: [18, 29] + } + ], + loc: [0, 29] + }, + loc: [0, 29] + }, + errors: [ + { + message: 'Unexpected placeholder key', + code: 11, + location: [19, 19] + } + ] + } + } +] diff --git a/tests/lib/utils/message-compiler/parser-v8.ts b/tests/lib/utils/message-compiler/parser-v8.ts new file mode 100644 index 00000000..274277b9 --- /dev/null +++ b/tests/lib/utils/message-compiler/parser-v8.ts @@ -0,0 +1,118 @@ +/** + * @author Yosuke Ota + */ +import assert from 'assert' +import { parse } from '../../../../lib/utils/message-compiler/parser-v8' +import { parse as parseForV9 } from '../../../../lib/utils/message-compiler/parser' +import { errorsFixtures } from './parser-v8-data' + +describe('parser-v8', () => { + describe('compare v9', () => { + const list = [ + 'message', + 'Hello World!', + 'Hello {target}!', + 'Hello %{target}!', + 'Hello { target }!', + 'Hello @:link', + 'Hello @.lower:link', + 'car | cars', + 'no apples | one apple | {count} apples', + 'no apples |\n one apple |\n {count} apples', + 'empty placeholder { }', + 'number placeholder { 42 }', + 'number placeholder { -42 }' + ] + for (const code of list) { + describe(JSON.stringify(code), () => { + it('should be equals', () => { + const v8 = normalize(parse(code)) + const v9 = normalize(parseForV9(code)) + assert.deepStrictEqual(v8, v9) + }) + }) + } + }) + describe('errors', () => { + const list = errorsFixtures + for (const { code, expected } of list) { + describe(JSON.stringify(code), () => { + it('should be equals', () => { + const parsed = simply(parse(code)) + try { + assert.deepStrictEqual(parsed, expected) + } catch (e) { + // require('fs').writeFileSync( + // __dirname + '/actual.json', + // JSON.stringify(parsed, null, 2) + // ) + throw e + } + }) + }) + } + }) +}) + +function normalize(obj: any) { + return JSON.parse( + JSON.stringify(obj, (key, value) => { + if (key === 'source' || key === 'domain') { + return undefined + } + if (key === 'end' && typeof value === 'number') { + return undefined + } + if (value instanceof Error) { + return { + // @ts-expect-error -- ignore + message: value.message, + ...value + } + } + return value + }) + ) +} +function simply(obj: any) { + return JSON.parse( + JSON.stringify(obj, (key, value) => { + if (key === 'domain') { + return undefined + } + if (key === 'end' && typeof value === 'number') { + return undefined + } + if (key === 'start' && typeof value === 'number') { + return undefined + } + if (key === 'loc' || key === 'location') { + return [value.start.offset, value.end.offset] + } + if (key === 'type') { + return NodeTypes[value] + } + if (value instanceof Error) { + return { + // @ts-expect-error -- ignore + message: value.message, + ...value + } + } + return value + }) + ) +} + +enum NodeTypes { + Resource = 0, + Plural = 1, + Message = 2, + Text = 3, + Named = 4, + List = 5, + Linked = 6, + LinkedKey = 7, + LinkedModifier = 8, + Literal = 9 +} diff --git a/tests/lib/utils/message-compiler/utils.ts b/tests/lib/utils/message-compiler/utils.ts new file mode 100644 index 00000000..6efc86d9 --- /dev/null +++ b/tests/lib/utils/message-compiler/utils.ts @@ -0,0 +1,48 @@ +/** + * @author Yosuke Ota + */ +import assert from 'assert' +import * as utils from '../../../../lib/utils/message-compiler/utils' +import type { RuleContext } from '../../../../lib/types' + +describe('message-compiler utils', () => { + describe('getMessageSyntaxVersions', () => { + function get(v: string) { + return utils.getMessageSyntaxVersions({ + settings: { 'vue-i18n': { messageSyntaxVersion: v } } + } as RuleContext) + } + it('should be equal to the expected value', () => { + assert.deepStrictEqual(get('^8.0.0'), { + v8: true, + v9: false, + isNotSet: false + }) + assert.deepStrictEqual(get('^9.0.0'), { + v8: false, + v9: true, + isNotSet: false + }) + assert.deepStrictEqual(get('^7.0.0'), { + v8: true, + v9: false, + isNotSet: false + }) + assert.deepStrictEqual(get('^10.0.0'), { + v8: false, + v9: true, + isNotSet: false + }) + assert.deepStrictEqual(get('>=5.0.0'), { + v8: true, + v9: true, + isNotSet: false + }) + assert.deepStrictEqual(get('^9.0.0-beta.8'), { + v8: false, + v9: true, + isNotSet: false + }) + }) + }) +}) diff --git a/yarn.lock b/yarn.lock index fb048f91..ad3e7fe3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -965,6 +965,25 @@ lodash "^4.17.13" to-fast-properties "^2.0.0" +"@intlify/message-compiler@^9.0.0-beta.15": + version "9.0.0-beta.15" + resolved "https://registry.yarnpkg.com/@intlify/message-compiler/-/message-compiler-9.0.0-beta.15.tgz#8529070003a19036c45c2dce239afbac2e61f07b" + integrity sha512-N1bh5dxLIrBNGM99O1/Uxvyb1IYmHl+sYcae88BKB2T5pvOSDC18Oa52pLjM0PMDFpKbQN+m/SGgiRPPRkp0nQ== + dependencies: + "@intlify/message-resolver" "9.0.0-beta.15" + "@intlify/shared" "9.0.0-beta.15" + source-map "0.6.1" + +"@intlify/message-resolver@9.0.0-beta.15": + version "9.0.0-beta.15" + resolved "https://registry.yarnpkg.com/@intlify/message-resolver/-/message-resolver-9.0.0-beta.15.tgz#ac1bf84923e76bd4356daf8c2e36530601fc49a5" + integrity sha512-i64xy281nzNJbFSruc0EgFKJKSvXI/1bhYliWq0qtYi6Mv2sIXb4+arOB69YYYBKuDDNLBjSCNCuCmab62sP0A== + +"@intlify/shared@9.0.0-beta.15": + version "9.0.0-beta.15" + resolved "https://registry.yarnpkg.com/@intlify/shared/-/shared-9.0.0-beta.15.tgz#beab8c49e0efd5cd5c4b9add2b71b9395e7cc7a1" + integrity sha512-f7qkzA8tUdGD5T7xUoNSiYA/qvPJSwu5DKt6xCrpitF2Ol+0QTLuk6jyZq81WKwo38DrTD/wjaM6STX/pF5zJg== + "@istanbuljs/load-nyc-config@^1.0.0": version "1.0.0" resolved "https://registry.yarnpkg.com/@istanbuljs/load-nyc-config/-/load-nyc-config-1.0.0.tgz#10602de5570baea82f8afbfa2630b24e7a8cfe5b" @@ -1239,6 +1258,11 @@ resolved "https://registry.yarnpkg.com/@types/q/-/q-1.5.2.tgz#690a1475b84f2a884fd07cd797c00f5f31356ea8" integrity sha512-ce5d3q03Ex0sy4R14722Rmt6MT07Ua+k4FwDfdcToYJcMKNtRVQvJ6JCAPdAmAnbRb6CsX6aYb9m96NGod9uTw== +"@types/semver@^7.3.4": + version "7.3.4" + resolved "https://registry.yarnpkg.com/@types/semver/-/semver-7.3.4.tgz#43d7168fec6fa0988bb1a513a697b29296721afb" + integrity sha512-+nVsLKlcUCeMzD2ufHEYuJ9a2ovstb6Dp52A5VsoKxDXgvE051XgHI/33I1EymwkRGQkwnA0LkhnUzituGs4EQ== + "@typescript-eslint/eslint-plugin@^4.10.0": version "4.10.0" resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-4.10.0.tgz#19ed3baf4bc4232c5a7fcd32eaca75c3a5baf9f3" @@ -6606,6 +6630,13 @@ lru-cache@^5.1.1: dependencies: yallist "^3.0.2" +lru-cache@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-6.0.0.tgz#6d6fe6570ebd96aaf90fcad1dafa3b2566db3a94" + integrity sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA== + dependencies: + yallist "^4.0.0" + macos-release@^2.2.0: version "2.3.0" resolved "https://registry.yarnpkg.com/macos-release/-/macos-release-2.3.0.tgz#eb1930b036c0800adebccd5f17bc4c12de8bb71f" @@ -9119,6 +9150,13 @@ semver@^7.2.1, semver@^7.3.2: resolved "https://registry.yarnpkg.com/semver/-/semver-7.3.2.tgz#604962b052b81ed0786aae84389ffba70ffd3938" integrity sha512-OrOb32TeeambH6UrhtShmF7CRDqhL6/5XpPNp2DuRH6+9QLw/orhp72j87v8Qa1ScDkvrrBNpZcDejAirJmfXQ== +semver@^7.3.4: + version "7.3.4" + resolved "https://registry.yarnpkg.com/semver/-/semver-7.3.4.tgz#27aaa7d2e4ca76452f98d3add093a72c943edc97" + integrity sha512-tCfb2WLjqFAtXn4KEdxIhalnRtoKFN7nAwj0B3ZXCbQloV2tq5eDbcTmT68JJD3nRJq24/XgxtQKFIpQdtvmVw== + dependencies: + lru-cache "^6.0.0" + send@0.17.1: version "0.17.1" resolved "https://registry.yarnpkg.com/send/-/send-0.17.1.tgz#c1d8b059f7900f7466dd4938bdc44e11ddb376c8" @@ -9467,16 +9505,16 @@ source-map@0.5.6: resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.5.6.tgz#75ce38f52bf0733c5a7f0c118d81334a2bb5f412" integrity sha1-dc449SvwczxafwwRjYEzSiu19BI= +source-map@0.6.1, source-map@^0.6.0, source-map@^0.6.1, source-map@~0.6.0, source-map@~0.6.1: + version "0.6.1" + resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263" + integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g== + source-map@^0.5.0, source-map@^0.5.6: version "0.5.7" resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.5.7.tgz#8a039d2d1021d22d1ea14c80d8ea468ba2ef3fcc" integrity sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w= -source-map@^0.6.0, source-map@^0.6.1, source-map@~0.6.0, source-map@~0.6.1: - version "0.6.1" - resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263" - integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g== - source-map@^0.7.3: version "0.7.3" resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.7.3.tgz#5302f8169031735226544092e64981f751750383"