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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 538d3def9f35a241acf4089c99a2d905acb03f77a097a2ad88b19e47c2009d16
4
- data.tar.gz: 28e0cbe935992181c22056f16c1a2edb52fac170a80400905e2cee90f0eec1ce
3
+ metadata.gz: 5f1d0fa5ba56d10568e9dd3625974abc815350c4c6c2ff03d5114d8c38aae9a1
4
+ data.tar.gz: 4b6256a168e7be4a70c162a26bea0983fd6397397224cd1953abbe9cc1cbd01b
5
5
  SHA512:
6
- metadata.gz: 4169b3f6b4d2dae45fbddec1b299ca2f787b8a854999d03c3f63307e9af2ef5007076d74f96852e3624e3a0d269b9480c2fbe8064bb707e57863df71e7d88618
7
- data.tar.gz: fc8cd1357d92ca01a5829248a58bc42f4f731f4122274950e98d17daa7a51c0a53a0e35c3cbbc2132b8a489d474adaf4489be05e9ec41f37c4412d6da1e27ece
6
+ metadata.gz: a90b0b5f699c21e3e5aebd5f33ce71c152694a3cc51c812d2ffda946d2250a7714ae572ab9e7538531386e09b91210aa50f5e4de49e1d11234afd6803525ede4
7
+ data.tar.gz: ab5588dfe3b5035e444219ad218756b475d964f18b47fdb78311593ba964c7c4436856abca58a13fc2e799eaa7d85815e6e70be39e6d0e083d9c7951c3867553
data/CHANGELOG.md CHANGED
@@ -1,5 +1,29 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [1.6.2] - 2026-04-24
4
+
5
+ Minor stability refinements on top of the 1.6.x architecture release. See the 1.6.1 notes below for the feature set this series delivers.
6
+
7
+ ## [1.6.1] - 2026-04-24
8
+
9
+ Follow-up release consolidating the 1.6.0 architecture work.
10
+
11
+ ### Rule-based formatter
12
+
13
+ The rule-based `format/` pipeline (`Formatter`, `Registry`, `Rule`) is the canonical formatting path, replacing the legacy monolithic emitter. Rules covering body indentation (`StatementsRule`), singleton classes (`SingletonClassRule`), and variable writes (`VariableWriteRule`) ship as part of the default registry. The Intermediate Representation (IR) module decouples parsing from emission for composability and testability.
14
+
15
+ ### Method chain reformatting
16
+
17
+ Multi-line method chains can be reformatted from aligned style (indented under the first dot) to indented style (one level beyond the receiver), preserving the source's base indent. The pass is wired into the fallback path for resilience.
18
+
19
+ ### Printer optimizations
20
+
21
+ The printer carries a pre-computed indent cache and inline hints for the hot path; `reformat_chain_lines` has been deduplicated across rules and uses `Cow<str>` to avoid allocations on pass-through.
22
+
23
+ ### Editor integration
24
+
25
+ Setup guides for VSCode, Neovim, Helix, Emacs, and Zed land in the repository; every editor uses the Ruby LSP addon system, so there are no editor-specific plugins to maintain. The README's Editor Integration section replaces the previous "Coming Soon" placeholder with a VSCode quick start.
26
+
3
27
  ## [1.6.0] - 2026-04-23
4
28
 
5
29
  ### Added
data/Cargo.lock CHANGED
@@ -1251,7 +1251,7 @@ checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c"
1251
1251
 
1252
1252
  [[package]]
1253
1253
  name = "rfmt"
1254
- version = "1.6.0"
1254
+ version = "1.6.2"
1255
1255
  dependencies = [
1256
1256
  "anyhow",
1257
1257
  "clap",
data/ext/rfmt/Cargo.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "rfmt"
3
- version = "1.6.0"
3
+ version = "1.6.2"
4
4
  edition = "2021"
5
5
  authors = ["fujitani sora <fujitanisora0414@gmail.com>"]
6
6
  license = "MIT"
@@ -108,6 +108,18 @@ impl<'a> Printer<'a> {
108
108
  self.output.push('\n');
109
109
  }
110
110
 
111
+ // Strip trailing whitespace on every line.
112
+ //
113
+ // When a `hardline` lands inside an `indent(...)` region and is
114
+ // immediately followed by another newline (because the user wrote a
115
+ // blank line between statements), the printer first emits
116
+ // `"\n" + indent_spaces`, then the next newline runs over those
117
+ // spaces — leaving them on the otherwise-blank line. Stripping here
118
+ // is simpler and safer than threading "next is newline?" state
119
+ // through `Doc::Line` emission, and it also removes stray spaces
120
+ // that show up after inline trailing comments.
121
+ strip_trailing_line_whitespace(&mut self.output);
122
+
111
123
  std::mem::take(&mut self.output)
112
124
  }
