rfmt 1.6.0 → 1.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -13,6 +13,107 @@ use crate::format::rule::{
13
13
  format_child, format_leading_comments, format_statements, format_trailing_comment, FormatRule,
14
14
  };
15
15
 
16
+ /// True when this BeginNode represents an implicit begin (the source does not
17
+ /// start with the `begin` keyword) AND carries at least one rescue/else/ensure
18
+ /// clause. Such bodies need the rescue/else/ensure keywords emitted at the
19
+ /// outer (e.g. `def`) indent level rather than at the body indent level.
20
+ pub(crate) fn is_implicit_begin_with_clauses(node: &Node, ctx: &FormatContext) -> bool {
21
+ if node.node_type != NodeType::BeginNode {
22
+ return false;
23
+ }
24
+ let has_clause = node.children.iter().any(|c| {
25
+ matches!(
26
+ c.node_type,
27
+ NodeType::RescueNode | NodeType::EnsureNode | NodeType::ElseNode
28
+ )
29
+ });
30
+ if !has_clause {
31
+ return false;
32
+ }
33
+ ctx.extract_source(node)
34
+ .map(|s| !s.trim_start().starts_with("begin"))
35
+ .unwrap_or(false)
36
+ }
37
+
38
+ /// Emits the body of a construct (def/class/module/block) whose body is an
39
+ /// implicit BeginNode with rescue/else/ensure clauses.
40
+ ///
41
+ /// The returned Doc is meant to sit between the opening line (e.g. `def foo`)
42
+ /// and a trailing `hardline + text("end")` emitted by the caller. Body
43
+ /// statements are wrapped in `indent`, while rescue/else/ensure clause
44
+ /// keywords are emitted at the caller's current indent level so that they
45
+ /// align with the opener instead of the body.
46
+ pub(crate) fn format_implicit_begin_body(
47
+ node: &Node,
48
+ ctx: &mut FormatContext,
49
+ registry: &RuleRegistry,
50
+ ) -> Result<Doc> {
51
+ let mut body_children: Vec<&Node> = Vec::new();
52
+ let mut clause_children: Vec<&Node> = Vec::new();
53
+
54
+ for child in &node.children {
55
+ match child.node_type {
56
+ NodeType::RescueNode | NodeType::EnsureNode | NodeType::ElseNode => {
57
+ clause_children.push(child);
58
+ }
59
+ _ => {
60
+ body_children.push(child);
61
+ }
62
+ }
63
+ }
64
+
65
+ let mut docs: Vec<Doc> = Vec::with_capacity(clause_children.len() * 2 + 2);
66
+
67
+ if !body_children.is_empty() {
68
+ let mut body_docs: Vec<Doc> = Vec::with_capacity(body_children.len() * 2 + 1);
69
+ body_docs.push(hardline());
70
+ for (i, child) in body_children.iter().enumerate() {
71
+ if i > 0 {
72
+ body_docs.push(hardline());
73
+ }
74
+ body_docs.push(format_child(child, ctx, registry)?);
75
+ }
76
+ docs.push(indent(concat(body_docs)));
77
+ }
78
+
79
+ for clause in clause_children {
80
+ docs.push(hardline());
81
+ docs.push(format_begin_clause(clause, ctx, registry)?);
82
+ }
83
+
84
+ Ok(concat(docs))
85
+ }
86
+
87
+ /// Emits a rescue/else/ensure clause the way the enclosing begin expects.
88
+ ///
89
+ /// `format_child` sends ElseNode to `FallbackRule`, which slices the source
90
+ /// between `else` and the next keyword — but Prism's `ElseNode.location`
91
+ /// stretches into the *following* clause's keyword (`ensure`, or the
92
+ /// begin's `end`). Emitting that slice as-is duplicates whichever keyword
93
+ /// comes after, producing e.g. `else\n y\nensure\nensure\n z\nend` or
94
+ /// `else\n y\nend\nend`. Handle ElseNode explicitly so we emit only
95
+ /// `else\n body` and let the caller (or the subsequent EnsureRule) write
96
+ /// the following keyword.
97
+ fn format_begin_clause(
98
+ clause: &Node,
99
+ ctx: &mut FormatContext,
100
+ registry: &RuleRegistry,
101
+ ) -> Result<Doc> {
102
+ if !matches!(clause.node_type, NodeType::ElseNode) {
103
+ return format_child(clause, ctx, registry);
104
+ }
105
+
106
+ let mut docs: Vec<Doc> = Vec::with_capacity(3);
107
+ docs.push(text("else"));
108
+ for child in &clause.children {
109
+ if matches!(child.node_type, NodeType::StatementsNode) {
110
+ let body_doc = format_statements(child, ctx, registry)?;
111
+ docs.push(indent(concat(vec![hardline(), body_doc])));
112
+ }
113
+ }
114
+ Ok(concat(docs))
115
+ }
116
+
16
117
  /// Rule for formatting begin/rescue/ensure blocks.
