diff --git a/Cargo.lock b/Cargo.lock index 2f054373b3924..2bb0e752b681d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -628,6 +628,15 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" +[[package]] +name = "editorconfig-parser" +version = "0.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebf5c801bcfdbcf7a706b19b314daec914ef1f6054047adfd195fff9c38984f9" +dependencies = [ + "globset", +] + [[package]] name = "either" version = "1.15.0" @@ -2531,6 +2540,7 @@ version = "0.18.0" dependencies = [ "bpaf", "cow-utils", + "editorconfig-parser", "ignore", "json-strip-comments", "mimalloc-safe", diff --git a/apps/oxfmt/.editorconfig b/apps/oxfmt/.editorconfig new file mode 100644 index 0000000000000..4da8382c7f355 --- /dev/null +++ b/apps/oxfmt/.editorconfig @@ -0,0 +1,3 @@ +# `oxfmt` looks for the nearest `.editorconfig` from the `cwd`. +# Therefore, during test execution, it may inadvertently refer to the `.editorconfig` at the repository root. +# To prevent this, we place an empty `.editorconfig` here. diff --git a/apps/oxfmt/Cargo.toml b/apps/oxfmt/Cargo.toml index 5e30be4d3e57b..1f88f8a449dc0 100644 --- a/apps/oxfmt/Cargo.toml +++ b/apps/oxfmt/Cargo.toml @@ -37,6 +37,7 @@ oxc_span = { workspace = true } bpaf = { workspace = true, features = ["autocomplete", "bright-color", "derive"] } cow-utils = { workspace = true } +editorconfig-parser = "0.0.2" ignore = { workspace = true, features = ["simd-accel"] } json-strip-comments = { workspace = true } miette = { workspace = true } diff --git a/apps/oxfmt/src/cli/format.rs b/apps/oxfmt/src/cli/format.rs index 282f364b4bf63..1c6f0be000ecf 100644 --- a/apps/oxfmt/src/cli/format.rs +++ b/apps/oxfmt/src/cli/format.rs @@ -9,7 +9,9 @@ use super::{ service::{FormatService, SuccessResult}, walk::Walk, }; -use crate::core::{ConfigResolver, SourceFormatter, resolve_config_path, utils}; +use crate::core::{ + ConfigResolver, SourceFormatter, resolve_editorconfig_path, resolve_oxfmtrc_path, utils, +}; #[derive(Debug)] pub struct FormatRunner { @@ -67,8 +69,12 @@ impl FormatRunner { // NOTE: Currently, we only load single config file. // - from `--config` if specified // - else, search nearest for the nearest `.oxfmtrc.json` from cwd upwards - let config_path = resolve_config_path(&cwd, config_options.config.as_deref()); - let mut config_resolver = match ConfigResolver::from_config_path(config_path.as_deref()) { + 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_paths( + oxfmtrc_path.as_deref(), + editorconfig_path.as_deref(), + ) { Ok(r) => r, Err(err) => { utils::print_and_flush( diff --git a/apps/oxfmt/src/core/config.rs b/apps/oxfmt/src/core/config.rs index 032ec7bb9bc9b..9196a3cba3f30 100644 --- a/apps/oxfmt/src/core/config.rs +++ b/apps/oxfmt/src/core/config.rs @@ -1,16 +1,20 @@ use std::path::{Path, PathBuf}; +use editorconfig_parser::{ + EditorConfig, EditorConfigProperties, EditorConfigProperty, EndOfLine, IndentStyle, + MaxLineLength, +}; use serde_json::Value; use oxc_formatter::{ FormatOptions, - oxfmtrc::{OxfmtOptions, Oxfmtrc}, + oxfmtrc::{EndOfLineConfig, OxfmtOptions, Oxfmtrc}, }; use super::{FormatFileStrategy, utils}; /// Resolve config file path from cwd and optional explicit path. -pub fn resolve_config_path(cwd: &Path, config_path: Option<&Path>) -> Option { +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(if config_path.is_absolute() { @@ -33,6 +37,13 @@ pub fn resolve_config_path(cwd: &Path, config_path: Option<&Path>) -> Option Option { + // Search the nearest `.editorconfig` from cwd upwards + cwd.ancestors().map(|dir| dir.join(".editorconfig")).find(|p| p.exists()) +} + +// --- + /// Resolved options for each file type. /// Each variant contains only the options needed for that formatter. pub enum ResolvedOptions { @@ -52,7 +63,7 @@ pub enum ResolvedOptions { /// Configuration resolver that derives all config values from a single `serde_json::Value`. /// -/// Priority order: `Oxfmtrc::default()` → (TODO: editorconfig) → user's oxfmtrc +/// Priority order: `Oxfmtrc::default()` → `.editorconfig` → user's `.oxfmtrc` pub struct ConfigResolver { /// User's raw config as JSON value. /// It contains every possible field, even those not recognized by `Oxfmtrc`. @@ -60,18 +71,19 @@ pub struct ConfigResolver { /// e.g. `vueIndentScriptAndStyle`: not recognized by `Oxfmtrc`, but used by Prettier /// e.g. `svelteSortAttributes`: not recognized by Prettier by default raw_config: Value, + /// Parsed `.editorconfig`, if any. + editorconfig: Option, /// 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. cached_options: Option<(FormatOptions, OxfmtOptions, Value)>, - // TODO: Add editorconfig support } 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, cached_options: None } + Self { raw_config, editorconfig: None, cached_options: None } } /// Create a resolver by loading config from a file path. @@ -80,15 +92,16 @@ impl ConfigResolver { /// Returns error if: /// - Config file is specified but not found or invalid /// - Config file parsing fails - pub fn from_config_path(config_path: Option<&Path>) -> Result { + pub fn from_config_paths( + oxfmtrc_path: Option<&Path>, + editorconfig_path: Option<&Path>, + ) -> Result { // Read and parse config file, or use empty JSON if not found - let json_string = match config_path { + let json_string = match oxfmtrc_path { Some(path) => { let mut json_string = utils::read_to_string(path) // Do not include OS error, it differs between platforms - .map_err(|_| { - format!("Failed to read config {}: File not found", path.display()) - })?; + .map_err(|_| format!("Failed to read {}: File not found", path.display()))?; // Strip comments (JSONC support) json_strip_comments::strip(&mut json_string).map_err(|err| { format!("Failed to strip comments from {}: {err}", path.display()) @@ -102,53 +115,67 @@ impl ConfigResolver { let raw_config: Value = serde_json::from_str(&json_string) .map_err(|err| format!("Failed to parse config: {err}"))?; - Ok(Self { raw_config, cached_options: None }) + let editorconfig = match editorconfig_path { + Some(path) => { + let str = utils::read_to_string(path) + .map_err(|_| format!("Failed to read {}: File not found", path.display()))?; + + Some(EditorConfig::parse(&str)) + } + None => None, + }; + + Ok(Self { raw_config, editorconfig, cached_options: None }) } /// Validate config and return ignore patterns for file walking. /// + /// Validated options are cached for fast path resolution. + /// See also [`ConfigResolver::resolve_with_overrides`] for per-file overrides. + /// /// # Errors /// Returns error if config deserialization fails. pub fn build_and_validate(&mut self) -> Result, String> { - let oxfmtrc: Oxfmtrc = serde_json::from_value(self.raw_config.clone()) + let mut oxfmtrc: Oxfmtrc = serde_json::from_value(self.raw_config.clone()) .map_err(|err| format!("Failed to deserialize Oxfmtrc: {err}"))?; - // TODO: Apply editorconfig settings - // if let Some(editorconfig) = &self.editorconfig { - // // Priority: oxfmtrc default < editorconfig < user's oxfmtrc - // if oxfmtrc.print_width.is_none() && let Some(max_line_length) = editorconfig.get_max_line_length() { - // oxfmtrc.print_width = Some(max_line_length); - // } - // // ... others - // } + // If `.editorconfig` is used, apply its root section first + // If there are per-file overrides, they will be applied during `resolve()` + if let Some(editorconfig) = &self.editorconfig + && let Some(props) = + editorconfig.sections().iter().find(|s| s.name == "*").map(|s| &s.properties) + { + apply_editorconfig(&mut oxfmtrc, props); + } + // If not specified, default options are resolved here let (format_options, oxfmt_options) = oxfmtrc .into_options() .map_err(|err| format!("Failed to parse configuration.\n{err}"))?; - // Apply our defaults for Prettier options too + // Apply our resolved defaults to Prettier options too // e.g. set `printWidth: 100` if not specified (= Prettier default: 80) let mut external_options = self.raw_config.clone(); Oxfmtrc::populate_prettier_config(&format_options, &mut external_options); - let ignore_patterns = oxfmt_options.ignore_patterns.clone(); + let ignore_patterns_clone = oxfmt_options.ignore_patterns.clone(); // NOTE: Save cache for fast path: no per-file overrides self.cached_options = Some((format_options, oxfmt_options, external_options)); - Ok(ignore_patterns) + Ok(ignore_patterns_clone) } /// Resolve format options for a specific file. pub fn resolve(&self, strategy: &FormatFileStrategy) -> ResolvedOptions { - // TODO: Check if editorconfig has any overrides for this file - let has_editorconfig_and_overrides = false; - - #[cfg_attr(not(feature = "napi"), allow(unused_variables))] - let (format_options, oxfmt_options, external_options) = if has_editorconfig_and_overrides { - self.resolve_with_overrides(strategy) + #[cfg_attr(not(feature = "napi"), expect(unused_variables))] + let (format_options, oxfmt_options, external_options) = if let Some(editorconfig) = + &self.editorconfig + && let Some(props) = get_editorconfig_overrides(editorconfig, strategy.path()) + { + self.resolve_with_overrides(&props) } else { - // Resolve format options for a specific file. + // Fast path: no per-file overrides // Either: // - `.editorconfig` is NOT used // - or used but per-file overrides do NOT exist for this file @@ -179,23 +206,16 @@ impl ConfigResolver { } } - /// Resolve format options for a specific file. - /// Since `.editorconfig` may contain per-file patterns, options are resolved per-file. + /// Resolve format options for a specific file with `.editorconfig` overrides. + /// This is the slow path, for fast path, see [`ConfigResolver::build_and_validate`]. fn resolve_with_overrides( &self, - _strategy: &FormatFileStrategy, + props: &EditorConfigProperties, ) -> (FormatOptions, OxfmtOptions, Value) { - let oxfmtrc: Oxfmtrc = serde_json::from_value(self.raw_config.clone()) + let mut oxfmtrc: Oxfmtrc = serde_json::from_value(self.raw_config.clone()) .expect("`build_and_validate()` should catch this before `resolve()`"); - // TODO: Apply base + per-file editorconfig settings - // if let Some(editorconfig) = &self.editorconfig.resolve(strategy.path()) { - // // Priority: oxfmtrc default < editorconfig < user's oxfmtrc - // if oxfmtrc.print_width.is_none() && let Some(max_line_length) = editorconfig.get_max_line_length() { - // oxfmtrc.print_width = Some(max_line_length); - // } - // // ... others - // } + apply_editorconfig(&mut oxfmtrc, props); let (format_options, oxfmt_options) = oxfmtrc .into_options() @@ -209,3 +229,92 @@ impl ConfigResolver { (format_options, oxfmt_options, external_options) } } + +// --- + +/// Check if `.editorconfig` has per-file overrides for this path. +/// +/// Returns `Some(props)` if the resolved properties differ from the root `[*]` section. +/// Returns `None` if no overrides. +/// +/// Currently, only the following properties are considered for overrides: +/// - max_line_length +/// - end_of_line +/// - indent_style +/// - indent_size +fn get_editorconfig_overrides( + editorconfig: &EditorConfig, + path: &Path, +) -> Option { + let sections = editorconfig.sections(); + + // No sections, or only root `[*]` section → no overrides + if sections.is_empty() || matches!(sections, [s] if s.name == "*") { + return None; + } + + let resolved = editorconfig.resolve(path); + + // Get the root `[*]` section properties + let root_props = sections.iter().find(|s| s.name == "*").map(|s| &s.properties); + + // Compare only the properties we care about + let has_overrides = match root_props { + Some(root) => { + resolved.max_line_length != root.max_line_length + || resolved.end_of_line != root.end_of_line + || resolved.indent_style != root.indent_style + || resolved.indent_size != root.indent_size + } + // No `[*]` section means any resolved property is an override + None => { + resolved.max_line_length != EditorConfigProperty::Unset + || resolved.end_of_line != EditorConfigProperty::Unset + || resolved.indent_style != EditorConfigProperty::Unset + || resolved.indent_size != EditorConfigProperty::Unset + } + }; + + if has_overrides { Some(resolved) } else { None } +} + +/// Apply `.editorconfig` properties to `Oxfmtrc`. +/// +/// Only applies values that are not already set in oxfmtrc. +/// Priority: oxfmtrc default < editorconfig < user's oxfmtrc +/// +/// Only properties checked by [`get_editorconfig_overrides`] are applied here. +fn apply_editorconfig(oxfmtrc: &mut Oxfmtrc, props: &EditorConfigProperties) { + #[expect(clippy::cast_possible_truncation)] + if oxfmtrc.print_width.is_none() + && let EditorConfigProperty::Value(MaxLineLength::Number(v)) = props.max_line_length + { + oxfmtrc.print_width = Some(v as u16); + } + + if oxfmtrc.end_of_line.is_none() + && let EditorConfigProperty::Value(eol) = props.end_of_line + { + oxfmtrc.end_of_line = Some(match eol { + EndOfLine::Lf => EndOfLineConfig::Lf, + EndOfLine::Cr => EndOfLineConfig::Cr, + EndOfLine::Crlf => EndOfLineConfig::Crlf, + }); + } + + if oxfmtrc.use_tabs.is_none() + && let EditorConfigProperty::Value(style) = props.indent_style + { + oxfmtrc.use_tabs = Some(match style { + IndentStyle::Tab => true, + IndentStyle::Space => false, + }); + } + + #[expect(clippy::cast_possible_truncation)] + if oxfmtrc.tab_width.is_none() + && let EditorConfigProperty::Value(size) = props.indent_size + { + oxfmtrc.tab_width = Some(size as u8); + } +} diff --git a/apps/oxfmt/src/core/mod.rs b/apps/oxfmt/src/core/mod.rs index 5f3e59e489519..0a3575f7cba70 100644 --- a/apps/oxfmt/src/core/mod.rs +++ b/apps/oxfmt/src/core/mod.rs @@ -6,7 +6,9 @@ pub mod utils; #[cfg(feature = "napi")] mod external_formatter; -pub use config::{ConfigResolver, ResolvedOptions, resolve_config_path}; +pub use config::{ + ConfigResolver, ResolvedOptions, resolve_editorconfig_path, resolve_oxfmtrc_path, +}; pub use format::{FormatResult, SourceFormatter}; pub use support::FormatFileStrategy; diff --git a/apps/oxfmt/src/stdin/mod.rs b/apps/oxfmt/src/stdin/mod.rs index 69b7656eb8715..3759ab7282d7c 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, SourceFormatter, - resolve_config_path, utils, + resolve_editorconfig_path, resolve_oxfmtrc_path, utils, }; #[derive(Debug)] @@ -63,8 +63,12 @@ impl StdinRunner { } // Load config - let config_path = resolve_config_path(&cwd, config_options.config.as_deref()); - let mut config_resolver = match ConfigResolver::from_config_path(config_path.as_deref()) { + 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_paths( + oxfmtrc_path.as_deref(), + editorconfig_path.as_deref(), + ) { Ok(r) => r, Err(err) => { utils::print_and_flush( diff --git a/apps/oxfmt/test/__snapshots__/config_file.test.ts.snap b/apps/oxfmt/test/__snapshots__/config_file.test.ts.snap index 5e914e94b388c..370cf76197560 100644 --- a/apps/oxfmt/test/__snapshots__/config_file.test.ts.snap +++ b/apps/oxfmt/test/__snapshots__/config_file.test.ts.snap @@ -88,6 +88,6 @@ exit code: 1 --- STDERR --------- Failed to load configuration file. -Failed to read config /NOT_EXISTS.json: File not found +Failed to read /NOT_EXISTS.json: File not found --------------------" `;