113
125
 
@@ -388,6 +400,31 @@ impl<'a> Printer<'a> {
388
400
  }
389
401
  }
390
402
 
403
+ /// Strips trailing ASCII spaces/tabs from every line in `s` in-place.
404
+ ///
405
+ /// Heredoc content embedded in `Doc::Text` also passes through this pass;
406
+ /// trailing whitespace inside a heredoc body is extremely rare in real Ruby
407
+ /// code and Rails projects universally run with `Layout/TrailingWhitespace`,
408
+ /// so trimming unconditionally matches project conventions.
409
+ fn strip_trailing_line_whitespace(buf: &mut String) {
410
+ if !buf.bytes().any(|b| b == b' ' || b == b'\t') {
411
+ return;
412
+ }
413
+
414
+ let mut out = String::with_capacity(buf.len());
415
+ for line in buf.split_inclusive('\n') {
416
+ // `split_inclusive` keeps the trailing `\n` attached to the line.
417
+ if let Some(stripped) = line.strip_suffix('\n') {
418
+ out.push_str(stripped.trim_end_matches([' ', '\t']));
419
+ out.push('\n');
420
+ } else {
421
+ // Final line without a trailing newline.
422
+ out.push_str(line.trim_end_matches([' ', '\t']));
423
+ }
424
+ }
425
+ *buf = out;
426
+ }
427
+
391
428
  #[cfg(test)]
