Skip to content

Commit c392a38

Browse files
authoredApr 13, 2024··
fix: no-unused-keys rule not working when using flat config (#497)
* fix: `no-unused-keys` rule not working when using flat config * fix * Create wicked-carpets-sing.md * test * fix * fix * fix
1 parent e827a23 commit c392a38

18 files changed

+1369
-529
lines changed
 

‎.changeset/wicked-carpets-sing.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@intlify/eslint-plugin-vue-i18n": minor
3+
---
4+
5+
fix: `no-unused-keys` rule not working when using flat config

‎files/empty.json

-1
This file was deleted.

‎lib/utils/collect-keys.ts

+10-72
Original file line numberDiff line numberDiff line change
@@ -2,23 +2,19 @@
22
* @fileoverview Collect localization keys
33
* @author kazuya kawaguchi (a.k.a. kazupon)
44
*/
5-
import type { Linter } from 'eslint'
6-
import { parseForESLint, AST as VAST } from 'vue-eslint-parser'
7-
import { readFileSync } from 'fs'
5+
import { AST as VAST } from 'vue-eslint-parser'
86
import { resolve, extname } from 'path'
97
import { listFilesToProcess } from './glob-utils'
108
import { ResourceLoader } from './resource-loader'
119
import { CacheLoader } from './cache-loader'
1210
import { defineCacheFunction } from './cache-function'
1311
import debugBuilder from 'debug'
1412
import type { RuleContext, VisitorKeys } from '../types'
15-
// @ts-expect-error -- ignore
16-
import { Legacy } from '@eslint/eslintrc'
1713
import { getCwd } from './get-cwd'
1814
import { isStaticLiteral, getStaticLiteralValue } from './index'
19-
import importFresh from 'import-fresh'
15+
import type { Parser } from './parser-config-resolver'
16+
import { buildParserFromConfig } from './parser-config-resolver'
2017
const debug = debugBuilder('eslint-plugin-vue-i18n:collect-keys')
21-
const { CascadingConfigArrayFactory } = Legacy
2218

2319
/**
2420
*
@@ -74,56 +70,20 @@ function getKeyFromI18nComponent(node: VAST.VAttribute) {
7470
}
7571
}
7672

77-
function getParser(parser: string | undefined): {
78-
parseForESLint?: typeof parseForESLint
79-
parse: (code: string, options: unknown) => VAST.ESLintProgram
80-
} {
81-
if (parser) {
82-
try {
83-
return require(parser)
84-
} catch (_e) {
85-
// ignore
86-
}
87-
}
88-
return {
89-
parseForESLint,
90-
parse(code: string, options: unknown) {
91-
return parseForESLint(code, options).ast
92-
}
93-
}
94-
}
95-
9673
/**
9774
* Collect the used keys from source code text.
9875
* @param {string} text
9976
* @param {string} filename
10077
* @returns {string[]}
10178
*/
102-
function collectKeysFromText(
103-
text: string,
104-
filename: string,
105-
getConfigForFile: (filePath: string) => Linter.Config<Linter.RulesRecord>
106-
) {
79+
function collectKeysFromText(filename: string, parser: Parser) {
10780
const effectiveFilename = filename || '<text>'
10881
debug(`collectKeysFromFile ${effectiveFilename}`)
109-
const config = getConfigForFile(effectiveFilename)
110-
const parser = getParser(config.parser)
111-
112-
const parserOptions = Object.assign({}, config.parserOptions, {
113-
loc: true,
114-
range: true,
115-
raw: true,
116-
tokens: true,
117-
comment: true,
118-
eslintVisitorKeys: true,
119-
eslintScopeManager: true,
120-
filePath: effectiveFilename
121-
})
12282
try {
123-
const parseResult =
124-
typeof parser.parseForESLint === 'function'
125-
? parser.parseForESLint(text, parserOptions)
126-
: { ast: parser.parse(text, parserOptions) }
83+
const parseResult = parser(filename)
84+
if (!parseResult) {
85+
return []
86+
}
12787
return collectKeysFromAST(parseResult.ast, parseResult.visitorKeys)
12888
} catch (_e) {
12989
return []
@@ -137,20 +97,7 @@ function collectKeysFromText(
13797
function collectKeyResourcesFromFiles(fileNames: string[], cwd: string) {
13898
debug('collectKeysFromFiles', fileNames)
13999

140-
const configArrayFactory = new CascadingConfigArrayFactory({
141-
additionalPluginPool: new Map([
142-
['@intlify/vue-i18n', importFresh('../index')]
143-
]),
144-
cwd,
145-
async getEslintRecommendedConfig() {
146-
return await import('../../files/empty.json')
147-
},
148-
async getEslintAllConfig() {
149-
return await import('../../files/empty.json')
150-
},
151-
eslintRecommendedPath: require.resolve('../../files/empty.json'),
152-
eslintAllPath: require.resolve('../../files/empty.json')
153-
})
100+
const parser = buildParserFromConfig(cwd)
154101

155102
const results = []
156103

@@ -160,21 +107,12 @@ function collectKeyResourcesFromFiles(fileNames: string[], cwd: string) {
160107

161108
results.push(
162109
new ResourceLoader(resolve(filename), () => {
163-
const text = readFileSync(resolve(filename), 'utf8')
164-
return collectKeysFromText(text, filename, getConfigForFile)
110+
return collectKeysFromText(filename, parser)
165111
})
166112
)
167113
}
168114

169115
return results
170-
171-
function getConfigForFile(filePath: string) {
172-
const absolutePath = resolve(cwd, filePath)
173-
return configArrayFactory
174-
.getConfigArrayForFile(absolutePath)
175-
.extractConfig(absolutePath)
176-
.toCompatibleObjectAsConfigFileContent()
177-
}
178116
}
179117

180118
/**
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
// @ts-expect-error -- ignore
2+
import { createSyncFn } from 'synckit'
3+
import type { ParseResult, Parser } from '.'
4+
5+
const getSync = createSyncFn(require.resolve('./worker'))
6+
7+
/**
8+
* Build synchronously parser using the flat config
9+
*/
10+
export function buildParserUsingFlatConfig(cwd: string): Parser {
11+
return (filePath: string) => {
12+
return getSync(cwd, filePath) as ParseResult
13+
}
14+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import type { Parser } from '.'
2+
// @ts-expect-error -- ignore
3+
import { Legacy } from '@eslint/eslintrc'
4+
import path from 'path'
5+
import { parseByParser } from './parse-by-parser'
6+
const { CascadingConfigArrayFactory } = Legacy
7+
8+
/**
9+
* Build parser using legacy config
10+
*/
11+
export function buildParserUsingLegacyConfig(cwd: string): Parser {
12+
const configArrayFactory = new CascadingConfigArrayFactory({
13+
additionalPluginPool: new Map([
14+
['@intlify/vue-i18n', require('../../index')]
15+
]),
16+
cwd,
17+
getEslintRecommendedConfig() {
18+
return {}
19+
},
20+
getEslintAllConfig() {
21+
return {}
22+
}
23+
})
24+
25+
function getConfigForFile(filePath: string) {
26+
const absolutePath = path.resolve(cwd, filePath)
27+
return configArrayFactory
28+
.getConfigArrayForFile(absolutePath)
29+
.extractConfig(absolutePath)
30+
.toCompatibleObjectAsConfigFileContent()
31+
}
32+
33+
return (filePath: string) => {
34+
const config = getConfigForFile(filePath)
35+
36+
const parserOptions = Object.assign({}, config.parserOptions, {
37+
loc: true,
38+
range: true,
39+
raw: true,
40+
tokens: true,
41+
comment: true,
42+
eslintVisitorKeys: true,
43+
eslintScopeManager: true,
44+
filePath
45+
})
46+
return parseByParser(filePath, config.parser, parserOptions)
47+
}
48+
}
+24
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { shouldUseFlatConfig } from './should-use-flat-config'
2+
import type { AST as VAST } from 'vue-eslint-parser'
3+
import { buildParserUsingLegacyConfig } from './build-parser-using-legacy-config'
4+
import { buildParserUsingFlatConfig } from './build-parser-using-flat-config'
5+
6+
export type ParseResult = Pick<
7+
VAST.ESLintExtendedProgram,
8+
'ast' | 'visitorKeys'
9+
> | null
10+
export type Parser = (filePath: string) => ParseResult
11+
12+
const parsers: Record<string, undefined | Parser> = {}
13+
14+
export function buildParserFromConfig(cwd: string): Parser {
15+
const parser = parsers[cwd]
16+
if (parser) {
17+
return parser
18+
}
19+
if (shouldUseFlatConfig(cwd)) {
20+
return (parsers[cwd] = buildParserUsingFlatConfig(cwd))
21+
}
22+
23+
return (parsers[cwd] = buildParserUsingLegacyConfig(cwd))
24+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import type { Linter } from 'eslint'
2+
import { readFileSync } from 'fs'
3+
import path from 'path'
4+
import { parseForESLint } from 'vue-eslint-parser'
5+
import type { ParseResult } from '.'
6+
7+
export function parseByParser(
8+
filePath: string,
9+
parserDefine: Linter.ParserModule | string | undefined,
10+
parserOptions: unknown
11+
): ParseResult {
12+
const parser = getParser(parserDefine, filePath)
13+
try {
14+
const text = readFileSync(path.resolve(filePath), 'utf8')
15+
const parseResult =
16+
'parseForESLint' in parser && typeof parser.parseForESLint === 'function'
17+
? parser.parseForESLint(text, parserOptions)
18+
: // eslint-disable-next-line @typescript-eslint/no-explicit-any
19+
{ ast: (parser as any).parse(text, parserOptions) }
20+
return parseResult as ParseResult
21+
} catch (_e) {
22+
return null
23+
}
24+
}
25+
26+
function getParser(
27+
parser: Linter.ParserModule | string | undefined,
28+
filePath: string
29+
): Linter.ParserModule {
30+
if (parser) {
31+
if (typeof parser === 'string') {
32+
try {
33+
return require(parser)
34+
} catch (_e) {
35+
// ignore
36+
}
37+
} else {
38+
return parser
39+
}
40+
}
41+
if (filePath.endsWith('.vue')) {
42+
return { parseForESLint } as Linter.ParserModule
43+
}
44+
return require('espree')
45+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
/** copied from https://github.com/eslint/eslint/blob/v8.56.0/lib/eslint/flat-eslint.js#L1119 */
2+
3+
import path from 'path'
4+
import fs from 'fs'
5+
6+
const FLAT_CONFIG_FILENAMES = [
7+
'eslint.config.js',
8+
'eslint.config.mjs',
9+
'eslint.config.cjs'
10+
]
11+
/**
12+
* Returns whether flat config should be used.
13+
* @returns {Promise<boolean>} Whether flat config should be used.
14+
*/
15+
export function shouldUseFlatConfig(cwd: string): boolean {
16+
// eslint-disable-next-line no-process-env -- ignore
17+
switch (process.env.ESLINT_USE_FLAT_CONFIG) {
18+
case 'true':
19+
return true
20+
case 'false':
21+
return false
22+
default:
23+
// If neither explicitly enabled nor disabled, then use the presence
24+
// of a flat config file to determine enablement.
25+
return Boolean(findFlatConfigFile(cwd))
26+
}
27+
}
28+
29+
/**
30+
* Searches from the current working directory up until finding the
31+
* given flat config filename.
32+
* @param {string} cwd The current working directory to search from.
33+
* @returns {string|undefined} The filename if found or `undefined` if not.
34+
*/
35+
export function findFlatConfigFile(cwd: string) {
36+
return findUp(FLAT_CONFIG_FILENAMES, { cwd })
37+
}
38+
39+
/** We used https://github.com/sindresorhus/find-up/blob/b733bb70d3aa21b22fa011be8089110d467c317f/index.js#L94 as a reference */
40+
function findUp(names: string[], options: { cwd: string }) {
41+
let directory = path.resolve(options.cwd)
42+
const { root } = path.parse(directory)
43+
const stopAt = path.resolve(directory, root)
44+
// eslint-disable-next-line no-constant-condition -- ignore
45+
while (true) {
46+
for (const name of names) {
47+
const target = path.resolve(directory, name)
48+
const stat = fs.existsSync(target)
49+
? fs.statSync(target, {
50+
throwIfNoEntry: false
51+
})
52+
: null
53+
if (stat?.isFile()) {
54+
return target
55+
}
56+
}
57+
58+
if (directory === stopAt) {
59+
break
60+
}
61+
62+
directory = path.dirname(directory)
63+
}
64+
65+
return null
66+
}
+42
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
// @ts-expect-error -- ignore
2+
import { runAsWorker } from 'synckit'
3+
import { getESLint } from 'eslint-compat-utils/eslint'
4+
import type { Linter } from 'eslint'
5+
import type { ParseResult } from '.'
6+
import { parseByParser } from './parse-by-parser'
7+
const ESLint = getESLint()
8+
9+
runAsWorker(async (cwd: string, filePath: string): Promise<ParseResult> => {
10+
const eslint = new ESLint({ cwd })
11+
const config: Linter.FlatConfig = await eslint.calculateConfigForFile(
12+
filePath
13+
)
14+
const languageOptions = config.languageOptions || {}
15+
const parserOptions = Object.assign(
16+
{
17+
sourceType: languageOptions.sourceType || 'module',
18+
ecmaVersion: languageOptions.ecmaVersion || 'latest'
19+
},
20+
languageOptions.parserOptions,
21+
{
22+
loc: true,
23+
range: true,
24+
raw: true,
25+
tokens: true,
26+
comment: true,
27+
eslintVisitorKeys: true,
28+
eslintScopeManager: true,
29+
filePath
30+
}
31+
)
32+
33+
const result = parseByParser(filePath, languageOptions.parser, parserOptions)
34+
if (!result) {
35+
return null
36+
}
37+
38+
return {
39+
ast: result.ast,
40+
visitorKeys: result?.visitorKeys
41+
}
42+
})

‎package.json

+4-2
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@
3434
"files": [
3535
"dist"
3636
],
37-
"main": "dist/lib/index.js",
37+
"main": "dist/index.js",
3838
"scripts": {
3939
"build": "tsc --project ./tsconfig.build.json",
4040
"clean": "git clean -fx .nyc_output coverage dist docs/.vitepress/dist",
@@ -75,6 +75,7 @@
7575
"lodash": "^4.17.21",
7676
"parse5": "^7.1.2",
7777
"semver": "^7.5.4",
78+
"synckit": "^0.9.0",
7879
"vue-eslint-parser": "^9.3.1",
7980
"yaml-eslint-parser": "^1.2.2"
8081
},
@@ -100,7 +101,7 @@
100101
"eslint-config-prettier": "^9.0.0",
101102
"eslint-plugin-markdown": "^3.0.0",
102103
"eslint-plugin-prettier": "^4.2.1",
103-
"eslint-plugin-vue": "^9.15.1",
104+
"eslint-plugin-vue": "^9.24.1",
104105
"eslint4b": "^7.32.0",
105106
"espree": "^9.6.1",
106107
"esquery": "^1.5.0",
@@ -114,6 +115,7 @@
114115
"opener": "^1.5.2",
115116
"path-scurry": "^1.10.1",
116117
"prettier": "^2.8.8",
118+
"ts-node": "^10.9.2",
117119
"typescript": "^5.1.6",
118120
"vitepress": "^1.0.2",
119121
"vue-eslint-editor": "^1.1.0",

‎pnpm-lock.yaml

+179-21
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
const base = require("../../../../../lib/configs/flat/base");
2+
module.exports = [
3+
...base,
4+
{
5+
files: ['**/*.vue', '*.vue'],
6+
languageOptions: {
7+
parser: require('vue-eslint-parser'),
8+
parserOptions: {
9+
parser: "@typescript-eslint/parser",
10+
},
11+
},
12+
},
13+
{
14+
files: ['**/*.ts', '*.ts'],
15+
languageOptions: {
16+
parser: require('@typescript-eslint/parser'),
17+
},
18+
},
19+
{
20+
rules: {
21+
"@intlify/vue-i18n/no-unused-keys": [
22+
"error",
23+
{
24+
src: "./src",
25+
extensions: [".tsx", ".ts", ".vue"],
26+
enableFix: true,
27+
},
28+
],
29+
},
30+
settings: {
31+
"vue-i18n": {
32+
localeDir: {
33+
pattern: `./locales/*.{json,yaml,yml}`,
34+
localeKey: "file",
35+
},
36+
},
37+
},
38+
},
39+
];
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
{
2+
"hello": "hello world",
3+
"messages": {
4+
"hello": "hi DIO!",
5+
"link": "@:message.hello",
6+
"nested": {
7+
"hello": "hi jojo!"
8+
}
9+
},
10+
"hello_dio": "hello underscore DIO!",
11+
"hello {name}": "hello {name}!",
12+
"hello-dio": "hello hyphen DIO!"
13+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
hello: "ハローワールド"
2+
messages:
3+
hello: "こんにちは、DIO!"
4+
link: "@:message.hello"
5+
nested:
6+
hello: "こんにちは、ジョジョ!"
7+
hello_dio: "こんにちは、アンダースコア DIO!"
8+
"hello {name}": "こんにちは、{name}!"
9+
hello-dio: "こんにちは、ハイフン DIO!"
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
<template>
2+
<div id="app">
3+
<p v-t="'hello_dio'">{{ $t('messages.hello') }}</p>
4+
</div>
5+
</template>
6+
7+
<script lang="ts">
8+
export default {
9+
name: 'App' as const,
10+
created() {
11+
this.$i18n.t('hello {name}', { name: 'DIO' })
12+
}
13+
}
14+
</script>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
const $t = (a:any) => {}
2+
$t('hello')

‎tests/lib/rules/no-unused-keys.ts

+842-432
Large diffs are not rendered by default.

‎tests/lib/test-utils.ts

+13-1
Original file line numberDiff line numberDiff line change
@@ -5,23 +5,29 @@ import type { RuleTester } from 'eslint'
55
import * as vueParser from 'vue-eslint-parser'
66
import * as jsonParser from 'jsonc-eslint-parser'
77
import * as yamlParser from 'yaml-eslint-parser'
8+
import { satisfies } from 'semver'
9+
import { version } from 'eslint/package.json'
810

911
type LanguageOptions = {
1012
parser: object
1113
}
1214

1315
export function getTestCasesFromFixtures(testOptions: {
16+
eslint?: string
1417
cwd: string
1518
options?: unknown[]
1619
localeDir?: SettingsVueI18nLocaleDir
1720
languageOptions?: LanguageOptions
21+
only?: boolean
1822
}): IterableIterator<RuleTester.ValidTestCase>
1923
export function getTestCasesFromFixtures(
2024
testOptions: {
25+
eslint?: string
2126
cwd: string
2227
options?: unknown[]
2328
localeDir?: SettingsVueI18nLocaleDir
2429
languageOptions?: LanguageOptions
30+
only?: boolean
2531
},
2632
outputs: {
2733
[file: string]:
@@ -31,10 +37,12 @@ export function getTestCasesFromFixtures(
3137
): IterableIterator<RuleTester.InvalidTestCase>
3238
export function* getTestCasesFromFixtures(
3339
testOptions: {
40+
eslint?: string
3441
cwd: string
3542
options?: unknown[]
3643
localeDir?: SettingsVueI18nLocaleDir
3744
languageOptions?: LanguageOptions
45+
only?: boolean
3846
},
3947
outputs?: {
4048
[file: string]:
@@ -45,6 +53,9 @@ export function* getTestCasesFromFixtures(
4553
if (!testOptions) {
4654
return
4755
}
56+
if (testOptions.eslint && !satisfies(version, testOptions.eslint)) {
57+
return
58+
}
4859
for (const { filename, relative, parser } of extractTargetFiles(
4960
testOptions.cwd
5061
)) {
@@ -62,7 +73,8 @@ export function* getTestCasesFromFixtures(
6273
localeDir: testOptions.localeDir,
6374
cwd: testOptions.cwd
6475
}
65-
}
76+
},
77+
...(testOptions.only ? { only: true } : {})
6678
}
6779
if (outputs) {
6880
const output = outputs[relative]

0 commit comments

Comments
 (0)
Please sign in to comment.