Skip to content

Fix: second sweep of Rails-idiom formatting bugs#104

Merged
sorafujitani merged 1 commit intomainfrom
fix/rails-idiom-bugs-round2
Apr 24, 2026
Merged

Fix: second sweep of Rails-idiom formatting bugs#104
sorafujitani merged 1 commit intomainfrom
fix/rails-idiom-bugs-round2

Conversation

@sorafujitani
Copy link
Copy Markdown
Owner

Re-opens #103 now that #102 has landed and its base branch was deleted. This PR now targets main directly; no content changes from #103.

Summary

Seven more formatter bugs surfaced by a systematic walk through Rails-flavoured Ruby patterns that weren't covered by #102. Three of them caused the formatter to emit code that no longer parses; the rest silently reshape user style.

Bugs fixed

ID Severity Symptom Root cause
#4 / #5 Syntax break begin/rescue/else/ensure/endelse...\nensure\nensure\n...\nend; begin/rescue/else/endelse...\nend\nend Prism's ElseNode.location extends through the next clause keyword. The fallback rule's source-slice emission therefore re-prints whatever comes after — duplicating ensure or end.
#3 Syntax break foo(<<~SQL, …) if cond\n …\nSQLfoo(…) if cond\n …\nSQL if cond — the if modifier sticks to the terminator line, so Ruby never closes the heredoc. The bridge extends a heredoc-carrying statement's end_offset past the terminator, and the extended slice already contains the modifier text, but format_postfix still appended if cond after it.
#2 Comment corruption + non-idempotence def demo(a, # c1\n b, # c2\n c) # c3 duplicated the first comment, ejected the rest into the body, and each format pass duplicated them again. build_def_header rebuilds the signature from parameters_text, and later format_trailing_comment / format_leading_comments calls re-collect comments already embedded in the slice.
#9 Indent collapse Chain continuations inside a brace-lambda / do-block (scope :x, -> { where(...)\n .where(...) }) were re-indented to the outer base_indent, erasing the nested scope. reformat_chain_lines treats any .xxx line as chain continuation regardless of nesting.
#10 Blank-line insertion if (sql = <<~SQL)\n …\nSQL\n execute(sql) gained a blank line between the terminator and execute. The predicate's source slice retained the trailing \n past the heredoc terminator, which compounded with format_normal's own hardline.
#6 Style reshape x = begin\n …\nend rewritten as x =\n begin\n …\nend. format_variable_write unconditionally split block-valued assignments across two lines.
!@ Name rewrite def !@ silently rewritten to def !. extract_node_name returns Prism's canonical name (:!), discarding the @ suffix; the original slice lives on name_loc.

Before / after highlights

# #4: else + ensure no longer duplicates the keyword
begin
  risky
rescue => e
  :e
else
  :ok
ensure
  :final
end   # was: ensure\nensure...\nend\nend

# #3: heredoc + `if` modifier stays on the opener line
scope.push(<<~SQL, type: params[:type]) if params[:type]
  SELECT 1
SQL    # was: SQL if params[:type] — broke heredoc termination

# #2: multi-line def header preserves every inline comment exactly once
def demo(a, # first
         b, # second
         c) # third
  [a, b, c]
end

# #9: chain inside a brace-lambda keeps its local indent
scope :active_in, ->(period) {
  where(active: true)
    .where(last_seen_at: period)  # was: collapsed to col 4
}

# #6: inline `x = begin` stays inline
x = begin
  1
rescue
  2
end  # was: x =\n  begin\n    1\n  ...

# #10: no blank line after `if (… <<~SQL)` terminator
if (sql = <<~SQL)
  SELECT 1
SQL
  execute(sql)
end

# !@ preserves the @ suffix
def !@
  false
end