392
429
  mod tests {
393
430
  use super::*;
@@ -6,7 +6,7 @@
6
6
  //! 3. Apply rules to generate Doc IR
7
7
  //! 4. Print Doc IR to string using Printer
8
8
 
9
- use crate::ast::{Node, NodeType};
9
+ use crate::ast::{CommentType, Node, NodeType};
10
10
  use crate::config::Config;
11
11
  use crate::doc::{concat, hardline, Doc, Printer};
12
12
  use crate::error::Result;
@@ -120,16 +120,40 @@ impl Formatter {
120
120
  let child_doc = self.format_node(child, ctx)?;
121
121
  docs.push(child_doc);
122
122
 
123
- // Add newlines between statements
123
+ // Add newlines between statements. Mirrors the logic in
124
+ // `format_statements` so top-level programs and node bodies
125
+ // agree on how comments participate in blank-line preservation:
126
+ // 1) subtract comment-occupied lines from the blank-line count
127
+ // so a standalone comment in the gap doesn't inflate it;
128
+ // 2) deduct one more when the gap contains a `=begin/=end`
129
+ // block comment, because that comment's `literalline`
130
+ // emission already supplies one of the line breaks.
124
131
  if let Some(next_child) = children.get(i + 1) {
125
132
  let current_end_line = child.location.end_line;
126
133
  let next_start_line = next_child.location.start_line;
127
134
  let line_diff = next_start_line.saturating_sub(current_end_line);
128
135
 
129
- // Add 1 hardline if consecutive, 2 hardlines (1 blank line) if there was a gap
130
136
  docs.push(hardline());
137
+
131
138
  if line_diff > 1 {
132
- docs.push(hardline());
139
+ let (comment_lines_in_gap, gap_has_block): (usize, bool) = ctx
140
+ .get_comment_indices_in_range(current_end_line + 1, next_start_line)
141
+ .filter_map(|idx| ctx.get_comment(idx).cloned())
142
+ .fold((0usize, false), |(lines, had_block), c| {
143
+ let span =
144
+ c.location.end_line.saturating_sub(c.location.start_line) + 1;
145
+ let is_block = matches!(c.comment_type, CommentType::Block);
146
+ (lines + span, had_block || is_block)
147
+ });
148
+ let mut blank_lines = line_diff
149
+ .saturating_sub(1)
150
+ .saturating_sub(comment_lines_in_gap);
151
+ if gap_has_block && blank_lines > 0 {
152
+ blank_lines -= 1;
153
+ }
154
+ if blank_lines >= 1 {
155
+ docs.push(hardline());
156
+ }
133
157
  }
134
158
  }
135
159
  }
@@ -3,8 +3,8 @@
3
3
  //! This module defines the core FormatRule trait that all formatting rules implement,
4
4
  //! along with shared helper functions for common formatting patterns.
5
5
 
6
- use crate::ast::Node;
7
- use crate::doc::{concat, hardline, leading_comment, trailing_comment, Doc};
6
+ use crate::ast::{CommentType, Node};
7
+ use crate::doc::{concat, hardline, leading_comment, literalline, text, trailing_comment, Doc};
8
8
  use crate::error::Result;
9
9
 
10
10
  use super::context::FormatContext;
@@ -46,6 +46,33 @@ struct CommentRef {
46
46
  idx: usize,
47
47
  start_line: usize,
48
48
  end_line: usize,
49
+ is_block: bool,
50
+ }
51
+
52
+ /// Emits a single leading comment at the configured indent context.
53
+ ///
54
+ /// `=begin ... =end` (Prism's `EmbDocComment`) must start in column 0;
55
+ /// nesting it under an `indent(...)` wrapper would emit `=begin` at the
56
+ /// body indent, which is a *syntax error*. Use `literalline` to break out
57
+ /// of the current indent so the `=begin` and `=end` markers — and every
58
+ /// intermediate line — land at column 0. The following `hardline` then
59
+ /// restores the normal indented flow for the code that comes after.
60
+ ///
61
+ /// Prism's location slice for an embdoc includes the trailing newline that
62
+ /// sits after `=end`; strip it so that our own `hardline` doesn't produce
63
+ /// a double line break.
64
+ fn push_leading_comment(docs: &mut Vec<Doc>, comment_text: &str, is_block: bool) {
65
+ if is_block {
66
+ let body = comment_text
67
+ .strip_suffix('\n')
68
+ .and_then(|s| s.strip_suffix('\r').or(Some(s)))
69
+ .unwrap_or(comment_text);
70
+ docs.push(literalline());
71
+ docs.push(text(body.to_string()));
72
+ docs.push(hardline());
73
+ } else {
74
+ docs.push(leading_comment(comment_text, true));
75
+ }
49
76
  }
50
77
 
51
78
  /// Formats leading comments before a given line.
@@ -68,6 +95,7 @@ pub fn format_leading_comments(ctx: &mut FormatContext, line: usize) -> Doc {
68
95
  idx,
69
96
  start_line: c.location.start_line,
70
97
  end_line: c.location.end_line,
98
+ is_block: matches!(c.comment_type, CommentType::Block),
71
99
  })
72
100
  })
73
101
  .collect();
@@ -90,7 +118,7 @@ pub fn format_leading_comments(ctx: &mut FormatContext, line: usize) -> Doc {
90
118
  }
91
119
 
92
120
  if let Some(comment) = ctx.get_comment(cref.idx) {
93
- docs.push(leading_comment(&comment.text, true));
121
+ push_leading_comment(&mut docs, &comment.text, cref.is_block);
94
122
  }
95
123
  last_end_line = Some(cref.end_line);
96
124
  indices_to_mark.push(cref.idx);
@@ -175,6 +203,7 @@ pub fn format_comments_before_end(
175
203
  idx,
176
204
  start_line: c.location.start_line,
177
205
  end_line: c.location.end_line,
206
+ is_block: matches!(c.comment_type, CommentType::Block),
178
207
  })
