eslint-plugin-oxfmt 0.1.2 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -13,7 +13,9 @@
13
13
  - 🔧 **Auto-fix** - Automatically format code on save or via ESLint's fix command
14
14
  - 🎯 **ESLint Integration** - Seamlessly integrates with ESLint v9+ flat config
15
15
  - 📦 **Zero Config** - Works out of the box with sensible defaults
16
- - 🎨 **Highly Configurable** - Support for all oxfmt formatting options
16
+ - 🧩 **Config File Discovery** - Supports `.oxfmtrc.json`, `.oxfmtrc.jsonc`, and `oxfmt.config.ts`
17
+ - 📝 **EditorConfig Integration** - Respects a subset of `.editorconfig` options via [oxfmt's strategy](https://oxc.rs/docs/guide/usage/formatter/config#editorconfig)
18
+ - 🎨 **Highly Configurable** - Supports all oxfmt formatting options
17
19
  - 🌐 **Multi-language Support** - JavaScript, TypeScript, JSX, TSX and [more](https://oxc.rs/docs/guide/usage/formatter.html#supported-languages)
18
20
 
19
21
  ## Requirements
@@ -104,10 +106,17 @@ export default [
104
106
  htmlWhitespaceSensitivity: 'css',
105
107
  proseWrap: 'preserve',
106
108
 
109
+ // JSDoc
110
+ jsdoc: {
111
+ commentLineStrategy: 'singleLine',
112
+ lineWrappingStyle: 'greedy',
113
+ separateTagGroups: false,
114
+ },
115
+
107
116
  // Vue
108
117
  vueIndentScriptAndStyle: false,
109
118
 
110
- // Experiments
119
+ // Advanced
111
120
  sortImports: {
112
121
  order: 'asc',
113
122
  newlinesBetween: true,
@@ -132,12 +141,27 @@ All options are optional and default to sensible values.
132
141
 
133
142
  ### Plugin Options
134
143
 
135
- | Option | Type | Default | Description |
136
- | ------------ | --------- | ------- | ------------------------------------------------------------------------------------------------------ |
137
- | `useConfig` | `boolean` | `true` | Load `.oxfmtrc` / config files via `load-oxfmt-config`. Set to `false` to rely only on inline options. |
138
- | `configPath` | `string` | — | Custom path to an oxfmt config file. Resolved from ESLint `cwd` when set. |
144
+ | Option | Type | Default | Description |
145
+ | ------------ | --------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
146
+ | `useConfig` | `boolean` | `true` | Load `.oxfmtrc.json`, `.oxfmtrc.jsonc`, or `oxfmt.config.ts` via `load-oxfmt-config` (with `.editorconfig` merge support). Set to `false` to rely only on inline options. |
147
+ | `configPath` | `string` | — | Custom path to an oxfmt config file. Resolved from ESLint `cwd` when set. |
139
148
 
140
149
  > Note: `cwd` is taken from ESLint automatically; you usually do not need to set it manually.
150
+ > `.editorconfig` merge behavior follows oxfmt's documented strategy: https://oxc.rs/docs/guide/usage/formatter/config#editorconfig
151
+
152
+ ### Config Discovery & Precedence
153
+
154
+ When `useConfig` is `true`, the plugin loads config using `load-oxfmt-config`.
155
+
156
+ - Config discovery order (from `cwd`, walking upward): `.oxfmtrc.json` → `.oxfmtrc.jsonc` → `oxfmt.config.ts`
157
+ - `.editorconfig` support: nearest `.editorconfig` (including section overrides) is merged into the final options
158
+ - `configPath` overrides discovery and directly targets the specified config file
159
+ - ESLint rule options generally take highest priority because inline rule options are merged after loaded config. However, when `useConfig` is `true`, `overrides` are taken from the loaded config (not from rule options).
160
+
161
+ For detailed behavior, see:
162
+
163
+ - [Oxfmt configuration](https://oxc.rs/docs/guide/usage/formatter/config)
164
+ - [Oxfmt `.editorconfig` strategy](https://oxc.rs/docs/guide/usage/formatter/config#editorconfig)
141
165
 
142
166
  ### Basic Options
143
167
 
@@ -199,6 +223,24 @@ All options are optional and default to sensible values.
199
223
  | `htmlWhitespaceSensitivity` | `'css' \| 'ignore' \| 'strict'` | `'css'` | Global whitespace sensitivity for HTML-like languages |
200
224
  | `proseWrap` | `'always' \| 'never' \| 'preserve'` | `'preserve'` | Control prose wrapping in Markdown/MDX |
201
225
 
226
+ ### JSDoc Options
227
+
228
+ Use `jsdoc` to enable and configure JSDoc comment formatting. Pass an object to enable it (for example `jsdoc: {}`).
229
+
230
+ | Option | Type | Default | Description |
231
+ | ----------------------------------- | --------------------------------------- | -------------- | ------------------------------------------------------ |
232
+ | `jsdoc.commentLineStrategy` | `'singleLine' \| 'multiline' \| 'keep'` | `'singleLine'` | Comment block style policy |
233
+ | `jsdoc.lineWrappingStyle` | `'greedy' \| 'balance'` | `'greedy'` | Wrapping strategy for long descriptions |
234
+ | `jsdoc.addDefaultToDescription` | `boolean` | `true` | Append default values to `@param` descriptions |
235
+ | `jsdoc.bracketSpacing` | `boolean` | `false` | Add spaces inside JSDoc type braces |
236
+ | `jsdoc.capitalizeDescriptions` | `boolean` | `true` | Capitalize the first letter of tag descriptions |
237
+ | `jsdoc.descriptionTag` | `boolean` | `false` | Emit `@description` tag instead of inline description |
238
+ | `jsdoc.descriptionWithDot` | `boolean` | `false` | Add a trailing dot to the end of descriptions |
239
+ | `jsdoc.keepUnparsableExampleIndent` | `boolean` | `false` | Preserve indentation in unparsable `@example` code |
240
+ | `jsdoc.preferCodeFences` | `boolean` | `false` | Prefer fenced code blocks over 4-space indentation |
241
+ | `jsdoc.separateReturnsFromParam` | `boolean` | `false` | Add a blank line between final `@param` and `@returns` |
242
+ | `jsdoc.separateTagGroups` | `boolean` | `false` | Add blank lines between different tag groups |
243
+
202
244
  ### Advanced Options
203
245
 
204
246
  | Option | Type | Default | Description |
package/dist/index.d.mts CHANGED
@@ -1,4 +1,4 @@
1
- import * as eslint from "eslint";
1
+ import * as _$eslint from "eslint";
2
2
  import { Linter, Rule } from "eslint";
3
3
 
4
4
  //#region src/types.d.ts
@@ -23,7 +23,7 @@ declare const meta: {
23
23
  //#endregion
24
24
  //#region src/rules/index.d.ts
25
25
  declare const rules: {
26
- oxfmt: eslint.Rule.RuleModule;
26
+ oxfmt: _$eslint.Rule.RuleModule;
27
27
  };
28
28
  //#endregion
29
29
  //#region src/parser.d.ts
package/dist/index.mjs CHANGED
@@ -59,7 +59,7 @@ const configs = { recommended };
59
59
  //#region src/meta.ts
60
60
  const meta = {
61
61
  name: "eslint-plugin-oxfmt",
62
- version: "0.1.2"
62
+ version: "0.2.0"
63
63
  };
64
64
  //#endregion
65
65
  //#region src/dir.ts
@@ -183,6 +183,63 @@ const oxfmtOptionsSchema = {
183
183
  description: `Whether to insert a final newline at the end of the file. (Default: true)`,
184
184
  type: "boolean"
185
185
  },
186
+ jsdoc: {
187
+ additionalProperties: false,
188
+ description: `Enable JSDoc comment formatting.\n\nWhen enabled, JSDoc comments are normalized and reformatted:\ntag aliases are canonicalized, descriptions are capitalized,\nlong lines are wrapped, and short comments are collapsed to single-line.\n\nPass an object (\`jsdoc: {}\`) to enable with defaults, or omit to disable.\n\n- (Default: Disabled)`,
189
+ type: "object",
190
+ properties: {
191
+ addDefaultToDescription: {
192
+ description: `Append default values to \`@param\` descriptions (e.g. "Default is \`value\`").\n\n- (Default: true)`,
193
+ type: "boolean"
194
+ },
195
+ bracketSpacing: {
196
+ description: `Add spaces inside JSDoc type braces: \`{string}\` → \`{ string }\`.\n\n- (Default: false)`,
197
+ type: "boolean"
198
+ },
199
+ capitalizeDescriptions: {
200
+ description: `Capitalize the first letter of tag descriptions.\n\n- (Default: true)`,
201
+ type: "boolean"
202
+ },
203
+ commentLineStrategy: {
204
+ description: `How to format comment blocks.\n\n- \`"singleLine"\` — Convert to single-line \`/** content */\` when possible.\n- \`"multiline"\` — Always use multi-line format.\n- \`"keep"\` — Preserve original formatting.\n\n- (Default: "singleLine")`,
205
+ enum: [
206
+ "singleLine",
207
+ "multiline",
208
+ "keep"
209
+ ],
210
+ type: "string"
211
+ },
212
+ descriptionTag: {
213
+ description: `Emit \`@description\` tag instead of inline description.\n\n- (Default: false)`,
214
+ type: "boolean"
215
+ },
216
+ descriptionWithDot: {
217
+ description: `Add a trailing dot to the end of descriptions.\n\n- (Default: false)`,
218
+ type: "boolean"
219
+ },
220
+ keepUnparsableExampleIndent: {
221
+ description: `Preserve indentation in unparsable \`@example\` code.\n\n- (Default: false)`,
222
+ type: "boolean"
223
+ },
224
+ lineWrappingStyle: {
225
+ description: `Strategy for wrapping description lines at print width.\n\n- \`"greedy"\` — Always re-wrap text to fit within print width.\n- \`"balance"\` — Preserve original line breaks if all lines fit within print width.\n\n- (Default: "greedy")`,
226
+ enum: ["greedy", "balance"],
227
+ type: "string"
228
+ },
229
+ preferCodeFences: {
230
+ description: `Use fenced code blocks (\`\`\`) instead of 4-space indentation for code without a language tag.\n\n- (Default: false)`,
231
+ type: "boolean"
232
+ },
233
+ separateReturnsFromParam: {
234
+ description: `Add a blank line between the last \`@param\` and \`@returns\`.\n\n- (Default: false)`,
235
+ type: "boolean"
236
+ },
237
+ separateTagGroups: {
238
+ description: `Add blank lines between different tag groups (e.g. between \`@param\` and \`@returns\`).\n\n- (Default: false)`,
239
+ type: "boolean"
240
+ }
241
+ }
242
+ },
186
243
  jsxSingleQuote: {
187
244
  description: `Use single quotes instead of double quotes in JSX. (Default: false)`,
188
245
  type: "boolean"
@@ -450,6 +507,8 @@ const rules = { oxfmt: {
450
507
  }
451
508
  },
452
509
  create(context) {
510
+ const physicalFilename = context.physicalFilename ?? context.filename;
511
+ if (context.filename !== physicalFilename) return {};
453
512
  if (!formatViaOxfmt) formatViaOxfmt = createSyncFn(join(dirWorkers, "oxfmt.mjs"));
454
513
  const sourceText = context.sourceCode.text;
455
514
  return { [context.sourceCode.ast.type || "Program"]() {
@@ -30,6 +30,31 @@ export type OxfmtOxfmt = []|[{
30
30
 
31
31
  insertFinalNewline?: boolean
32
32
 
33
+ jsdoc?: {
34
+
35
+ addDefaultToDescription?: boolean
36
+
37
+ bracketSpacing?: boolean
38
+
39
+ capitalizeDescriptions?: boolean
40
+
41
+ commentLineStrategy?: ("singleLine" | "multiline" | "keep")
42
+
43
+ descriptionTag?: boolean
44
+
45
+ descriptionWithDot?: boolean
46
+
47
+ keepUnparsableExampleIndent?: boolean
48
+
49
+ lineWrappingStyle?: ("greedy" | "balance")
50
+
51
+ preferCodeFences?: boolean
52
+
53
+ separateReturnsFromParam?: boolean
54
+
55
+ separateTagGroups?: boolean
56
+ }
57
+
33
58
  jsxSingleQuote?: boolean
34
59
 
35
60
  objectWrap?: ("preserve" | "collapse" | "always")
@@ -135,6 +160,31 @@ export type OxfmtOxfmt = []|[{
135
160
 
136
161
  insertFinalNewline?: boolean
137
162
 
163
+ jsdoc?: {
164
+
165
+ addDefaultToDescription?: boolean
166
+
167
+ bracketSpacing?: boolean
168
+
169
+ capitalizeDescriptions?: boolean
170
+
171
+ commentLineStrategy?: ("singleLine" | "multiline" | "keep")
172
+
173
+ descriptionTag?: boolean
174
+
175
+ descriptionWithDot?: boolean
176
+
177
+ keepUnparsableExampleIndent?: boolean
178
+
179
+ lineWrappingStyle?: ("greedy" | "balance")
180
+
181
+ preferCodeFences?: boolean
182
+
183
+ separateReturnsFromParam?: boolean
184
+
185
+ separateTagGroups?: boolean
186
+ }
187
+
138
188
  jsxSingleQuote?: boolean
139
189
 
140
190
  objectWrap?: ("preserve" | "collapse" | "always")
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "eslint-plugin-oxfmt",
3
3
  "type": "module",
4
- "version": "0.1.2",
4
+ "version": "0.2.0",
5
5
  "description": "An ESLint plugin for formatting code with oxfmt.",
6
6
  "keywords": [
7
7
  "eslint",
@@ -44,35 +44,35 @@
44
44
  "sideEffects": false,
45
45
  "peerDependencies": {
46
46
  "eslint": "^9.5.0 || ^10.0.0",
47
- "oxfmt": ">=0.35.0"
47
+ "oxfmt": ">=0.42.0"
48
48
  },
49
49
  "dependencies": {
50
50
  "generate-differences": "^0.1.1",
51
- "load-oxfmt-config": "^0.2.0",
52
- "picomatch": "^4.0.3",
51
+ "load-oxfmt-config": "^0.3.0",
52
+ "picomatch": "^4.0.4",
53
53
  "synckit": "^0.11.12"
54
54
  },
55
55
  "devDependencies": {
56
- "@ntnyq/eslint-config": "^6.0.0-beta.13",
56
+ "@ntnyq/eslint-config": "^6.0.0-beta.15",
57
57
  "@types/json-schema": "^7.0.15",
58
- "@types/node": "^25.4.0",
59
- "@typescript/native-preview": "^7.0.0-dev.20260312.1",
60
- "bumpp": "^10.4.1",
61
- "eslint": "^10.0.3",
58
+ "@types/node": "^25.5.0",
59
+ "@typescript/native-preview": "^7.0.0-dev.20260328.1",
60
+ "bumpp": "^11.0.1",
61
+ "eslint": "^10.1.0",
62
62
  "eslint-parser-plain": "^0.1.1",
63
63
  "eslint-typegen": "^2.3.1",
64
64
  "eslint-vitest-rule-tester": "^3.1.0",
65
65
  "husky": "^9.1.7",
66
66
  "nano-staged": "^0.9.0",
67
67
  "npm-run-all2": "^8.0.4",
68
- "oxfmt": "^0.39.0",
68
+ "oxfmt": "^0.42.0",
69
69
  "show-invisibles": "^0.0.2",
70
70
  "tinyglobby": "^0.2.15",
71
- "tsdown": "^0.21.2",
71
+ "tsdown": "^0.21.7",
72
72
  "tsx": "^4.21.0",
73
- "typescript": "^5.9.3",
74
- "vitest": "^4.0.18",
75
- "eslint-plugin-oxfmt": "0.1.2"
73
+ "typescript": "^6.0.2",
74
+ "vitest": "^4.1.2",
75
+ "eslint-plugin-oxfmt": "0.2.0"
76
76
  },
77
77
  "engines": {
78
78
  "node": "^20.19.0 || >=22.12.0"
package/workers/oxfmt.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  // @ts-check
2
2
 
3
- import { relative } from 'node:path'
4
- import { loadOxfmtConfig } from 'load-oxfmt-config'
3
+ import { dirname, isAbsolute, join, relative } from 'node:path'
4
+ import { loadOxfmtConfig, resolveOxfmtrcPath } from 'load-oxfmt-config'
5
5
  import { format } from 'oxfmt'
6
6
  import picomatch from 'picomatch'
7
7
  import { runAsWorker } from 'synckit'
@@ -12,17 +12,75 @@ import { runAsWorker } from 'synckit'
12
12
  * @property {string} cwd - Current working directory for resolving configuration
13
13
  * @property {string} [configPath] - Custom path to oxfmt configuration file
14
14
  */
15
+
15
16
  /**
16
- * @typedef {import('load-oxfmt-config').FormatOptionOverride} Override
17
+ * @typedef {import('load-oxfmt-config').OxfmtConfigOverride} Override
17
18
  */
18
19
  /**
19
20
  * @typedef {import('load-oxfmt-config').OxfmtOptions & PluginOptions} Options
20
21
  */
21
22
 
23
+ /**
24
+ * @typedef {object} ResolvedBaseOptions
25
+ * @property {import('oxfmt').FormatConfig} baseOptions Resolved base formatter options.
26
+ * @property {string} configDir Directory of the resolved config file, used as base for config-derived glob patterns.
27
+ */
28
+
29
+ const MAX_CACHE_SIZE = 1000
30
+
31
+ /**
32
+ * Evict oldest entries when the cache exceeds MAX_CACHE_SIZE.
33
+ * @param cache - The cache map to evict entries from
34
+ */
35
+ function evictCache(
36
+ /** @type {Map<string, unknown>} */
37
+ cache,
38
+ ) {
39
+ if (cache.size <= MAX_CACHE_SIZE) {
40
+ return
41
+ }
42
+ const keysToDelete = [...cache.keys()].slice(0, cache.size - MAX_CACHE_SIZE)
43
+ for (const key of keysToDelete) {
44
+ cache.delete(key)
45
+ }
46
+ }
47
+
48
+ /**
49
+ * JSON.stringify replacer that sorts object keys for stable serialization.
50
+ * @param key - The key of the property being processed
51
+ * @param value - The value of the property being processed
52
+ * @returns - The value to be serialized, with object keys sorted if it's an object
53
+ */
54
+ function stableReplacer(
55
+ /** @type {string} */
56
+ key,
57
+ /** @type {unknown} */
58
+ value,
59
+ ) {
60
+ if (value && typeof value === 'object' && !Array.isArray(value)) {
61
+ return Object.keys(value)
62
+ .sort()
63
+ .reduce((sorted, k) => {
64
+ sorted[k] = /** @type {Record<string, unknown>} */ (value)[k]
65
+ return sorted
66
+ }, /** @type {Record<string, unknown>} */ ({}))
67
+ }
68
+ return value
69
+ }
70
+
71
+ /** @type {Map<string, Promise<ResolvedBaseOptions>>} */
72
+ const resolvedBaseOptionsCache = new Map()
73
+
74
+ /** @type {Map<string, import('oxfmt').FormatConfig>} */
75
+ const mergedOptionsCache = new Map()
76
+
77
+ /** @type {Map<string, ReturnType<typeof picomatch>>} */
78
+ const picomatchCache = new Map()
79
+
22
80
  /**
23
81
  * Apply overrides to the base options based on the filename
24
82
  * @param filename - The file path
25
- * @param cwd - Current working directory
83
+ * @param cwd - Base directory for glob matching
26
84
  * @param baseOptions - Base format options
27
85
  * @param [overrides] - Override configurations
28
86
  * @returns - Merged options
@@ -32,7 +90,7 @@ function applyOverrides(
32
90
  filename,
33
91
  /** @type {string} */
34
92
  cwd,
35
- /** @type {import('oxfmt').FormatOptions} */
93
+ /** @type {import('oxfmt').FormatConfig} */
36
94
  baseOptions,
37
95
  /** @type {Override[] | undefined} */
38
96
  overrides,
@@ -52,12 +110,12 @@ function applyOverrides(
52
110
  const { excludeFiles, files, options: overrideOptions } = override
53
111
 
54
112
  // Check if file matches the files patterns
55
- const matches = picomatch.isMatch(relativePath, files)
113
+ const matches = getCachedMatcher(files)(relativePath)
56
114
 
57
115
  // Check if file is excluded
58
116
  const excluded =
59
117
  excludeFiles && excludeFiles.length > 0
60
- ? picomatch.isMatch(relativePath, excludeFiles)
118
+ ? getCachedMatcher(excludeFiles)(relativePath)
61
119
  : false
62
120
 
63
121
  if (matches && !excluded && overrideOptions) {
@@ -69,10 +127,209 @@ function applyOverrides(
69
127
  return hasOverrides ? mergedOptions : baseOptions
70
128
  }
71
129
 
130
+ /**
131
+ * Get or create a cached picomatch matcher for the given patterns.
132
+ * @param patterns - Glob patterns to match against file paths
133
+ * @returns - Picomatch matcher function
134
+ */
135
+ function getCachedMatcher(
136
+ /** @type {string | string[]} */
137
+ patterns,
138
+ ) {
139
+ const key = Array.isArray(patterns) ? patterns.join('\0') : patterns
140
+ const cached = picomatchCache.get(key)
141
+ if (cached) {
142
+ return cached
143
+ }
144
+ const matcher = picomatch(patterns)
145
+ picomatchCache.set(key, matcher)
146
+ evictCache(picomatchCache)
147
+ return matcher
148
+ }
149
+
150
+ /**
151
+ * Resolve an effective config path for a file.
152
+ * - Absolute paths are returned as-is.
153
+ * - Relative paths are resolved from cwd.
154
+ *
155
+ * @param cwd - Current working directory from ESLint context.
156
+ * @param [configPath] - Optional user-provided config path.
157
+ * @returns Absolute config path when provided, otherwise undefined.
158
+ */
159
+ function getConfigPathForFile(
160
+ /** @type {string} */
161
+ cwd,
162
+ /** @type {string | undefined} */
163
+ configPath,
164
+ ) {
165
+ if (!configPath) {
166
+ return undefined
167
+ }
168
+
169
+ return isAbsolute(configPath) ? configPath : join(cwd, configPath)
170
+ }
171
+
172
+ /**
173
+ * Build cache key for merged options per file invocation.
174
+ * The key includes filename, cwd, configDir, resolved base options, and rule-level
175
+ * override inputs to avoid stale cache hits.
176
+ *
177
+ * @param filename - Current file path.
178
+ * @param cwd - Base directory used for rule-level glob matching.
179
+ * @param configDir - Directory of the resolved config file, used for config-derived glob matching.
180
+ * @param baseOptions - Resolved base options used for formatting.
181
+ * @param ignorePatterns - Rule-level ignore patterns.
182
+ * @param overrides - Rule-level override entries.
183
+ * @param useConfig - Whether config file loading is enabled.
184
+ * @returns Serialized cache key.
185
+ */
186
+ function getMergedOptionsCacheKey(
187
+ /** @type {string} */
188
+ filename,
189
+ /** @type {string} */
190
+ cwd,
191
+ /** @type {string} */
192
+ configDir,
193
+ /** @type {import('oxfmt').FormatConfig} */
194
+ baseOptions,
195
+ /** @type {string[] | undefined} */
196
+ ignorePatterns,
197
+ /** @type {Override[] | undefined} */
198
+ overrides,
199
+ /** @type {boolean} */
200
+ useConfig,
201
+ ) {
202
+ return JSON.stringify(
203
+ {
204
+ baseOptions,
205
+ configDir,
206
+ cwd,
207
+ filename,
208
+ ignorePatterns,
209
+ overrides,
210
+ useConfig,
211
+ },
212
+ stableReplacer,
213
+ )
214
+ }
215
+
216
+ /**
217
+ * Build cache key for resolving base formatter options.
218
+ * The key includes file directory and all resolution inputs.
219
+ *
220
+ * @param filename - Current file path.
221
+ * @param cwd - Current working directory from ESLint context.
222
+ * @param configPath - Optional user-provided config path.
223
+ * @param useConfig - Whether config file loading is enabled.
224
+ * @param formatOptions - Rule-level format options.
225
+ * @returns Serialized cache key.
226
+ */
227
+ function getResolvedBaseOptionsCacheKey(
228
+ /** @type {string} */
229
+ filename,
230
+ /** @type {string} */
231
+ cwd,
232
+ /** @type {string | undefined} */
233
+ configPath,
234
+ /** @type {boolean} */
235
+ useConfig,
236
+ /** @type {import('oxfmt').FormatConfig} */
237
+ formatOptions,
238
+ ) {
239
+ return JSON.stringify(
240
+ {
241
+ configPath: configPath || '',
242
+ cwd,
243
+ fileDir: dirname(filename),
244
+ formatOptions,
245
+ useConfig,
246
+ },
247
+ stableReplacer,
248
+ )
249
+ }
250
+
251
+ /**
252
+ * Resolve base formatter options for a file and cache the async result.
253
+ *
254
+ * @param filename - Current file path.
255
+ * @param cwd - Current working directory from ESLint context.
256
+ * @param configPath - Optional user-provided config path.
257
+ * @param useConfig - Whether config file loading is enabled.
258
+ * @param formatOptions - Rule-level format options.
259
+ * @returns Base options after config loading and inline option merge.
260
+ */
261
+ async function resolveBaseOptions(
262
+ /** @type {string} */
263
+ filename,
264
+ /** @type {string} */
265
+ cwd,
266
+ /** @type {string | undefined} */
267
+ configPath,
268
+ /** @type {boolean} */
269
+ useConfig,
270
+ /** @type {import('oxfmt').FormatConfig} */
271
+ formatOptions,
272
+ ) {
273
+ const cacheKey = getResolvedBaseOptionsCacheKey(
274
+ filename,
275
+ cwd,
276
+ configPath,
277
+ useConfig,
278
+ formatOptions,
279
+ )
280
+
281
+ const cachedTask = resolvedBaseOptionsCache.get(cacheKey)
282
+ if (cachedTask) {
283
+ return cachedTask
284
+ }
285
+
286
+ const task = (async () => {
287
+ const resolveFromDir = dirname(filename)
288
+
289
+ if (!useConfig) {
290
+ return {
291
+ configDir: cwd,
292
+ baseOptions: {
293
+ ...formatOptions,
294
+ },
295
+ }
296
+ }
297
+
298
+ const resolvedConfigPath = getConfigPathForFile(cwd, configPath)
299
+ const resolvedPath = await resolveOxfmtrcPath(
300
+ resolveFromDir,
301
+ resolvedConfigPath,
302
+ )
303
+ const configDir = resolvedPath ? dirname(resolvedPath) : cwd
304
+ const configOptions = await loadOxfmtConfig({
305
+ configPath: resolvedConfigPath,
306
+ cwd: resolveFromDir,
307
+ })
308
+
309
+ return {
310
+ configDir,
311
+ baseOptions: {
312
+ ...configOptions,
313
+ ...formatOptions,
314
+ },
315
+ }
316
+ })()
317
+
318
+ resolvedBaseOptionsCache.set(cacheKey, task)
319
+ evictCache(resolvedBaseOptionsCache)
320
+
321
+ try {
322
+ return await task
323
+ } catch (err) {
324
+ resolvedBaseOptionsCache.delete(cacheKey)
325
+ throw err
326
+ }
327
+ }
328
+
72
329
  /**
73
330
  * Check if a file should be ignored based on ignorePatterns
74
331
  * @param filename - The file path
75
- * @param cwd - Current working directory
332
+ * @param cwd - Base directory for glob matching
76
333
  * @param [ignorePatterns] - Ignore patterns
77
334
  * @returns - Whether the file should be ignored
78
335
  */
@@ -92,7 +349,7 @@ function shouldIgnoreFile(
92
349
  const relativePath = relative(cwd, filename).replace(/\\/g, '/')
93
350
 
94
351
  // Check if file matches any ignore pattern
95
- return picomatch.isMatch(relativePath, ignorePatterns)
352
+ return getCachedMatcher(ignorePatterns)(relativePath)
96
353
  }
97
354
 
98
355
  runAsWorker(
@@ -119,36 +376,62 @@ runAsWorker(
119
376
  ...formatOptions
120
377
  } = options
121
378
 
122
- // Load base options from config or use provided options
123
- const baseOptions = {
124
- ...(useConfig
125
- ? await loadOxfmtConfig({
126
- configPath,
127
- cwd,
128
- })
129
- : {}),
130
- ...formatOptions,
379
+ const { baseOptions, configDir } = await resolveBaseOptions(
380
+ filename,
381
+ cwd,
382
+ configPath,
383
+ useConfig,
384
+ formatOptions,
385
+ )
386
+
387
+ const mergedOptionsCacheKey = getMergedOptionsCacheKey(
388
+ filename,
389
+ cwd,
390
+ configDir,
391
+ baseOptions,
392
+ ignorePatterns,
393
+ overrides,
394
+ useConfig,
395
+ )
396
+
397
+ const baseIgnorePatterns = /** @type {string[] | undefined} */ (
398
+ baseOptions.ignorePatterns
399
+ )
400
+ const effectiveIgnorePatterns = ignorePatterns ?? baseIgnorePatterns
401
+ const ignoreBase = ignorePatterns == null ? configDir : cwd
402
+
403
+ if (shouldIgnoreFile(filename, ignoreBase, effectiveIgnorePatterns)) {
404
+ return { code: sourceText }
131
405
  }
132
406
 
133
- if (
134
- shouldIgnoreFile(
407
+ const cachedMergedOptions = mergedOptionsCache.get(mergedOptionsCacheKey)
408
+ if (cachedMergedOptions) {
409
+ return format(filename, sourceText, cachedMergedOptions)
410
+ }
411
+
412
+ const baseOverrides = /** @type {Override[] | undefined} */ (
413
+ baseOptions.overrides
414
+ )
415
+
416
+ // Apply config-level overrides (relative to config directory)
417
+ let mergedOptions = baseOptions
418
+ if (useConfig && baseOverrides && baseOverrides.length > 0) {
419
+ mergedOptions = applyOverrides(
135
420
  filename,
136
- cwd,
137
- ignorePatterns || baseOptions.ignorePatterns,
421
+ configDir,
422
+ mergedOptions,
423
+ baseOverrides,
138
424
  )
139
- ) {
140
- return { code: sourceText }
141
425
  }
142
426
 
143
- // Apply overrides based on filename
144
- const mergedOptions = applyOverrides(
145
- filename,
146
- cwd,
147
- baseOptions,
148
- useConfig ? baseOptions.overrides : overrides,
149
- )
427
+ // Apply rule-level overrides (relative to ESLint cwd)
428
+ if (overrides && overrides.length > 0) {
429
+ mergedOptions = applyOverrides(filename, cwd, mergedOptions, overrides)
430
+ }
431
+
432
+ mergedOptionsCache.set(mergedOptionsCacheKey, mergedOptions)
433
+ evictCache(mergedOptionsCache)
150
434
 
151
- const formatResult = await format(filename, sourceText, mergedOptions)
152
- return formatResult
435
+ return format(filename, sourceText, mergedOptions)
153
436
  },
154
437
  )