From 95943aa6f28e0697b577b010638a16cf2358fa4f Mon Sep 17 00:00:00 2001
From: leaysgur <6259812+leaysgur@users.noreply.github.com>
Date: Wed, 11 Mar 2026 04:34:48 +0000
Subject: [PATCH] feat(oxfmt): Support `vite.config.*` `.fmt` field (#20197)
NOTE: I'll do a self-review and add the test tomorrow.
Prompt
```
# Support `vite.config.*` as a Configuration File
## Target Files
- `vite.config.ts`
- `vite.config.mts`
- `vite.config.cts`
- `vite.config.js`
- `vite.config.mjs`
- `vite.config.cjs`
## Auto-Discovery Priority
When searching for a config file from `cwd` upwards, the following priority applies within the same directory:
1. Tool-specific JSON/JSONC config files (e.g. `.oxfmtrc.json`, `.oxlintrc.json`)
2. Tool-specific JS/TS config files (e.g. `oxfmt.config.ts`, `oxlint.config.ts`)
3. `vite.config.*`
## Extracting Configuration
The default export of `vite.config.*` is expected to contain tool-specific configuration under a dedicated field:
- oxfmt: `.fmt`
- oxlint: `.lint`
If the field does not exist, an error is returned (e.g. `Expected a 'fmt' field in the default export.`).
## Explicit `--config` Flag
When `--config vite.config.ts` is explicitly specified, the same behavior applies. The file name is used to determine whether to extract a nested field.
## LSP
`vite.config.*` files are added to the file watch patterns so that configuration changes trigger a reload.
```
---
apps/oxfmt/src/cli/format.rs | 1 +
apps/oxfmt/src/core/config.rs | 109 +++++++++++-------
apps/oxfmt/src/core/mod.rs | 4 +-
apps/oxfmt/src/lsp/server_formatter.rs | 12 +-
.../__snapshots__/vite_config.test.ts.snap | 49 ++++++++
.../cli/vite_config/fixtures/basic/test.ts | 1 +
.../vite_config/fixtures/basic/vite.config.ts | 5 +
.../fixtures/error_no_fmt_field/test.ts | 1 +
.../error_no_fmt_field/vite.config.ts | 3 +
.../fixtures/priority/oxfmt.config.ts | 3 +
.../cli/vite_config/fixtures/priority/test.ts | 1 +
.../fixtures/priority/vite.config.ts | 5 +
.../test/cli/vite_config/vite_config.test.ts | 27 +++++
.../format/__snapshots__/format.test.ts.snap | 12 ++
.../format/fixtures/config-vite-semi/test.ts | 1 +
.../fixtures/config-vite-semi/vite.config.ts | 5 +
apps/oxfmt/test/lsp/format/format.test.ts | 1 +
apps/oxfmt/test/lsp/init/init.test.ts | 12 ++
18 files changed, 199 insertions(+), 53 deletions(-)
create mode 100644 apps/oxfmt/test/cli/vite_config/__snapshots__/vite_config.test.ts.snap
create mode 100644 apps/oxfmt/test/cli/vite_config/fixtures/basic/test.ts
create mode 100644 apps/oxfmt/test/cli/vite_config/fixtures/basic/vite.config.ts
create mode 100644 apps/oxfmt/test/cli/vite_config/fixtures/error_no_fmt_field/test.ts
create mode 100644 apps/oxfmt/test/cli/vite_config/fixtures/error_no_fmt_field/vite.config.ts
create mode 100644 apps/oxfmt/test/cli/vite_config/fixtures/priority/oxfmt.config.ts
create mode 100644 apps/oxfmt/test/cli/vite_config/fixtures/priority/test.ts
create mode 100644 apps/oxfmt/test/cli/vite_config/fixtures/priority/vite.config.ts
create mode 100644 apps/oxfmt/test/cli/vite_config/vite_config.test.ts
create mode 100644 apps/oxfmt/test/lsp/format/fixtures/config-vite-semi/test.ts
create mode 100644 apps/oxfmt/test/lsp/format/fixtures/config-vite-semi/vite.config.ts
diff --git a/apps/oxfmt/src/cli/format.rs b/apps/oxfmt/src/cli/format.rs
index 8e2a3628e10a5..d58e2f643598c 100644
--- a/apps/oxfmt/src/cli/format.rs
+++ b/apps/oxfmt/src/cli/format.rs
@@ -217,6 +217,7 @@ impl FormatRunner {
);
// Config stats: only show when no config is found
if oxfmtrc_path.is_none() && editorconfig_path.is_none() {
+ #[cfg(feature = "napi")]
let hint = "No config found, using defaults. Please add a config file or try `oxfmt --init` if needed.\n";
#[cfg(not(feature = "napi"))]
let hint =
diff --git a/apps/oxfmt/src/core/config.rs b/apps/oxfmt/src/core/config.rs
index 289a2054ef766..106086f4b25ab 100644
--- a/apps/oxfmt/src/core/config.rs
+++ b/apps/oxfmt/src/core/config.rs
@@ -23,16 +23,24 @@ use super::{
};
/// JSON/JSONC config file names, in order of preference.
-pub const JSON_CONFIG_FILES: &[&str] = &[".oxfmtrc.json", ".oxfmtrc.jsonc"];
-/// JS/TS config file names, in order of preference.
-pub const JS_CONFIG_FILES: &[&str] = &[
- "oxfmt.config.ts",
- "oxfmt.config.mts",
- "oxfmt.config.cts",
- "oxfmt.config.js",
- "oxfmt.config.mjs",
- "oxfmt.config.cjs",
-];
+const JSON_CONFIG_FILES: &[&str] = &[".oxfmtrc.json", ".oxfmtrc.jsonc"];
+/// JS/TS config file extensions.
+const JS_CONFIG_EXTENSIONS: &[&str] = &["ts", "mts", "cts", "js", "mjs", "cjs"];
+/// Oxfmt JS/TS config file prefix.
+const OXFMT_JS_CONFIG_PREFIX: &str = "oxfmt.config.";
+/// Vite+ config file prefix that may contain Oxfmt config under a `.fmt` field.
+const VITE_PLUS_JS_CONFIG_PREFIX: &str = "vite.config.";
+#[cfg(feature = "napi")]
+const VITE_PLUS_OXFMT_CONFIG_FIELD: &str = "fmt";
+
+/// Returns an iterator of all supported config file names, in priority order.
+pub fn all_config_file_names() -> impl Iterator- {
+ let json = JSON_CONFIG_FILES.iter().map(|f| (*f).to_string());
+ let oxfmt_js = JS_CONFIG_EXTENSIONS.iter().map(|ext| format!("{OXFMT_JS_CONFIG_PREFIX}{ext}"));
+ let vite_plus =
+ JS_CONFIG_EXTENSIONS.iter().map(|ext| format!("{VITE_PLUS_JS_CONFIG_PREFIX}{ext}"));
+ json.chain(oxfmt_js).chain(vite_plus)
+}
/// Resolve config file path from cwd and optional explicit path.
pub fn resolve_oxfmtrc_path(cwd: &Path, config_path: Option<&Path>) -> Option {
@@ -41,12 +49,12 @@ pub fn resolve_oxfmtrc_path(cwd: &Path, config_path: Option<&Path>) -> Option Result {
// Uses extension-based matching so that both auto-discovered files (e.g. `oxfmt.config.ts`)
// and explicitly specified files (e.g. `--config ./my-config.ts`) are handled.
- let is_js_config_path = |path: &Path| {
- let Some(ext) = path.extension().and_then(|e| e.to_str()) else {
- return false;
- };
- JS_CONFIG_FILES.iter().any(|f| f.ends_with(ext))
- };
+ let is_js_config = oxfmtrc_path.is_some_and(|path| {
+ path.extension()
+ .and_then(|e| e.to_str())
+ .is_some_and(|ext| JS_CONFIG_EXTENSIONS.contains(&ext))
+ });
- if let Some(path) = oxfmtrc_path
- && is_js_config_path(path)
- {
- #[cfg(not(feature = "napi"))]
- return Err(format!(
+ #[cfg(not(feature = "napi"))]
+ if is_js_config {
+ return Err(
"JS/TS config files are not supported in pure Rust CLI.\nUse JSON/JSONC instead."
- ));
+ .to_string(),
+ );
+ }
- // Call `import(oxfmtrc_path)` via NAPI
- #[cfg(feature = "napi")]
- {
- let raw_config = js_config_loader
- .expect("JS config loader must be set when `napi` feature is enabled")(
- path.to_string_lossy().into_owned(),
+ // Call `import(oxfmtrc_path)` via NAPI
+ #[cfg(feature = "napi")]
+ if let Some(path) = oxfmtrc_path
+ && is_js_config
+ {
+ let raw_config = js_config_loader
+ .expect("JS config loader must be set when `napi` feature is enabled")(
+ path.to_string_lossy().into_owned(),
+ )
+ .map_err(|_| {
+ format!(
+ "{}\nEnsure the file has a valid default export of a JSON-serializable configuration object.",
+ path.display()
)
- .map_err(|_| {
- format!(
- "{}\nEnsure the file has a valid default export of a JSON-serializable configuration object.",
- path.display()
- )
- })?;
- let config_dir = path.parent().map(Path::to_path_buf);
- let editorconfig = load_editorconfig(cwd, editorconfig_path)?;
+ })?;
+
+ // Vite+ config files (e.g. `vite.config.ts`),
+ // under a `.fmt` field instead of the default export directly.
+ let is_vite_plus = path
+ .file_name()
+ .and_then(|f| f.to_str())
+ .is_some_and(|name| name.starts_with(VITE_PLUS_JS_CONFIG_PREFIX));
+ let raw_config = if is_vite_plus {
+ raw_config.get(VITE_PLUS_OXFMT_CONFIG_FIELD).cloned().ok_or_else(|| {
+ format!("{}\nExpected a `{VITE_PLUS_OXFMT_CONFIG_FIELD}` field in the default export.", path.display())
+ })?
+ } else {
+ raw_config
+ };
- return Ok(Self::new(raw_config, config_dir, editorconfig));
- }
+ let config_dir = path.parent().map(Path::to_path_buf);
+ let editorconfig = load_editorconfig(cwd, editorconfig_path)?;
+
+ return Ok(Self::new(raw_config, config_dir, editorconfig));
}
Self::from_json_config(cwd, oxfmtrc_path, editorconfig_path)
diff --git a/apps/oxfmt/src/core/mod.rs b/apps/oxfmt/src/core/mod.rs
index bc73bb7674fe7..d7fbf02f28b25 100644
--- a/apps/oxfmt/src/core/mod.rs
+++ b/apps/oxfmt/src/core/mod.rs
@@ -9,13 +9,13 @@ mod external_formatter;
#[cfg(feature = "napi")]
mod js_config;
+#[cfg(feature = "napi")]
+pub use config::all_config_file_names;
#[cfg(feature = "napi")]
pub use config::resolve_options_from_value;
pub use config::{
ConfigResolver, ResolvedOptions, resolve_editorconfig_path, resolve_oxfmtrc_path,
};
-#[cfg(feature = "napi")]
-pub use config::{JS_CONFIG_FILES, JSON_CONFIG_FILES};
pub use format::{FormatResult, SourceFormatter};
pub use support::FormatFileStrategy;
diff --git a/apps/oxfmt/src/lsp/server_formatter.rs b/apps/oxfmt/src/lsp/server_formatter.rs
index f68226e07942c..74c535fa0ab7b 100644
--- a/apps/oxfmt/src/lsp/server_formatter.rs
+++ b/apps/oxfmt/src/lsp/server_formatter.rs
@@ -8,9 +8,8 @@ use oxc_data_structures::rope::{Rope, get_line_column};
use oxc_language_server::{Capabilities, LanguageId, Tool, ToolBuilder, ToolRestartChanges};
use crate::core::{
- ConfigResolver, ExternalFormatter, FormatFileStrategy, FormatResult, JS_CONFIG_FILES,
- JSON_CONFIG_FILES, JsConfigLoaderCb, SourceFormatter, resolve_editorconfig_path,
- resolve_oxfmtrc_path, utils,
+ ConfigResolver, ExternalFormatter, FormatFileStrategy, FormatResult, JsConfigLoaderCb,
+ SourceFormatter, all_config_file_names, resolve_editorconfig_path, resolve_oxfmtrc_path, utils,
};
use crate::lsp::create_fake_file_path_from_language_id;
use crate::lsp::options::FormatOptions as LSPFormatOptions;
@@ -234,11 +233,8 @@ impl Tool for ServerFormatter {
if let Some(config_path) = options.config_path.as_ref().filter(|s| !s.is_empty()) {
vec![config_path.clone()]
} else {
- JSON_CONFIG_FILES
- .iter()
- .chain(JS_CONFIG_FILES.iter())
- .map(|file| (*file).to_string())
- .collect()
+ // TODO: This can be glob patterns?
+ all_config_file_names().collect()
};
patterns.push(".editorconfig".to_string());
diff --git a/apps/oxfmt/test/cli/vite_config/__snapshots__/vite_config.test.ts.snap b/apps/oxfmt/test/cli/vite_config/__snapshots__/vite_config.test.ts.snap
new file mode 100644
index 0000000000000..9de7b5a52ca08
--- /dev/null
+++ b/apps/oxfmt/test/cli/vite_config/__snapshots__/vite_config.test.ts.snap
@@ -0,0 +1,49 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`vite_config > basic: reads fmt field from vite.config.ts 1`] = `
+"--------------------
+arguments: --check test.ts
+working directory: vite_config/fixtures/basic
+exit code: 1
+--- STDOUT ---------
+Checking formatting...
+
+test.ts (ms)
+
+Format issues found in above 1 files. Run without \`--check\` to fix.
+Finished in ms on 1 files using 1 threads.
+--- STDERR ---------
+
+--------------------"
+`;
+
+exports[`vite_config > error: no fmt field in vite.config.ts 1`] = `
+"--------------------
+arguments: --check test.ts
+working directory: vite_config/fixtures/error_no_fmt_field
+exit code: 1
+--- STDOUT ---------
+
+--- STDERR ---------
+Failed to load configuration file.
+/vite.config.ts
+Expected a \`fmt\` field in the default export.
+--------------------"
+`;
+
+exports[`vite_config > priority: oxfmt.config.ts takes precedence over vite.config.ts 1`] = `
+"--------------------
+arguments: --check test.ts
+working directory: vite_config/fixtures/priority
+exit code: 1
+--- STDOUT ---------
+Checking formatting...
+
+test.ts (ms)
+
+Format issues found in above 1 files. Run without \`--check\` to fix.
+Finished in ms on 1 files using 1 threads.
+--- STDERR ---------
+
+--------------------"
+`;
diff --git a/apps/oxfmt/test/cli/vite_config/fixtures/basic/test.ts b/apps/oxfmt/test/cli/vite_config/fixtures/basic/test.ts
new file mode 100644
index 0000000000000..54b82a09ad543
--- /dev/null
+++ b/apps/oxfmt/test/cli/vite_config/fixtures/basic/test.ts
@@ -0,0 +1 @@
+const a = 1;
diff --git a/apps/oxfmt/test/cli/vite_config/fixtures/basic/vite.config.ts b/apps/oxfmt/test/cli/vite_config/fixtures/basic/vite.config.ts
new file mode 100644
index 0000000000000..c584bee65363a
--- /dev/null
+++ b/apps/oxfmt/test/cli/vite_config/fixtures/basic/vite.config.ts
@@ -0,0 +1,5 @@
+export default {
+ fmt: {
+ semi: false,
+ },
+};
diff --git a/apps/oxfmt/test/cli/vite_config/fixtures/error_no_fmt_field/test.ts b/apps/oxfmt/test/cli/vite_config/fixtures/error_no_fmt_field/test.ts
new file mode 100644
index 0000000000000..54b82a09ad543
--- /dev/null
+++ b/apps/oxfmt/test/cli/vite_config/fixtures/error_no_fmt_field/test.ts
@@ -0,0 +1 @@
+const a = 1;
diff --git a/apps/oxfmt/test/cli/vite_config/fixtures/error_no_fmt_field/vite.config.ts b/apps/oxfmt/test/cli/vite_config/fixtures/error_no_fmt_field/vite.config.ts
new file mode 100644
index 0000000000000..4ed8f62dd39b0
--- /dev/null
+++ b/apps/oxfmt/test/cli/vite_config/fixtures/error_no_fmt_field/vite.config.ts
@@ -0,0 +1,3 @@
+export default {
+ plugins: [],
+};
diff --git a/apps/oxfmt/test/cli/vite_config/fixtures/priority/oxfmt.config.ts b/apps/oxfmt/test/cli/vite_config/fixtures/priority/oxfmt.config.ts
new file mode 100644
index 0000000000000..2c123060d2bf5
--- /dev/null
+++ b/apps/oxfmt/test/cli/vite_config/fixtures/priority/oxfmt.config.ts
@@ -0,0 +1,3 @@
+export default {
+ semi: false,
+};
diff --git a/apps/oxfmt/test/cli/vite_config/fixtures/priority/test.ts b/apps/oxfmt/test/cli/vite_config/fixtures/priority/test.ts
new file mode 100644
index 0000000000000..54b82a09ad543
--- /dev/null
+++ b/apps/oxfmt/test/cli/vite_config/fixtures/priority/test.ts
@@ -0,0 +1 @@
+const a = 1;
diff --git a/apps/oxfmt/test/cli/vite_config/fixtures/priority/vite.config.ts b/apps/oxfmt/test/cli/vite_config/fixtures/priority/vite.config.ts
new file mode 100644
index 0000000000000..e605a95f8b7d5
--- /dev/null
+++ b/apps/oxfmt/test/cli/vite_config/fixtures/priority/vite.config.ts
@@ -0,0 +1,5 @@
+export default {
+ fmt: {
+ semi: true,
+ },
+};
diff --git a/apps/oxfmt/test/cli/vite_config/vite_config.test.ts b/apps/oxfmt/test/cli/vite_config/vite_config.test.ts
new file mode 100644
index 0000000000000..955476a8a7c41
--- /dev/null
+++ b/apps/oxfmt/test/cli/vite_config/vite_config.test.ts
@@ -0,0 +1,27 @@
+import { describe, expect, it } from "vitest";
+import { join } from "node:path";
+import { runAndSnapshot } from "../utils";
+
+const fixturesDir = join(import.meta.dirname, "fixtures");
+
+describe("vite_config", () => {
+ it("basic: reads fmt field from vite.config.ts", async () => {
+ const cwd = join(fixturesDir, "basic");
+ const snapshot = await runAndSnapshot(cwd, [["--check", "test.ts"]]);
+ expect(snapshot).toMatchSnapshot();
+ });
+
+ it("error: no fmt field in vite.config.ts", async () => {
+ const cwd = join(fixturesDir, "error_no_fmt_field");
+ const snapshot = await runAndSnapshot(cwd, [["--check", "test.ts"]]);
+ expect(snapshot).toMatchSnapshot();
+ });
+
+ it("priority: oxfmt.config.ts takes precedence over vite.config.ts", async () => {
+ // `oxfmt.config.ts` has `semi: false`, `vite.config.ts` has `semi: true`
+ // oxfmt.config.ts should win, so `const a = 1;` (with semicolon) should be flagged
+ const cwd = join(fixturesDir, "priority");
+ const snapshot = await runAndSnapshot(cwd, [["--check", "test.ts"]]);
+ expect(snapshot).toMatchSnapshot();
+ });
+});
diff --git a/apps/oxfmt/test/lsp/format/__snapshots__/format.test.ts.snap b/apps/oxfmt/test/lsp/format/__snapshots__/format.test.ts.snap
index 859fc131251d8..a76572edd5684 100644
--- a/apps/oxfmt/test/lsp/format/__snapshots__/format.test.ts.snap
+++ b/apps/oxfmt/test/lsp/format/__snapshots__/format.test.ts.snap
@@ -189,6 +189,18 @@ file:///config-sort-tailwindcss/test.vue
--------------------"
`;
+exports[`LSP formatting > config options > should apply config from config-vite-semi/test.ts 1`] = `
+"--- URI -----------
+file:///config-vite-semi/test.ts
+--- BEFORE ---------
+const x = 1;
+
+--- AFTER ----------
+const x = 1
+
+--------------------"
+`;
+
exports[`LSP formatting > config options > should apply config from config-vue-indent/test.vue 1`] = `
"--- URI -----------
file:///config-vue-indent/test.vue
diff --git a/apps/oxfmt/test/lsp/format/fixtures/config-vite-semi/test.ts b/apps/oxfmt/test/lsp/format/fixtures/config-vite-semi/test.ts
new file mode 100644
index 0000000000000..943c458c79e20
--- /dev/null
+++ b/apps/oxfmt/test/lsp/format/fixtures/config-vite-semi/test.ts
@@ -0,0 +1 @@
+const x = 1;
diff --git a/apps/oxfmt/test/lsp/format/fixtures/config-vite-semi/vite.config.ts b/apps/oxfmt/test/lsp/format/fixtures/config-vite-semi/vite.config.ts
new file mode 100644
index 0000000000000..c584bee65363a
--- /dev/null
+++ b/apps/oxfmt/test/lsp/format/fixtures/config-vite-semi/vite.config.ts
@@ -0,0 +1,5 @@
+export default {
+ fmt: {
+ semi: false,
+ },
+};
diff --git a/apps/oxfmt/test/lsp/format/format.test.ts b/apps/oxfmt/test/lsp/format/format.test.ts
index 91285b6cd0ed8..fd085945aca44 100644
--- a/apps/oxfmt/test/lsp/format/format.test.ts
+++ b/apps/oxfmt/test/lsp/format/format.test.ts
@@ -24,6 +24,7 @@ describe("LSP formatting", () => {
it.each([
["config-semi/test.ts", "typescript"],
["config-js-semi/test.ts", "typescript"],
+ ["config-vite-semi/test.ts", "typescript"],
["config-no-sort-package-json/package.json", "json"],
["config-vue-indent/test.vue", "vue"],
["config-sort-imports/test.js", "javascript"],
diff --git a/apps/oxfmt/test/lsp/init/init.test.ts b/apps/oxfmt/test/lsp/init/init.test.ts
index bc30016f4fd81..9a1dab5296cb0 100644
--- a/apps/oxfmt/test/lsp/init/init.test.ts
+++ b/apps/oxfmt/test/lsp/init/init.test.ts
@@ -27,6 +27,12 @@ describe("LSP initialization", () => {
"oxfmt.config.js",
"oxfmt.config.mjs",
"oxfmt.config.cjs",
+ "vite.config.ts",
+ "vite.config.mts",
+ "vite.config.cts",
+ "vite.config.js",
+ "vite.config.mjs",
+ "vite.config.cjs",
".editorconfig",
],
],
@@ -41,6 +47,12 @@ describe("LSP initialization", () => {
"oxfmt.config.js",
"oxfmt.config.mjs",
"oxfmt.config.cjs",
+ "vite.config.ts",
+ "vite.config.mts",
+ "vite.config.cts",
+ "vite.config.js",
+ "vite.config.mjs",
+ "vite.config.cjs",
".editorconfig",
],
],