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;
use deno_config::deno_json::NewLineKind;
use regex::Regex;
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct EditorConfigProperties {
pub indent_style: Option<IndentStyle>,
pub indent_size: Option<IndentSize>,
pub tab_width: Option<u8>,
pub max_line_length: Option<u32>,
pub end_of_line: Option<EndOfLine>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum IndentStyle {
Space,
Tab,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum IndentSize {
Width(u8),
UseTabWidth,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EndOfLine {
Lf,
Crlf,
Cr,
}
impl EditorConfigProperties {
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() {
let indent = match self.indent_size {
Some(IndentSize::Width(n)) => Some(n),
Some(IndentSize::UseTabWidth) => self.tab_width,
None if matches!(self.indent_style, Some(IndentStyle::Tab)) => {
self.tab_width
}
None => 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),
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)]
struct ParsedFile {
root: bool,
sections: Vec<Section>,
}
#[derive(Debug)]
struct Section {
regex: Option<Regex>,
properties: SectionProperties,
}
#[derive(Debug, Clone, Default)]
struct SectionProperties {
indent_style: Option<IndentStyle>,
indent_size: Option<IndentSize>,
tab_width: Option<u8>,
max_line_length: Option<u32>,
end_of_line: Option<EndOfLine>,
}
#[derive(Debug)]
struct ChainEntry {
dir: PathBuf,
file: Arc<ParsedFile>,
}
#[derive(Debug, Default)]
pub struct EditorConfigCache {
files: Mutex<HashMap<PathBuf, Option<Arc<ParsedFile>>>>,
chains: Mutex<HashMap<PathBuf, Arc<Vec<ChainEntry>>>>,
}
impl EditorConfigCache {
pub fn new() -> Self {
Self::default()
}
pub fn resolve(&self, file_path: &Path) -> EditorConfigProperties {
debug_assert!(
file_path.is_absolute(),
"resolve() requires an absolute path, got {}",
file_path.display()
);
let Some(start) = file_path.parent() else {
return EditorConfigProperties::default();
};
let chain = self.resolve_chain(start);
if chain.is_empty() {
return EditorConfigProperties::default();
}
let mut out = EditorConfigProperties::default();
for entry in chain.iter() {
let Ok(rel) = file_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);
}
}
}
out
}
fn resolve_chain(&self, dir: &Path) -> Arc<Vec<ChainEntry>> {
if let Some(c) = self.chains.lock().unwrap().get(dir) {
return c.clone();
}
let mut entries: Vec<ChainEntry> = 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();
}
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<Arc<ParsedFile>> {
{
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| 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
}
}
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<Section> = Vec::new();
let mut current: Option<Section> = 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);
}
let regex = compile_glob_regex(pattern);
current = Some(Section {
regex,
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() {
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" => {
if value.eq_ignore_ascii_case("tab") {
props.indent_size = Some(IndentSize::UseTabWidth);
} else if let Some(n) = parse_saturating_u8(value) {
props.indent_size = Some(IndentSize::Width(n));
}
}
"tab_width" => {
if let Some(n) = parse_saturating_u8(value) {
props.tab_width = Some(n);
}
}
"max_line_length" => {
if !value.eq_ignore_ascii_case("off")
&& let Some(n) = parse_saturating_u32(value)
{
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 parse_saturating_u8(value: &str) -> Option<u8> {
let n = value.trim().parse::<u64>().ok()?;
Some(n.min(u8::MAX as u64) as u8)
}
fn parse_saturating_u32(value: &str) -> Option<u32> {
let n = value.trim().parse::<u64>().ok()?;
Some(n.min(u32::MAX as u64) as u32)
}
fn strip_comment(s: &str) -> &str {
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
}
fn compile_glob_regex(pattern: &str) -> Option<Regex> {
if pattern.is_empty() {
return None;
}
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 {
let s = p.to_string_lossy().into_owned();
if std::path::MAIN_SEPARATOR == '/' {
s
} else {
s.replace(std::path::MAIN_SEPARATOR, "/")
}
}
fn glob_to_regex(pattern: &str, match_any_dir: bool) -> String {
glob_to_regex_depth(pattern, match_any_dir, 0)
}
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("(?:.*/)?");
}
let pattern = pattern.strip_prefix('/').unwrap_or(pattern);
let chars: Vec<char> = pattern.chars().collect();
let mut i = 0;
while i < chars.len() {
let c = chars[i];
match c {
'*' => {
if i + 1 < chars.len() && chars[i + 1] == '*' {
if i + 2 < chars.len() && chars[i + 2] == '/' {
out.push_str("(?:[^/]*/)*");
i += 3;
continue;
}
out.push_str(".*");
i += 2;
continue;
} else {
out.push_str("[^/]*");
}
}
'?' => out.push_str("[^/]"),
'{' => {
let mut brace_depth = 1;
let mut j = i + 1;
while j < chars.len() && brace_depth > 0 {
match chars[j] {
'{' => brace_depth += 1,
'}' => {
brace_depth -= 1;
if brace_depth == 0 {
break;
}
}
_ => {}
}
j += 1;
}
if brace_depth == 0 {
let group: String = chars[i + 1..j].iter().collect();
if let Some((lhs, rhs)) = group.split_once("..")
&& let (Ok(lo), Ok(hi)) =
(lhs.trim().parse::<i64>(), rhs.trim().parse::<i64>())
{
let (a, b) = if lo <= hi { (lo, hi) } else { (hi, lo) };
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;
}
}
let alts = split_top_level_commas(&group);
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, depth + 1));
}
out.push(')');
i = j + 1;
continue;
}
regex_push_escaped(&mut out, '{');
for ch in group.chars() {
regex_push_escaped(&mut out, ch);
}
regex_push_escaped(&mut out, '}');
i = j + 1;
continue;
} else {
regex_push_escaped(&mut out, '{');
}
}
'[' => {
let mut j = i + 1;
let mut negate = false;
if j < chars.len() && (chars[j] == '!' || chars[j] == '^') {
negate = true;
j += 1;
}
let mut class_chars = Vec::new();
while j < chars.len() && chars[j] != ']' {
class_chars.push(chars[j]);
j += 1;
}
if j < chars.len() {
out.push('[');
if negate {
out.push('^');
}
for ch in class_chars {
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, depth: u32) -> String {
let inner = glob_to_regex_depth(s, false, depth);
inner
.strip_prefix('^')
.and_then(|t| t.strip_suffix('$'))
.unwrap_or(&inner)
.to_string()
}
fn split_top_level_commas(s: &str) -> Vec<String> {
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);
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(IndentSize::Width(2))
);
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(IndentSize::Width(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(IndentSize::Width(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() {
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 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() {
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() {
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() {
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('$'));
assert!(Regex::new(&r).is_ok());
}
#[test]
fn glob_commaless_brace_is_literal() {
let r = glob_to_regex("{word}.ts", true);
assert!(match_regex(&r, "{word}.ts"));
assert!(!match_regex(&r, "word.ts"));
}
#[test]
fn glob_empty_and_trailing_comma_braces() {
let empty = glob_to_regex("a{}b", false);
assert!(match_regex(&empty, "a{}b"));
assert!(!match_regex(&empty, "ab"));
let trailing = glob_to_regex("a{x,}b", false);
assert!(match_regex(&trailing, "axb"));
assert!(match_regex(&trailing, "ab"));
}
#[test]
fn glob_unbalanced_brace_compiles() {
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(IndentSize::Width(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();
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(IndentSize::Width(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_indent_size_tab_uses_tab_width_without_indent_style() {
let mut cfg = FmtOptionsConfig::default();
let props = EditorConfigProperties {
indent_size: Some(IndentSize::UseTabWidth),
tab_width: Some(4),
..Default::default()
};
props.apply_to(&mut cfg);
assert_eq!(cfg.indent_width, Some(4));
}
#[test]
fn parses_indent_size_tab() {
let f = parse_str("[*]\nindent_size = tab\ntab_width = 4\n");
let p = &f.sections[0].properties;
assert_eq!(p.indent_size, Some(IndentSize::UseTabWidth));
assert_eq!(p.tab_width, Some(4));
}
#[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));
}
}