Skip to content

Commit

Permalink
fix: no-unused-keys rule not working when using flat config (#497)
Browse files Browse the repository at this point in the history
* fix: `no-unused-keys` rule not working when using flat config

* fix

* Create wicked-carpets-sing.md

* test

* fix

* fix

* fix
  • Loading branch information
ota-meshi committed Apr 13, 2024
1 parent e827a23 commit c392a38
Show file tree
Hide file tree
Showing 18 changed files with 1,369 additions and 529 deletions.
5 changes: 5 additions & 0 deletions .changeset/wicked-carpets-sing.md
@@ -0,0 +1,5 @@
---
"@intlify/eslint-plugin-vue-i18n": minor
---

fix: `no-unused-keys` rule not working when using flat config
1 change: 0 additions & 1 deletion files/empty.json

This file was deleted.

82 changes: 10 additions & 72 deletions lib/utils/collect-keys.ts
Expand Up @@ -2,23 +2,19 @@
* @fileoverview Collect localization keys
* @author kazuya kawaguchi (a.k.a. kazupon)
*/
import type { Linter } from 'eslint'
import { parseForESLint, AST as VAST } from 'vue-eslint-parser'
import { readFileSync } from 'fs'
import { AST as VAST } from 'vue-eslint-parser'
import { resolve, extname } from 'path'
import { listFilesToProcess } from './glob-utils'
import { ResourceLoader } from './resource-loader'
import { CacheLoader } from './cache-loader'
import { defineCacheFunction } from './cache-function'
import debugBuilder from 'debug'
import type { RuleContext, VisitorKeys } from '../types'
// @ts-expect-error -- ignore
import { Legacy } from '@eslint/eslintrc'
import { getCwd } from './get-cwd'
import { isStaticLiteral, getStaticLiteralValue } from './index'
import importFresh from 'import-fresh'
import type { Parser } from './parser-config-resolver'
import { buildParserFromConfig } from './parser-config-resolver'
const debug = debugBuilder('eslint-plugin-vue-i18n:collect-keys')
const { CascadingConfigArrayFactory } = Legacy

/**
*
Expand Down Expand Up @@ -74,56 +70,20 @@ function getKeyFromI18nComponent(node: VAST.VAttribute) {
}
}

function getParser(parser: string | undefined): {
parseForESLint?: typeof parseForESLint
parse: (code: string, options: unknown) => VAST.ESLintProgram
} {
if (parser) {
try {
return require(parser)
} catch (_e) {
// ignore
}
}
return {
parseForESLint,
parse(code: string, options: unknown) {
return parseForESLint(code, options).ast
}
}
}

/**
* Collect the used keys from source code text.
* @param {string} text
* @param {string} filename
* @returns {string[]}
*/
function collectKeysFromText(
text: string,
filename: string,
getConfigForFile: (filePath: string) => Linter.Config<Linter.RulesRecord>
) {
function collectKeysFromText(filename: string, parser: Parser) {
const effectiveFilename = filename || '<text>'
debug(`collectKeysFromFile ${effectiveFilename}`)
const config = getConfigForFile(effectiveFilename)
const parser = getParser(config.parser)

const parserOptions = Object.assign({}, config.parserOptions, {
loc: true,
range: true,
raw: true,
tokens: true,
comment: true,
eslintVisitorKeys: true,
eslintScopeManager: true,
filePath: effectiveFilename
})
try {
const parseResult =
typeof parser.parseForESLint === 'function'
? parser.parseForESLint(text, parserOptions)
: { ast: parser.parse(text, parserOptions) }
const parseResult = parser(filename)
if (!parseResult) {
return []
}
return collectKeysFromAST(parseResult.ast, parseResult.visitorKeys)
} catch (_e) {
return []
Expand All @@ -137,20 +97,7 @@ function collectKeysFromText(
function collectKeyResourcesFromFiles(fileNames: string[], cwd: string) {
debug('collectKeysFromFiles', fileNames)

const configArrayFactory = new CascadingConfigArrayFactory({
additionalPluginPool: new Map([
['@intlify/vue-i18n', importFresh('../index')]
]),
cwd,
async getEslintRecommendedConfig() {
return await import('../../files/empty.json')
},
async getEslintAllConfig() {
return await import('../../files/empty.json')
},
eslintRecommendedPath: require.resolve('../../files/empty.json'),
eslintAllPath: require.resolve('../../files/empty.json')
})
const parser = buildParserFromConfig(cwd)

const results = []

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

results.push(
new ResourceLoader(resolve(filename), () => {
const text = readFileSync(resolve(filename), 'utf8')
return collectKeysFromText(text, filename, getConfigForFile)
return collectKeysFromText(filename, parser)
})
)
}

return results

function getConfigForFile(filePath: string) {
const absolutePath = resolve(cwd, filePath)
return configArrayFactory
.getConfigArrayForFile(absolutePath)
.extractConfig(absolutePath)
.toCompatibleObjectAsConfigFileContent()
}
}

/**
Expand Down
14 changes: 14 additions & 0 deletions lib/utils/parser-config-resolver/build-parser-using-flat-config.ts
@@ -0,0 +1,14 @@
// @ts-expect-error -- ignore
import { createSyncFn } from 'synckit'
import type { ParseResult, Parser } from '.'

const getSync = createSyncFn(require.resolve('./worker'))

/**
* Build synchronously parser using the flat config
*/
export function buildParserUsingFlatConfig(cwd: string): Parser {
return (filePath: string) => {
return getSync(cwd, filePath) as ParseResult
}
}
@@ -0,0 +1,48 @@
import type { Parser } from '.'
// @ts-expect-error -- ignore
import { Legacy } from '@eslint/eslintrc'
import path from 'path'
import { parseByParser } from './parse-by-parser'
const { CascadingConfigArrayFactory } = Legacy

/**
* Build parser using legacy config
*/
export function buildParserUsingLegacyConfig(cwd: string): Parser {
const configArrayFactory = new CascadingConfigArrayFactory({
additionalPluginPool: new Map([
['@intlify/vue-i18n', require('../../index')]
]),
cwd,
getEslintRecommendedConfig() {
return {}
},
getEslintAllConfig() {
return {}
}
})

function getConfigForFile(filePath: string) {
const absolutePath = path.resolve(cwd, filePath)
return configArrayFactory
.getConfigArrayForFile(absolutePath)
.extractConfig(absolutePath)
.toCompatibleObjectAsConfigFileContent()
}

return (filePath: string) => {
const config = getConfigForFile(filePath)

const parserOptions = Object.assign({}, config.parserOptions, {
loc: true,
range: true,
raw: true,
tokens: true,
comment: true,
eslintVisitorKeys: true,
eslintScopeManager: true,
filePath
})
return parseByParser(filePath, config.parser, parserOptions)
}
}
24 changes: 24 additions & 0 deletions lib/utils/parser-config-resolver/index.ts
@@ -0,0 +1,24 @@
import { shouldUseFlatConfig } from './should-use-flat-config'
import type { AST as VAST } from 'vue-eslint-parser'
import { buildParserUsingLegacyConfig } from './build-parser-using-legacy-config'
import { buildParserUsingFlatConfig } from './build-parser-using-flat-config'

export type ParseResult = Pick<
VAST.ESLintExtendedProgram,
'ast' | 'visitorKeys'
> | null
export type Parser = (filePath: string) => ParseResult

const parsers: Record<string, undefined | Parser> = {}

export function buildParserFromConfig(cwd: string): Parser {
const parser = parsers[cwd]
if (parser) {
return parser
}
if (shouldUseFlatConfig(cwd)) {
return (parsers[cwd] = buildParserUsingFlatConfig(cwd))
}

return (parsers[cwd] = buildParserUsingLegacyConfig(cwd))
}
45 changes: 45 additions & 0 deletions lib/utils/parser-config-resolver/parse-by-parser.ts
@@ -0,0 +1,45 @@
import type { Linter } from 'eslint'
import { readFileSync } from 'fs'
import path from 'path'
import { parseForESLint } from 'vue-eslint-parser'
import type { ParseResult } from '.'

export function parseByParser(
filePath: string,
parserDefine: Linter.ParserModule | string | undefined,
parserOptions: unknown
): ParseResult {
const parser = getParser(parserDefine, filePath)
try {
const text = readFileSync(path.resolve(filePath), 'utf8')
const parseResult =
'parseForESLint' in parser && typeof parser.parseForESLint === 'function'
? parser.parseForESLint(text, parserOptions)
: // eslint-disable-next-line @typescript-eslint/no-explicit-any
{ ast: (parser as any).parse(text, parserOptions) }
return parseResult as ParseResult
} catch (_e) {
return null
}
}

function getParser(
parser: Linter.ParserModule | string | undefined,
filePath: string
): Linter.ParserModule {
if (parser) {
if (typeof parser === 'string') {
try {
return require(parser)
} catch (_e) {
// ignore
}
} else {
return parser
}
}
if (filePath.endsWith('.vue')) {
return { parseForESLint } as Linter.ParserModule
}
return require('espree')
}
66 changes: 66 additions & 0 deletions lib/utils/parser-config-resolver/should-use-flat-config.ts
@@ -0,0 +1,66 @@
/** copied from https://github.com/eslint/eslint/blob/v8.56.0/lib/eslint/flat-eslint.js#L1119 */

import path from 'path'
import fs from 'fs'

const FLAT_CONFIG_FILENAMES = [
'eslint.config.js',
'eslint.config.mjs',
'eslint.config.cjs'
]
/**
* Returns whether flat config should be used.
* @returns {Promise<boolean>} Whether flat config should be used.
*/
export function shouldUseFlatConfig(cwd: string): boolean {
// eslint-disable-next-line no-process-env -- ignore
switch (process.env.ESLINT_USE_FLAT_CONFIG) {
case 'true':
return true
case 'false':
return false
default:
// If neither explicitly enabled nor disabled, then use the presence
// of a flat config file to determine enablement.
return Boolean(findFlatConfigFile(cwd))
}
}

/**
* Searches from the current working directory up until finding the
* given flat config filename.
* @param {string} cwd The current working directory to search from.
* @returns {string|undefined} The filename if found or `undefined` if not.
*/
export function findFlatConfigFile(cwd: string) {
return findUp(FLAT_CONFIG_FILENAMES, { cwd })
}

/** We used https://github.com/sindresorhus/find-up/blob/b733bb70d3aa21b22fa011be8089110d467c317f/index.js#L94 as a reference */
function findUp(names: string[], options: { cwd: string }) {
let directory = path.resolve(options.cwd)
const { root } = path.parse(directory)
const stopAt = path.resolve(directory, root)
// eslint-disable-next-line no-constant-condition -- ignore
while (true) {
for (const name of names) {
const target = path.resolve(directory, name)
const stat = fs.existsSync(target)
? fs.statSync(target, {
throwIfNoEntry: false
})
: null
if (stat?.isFile()) {
return target
}
}

if (directory === stopAt) {
break
}

directory = path.dirname(directory)
}

return null
}
42 changes: 42 additions & 0 deletions lib/utils/parser-config-resolver/worker.ts
@@ -0,0 +1,42 @@
// @ts-expect-error -- ignore
import { runAsWorker } from 'synckit'
import { getESLint } from 'eslint-compat-utils/eslint'
import type { Linter } from 'eslint'
import type { ParseResult } from '.'
import { parseByParser } from './parse-by-parser'
const ESLint = getESLint()

runAsWorker(async (cwd: string, filePath: string): Promise<ParseResult> => {
const eslint = new ESLint({ cwd })
const config: Linter.FlatConfig = await eslint.calculateConfigForFile(
filePath
)
const languageOptions = config.languageOptions || {}
const parserOptions = Object.assign(
{
sourceType: languageOptions.sourceType || 'module',
ecmaVersion: languageOptions.ecmaVersion || 'latest'
},
languageOptions.parserOptions,
{
loc: true,
range: true,
raw: true,
tokens: true,
comment: true,
eslintVisitorKeys: true,
eslintScopeManager: true,
filePath
}
)

const result = parseByParser(filePath, languageOptions.parser, parserOptions)
if (!result) {
return null
}

return {
ast: result.ast,
visitorKeys: result?.visitorKeys
}
})

0 comments on commit c392a38

Please sign in to comment.