Skip to content

Auto-indent engine gaps: braceless TS/JS bodies and live keyword dedent - #2949

Merged
sinelaw merged 3 commits into
masterfrom
claude/fix-indent-engine-gaps
Aug 10, 2026
Merged

Auto-indent engine gaps: braceless TS/JS bodies and live keyword dedent#2949
sinelaw merged 3 commits into
masterfrom
claude/fix-indent-engine-gaps

Conversation

@sinelaw

@sinelaw sinelaw commented Aug 10, 2026

Copy link
Copy Markdown
Owner

Fixes #2492
Fixes #2582

Two related gaps in the auto-indent engine, plus a follow-up commit tightening the live-dedent trigger data.

#2492 — braceless control-flow bodies not indented in TS/JS

if (a) + Enter left the body at column 0 in TypeScript/JavaScript while C (and every rules-tier curly language) indented it.

Root cause: languages with a bundled tree-sitter grammar route through the AST indent tier, whose JS/TS indents.scm captures ) as @dedent. A braceless head ends in a @dedent token, so the "line ends with a closing token → keep level" branch wins and the regex rules tier — whose indent_next_line pattern already handles braceless heads — is never consulted.

Fix (contained fallback): after the tree-sitter tier produces an indent, consult the language's rules for a braceless head (IndentRules::braceless_head_body_indent, matched against indent_next_line); prefer its deeper body indent when it applies. Languages without an indent_next_line pattern (Go in practice, JSON, Templ, and everything off the tree-sitter path — including C) are untouched.

#2582 — typing a dedent trigger does not re-indent the line

Typing else: in a Python body, or a custom decrease_indent_pattern token (CLOSE, end), left the line over-indented and the error compounded on the next Enter; only } had electric dedent.

Fix: the electric-} path is extended to keyword triggers. When a typed character turns the line into exactly leading whitespace + a decrease_indent_pattern trigger and the cursor is at end of line, the line re-indents one level shallower than the previous non-blank line (VS Code / Sublime / Vim behavior).

Trigger contract

The engine's rule is deliberately simple and unchanged from the first version: on each keystroke the line is matched against decrease_indent_pattern, and the dedent fires at the keystroke where the pattern first consumes the whole line (leading whitespace included, trailing whitespace tolerated). There is no restore-on-unmatch, no separate terminator config, and no reindent when Enter leaves the line.

Which keystroke that is, is decided by the pattern — which is exactly how VS Code's decreaseIndentPattern works. The built-in Python-family pattern therefore requires the statement-final colon, mirroring ms-python's ^\s*(elif\s.*|else\s*|except.*|finally\s*)::

^\s*(elif\s.*|else\s*|except.*|finally\s*|case\s.*):\s*

so:

  • else:, elif x > 0:, except ValueError:, finally: dedent on the : keystroke — the moment the statement is unambiguous;
  • a bare else does not move the line;
  • an identifier that merely begins with a keyword (elsewhere, else_value) can never fire, at any point while typing;
  • case _: typed as the first body line under match x: still stays inside its block.

Only the Python family has a syntactic statement terminator, so only its pattern changed. The keyword-delimited families (Ruby/Lua/Bash/Fish/Pascal end, fi, until, …) have no terminator to include and are left as they are — VS Code-land behaves the same way there. The pattern is not end-anchored, so the pre-existing Enter-time (line-split) dedent path still recognizes a moved-down else: … tail.

The remaining engine safeguards are unchanged:

  • fires only with the cursor at end of line, and only at the first whole-line match, so later keystrokes never re-fire and mid-line occurrences never fire;
  • only ever dedents; a line the user already dedented at/below the target is left alone;
  • skipped when the previous non-blank line opens a block, so a block's first body line is never pulled out of its block;
  • prefix scan capped at 128 bytes (O(1) per keystroke on long lines) and scope-masked, so triggers inside strings/comments are ignored.

docs/configuration now documents this for custom patterns: decrease_indent_pattern is matched against the whole line on every keystroke, so include the terminator in the pattern when the language has one (^\s*end$ rather than ^\s*end\b).

Tests

Verified in this environment (run and observed):

  • cargo test -p fresh-editor --lib indent145 passed, 0 failed (unit tests for the rules tier, including the updated python_decrease_consumes_line_only_for_pure_trigger, the new Enter-time python_moved_down_else_dedents_on_enter, and the colon-based on-type target tests).
  • cargo test -p fresh-editor --lib test_typing — action-level tests through the real InsertChar action: all passed, covering test_typing_else_colon_dedents_python_line, test_typing_elif_condition_dedents_at_colon, test_typing_elsewhere_never_dedents, test_typing_else_mid_line_does_not_reindent, test_typing_case_directly_under_match_keeps_indent, test_typing_else_on_manually_dedented_line_is_noop.
  • Fails-without-fix check performed: with the Python decrease pattern reverted to the old bare-keyword form, python_decrease_consumes_line_only_for_pure_trigger and 3 of the action-level typing tests fail; restoring the colon pattern makes them pass.
  • cargo clippy -p fresh-editor --lib — no new warnings from the touched code.
  • cargo fmt — clean.
  • Config-schema regenerated (generate_schema) so the committed config-schema.json matches the updated doc comment; the schema-related unit tests pass.
  • Manual tmux validation with a freshly built debug binary (Python file): if a:x = 1 ⏎ typing else leaves the line at the body indent, the following : dedents it to column 1; typing elsewhere = 3 on an indented line never dedents.
  • Earlier (pre-follow-up-commit) run of the full cargo test -p fresh-editor --lib suite and cargo test -p fresh-editor --test e2e_tests -- indent (115 passed) covered the first two commits.

