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
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions apps/oxfmt/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ oxc_span = { workspace = true }
bpaf = { workspace = true, features = ["autocomplete", "bright-color", "derive"] }
cow-utils = { workspace = true }
editorconfig-parser = { workspace = true }
fast-glob = { workspace = true }
ignore = { workspace = true, features = ["simd-accel"] }
json-strip-comments = { workspace = true }
miette = { workspace = true }
Expand Down
181 changes: 139 additions & 42 deletions apps/oxfmt/src/core/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ use editorconfig_parser::{
EditorConfig, EditorConfigProperties, EditorConfigProperty, EndOfLine, IndentStyle,
MaxLineLength,
};
use fast_glob::glob_match;
use serde_json::Value;
use tracing::instrument;

Expand All @@ -12,7 +13,10 @@ use oxc_toml::Options as TomlFormatterOptions;

use super::{
FormatFileStrategy,
oxfmtrc::{EndOfLineConfig, FormatConfig, OxfmtOptions, Oxfmtrc, populate_prettier_config},
oxfmtrc::{
EndOfLineConfig, FormatConfig, OxfmtOptions, OxfmtOverrideConfig, Oxfmtrc,
populate_prettier_config,
},
utils,
};

Expand Down Expand Up @@ -68,29 +72,40 @@ pub enum ResolvedOptions {
},
}

/// Configuration resolver that derives all config values from a single `serde_json::Value`.
/// Configuration resolver to handle `.oxfmtrc` and `.editorconfig` files.
///
/// Priority order: `Oxfmtrc::default()` → `.editorconfig` → user's `.oxfmtrc`
/// Priority order: `Oxfmtrc::default()` → user's `.oxfmtrc` base → `.oxfmtrc` overrides
/// `.editorconfig` is applied as fallback for unset fields only.
#[derive(Debug)]
pub struct ConfigResolver {
/// User's raw config as JSON value.
/// It contains every possible field, even those not recognized by `Oxfmtrc`.
/// e.g. `printWidth`: recognized by both `Oxfmtrc` and Prettier
/// e.g. `vueIndentScriptAndStyle`: not recognized by `Oxfmtrc`, but used by Prettier
/// e.g. `svelteSortAttributes`: not recognized by Prettier by default
/// e.g. `svelteSortAttributes`: not recognized by Prettier and require plugins
raw_config: Value,
/// Parsed `.editorconfig`, if any.
editorconfig: Option<EditorConfig>,
/// Directory containing the config file (for relative path resolution in overrides).
config_dir: Option<PathBuf>,
/// Cached parsed options after validation.
/// Used to avoid re-parsing during per-file resolution, if `.editorconfig` is not used.
/// NOTE: Currently, only `.editorconfig` provides per-file overrides, `.oxfmtrc` does not.
/// Used to avoid re-parsing during per-file resolution, if no per-file overrides exist.
cached_options: Option<(OxfmtOptions, Value)>,
/// Resolved overrides from `.oxfmtrc` for file-specific matching.
oxfmtrc_overrides: Option<OxfmtrcOverrides>,
/// Parsed `.editorconfig`, if any.
editorconfig: Option<EditorConfig>,
}

