Skip to content

feat(fmt): infer config from .editorconfig#34071

Merged
bartlomieju merged 13 commits into
mainfrom
orch/issue-77
Jun 23, 2026
Merged

feat(fmt): infer config from .editorconfig#34071
bartlomieju merged 13 commits into
mainfrom
orch/issue-77

Conversation

@fibibot

@fibibot fibibot commented May 14, 2026

Copy link
Copy Markdown
Contributor

Summary

deno fmt now reads .editorconfig files and uses their settings to fill
in fmt config fields that aren't otherwise set. This is the long-standing
ask in #14717.

Precedence (highest to lowest):

  1. CLI flags (--indent-width, --use-tabs, ...)
  2. deno.json fmt block
  3. .editorconfig
  4. Built-in defaults

So .editorconfig only fills in fields the user hasn't already
configured. Mappings:

.editorconfig Deno fmt
indent_style useTabs
indent_size indentWidth
tab_width indentWidth (fallback when indent_style = tab and indent_size is unset)
max_line_length lineWidth (ignored when off)
end_of_line newLineKind (lf/crlf)

.editorconfig resolution walks up from the file being formatted,
parsing each file it finds, and stops at the first one with root = true
(or at the filesystem root). Sections farther from the file are applied
first so nearer files override them — matching the editorconfig spec.

A small glob-to-regex translator handles the section header patterns:
*, **, **/, ?, [abc], [!abc], {a,b,c}, and {n..m}. The
**/foo.ts form is treated as matching foo.ts at any depth including
the root, matching gitignore-style user expectations.

Parsed .editorconfig files are cached per-fmt-run via
EditorConfigCache, so repeated lookups within a batch do not re-read or
re-parse them. When a file is found, fmt logs Found .editorconfig at <path> and using it at debug level (visible with -L debug), once per
discovered file.

Incremental cache

The fmt incremental cache is keyed on file content plus the batch-level
fmt options. Because .editorconfig resolves per-file options that the
batch-level key does not capture, those resolved options are folded into
the cached hash for each file. Editing an .editorconfig value therefore
invalidates the cached "already formatted" result even when the file body
is unchanged, so a subsequent --check re-evaluates the file rather than
returning a stale pass. Files not governed by any .editorconfig keep
their existing cache entries and hash the file text as-is (no extra
allocation).

Test coverage

  • cli/tools/fmt_editorconfig.rs — 16 unit tests covering the parser,
    glob translation, and apply_to/precedence behavior.
  • tests/specs/fmt/editorconfig — 7 spec tests:
    • infers_indent_size.editorconfig sets indent_size = 4 and
      fmt --check passes on a 4-space file.
    • infers_indent_size_negative--indent-width=2 on the CLI
      overrides .editorconfig, so check fails for the same file.
    • infers_use_tabsindent_style = tab in a subdirectory.
    • infers_max_line_lengthmax_line_length = 200 lets a long line
      through that would otherwise wrap.
    • deno_json_takes_precedence — explicit indentWidth: 2 in
      deno.json is not overridden by .editorconfig's indent_size = 4.
    • nested_overrides_parent — a nearer non-root .editorconfig
      overrides a farther root = true one (exercises the walk-up and
      nearer-overrides-farther merge order).
    • logs_at_debug-L debug prints the "found and using it" notice.

Robustness

The .editorconfig glob translator and value parser degrade gracefully
on malformed or adversarial input rather than crashing:

  • Numeric range {n..m} expansion is bounded, so a pathological span
    such as {1..1000000000} does not build a giant regex; oversized
    ranges fall back to a literal that simply will not match.
  • Brace-nesting recursion is depth-capped, so deeply nested alternations
    like {a,{a,{a,...}}} cannot overflow the stack.
  • indent_size / tab_width / max_line_length parse with saturating
    integer conversion, clamping out-of-range values instead of silently
    dropping them.
  • Literal brace/bracket characters are escaped so unbalanced or degraded
    patterns still compile to a valid regex (a section that fails to
    compile is simply inert).