17
118
  pub struct BeginRule;
18
119
 
@@ -71,14 +172,36 @@ fn format_explicit_begin(
71
172
  }
72
173
 
73
174
  docs.push(text("begin"));
74
- docs.push(hardline());
75
175
 
176
+ let mut body_children: Vec<&Node> = Vec::new();
177
+ let mut clause_children: Vec<&Node> = Vec::new();
76
178
  for child in &node.children {
77
- let child_doc = format_child(child, ctx, registry)?;
78
- docs.push(child_doc);
179
+ match child.node_type {
180
+ NodeType::RescueNode | NodeType::EnsureNode | NodeType::ElseNode => {
181
+ clause_children.push(child);
182
+ }
183
+ _ => body_children.push(child),
184
+ }
185
+ }
186
+
187
+ if !body_children.is_empty() {
188
+ let mut body_docs: Vec<Doc> = Vec::with_capacity(body_children.len() * 2 + 1);
189
+ body_docs.push(hardline());
190
+ for (i, child) in body_children.iter().enumerate() {
191
+ if i > 0 {
192
+ body_docs.push(hardline());
193
+ }
194
+ body_docs.push(format_child(child, ctx, registry)?);
195
+ }
196
+ docs.push(indent(concat(body_docs)));
197
+ }
198
+
199
+ for clause in &clause_children {
79
200
  docs.push(hardline());
201
+ docs.push(format_begin_clause(clause, ctx, registry)?);
80
202
  }
81
203
 
204
+ docs.push(hardline());
82
205
  docs.push(text("end"));
83
206
 
84
207
  // Trailing comment on end line
@@ -122,6 +245,15 @@ fn format_rescue(
122
245
  // But since we're in Doc IR, we handle this at the caller level
123
246
  let _ = dedent_level;
124
247
 
248
+ // Emit any standalone comments that sit immediately before the rescue
249
+ // keyword. Without this they would be picked up as leading comments of
250
+ // the first statement inside the rescue body, which visually moves an
251
+ // annotation like `# retry on flaky errors` *into* the rescue branch.
252
+ let leading = format_leading_comments(ctx, node.location.start_line);
253
+ if !leading.is_empty() {
254
+ docs.push(leading);
255
+ }
256
+
125
257
  docs.push(text("rescue"));
126
258
 
127
259
  // Extract exception classes and variable from source
@@ -167,26 +299,32 @@ fn format_rescue(
167
299
  }
168
300
  }
169
301
 
170
- docs.push(hardline());
171
-
172
- // Emit rescue body and handle subsequent rescue nodes
302
+ // Emit rescue body (indented under the rescue keyword) and subsequent
303
+ // chained rescue clauses (at the same indent level as this rescue).
304
+ //
305
+ // The hardline lives INSIDE the `indent(...)` wrap so that the body's
306
+ // first statement lands at `caller_indent + indent_width`, matching the
307
+ // indentation applied by hardlines later inside `format_statements`.
308
+ let mut body_stmts: Option<&Node> = None;
309
+ let mut subsequent: Option<&Node> = None;
173
310
  for child in &node.children {
174
311
  match &child.node_type {
175
- NodeType::StatementsNode => {
176
- let body_doc = format_statements(child, ctx, registry)?;
177
- docs.push(indent(body_doc));
178
- }
179
- NodeType::RescueNode => {
180
- // Emit subsequent rescue clause
181
- let rescue_doc = format_rescue(child, ctx, registry, dedent_level)?;
182
- docs.push(rescue_doc);
183
- }
184
- _ => {
185
- // Skip exception classes and variable (already handled above)
186
- }
312
+ NodeType::StatementsNode => body_stmts = Some(child),
313
+ NodeType::RescueNode => subsequent = Some(child),
314
+ _ => {}
187
315
  }
188
316
  }
