🔎 How I made an EditorConfig linter 20x faster for fun (and no profit)
I made an EditorConfig linter that went from 1.8s to 91ms on my M1 Max Linux setup when checking Home Assistant with a mock config — about a 20x speedup. This post goes through some of the techniques I used to make it faster, starting from a naive byte-oriented implementation and ending with a highly optimized one that can process tens of millions of lines of code per second.
TL;DR: It’s called edcfg-lint and you should give it a try!
Synopsis
Why?
It’s simple: consistent code style is important, and while EditorConfig linters will never be able to beat language-specific tooling (such as oxfmt, ruff, cargo fmt, or go fmt), it is still worth doing as not everyone wants to introduce lots of linting tools, even in an era where pre-commit exists, and it is also useful for formats where no specific linter exists yet.
Granted, EditorConfig is primarily intended to provide hints to an IDE about how specific files should be formatted. But it is perfectly valid to lint files against an EditorConfig file.
A survey of the existing solution
The incumbent solution I am comparing against is editorconfig-checker. It is written in Go, and it’s a great project. The problem is that its implementation relies heavily on regexes, specifically, Go’s regex package. Some of the regexes it uses are compiled dynamically as well. However, EditorConfig linting lends itself well to a byte-based approach, which can be accelerated drastically through the use of SIMD instructions, implemented in crates such as memchr.
Why Rust?
Why not?
More seriously: this is a problem that is a near-ideal fit for Rust: we want the fearless concurrency, the byte scanning benefits from being closer to the metal, and our global state is well-bound.
There is a reason why I name-dropped oxfmt and ruff: developer tooling written in Rust has been having a moment for the past few years. Rust is ideal for tools like these: the problem these tools solve is often embarrassingly parallel with only a minimal amount of global state that has to be shared. Rust gives you the tooling to write these tools much more safely and more ergonomically than before.
I think we’re going to continue to see more developer tooling migrate over to systems languages: Microsoft is rewriting the TypeScript compiler in Go, Astral has released a very fast Python type checker, and these tools are continuing to gain wide adoption across their ecosystems. I do not think this trend is reversing any time soon.
Also, I want to keep working with Rust. I have a Java background and my day job is Python.
Building the tool
The EditorConfig core
Luckily, I did not need to dig very hard to find a library that implements the EditorConfig core (essentially, parsing the EditorConfig files that apply to a given file). They officially recommend using ec4rs. I felt confident in using it, as Zed also uses it for its EditorConfig support (but here, it uses it primarily to inform the editor’s own settings).
The linter
For the actual linting, I chose to start with the basics. I primarily utilized ec4rs (for obtaining properties that apply to a given file) and memchr.
First, we need to be able to know how deeply indented the current line is:
fn line_space_width(line: &str, properties: &Properties) -> usize {
let TabWidth::Value(tab_width) = properties.get::<TabWidth>().unwrap_or(TabWidth::Value(4));
// skip over consecutive runs of `tabs_or_spaces` positions, on the theory the first non-whitespace/tab character will
// occur after a run of positions
line.bytes()
.take_while(|&b| b == b' ' || b == b'\t')
.map(|b| if b == b'\t' { tab_width } else { 1 })
.sum()
}
Likewise we need to be able to obtain the index at which spaces and tabs stop:
fn first_non_whitespace_or_tab_pos(line: &str) -> Option<usize> {
line.bytes().position(|b| b != b' ' && b != b'\t')
}
Next, we do indent style checks on each line. Notice how idiomatic the code reads:
let indent_style = properties
.get::<IndentStyle>()
.unwrap_or(IndentStyle::Spaces);
let leading_whitespace_or_tabs_str = first_non_whitespace_or_tab_pos(cur_line)
.map(|pos| &cur_line[0..pos])
.unwrap_or(cur_line);
let spaces = memchr_iter(b' ', leading_whitespace_or_tabs_str.as_bytes()).count();
let tabs = memchr_iter(b'\t', leading_whitespace_or_tabs_str.as_bytes()).count();
let (desired_tabs, desired_spaces) = match indent_style {
IndentStyle::Spaces => (0, cur_line_width),
IndentStyle::Tabs => (
cur_line_width.div_euclid(tab_width),
cur_line_width.rem_euclid(tab_width),
),
};
if desired_tabs != tabs || spaces != desired_spaces {
errors.push(CheckError::WrongIndentStyle {
line: cur_line_num,
expected: indent_style,
expected_tabs: desired_tabs,
expected_spaces: desired_spaces,
actual_spaces: spaces,
actual_tabs: tabs,
});
}
Checking for trailing whitespace on each line is also trivial:
let TrimTrailingWs::Value(trim_trailing_ws) = properties
.get::<TrimTrailingWs>()
.unwrap_or(TrimTrailingWs::Value(true));
if trim_trailing_ws
&& let Some(last_char) = cur_line.chars().next_back()
&& (last_char == ' ' || last_char == '\t')
{
errors.push(CheckError::TrailingWhitespace { line: cur_line_num });
}
Next, maximum line length:
if let MaxLineLen::Value(max_line_len) =
properties.get::<MaxLineLen>().unwrap_or(MaxLineLen::Off)
{
let line_len = cur_line.chars().count();
if line_len > max_line_len {
errors.push(CheckError::LineTooLong {
line: cur_line_num,
actual_length: line_len,
max_length: max_line_len,
});
}
}
Finally, line endings:
let line_ending_mode = properties.get::<EndOfLine>().unwrap_or(EndOfLine::Lf);
let desired_le = match line_ending_mode {
EndOfLine::Cr => "\r",
EndOfLine::Lf => "\n",
EndOfLine::CrLf => "\r\n",
};
let desired_endings =
memchr::memmem::find_iter(contents.as_bytes(), desired_le.as_bytes()).count();
let crs = memchr_iter(b'\r', contents.as_bytes()).count();
let lfs = memchr_iter(b'\n', contents.as_bytes()).count();
let line_endings_match = match line_ending_mode {
EndOfLine::Cr => crs == desired_endings && lfs == 0,
EndOfLine::Lf => crs == 0 && lfs == desired_endings,
EndOfLine::CrLf => crs == desired_endings && lfs == desired_endings,
};
if !line_endings_match {
errors.push(CheckError::WrongLineEnding {
expected: desired_le.escape_unicode().to_string(),
});
}
if let Some(FinalNewline::Value(final_newline)) = properties.get::<FinalNewline>().ok()
&& final_newline
{
let desired_le_len = desired_le.len();
if contents.len() < desired_le_len || !contents.ends_with(desired_le) {
errors.push(CheckError::MissingFinalNewline);
}
}
The harness
This is where I take a bit of a diversion to talk about the elephant in the room: vibe-coding. I will not lie: LLMs crossed a valley from “useful for small tasks” in 2024 to “can do serious work” in 2025.
This unlocks a bunch of interesting approaches. For instance, test-driven development is almost ideal for LLMs — you write tests to verify behavior, and a coding agent can then be given the tests and be told “write the logic that makes these tests pass”. I personally like using LLMs to generate boilerplate logic around code I’ve already written. I pulled in the ignore crate for file-walking, and told Claude to generate the harness logic. Claude generated the initial harness logic for me and let me get to trying my tool out. This made iteration fast.
My initial version of eddy simply provided a “pass or fail” for each file. This was great for speed, but a linter that only says “yes or no” is not particularly useful! The next step was to start accumulating errors as structured enums and report them. I also used Claude Code to generate this logic.
Finally, I added support (borrowed from editorconfig-checker) to skip certain MIME types. This I did by hand. This was good enough to start out with, and I now had a tool that was “good enough” to try on a larger codebase. All I had to do now was give it a try.
Optimizing the tool
First, we should pick a codebase sufficiently large enough to give us results worth looking at. I chose to use a large, open source codebase: Home Assistant. With over 3.7 million lines of code, mostly Python, it’s large enough to give a lot of tools a serious stress test. It doesn’t have an .editorconfig, so I created a mock one with Claude Code.
Next, we need some test environments. I selected four that would simulate common developer workflows:
- M1 Max (Linux): 2021 MacBook Pro running Fedora Asahi Remix 43, 10 cores, 64GB RAM, NVMe/btrfs. Think of this as a stand-in for a high-end desktop.
- M1 Max (macOS): Same hardware running macOS Tahoe with an APFS file system.
- Cloud 4-vCPU: DigitalOcean droplet with 4 dedicated vCPUs of an Intel Xeon Platinum 8358 (Ice Lake), 16GB RAM, ext4/NVMe. This was intended to mirror a setup such as a GitHub Actions standard runner.
- Cloud 1-vCPU: DigitalOcean droplet with 1 shared Intel vCPU (Broadwell), 1GB RAM, ext4/SSD. This simulates an extremely constrained setup where I/O and CPU would both be fairly expensive.
Obtaining a baseline
Let’s try it!
| Platform | Time | User CPU | System CPU |
|---|---|---|---|
| M1 Max (Linux) | 1.813s ± 0.020s | 1.555s | 0.234s |
| M1 Max (macOS) | 2.642s ± 0.088s | 1.510s | 1.130s |
| Cloud 4-vCPU | 2.333s ± 0.021s | 1.924s | 0.409s |
| Cloud 1-vCPU | 5.534s ± 0.195s | 4.764s | 0.745s |
That is pretty good for a first try, but this is pretty darn slow. The single-core performance of the M1 Max is great but it cannot carry us on larger code bases.
Parallelization
Twenty years ago, we probably would have not bothered with this step. Multiple-CPU machines were primarily the domain of workstations and servers, and multi-core functionality had only just started to trickle down into the mainstream.
In 2026, you can buy a laptop with an 18-core CPU with excellent performance (excusing, of course, high memory prices brought on by the AI build-out).
Our initial harness was a single-threaded linter. My M1 Max has 10 cores, so we are leaving a lot of raw power on the table. All we’d need to do is use WalkBuilder::build_parallel and use std::sync::mpsc::channel to communicate results with the main thread.
What did that get us?
| Platform | Time | Speedup | User CPU | System CPU |
|---|---|---|---|---|
| M1 Max (Linux) | 325.9ms ± 22.2ms | 5.6x | 1.729s | 0.388s |
| M1 Max (macOS) | 787.7ms ± 27.9ms | 3.4x | 2.042s | 4.039s |
| Cloud 4-vCPU | 1.090s ± 0.003s | 2.1x | 3.321s | 0.662s |
| Cloud 1-vCPU | 6.003s ± 0.240s | 0.92x ⚠️ | 5.191s | 0.783s |
User and system time go up modestly, but since we’re now utilizing all of the cores on multicore systems, wall time improves dramatically. The M1 Max with 10 cores sees a 5.6x speedup on Linux. The higher system time on macOS suggests higher overhead for parallel I/O operations.
The single vCPU system got slower — an 8.5% regression due to parallelization overhead without any cores to actually parallelize across (though this could be affected by noisy neighbors). Parallelization isn’t free, but it usually is free enough.
Skipping binary files more efficiently
It’s not useful for us to lint binary files. I initially used the infer crate for this, similar to editorconfig-checker doing its own MIME type sniffing. However, the actual problem we want to solve is “we don’t want to look at binary files”, because a lint check on them would be functionally useless.
I decided to use the same check Git does: read the first 8,000 bytes and check if any null byte is present (which the memchr crate can do efficiently). This was a straightforward optimization to make.
Did it pay off?
| Platform | Time | Speedup | Overall | User CPU | System CPU |
|---|---|---|---|---|---|
| M1 Max (Linux) | 186.1ms ± 6.1ms | 1.8x | 9.7x | 1.345s | 0.305s |
| M1 Max (macOS) | 634.2ms ± 33.9ms | 1.2x | 4.2x | 1.671s | 4.092s |
| Cloud 4-vCPU | 763.4ms ± 3.9ms | 1.4x | 3.1x | 2.525s | 0.497s |
| Cloud 1-vCPU | 4.385s ± 0.178s | 1.4x | 1.3x | 3.768s | 0.605s |
Indeed. The Git heuristic is good enough and it is faster, too. The speedup is most drastic where we are not I/O-bound, so macOS shows a muted impact.
Caching parsed .editorconfig files
I did much of the initial development on macOS. As I was working on the app, I profiled the application to understand where we were spending time. The profile showed that open()ing potential .editorconfig files was a particularly hot code path, coming from ec4rs::file::ConfigFiles::open. Here’s the function:
pub fn open(
path: impl AsRef<Path>,
config_path_override: Option<impl AsRef<std::path::Path>>,
) -> Result<ConfigFiles, Error> {
use std::borrow::Cow;
let filename = config_path_override
.as_ref()
.map_or_else(|| ".editorconfig".as_ref(), |f| f.as_ref());
Ok(ConfigFiles(if filename.is_relative() {
let mut abs_path = Cow::from(path.as_ref());
if abs_path.is_relative() {
abs_path = std::env::current_dir()
.map_err(Error::InvalidCwd)?
.join(&path)
.into()
}
let mut path = abs_path.as_ref();
let mut vec = Vec::new();
while let Some(dir) = path.parent() {
if let Ok(file) = ConfigFile::open(dir.join(filename)) {
let should_break = file.reader.is_root;
vec.push(file);
if should_break {
break;
}
}
path = dir;
}
vec
} else {
// TODO: Better errors.
vec![ConfigFile::open(filename).map_err(Error::Parse)?]
}))
}
Given that this is a linter that does not run persistently, we can make a reasonable assumption that the .editorconfig won’t change from underneath us, and caching parsed .editorconfig files would be profitable. In fact, a Microsoft engineer correctly identified this as an issue, though the upstream issue hasn’t been resolved yet. No worries, we can solve it downstream.
This was really instructive as this taught me a bit about Rust’s approach to concurrent data access and how to make the borrow checker work for me.
First, we should note that ec4rs’s EditorConfig parsers wrap a stream and obtaining sections from the parser consumes the stream from the .editorconfig, so we need to eagerly parse everything first. This isn’t hard, but the terminology used in the core library was a little hard to grok at first:
/// An eagerly-parsed version of `ec4rs::ConfigParser`. This is done primarily to improve
/// performance.
struct EagerlyParsedEditorConfig {
is_root: bool,
sections: Vec<Section>,
}
impl EagerlyParsedEditorConfig {
/// Eagerly parses the configuration from the given `parser`, consuming it in the process.
pub fn from_config_parser<R: io::BufRead>(
parser: &mut ConfigParser<R>,
) -> Result<EagerlyParsedEditorConfig, Error> {
let is_root = parser.is_root;
let mut sections = vec![];
for result in parser {
if let Ok(section) = result {
sections.push(section);
} else if let Err(e) = result {
return Err(Error::Parse(e));
}
}
Ok(EagerlyParsedEditorConfig { is_root, sections })
}
/// Eagerly parses the configuration from the given `cfg`, consuming its enclosed reader in the process.
pub fn from_config_file(cfg: &mut ConfigFile) -> Result<EagerlyParsedEditorConfig, Error> {
EagerlyParsedEditorConfig::from_config_parser(&mut cfg.reader)
}
}
impl PropertiesSource for &EagerlyParsedEditorConfig {
fn apply_to(
self,
props: &mut Properties,
path: impl AsRef<std::path::Path>,
) -> Result<(), Error> {
let path = path.as_ref();
for section in self.sections.iter() {
let _ = section.apply_to(props, path);
}
Ok(())
}
}
Now that we can eagerly parse .editorconfig sections, how do we cache the data? Rust gives you many options. I went with the first reasonable option: a HashMap guarded by a RwLock. This synchronization worked fine for lower core counts, but eventually failed once you went above 4 cores. I also tried parking_lot and it scaled slightly better, capping out at about 6 cores.
At that point, I switched to a sharded concurrent hash table implementation, dashmap. This resolved the scaling issue. We accept a bit of inefficiency (we might parse .editorconfigs more than once) but the reduction in I/O time makes it worth it.
In the end, the cache we use is a statically-initialized DashMap<PathBuf, Option<Arc<EagerlyParsedEditorConfig>>>:
DashMap<PathBuf, ...>for a sharded concurrent hash table mapping paths to a cache resultOption<...>indicates that we are caching both positive and negative hits (caching both is safe and not caching negative hits has a non-negligible cost!)Arc<...>means that we intend to share the contained value across callers — giving us a cheapClone(an atomic reference count increment/decrement)EagerlyParsedEditorConfigis our eagerly parsed.editorconfigtype
OK, after implementing that, how is the performance?
| Platform | Time | Speedup | Overall | User CPU | System CPU |
|---|---|---|---|---|---|
| M1 Max (Linux) | 162.3ms ± 5.1ms | 1.1x | 11.2x | 1.279s | 0.132s |
| M1 Max (macOS) | 442.6ms ± 4.8ms | 1.4x | 6.0x | 1.451s | 2.547s |
| Cloud 4-vCPU | 659.3ms ± 3.6ms | 1.2x | 3.5x | 2.329s | 0.277s |
| Cloud 1-vCPU | 3.932s ± 0.145s | 1.1x | 1.4x | 3.541s | 0.375s |
The performance uplift is present but muted on Linux (since I/O is vastly cheaper there), but it’s on the macOS side where things get interesting—system time on macOS drops from 4.092s to 2.547s, since we are no longer spending as much time trying to find .editorconfigs that likely don’t exist.
Amortizing per-line property fetches
There was one last unplucked optimization opportunity. We looked up 4 properties for every line we check. This is documented to be an O(log n) operation in ec4rs (specifically, the operation in question is a binary search). When we’re doing that millions of times over an entire code base, even O(log n) time complexity can really add up.
I already noticed this problem and told Claude to tackle it. It did the same solution I would have: fetch the four properties when checking the file, store them in a struct, and pass a reference to it for each line. In this way, we amortize each O(log n) property fetch to O(1).
Does this help?
| Platform | Time | Speedup | Overall | User CPU | System CPU |
|---|---|---|---|---|---|
| M1 Max (Linux) | 91.6ms ± 5.8ms | 1.8x | 19.8x | 0.606s (2.6x↓) | 0.142s |
| M1 Max (macOS) | 435.6ms ± 61.8ms | 1.0x | 6.1x | 0.808s (1.9x↓) | 3.074s |
| Cloud 4-vCPU | 334.2ms ± 4.5ms | 2.0x | 7.0x | 1.017s (1.9x↓) | 0.283s |
| Cloud 1-vCPU | 1.707s ± 0.128s | 2.3x | 3.2x | 1.364s (3.5x↓) | 0.337s |
Interesting! User CPU time drops dramatically across all platforms, confirming the algorithmic win. On Linux, we remain CPU-bound, so wall time improves. However, we are well past I/O-bound on macOS, and so the CPU savings are masked by waiting for macOS to return data from the disk.
The biggest win is on the most constrained and slowest system, the 1-vCPU instance that has to fight for shreds of time on a Broadwell hyperthread. It is not clear why that system improved so drastically. Some theories:
- Broadwell has a far less advanced branch predictor than Ice Lake and Firestorm. Perhaps the binary search didn’t help matters.
- Broadwell uses an older memory standard, DDR3. The M1 Max has DRAM soldered to the chip, the Ice Lake chip uses DDR4.
- We might have been suffering from CPU steal.
Putting it together
| Platform | Start | End | Speedup | User CPU (Start → End) | System CPU (Start → End) |
|---|---|---|---|---|---|
| M1 Max (Linux) | 1.813s ± 0.020s | 91.6ms ± 5.8ms | 19.8x | 1.555s → 0.606s (2.6x↓) | 0.234s → 0.142s (1.6x↓) |
| M1 Max (macOS) | 2.642s ± 0.088s | 435.6ms ± 61.8ms | 6.1x | 1.510s → 0.808s (1.9x↓) | 1.130s → 3.074s (2.7x↑) |
| Cloud 4-vCPU | 2.333s ± 0.021s | 334.2ms ± 4.5ms | 7.0x | 1.924s → 1.017s (1.9x↓) | 0.409s → 0.283s (1.4x↓) |
| Cloud 1-vCPU | 5.534s ± 0.195s | 1.707s ± 0.128s | 3.2x | 4.764s → 1.364s (3.5x↓) | 0.745s → 0.337s (2.2x↓) |
Let’s start with M1 Max: the largest gains came when running on Asahi Linux — everything we did benefited performance immensely since the I/O and syscall costs were the lowest to begin with, and we kept finding ways to play to the hardware’s strengths. Meanwhile, we approached being I/O-bound on macOS on the same hardware. The 4.76x speedup of using Linux on the same hardware is fairly insane.
As for our cloud instances: the 1 vCPU instance claims the prize for “most improved”, but primarily because we crippled it by forcing it to start from a low base. The 4 vCPU instance is much more representative of a typical CI runner and saw major gains.
Conclusion
EditorConfig linting can’t replace language-specific formatters, but there exists a niche for this: repositories with many file types, lightweight CI checks, projects that already have an .editorconfig, and formats where no dedicated formatter exists.
More than anything, edcfg-lint was a useful excuse to build a small Rust tool, profile it, and make it substantially faster. That is a good kind of side project: constrained, measurable, and just useful enough to justify existing.