Notes

  • .editorconfig is currently always read when present. Prettier matches
    this behavior; if maintainers prefer an opt-in (e.g. a fmt config flag
    or an unstable gate) I'll add it.

Closes bartlomieju/orchid-inbox#77
Closes #14717

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
@lunadogbot lunadogbot changed the title @bartlomieju: Pick up and address the PR. feat(fmt): infer config from .editorconfig May 14, 2026
Comment thread cli/tools/fmt_editorconfig.rs Outdated
/// 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) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a lot of extra work. Probably it's going to make deno fmt much slower on large directories.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good call — pushed 029a6b7 which:

  • Pre-compiles each section's glob into a Regex once at parse time (it was being recompiled for every (file, section) pair).
  • Memoizes the resolved chain (outermost → innermost) of .editorconfig files per starting directory, so files in the same directory share one walk and one set of mutex lookups.

So for a project with no .editorconfig, the per-file cost is now one canonicalize_path + one cached chain lookup that returns an empty Arc<Vec<_>> and bails immediately. For projects with a single root .editorconfig, the only per-file work is the regex is_match calls (no re-compilation, no re-walk).

Happy to add a benchmark or a directory-tree-wide short-circuit ("any .editorconfig anywhere?" check at fmt-batch start) if you want one — let me know.

…ection

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.
@bartlomieju bartlomieju added this to the 2.9.0 milestone Jun 7, 2026
… 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.
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.
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.

@bartlomieju bartlomieju left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed the editorconfig loader and fmt integration. Solid, well-tested, and the incremental-cache folding + pathological-input hardening are genuinely careful work. Posting a few non-blocking nits inline. (Separately, worth deciding before merge: reading .editorconfig default-on is a silent behavior change for existing projects that have one but no deno.json fmt block — they'd get reformatted with no opt-out. You already flagged this in the description; just calling it out as the main product decision. CI also hasn't run here yet.)

Comment thread cli/tools/fmt_editorconfig.rs Outdated
// 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(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: per the editorconfig spec, indent_size = tab should resolve to tab_width regardless of indent_style. Here the tab_width fallback only fires when indent_style == Tab, so a section with indent_size = tab + tab_width = 4 but no indent_style falls through to the default instead of 4. Rare config, but a spec deviation.