189
317
 
318
+ if let Some(stmts) = body_stmts {
319
+ let body_doc = format_statements(stmts, ctx, registry)?;
320
+ docs.push(indent(concat(vec![hardline(), body_doc])));
321
+ }
322
+
323
+ if let Some(sub) = subsequent {
324
+ docs.push(hardline());
325
+ docs.push(format_rescue(sub, ctx, registry, dedent_level)?);
326
+ }
327
+
190
328
  Ok(concat(docs))
191
329
  }
192
330
 
@@ -207,18 +345,19 @@ fn format_ensure(
207
345
  }
208
346
 
209
347
  docs.push(text("ensure"));
210
- docs.push(hardline());
211
348
 
212
- // Emit ensure body statements
349
+ // Emit ensure body. The hardline lives INSIDE the `indent(...)` wrap so
350
+ // that every body statement — including the first one — lands at
351
+ // `caller_indent + indent_width`.
213
352
  for child in &node.children {
214
353
  match &child.node_type {
215
354
  NodeType::StatementsNode => {
216
355
  let body_doc = format_statements(child, ctx, registry)?;
217
- docs.push(indent(body_doc));
356
+ docs.push(indent(concat(vec![hardline(), body_doc])));
218
357
  }
219
358
  _ => {
220
359
  let child_doc = format_child(child, ctx, registry)?;
221
- docs.push(indent(child_doc));
360
+ docs.push(indent(concat(vec![hardline(), child_doc])));
222
361
  }
223
362
  }
224
363
  }
@@ -10,9 +10,11 @@ use crate::format::context::FormatContext;
10
10
  use crate::format::registry::RuleRegistry;
11
11
  use crate::format::rule::{
12
12
  format_child, format_comments_before_end, format_leading_comments, format_trailing_comment,
13
- is_structural_node,
13
+ is_structural_node, mark_comments_in_range_emitted,
14
14
  };
15
15
 
16
+ use super::begin::{format_implicit_begin_body, is_implicit_begin_with_clauses};
17
+
16
18
  /// Configuration for formatting a body-with-end construct.
17
19
  pub struct BodyEndConfig<'a> {
18
20
  /// The keyword (e.g., "class", "module", "def")
@@ -51,6 +53,79 @@ pub fn format_body_end(
51
53
  docs.push(leading);
52
54
  }
53
55
 
