Skip to content

Commit a893aa7

Browse files
committed
feat: performance improvement via cache
1 parent f9b7c8d commit a893aa7

File tree

4 files changed

+169
-58
lines changed

4 files changed

+169
-58
lines changed
Lines changed: 92 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
1+
import fs from 'fs'
12
import path from 'path'
23

34
import { cosmiconfigSync } from 'cosmiconfig'
4-
import type { CosmiconfigResult } from 'cosmiconfig/dist/types'
55
import { arrayify } from 'eslint-mdx'
66
import remarkMdx from 'remark-mdx'
77
import remarkParse from 'remark-parse'
88
import remarkStringify from 'remark-stringify'
9+
import type { FrozenProcessor } from 'unified'
910
import unified from 'unified'
1011

1112
import type { RemarkConfig, RemarkPlugin } from './types'
@@ -39,74 +40,109 @@ export const requirePkg = <T>(
3940
throw error
4041
}
4142

42-
let searchSync: (searchFrom?: string) => CosmiconfigResult
43+
/**
44+
* Given a filepath, get the nearest path that is a regular file.
45+
* The filepath provided by eslint may be a virtual filepath rather than a file
46+
* on disk. This attempts to transform a virtual path into an on-disk path
47+
*/
48+
export const getPhysicalFilename = (filename: string): string => {
49+
try {
50+
if (fs.statSync(filename).isFile()) {
51+
return filename
52+
}
53+
} catch (err) {
54+
// https://github.com/eslint/eslint/issues/11989
55+
if ((err as { code: string }).code === 'ENOTDIR') {
56+
return getPhysicalFilename(path.dirname(filename))
57+
}
58+
}
59+
60+
return filename
61+
}
4362

4463
export const remarkProcessor = unified().use(remarkParse).freeze()
4564