match c {
'*' => {
if i + 1 < bytes.len() && bytes[i + 1] == '*' {
// Treat `**/` as zero or more path components so that

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: treating **/foo.ts as also matching foo.ts at the root is gitignore semantics, which deviates slightly from strict editorconfig **. It's documented and tested, so fine to keep — just worth a sanity check against the editorconfig reference test suite if exact parity matters.

Comment thread cli/tools/fmt_editorconfig.rs Outdated
out.push_str("(?:.*/)?");
}
let pattern = pattern.strip_prefix('/').unwrap_or(pattern);
let bytes: Vec<char> = pattern.chars().collect();

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: bytes holds chars, not bytes — slightly misleading name (chars would read better).

@@ -0,0 +1,48 @@
{
"tempDir": true,
"tests": {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: the incremental-cache invalidation is the subtlest logic in the PR but only covered implicitly. Consider a spec test that runs fmt --check, edits .editorconfig, and re-runs --check to assert it re-evaluates rather than returning a stale pass — that would lock in the behavior incremental_cache_text was built for.

- indent_size = tab now resolves to tab_width regardless of indent_style,
  per the editorconfig spec (previously the tab_width fallback only fired
  when indent_style = tab). Tracked via a new IndentSize enum so the
  literal "tab" value is distinguished from a numeric width.
- rename the misleading `bytes` local (it holds chars, not bytes) to
  `chars` in the glob translator.
- add a spec test that runs fmt --check, edits .editorconfig, and re-runs
  --check to lock in incremental-cache invalidation on editorconfig change.
@bartlomieju

Copy link
Copy Markdown
Member

Took a close read through fmt_editorconfig.rs — nice work, the parser/glob/precedence split is clean and the test coverage is strong. A few correctness and minor issues worth addressing:

Correctness

1. Comma-less brace groups strip the braces (spec deviation). In glob_to_regex_depth, a {...} group with no comma and no .. range falls through to the "single literal" branch, which emits the group contents without the braces:

// Single literal — fall through to literal
for ch in group.chars() {
  regex_push_escaped(&mut out, ch);
}

So {word}.ts compiles to match word.ts. The editorconfig reference implementation treats a comma-less {...} as a literal including the braces{word}.ts should match the literal string {word}.ts. Suggest keeping the braces when alts.len() <= 1 and there's no range. There's a test for unbalanced braces but none pinning this case; worth adding one.

2. **/foo.ts matching at the root is a deviation from the editorconfig reference (the code comment and PR body acknowledge this). The reference translates ** to .*, so **/foo.ts does not match foo.ts at the root there. The gitignore-style choice is defensible, but it means deno fmt will disagree with editors and other editorconfig consumers on this pattern. Flagging so it's a conscious decision rather than an accident.

3. Character-class ranges are more permissive than the spec. [a-z] passes straight through to the regex crate and works in deno, but the editorconfig spec only defines [name] / [!name] sets, not ranges. More permissive, unlikely to bite, just noting the inconsistency with other tools.

4. resolve() silently depends on an absolute file_path. The relative-path computation (strip_prefix(&entry.dir)) and the parent walk both assume it. fmt's FileCollector resolves against an absolute base so this holds in practice, but if a relative path ever reaches resolve() the walk-up stops early and parent .editorconfigs above cwd are missed with no error. A debug_assert!(file_path.is_absolute()) would make the contract enforced rather than implicit.

Minor

  • split_top_level_commas("{}") makes an empty group vanish entirely, and {a,} yields (?:a|) (matches empty). Harmless edge cases, but no coverage beyond the unbalanced-brace test.
  • The IncrementalCache DB key only captures batch-level options while the per-file editorconfig options are folded into the hashed text instead. That's correct, but the relationship is non-obvious — a one-line comment at the cache-construction site would help future readers.

@bartlomieju

Copy link
Copy Markdown
Member

On the always-on question raised in the PR description: I think we should keep reading .editorconfig by default (matches Prettier, which is the closest analog and what most users coming from #14717 expect) but add an opt-out so projects that ship a .editorconfig for editors but want deno fmt to stick to its own defaults aren't silently reformatted on upgrade.

For comparison across the ecosystem:

  • Prettier reads .editorconfig by default and opts out via --no-editorconfig / "editorconfig": false.
  • Biome is opt-in instead — off by default, enabled with formatter.useEditorconfig: true.
  • dprint (what deno fmt is built on) doesn't read .editorconfig at all, which is why this has to be implemented here directly.

Concretely, suggest:

  • a --no-editorconfig CLI flag, and
  • a fmt config field in deno.json, e.g. "fmt": { "useEditorConfig": false },

with the flag taking precedence. Default stays true. That keeps the zero-config behavior people are asking for while giving an escape hatch for the upgrade-compatibility case (a repo with .editorconfig but no fmt config suddenly changing output / failing fmt --check in CI).

Adds an escape hatch so projects that ship a .editorconfig for editors
but want `deno fmt` to keep its own defaults aren't silently reformatted:

- `--no-editorconfig` CLI flag (matches Prettier's flag name)
- `"fmt": { "useEditorConfig": false }` in deno.json
- the flag takes precedence over the config field; default stays on

Also addresses the remaining review nits in fmt_editorconfig.rs:

- a comma-less `{...}` glob group is now a literal *including* the
  braces (`{word}.ts` matches `{word}.ts`), matching the editorconfig
  reference instead of stripping the braces.
- `resolve()` asserts its `file_path` is absolute, making the
  previously-implicit contract explicit.
- document at the IncrementalCache construction site why per-file
  editorconfig options are folded into the hashed text rather than the
  DB key.
- add tests for the comma-less brace literal, `{}`/`{a,}` edge cases,
  and spec tests for both opt-out paths.
@bartlomieju
bartlomieju merged commit 969f8cd into main Jun 23, 2026
135 checks passed
@bartlomieju
bartlomieju deleted the orch/issue-77 branch June 23, 2026 11:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(fmt): infer config from .editorconfig

4 participants