eslint-plugin-oxfmt 0.1.2 → 0.3.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,16 +223,38 @@ 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 `true` to enable defaults, or pass an object for custom behavior (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
+
244
+ Tip: `jsdoc: true` is equivalent to enabling JSDoc with default settings.
245
+
202
246
  ### Advanced Options
203
247
 
204
248
  | Option | Type | Default | Description |
205
249
  | ----------------- | ------------------- | -------- | ---------------------------------------------------------------------------- |
206
- | `sortImports` | `object` | disabled | Experimental import sorting configuration |
250
+ | `sortImports` | `boolean \| object` | disabled | Experimental import sorting configuration |
207
251
  | `sortPackageJson` | `boolean \| object` | `true` | Experimental package.json sorting (object form: `{ sortScripts?: boolean }`) |
208
- | `sortTailwindcss` | `object` | disabled | Experimental Tailwind CSS class sorting (enable with `{}` for defaults) |
252
+ | `sortTailwindcss` | `boolean \| object` | disabled | Experimental Tailwind CSS class sorting (enable with `{}` for defaults) |
209
253
 
210
254
  #### Import sorting (`sortImports`)
211
255
 
256
+ Use `sortImports: true` to enable import sorting with defaults, or pass an object to customize behavior.
257
+
212
258
  Available keys:
213
259
 
214
260
  - `customGroups`: Ordered custom group definitions `{ elementNamePattern?: string[]; groupName?: string; modifiers?: string[]; selector?: string }[]`
@@ -228,7 +274,7 @@ Available keys:
228
274
 
229
275
  #### Tailwind CSS class sorting
230
276
 
231
- Enable experimental Tailwind CSS class sorting powered by `prettier-plugin-tailwindcss` (pass an empty object to turn it on):
277
+ Enable experimental Tailwind CSS class sorting powered by `prettier-plugin-tailwindcss` (set `sortTailwindcss: true` for defaults, or pass an object for custom options):
232
278
 
233
279
  ```js
234
280
  // eslint.config.mjs
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.3.0"
63
63
  };
64
64
  //#endregion
65
65
  //#region src/dir.ts
@@ -183,6 +183,65 @@ 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
+ 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)`,
188
+ oneOf: [{ type: "boolean" }, {
189
+ additionalProperties: false,
190
+ type: "object",
191
+ properties: {
192
+ addDefaultToDescription: {
193
+ description: `Append default values to \`@param\` descriptions (e.g. "Default is \`value\`").\n\n- (Default: true)`,
194
+ type: "boolean"
195
+ },
196
+ bracketSpacing: {
197
+ description: `Add spaces inside JSDoc type braces: \`{string}\` → \`{ string }\`.\n\n- (Default: false)`,
198
+ type: "boolean"
199
+ },
200
+ capitalizeDescriptions: {
201
+ description: `Capitalize the first letter of tag descriptions.\n\n- (Default: true)`,
202
+ type: "boolean"
203
+ },
204
+ commentLineStrategy: {
205
+ 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")`,
206
+ enum: [
207
+ "singleLine",
208
+ "multiline",
209
+ "keep"
210
+ ],
211
+ type: "string"
212
+ },
213
+ descriptionTag: {
214
+ description: `Emit \`@description\` tag instead of inline description.\n\n- (Default: false)`,
215
+ type: "boolean"
216
+ },
217
+ descriptionWithDot: {
218
+ description: `Add a trailing dot to the end of descriptions.\n\n- (Default: false)`,
219
+ type: "boolean"
220
+ },
221
+ keepUnparsableExampleIndent: {
222
+ description: `Preserve indentation in unparsable \`@example\` code.\n\n- (Default: false)`,
223
+ type: "boolean"
224
+ },
225
+ lineWrappingStyle: {
226
+ 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")`,
227
+ enum: ["greedy", "balance"],
228
+ type: "string"
229
+ },
230
+ preferCodeFences: {
231
+ description: `Use fenced code blocks (\`\`\`) instead of 4-space indentation for code without a language tag.\n\n- (Default: false)`,
232
+ type: "boolean"
233
+ },
234
+ separateReturnsFromParam: {
235
+ description: `Add a blank line between the last \`@param\` and \`@returns\`.\n\n- (Default: false)`,
236
+ type: "boolean"
237
+ },
238
+ separateTagGroups: {
239
+ description: `Add blank lines between different tag groups (e.g. between \`@param\` and \`@returns\`).\n\n- (Default: false)`,
240
+ type: "boolean"
241
+ }
242
+ }
243
+ }]
244
+ },
186
245
  jsxSingleQuote: {
187
246
  description: `Use single quotes instead of double quotes in JSX. (Default: false)`,
188
247
  type: "boolean"
@@ -231,89 +290,91 @@ const oxfmtOptionsSchema = {
231
290
  type: "boolean"
232
291
  },
233
292
  sortImports: {
234
- additionalProperties: false,
235
293
  description: `Experimental: Sort import statements. Disabled by default.`,
236
- type: "object",
237
- properties: {
238
- customGroups: {
239
- description: `Define your own groups for matching very specific imports.\n\nThe customGroups list is ordered: The first definition that matches an element will be used.\nCustom groups have a higher priority than any predefined group.\n\nIf you want a predefined group to take precedence over a custom group,\nyou must write a custom group definition that does the same as what the predefined group does, and put it first in the list.\n\n- (Default: [])`,
240
- type: "array",
241
- items: {
242
- additionalProperties: false,
243
- type: "object",
244
- properties: {
245
- elementNamePattern: {
246
- description: `List of glob patterns to match import sources for this group.`,
247
- type: "array",
248
- items: { type: "string" }
249
- },
250
- groupName: {
251
- description: `Name of the custom group, used in the groups option.`,
252
- type: "string"
253
- },
254
- modifiers: {
255
- description: `Modifiers to match the import characteristics.\nAll specified modifiers must be present (AND logic).\n\nPossible values: "side_effect", "type", "value", "default", "wildcard", "named"`,
294
+ oneOf: [{ type: "boolean" }, {
295
+ additionalProperties: false,
296
+ type: "object",
297
+ properties: {
298
+ customGroups: {
299
+ description: `Define your own groups for matching very specific imports.\n\nThe customGroups list is ordered: The first definition that matches an element will be used.\nCustom groups have a higher priority than any predefined group.\n\nIf you want a predefined group to take precedence over a custom group,\nyou must write a custom group definition that does the same as what the predefined group does, and put it first in the list.\n\n- (Default: [])`,
300
+ type: "array",
301
+ items: {
302
+ additionalProperties: false,
303
+ type: "object",
304
+ properties: {
305
+ elementNamePattern: {
306
+ description: `List of glob patterns to match import sources for this group.`,
307
+ type: "array",
308
+ items: { type: "string" }
309
+ },
310
+ groupName: {
311
+ description: `Name of the custom group, used in the groups option.`,
312
+ type: "string"
313
+ },
314
+ modifiers: {
315
+ description: `Modifiers to match the import characteristics.\nAll specified modifiers must be present (AND logic).\n\nPossible values: "side_effect", "type", "value", "default", "wildcard", "named"`,
316
+ type: "array",
317
+ items: { type: "string" }
318
+ },
319
+ selector: {
320
+ description: `Selector to match the import kind.\n\nPossible values: "type", "side_effect_style", "side_effect", "style", "index", "sibling", "parent", "subpath", "internal", "builtin", "external", "import"`,
321
+ type: "string"
322
+ }
323
+ }
324
+ }
325
+ },
326
+ groups: {
327
+ description: `Specifies a list of predefined import groups for sorting.\n\nEach import will be assigned a single group specified in the groups option (or the \`unknown\` group if no match is found).\nThe order of items in the \`groups\` option determines how groups are ordered.\n\nWithin a given group, members will be sorted according to the type, order, ignoreCase, etc. options.\n\nIndividual groups can be combined together by placing them in an array.\nThe order of groups in that array does not matter.\nAll members of the groups in the array will be sorted together as if they were part of a single group.\n\nPredefined groups are characterized by a single selector and potentially multiple modifiers.\nYou may enter modifiers in any order, but the selector must always come at the end.\n\nThe list of selectors is sorted from most to least important:\n- \`type\` — TypeScript type imports.\n- \`side_effect_style\` — Side effect style imports.\n- \`side_effect\` — Side effect imports.\n- \`style\` — Style imports.\n- \`index\` — Main file from the current directory.\n- \`sibling\` — Modules from the same directory.\n- \`parent\` — Modules from the parent directory.\n- \`subpath\` — Node.js subpath imports.\n- \`internal\` — Your internal modules.\n- \`builtin\` — Node.js Built-in Modules.\n- \`external\` — External modules installed in the project.\n- \`import\` — Any import.\n\nThe list of modifiers is sorted from most to least important:\n- \`side_effect\` — Side effect imports.\n- \`type\` — TypeScript type imports.\n- \`value\` — Value imports.\n- \`default\` — Imports containing the default specifier.\n- \`wildcard\` — Imports containing the wildcard (\`* as\`) specifier.\n- \`named\` — Imports containing at least one named specifier.\n\n- Default: See below\n\`\`\`json\n[\n\"builtin\",\n\"external\",\n[\"internal\", \"subpath\"],\n[\"parent\", \"sibling\", \"index\"],\n\"style\",\n\"unknown\"\n]\n\`\`\`\n\nAlso, you can override the global \`newlinesBetween\` setting for specific group boundaries\nby including a \`{ \"newlinesBetween\": boolean }\` marker object in the \`groups\` list at the desired position.`,
328
+ type: "array",
329
+ items: { anyOf: [
330
+ { type: "string" },
331
+ {
256
332
  type: "array",
257
333
  items: { type: "string" }
258
334
  },
259
- selector: {
260
- description: `Selector to match the import kind.\n\nPossible values: "type", "side_effect_style", "side_effect", "style", "index", "sibling", "parent", "subpath", "internal", "builtin", "external", "import"`,
261
- type: "string"
335
+ {
336
+ additionalProperties: false,
337
+ required: ["newlinesBetween"],
338
+ type: "object",
339
+ properties: { newlinesBetween: {
340
+ description: `A marker object for overriding \`newlinesBetween\` at a specific group boundary.`,
341
+ type: "boolean"
342
+ } }
262
343
  }
263
- }
344
+ ] }
345
+ },
346
+ ignoreCase: {
347
+ description: `Ignore case when sorting. (Default: true)`,
348
+ type: "boolean"
349
+ },
350
+ internalPattern: {
351
+ description: `Glob patterns to identify internal imports.`,
352
+ type: "array",
353
+ items: { type: "string" }
354
+ },
355
+ newlinesBetween: {
356
+ description: `Add newlines between import groups. (Default: true)`,
357
+ type: "boolean"
358
+ },
359
+ order: {
360
+ description: `Sort order. (Default: "asc")`,
361
+ enum: ["asc", "desc"],
362
+ type: "string"
363
+ },
364
+ partitionByComment: {
365
+ description: `Partition imports by comments. (Default: false)`,
366
+ type: "boolean"
367
+ },
368
+ partitionByNewline: {
369
+ description: `Partition imports by newlines. (Default: false)`,
370
+ type: "boolean"
371
+ },
372
+ sortSideEffects: {
373
+ description: `Sort side-effect imports. (Default: false)`,
374
+ type: "boolean"
264
375
  }
265
- },
266
- groups: {
267
- description: `Specifies a list of predefined import groups for sorting.\n\nEach import will be assigned a single group specified in the groups option (or the \`unknown\` group if no match is found).\nThe order of items in the \`groups\` option determines how groups are ordered.\n\nWithin a given group, members will be sorted according to the type, order, ignoreCase, etc. options.\n\nIndividual groups can be combined together by placing them in an array.\nThe order of groups in that array does not matter.\nAll members of the groups in the array will be sorted together as if they were part of a single group.\n\nPredefined groups are characterized by a single selector and potentially multiple modifiers.\nYou may enter modifiers in any order, but the selector must always come at the end.\n\nThe list of selectors is sorted from most to least important:\n- \`type\` — TypeScript type imports.\n- \`side_effect_style\` — Side effect style imports.\n- \`side_effect\` — Side effect imports.\n- \`style\` — Style imports.\n- \`index\` — Main file from the current directory.\n- \`sibling\` — Modules from the same directory.\n- \`parent\` — Modules from the parent directory.\n- \`subpath\` — Node.js subpath imports.\n- \`internal\` — Your internal modules.\n- \`builtin\` — Node.js Built-in Modules.\n- \`external\` — External modules installed in the project.\n- \`import\` — Any import.\n\nThe list of modifiers is sorted from most to least important:\n- \`side_effect\` — Side effect imports.\n- \`type\` — TypeScript type imports.\n- \`value\` — Value imports.\n- \`default\` — Imports containing the default specifier.\n- \`wildcard\` — Imports containing the wildcard (\`* as\`) specifier.\n- \`named\` — Imports containing at least one named specifier.\n\n- Default: See below\n\`\`\`json\n[\n\"builtin\",\n\"external\",\n[\"internal\", \"subpath\"],\n[\"parent\", \"sibling\", \"index\"],\n\"style\",\n\"unknown\"\n]\n\`\`\`\n\nAlso, you can override the global \`newlinesBetween\` setting for specific group boundaries\nby including a \`{ \"newlinesBetween\": boolean }\` marker object in the \`groups\` list at the desired position.`,
268
- type: "array",
269
- items: { anyOf: [
270
- { type: "string" },
271
- {
272
- type: "array",
273
- items: { type: "string" }
274
- },
275
- {
276
- additionalProperties: false,
277
- required: ["newlinesBetween"],
278
- type: "object",
279
- properties: { newlinesBetween: {
280
- description: `A marker object for overriding \`newlinesBetween\` at a specific group boundary.`,
281
- type: "boolean"
282
- } }
283
- }
284
- ] }
285
- },
286
- ignoreCase: {
287
- description: `Ignore case when sorting. (Default: true)`,
288
- type: "boolean"
289
- },
290
- internalPattern: {
291
- description: `Glob patterns to identify internal imports.`,
292
- type: "array",
293
- items: { type: "string" }
294
- },
295
- newlinesBetween: {
296
- description: `Add newlines between import groups. (Default: true)`,
297
- type: "boolean"
298
- },
299
- order: {
300
- description: `Sort order. (Default: "asc")`,
301
- enum: ["asc", "desc"],
302
- type: "string"
303
- },
304
- partitionByComment: {
305
- description: `Partition imports by comments. (Default: false)`,
306
- type: "boolean"
307
- },
308
- partitionByNewline: {
309
- description: `Partition imports by newlines. (Default: false)`,
310
- type: "boolean"
311
- },
312
- sortSideEffects: {
313
- description: `Sort side-effect imports. (Default: false)`,
314
- type: "boolean"
315
376
  }
316
- }
377
+ }]
317
378
  },
318
379
  sortPackageJson: {
319
380
  description: `Experimental: Sort package.json keys. (Default: true)`,
@@ -327,37 +388,39 @@ const oxfmtOptionsSchema = {
327
388
  }]
328
389
  },
329
390
  sortTailwindcss: {
330
- additionalProperties: false,
331
391
  description: `Experimental: Enable Tailwind CSS class sorting in JSX class/className attributes.\nWhen enabled, class strings will be collected and passed to a callback for sorting.\nPass an object with options from "prettier-plugin-tailwindcss".\n\n- (Default: disabled)`,
332
- type: "object",
333
- properties: {
334
- attributes: {
335
- description: `List of additional attributes to sort beyond "class" and "className" (exact match).\n\nNOTE: Regex patterns are not yet supported.\n\n- (Default: [])\n- Example: ["myClassProp", ":class"]`,
336
- type: "array",
337
- items: { type: "string" }
338
- },
339
- config: {
340
- description: `Path to your Tailwind CSS configuration file (v3).\n\nNote: Paths are resolved relative to the Oxfmt configuration file.\n\n- (Default: "./tailwind.config.js")`,
341
- type: "string"
342
- },
343
- functions: {
344
- description: `List of custom function names whose arguments should be sorted (exact match).\n\nNOTE: Regex patterns are not yet supported.\n\n- (Default: [])\n- Example: ["clsx", "cn", "cva", "tw"]`,
345
- type: "array",
346
- items: { type: "string" }
347
- },
348
- preserveDuplicates: {
349
- description: `Preserve duplicate classes.\n\n- (Default: false)`,
350
- type: "boolean"
351
- },
352
- preserveWhitespace: {
353
- description: `Preserve whitespace around classes.\n\n- (Default: false)`,
354
- type: "boolean"
355
- },
356
- stylesheet: {
357
- description: `Path to your Tailwind CSS stylesheet (v4).\n\nNote: Paths are resolved relative to the Oxfmt configuration file.\n\n- (Example: "./src/app.css")`,
358
- type: "string"
392
+ oneOf: [{ type: "boolean" }, {
393
+ additionalProperties: false,
394
+ type: "object",
395
+ properties: {
396
+ attributes: {
397
+ description: `List of additional attributes to sort beyond "class" and "className" (exact match).\n\nNOTE: Regex patterns are not yet supported.\n\n- (Default: [])\n- Example: ["myClassProp", ":class"]`,
398
+ type: "array",
399
+ items: { type: "string" }
400
+ },
401
+ config: {
402
+ description: `Path to your Tailwind CSS configuration file (v3).\n\nNote: Paths are resolved relative to the Oxfmt configuration file.\n\n- (Default: "./tailwind.config.js")`,
403
+ type: "string"
404
+ },
405
+ functions: {
406
+ description: `List of custom function names whose arguments should be sorted (exact match).\n\nNOTE: Regex patterns are not yet supported.\n\n- (Default: [])\n- Example: ["clsx", "cn", "cva", "tw"]`,
407
+ type: "array",
408
+ items: { type: "string" }
409
+ },
410
+ preserveDuplicates: {
411
+ description: `Preserve duplicate classes.\n\n- (Default: false)`,
412
+ type: "boolean"
413
+ },
414
+ preserveWhitespace: {
415
+ description: `Preserve whitespace around classes.\n\n- (Default: false)`,
416
+ type: "boolean"
417
+ },
418
+ stylesheet: {
419
+ description: `Path to your Tailwind CSS stylesheet (v4).\n\nNote: Paths are resolved relative to the Oxfmt configuration file.\n\n- (Example: "./src/app.css")`,
420
+ type: "string"
421
+ }
359
422
  }
360
- }
423
+ }]
361
424
  },
362
425
  tabWidth: {
363
426
  description: `Number of spaces per indentation level. (Default: 2)`,
@@ -450,6 +513,8 @@ const rules = { oxfmt: {
450
513
  }
451
514
  },
452
515
  create(context) {
516
+ const physicalFilename = context.physicalFilename ?? context.filename;
517
+ if (context.filename !== physicalFilename) return {};
453
518
  if (!formatViaOxfmt) formatViaOxfmt = createSyncFn(join(dirWorkers, "oxfmt.mjs"));
454
519
  const sourceText = context.sourceCode.text;
455
520
  return { [context.sourceCode.ast.type || "Program"]() {
@@ -30,6 +30,31 @@ export type OxfmtOxfmt = []|[{
30
30
 
31
31
  insertFinalNewline?: boolean
32
32
 
33
+ jsdoc?: (boolean | {
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")
@@ -46,7 +71,7 @@ export type OxfmtOxfmt = []|[{
46
71
 
47
72
  singleQuote?: boolean
48
73
 
49
- sortImports?: {
74
+ sortImports?: (boolean | {
50
75
 
51
76
  customGroups?: {
52
77
 
@@ -77,14 +102,14 @@ export type OxfmtOxfmt = []|[{
77
102
  partitionByNewline?: boolean
78
103
 
79
104
  sortSideEffects?: boolean
80
- }
105
+ })
81
106
 
82
107
  sortPackageJson?: (boolean | {
83
108
 
84
109
  sortScripts?: boolean
85
110
  })
86
111
 
87
- sortTailwindcss?: {
112
+ sortTailwindcss?: (boolean | {
88
113
 
89
114
  attributes?: string[]
90
115
 
@@ -97,7 +122,7 @@ export type OxfmtOxfmt = []|[{
97
122
  preserveWhitespace?: boolean
98
123
 
99
124
  stylesheet?: string
100
- }
125
+ })
101
126
 
102
127
  tabWidth?: number
103
128
 
@@ -135,6 +160,31 @@ export type OxfmtOxfmt = []|[{
135
160
 
136
161
  insertFinalNewline?: boolean
137
162
 
163
+ jsdoc?: (boolean | {
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")
@@ -151,7 +201,7 @@ export type OxfmtOxfmt = []|[{
151
201
 
152
202
  singleQuote?: boolean
153
203
 
154
- sortImports?: {
204
+ sortImports?: (boolean | {
155
205
 
156
206
  customGroups?: {
157
207
 
@@ -182,14 +232,14 @@ export type OxfmtOxfmt = []|[{
182
232
  partitionByNewline?: boolean
183
233
 
184
234
  sortSideEffects?: boolean
185
- }
235
+ })
186
236
 
187
237
  sortPackageJson?: (boolean | {
188
238
 
189
239
  sortScripts?: boolean
190
240
  })
191
241
 
192
- sortTailwindcss?: {
242
+ sortTailwindcss?: (boolean | {
193
243
 
194
244
  attributes?: string[]
195
245
 
@@ -202,7 +252,7 @@ export type OxfmtOxfmt = []|[{
202
252
  preserveWhitespace?: boolean
203
253
 
204
254
  stylesheet?: string
205
- }
255
+ })
206
256
 
207
257
  tabWidth?: number
208
258
 
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.3.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.43.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.1",
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",
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.20260331.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.43.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.3.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
  )