65+
const explorer = cosmiconfigSync('remark', {
66+
packageProp: 'remarkConfig',
67+
})
68+
69+
// @internal - exported for testing
70+
export const processorCache = new Map<string, FrozenProcessor>()
71+
72+
// eslint-disable-next-line sonarjs/cognitive-complexity
4673
export const getRemarkProcessor = (searchFrom: string, isMdx: boolean) => {
47-
if (!searchSync) {
48-
searchSync = cosmiconfigSync('remark', {
49-
packageProp: 'remarkConfig',
50-
}).search
74+
const initCacheKey = `${String(isMdx)}-${searchFrom}`
75+
76+
let cachedProcessor = processorCache.get(initCacheKey)
77+
78+
if (cachedProcessor) {
79+
return cachedProcessor
5180
}
5281

53-
let result: Partial<CosmiconfigResult>
82+
const result = explorer.search(searchFrom)
5483

55-
try {
56-
result = searchSync(searchFrom)
57-
} catch (err) {
58-
// https://github.com/eslint/eslint/issues/11989
59-
/* istanbul ignore if */
60-
if (
61-
(err as { code?: string }).code !== 'ENOTDIR' ||
62-
!/[/\\]\d+_[^/\\]*\.[\da-z]+$/i.test(searchFrom)
63-
) {
64-
throw err
65-
}
66-
try {
67-
result = searchSync(path.dirname(searchFrom))
68-
} catch {
69-
/* istanbul ignore next */
70-
throw err
71-
}
84+
const cacheKey = result ? `${String(isMdx)}-${result.filepath}` : ''
85+
86+
cachedProcessor = processorCache.get(cacheKey)
87+
88+
if (cachedProcessor) {
89+
return cachedProcessor
7290
}
7391

74-
/* istanbul ignore next */
75-
const { plugins = [], settings } = (result?.config ||
76-
{}) as Partial<RemarkConfig>
92+
if (result) {
93+
/* istanbul ignore next */
94+
const { plugins = [], settings } = (result.config ||
95+
{}) as Partial<RemarkConfig>
7796

78-
// disable this rule automatically since we already have a parser option `extensions`
79-
// only disable this plugin if there are at least one plugin enabled
80-
// otherwise `result` could be null inside `plugins.reduce`
81-
/* istanbul ignore else */
82-
if (plugins.length > 0) {
83-
try {
84-
// eslint-disable-next-line node/no-extraneous-require
85-
plugins.push([require.resolve('remark-lint-file-extension'), false])
86-
} catch {
87-
// just ignore if the package does not exist
97+
// disable this rule automatically since we already have a parser option `extensions`
98+
// only disable this plugin if there are at least one plugin enabled
99+
// otherwise it is redundant
100+
/* istanbul ignore else */
101+
if (plugins.length > 0) {
102+
try {
103+
// eslint-disable-next-line node/no-extraneous-require
104+
plugins.push([require.resolve('remark-lint-file-extension'), false])
105+
} catch {
106+
// just ignore if the package does not exist
107+
}
108+
}
109+
110+
const initProcessor = remarkProcessor()
111+
.use({ settings })
112+
.use(remarkStringify)
113+
114+
if (isMdx) {
115+
initProcessor.use(remarkMdx)
88116
}
89-
}
90117

91-
const initProcessor = remarkProcessor().use({ settings }).use(remarkStringify)
118+
cachedProcessor = plugins
119+
.reduce((processor, pluginWithSettings) => {
120+
const [plugin, ...pluginSettings] = arrayify(pluginWithSettings) as [
121+
RemarkPlugin,
122+
...unknown[]
123+
]
124+
return processor.use(
125+
/* istanbul ignore next */
126+
typeof plugin === 'string'
127+
? requirePkg(plugin, 'remark', result.filepath)
128+
: plugin,
129+
...pluginSettings,
130+
)
131+
}, initProcessor)
132+
.freeze()
133+
} else {
134+
const initProcessor = remarkProcessor().use(remarkStringify)
92135

93-
if (isMdx) {
94-
initProcessor.use(remarkMdx)
136+
if (isMdx) {
137+
initProcessor.use(remarkMdx)
138+
}
139+
140+
cachedProcessor = initProcessor.freeze()
95141
}
96142

97-
return plugins
98-
.reduce((processor, pluginWithSettings) => {
99-
const [plugin, ...pluginSettings] = arrayify(pluginWithSettings) as [
100-
RemarkPlugin,
101-
...unknown[]
102-
]
103-
return processor.use(
104-
/* istanbul ignore next */
105-
typeof plugin === 'string'
106-
? requirePkg(plugin, 'remark', result.filepath)
107-
: plugin,
108-
...pluginSettings,
109-
)
110-
}, initProcessor)
111-
.freeze()
143+
processorCache
144+
.set(initCacheKey, cachedProcessor)
145+
.set(cacheKey, cachedProcessor)
146+
147+
return cachedProcessor
112148
}

packages/eslint-plugin-mdx/src/rules/remark.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import type { ParserOptions } from 'eslint-mdx'
55
import { DEFAULT_EXTENSIONS, MARKDOWN_EXTENSIONS } from 'eslint-mdx'
66
import vfile from 'vfile'
77

8-
import { getRemarkProcessor } from './helpers'
8+
import { getPhysicalFilename, getRemarkProcessor } from './helpers'
99
import type { RemarkLintMessage } from './types'
1010

1111
export const remark: Rule.RuleModule = {
@@ -37,7 +37,10 @@ export const remark: Rule.RuleModule = {
3737
return
3838
}
3939
const sourceText = sourceCode.getText(node)
40-
const remarkProcessor = getRemarkProcessor(filename, isMdx)
40+
const remarkProcessor = getRemarkProcessor(
41+
getPhysicalFilename(filename),
42+
isMdx,
43+
)
4144
const file = vfile({
4245
path: filename,
4346
contents: sourceText,

test/fixtures/dir.mdx/.gitkeep

Whitespace-only changes.

test/remark.test.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import path from 'path'
2+
3+
import { DEFAULT_PARSER_OPTIONS as parserOptions } from 'eslint-mdx'
4+
// @ts-ignore - processorCache is an internal API
5+
import { processorCache, remark } from 'eslint-plugin-mdx'
6+
import { homedir } from 'os'
7+
8+
import { parser, ruleTester } from './helpers'
9+
10+
const userDir = homedir()
11+
12+
ruleTester.run('remark 1', remark, {
13+
valid: [
14+
{
15+
code: '<header>Header1</header>',
16+
parser,
17+
parserOptions,
18+
filename: 'remark.mdx',
19+
},
20+
{
21+
code: '<header>Header2</header>',
22+
parser,
23+
parserOptions,
24+
filename: path.resolve(__filename, '0-fake.mdx'),
25+
},
26+
{
27+
code: '<header>Header3</header>',
28+
parser,
29+
parserOptions,
30+
filename: path.resolve(__dirname, 'fixtures/dir.mdx'),
31+
},
32+
{
33+
code: '<header>Header4</header>',
34+
parser,
35+
parserOptions,
36+
filename: path.resolve(userDir, '../test.mdx'),
37+
},
38+
{
39+
code: '<header>Header5</header>',
40+
parser,
41+
parserOptions,
42+
// dark hack
43+
get filename() {
44+
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
45+
processorCache.clear()
46+
return path.resolve(userDir, '../test.md')
47+
},
48+
},
49+
],
50+
invalid: [
51+
{
52+
code: '<header>Header</header>',
53+
parser,
54+
parserOptions,
55+
filename: path.resolve(__filename, '0_fake_virtual_filename.mdx'),
56+
errors: [
57+
{
58+
message: JSON.stringify({
59+
reason: 'Do not use `_` in a file name',
60+
source: 'remark-lint',
61+
ruleId: 'no-file-name-irregular-characters',
62+
severity: 1,
63+
}),
64+
line: null,
65+
column: 0,
66+
endLine: null,
67+
endColumn: 0,
68+
},
69+
],
70+
},
71+
],
72+
})

0 commit comments

Comments
 (0)