Not re-verified in this environment: the full lib suite and the e2e -- indent suite were not re-run after this final commit and after the rebase onto current master (build capacity was constrained); CI covers both.

🤖 Generated with Claude Code

https://claude.ai/code/session_01D6SkGXXTcJsytTjF1TBf8Y


Generated by Claude Code

claude added 3 commits August 10, 2026 15:25
Typing `if (a)` + Enter in a TypeScript or JavaScript buffer left the
body line at the head's own column, while the same edit in C (and every
other curly-brace language on the regex rules tier) correctly indented
one level (issue #2492).

Root cause: languages with a bundled tree-sitter grammar route Enter
indentation through the AST tier, whose JS/TS `indents.scm` captures `)`
as `@dedent`. A braceless head therefore ends in a @dedent token and the
calculator's "line ends with a closing token → keep level" branch wins,
so the rules tier's `indent_next_line` knowledge (braceless `if`/`for`/
`while` heads, bare `else`) is never consulted.

Fix: after the tree-sitter tier produces an indent, ask the language's
regex rules whether the line being split is a braceless control head
(new `IndentRules::braceless_head_body_indent`); when the head dictates
a deeper body indent, prefer it. Languages without an
`indent_next_line` pattern (or without rules) are untouched, so C and
the other rules-tier languages behave exactly as before.

Reproducer tests (fail without the fix): braceless `if`/`while`/`for`
bodies indent in TS and JS, including nested inside a function; braced
heads and plain statements ending in `)` are pinned unchanged.

Fixes #2492

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D6SkGXXTcJsytTjF1TBf8Y
Typing `else:` in a Python body, or a custom `decrease_indent_pattern`
token (`CLOSE`, `end`, …), left the line at the inherited body indent —
dedent rules only ran when Enter moved text down, so the mis-indent
stuck and then compounded on the next Enter. Only `}`-style closing
brackets had live "electric" dedent treatment (issue #2582).

Fix: extend the electric-`}` path to keyword triggers. When a typed
character turns the line into exactly "leading whitespace + a
`decrease_indent_pattern` trigger" and the cursor is at end of line,
re-indent the line one level shallower than the previous non-blank
line, matching VS Code / Sublime / Vim behavior and the electric `}`.

Deliberately conservative:
- fires only at the keystroke where the trigger regex first consumes
  the whole line, so it cannot re-fire on later keystrokes and never
  fires for a trigger appearing mid-line;
- only ever dedents — a manually dedented line is left alone;
- skipped when the previous non-blank line opens a block, so a block's
  first body line (e.g. Python `case` right under `match x:`) is never
  pulled out of its block;
- prefix scan is capped, keeping the per-keystroke cost O(1) even on
  long lines, and the line content is scope-masked so triggers inside
  strings/comments are ignored.

Reproducer test (fails without the fix): typing the final `e` of
`else` under a Python `if` body re-indents the line to the `if`'s
column through the real InsertChar action; controls pin the no-fire
cases (mid-line trigger, first body line, already-dedented line).

Fixes #2582

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D6SkGXXTcJsytTjF1TBf8Y
The live keyword dedent fires at the keystroke where the whole line
first matches `decrease_indent_pattern`. With the Python family's
pattern matching the bare keyword (`^\s*(elif|else|except|finally|
case)\b`), that keystroke was the one completing the *word*: typing
`else` dedented immediately, and an identifier that merely starts with a
trigger word — `elsewhere`, `else_value` — dedented transiently as it
was typed.

Fix the data, not the engine: require the statement-final colon, exactly
as ms-python's `decreaseIndentPattern` does
(`^\s*(elif\s.*|else\s*|except.*|finally\s*):`). `else:`, `elif x > 0:`,
`except ValueError:` and `finally:` now dedent on the `:` keystroke — the
moment the statement is unambiguous — while a bare `else` stays put and
`elsewhere` can never match. The trailing `\s*` keeps the whole-line
match tolerant of trailing whitespace, and the pattern is not
end-anchored, so the pre-existing Enter-time (line-split) dedent path
still recognizes a moved-down `else: ...` tail.

Only the Python family carries a syntactic statement terminator, so only
its pattern changes. The keyword-delimited families (Ruby/Lua/Bash/Fish/
Pascal `end`, `fi`, `until`, …) have no terminator to include and are
left as they are — VS Code-land behaves the same way there.

Documented the semantics for custom patterns: `decrease_indent_pattern`
is matched against the whole line on every keystroke, so a pattern should
include the terminator (`^\s*end$` rather than `^\s*end\b`) when the
language has one.

Tests updated to the new contract and verified to fail against the old
bare-keyword pattern: `    else:` fires, `    else` does not, `elsewhere`
and `else_value` never fire, `elif x > 0:` fires at the colon, and
`case _:` under `match x:` still stays inside its block.

Refs #2582

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D6SkGXXTcJsytTjF1TBf8Y
@sinelaw
sinelaw force-pushed the claude/fix-indent-engine-gaps branch from f448c60 to ce2a8cc Compare August 10, 2026 15:30
@sinelaw
sinelaw merged commit d4ff6c8 into master Aug 10, 2026
10 checks passed
@sinelaw
sinelaw deleted the claude/fix-indent-engine-gaps branch August 10, 2026 17:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants