eslint-plugin-oxfmt 0.1.1 → 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
@@ -2,23 +2,17 @@ import { join } from "node:path";
2
2
  import { createSyncFn } from "synckit";
3
3
  import { URL, fileURLToPath } from "node:url";
4
4
  import { DIFFERENCE, generateDifferences } from "generate-differences";
5
-
6
5
  //#region \0rolldown/runtime.js
7
6
  var __defProp = Object.defineProperty;
8
7
  var __exportAll = (all, no_symbols) => {
9
8
  let target = {};
10
- for (var name in all) {
11
- __defProp(target, name, {
12
- get: all[name],
13
- enumerable: true
14
- });
15
- }
16
- if (!no_symbols) {
17
- __defProp(target, Symbol.toStringTag, { value: "Module" });
18
- }
9
+ for (var name in all) __defProp(target, name, {
10
+ get: all[name],
11
+ enumerable: true
12
+ });
13
+ if (!no_symbols) __defProp(target, Symbol.toStringTag, { value: "Module" });
19
14
  return target;
20
15
  };
21
-
22
16
  //#endregion
23
17
  //#region node_modules/.pnpm/eslint-parser-plain@0.1.1/node_modules/eslint-parser-plain/dist/index.mjs
24
18
  var dist_exports = /* @__PURE__ */ __exportAll({
@@ -47,11 +41,9 @@ const meta$1 = {
47
41
  name: name$1,
48
42
  version: version$1
49
43
  };
50
-
51
44
  //#endregion
52
45
  //#region src/parser.ts
53
46
  const parserPlain = dist_exports;
54
-
55
47
  //#endregion
56
48
  //#region src/configs.ts
57
49
  const recommended = {
@@ -63,23 +55,15 @@ const recommended = {
63
55
  rules: { "oxfmt/oxfmt": "error" }
64
56
  };
65
57
  const configs = { recommended };
66
-
67
- //#endregion
68
- //#region package.json
69
- var name = "eslint-plugin-oxfmt";
70
- var version = "0.1.1";
71
-
72
58
  //#endregion
73
59
  //#region src/meta.ts
74
60
  const meta = {
75
- name,
76
- version
61
+ name: "eslint-plugin-oxfmt",
62
+ version: "0.2.0"
77
63
  };
78
-
79
64
  //#endregion
80
65
  //#region src/dir.ts
81
66
  const dirWorkers = fileURLToPath(new URL("../workers", import.meta.url));
82
-
83
67
  //#endregion
84
68
  //#region node_modules/.pnpm/show-invisibles@0.0.2/node_modules/show-invisibles/dist/index.js
85
69
  const DEFAULT_MAPPINGS = new Map([
@@ -112,7 +96,6 @@ function showInvisibles(input, options = {}) {
112
96
  if (typeof input !== "string") throw new TypeError(`Expected input to type string, got ${typeof input}`);
113
97
  return input.split("").map((c) => mappings.get(c) || c).join("");
114
98
  }
115
-
116
99
  //#endregion
117
100
  //#region src/reporter.ts
118
101
  /**
@@ -149,7 +132,6 @@ function _reportDifference(context, difference, rangeOffset = 0) {
149
132
  fix: (fixer) => fixer.replaceTextRange(range, insertText)
150
133
  });
151
134
  }
152
-
153
135
  //#endregion
154
136
  //#region src/schema.ts
155
137
  const oxfmtOptionsSchema = {
@@ -201,6 +183,63 @@ const oxfmtOptionsSchema = {
201
183
  description: `Whether to insert a final newline at the end of the file. (Default: true)`,
202
184
  type: "boolean"
203
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
+ },
204
243
  jsxSingleQuote: {
205
244
  description: `Use single quotes instead of double quotes in JSX. (Default: false)`,
206
245
  type: "boolean"
@@ -449,11 +488,12 @@ const oxfmtRuleSchema = {
449
488
  }
450
489
  }
451
490
  };
452
-
453
491
  //#endregion
454
492
  //#region src/rules/oxfmt.ts
455
493
  let formatViaOxfmt;
456
- const oxfmt = {
494
+ //#endregion
495
+ //#region src/rules/index.ts
496
+ const rules = { oxfmt: {
457
497
  meta: {
458
498
  defaultOptions: [],
459
499
  fixable: "whitespace",
@@ -467,6 +507,8 @@ const oxfmt = {
467
507
  }
468
508
  },
469
509
  create(context) {
510
+ const physicalFilename = context.physicalFilename ?? context.filename;
511
+ if (context.filename !== physicalFilename) return {};
470
512
  if (!formatViaOxfmt) formatViaOxfmt = createSyncFn(join(dirWorkers, "oxfmt.mjs"));
471
513
  const sourceText = context.sourceCode.text;
472
514
  return { [context.sourceCode.ast.type || "Program"]() {
@@ -519,12 +561,7 @@ const oxfmt = {
519
561
  }
520
562
  } };
521
563
  }
522
- };
523
-
524
- //#endregion
525
- //#region src/rules/index.ts
526
- const rules = { oxfmt };
527
-
564
+ } };
528
565
  //#endregion
529
566
  //#region src/index.ts
530
567
  const plugin = {
@@ -532,6 +569,5 @@ const plugin = {
532
569
  meta,
533
570
  rules
534
571
  };
535
-
536
572
  //#endregion
537
- export { configs, plugin as default, plugin, meta, parserPlain, recommended, rules };
573
+ export { configs, plugin as default, plugin, meta, parserPlain, recommended, rules };
@@ -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.1",
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.1.1",
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.9",
56
+ "@ntnyq/eslint-config": "^6.0.0-beta.15",
57
57
  "@types/json-schema": "^7.0.15",
58
- "@types/node": "^25.3.0",
59
- "@typescript/native-preview": "^7.0.0-dev.20260224.1",
60
- "bumpp": "^10.4.1",
61
- "eslint": "^10.0.2",
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.35.0",
68
+ "oxfmt": "^0.42.0",
69
69
  "show-invisibles": "^0.0.2",
70
70
  "tinyglobby": "^0.2.15",
71
- "tsdown": "^0.20.3",
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.1"
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,6 +127,231 @@ 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
+
329
+ /**
330
+ * Check if a file should be ignored based on ignorePatterns
331
+ * @param filename - The file path
332
+ * @param cwd - Base directory for glob matching
333
+ * @param [ignorePatterns] - Ignore patterns
334
+ * @returns - Whether the file should be ignored
335
+ */
336
+ function shouldIgnoreFile(
337
+ /** @type {string} */
338
+ filename,
339
+ /** @type {string} */
340
+ cwd,
341
+ /** @type {string[] | undefined} */
342
+ ignorePatterns,
343
+ ) {
344
+ if (!ignorePatterns || ignorePatterns.length === 0) {
345
+ return false
346
+ }
347
+
348
+ // Get relative path from cwd and normalize to forward slashes for cross-platform compatibility
349
+ const relativePath = relative(cwd, filename).replace(/\\/g, '/')
350
+
351
+ // Check if file matches any ignore pattern
352
+ return getCachedMatcher(ignorePatterns)(relativePath)
353
+ }
354
+
72
355
  runAsWorker(
73
356
  async (
74
357
  /**
@@ -87,31 +370,68 @@ runAsWorker(
87
370
  const {
88
371
  configPath,
89
372
  cwd,
373
+ ignorePatterns,
90
374
  overrides,
91
375
  useConfig = true,
92
376
  ...formatOptions
93
377
  } = options
94
378
 
95
- // Load base options from config or use provided options
96
- const baseOptions = {
97
- ...(useConfig
98
- ? await loadOxfmtConfig({
99
- configPath,
100
- cwd,
101
- })
102
- : {}),
103
- ...formatOptions,
104
- }
379
+ const { baseOptions, configDir } = await resolveBaseOptions(
380
+ filename,
381
+ cwd,
382
+ configPath,
383
+ useConfig,
384
+ formatOptions,
385
+ )
105
386
 
106
- // Apply overrides based on filename
107
- const mergedOptions = applyOverrides(
387
+ const mergedOptionsCacheKey = getMergedOptionsCacheKey(
108
388
  filename,
109
389
  cwd,
390
+ configDir,
110
391
  baseOptions,
111
- useConfig ? baseOptions.overrides : overrides,
392
+ ignorePatterns,
393
+ overrides,
394
+ useConfig,
395
+ )
396
+
397
+ const baseIgnorePatterns = /** @type {string[] | undefined} */ (
398
+ baseOptions.ignorePatterns
112
399
  )
400
+ const effectiveIgnorePatterns = ignorePatterns ?? baseIgnorePatterns
401
+ const ignoreBase = ignorePatterns == null ? configDir : cwd
402
+
403
+ if (shouldIgnoreFile(filename, ignoreBase, effectiveIgnorePatterns)) {
404
+ return { code: sourceText }
405
+ }
406
+
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(
420
+ filename,
421
+ configDir,
422
+ mergedOptions,
423
+ baseOverrides,
424
+ )
425
+ }
426
+
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)
113
434
 
114
- const formatResult = await format(filename, sourceText, mergedOptions)
115
- return formatResult
435
+ return format(filename, sourceText, mergedOptions)
116
436
  },
117
437
  )