diff --git a/apps/oxfmt/src/cli/format.rs b/apps/oxfmt/src/cli/format.rs index d58e2f643598c..af5e42458f678 100644 --- a/apps/oxfmt/src/cli/format.rs +++ b/apps/oxfmt/src/cli/format.rs @@ -11,9 +11,7 @@ use super::{ }; #[cfg(feature = "napi")] use crate::core::JsConfigLoaderCb; -use crate::core::{ - ConfigResolver, SourceFormatter, resolve_editorconfig_path, resolve_oxfmtrc_path, utils, -}; +use crate::core::{ConfigResolver, SourceFormatter, resolve_editorconfig_path, utils}; pub struct FormatRunner { options: FormatCommand, @@ -81,11 +79,10 @@ impl FormatRunner { // NOTE: Currently, we only load single config file. // - from `--config` if specified // - else, search nearest config file from cwd upwards - let oxfmtrc_path = resolve_oxfmtrc_path(&cwd, config_options.config.as_deref()); let editorconfig_path = resolve_editorconfig_path(&cwd); let mut config_resolver = match ConfigResolver::from_config( &cwd, - oxfmtrc_path.as_deref(), + config_options.config.as_deref(), editorconfig_path.as_deref(), #[cfg(feature = "napi")] self.js_config_loader.as_ref(), @@ -134,7 +131,7 @@ impl FormatRunner { &paths, &ignore_options.ignore_path, ignore_options.with_node_modules, - oxfmtrc_path.as_deref(), + config_resolver.config_dir(), &ignore_patterns, ) { Ok(Some(walker)) => walker, @@ -174,6 +171,7 @@ impl FormatRunner { #[cfg(feature = "napi")] let source_formatter = source_formatter.with_external_formatter(self.external_formatter); + let no_config = config_resolver.config_dir().is_none() && editorconfig_path.is_none(); let format_mode_clone = format_mode.clone(); // Spawn a thread to run formatting service with streaming entries @@ -216,7 +214,7 @@ impl FormatRunner { ), ); // Config stats: only show when no config is found - if oxfmtrc_path.is_none() && editorconfig_path.is_none() { + if no_config { #[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"))] diff --git a/apps/oxfmt/src/cli/walk.rs b/apps/oxfmt/src/cli/walk.rs index ac4258ee43227..10f49566890f7 100644 --- a/apps/oxfmt/src/cli/walk.rs +++ b/apps/oxfmt/src/cli/walk.rs @@ -22,7 +22,7 @@ impl Walk { paths: &[PathBuf], ignore_paths: &[PathBuf], with_node_modules: bool, - oxfmtrc_path: Option<&Path>, + config_dir: Option<&Path>, ignore_patterns: &[String], ) -> Result, String> { // @@ -103,11 +103,9 @@ impl Walk { // 2. Handle `oxfmtrc.ignorePatterns` // Patterns are relative to the config file location if !ignore_patterns.is_empty() - && let Some(oxfmtrc_path) = oxfmtrc_path + && let Some(config_dir) = config_dir { - let mut builder = GitignoreBuilder::new( - oxfmtrc_path.parent().expect("`oxfmtrc_path` should have a parent directory"), - ); + let mut builder = GitignoreBuilder::new(config_dir); for pattern in ignore_patterns { if builder.add_line(None, pattern).is_err() { return Err(format!( diff --git a/apps/oxfmt/src/core/config.rs b/apps/oxfmt/src/core/config.rs index b556a2a4595bf..2825e6141db18 100644 --- a/apps/oxfmt/src/core/config.rs +++ b/apps/oxfmt/src/core/config.rs @@ -28,40 +28,36 @@ const JSON_CONFIG_FILES: &[&str] = &[".oxfmtrc.json", ".oxfmtrc.jsonc"]; const JS_CONFIG_EXTENSIONS: &[&str] = &["ts", "mts", "cts", "js", "mjs", "cjs"]; /// Oxfmt JS/TS config file name. /// Only `.ts` extension is supported, matching oxlint's behavior. +#[cfg(feature = "napi")] const OXFMT_JS_CONFIG_NAME: &str = "oxfmt.config.ts"; /// Vite+ config file name that may contain Oxfmt config under a `.fmt` field. /// Only `.ts` extension is supported, matching oxlint's behavior. +#[cfg(feature = "napi")] const VITE_PLUS_CONFIG_NAME: &str = "vite.config.ts"; #[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 = std::iter::once(OXFMT_JS_CONFIG_NAME.to_string()); - let vite_plus = std::iter::once(VITE_PLUS_CONFIG_NAME.to_string()); - json.chain(oxfmt_js).chain(vite_plus) +fn is_js_config_file(path: &Path) -> bool { + path.extension().and_then(|e| e.to_str()).is_some_and(|ext| JS_CONFIG_EXTENSIONS.contains(&ext)) } -/// Resolve config file path from cwd and optional explicit path. -pub fn resolve_oxfmtrc_path(cwd: &Path, config_path: Option<&Path>) -> Option { - // If `--config` is explicitly specified, use that path - if let Some(config_path) = config_path { - return Some(utils::normalize_relative_path(cwd, config_path)); - } +#[cfg(feature = "napi")] +fn is_vite_plus_config(path: &Path) -> bool { + path.file_name().and_then(|f| f.to_str()).is_some_and(|name| name == VITE_PLUS_CONFIG_NAME) +} - // If `--config` is not specified, search the nearest config file from cwd upwards. - // Support JSON, JSONC, and JS/TS config files. - // Prefer Oxfmt JSON/JSONC over JS/TS over Vite+ config if multiple exist in the same directory. - cwd.ancestors().find_map(|dir| { - for filename in all_config_file_names() { - let config_path = dir.join(&filename); - if config_path.exists() { - return Some(config_path); - } - } - None - }) +/// Returns an iterator of all supported config file names, in priority order. +pub fn all_config_file_names() -> impl Iterator { + #[cfg(feature = "napi")] + { + JSON_CONFIG_FILES + .iter() + .copied() + .chain([OXFMT_JS_CONFIG_NAME, VITE_PLUS_CONFIG_NAME]) + .map(ToString::to_string) + } + #[cfg(not(feature = "napi"))] + JSON_CONFIG_FILES.iter().map(|f| (*f).to_string()) } pub fn resolve_editorconfig_path(cwd: &Path) -> Option { @@ -223,8 +219,16 @@ impl ConfigResolver { Self { raw_config, config_dir, cached_options: None, oxfmtrc_overrides: None, editorconfig } } + /// Returns the directory containing the config file, if any was loaded. + pub fn config_dir(&self) -> Option<&Path> { + self.config_dir.as_deref() + } + /// Create a resolver, handling both JSON/JSONC and JS/TS config files. /// + /// When `oxfmtrc_path` is `Some`, it is treated as an explicitly specified config file. + /// When `oxfmtrc_path` is `None`, auto-discovery searches upwards from `cwd`. + /// /// If the resolved config path is a JS/TS file: /// - With `napi` feature: evaluates it via the provided `js_config_loader` callback. /// - Without `napi` feature: returns an error (requires the Node.js CLI). @@ -237,45 +241,51 @@ impl ConfigResolver { editorconfig_path: Option<&Path>, #[cfg(feature = "napi")] js_config_loader: Option<&JsConfigLoaderCb>, ) -> 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 = oxfmtrc_path.is_some_and(|path| { - path.extension() - .and_then(|e| e.to_str()) - .is_some_and(|ext| JS_CONFIG_EXTENSIONS.contains(&ext)) - }); + // Explicit path: normalize and load directly + if let Some(config_path) = oxfmtrc_path { + let path = utils::normalize_relative_path(cwd, config_path); + return Self::load_config_at( + cwd, + &path, + editorconfig_path, + #[cfg(feature = "napi")] + js_config_loader, + ); + } + + // Auto-discovery: search upwards from cwd, load in one pass + Self::discover_config( + cwd, + editorconfig_path, + #[cfg(feature = "napi")] + js_config_loader, + ) + } + /// Load a config file at a known path. + /// Handles both JSON/JSONC and JS/TS config files. + fn load_config_at( + cwd: &Path, + path: &Path, + editorconfig_path: Option<&Path>, + #[cfg(feature = "napi")] js_config_loader: Option<&JsConfigLoaderCb>, + ) -> Result { #[cfg(not(feature = "napi"))] - if is_js_config { + if is_js_config_file(path) { 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")] - 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() - ) - })?; - - // 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 == VITE_PLUS_CONFIG_NAME); - let raw_config = if is_vite_plus { + if is_js_config_file(path) { + let loader = js_config_loader + .expect("JS config loader must be set when `napi` feature is enabled"); + let raw_config = load_js_config(loader, path)?; + + // Vite+ config files use a `.fmt` field instead of the default export directly. + let raw_config = if is_vite_plus_config(path) { 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()) })? @@ -283,13 +293,60 @@ impl ConfigResolver { raw_config }; - let config_dir = path.parent().map(Path::to_path_buf); let editorconfig = load_editorconfig(cwd, editorconfig_path)?; + return Ok(Self::new(raw_config, path.parent().map(Path::to_path_buf), editorconfig)); + } + + Self::from_json_config(cwd, Some(path), editorconfig_path) + } + + /// Auto-discover and load config by searching upwards from `cwd`. + /// + /// Tries each candidate file in priority order. If a `vite.config.ts` is found + /// but lacks a `.fmt` field, it is skipped and the search continues. + fn discover_config( + cwd: &Path, + editorconfig_path: Option<&Path>, + #[cfg(feature = "napi")] js_config_loader: Option<&JsConfigLoaderCb>, + ) -> Result { + let candidates: Vec = all_config_file_names().collect(); + for dir in cwd.ancestors() { + for filename in &candidates { + let path = dir.join(filename); + if !path.exists() { + continue; + } + + // Special case: vite.config.ts is only used if it has a `.fmt` field. + // If not, skip it and continue searching for other config files. + #[cfg(feature = "napi")] + if is_vite_plus_config(&path) { + let loader = js_config_loader + .expect("JS config loader must be set when `napi` feature is enabled"); + if let Some(raw_config) = try_load_vite_config(loader, &path)? { + let editorconfig = load_editorconfig(cwd, editorconfig_path)?; + return Ok(Self::new( + raw_config, + path.parent().map(Path::to_path_buf), + editorconfig, + )); + } + continue; + } - return Ok(Self::new(raw_config, config_dir, editorconfig)); + // All other config types: load directly + return Self::load_config_at( + cwd, + &path, + editorconfig_path, + #[cfg(feature = "napi")] + js_config_loader, + ); + } } - Self::from_json_config(cwd, oxfmtrc_path, editorconfig_path) + // No config found — use defaults + Self::from_json_config(cwd, None, editorconfig_path) } /// Create a resolver by loading JSON/JSONC config from a file path. @@ -442,6 +499,36 @@ impl ConfigResolver { } } +/// Load a JS/TS config file via NAPI and return the raw JSON value. +#[cfg(feature = "napi")] +fn load_js_config(js_config_loader: &JsConfigLoaderCb, path: &Path) -> Result { + js_config_loader(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() + ) + }) +} + +/// Try to load a `vite.config.ts` and extract the `.fmt` field. +/// Returns `Ok(Some(value))` if the field exists, `Ok(None)` if it doesn't. +#[cfg(feature = "napi")] +fn try_load_vite_config( + js_config_loader: &JsConfigLoaderCb, + path: &Path, +) -> Result, String> { + let raw_config = load_js_config(js_config_loader, path)?; + if let Some(config) = raw_config.get(VITE_PLUS_OXFMT_CONFIG_FIELD).cloned() { + return Ok(Some(config)); + } + + tracing::debug!( + "Skipping {} (no `{VITE_PLUS_OXFMT_CONFIG_FIELD}` field), continuing config search...", + path.display() + ); + Ok(None) +} + // --- /// Resolved overrides from `.oxfmtrc` for file-specific matching. diff --git a/apps/oxfmt/src/core/mod.rs b/apps/oxfmt/src/core/mod.rs index d7fbf02f28b25..92064f824bc9b 100644 --- a/apps/oxfmt/src/core/mod.rs +++ b/apps/oxfmt/src/core/mod.rs @@ -13,9 +13,7 @@ mod js_config; 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, -}; +pub use config::{ConfigResolver, ResolvedOptions, resolve_editorconfig_path}; 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 125181ed2f64a..90138bbd9d3c1 100644 --- a/apps/oxfmt/src/lsp/server_formatter.rs +++ b/apps/oxfmt/src/lsp/server_formatter.rs @@ -9,7 +9,7 @@ use oxc_language_server::{Capabilities, LanguageId, Tool, ToolBuilder, ToolResta use crate::core::{ ConfigResolver, ExternalFormatter, FormatFileStrategy, FormatResult, JsConfigLoaderCb, - SourceFormatter, all_config_file_names, resolve_editorconfig_path, resolve_oxfmtrc_path, utils, + SourceFormatter, all_config_file_names, resolve_editorconfig_path, utils, }; use crate::lsp::create_fake_file_path_from_language_id; use crate::lsp::options::FormatOptions as LSPFormatOptions; @@ -116,13 +116,12 @@ impl ServerFormatterBuilder { root_path: &Path, config_path: Option<&String>, ) -> Result<(ConfigResolver, Vec), String> { - let oxfmtrc_path = - resolve_oxfmtrc_path(root_path, config_path.filter(|s| !s.is_empty()).map(Path::new)); + let oxfmtrc_path = config_path.filter(|s| !s.is_empty()).map(Path::new); let editorconfig_path = resolve_editorconfig_path(root_path); let mut resolver = ConfigResolver::from_config( root_path, - oxfmtrc_path.as_deref(), + oxfmtrc_path, editorconfig_path.as_deref(), Some(&self.js_config_loader), )?; diff --git a/apps/oxfmt/src/stdin/mod.rs b/apps/oxfmt/src/stdin/mod.rs index 30fb6116d50fc..102d2654e5b22 100644 --- a/apps/oxfmt/src/stdin/mod.rs +++ b/apps/oxfmt/src/stdin/mod.rs @@ -7,7 +7,7 @@ use std::{ use crate::cli::{CliRunResult, FormatCommand, Mode}; use crate::core::{ ConfigResolver, ExternalFormatter, FormatFileStrategy, FormatResult, JsConfigLoaderCb, - SourceFormatter, resolve_editorconfig_path, resolve_oxfmtrc_path, utils, + SourceFormatter, resolve_editorconfig_path, utils, }; pub struct StdinRunner { @@ -56,11 +56,10 @@ impl StdinRunner { } // Load config - let oxfmtrc_path = resolve_oxfmtrc_path(&cwd, config_options.config.as_deref()); let editorconfig_path = resolve_editorconfig_path(&cwd); let mut config_resolver = match ConfigResolver::from_config( &cwd, - oxfmtrc_path.as_deref(), + config_options.config.as_deref(), editorconfig_path.as_deref(), Some(&self.js_config_loader), ) { 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 index 9de7b5a52ca08..97b02251cdcb1 100644 --- 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 @@ -17,9 +17,9 @@ Finished in ms on 1 files using 1 threads. --------------------" `; -exports[`vite_config > error: no fmt field in vite.config.ts 1`] = ` +exports[`vite_config > error: explicit --config vite.config.ts without fmt field 1`] = ` "-------------------- -arguments: --check test.ts +arguments: --check --config vite.config.ts test.ts working directory: vite_config/fixtures/error_no_fmt_field exit code: 1 --- STDOUT --------- @@ -47,3 +47,35 @@ Finished in ms on 1 files using 1 threads. --------------------" `; + +exports[`vite_config > skip: auto-discovered vite.config.ts without fmt field uses defaults 1`] = ` +"-------------------- +arguments: --check test.ts +working directory: vite_config/fixtures/error_no_fmt_field +exit code: 0 +--- STDOUT --------- +Checking formatting... + +All matched files use the correct format. +Finished in ms on 1 files using 1 threads. +--- STDERR --------- + +--------------------" +`; + +exports[`vite_config > skip: parent config is found when vite.config.ts without fmt is skipped 1`] = ` +"-------------------- +arguments: --check test.ts +working directory: vite_config/fixtures/skip_finds_parent/child +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/skip_finds_parent/.oxfmtrc.json b/apps/oxfmt/test/cli/vite_config/fixtures/skip_finds_parent/.oxfmtrc.json new file mode 100644 index 0000000000000..cce9d3c080177 --- /dev/null +++ b/apps/oxfmt/test/cli/vite_config/fixtures/skip_finds_parent/.oxfmtrc.json @@ -0,0 +1,3 @@ +{ + "semi": false +} diff --git a/apps/oxfmt/test/cli/vite_config/fixtures/skip_finds_parent/child/test.ts b/apps/oxfmt/test/cli/vite_config/fixtures/skip_finds_parent/child/test.ts new file mode 100644 index 0000000000000..54b82a09ad543 --- /dev/null +++ b/apps/oxfmt/test/cli/vite_config/fixtures/skip_finds_parent/child/test.ts @@ -0,0 +1 @@ +const a = 1; diff --git a/apps/oxfmt/test/cli/vite_config/fixtures/skip_finds_parent/child/vite.config.ts b/apps/oxfmt/test/cli/vite_config/fixtures/skip_finds_parent/child/vite.config.ts new file mode 100644 index 0000000000000..4ed8f62dd39b0 --- /dev/null +++ b/apps/oxfmt/test/cli/vite_config/fixtures/skip_finds_parent/child/vite.config.ts @@ -0,0 +1,3 @@ +export default { + plugins: [], +}; diff --git a/apps/oxfmt/test/cli/vite_config/vite_config.test.ts b/apps/oxfmt/test/cli/vite_config/vite_config.test.ts index 955476a8a7c41..c2d81a7e05b37 100644 --- a/apps/oxfmt/test/cli/vite_config/vite_config.test.ts +++ b/apps/oxfmt/test/cli/vite_config/vite_config.test.ts @@ -11,8 +11,25 @@ describe("vite_config", () => { expect(snapshot).toMatchSnapshot(); }); - it("error: no fmt field in vite.config.ts", async () => { + it("error: explicit --config vite.config.ts without fmt field", async () => { const cwd = join(fixturesDir, "error_no_fmt_field"); + const snapshot = await runAndSnapshot(cwd, [ + ["--check", "--config", "vite.config.ts", "test.ts"], + ]); + expect(snapshot).toMatchSnapshot(); + }); + + it("skip: auto-discovered vite.config.ts without fmt field uses defaults", async () => { + const cwd = join(fixturesDir, "error_no_fmt_field"); + const snapshot = await runAndSnapshot(cwd, [["--check", "test.ts"]]); + expect(snapshot).toMatchSnapshot(); + }); + + it("skip: parent config is found when vite.config.ts without fmt is skipped", async () => { + // child/ has vite.config.ts without .fmt → skipped + // parent has .oxfmtrc.json with semi: false + // So `const a = 1;` (with semicolon) should be flagged as mismatch + const cwd = join(fixturesDir, "skip_finds_parent", "child"); const snapshot = await runAndSnapshot(cwd, [["--check", "test.ts"]]); expect(snapshot).toMatchSnapshot(); });