From d2f7c771c65efd26ce51110e5083d3d752b9123f Mon Sep 17 00:00:00 2001 From: lunadogbot Date: Thu, 14 May 2026 18:16:36 +0000 Subject: [PATCH 1/5] feat(fmt): infer config from .editorconfig Adds an .editorconfig loader to `deno fmt`. When formatting a file, the nearest `.editorconfig` (walking up to a `root = true` boundary) is read and its properties are merged into the resolved fmt config, filling in only fields that were not set by `deno.json` or CLI flags. Precedence is CLI flags > deno.json > .editorconfig > defaults. Mappings: - indent_style -> useTabs - indent_size -> indentWidth (falls back to tab_width when style=tab) - tab_width -> indentWidth (when used as fallback above) - max_line_length -> lineWidth (ignored when set to "off") - end_of_line -> newLineKind (lf/crlf) Glob patterns from `.editorconfig` section headers are translated to regex and matched against the file path. Parsed config files are cached per `EditorConfigCache` so repeated lookups within a fmt batch do not re-read or re-parse them. Closes bartlomieju/orchid-inbox#77 Refs #14717 --- cli/tools/fmt.rs | 39 +- cli/tools/fmt_editorconfig.rs | 700 ++++++++++++++++++ cli/tools/mod.rs | 1 + tests/specs/fmt/editorconfig/.editorconfig | 5 + tests/specs/fmt/editorconfig/__test__.jsonc | 35 + tests/specs/fmt/editorconfig/indent_size_4.ts | 3 + .../fmt/editorconfig/long_lines/.editorconfig | 4 + .../specs/fmt/editorconfig/long_lines/file.ts | 1 + .../specs/fmt/editorconfig/tabs/.editorconfig | 4 + tests/specs/fmt/editorconfig/tabs/file.ts | 3 + .../editorconfig/with_deno_json/.editorconfig | 4 + .../fmt/editorconfig/with_deno_json/deno.json | 5 + .../fmt/editorconfig/with_deno_json/file.ts | 3 + 13 files changed, 805 insertions(+), 2 deletions(-) create mode 100644 cli/tools/fmt_editorconfig.rs create mode 100644 tests/specs/fmt/editorconfig/.editorconfig create mode 100644 tests/specs/fmt/editorconfig/__test__.jsonc create mode 100644 tests/specs/fmt/editorconfig/indent_size_4.ts create mode 100644 tests/specs/fmt/editorconfig/long_lines/.editorconfig create mode 100644 tests/specs/fmt/editorconfig/long_lines/file.ts create mode 100644 tests/specs/fmt/editorconfig/tabs/.editorconfig create mode 100644 tests/specs/fmt/editorconfig/tabs/file.ts create mode 100644 tests/specs/fmt/editorconfig/with_deno_json/.editorconfig create mode 100644 tests/specs/fmt/editorconfig/with_deno_json/deno.json create mode 100644 tests/specs/fmt/editorconfig/with_deno_json/file.ts diff --git a/cli/tools/fmt.rs b/cli/tools/fmt.rs index e403768b5181b0..b5bda404191a85 100644 --- a/cli/tools/fmt.rs +++ b/cli/tools/fmt.rs @@ -49,6 +49,7 @@ use crate::cache::IncrementalCache; use crate::colors; use crate::factory::CliFactory; use crate::sys::CliSys; +use crate::tools::fmt_editorconfig::EditorConfigCache; use crate::util; use crate::util::file_watcher; use crate::util::fs::canonicalize_path; @@ -233,6 +234,7 @@ async fn format_files( } else { Box::new(RealFormatter::default()) }; + let editorconfig_cache = Arc::new(EditorConfigCache::new()); for paths_with_options in paths_with_options_batches { log::debug!( "Formatting {} file(s) in {}", @@ -252,6 +254,7 @@ async fn format_files( fmt_options.options, fmt_options.unstable, incremental_cache.clone(), + editorconfig_cache.clone(), cli_options.ext_flag().clone(), ) .await?; @@ -916,12 +919,30 @@ trait Formatter { fmt_options: FmtOptionsConfig, unstable_options: UnstableFmtOptions, incremental_cache: Arc, + editorconfig_cache: Arc, ext: Option, ) -> Result<(), AnyError>; fn finish(&self) -> Result<(), AnyError>; } +/// Returns a per-file [`FmtOptionsConfig`], merging values from +/// `.editorconfig` (lowest priority) under the resolved `base` config +/// (which already incorporates `deno.json` plus CLI flags). +fn resolve_per_file_options( + base: &FmtOptionsConfig, + editorconfig_cache: &EditorConfigCache, + file_path: &Path, +) -> FmtOptionsConfig { + let props = editorconfig_cache.resolve(file_path); + if props.is_empty() { + return base.clone(); + } + let mut cfg = base.clone(); + props.apply_to(&mut cfg); + cfg +} + struct CheckFormatter { not_formatted_files_count: Arc, checked_files_count: Arc, @@ -950,6 +971,7 @@ impl Formatter for CheckFormatter { fmt_options: FmtOptionsConfig, unstable_options: UnstableFmtOptions, incremental_cache: Arc, + editorconfig_cache: Arc, ext: Option, ) -> Result<(), AnyError> { // prevent threads outputting at the same time @@ -977,10 +999,16 @@ impl Formatter for CheckFormatter { return Ok(()); } + let per_file_options = resolve_per_file_options( + &fmt_options, + &editorconfig_cache, + &file_path, + ); + match format_file( &file_path, &file, - &fmt_options, + &per_file_options, &unstable_options, ext.clone(), ) { @@ -1071,6 +1099,7 @@ impl Formatter for RealFormatter { fmt_options: FmtOptionsConfig, unstable_options: UnstableFmtOptions, incremental_cache: Arc, + editorconfig_cache: Arc, ext: Option, ) -> Result<(), AnyError> { let output_lock = Arc::new(Mutex::new(0)); // prevent threads outputting at the same time @@ -1090,11 +1119,17 @@ impl Formatter for RealFormatter { return Ok(()); } + let per_file_options = resolve_per_file_options( + &fmt_options, + &editorconfig_cache, + &file_path, + ); + match format_ensure_stable(&file_path, &file, |file_path, file| { format_file( file_path, file, - &fmt_options, + &per_file_options, &unstable_options, ext.clone(), ) diff --git a/cli/tools/fmt_editorconfig.rs b/cli/tools/fmt_editorconfig.rs new file mode 100644 index 00000000000000..1e36a9b0a3d6bf --- /dev/null +++ b/cli/tools/fmt_editorconfig.rs @@ -0,0 +1,700 @@ +// Copyright 2018-2026 the Deno authors. MIT license. + +//! Minimal [EditorConfig](https://editorconfig.org/) loader used by `deno fmt`. +//! +//! Walks up the directory tree from a given file, parses encountered +//! `.editorconfig` files, and resolves matching properties for that file. +//! Results are cached so repeated lookups within a tree do not re-read +//! and re-parse the same files. + +use std::collections::HashMap; +use std::path::Path; +use std::path::PathBuf; +use std::sync::Mutex; + +use deno_config::deno_json::FmtOptionsConfig; +use deno_config::deno_json::NewLineKind; +use regex::Regex; + +use crate::util::fs::canonicalize_path; + +/// Properties resolved from `.editorconfig` files for a particular file. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct EditorConfigProperties { + pub indent_style: Option, + pub indent_size: Option, + pub tab_width: Option, + pub max_line_length: Option, + pub end_of_line: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum IndentStyle { + Space, + Tab, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum EndOfLine { + Lf, + Crlf, + Cr, +} + +impl EditorConfigProperties { + /// Apply the resolved properties to `cfg`, filling in only fields + /// that are currently `None`. Deno's own config and CLI flags + /// therefore always take precedence. + pub fn apply_to(&self, cfg: &mut FmtOptionsConfig) { + if cfg.use_tabs.is_none() + && let Some(style) = self.indent_style + { + cfg.use_tabs = Some(matches!(style, IndentStyle::Tab)); + } + + if cfg.indent_width.is_none() { + // Per the editorconfig spec, when indent_style is "tab" and + // indent_size is not set, indent_size defaults to tab_width. + // For "space" or unset indent_style, indent_size is taken as-is. + let indent = self.indent_size.or( + if matches!(self.indent_style, Some(IndentStyle::Tab)) { + self.tab_width + } else { + None + }, + ); + if let Some(n) = indent { + cfg.indent_width = Some(n); + } + } + + if cfg.line_width.is_none() + && let Some(n) = self.max_line_length + { + cfg.line_width = Some(n); + } + + if cfg.new_line_kind.is_none() { + cfg.new_line_kind = match self.end_of_line { + Some(EndOfLine::Lf) => Some(NewLineKind::LineFeed), + Some(EndOfLine::Crlf) => Some(NewLineKind::CarriageReturnLineFeed), + // No mapping for CR-only; leave unset. + Some(EndOfLine::Cr) | None => None, + }; + } + } + + pub fn is_empty(&self) -> bool { + self.indent_style.is_none() + && self.indent_size.is_none() + && self.tab_width.is_none() + && self.max_line_length.is_none() + && self.end_of_line.is_none() + } +} + +#[derive(Debug, Clone)] +struct ParsedFile { + root: bool, + sections: Vec
, +} + +#[derive(Debug, Clone)] +struct Section { + pattern: String, + properties: SectionProperties, +} + +#[derive(Debug, Clone, Default)] +struct SectionProperties { + indent_style: Option, + indent_size: Option, + tab_width: Option, + max_line_length: Option, + end_of_line: Option, +} + +/// Cache of parsed `.editorconfig` files, keyed by absolute path. +#[derive(Debug, Default)] +pub struct EditorConfigCache { + files: Mutex>>, +} + +impl EditorConfigCache { + pub fn new() -> Self { + Self::default() + } + + /// Resolve `.editorconfig` properties for `file_path`. Returns + /// `Default::default()` if no `.editorconfig` files apply. + pub fn resolve(&self, file_path: &Path) -> EditorConfigProperties { + let abs_path = match canonicalize_path(file_path) { + Ok(p) => p, + Err(_) => file_path.to_path_buf(), + }; + + // Walk up from the file's directory, collecting .editorconfig files. + // Stop when we hit one with `root = true`. + let mut configs: Vec<(PathBuf, ParsedFile)> = Vec::new(); + let start = abs_path.parent().unwrap_or(&abs_path); + let mut cur: Option<&Path> = Some(start); + while let Some(dir) = cur { + let ec_path = dir.join(".editorconfig"); + if let Some(parsed) = self.read_and_parse(&ec_path) { + let is_root = parsed.root; + configs.push((dir.to_path_buf(), parsed)); + if is_root { + break; + } + } + cur = dir.parent(); + } + + // Apply from outermost to innermost so nearer files override farther ones. + configs.reverse(); + + let mut out = EditorConfigProperties::default(); + for (dir, parsed) in configs { + for section in &parsed.sections { + if pattern_matches(§ion.pattern, &dir, &abs_path) { + merge_section(&mut out, §ion.properties); + } + } + } + out + } + + fn read_and_parse(&self, path: &Path) -> Option { + { + let files = self.files.lock().unwrap(); + if let Some(cached) = files.get(path) { + return cached.clone(); + } + } + let parsed = std::fs::read_to_string(path).ok().map(|s| parse(&s)); + let mut files = self.files.lock().unwrap(); + files.insert(path.to_path_buf(), parsed.clone()); + parsed + } +} + +fn merge_section(dst: &mut EditorConfigProperties, src: &SectionProperties) { + if let Some(v) = src.indent_style { + dst.indent_style = Some(v); + } + if let Some(v) = src.indent_size { + dst.indent_size = Some(v); + } + if let Some(v) = src.tab_width { + dst.tab_width = Some(v); + } + if let Some(v) = src.max_line_length { + dst.max_line_length = Some(v); + } + if let Some(v) = src.end_of_line { + dst.end_of_line = Some(v); + } +} + +fn parse(contents: &str) -> ParsedFile { + let mut root = false; + let mut sections: Vec
= Vec::new(); + let mut current: Option
= None; + + for raw_line in contents.lines() { + let line = strip_comment(raw_line).trim(); + if line.is_empty() { + continue; + } + if let Some(rest) = line.strip_prefix('[') + && let Some(pattern) = rest.strip_suffix(']') + { + if let Some(prev) = current.take() { + sections.push(prev); + } + current = Some(Section { + pattern: pattern.to_string(), + properties: SectionProperties::default(), + }); + continue; + } + + let Some((key, value)) = line.split_once('=') else { + continue; + }; + let key = key.trim().to_ascii_lowercase(); + let value = value.trim(); + + if current.is_none() { + // Preamble: only "root" is meaningful. + if key == "root" && value.eq_ignore_ascii_case("true") { + root = true; + } + continue; + } + let props = &mut current.as_mut().unwrap().properties; + match key.as_str() { + "indent_style" => { + props.indent_style = match value.to_ascii_lowercase().as_str() { + "tab" => Some(IndentStyle::Tab), + "space" => Some(IndentStyle::Space), + _ => None, + }; + } + "indent_size" => { + // "tab" means use tab_width; otherwise parse as integer. + if value.eq_ignore_ascii_case("tab") { + // leave indent_size None; consumer falls back to tab_width + } else if let Ok(n) = value.parse::() { + props.indent_size = Some(n); + } + } + "tab_width" => { + if let Ok(n) = value.parse::() { + props.tab_width = Some(n); + } + } + "max_line_length" => { + if !value.eq_ignore_ascii_case("off") + && let Ok(n) = value.parse::() + { + props.max_line_length = Some(n); + } + } + "end_of_line" => { + props.end_of_line = match value.to_ascii_lowercase().as_str() { + "lf" => Some(EndOfLine::Lf), + "crlf" => Some(EndOfLine::Crlf), + "cr" => Some(EndOfLine::Cr), + _ => None, + }; + } + _ => {} + } + } + if let Some(prev) = current.take() { + sections.push(prev); + } + + ParsedFile { root, sections } +} + +fn strip_comment(s: &str) -> &str { + // EditorConfig allows ';' or '#' as comment markers. They start + // a comment if at the beginning of a line or preceded by whitespace. + let mut prev_ws = true; + for (i, ch) in s.char_indices() { + if (ch == ';' || ch == '#') && prev_ws { + return &s[..i]; + } + prev_ws = ch.is_whitespace(); + } + s +} + +/// Check whether `pattern` from `.editorconfig` in `config_dir` matches +/// `file_path`. `file_path` must be inside `config_dir` or one of its +/// descendants for any match to occur. +fn pattern_matches(pattern: &str, config_dir: &Path, file_path: &Path) -> bool { + let Ok(rel) = file_path.strip_prefix(config_dir) else { + return false; + }; + let rel = path_to_forward_slash(rel); + + // Spec: if the pattern contains a path separator, it is matched + // relative to the .editorconfig directory. Otherwise it matches + // the basename in any subdirectory. + let pattern_has_slash = pattern.contains('/'); + let pattern_re = glob_to_regex(pattern, !pattern_has_slash); + Regex::new(&pattern_re) + .map(|re| re.is_match(&rel)) + .unwrap_or(false) +} + +fn path_to_forward_slash(p: &Path) -> String { + let s = p.to_string_lossy().into_owned(); + if std::path::MAIN_SEPARATOR == '/' { + s + } else { + s.replace(std::path::MAIN_SEPARATOR, "/") + } +} + +/// Convert an editorconfig glob pattern to a regex string anchored +/// with `^` and `$`. If `match_any_dir`, the pattern is allowed to +/// be preceded by any number of leading directory components. +fn glob_to_regex(pattern: &str, match_any_dir: bool) -> String { + let mut out = String::from("^"); + if match_any_dir { + out.push_str("(?:.*/)?"); + } + let pattern = pattern.strip_prefix('/').unwrap_or(pattern); + let bytes: Vec = pattern.chars().collect(); + let mut i = 0; + while i < bytes.len() { + let c = bytes[i]; + match c { + '*' => { + if i + 1 < bytes.len() && bytes[i + 1] == '*' { + // Treat `**/` as zero or more path components so that + // `**/foo.ts` also matches `foo.ts` at the root, matching + // gitignore-style user expectations. + if i + 2 < bytes.len() && bytes[i + 2] == '/' { + out.push_str("(?:[^/]*/)*"); + i += 3; + continue; + } + out.push_str(".*"); + i += 2; + continue; + } else { + out.push_str("[^/]*"); + } + } + '?' => out.push_str("[^/]"), + '{' => { + // Find matching '}'. + let mut depth = 1; + let mut j = i + 1; + while j < bytes.len() && depth > 0 { + match bytes[j] { + '{' => depth += 1, + '}' => { + depth -= 1; + if depth == 0 { + break; + } + } + _ => {} + } + j += 1; + } + if depth == 0 { + let group: String = bytes[i + 1..j].iter().collect(); + // Numeric range {n..m} + if let Some((lhs, rhs)) = group.split_once("..") + && let (Ok(lo), Ok(rhs)) = (lhs.parse::(), rhs.parse::()) + { + let (a, b) = if lo <= rhs { (lo, rhs) } else { (rhs, lo) }; + out.push('('); + for n in a..=b { + if n != a { + out.push('|'); + } + for ch in n.to_string().chars() { + regex_push_escaped(&mut out, ch); + } + } + out.push(')'); + i = j + 1; + continue; + } + // Comma alternatives + let alts = split_top_level_commas(&group); + if alts.len() > 1 { + out.push_str("(?:"); + for (k, alt) in alts.iter().enumerate() { + if k > 0 { + out.push('|'); + } + out.push_str(&glob_inner_to_regex(alt)); + } + out.push(')'); + i = j + 1; + continue; + } + // Single literal — fall through to literal + for ch in group.chars() { + regex_push_escaped(&mut out, ch); + } + i = j + 1; + continue; + } else { + // Unmatched brace - treat literally. + regex_push_escaped(&mut out, '{'); + } + } + '[' => { + // Character class; pass through, but translate `[!...]` -> `[^...]`. + let mut j = i + 1; + let mut negate = false; + if j < bytes.len() && (bytes[j] == '!' || bytes[j] == '^') { + negate = true; + j += 1; + } + let mut chars = Vec::new(); + while j < bytes.len() && bytes[j] != ']' { + chars.push(bytes[j]); + j += 1; + } + if j < bytes.len() { + out.push('['); + if negate { + out.push('^'); + } + for ch in chars { + // Inside a char class, escape `\`, `]`, `^`. + match ch { + '\\' | ']' => { + out.push('\\'); + out.push(ch); + } + _ => out.push(ch), + } + } + out.push(']'); + i = j + 1; + continue; + } else { + regex_push_escaped(&mut out, '['); + } + } + _ => regex_push_escaped(&mut out, c), + } + i += 1; + } + out.push('$'); + out +} + +fn glob_inner_to_regex(s: &str) -> String { + // Recursive use for alternatives - reuse the same logic without + // adding `^`/`$` anchors or the leading any-dir prefix. + let inner = glob_to_regex(s, false); + // Strip the surrounding ^...$. + inner + .strip_prefix('^') + .and_then(|t| t.strip_suffix('$')) + .unwrap_or(&inner) + .to_string() +} + +fn split_top_level_commas(s: &str) -> Vec { + let mut out = Vec::new(); + let mut current = String::new(); + let mut depth = 0i32; + for ch in s.chars() { + match ch { + '{' => { + depth += 1; + current.push(ch); + } + '}' => { + depth -= 1; + current.push(ch); + } + ',' if depth == 0 => { + out.push(std::mem::take(&mut current)); + } + _ => current.push(ch), + } + } + if !current.is_empty() || !out.is_empty() { + out.push(current); + } + out +} + +fn regex_push_escaped(out: &mut String, ch: char) { + match ch { + '.' | '+' | '(' | ')' | '|' | '^' | '$' | '\\' => { + out.push('\\'); + out.push(ch); + } + _ => out.push(ch), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn parse_str(s: &str) -> ParsedFile { + parse(s) + } + + fn match_regex(pattern: &str, text: &str) -> bool { + Regex::new(pattern) + .map(|re| re.is_match(text)) + .unwrap_or_else(|err| panic!("invalid regex {pattern:?}: {err}")) + } + + #[test] + fn parses_root_and_sections() { + let f = parse_str( + r"root = true + +[*] +indent_style = space +indent_size = 2 + +[*.py] +indent_size = 4 +", + ); + assert!(f.root); + assert_eq!(f.sections.len(), 2); + assert_eq!(f.sections[0].pattern, "*"); + assert_eq!( + f.sections[0].properties.indent_style, + Some(IndentStyle::Space) + ); + assert_eq!(f.sections[0].properties.indent_size, Some(2)); + assert_eq!(f.sections[1].pattern, "*.py"); + assert_eq!(f.sections[1].properties.indent_size, Some(4)); + } + + #[test] + fn parses_tab_and_max_line_length() { + let f = parse_str( + r"[*] +indent_style = tab +tab_width = 4 +max_line_length = 100 +end_of_line = crlf +", + ); + let p = &f.sections[0].properties; + assert_eq!(p.indent_style, Some(IndentStyle::Tab)); + assert_eq!(p.tab_width, Some(4)); + assert_eq!(p.max_line_length, Some(100)); + assert_eq!(p.end_of_line, Some(EndOfLine::Crlf)); + } + + #[test] + fn strips_comments() { + let f = parse_str( + r"; preamble comment +root = true # ignored + +[*] # section +indent_size = 2 ; inline +", + ); + assert!(f.root); + assert_eq!(f.sections[0].properties.indent_size, Some(2)); + } + + #[test] + fn max_line_length_off() { + let f = parse_str( + r"[*] +max_line_length = off +", + ); + assert_eq!(f.sections[0].properties.max_line_length, None); + } + + #[test] + fn glob_basic_extension() { + let r = glob_to_regex("*.ts", true); + assert!(match_regex(&r, "foo.ts")); + assert!(match_regex(&r, "sub/foo.ts")); + assert!(!match_regex(&r, "foo.js")); + } + + #[test] + fn glob_braces() { + let r = glob_to_regex("*.{ts,tsx,js}", true); + assert!(match_regex(&r, "foo.ts")); + assert!(match_regex(&r, "foo.tsx")); + assert!(match_regex(&r, "foo.js")); + assert!(!match_regex(&r, "foo.py")); + } + + #[test] + fn glob_double_star() { + let r = glob_to_regex("**/foo.ts", false); + assert!(match_regex(&r, "foo.ts")); + assert!(match_regex(&r, "a/foo.ts")); + assert!(match_regex(&r, "a/b/foo.ts")); + assert!(!match_regex(&r, "a/foo.js")); + } + + #[test] + fn glob_single_star_no_slash() { + let r = glob_to_regex("foo/*.ts", false); + assert!(match_regex(&r, "foo/bar.ts")); + assert!(!match_regex(&r, "foo/sub/bar.ts")); + } + + #[test] + fn glob_with_slash_anchored() { + // Pattern with '/' is anchored at config dir root. + let r = glob_to_regex("src/*.ts", false); + assert!(match_regex(&r, "src/a.ts")); + assert!(!match_regex(&r, "lib/src/a.ts")); + } + + #[test] + fn glob_question_mark() { + let r = glob_to_regex("?.ts", true); + assert!(match_regex(&r, "a.ts")); + assert!(!match_regex(&r, "ab.ts")); + } + + #[test] + fn glob_char_class() { + let r = glob_to_regex("[abc].ts", true); + assert!(match_regex(&r, "a.ts")); + assert!(match_regex(&r, "b.ts")); + assert!(!match_regex(&r, "d.ts")); + } + + #[test] + fn glob_negated_char_class() { + let r = glob_to_regex("[!abc].ts", true); + assert!(!match_regex(&r, "a.ts")); + assert!(match_regex(&r, "d.ts")); + } + + #[test] + fn glob_numeric_range() { + let r = glob_to_regex("file{1..3}.txt", true); + assert!(match_regex(&r, "file1.txt")); + assert!(match_regex(&r, "file2.txt")); + assert!(match_regex(&r, "file3.txt")); + assert!(!match_regex(&r, "file4.txt")); + } + + #[test] + fn apply_indent_tab_with_width() { + let mut cfg = FmtOptionsConfig::default(); + let props = EditorConfigProperties { + indent_style: Some(IndentStyle::Tab), + tab_width: Some(4), + ..Default::default() + }; + props.apply_to(&mut cfg); + assert_eq!(cfg.use_tabs, Some(true)); + assert_eq!(cfg.indent_width, Some(4)); + } + + #[test] + fn apply_does_not_override_existing() { + let mut cfg = FmtOptionsConfig { + use_tabs: Some(false), + indent_width: Some(2), + ..Default::default() + }; + let props = EditorConfigProperties { + indent_style: Some(IndentStyle::Tab), + indent_size: Some(8), + ..Default::default() + }; + props.apply_to(&mut cfg); + assert_eq!(cfg.use_tabs, Some(false)); + assert_eq!(cfg.indent_width, Some(2)); + } + + #[test] + fn apply_end_of_line_maps_to_new_line_kind() { + let mut cfg = FmtOptionsConfig::default(); + let props = EditorConfigProperties { + end_of_line: Some(EndOfLine::Crlf), + ..Default::default() + }; + props.apply_to(&mut cfg); + assert_eq!(cfg.new_line_kind, Some(NewLineKind::CarriageReturnLineFeed)); + } +} diff --git a/cli/tools/mod.rs b/cli/tools/mod.rs index 121cdf0c8a006d..1597c99fb045c1 100644 --- a/cli/tools/mod.rs +++ b/cli/tools/mod.rs @@ -10,6 +10,7 @@ pub mod coverage; pub mod deploy; pub mod doc; pub mod fmt; +pub mod fmt_editorconfig; pub mod framework; pub mod info; pub mod init; diff --git a/tests/specs/fmt/editorconfig/.editorconfig b/tests/specs/fmt/editorconfig/.editorconfig new file mode 100644 index 00000000000000..e566e72986ad90 --- /dev/null +++ b/tests/specs/fmt/editorconfig/.editorconfig @@ -0,0 +1,5 @@ +root = true + +[*.ts] +indent_style = space +indent_size = 4 diff --git a/tests/specs/fmt/editorconfig/__test__.jsonc b/tests/specs/fmt/editorconfig/__test__.jsonc new file mode 100644 index 00000000000000..5919954dd15e19 --- /dev/null +++ b/tests/specs/fmt/editorconfig/__test__.jsonc @@ -0,0 +1,35 @@ +{ + "tempDir": true, + "tests": { + "infers_indent_size": { + // .editorconfig sets indent_size=4. The file is already formatted with + // 4-space indentation, so `fmt --check` should pass. + "args": "fmt --check indent_size_4.ts", + "output": "Checked 1 file\n" + }, + "infers_indent_size_negative": { + // Without reading editorconfig, deno's default 2-space indent would + // refuse the file. With editorconfig support, --indent-width=2 on the + // CLI overrides the editorconfig value, so check still fails. + "args": "fmt --check --indent-width=2 indent_size_4.ts", + "output": "[WILDCARD]Found 1 not formatted file in 1 file\n", + "exitCode": 1 + }, + "infers_use_tabs": { + // .editorconfig in tabs/ sets indent_style=tab. + "args": "fmt --check tabs/file.ts", + "output": "Checked 1 file\n" + }, + "infers_max_line_length": { + // .editorconfig in long_lines/ sets max_line_length=200, allowing a + // long line that would otherwise exceed deno's default 80. + "args": "fmt --check long_lines/file.ts", + "output": "Checked 1 file\n" + }, + "deno_json_takes_precedence": { + // deno.json explicitly sets indentWidth=2; editorconfig must NOT override. + "args": "fmt --check --config with_deno_json/deno.json with_deno_json/file.ts", + "output": "Checked 1 file\n" + } + } +} diff --git a/tests/specs/fmt/editorconfig/indent_size_4.ts b/tests/specs/fmt/editorconfig/indent_size_4.ts new file mode 100644 index 00000000000000..1d226d60f668c2 --- /dev/null +++ b/tests/specs/fmt/editorconfig/indent_size_4.ts @@ -0,0 +1,3 @@ +function greet(name: string) { + return "hello " + name; +} diff --git a/tests/specs/fmt/editorconfig/long_lines/.editorconfig b/tests/specs/fmt/editorconfig/long_lines/.editorconfig new file mode 100644 index 00000000000000..54c1d299f4a997 --- /dev/null +++ b/tests/specs/fmt/editorconfig/long_lines/.editorconfig @@ -0,0 +1,4 @@ +root = true + +[*.ts] +max_line_length = 200 diff --git a/tests/specs/fmt/editorconfig/long_lines/file.ts b/tests/specs/fmt/editorconfig/long_lines/file.ts new file mode 100644 index 00000000000000..26bf58c691cfac --- /dev/null +++ b/tests/specs/fmt/editorconfig/long_lines/file.ts @@ -0,0 +1 @@ +export const a = "this string is intentionally long enough to exceed deno's default line width of 80, but shorter than 200 characters"; diff --git a/tests/specs/fmt/editorconfig/tabs/.editorconfig b/tests/specs/fmt/editorconfig/tabs/.editorconfig new file mode 100644 index 00000000000000..719b999e2bd2a0 --- /dev/null +++ b/tests/specs/fmt/editorconfig/tabs/.editorconfig @@ -0,0 +1,4 @@ +root = true + +[*.ts] +indent_style = tab diff --git a/tests/specs/fmt/editorconfig/tabs/file.ts b/tests/specs/fmt/editorconfig/tabs/file.ts new file mode 100644 index 00000000000000..79e28c04fadc79 --- /dev/null +++ b/tests/specs/fmt/editorconfig/tabs/file.ts @@ -0,0 +1,3 @@ +function greet(name: string) { + return "hello " + name; +} diff --git a/tests/specs/fmt/editorconfig/with_deno_json/.editorconfig b/tests/specs/fmt/editorconfig/with_deno_json/.editorconfig new file mode 100644 index 00000000000000..b3f3ada94de7ee --- /dev/null +++ b/tests/specs/fmt/editorconfig/with_deno_json/.editorconfig @@ -0,0 +1,4 @@ +root = true + +[*.ts] +indent_size = 4 diff --git a/tests/specs/fmt/editorconfig/with_deno_json/deno.json b/tests/specs/fmt/editorconfig/with_deno_json/deno.json new file mode 100644 index 00000000000000..f6ebffa4aff81b --- /dev/null +++ b/tests/specs/fmt/editorconfig/with_deno_json/deno.json @@ -0,0 +1,5 @@ +{ + "fmt": { + "indentWidth": 2 + } +} diff --git a/tests/specs/fmt/editorconfig/with_deno_json/file.ts b/tests/specs/fmt/editorconfig/with_deno_json/file.ts new file mode 100644 index 00000000000000..539b2cc794bb89 --- /dev/null +++ b/tests/specs/fmt/editorconfig/with_deno_json/file.ts @@ -0,0 +1,3 @@ +function greet(name: string) { + return "hello " + name; +} From 029a6b7c76ba7d90876a078f6230681b6d2b9a91 Mon Sep 17 00:00:00 2001 From: lunadogbot Date: Thu, 14 May 2026 19:55:58 +0000 Subject: [PATCH 2/5] perf(fmt): cache editorconfig chain per dir; compile regex once per section Addresses review feedback that per-file `.editorconfig` lookup was too expensive on large trees: - Pre-compile each section's glob into a `Regex` once at parse time instead of recompiling for every (file, section) pair. - Store parsed files behind `Arc` and memoize the resolved chain (outermost -> innermost) per starting directory. Subsequent files under the same directory reuse the chain without re-walking parents or re-locking the file cache per ancestor. --- cli/tools/fmt_editorconfig.rs | 149 +++++++++++++++++++++++----------- 1 file changed, 100 insertions(+), 49 deletions(-) diff --git a/cli/tools/fmt_editorconfig.rs b/cli/tools/fmt_editorconfig.rs index 1e36a9b0a3d6bf..c5f89ac169d2d5 100644 --- a/cli/tools/fmt_editorconfig.rs +++ b/cli/tools/fmt_editorconfig.rs @@ -10,6 +10,7 @@ use std::collections::HashMap; use std::path::Path; use std::path::PathBuf; +use std::sync::Arc; use std::sync::Mutex; use deno_config::deno_json::FmtOptionsConfig; @@ -93,15 +94,21 @@ impl EditorConfigProperties { } } -#[derive(Debug, Clone)] +#[derive(Debug)] struct ParsedFile { root: bool, sections: Vec
, } -#[derive(Debug, Clone)] +#[derive(Debug)] struct Section { - pattern: String, + /// Anchored regex compiled from the section's glob pattern, + /// matched against the slash-separated path relative to the + /// `.editorconfig` directory. `None` if the pattern was empty or + /// failed to compile (in which case the section is inert). Patterns + /// without a `/` match the basename in any subdirectory; this is + /// encoded by a `(?:.*/)?` prefix in the compiled regex. + regex: Option, properties: SectionProperties, } @@ -114,10 +121,24 @@ struct SectionProperties { end_of_line: Option, } -/// Cache of parsed `.editorconfig` files, keyed by absolute path. +/// One entry in a resolved `.editorconfig` chain — a parsed file and +/// the directory it lives in (needed to compute the path relative to +/// the `.editorconfig` for pattern matching). +#[derive(Debug)] +struct ChainEntry { + dir: PathBuf, + file: Arc, +} + +/// Cache of parsed `.editorconfig` files, keyed by the absolute path +/// of the directory the file lives in. The cache also memoizes the +/// resolved chain (outermost → innermost) for each starting directory +/// so that the directory walk runs once per unique directory rather +/// than once per file. #[derive(Debug, Default)] pub struct EditorConfigCache { - files: Mutex>>, + files: Mutex>>>, + chains: Mutex>>>, } impl EditorConfigCache { @@ -132,31 +153,23 @@ impl EditorConfigCache { Ok(p) => p, Err(_) => file_path.to_path_buf(), }; - - // Walk up from the file's directory, collecting .editorconfig files. - // Stop when we hit one with `root = true`. - let mut configs: Vec<(PathBuf, ParsedFile)> = Vec::new(); - let start = abs_path.parent().unwrap_or(&abs_path); - let mut cur: Option<&Path> = Some(start); - while let Some(dir) = cur { - let ec_path = dir.join(".editorconfig"); - if let Some(parsed) = self.read_and_parse(&ec_path) { - let is_root = parsed.root; - configs.push((dir.to_path_buf(), parsed)); - if is_root { - break; - } - } - cur = dir.parent(); + let start = abs_path.parent().unwrap_or(&abs_path).to_path_buf(); + let chain = self.resolve_chain(&start); + if chain.is_empty() { + return EditorConfigProperties::default(); } - // Apply from outermost to innermost so nearer files override farther ones. - configs.reverse(); - let mut out = EditorConfigProperties::default(); - for (dir, parsed) in configs { - for section in &parsed.sections { - if pattern_matches(§ion.pattern, &dir, &abs_path) { + for entry in chain.iter() { + // Compute the file path relative to this .editorconfig's dir. + let Ok(rel) = abs_path.strip_prefix(&entry.dir) else { + continue; + }; + let rel = path_to_forward_slash(rel); + for section in &entry.file.sections { + if let Some(re) = §ion.regex + && re.is_match(&rel) + { merge_section(&mut out, §ion.properties); } } @@ -164,14 +177,50 @@ impl EditorConfigCache { out } - fn read_and_parse(&self, path: &Path) -> Option { + /// Resolve the (outermost → innermost) chain of `.editorconfig` + /// files that apply to anything in `dir`. Result is cached per dir, + /// so files in the same directory share the same walk. + fn resolve_chain(&self, dir: &Path) -> Arc> { + if let Some(c) = self.chains.lock().unwrap().get(dir) { + return c.clone(); + } + let mut entries: Vec = Vec::new(); + let mut cur: Option<&Path> = Some(dir); + while let Some(d) = cur { + let ec_path = d.join(".editorconfig"); + if let Some(parsed) = self.read_and_parse(&ec_path) { + let is_root = parsed.root; + entries.push(ChainEntry { + dir: d.to_path_buf(), + file: parsed, + }); + if is_root { + break; + } + } + cur = d.parent(); + } + // Apply outermost first so nearer files override farther ones. + entries.reverse(); + let arc = Arc::new(entries); + self + .chains + .lock() + .unwrap() + .insert(dir.to_path_buf(), arc.clone()); + arc + } + + fn read_and_parse(&self, path: &Path) -> Option> { { let files = self.files.lock().unwrap(); if let Some(cached) = files.get(path) { return cached.clone(); } } - let parsed = std::fs::read_to_string(path).ok().map(|s| parse(&s)); + let parsed = std::fs::read_to_string(path) + .ok() + .map(|s| Arc::new(parse(&s))); let mut files = self.files.lock().unwrap(); files.insert(path.to_path_buf(), parsed.clone()); parsed @@ -212,8 +261,9 @@ fn parse(contents: &str) -> ParsedFile { if let Some(prev) = current.take() { sections.push(prev); } + let regex = compile_glob_regex(pattern); current = Some(Section { - pattern: pattern.to_string(), + regex, properties: SectionProperties::default(), }); continue; @@ -292,23 +342,20 @@ fn strip_comment(s: &str) -> &str { s } -/// Check whether `pattern` from `.editorconfig` in `config_dir` matches -/// `file_path`. `file_path` must be inside `config_dir` or one of its -/// descendants for any match to occur. -fn pattern_matches(pattern: &str, config_dir: &Path, file_path: &Path) -> bool { - let Ok(rel) = file_path.strip_prefix(config_dir) else { - return false; - }; - let rel = path_to_forward_slash(rel); - - // Spec: if the pattern contains a path separator, it is matched - // relative to the .editorconfig directory. Otherwise it matches - // the basename in any subdirectory. - let pattern_has_slash = pattern.contains('/'); - let pattern_re = glob_to_regex(pattern, !pattern_has_slash); - Regex::new(&pattern_re) - .map(|re| re.is_match(&rel)) - .unwrap_or(false) +/// Compile a `.editorconfig` section glob pattern to a regex anchored +/// against the slash-separated relative path of a file. Returns `None` +/// if the pattern is empty or fails to compile (the section then has +/// no effect). +fn compile_glob_regex(pattern: &str) -> Option { + if pattern.is_empty() { + return None; + } + // If the pattern doesn't contain a path separator it matches the + // basename in any subdirectory; otherwise it's anchored at the + // `.editorconfig` directory. + let match_any_dir = !pattern.contains('/'); + let pattern_re = glob_to_regex(pattern, match_any_dir); + Regex::new(&pattern_re).ok() } fn path_to_forward_slash(p: &Path) -> String { @@ -534,13 +581,17 @@ indent_size = 4 ); assert!(f.root); assert_eq!(f.sections.len(), 2); - assert_eq!(f.sections[0].pattern, "*"); + let s0_re = f.sections[0].regex.as_ref().unwrap(); + assert!(s0_re.is_match("foo.ts")); + assert!(s0_re.is_match("a/b/foo.ts")); assert_eq!( f.sections[0].properties.indent_style, Some(IndentStyle::Space) ); assert_eq!(f.sections[0].properties.indent_size, Some(2)); - assert_eq!(f.sections[1].pattern, "*.py"); + let s1_re = f.sections[1].regex.as_ref().unwrap(); + assert!(s1_re.is_match("a.py")); + assert!(!s1_re.is_match("a.ts")); assert_eq!(f.sections[1].properties.indent_size, Some(4)); } From 8841a59829404c98314ba8a3264b551876f30393 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartek=20Iwa=C5=84czuk?= Date: Sat, 13 Jun 2026 10:48:40 +0200 Subject: [PATCH 3/5] fix(fmt): invalidate incremental cache on editorconfig change; log at debug Fold the per-file .editorconfig-resolved options into the incremental cache hash so editing .editorconfig invalidates the cached "already formatted" result even when the file body is unchanged. Previously the cache keyed only on file content plus batch-level options, so a --check could pass on a stale entry after an .editorconfig edit. Also log at debug level (once per discovered file) when an .editorconfig is found and used, and add spec coverage for the debug log and for the nested walk-up where a nearer non-root .editorconfig overrides a farther one. --- cli/tools/fmt.rs | 64 ++++++++++++++----- cli/tools/fmt_editorconfig.rs | 3 + tests/specs/fmt/editorconfig/__test__.jsonc | 13 ++++ .../fmt/editorconfig/nested/.editorconfig | 5 ++ .../fmt/editorconfig/nested/sub/.editorconfig | 2 + .../specs/fmt/editorconfig/nested/sub/file.ts | 3 + 6 files changed, 73 insertions(+), 17 deletions(-) create mode 100644 tests/specs/fmt/editorconfig/nested/.editorconfig create mode 100644 tests/specs/fmt/editorconfig/nested/sub/.editorconfig create mode 100644 tests/specs/fmt/editorconfig/nested/sub/file.ts diff --git a/cli/tools/fmt.rs b/cli/tools/fmt.rs index 020d0cf71a58ef..e342f6f7c5b19b 100644 --- a/cli/tools/fmt.rs +++ b/cli/tools/fmt.rs @@ -947,6 +947,25 @@ fn resolve_per_file_options( cfg } +/// Returns the value hashed by the incremental cache for a file. When +/// `.editorconfig` contributes options that differ from the batch-level +/// `base` config, those options are folded into the hashed value so that +/// editing `.editorconfig` invalidates the cached "already formatted" +/// result even when the file body itself is unchanged. When nothing was +/// contributed the file text is hashed as-is, preserving existing cache +/// entries and avoiding an allocation. +fn incremental_cache_text<'a>( + per_file_options: &FmtOptionsConfig, + base: &FmtOptionsConfig, + text: &'a str, +) -> Cow<'a, str> { + if per_file_options == base { + Cow::Borrowed(text) + } else { + Cow::Owned(format!("{per_file_options:?}\n{text}")) + } +} + struct CheckFormatter { not_formatted_files_count: Arc, checked_files_count: Arc, @@ -996,18 +1015,20 @@ impl Formatter for CheckFormatter { checked_files_count.fetch_add(1, Ordering::Relaxed); let file = read_file_contents(&file_path)?; - // skip checking the file if we know it's formatted - if !file.had_bom - && incremental_cache.is_file_same(&file_path, &file.text) - { - return Ok(()); - } - let per_file_options = resolve_per_file_options( &fmt_options, &editorconfig_cache, &file_path, ); + let cache_text = + incremental_cache_text(&per_file_options, &fmt_options, &file.text); + + // skip checking the file if we know it's formatted + if !file.had_bom + && incremental_cache.is_file_same(&file_path, &cache_text) + { + return Ok(()); + } match format_file( &file_path, @@ -1037,7 +1058,7 @@ impl Formatter for CheckFormatter { // formatting here. Additionally, ensure this is done during check // so that CIs that cache the DENO_DIR will get the benefit of // incremental formatting - incremental_cache.update_file(&file_path, &file.text); + incremental_cache.update_file(&file_path, &cache_text); } Err(e) => { not_formatted_files_count.fetch_add(1, Ordering::Relaxed); @@ -1116,18 +1137,20 @@ impl Formatter for RealFormatter { checked_files_count.fetch_add(1, Ordering::Relaxed); let file = read_file_contents(&file_path)?; - // skip formatting the file if we know it's formatted - if !file.had_bom - && incremental_cache.is_file_same(&file_path, &file.text) - { - return Ok(()); - } - let per_file_options = resolve_per_file_options( &fmt_options, &editorconfig_cache, &file_path, ); + let cache_text = + incremental_cache_text(&per_file_options, &fmt_options, &file.text); + + // skip formatting the file if we know it's formatted + if !file.had_bom + && incremental_cache.is_file_same(&file_path, &cache_text) + { + return Ok(()); + } match format_ensure_stable(&file_path, &file, |file_path, file| { format_file( @@ -1139,14 +1162,21 @@ impl Formatter for RealFormatter { ) }) { Ok(Some(formatted_text)) => { - incremental_cache.update_file(&file_path, &formatted_text); + incremental_cache.update_file( + &file_path, + &incremental_cache_text( + &per_file_options, + &fmt_options, + &formatted_text, + ), + ); write_file_contents(&file_path, &formatted_text)?; formatted_files_count.fetch_add(1, Ordering::Relaxed); let _g = output_lock.lock(); info!("{}", file_path.to_string_lossy()); } Ok(None) => { - incremental_cache.update_file(&file_path, &file.text); + incremental_cache.update_file(&file_path, &cache_text); } Err(e) => { failed_files_count.fetch_add(1, Ordering::Relaxed); diff --git a/cli/tools/fmt_editorconfig.rs b/cli/tools/fmt_editorconfig.rs index c5f89ac169d2d5..a96f4da1eba1e2 100644 --- a/cli/tools/fmt_editorconfig.rs +++ b/cli/tools/fmt_editorconfig.rs @@ -221,6 +221,9 @@ impl EditorConfigCache { let parsed = std::fs::read_to_string(path) .ok() .map(|s| Arc::new(parse(&s))); + if parsed.is_some() { + log::debug!("Found .editorconfig at {} and using it", path.display()); + } let mut files = self.files.lock().unwrap(); files.insert(path.to_path_buf(), parsed.clone()); parsed diff --git a/tests/specs/fmt/editorconfig/__test__.jsonc b/tests/specs/fmt/editorconfig/__test__.jsonc index 5919954dd15e19..9603a7f63beea1 100644 --- a/tests/specs/fmt/editorconfig/__test__.jsonc +++ b/tests/specs/fmt/editorconfig/__test__.jsonc @@ -30,6 +30,19 @@ // deno.json explicitly sets indentWidth=2; editorconfig must NOT override. "args": "fmt --check --config with_deno_json/deno.json with_deno_json/file.ts", "output": "Checked 1 file\n" + }, + "nested_overrides_parent": { + // nested/.editorconfig (root=true) sets indent_size=4; the nearer + // nested/sub/.editorconfig (not root) sets indent_size=8 and must win. + // This exercises the walk-up across a non-root file and the + // nearer-overrides-farther merge order. + "args": "fmt --check nested/sub/file.ts", + "output": "Checked 1 file\n" + }, + "logs_at_debug": { + // With -L debug, fmt notes that it found and is using the .editorconfig. + "args": "fmt --check -L debug indent_size_4.ts", + "output": "[WILDCARD]Found .editorconfig at [WILDLINE].editorconfig and using it[WILDCARD]Checked 1 file[WILDCARD]" } } } diff --git a/tests/specs/fmt/editorconfig/nested/.editorconfig b/tests/specs/fmt/editorconfig/nested/.editorconfig new file mode 100644 index 00000000000000..e566e72986ad90 --- /dev/null +++ b/tests/specs/fmt/editorconfig/nested/.editorconfig @@ -0,0 +1,5 @@ +root = true + +[*.ts] +indent_style = space +indent_size = 4 diff --git a/tests/specs/fmt/editorconfig/nested/sub/.editorconfig b/tests/specs/fmt/editorconfig/nested/sub/.editorconfig new file mode 100644 index 00000000000000..258b0918219786 --- /dev/null +++ b/tests/specs/fmt/editorconfig/nested/sub/.editorconfig @@ -0,0 +1,2 @@ +[*.ts] +indent_size = 8 diff --git a/tests/specs/fmt/editorconfig/nested/sub/file.ts b/tests/specs/fmt/editorconfig/nested/sub/file.ts new file mode 100644 index 00000000000000..093291fd22c794 --- /dev/null +++ b/tests/specs/fmt/editorconfig/nested/sub/file.ts @@ -0,0 +1,3 @@ +function greet(name: string) { + return "hello " + name; +} From 8007c2e55d498b581b1c5e47e9029db15a4f21d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartek=20Iwa=C5=84czuk?= Date: Sat, 13 Jun 2026 11:03:54 +0200 Subject: [PATCH 4/5] fix(fmt): harden editorconfig glob parsing against pathological input Make the .editorconfig glob translator degrade gracefully instead of crashing on malformed or adversarial section headers: - Bound numeric range {n..m} expansion so a huge span like {1..1000000000} no longer builds a giant regex (memory/CPU blowup); oversized ranges degrade to a literal that simply does not match. - Cap brace-nesting recursion depth so deeply nested alternations like {a,{a,{a,...}}} cannot overflow the stack. - Parse indent_size/tab_width/max_line_length with saturating integer conversion so out-of-range values clamp rather than being silently dropped. - Escape '[' ']' '{' '}' in literal output so degraded/unbalanced patterns still compile to a valid regex. Adds unit tests for each case. --- cli/tools/fmt_editorconfig.rs | 170 ++++++++++++++++++++++++++++------ 1 file changed, 140 insertions(+), 30 deletions(-) diff --git a/cli/tools/fmt_editorconfig.rs b/cli/tools/fmt_editorconfig.rs index a96f4da1eba1e2..eecad714458523 100644 --- a/cli/tools/fmt_editorconfig.rs +++ b/cli/tools/fmt_editorconfig.rs @@ -295,21 +295,22 @@ fn parse(contents: &str) -> ParsedFile { }; } "indent_size" => { - // "tab" means use tab_width; otherwise parse as integer. - if value.eq_ignore_ascii_case("tab") { - // leave indent_size None; consumer falls back to tab_width - } else if let Ok(n) = value.parse::() { + // "tab" means use tab_width; otherwise parse as integer, + // clamping out-of-range values rather than dropping them. + if !value.eq_ignore_ascii_case("tab") + && let Some(n) = parse_saturating_u8(value) + { props.indent_size = Some(n); } } "tab_width" => { - if let Ok(n) = value.parse::() { + if let Some(n) = parse_saturating_u8(value) { props.tab_width = Some(n); } } "max_line_length" => { if !value.eq_ignore_ascii_case("off") - && let Ok(n) = value.parse::() + && let Some(n) = parse_saturating_u32(value) { props.max_line_length = Some(n); } @@ -332,6 +333,20 @@ fn parse(contents: &str) -> ParsedFile { ParsedFile { root, sections } } +/// Parse a non-negative integer editorconfig value, saturating to the +/// target type's maximum on overflow instead of discarding the value. +/// Returns `None` for negative or non-numeric input so the property is +/// simply ignored. +fn parse_saturating_u8(value: &str) -> Option { + let n = value.trim().parse::().ok()?; + Some(n.min(u8::MAX as u64) as u8) +} + +fn parse_saturating_u32(value: &str) -> Option { + let n = value.trim().parse::().ok()?; + Some(n.min(u32::MAX as u64) as u32) +} + fn strip_comment(s: &str) -> &str { // EditorConfig allows ';' or '#' as comment markers. They start // a comment if at the beginning of a line or preceded by whitespace. @@ -374,6 +389,19 @@ fn path_to_forward_slash(p: &Path) -> String { /// with `^` and `$`. If `match_any_dir`, the pattern is allowed to /// be preceded by any number of leading directory components. fn glob_to_regex(pattern: &str, match_any_dir: bool) -> String { + glob_to_regex_depth(pattern, match_any_dir, 0) +} + +/// Maximum brace-nesting depth expanded before a pattern degrades to a +/// literal match. Guards against a stack overflow on a pathological +/// pattern such as `{a,{a,{a,...}}}` nested thousands deep. +const MAX_GLOB_DEPTH: u32 = 32; + +fn glob_to_regex_depth( + pattern: &str, + match_any_dir: bool, + depth: u32, +) -> String { let mut out = String::from("^"); if match_any_dir { out.push_str("(?:.*/)?"); @@ -404,14 +432,14 @@ fn glob_to_regex(pattern: &str, match_any_dir: bool) -> String { '?' => out.push_str("[^/]"), '{' => { // Find matching '}'. - let mut depth = 1; + let mut brace_depth = 1; let mut j = i + 1; - while j < bytes.len() && depth > 0 { + while j < bytes.len() && brace_depth > 0 { match bytes[j] { - '{' => depth += 1, + '{' => brace_depth += 1, '}' => { - depth -= 1; - if depth == 0 { + brace_depth -= 1; + if brace_depth == 0 { break; } } @@ -419,35 +447,49 @@ fn glob_to_regex(pattern: &str, match_any_dir: bool) -> String { } j += 1; } - if depth == 0 { + if brace_depth == 0 { let group: String = bytes[i + 1..j].iter().collect(); - // Numeric range {n..m} + // Numeric range {n..m}. Bounds are parsed as integers, so + // leading zeros are ignored and numbers are emitted in their + // natural decimal form, matching the editorconfig reference. if let Some((lhs, rhs)) = group.split_once("..") - && let (Ok(lo), Ok(rhs)) = (lhs.parse::(), rhs.parse::()) + && let (Ok(lo), Ok(hi)) = + (lhs.trim().parse::(), rhs.trim().parse::()) { - let (a, b) = if lo <= rhs { (lo, rhs) } else { (rhs, lo) }; - out.push('('); - for n in a..=b { - if n != a { - out.push('|'); - } - for ch in n.to_string().chars() { - regex_push_escaped(&mut out, ch); + let (a, b) = if lo <= hi { (lo, hi) } else { (hi, lo) }; + // Bound the enumeration so a pathological range such as + // `{1..1000000000}` cannot exhaust memory while building the + // regex. Real editorconfig ranges are tiny; a larger span + // degrades to a literal match (the section simply will not + // apply) rather than hanging or crashing. + const MAX_RANGE_SPAN: i64 = 4096; + let span_ok = + b.checked_sub(a).is_some_and(|span| span < MAX_RANGE_SPAN); + if span_ok { + out.push('('); + for n in a..=b { + if n != a { + out.push('|'); + } + for ch in n.to_string().chars() { + regex_push_escaped(&mut out, ch); + } } + out.push(')'); + i = j + 1; + continue; } - out.push(')'); - i = j + 1; - continue; + // Span too large: fall through to literal handling below. } // Comma alternatives let alts = split_top_level_commas(&group); - if alts.len() > 1 { + if alts.len() > 1 && depth < MAX_GLOB_DEPTH { out.push_str("(?:"); for (k, alt) in alts.iter().enumerate() { if k > 0 { out.push('|'); } - out.push_str(&glob_inner_to_regex(alt)); + out.push_str(&glob_inner_to_regex(alt, depth + 1)); } out.push(')'); i = j + 1; @@ -507,10 +549,10 @@ fn glob_to_regex(pattern: &str, match_any_dir: bool) -> String { out } -fn glob_inner_to_regex(s: &str) -> String { +fn glob_inner_to_regex(s: &str, depth: u32) -> String { // Recursive use for alternatives - reuse the same logic without // adding `^`/`$` anchors or the leading any-dir prefix. - let inner = glob_to_regex(s, false); + let inner = glob_to_regex_depth(s, false, depth); // Strip the surrounding ^...$. inner .strip_prefix('^') @@ -547,7 +589,7 @@ fn split_top_level_commas(s: &str) -> Vec { fn regex_push_escaped(out: &mut String, ch: char) { match ch { - '.' | '+' | '(' | ')' | '|' | '^' | '$' | '\\' => { + '.' | '+' | '(' | ')' | '|' | '^' | '$' | '\\' | '[' | ']' | '{' | '}' => { out.push('\\'); out.push(ch); } @@ -711,6 +753,74 @@ max_line_length = off assert!(!match_regex(&r, "file4.txt")); } + #[test] + fn glob_reversed_numeric_range() { + let r = glob_to_regex("file{3..1}.txt", true); + assert!(match_regex(&r, "file1.txt")); + assert!(match_regex(&r, "file2.txt")); + assert!(match_regex(&r, "file3.txt")); + } + + #[test] + fn glob_leading_zero_range_matches_reference() { + // Bounds are parsed as integers, so leading zeros are ignored, + // matching the editorconfig reference implementation. + let r = glob_to_regex("file{01..03}.txt", true); + assert!(match_regex(&r, "file1.txt")); + assert!(match_regex(&r, "file3.txt")); + } + + #[test] + fn glob_huge_numeric_range_degrades() { + // A pathological range must not be expanded into a giant regex; it + // degrades to a literal that simply does not match normal files. + let r = glob_to_regex("file{1..1000000000}.txt", true); + assert!(r.len() < 100, "regex unexpectedly large: {} chars", r.len()); + assert!(!match_regex(&r, "file5.txt")); + } + + #[test] + fn glob_deeply_nested_braces_do_not_overflow() { + // Nest braces well past MAX_GLOB_DEPTH; this must return without + // overflowing the stack and still produce a valid regex. + let mut p = String::from("x"); + for _ in 0..5000 { + p = format!("{{a,{p}}}"); + } + let r = glob_to_regex(&p, false); + assert!(r.starts_with('^') && r.ends_with('$')); + // Must compile rather than blow up. + assert!(Regex::new(&r).is_ok()); + } + + #[test] + fn glob_unbalanced_brace_compiles() { + // An unbalanced brace must still translate to a valid regex. + let r = glob_to_regex("foo{bar.ts", true); + assert!(match_regex(&r, "foo{bar.ts")); + } + + #[test] + fn parse_saturating_clamps() { + assert_eq!(parse_saturating_u8("8"), Some(8)); + assert_eq!(parse_saturating_u8("256"), Some(255)); + assert_eq!(parse_saturating_u8("-1"), None); + assert_eq!(parse_saturating_u8("nope"), None); + assert_eq!(parse_saturating_u32("99999999999"), Some(u32::MAX)); + } + + #[test] + fn indent_size_overflow_clamped() { + let f = parse_str("[*]\nindent_size = 1000\n"); + assert_eq!(f.sections[0].properties.indent_size, Some(255)); + } + + #[test] + fn max_line_length_overflow_clamped() { + let f = parse_str("[*]\nmax_line_length = 99999999999\n"); + assert_eq!(f.sections[0].properties.max_line_length, Some(u32::MAX)); + } + #[test] fn apply_indent_tab_with_width() { let mut cfg = FmtOptionsConfig::default(); From 67ac9a905d9e141fe75e0ba70b2701040320323f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartek=20Iwa=C5=84czuk?= Date: Sat, 13 Jun 2026 14:50:23 +0200 Subject: [PATCH 5/5] perf(fmt): skip per-file realpath in editorconfig resolution resolve() canonicalized every file path on every fmt run, paying a realpath syscall per file even when no .editorconfig exists anywhere in the tree. Walk the literal absolute path instead (fmt's collected paths are already absolute) and short-circuit to defaults via the memoized per-directory chain lookup before any filesystem work. No .editorconfig present now costs a single cached HashMap lookup per file with zero syscalls; discovery happens once per directory rather than once per file. Symlinks are no longer resolved during the walk, matching the editorconfig reference implementation. --- cli/tools/fmt_editorconfig.rs | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/cli/tools/fmt_editorconfig.rs b/cli/tools/fmt_editorconfig.rs index eecad714458523..7fcbc17cfcce54 100644 --- a/cli/tools/fmt_editorconfig.rs +++ b/cli/tools/fmt_editorconfig.rs @@ -17,8 +17,6 @@ use deno_config::deno_json::FmtOptionsConfig; use deno_config::deno_json::NewLineKind; use regex::Regex; -use crate::util::fs::canonicalize_path; - /// Properties resolved from `.editorconfig` files for a particular file. #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct EditorConfigProperties { @@ -146,15 +144,21 @@ impl EditorConfigCache { Self::default() } - /// Resolve `.editorconfig` properties for `file_path`. Returns - /// `Default::default()` if no `.editorconfig` files apply. + /// Resolve `.editorconfig` properties for `file_path`, which must be + /// an absolute path. Returns `Default::default()` if no + /// `.editorconfig` files apply. + /// + /// The common case (no `.editorconfig` anywhere up the tree) is kept + /// cheap: the directory walk is memoized per directory, so resolving + /// every file in a batch costs one cached chain lookup per file and + /// performs no per-file filesystem work. Symlinks are not resolved; + /// the literal path is walked, matching the editorconfig reference + /// implementation (and avoiding a `realpath` syscall per file). pub fn resolve(&self, file_path: &Path) -> EditorConfigProperties { - let abs_path = match canonicalize_path(file_path) { - Ok(p) => p, - Err(_) => file_path.to_path_buf(), + let Some(start) = file_path.parent() else { + return EditorConfigProperties::default(); }; - let start = abs_path.parent().unwrap_or(&abs_path).to_path_buf(); - let chain = self.resolve_chain(&start); + let chain = self.resolve_chain(start); if chain.is_empty() { return EditorConfigProperties::default(); } @@ -162,7 +166,7 @@ impl EditorConfigCache { let mut out = EditorConfigProperties::default(); for entry in chain.iter() { // Compute the file path relative to this .editorconfig's dir. - let Ok(rel) = abs_path.strip_prefix(&entry.dir) else { + let Ok(rel) = file_path.strip_prefix(&entry.dir) else { continue; }; let rel = path_to_forward_slash(rel);