Changes

  • ext/rfmt/src/format/rule.rsreformat_chain_lines now skips lines that open a block ({, do, |).
  • ext/rfmt/src/format/rules/begin.rsformat_begin_clause emitter handles ElseNode explicitly; used from both format_explicit_begin and format_implicit_begin_body.
  • ext/rfmt/src/format/rules/body_end.rs — multi-line header detection with verbatim source emission + comment-mass-mark-emitted; shared push_body_and_end body/end emitter; line_end_offset helper.
  • ext/rfmt/src/format/rules/if_unless.rsformat_postfix emits heredoc-containing statements verbatim; format_normal strips one trailing newline from the predicate slice; new statement_contains_heredoc_tail detector.
  • ext/rfmt/src/format/rules/variable_write.rs — inline preservation when the block-valued RHS starts on the assignment's own line.
  • lib/rfmt/prism_bridge.rbDefNode name uses name_loc.slice to keep operator @ suffixes.
  • spec/rails_idioms_round2_spec.rb — 9 round-trip + idempotence + Prism parse-check tests, one per bug.

Test plan

  • cargo test --manifest-path ext/rfmt/Cargo.toml --lib — 127 passed
  • bundle exec rspec — 128 passed (119 existing + 9 new)
  • cargo clippy -- -D warnings — clean
  • cargo fmt --check — clean
  • bundle exec rubocop — clean
  • All 25 round-2 synthetic Rails-idiom fixtures pass Prism parse after formatting, and are idempotent.

Supersedes #103 (closed when its base branch was deleted alongside the #102 merge).

Follow-up to PR #102. A second pass over common Ruby/Rails patterns
(operator methods, begin/else/ensure, heredocs in unusual positions,
inline comments in headers, block-nested chains, etc.) turned up seven
additional latent bugs, three of which produced code that stopped
parsing as Ruby. This change fixes all of them in one shot.

Critical — formatter output no longer parses
- else/ensure overshoot in `begin…end` (bugs #4/#5)
  `ElseNode.location` extends through the following clause's keyword
  (`ensure`, or the begin's `end`). Emitting that slice through the
  fallback rule therefore duplicated whichever keyword came next:
  `else/ensure/end` became `else…\nensure\nensure\n…\nend`, and
  `else/end` became `else…\nend\nend`. Fixed by handling ElseNode
  explicitly in `format_explicit_begin` /
  `format_implicit_begin_body` so we emit just `else\n  body` and
  defer the following keyword to its own rule.
- heredoc argument + postfix `if`/`unless` (bug #3)
  `foo(<<~SQL, …) if cond\n  …\nSQL` used to be rewritten as
  `foo(…) if cond\n  …\nSQL if cond` — which makes Ruby treat `SQL`
  as part of the heredoc body and silently merges into the next
  heredoc, or fails to parse entirely. The bridge extends a
  heredoc-carrying statement's end_offset past the terminator, so the
  source slice already contains the modifier in the right place; emit
  the slice verbatim in `format_postfix` when it looks like a heredoc
  tail (line 1 contains `<<`, and later lines contain non-chain
  content).
- multi-line def header with inline comments (bug #2)
  `def demo(a, # c1\n  b, # c2\n  c) # c3` lost the trailing paren's
  comment, duplicated the opener's, and moved the rest into the body;
  worse, each format pass duplicated them again, so the file was
  non-idempotent. When `parameters_text` spans multiple lines, emit
  the header verbatim from source (def_start → end of the line before
  the body begins) and mark every comment in that range as emitted.

High — wrong indent, not a parse error
- chain continuations inside a block/lambda body (bug #9)
  `scope :x, -> { where(...)\n  .where(...) }` had its inner
  `.where(...)` collapsed to the outer base indent because
  `reformat_chain_lines` didn't distinguish top-level chain
  continuations from continuations inside a nested scope. Skip the
  reformat when the opening line ends with `{`, ` do`, or `|`
  (the marker of a `do |params|` opener).
- heredoc inside `if` predicate (bug #10)
  `if (sql = <<~SQL)\n  …\nSQL\n  body` inserted a blank line between
  the terminator and the body, because the predicate slice kept the
  trailing newline past the heredoc terminator. Strip one trailing
  newline from the predicate source in `format_normal`.

Style preservation
- `x = begin…end` inline (bug #6)
  The assignment `x = begin\n  …\nend` was rewritten to `x =\n
  begin\n  …\nend`. Detect "value starts on the same line as the
  assignment" in `format_variable_write` and keep the opener on the
  assignment line.
- `def !@` → `def !` (operator method)
  `extract_node_name` returned Prism's canonical name (`:!`),
  silently rewriting `def !@` to `def !`. Prefer `name_loc.slice`
  for DefNode so explicit `@` suffixes survive.

Shared plumbing
- `format_body_end` factors body + `end` emission into
  `push_body_and_end` so the single-line, multi-line-header, and
  standard paths share the logic.
- `begin.rs` introduces `format_begin_clause`, a clause emitter
  shared by explicit and implicit begin paths.
- `if_unless.rs` adds `statement_contains_heredoc_tail` for the
  postfix-with-heredoc detection.
- New helper `line_end_offset` in `body_end.rs` locates the newline
  that terminates a given 1-based line.

Tests
- spec/rails_idioms_round2_spec.rb: 9 round-trip + idempotence +
  Prism parse-check tests covering every fix above.
- All 128 rspec examples (119 existing + 9 new) and 127 cargo unit
  tests pass. `cargo clippy -D warnings`, `cargo fmt --check`, and
  `rubocop` are clean.
@sorafujitani sorafujitani merged commit 3d52859 into main Apr 24, 2026
9 checks passed
@sorafujitani sorafujitani deleted the fix/rails-idiom-bugs-round2 branch April 24, 2026 02:09
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.

1 participant