56
+ // Single-line form: `def foo = expr` (endless), `def foo; body; end`,
57
+ // `class Foo; end`, etc. Emit the source verbatim instead of forcing
58
+ // a multi-line `def ... end` layout. This preserves Ruby 3+ endless
59
+ // methods and the `Error < StandardError; end` exception-hierarchy
60
+ // idiom that is pervasive in Rails code.
61
+ if start_line == end_line {
62
+ if let Some(source_text) = ctx.extract_source(config.node) {
63
+ docs.push(text(source_text.to_string()));
64
+ mark_comments_in_range_emitted(ctx, start_line, end_line);
65
+
66
+ let trailing = format_trailing_comment(ctx, end_line);
67
+ if !trailing.is_empty() {
68
+ docs.push(trailing);
69
+ }
70
+ return Ok(concat(docs));
71
+ }
72
+ }
73
+
74
+ // Body children (filtered) — reused by both the multi-line header path
75
+ // below and the normal path further down.
76
+ let body_children: Vec<&Node> = config
77
+ .node
78
+ .children
79
+ .iter()
80
+ .filter(|c| {
81
+ if config.skip_same_line_children && c.location.start_line == start_line {
82
+ return false;
83
+ }
84
+ !is_structural_node(c)
85
+ })
86
+ .collect();
87
+
88
+ // Multi-line header form: `def foo(a, # c1\n b, # c2\n c) # c3`.
89
+ //
90
+ // Rebuilding the header from `parameters_text` + a reconstructed `)`
91
+ // drops the closing line's trailing comment and, worse, lets comments
92
+ // that the params slice already contains be re-picked up by the later
93
+ // `format_trailing_comment` + `format_leading_comments` calls. The net
94
+ // effect is that the first inline comment is duplicated, the rest are
95
+ // moved into the body, and the file stops being idempotent because the
96
+ // relocated comments keep accumulating every format pass. Detect this
97
+ // case early and emit the header verbatim from source.
98
+ let params_text = config.node.metadata.get("parameters_text");
99
+ let header_is_multiline = params_text.is_some_and(|t| t.contains('\n'));
100
+ if header_is_multiline {
101
+ let header_end_line = body_children
102
+ .first()
103
+ .map(|c| c.location.start_line.saturating_sub(1))
104
+ .unwrap_or(start_line);
105
+ if header_end_line >= start_line {
106
+ let header_end_offset = line_end_offset(ctx.source(), header_end_line);
107
+ if let Some(header_src) = ctx
108
+ .source()
109
+ .get(config.node.location.start_offset..header_end_offset)
110
+ {
111
+ docs.push(text(header_src.to_string()));
112
+ mark_comments_in_range_emitted(ctx, start_line, header_end_line + 1);
113
+
114
+ // Body + "end" (same as the normal path; no header trailing
115
+ // comment — it's already baked into the source slice above).
116
+ push_body_and_end(
117
+ &mut docs,
118
+ ctx,
119
+ registry,
120
+ &body_children,
121
+ start_line,
122
+ end_line,
123
+ )?;
124
+ return Ok(concat(docs));
125
+ }
126
+ }
127
+ }
128
+
54
129
  // 2. Build header: "keyword ..."
55
130
  let mut header_parts: Vec<Doc> = vec![text(config.keyword), text(" ")];
56
131
  header_parts.extend((config.header_builder)(config.node));
@@ -62,28 +137,21 @@ pub fn format_body_end(
62
137
  docs.push(trailing);
63
138
  }
64
139
 
