Skip to content

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

Open
fibibot wants to merge 7 commits into
mainfrom
orch/issue-77
Open

feat(fmt): infer config from .editorconfig#34071
fibibot wants to merge 7 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.)

// 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.

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.

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