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", ], ],