179
208
  } else {
180
209
  None
@@ -188,10 +217,28 @@ pub fn format_comments_before_end(
188
217
  }
189
218
 
190
219
  let mut docs: Vec<Doc> = vec![hardline()];
220
+ // Preserve the blank line that separated the body's last content from
221
+ // the first trailing comment. Without this, a construct like
222
+ //
223
+ // def foo
224
+ // body
225
+ // <- blank line
226
+ // # trailing annotation
227
+ // end
228
+ //
229
+ // collapses to `body\n# trailing annotation\nend`. Detect the case
230
+ // heuristically: if the source line immediately above the first
231
+ // standalone comment is blank, emit an extra hardline.
232
+ if let Some(first) = standalone_refs.first() {
233
+ if first.start_line > 1 && is_line_blank(ctx.source(), first.start_line - 1) {
234
+ docs.push(hardline());
235
+ }
236
+ }
191
237
  let mut last_end_line: Option<usize> = None;
192
238
  let mut indices_to_mark: Vec<usize> = Vec::with_capacity(standalone_refs.len());
193
239
 
194
- for cref in &standalone_refs {
240
+ let last_idx = standalone_refs.len().saturating_sub(1);
241
+ for (i, cref) in standalone_refs.iter().enumerate() {
195
242
  // Preserve blank lines between comments
196
243
  if let Some(prev_end) = last_end_line {
197
244
  let gap = cref.start_line.saturating_sub(prev_end);
@@ -201,7 +248,26 @@ pub fn format_comments_before_end(
201
248
  }
202
249
 
203
250
  if let Some(comment) = ctx.get_comment(cref.idx) {
204
- docs.push(leading_comment(&comment.text, true));
251
+ // The caller always emits its own `hardline + "end"` right after
252
+ // us, so the last comment must *not* also emit its own trailing
253
+ // newline — doing so produces a spurious blank line between the
254
+ // comment and the `end` keyword.
255
+ let hard_line_after = i != last_idx;
256
+ if cref.is_block {
257
+ let body = comment
258
+ .text
259
+ .strip_suffix('\n')
260
+ .and_then(|s| s.strip_suffix('\r').or(Some(s)))
261
+ .unwrap_or(&comment.text)
262
+ .to_string();
263
+ docs.push(literalline());
264
+ docs.push(text(body));
265
+ if hard_line_after {
266
+ docs.push(hardline());
267
+ }
268
+ } else {
269
+ docs.push(leading_comment(&comment.text, hard_line_after));
270
+ }
205
271
  }
206
272
  last_end_line = Some(cref.end_line);
207
273
  indices_to_mark.push(cref.idx);
@@ -233,6 +299,7 @@ pub fn format_remaining_comments(ctx: &mut FormatContext, last_code_line: usize)
233
299
  idx,
234
300
  start_line: c.location.start_line,
235
301
  end_line: c.location.end_line,
302
+ is_block: matches!(c.comment_type, CommentType::Block),
236
303
  })
237
304
  })
238
305
  .collect();
@@ -247,18 +314,41 @@ pub fn format_remaining_comments(ctx: &mut FormatContext, last_code_line: usize)
247
314
  let mut indices_to_mark: Vec<usize> = Vec::with_capacity(comment_refs.len());
248
315
 
