Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 5 additions & 7 deletions apps/oxfmt/src/cli/format.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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"))]
Expand Down
8 changes: 3 additions & 5 deletions apps/oxfmt/src/cli/walk.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Option<Self>, String> {
//
Expand Down Expand Up @@ -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!(
Expand Down
201 changes: 144 additions & 57 deletions apps/oxfmt/src/core/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Item = String> {
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<PathBuf> {
// 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<Item = String> {
#[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<PathBuf> {
Expand Down Expand Up @@ -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).
Expand All @@ -237,59 +241,112 @@ impl ConfigResolver {
editorconfig_path: Option<&Path>,
#[cfg(feature = "napi")] js_config_loader: Option<&JsConfigLoaderCb>,
) -> Result<Self, String> {
// 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<Self, String> {
#[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())
})?
} else {
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<Self, String> {
let candidates: Vec<String> = 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.
Expand Down Expand Up @@ -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<Value, String> {
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<Option<Value>, 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.
Expand Down
4 changes: 1 addition & 3 deletions apps/oxfmt/src/core/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
7 changes: 3 additions & 4 deletions apps/oxfmt/src/lsp/server_formatter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -116,13 +116,12 @@ impl ServerFormatterBuilder {
root_path: &Path,
config_path: Option<&String>,
) -> Result<(ConfigResolver, Vec<String>), 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),
)?;
Expand Down
5 changes: 2 additions & 3 deletions apps/oxfmt/src/stdin/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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),
) {
Expand Down
Loading
Loading