impl ConfigResolver {
/// Create a new resolver from a raw JSON config value.
#[cfg(feature = "napi")]
pub fn from_value(raw_config: Value) -> Self {
Self { raw_config, editorconfig: None, cached_options: None }
Self {
raw_config,
config_dir: None,
cached_options: None,
oxfmtrc_overrides: None,
editorconfig: None,
}
}

/// Create a resolver by loading config from a file path.
Expand Down Expand Up @@ -123,6 +138,8 @@ impl ConfigResolver {
// Parse as raw JSON value
let raw_config: Value = serde_json::from_str(&json_string)
.map_err(|err| format!("Failed to parse config: {err}"))?;
// Store the config directory for override path resolution
let config_dir = oxfmtrc_path.and_then(|p| p.parent().map(Path::to_path_buf));

let editorconfig = match editorconfig_path {
Some(path) => {
Expand All @@ -136,13 +153,18 @@ impl ConfigResolver {
None => None,
};

Ok(Self { raw_config, editorconfig, cached_options: None })
Ok(Self {
raw_config,
config_dir,
cached_options: None,
oxfmtrc_overrides: None,
editorconfig,
})
}

/// Validate config and return ignore patterns (= non-formatting option) for file walking.
///
/// Validated options are cached for fast path resolution.
/// See also [`ConfigResolver::resolve_with_editorconfig_overrides`] for per-file overrides.
///
/// # Errors
/// Returns error if config deserialization fails.
Expand All @@ -151,6 +173,11 @@ impl ConfigResolver {
let oxfmtrc: Oxfmtrc = serde_json::from_value(self.raw_config.clone())
.map_err(|err| format!("Failed to deserialize Oxfmtrc: {err}"))?;

// Resolve `overrides` from `Oxfmtrc` for later per-file matching
let base_dir = self.config_dir.take();
self.oxfmtrc_overrides =
oxfmtrc.overrides.map(|overrides| OxfmtrcOverrides::new(overrides, base_dir));

let mut format_config = oxfmtrc.format_config;

// If `.editorconfig` is used, apply its root section first
Expand Down Expand Up @@ -185,19 +212,7 @@ impl ConfigResolver {
/// Resolve format options for a specific file.
#[instrument(level = "debug", name = "oxfmt::config::resolve", skip_all, fields(path = %strategy.path().display()))]
pub fn resolve(&self, strategy: &FormatFileStrategy) -> ResolvedOptions {
let (oxfmt_options, external_options) = if let Some(editorconfig) = &self.editorconfig
&& let Some(props) = get_editorconfig_overrides(editorconfig, strategy.path())
{
self.resolve_with_editorconfig_overrides(&props)
} else {
// Fast path: no per-file overrides
// Either:
// - `.editorconfig` is NOT used
// - or used but per-file overrides do NOT exist for this file
self.cached_options
.clone()
.expect("`build_and_validate()` must be called before `resolve()`")
};
let (oxfmt_options, external_options) = self.resolve_options(strategy.path());

#[cfg(feature = "napi")]
let OxfmtOptions { format_options, toml_options, sort_package_json, insert_final_newline } =
Expand Down Expand Up @@ -233,26 +248,41 @@ impl ConfigResolver {
}
}

/// Resolve format options for a specific file with `.editorconfig` overrides.
/// This is the slow path, for fast path, see [`ConfigResolver::build_and_validate`].
/// Also main logics are the same as in `build_and_validate()`.
#[instrument(level = "debug", name = "oxfmt::config::resolve_with_overrides", skip_all)]
fn resolve_with_editorconfig_overrides(
&self,
props: &EditorConfigProperties,
) -> (OxfmtOptions, Value) {
// NOTE: Deserialize `FormatConfig` from `raw_config` (not from cached options).
// If we base it on cached options, root section may be already applied,
// so `.is_some()` checks won't work and per-file overrides may not be applied.
// And `props` already has root section applied.
/// Resolve options for a specific file path.
/// Priority: oxfmtrc base → oxfmtrc overrides → editorconfig (fallback for unset fields)
fn resolve_options(&self, path: &Path) -> (OxfmtOptions, Value) {
let editorconfig_overrides =
self.editorconfig.as_ref().and_then(|ec| get_editorconfig_overrides(ec, path));
let has_oxfmtrc_overrides =
self.oxfmtrc_overrides.as_ref().is_some_and(|o| o.has_match(path));

// Fast path: no per-file overrides
if editorconfig_overrides.is_none() && !has_oxfmtrc_overrides {
return self
.cached_options
.clone()
.expect("`build_and_validate()` must be called first");
}

// Slow path: reconstruct `FormatConfig` to apply overrides
let mut format_config: FormatConfig = serde_json::from_value(self.raw_config.clone())
.expect("`build_and_validate()` should catch this before `resolve()`");
.expect("`build_and_validate()` should catch this before");

// Apply oxfmtrc overrides first (explicit settings)
if let Some(overrides) = &self.oxfmtrc_overrides {
for options in overrides.get_matching(path) {
format_config.merge(options);
}
}

apply_editorconfig(&mut format_config, props);
// Apply editorconfig as fallback (fills in unset fields only)
if let Some(props) = &editorconfig_overrides {
apply_editorconfig(&mut format_config, props);
}

let oxfmt_options = format_config
.into_oxfmt_options()
.expect("If this fails, there is an issue with editorconfig insertion above");
.expect("If this fails, there is an issue with override values");

let mut external_options = self.raw_config.clone();
populate_prettier_config(&oxfmt_options.format_options, &mut external_options);
Expand All @@ -263,6 +293,75 @@ impl ConfigResolver {

// ---

/// Resolved overrides from `.oxfmtrc` for file-specific matching.
/// Similar to `EditorConfig`, this handles `FormatConfig` override resolution.
#[derive(Debug)]
struct OxfmtrcOverrides {
base_dir: Option<PathBuf>,
entries: Vec<OxfmtrcOverrideEntry>,
}

impl OxfmtrcOverrides {
fn new(overrides: Vec<OxfmtOverrideConfig>, base_dir: Option<PathBuf>) -> Self {
// Normalize glob patterns by adding `**/` prefix to patterns without `/`.
// This matches ESLint/Prettier behavior.
let normalize_patterns = |patterns: Vec<String>| {
patterns
.into_iter()
.map(|pat| if pat.contains('/') { pat } else { format!("**/{pat}") })
.collect()
};

Self {
base_dir,
entries: overrides
.into_iter()
.map(|o| OxfmtrcOverrideEntry {
files: normalize_patterns(o.files),
exclude_files: o.exclude_files.map(normalize_patterns).unwrap_or_default(),
options: o.options,
})
.collect(),
}
}

/// Check if any overrides exist that match the given path.
fn has_match(&self, path: &Path) -> bool {
let relative = self.relative_path(path);
self.entries.iter().any(|e| Self::is_entry_match(e, &relative))
}

/// Get all matching override options for a given path.
fn get_matching(&self, path: &Path) -> impl Iterator<Item = &FormatConfig> + '_ {
let relative = self.relative_path(path);
self.entries.iter().filter(move |e| Self::is_entry_match(e, &relative)).map(|e| &e.options)
}

fn relative_path(&self, path: &Path) -> String {
self.base_dir
.as_ref()
.and_then(|dir| path.strip_prefix(dir).ok())
.unwrap_or(path)
.to_string_lossy()
.into_owned()
}

fn is_entry_match(entry: &OxfmtrcOverrideEntry, relative: &str) -> bool {
entry.files.iter().any(|glob| glob_match(glob, relative))
&& !entry.exclude_files.iter().any(|glob| glob_match(glob, relative))
}
}

/// A single override entry with normalized glob patterns.
#[derive(Debug)]
struct OxfmtrcOverrideEntry {
files: Vec<String>,
exclude_files: Vec<String>,
options: FormatConfig,
}

// ---

/// Check if `.editorconfig` has per-file overrides for this path.
///
/// Returns `Some(props)` if the resolved properties differ from the root `[*]` section.
Expand Down Expand Up @@ -315,9 +414,7 @@ fn get_editorconfig_overrides(
/// Apply `.editorconfig` properties to `FormatConfig`.
///
/// Only applies values that are not already set in the user's config.
/// Priority: `FormatConfig` default < `.editorconfig` < user's `FormatConfig`
///
/// Only properties checked by [`get_editorconfig_overrides`] are applied here.
/// NOTE: Only properties checked by [`get_editorconfig_overrides`] are applied here.
fn apply_editorconfig(config: &mut FormatConfig, props: &EditorConfigProperties) {
#[expect(clippy::cast_possible_truncation)]
if config.print_width.is_none()
Expand Down
Loading
Loading