249
316
  for cref in &comment_refs {
250
- // Preserve blank lines
317
+ // Preserve blank lines. On the first iteration we must emit *at
318
+ // least one* hardline to separate the first remaining comment from
319
+ // the main document's last token (otherwise an orphan comment whose
320
+ // `start_line <= last_code_line` would concatenate onto whatever
321
+ // ended the output — producing e.g. `end# comment…` when a block's
322
+ // internal comments fall through to this tail handler). Round-tripping
323
+ // the already-formatted output must still be idempotent, so we
324
+ // cap the emission at the number of line breaks visible in the
325
+ // source: 1 for an adjacent comment, N for N-1 blank lines above it.
251
326
  let gap = cref.start_line.saturating_sub(last_end_line);
252
327
 
253
- // Only add newlines if not the first comment or if there's a gap
254
- if !is_first || gap > 0 {
328
+ if is_first {
329
+ let hardlines_to_emit = gap.max(1);
330
+ for _ in 0..hardlines_to_emit {
331
+ docs.push(hardline());
332
+ }
333
+ } else if gap > 0 {
255
334
  for _ in 0..gap.max(1) {
256
335
  docs.push(hardline());
257
336
  }
258
337
  }
259
338
 
260
339
  if let Some(comment) = ctx.get_comment(cref.idx) {
261
- docs.push(leading_comment(&comment.text, false));
340
+ if cref.is_block {
341
+ let body = comment
342
+ .text
343
+ .strip_suffix('\n')
344
+ .and_then(|s| s.strip_suffix('\r').or(Some(s)))
345
+ .unwrap_or(&comment.text)
346
+ .to_string();
347
+ docs.push(literalline());
348
+ docs.push(text(body));
349
+ } else {
350
+ docs.push(leading_comment(&comment.text, false));
351
+ }
262
352
  }
263
353
  last_end_line = cref.end_line;
264
354
  is_first = false;
@@ -298,15 +388,46 @@ pub fn format_statements(
298
388
  let child_doc = format_child(child, ctx, registry)?;
299
389
  docs.push(child_doc);
300
390
 
301
- // Add newlines between statements
391
+ // Add newlines between statements. A pure line-number diff would add
392
+ // a blank-line hardline whenever two consecutive statements sit on
393
+ // lines that are more than 1 apart — but that gap may be occupied
394
+ // by one or more standalone comments, each of which gets emitted as
395
+ // a leading comment of the next statement and already supplies its
396
+ // own line break. Subtract the lines consumed by comments so we
397
+ // only preserve *actually* blank lines between statements.
302
398
  if let Some(next_child) = node.children.get(i + 1) {
303
399
  let current_end_line = child.location.end_line;
304
400
  let next_start_line = next_child.location.start_line;
305
401
  let line_diff = next_start_line.saturating_sub(current_end_line);
306
402
 
307
403
  docs.push(hardline());
404
+
308
405
  if line_diff > 1 {
309
- docs.push(hardline());
406
+ let (comment_lines_in_gap, gap_has_block): (usize, bool) = ctx
407
+ .get_comment_indices_in_range(current_end_line + 1, next_start_line)
408
+ .filter_map(|idx| ctx.get_comment(idx).cloned())
409
+ .fold((0usize, false), |(lines, had_block), c| {
410
+ let span = c.location.end_line.saturating_sub(c.location.start_line) + 1;
411
+ let is_block = matches!(c.comment_type, CommentType::Block);
412
+ (lines + span, had_block || is_block)
413
+ });
414
+ // `line_diff - 1` is the count of lines strictly between the
415
+ // two statements. Subtract comment-occupied lines to get the
416
+ // count of truly blank lines.
417
+ let mut blank_lines = line_diff
418
+ .saturating_sub(1)
419
+ .saturating_sub(comment_lines_in_gap);
420
+ // A block comment (`=begin/=end`) is emitted via
421
+ // `literalline + text + hardline`. The leading `literalline`
422
+ // already supplies one line break, so the normal
423
+ // blank-line hardline added here would produce one extra
424
+ // blank line above `=begin`. Deduct one.
425
+ if gap_has_block && blank_lines > 0 {
426
+ blank_lines -= 1;
427
+ }
428
+ if blank_lines >= 1 {
429
+ docs.push(hardline());
430
+ }
310
431
  }
311
432
  }
312
433
  }
@@ -314,24 +435,47 @@ pub fn format_statements(
314
435
  Ok(concat(docs))
315
436
  }
316
437
 
438
+ /// Returns the number of leading space/tab characters on the line containing `offset`.
439
+ ///
440
+ /// The source text extracted by `FormatContext::extract_source` starts at the node's
441
+ /// offset and does not include the whitespace that precedes the first line in the
442
+ /// original source. `Doc::Text` is printed verbatim without re-indenting embedded
443
+ /// newlines, so any reformatting that emits a multi-line string must include the
444
+ /// original leading indent itself.
445
+ pub fn line_leading_indent(source: &str, offset: usize) -> usize {
446
+ let offset = offset.min(source.len());
447
+ let line_start = source[..offset].rfind('\n').map(|p| p + 1).unwrap_or(0);
448
+ source.as_bytes()[line_start..offset]
449
+ .iter()
450
+ .take_while(|&&b| b == b' ' || b == b'\t')
451
+ .count()
452
+ }
453
+
317
454
  /// Reformats multiline method chain text with indented style.
318
455
  ///
319
456
  /// Converts aligned method chains to indented style:
320
457
  /// - First line is kept as-is (trimmed at end)
321
- /// - Subsequent lines starting with `.` or `&.` are re-indented with one level of indentation
458
+ /// - Subsequent lines starting with `.` or `&.` are re-indented to
459
+ /// `base_indent + indent_width` spaces
322
460
  ///
323
- /// Returns `Cow::Borrowed` when no transformation is needed to avoid allocation.
461
+ /// `base_indent` is the column at which the first line starts in the original source
462
+ /// (obtain via `line_leading_indent`). Because `Doc::Text` is printed verbatim without
463
+ /// re-indenting embedded newlines, this indent must be included in the returned string.
324
464
  ///
325
- /// # Arguments
326
- /// * `source_text` - The source text containing a method chain
327
- /// * `indent_width` - The number of spaces for one level of indentation
465
+ /// Returns `Cow::Borrowed` when no transformation is needed to avoid allocation.
328
466
  ///
329
467
  /// # Example
330
468
  /// ```text
331
- /// Input (indent_width=2): "foo.bar\n .baz"
332
- /// Output: "foo.bar\n .baz"
469
+ /// Input (base_indent=4, indent_width=2):
470
+ /// "foo.bar\n .baz"
471
+ /// Output:
472
+ /// "foo.bar\n .baz"
333
473
  /// ```
334
- pub fn reformat_chain_lines(source_text: &str, indent_width: usize) -> std::borrow::Cow<'_, str> {
474
+ pub fn reformat_chain_lines(
475
+ source_text: &str,
476
+ base_indent: usize,
477
+ indent_width: usize,
478
+ ) -> std::borrow::Cow<'_, str> {
335
479
  use std::borrow::Cow;
336
480
 
337
481
  let lines: Vec<&str> = source_text.lines().collect();
@@ -339,6 +483,21 @@ pub fn reformat_chain_lines(source_text: &str, indent_width: usize) -> std::borr
339
483
  return Cow::Borrowed(source_text);
340
484
  }
341
485
 
486
+ // Skip reformatting when the first line opens a new scope (a `{` brace
487
+ // lambda, a `do` block, or a `do |params|` block). The `.method` lines
488
+ // that follow are chain continuations *inside the block body*, not of
489
+ // the outer call — re-indenting them relative to the outer
490
+ // `base_indent` collapses the nested chain one level to the left and
491
+ // breaks the visual structure of the block body.
492
+ //
493
+ // This deliberately keeps the reformat conservative: for a top-level
494
+ // `User.active.where(...)` chain, line 1 ends with an identifier so
495
+ // we still rewrite aligned → indented as PR #100 intended.
496
+ let first_line = lines[0].trim_end();
497
+ if first_line.ends_with('{') || first_line.ends_with(" do") || first_line.ends_with('|') {
498
+ return Cow::Borrowed(source_text);
499
+ }
500
+
342
501
  // Check if there are actual chain continuation lines (. or &.)
343
502
  let has_chain = lines[1..].iter().any(|l| {
344
503
  let t = l.trim_start();
@@ -350,7 +509,7 @@ pub fn reformat_chain_lines(source_text: &str, indent_width: usize) -> std::borr
350
509
  }
351
510
 
352
511
  // Build the indented chain with pre-allocated capacity
353
- let chain_indent = " ".repeat(indent_width);
512
+ let chain_indent = " ".repeat(base_indent + indent_width);
354
513
  let mut result = String::with_capacity(source_text.len());
355
514
  result.push_str(lines[0].trim_end());
356
515
 
@@ -369,6 +528,45 @@ pub fn reformat_chain_lines(source_text: &str, indent_width: usize) -> std::borr
369
528
  Cow::Owned(result)
370
529
  }
371
530
 
531
+ /// Returns true when the given 1-based `line` in `source` contains only
532
+ /// whitespace (or is empty). Returns false for any line that has code or
533
+ /// a comment.
534
+ fn is_line_blank(source: &str, line: usize) -> bool {
535
+ let mut current = 1usize;
536
+ let mut line_start = 0usize;
537
+ for (i, b) in source.bytes().enumerate() {
538
+ if current == line {
539
+ let end = source[i..].find('\n').map_or(source.len(), |n| i + n);
540
+ return source[line_start..end]
541
+ .bytes()
542
+ .all(|b| b == b' ' || b == b'\t' || b == b'\r');
543
+ }
544
+ if b == b'\n' {
545
+ current += 1;
546
+ line_start = i + 1;
547
+ }
548
+ }
549
+ false
550
+ }
551
+
552
+ /// Removes at most one trailing `\n` (optionally preceded by a single `\r`)
553
+ /// from `s`. Spaces, tabs, and any additional preceding newlines are
554
+ /// preserved.
555
+ ///
556
+ /// This is used by source-extracting rules (FallbackRule, CallRule,
557
+ /// VariableWriteRule) to strip the terminator-line newline that Prism
558
+ /// includes in node extents for constructs like
559
+ /// `foo(<<~HEREDOC)\n…\nHEREDOC\n`. A full `trim_end` would also eat any
560
+ /// blank separator line that happens to fall inside the node's range,
561
+ /// collapsing the spacing between consecutive statements.
562
+ pub fn strip_one_trailing_newline(s: &str) -> &str {
563
+ if let Some(rest) = s.strip_suffix('\n') {
564
+ rest.strip_suffix('\r').unwrap_or(rest)
565
+ } else {
566
+ s
567
+ }
568
+ }
569
+
372
570
  /// Marks comments within a line range as emitted.
373
571
  ///
374
572
  /// This is used when source text is extracted directly, as any comments
@@ -528,28 +726,51 @@ mod tests {
528
726
  #[test]
529
727
  fn test_reformat_chain_lines_single_line() {
530
728
  let input = "foo.bar.baz";
531
- let result = reformat_chain_lines(input, 2);
729
+ let result = reformat_chain_lines(input, 0, 2);
532
730
  assert_eq!(result, "foo.bar.baz");
533
731
  }
534
732
 
535
733
  #[test]
536
734
  fn test_reformat_chain_lines_multiline_chain() {
537
735
  let input = "foo.bar\n .baz\n .qux";
538
- let result = reformat_chain_lines(input, 2);
736
+ let result = reformat_chain_lines(input, 0, 2);
539
737
  assert_eq!(result, "foo.bar\n .baz\n .qux");
540
738
  }
541
739
 
542
740
  #[test]
543
741
  fn test_reformat_chain_lines_safe_navigation() {
544
742
  let input = "foo&.bar\n &.baz";
545
- let result = reformat_chain_lines(input, 2);
743
+ let result = reformat_chain_lines(input, 0, 2);
546
744
  assert_eq!(result, "foo&.bar\n &.baz");
547
745
  }
548
746
 
549
747
  #[test]
550
748
  fn test_reformat_chain_lines_no_chain() {
551
749
  let input = "foo(\n arg1,\n arg2\n)";
552
- let result = reformat_chain_lines(input, 2);
750
+ let result = reformat_chain_lines(input, 0, 2);
553
751
  assert_eq!(result, input);
554
752
  }
753
+
754
+ #[test]
755
+ fn test_reformat_chain_lines_preserves_base_indent() {
756
+ // Simulates a chain inside a 4-space-indented method body:
757
+ // the caller must include base_indent so the printed continuation
758
+ // lines up with `base_indent + indent_width` columns.
759
+ let input = "foo.bar\n .baz\n .qux";
760
+ let result = reformat_chain_lines(input, 4, 2);
761
+ assert_eq!(result, "foo.bar\n .baz\n .qux");
762
+ }
763
+
764
+ #[test]
765
+ fn test_line_leading_indent_counts_spaces_and_tabs() {
766
+ let source = "def foo\n bar\n\tbaz\nqux\n";
767
+ let bar = source.find("bar").unwrap();
768
+ let baz = source.find("baz").unwrap();
769
+ let qux = source.find("qux").unwrap();
770
+ assert_eq!(line_leading_indent(source, bar), 4);
771
+ assert_eq!(line_leading_indent(source, baz), 1);
772
+ assert_eq!(line_leading_indent(source, qux), 0);
773
+ // Out-of-range offset is clamped.
774
+ assert_eq!(line_leading_indent(source, usize::MAX), 0);
775
+ }
555
776
  }