Auto-indent engine gaps: braceless TS/JS bodies and live keyword dedent - #2949
Merged
Conversation
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
force-pushed
the
claude/fix-indent-engine-gaps
branch
from
August 10, 2026 15:30
f448c60 to
ce2a8cc
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.scmcaptures)as@dedent. A braceless head ends in a@dedenttoken, so the "line ends with a closing token → keep level" branch wins and the regex rules tier — whoseindent_next_linepattern 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 againstindent_next_line); prefer its deeper body indent when it applies. Languages without anindent_next_linepattern (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 customdecrease_indent_patterntoken (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 + adecrease_indent_patterntrigger 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
decreaseIndentPatternworks. The built-in Python-family pattern therefore requires the statement-final colon, mirroring ms-python's^\s*(elif\s.*|else\s*|except.*|finally\s*)::so:
else:,elif x > 0:,except ValueError:,finally:dedent on the:keystroke — the moment the statement is unambiguous;elsedoes not move the line;elsewhere,else_value) can never fire, at any point while typing;case _:typed as the first body line undermatch 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-downelse: …tail.The remaining engine safeguards are unchanged:
docs/configurationnow documents this for custom patterns:decrease_indent_patternis 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 indent— 145 passed, 0 failed (unit tests for the rules tier, including the updatedpython_decrease_consumes_line_only_for_pure_trigger, the new Enter-timepython_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 realInsertCharaction: all passed, coveringtest_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.decreasepattern reverted to the old bare-keyword form,python_decrease_consumes_line_only_for_pure_triggerand 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.generate_schema) so the committedconfig-schema.jsonmatches the updated doc comment; the schema-related unit tests pass.if a:⏎x = 1⏎ typingelseleaves the line at the body indent, the following:dedents it to column 1; typingelsewhere = 3on an indented line never dedents.cargo test -p fresh-editor --libsuite andcargo 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
-- indentsuite were not re-run after this final commit and after the rebase onto currentmaster(build capacity was constrained); CI covers both.🤖 Generated with Claude Code
https://claude.ai/code/session_01D6SkGXXTcJsytTjF1TBf8Y
Generated by Claude Code