diff --git a/lib/index.js b/lib/index.js
index 1bde6e3d..925113bb 100644
--- a/lib/index.js
+++ b/lib/index.js
@@ -1,6 +1,6 @@
/**
* @fileoverview ESLint plugin for vue-i18n
- * @author kazuya kawaguchi
+ * @author kazuya kawaguchi (a.k.a. kazupon)
*/
'use strict'
@@ -14,7 +14,7 @@
// import all rules in lib/rules
module.exports.rules = {
- // add your processors here
+ 'no-missing-key': require('./rules/no-missing-key')
}
// import processors
diff --git a/lib/rules/no-missing-key.js b/lib/rules/no-missing-key.js
new file mode 100644
index 00000000..c828f8ed
--- /dev/null
+++ b/lib/rules/no-missing-key.js
@@ -0,0 +1,87 @@
+/**
+ * @author kazuya kawaguchi (a.k.a. kazupon)
+ */
+'use strict'
+
+const {
+ defineTemplateBodyVisitor,
+ loadLocaleMessages,
+ findMissingsFromLocaleMessages
+} = require('../utils/index')
+
+let localeMessages = null // cache
+
+function create (context) {
+ const { settings } = context
+ const localeDir = settings['vue-i18n'].localeDir
+ localeMessages = localeMessages || loadLocaleMessages(localeDir)
+
+ return defineTemplateBodyVisitor(context, {
+ "VAttribute[directive=true][key.name='t']" (node) {
+ checkDirective(context, localeDir, localeMessages, node)
+ },
+
+ "VAttribute[directive=true][key.name.name='t']" (node) {
+ checkDirective(context, localeDir, localeMessages, node)
+ },
+
+ CallExpression (node) {
+ checkCallExpression(context, localeDir, localeMessages, node)
+ }
+ }, {
+ CallExpression (node) {
+ checkCallExpression(context, localeDir, localeMessages, node)
+ }
+ })
+}
+
+function checkDirective (context, localeDir, localeMessages, node) {
+ if ((node.value && node.value.type === 'VExpressionContainer') &&
+ (node.value.expression && node.value.expression.type === 'Literal')) {
+ const key = node.value.expression.value
+ if (!key) {
+ // TODO: should be error
+ return
+ }
+ const missings = findMissingsFromLocaleMessages(localeMessages, key, localeDir)
+ if (missings.length) {
+ missings.forEach(missing => context.report({ node, ...missing }))
+ }
+ }
+}
+
+function checkCallExpression (context, localeDir, localeMessages, node) {
+ const funcName = (node.callee.type === 'MemberExpression' && node.callee.property.name) || node.callee.name
+
+ if (!/^(\$t|t|\$tc|tc)$/.test(funcName) || !node.arguments || !node.arguments.length) {
+ return
+ }
+
+ const [keyNode] = node.arguments
+ if (keyNode.type !== 'Literal') { return }
+
+ const key = keyNode.value
+ if (!key) {
+ // TODO: should be error
+ return
+ }
+
+ const missings = findMissingsFromLocaleMessages(localeMessages, key, localeDir)
+ if (missings.length) {
+ missings.forEach(missing => context.report({ node, ...missing }))
+ }
+}
+
+module.exports = {
+ meta: {
+ type: 'problem',
+ docs: {
+ description: 'disallow missing locale message key at localization methods',
+ category: 'Possible Errors',
+ recommended: true
+ },
+ fixable: null,
+ schema: []
+ },
+ create
+}
diff --git a/lib/rules/no-missing.js b/lib/rules/no-missing.js
deleted file mode 100644
index 90d960b6..00000000
--- a/lib/rules/no-missing.js
+++ /dev/null
@@ -1,12 +0,0 @@
-'use strict'
-
-function create (context) {
- return {
- }
-}
-
-module.exports = {
- meta: {
- },
- create
-}
diff --git a/lib/utils/index.js b/lib/utils/index.js
new file mode 100644
index 00000000..656d0833
--- /dev/null
+++ b/lib/utils/index.js
@@ -0,0 +1,55 @@
+/**
+ * @author kazuya kawaguchi (a.k.a. kazupon)
+ */
+'use strict'
+
+const glob = require('glob')
+const { resolve } = require('path')
+
+function defineTemplateBodyVisitor (context, templateBodyVisitor, scriptVisitor) {
+ if (context.parserServices.defineTemplateBodyVisitor === null) {
+ context.report({
+ loc: { line: 1, column: 0 },
+ message: 'Use the latest vue-eslint-parser. See also https://github.com/vuejs/eslint-plugin-vue#what-is-the-use-the-latest-vue-eslint-parser-error'
+ })
+ return {}
+ }
+ return context.parserServices.defineTemplateBodyVisitor(templateBodyVisitor, scriptVisitor)
+}
+
+function loadLocaleMessages (pattern) {
+ const files = glob.sync(pattern)
+ return files.map(file => {
+ const path = resolve(process.cwd(), file)
+ const filename = file.replace(/^.*(\\|\/|:)/, '')
+ const messages = require(path)
+ return { path: file, filename, messages }
+ })
+}
+
+function findMissingsFromLocaleMessages (localeMessages, key) {
+ const missings = []
+ const paths = key.split('.')
+ localeMessages.forEach(localeMessage => {
+ const length = paths.length
+ let last = localeMessage.messages
+ let i = 0
+ while (i < length) {
+ const value = last[paths[i]]
+ if (value === undefined) {
+ missings.push({
+ message: `'${key}' does not exist in '${localeMessage.path}'`
+ })
+ }
+ last = value
+ i++
+ }
+ })
+ return missings
+}
+
+module.exports = {
+ defineTemplateBodyVisitor,
+ loadLocaleMessages,
+ findMissingsFromLocaleMessages
+}
diff --git a/tests/fixtures/locales/en.json b/tests/fixtures/locales/en.json
new file mode 100644
index 00000000..2714014b
--- /dev/null
+++ b/tests/fixtures/locales/en.json
@@ -0,0 +1,14 @@
+{
+ "hello": "hello world",
+ "messages": {
+ "hello": "hi DIO!",
+ "link": "@:message.hello",
+ "nested": {
+ "hello": "hi jojo!"
+ }
+ },
+ "hello_dio": "hello underscore DIO!",
+ "hello {name}": "hello {name}!",
+ "hello-dio": "hello hyphen DIO!",
+ "foo.bar.buz": "hi flat key!"
+}
diff --git a/tests/fixtures/locales/ja.json b/tests/fixtures/locales/ja.json
new file mode 100644
index 00000000..ee642ce2
--- /dev/null
+++ b/tests/fixtures/locales/ja.json
@@ -0,0 +1,14 @@
+{
+ "hello": "ハローワールド",
+ "messages": {
+ "hello": "こんにちは、DIO!",
+ "link": "@:message.hello",
+ "nested": {
+ "hello": "こんにちは、ジョジョ!"
+ }
+ },
+ "hello_dio": "こんにちは、アンダースコア DIO!",
+ "hello {name}": "こんにちは、{name}!",
+ "hello-dio": "こんにちは、ハイフン DIO!",
+ "foo.bar.buz": "こんにちは、フラットなキー!"
+}
\ No newline at end of file
diff --git a/tests/lib/rules/no-missing-key.js b/tests/lib/rules/no-missing-key.js
new file mode 100644
index 00000000..41f372d1
--- /dev/null
+++ b/tests/lib/rules/no-missing-key.js
@@ -0,0 +1,91 @@
+/**
+ * @author kazuya kawaguchi (a.k.a. kazupon)
+ */
+'use strict'
+
+const RuleTester = require('eslint').RuleTester
+const rule = require('../../../lib/rules/no-missing-key')
+
+const baseDir = './tests/fixtures/locales'
+const resolve = file => `${baseDir}/${file}`
+
+const settings = {
+ 'vue-i18n': {
+ localeDir: `${baseDir}/*.json`
+ }
+}
+
+const tester = new RuleTester({
+ parser: 'vue-eslint-parser',
+ parserOptions: { ecmaVersion: 2015 }
+})
+
+tester.run('no-missing-key', rule, {
+ valid: [{
+ // basic key
+ settings,
+ code: `$t('hello')`
+ }, {
+ // nested key
+ settings,
+ code: `t('messages.nested.hello')`
+ }, {
+ // linked key
+ settings,
+ code: `$tc('messages.hello.link')`
+ }, {
+ // hypened key
+ settings,
+ code: `tc('hello-dio')`
+ }, {
+ // key like the message
+ settings,
+ code: `$t('hello {name}')`
+ }, {
+ // Identifier
+ settings,
+ code: `$t(key)`
+ }, {
+ // using mustaches in template block
+ settings,
+ code: `
+ {{ $t('hello') }}
+ `
+ }, {
+ // using custom directive in template block
+ settings,
+ code: `
+
+ `
+ }],
+
+ invalid: [{
+ // basic
+ settings,
+ code: `$t('missing')`,
+ errors: [
+ `'missing' does not exist in '${resolve('en.json')}'`,
+ `'missing' does not exist in '${resolve('ja.json')}'`
+ ]
+ }, {
+ // using mustaches in template block
+ settings,
+ code: `
+ {{ $t('missing') }}
+ `,
+ errors: [
+ `'missing' does not exist in '${resolve('en.json')}'`,
+ `'missing' does not exist in '${resolve('ja.json')}'`
+ ]
+ }, {
+ // using custom directive in template block
+ settings,
+ code: `
+
+ `,
+ errors: [
+ `'missing' does not exist in '${resolve('en.json')}'`,
+ `'missing' does not exist in '${resolve('ja.json')}'`
+ ]
+ }]
+})
diff --git a/tests/lib/rules/no-missing.js b/tests/lib/rules/no-missing.js
deleted file mode 100644
index 72e872e6..00000000
--- a/tests/lib/rules/no-missing.js
+++ /dev/null
@@ -1,12 +0,0 @@
-'use strict'
-
-const RuleTester = require('eslint').RuleTester
-
-const tester = new RuleTester()
-
-tester.run('no-missing', require('../../lib/rules/no-missing'), {
- valid: [
- ],
- invalid: [
- ]
-})