65
- // 4. Body (children), skipping structural nodes
66
- let mut body_docs: Vec<Doc> = Vec::new();
67
- let mut has_body_content = false;
68
-
69
- for child in &config.node.children {
70
- // Skip nodes on the same line as definition (name, parameters, etc.)
71
- if config.skip_same_line_children && child.location.start_line == start_line {
72
- continue;
140
+ // 4. Body (children), skipping structural nodes — already collected above.
141
+
142
+ // Special case: body is an implicit BeginNode carrying rescue/else/ensure.
143
+ // In that case the clause keywords must align with the opener, not with
144
+ // the body statements — so we split the body and clause emission instead
145
+ // of wrapping everything in a single `indent(...)`.
146
+ if body_children.len() == 1 && is_implicit_begin_with_clauses(body_children[0], ctx) {
147
+ docs.push(format_implicit_begin_body(body_children[0], ctx, registry)?);
148
+ } else if !body_children.is_empty() {
149
+ let mut body_docs: Vec<Doc> = Vec::with_capacity(body_children.len() * 2);
150
+ for child in &body_children {
151
+ let child_doc = format_child(child, ctx, registry)?;
152
+ body_docs.push(hardline());
153
+ body_docs.push(child_doc);
73
154
  }
74
- if is_structural_node(child) {
75
- continue;
76
- }
77
-
78
- has_body_content = true;
79
-
80
- // Format the child node using recursive rule dispatch
81
- let child_doc = format_child(child, ctx, registry)?;
82
- body_docs.push(hardline());
83
- body_docs.push(child_doc);
84
- }
85
-
86
- if has_body_content {
87
155
  docs.push(indent(concat(body_docs)));
88
156
  }
89
157
 
@@ -107,3 +175,59 @@ pub fn format_body_end(
107
175
 
108
176
  Ok(concat(docs))
109
177
  }
178
+
179
+ /// Returns the byte offset of the character immediately past the end of the
180
+ /// given 1-based line (i.e. the index of the trailing `\n`, or `source.len()`
181
+ /// if the line has no trailing newline).
182
+ fn line_end_offset(source: &str, line: usize) -> usize {
183
+ let mut current = 1;
184
+ for (i, b) in source.bytes().enumerate() {
185
+ if b == b'\n' {
186
+ if current == line {
187
+ return i;
188
+ }
189
+ current += 1;
190
+ }
191
+ }
192
+ source.len()
193
+ }
194
+
195
+ /// Emits the body (possibly an implicit BeginNode with rescue clauses) and the
196
+ /// trailing `end` keyword. Extracted so that both the standard
197
+ /// header-reconstruction path and the multi-line source-extraction path can
198
+ /// share the emission logic.
199
+ fn push_body_and_end(
200
+ docs: &mut Vec<Doc>,
201
+ ctx: &mut FormatContext,
202
+ registry: &RuleRegistry,
203
+ body_children: &[&Node],
204
+ start_line: usize,
205
+ end_line: usize,
206
+ ) -> Result<()> {
207
+ if body_children.len() == 1 && is_implicit_begin_with_clauses(body_children[0], ctx) {
208
+ docs.push(format_implicit_begin_body(body_children[0], ctx, registry)?);
209
+ } else if !body_children.is_empty() {
210
+ let mut body_docs: Vec<Doc> = Vec::with_capacity(body_children.len() * 2);
211
+ for child in body_children {
212
+ let child_doc = format_child(child, ctx, registry)?;
213
+ body_docs.push(hardline());
214
+ body_docs.push(child_doc);
215
+ }
216
+ docs.push(indent(concat(body_docs)));
217
+ }
218
+
219
+ let comments_before_end = format_comments_before_end(ctx, start_line, end_line);
220
+ if !comments_before_end.is_empty() {
221
+ docs.push(indent(comments_before_end));
222
+ }
223
+
224
+ docs.push(hardline());
225
+ docs.push(text("end"));
226
+
227
+ let end_trailing = format_trailing_comment(ctx, end_line);
228
+ if !end_trailing.is_empty() {
229
+ docs.push(end_trailing);
230
+ }
231
+
232
+ Ok(())
233
+ }
@@ -5,14 +5,17 @@
5
5
  //! - Calls with blocks: `foo.bar do ... end` or `foo.bar { ... }`
6
6
  //! - Method chains: `foo.bar.baz`
7
7
 
8
+ use std::borrow::Cow;
9
+
8
10
  use crate::ast::{Node, NodeType};
9
- use crate::doc::{concat, hardline, indent, text, Doc};
11
+ use crate::doc::{align, concat, hardline, indent, text, Doc};
10
12
  use crate::error::Result;
11
13
  use crate::format::context::FormatContext;
12
14
  use crate::format::registry::RuleRegistry;
13
15
  use crate::format::rule::{
14
- format_child, format_leading_comments, format_statements, format_trailing_comment,
15
- mark_comments_in_range_emitted, reformat_chain_lines, FormatRule,
16
+ format_child, format_comments_before_end, format_leading_comments, format_statements,
17
+ format_trailing_comment, line_leading_indent, mark_comments_in_range_emitted,
18
+ reformat_chain_lines, strip_one_trailing_newline, FormatRule,
16
19
  };
17
20
 
18
21
  /// Rule for formatting method calls.
@@ -100,11 +103,25 @@ fn format_call(node: &Node, ctx: &mut FormatContext, registry: &RuleRegistry) ->
100
103
  .unwrap_or(false);
101
104
 
102
105
  if !has_block {
103
- // Simple call - use source extraction with chain reformatting
106
+ // Simple call - use source extraction with chain reformatting.
107
+ //
108
+ // A CallNode that carries a heredoc argument (e.g.
109
+ // `query(<<~SQL)\n …\nSQL`) reports its end_offset past the
110
+ // heredoc terminator's trailing newline. Leaving that newline in
111
+ // the emitted `Doc::Text` combines with the later `hardline + end`
112
+ // or inter-statement hardline to produce a spurious blank line.
113
+ // Strip at most one trailing newline so this doesn't happen; using
114
+ // the full `trim_end` here would instead eat a blank separator line
115
+ // that legitimately belongs between statements.
104
116
  if let Some(source_text) = ctx.extract_source(node) {
105
- let reformatted =
106
- reformat_chain_lines(source_text, ctx.config().formatting.indent_width);
107
- docs.push(text(reformatted));
117
+ let base_indent = line_leading_indent(ctx.source(), node.location.start_offset);
118
+ let reformatted = reformat_chain_lines(
119
+ source_text,
120
+ base_indent,
121
+ ctx.config().formatting.indent_width,
122
+ );
123
+ let trimmed = strip_one_trailing_newline(&reformatted);
124
+ docs.push(text(trimmed.to_string()));
108
125
  }
109
126
 
110
127
  // Mark comments in this range as emitted (they're in source extraction)
@@ -123,16 +140,26 @@ fn format_call(node: &Node, ctx: &mut FormatContext, registry: &RuleRegistry) ->
123
140
  let block_node = node.children.last().unwrap();
124
141
  let block_style = detect_block_style(block_node, ctx);
125
142
 
126
- // Emit the call part (receiver.method(args)) from source with chain reformatting
143
+ // Emit the call part (receiver.method(args)) from source with chain
144
+ // reformatting. Track whether reformatting actually fired so the block
145
+ // body can be re-aligned to match the chain's new depth.
127
146
  let call_end_offset = block_node.location.start_offset;
128
- if let Some(call_text) = ctx
147
+ let chain_reformatted = if let Some(call_text) = ctx
129
148
  .source()
130
149
  .get(node.location.start_offset..call_end_offset)
131
150
  {
132
- let reformatted =
133
- reformat_chain_lines(call_text.trim_end(), ctx.config().formatting.indent_width);
151
+ let base_indent = line_leading_indent(ctx.source(), node.location.start_offset);
152
+ let reformatted = reformat_chain_lines(
153
+ call_text.trim_end(),
154
+ base_indent,
155
+ ctx.config().formatting.indent_width,
156
+ );
157
+ let changed = matches!(reformatted, Cow::Owned(_));
134
158
  docs.push(text(reformatted));
135
- }
159
+ changed
160
+ } else {
161
+ false
162
+ };
136
163
 
137
164
  // Mark comments in the call part (before block) as emitted
138
165
  // This includes trailing comments that are part of the extracted source
@@ -142,16 +169,21 @@ fn format_call(node: &Node, ctx: &mut FormatContext, registry: &RuleRegistry) ->
142
169
  block_node.location.start_line,
143
170
  );
144
171
 
145
- // Format the block
146
- match block_style {
147
- BlockStyle::DoEnd => {
148
- let block_doc = format_do_end_block(block_node, ctx, registry)?;
149
- docs.push(block_doc);
150
- }
151
- BlockStyle::Braces => {
152
- let block_doc = format_brace_block(block_node, ctx, registry)?;
153
- docs.push(block_doc);
154
- }
172
+ // Format the block. When the receiver's chain was re-indented, the
173
+ // `do`-line ends up one level below `base_indent` instead of at
174
+ // `base_indent` itself, so the default `indent(body)` wrap inside the
175
+ // block formatter now places the body *at* the chain depth rather than
176
+ // one level below it (and the `end` keyword floats up to `base_indent`).
177
+ // Push both down with `Align` so the `do…end` body is indented relative
178
+ // to the chain's last line, matching what a human would write.
179
+ let block_doc = match block_style {
180
+ BlockStyle::DoEnd => format_do_end_block(block_node, ctx, registry)?,
181
+ BlockStyle::Braces => format_brace_block(block_node, ctx, registry)?,
182
+ };
183
+ if chain_reformatted {
184
+ docs.push(align(ctx.config().formatting.indent_width, block_doc));
185
+ } else {
186
+ docs.push(block_doc);
155
187
  }
156
188
 
157
189
  Ok(concat(docs))
@@ -201,9 +233,16 @@ fn format_do_end_block(
201
233
  break;
202
234
  }
203
235
  NodeType::BeginNode => {
204
- // Block with rescue/ensure/else
205
- let body_doc = format_child(child, ctx, registry)?;
206
- docs.push(indent(concat(vec![hardline(), body_doc])));
236
+ // Block with rescue/else/ensure needs the clause keywords at
237
+ // the block opener's indent level, not at the body indent.
238
+ if super::begin::is_implicit_begin_with_clauses(child, ctx) {
239
+ docs.push(super::begin::format_implicit_begin_body(
240
+ child, ctx, registry,
241
+ )?);
242
+ } else {
243
+ let body_doc = format_child(child, ctx, registry)?;
244
+ docs.push(indent(concat(vec![hardline(), body_doc])));
245
+ }
207
246
  break;
208
247
  }
209
248
  _ => {
@@ -212,6 +251,23 @@ fn format_do_end_block(
212
251
  }
213
252
  }
214
253
 
254
+ // Emit any standalone comments between the last body statement and `end`.
255
+ //
256
+ // Without this the orphan comments inside a `do…end` block (e.g. the
257
+ // commented-out config stanzas in a generated `spec_helper.rb`) never
258
+ // get claimed by any `format_leading_comments` call, fall through to
259
+ // `format_remaining_comments` at the end of the file, and get emitted
260
+ // *after* the block's own `end` — producing `end# comment…` with no
261
+ // separator and dropping the body indent.
262
+ let comments_before_end = format_comments_before_end(
263
+ ctx,
264
+ block_node.location.start_line,
265
+ block_node.location.end_line,
266
+ );
267
+ if !comments_before_end.is_empty() {
268
+ docs.push(indent(comments_before_end));
269
+ }
270
+
215
271
  // Emit 'end'
216
272
  docs.push(hardline());
217
273
  docs.push(text("end"));
@@ -10,8 +10,8 @@ use crate::error::Result;
10
10
  use crate::format::context::FormatContext;
11
11
  use crate::format::registry::RuleRegistry;
12
12
  use crate::format::rule::{
13
- format_leading_comments, format_trailing_comment, mark_comments_in_range_emitted,
14
- reformat_chain_lines, FormatRule,
13
+ format_leading_comments, format_trailing_comment, line_leading_indent,
14
+ mark_comments_in_range_emitted, reformat_chain_lines, strip_one_trailing_newline, FormatRule,
15
15
  };
16
16
 
17
17
  /// Fallback rule that extracts source text directly.
@@ -36,11 +36,25 @@ impl FormatRule for FallbackRule {
36
36
  docs.push(leading);
37
37
  }
38
38
 
39
- // Extract source text with chain reformatting
39
+ // Extract source text with chain reformatting.
40
+ //
41
+ // Nodes such as ConstantWriteNode for `CONST = <<~HEREDOC ... HEREDOC`
42
+ // report an end_offset that sits past the heredoc terminator's
43
+ // newline. Preserving that newline verbatim combines with the
44
+ // surrounding `format_statements` hardline to produce a spurious
45
+ // blank line before the following statement or `end`. Strip at
46
+ // most one trailing newline (not all trailing whitespace, which
47
+ // could swallow an intentional blank line captured by the node's
48
+ // extent).
40
49
  if let Some(source_text) = ctx.extract_source(node) {
41
- let reformatted =
42
- reformat_chain_lines(source_text, ctx.config().formatting.indent_width);
43
- docs.push(text(reformatted));
50
+ let base_indent = line_leading_indent(ctx.source(), node.location.start_offset);
51
+ let reformatted = reformat_chain_lines(
52
+ source_text,
53
+ base_indent,
54
+ ctx.config().formatting.indent_width,
55
+ );
56
+ let trimmed = strip_one_trailing_newline(&reformatted);
57
+ docs.push(text(trimmed.to_string()));
44
58
 
45
59
  // Mark any comments within this node's range as emitted
46
60
  // (they are included in the source extraction)