rfmt 1.6.0 → 1.6.1

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: 327f57f105df77a0d6b77bda9ab87c4f234f03f23fb918d74679942456c5a8ec
4
+ data.tar.gz: 482a5406275e422fb306970ffb47708f37be0c3115bc92485996d2adde6c9463
5
5
  SHA512:
6
- metadata.gz: 4169b3f6b4d2dae45fbddec1b299ca2f787b8a854999d03c3f63307e9af2ef5007076d74f96852e3624e3a0d269b9480c2fbe8064bb707e57863df71e7d88618
7
- data.tar.gz: fc8cd1357d92ca01a5829248a58bc42f4f731f4122274950e98d17daa7a51c0a53a0e35c3cbbc2132b8a489d474adaf4489be05e9ec41f37c4412d6da1e27ece
6
+ metadata.gz: 52982745650ed439f2ac7a5a1a9a26ae2dcf9e9df8015526278f58508929e15a1d72425d2d3c5963c0a4008968e20fcb6c374881122a3e99618d2cef15434eaf
7
+ data.tar.gz: 10e8282f05caacdffcbb2d7042e9f95a08c5d08c9b081307a12752840549fbb08b6bc557a256a9a46fd012986369fd7cfd0442a46d1d81146c2fb234d0b936f7
data/CHANGELOG.md CHANGED
@@ -1,5 +1,25 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [1.6.1] - 2026-04-24
4
+
5
+ Follow-up release consolidating the 1.6.0 architecture work.
6
+
7
+ ### Rule-based formatter
8
+
9
+ 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.
10
+
11
+ ### Method chain reformatting
12
+
13
+ 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.
14
+
15
+ ### Printer optimizations
16
+
17
+ 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.
18
+
19
+ ### Editor integration
20
+
21
+ 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.
22
+
3
23
  ## [1.6.0] - 2026-04-23
4
24
 
5
25
  ### 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.1"
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.1"
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
@@ -191,7 +220,8 @@ pub fn format_comments_before_end(
191
220
  let mut last_end_line: Option<usize> = None;
192
221
  let mut indices_to_mark: Vec<usize> = Vec::with_capacity(standalone_refs.len());
193
222
 
194
- for cref in &standalone_refs {
223
+ let last_idx = standalone_refs.len().saturating_sub(1);
224
+ for (i, cref) in standalone_refs.iter().enumerate() {
195
225
  // Preserve blank lines between comments
196
226
  if let Some(prev_end) = last_end_line {
197
227
  let gap = cref.start_line.saturating_sub(prev_end);
@@ -201,7 +231,26 @@ pub fn format_comments_before_end(
201
231
  }
202
232
 
203
233
  if let Some(comment) = ctx.get_comment(cref.idx) {
204
- docs.push(leading_comment(&comment.text, true));
234
+ // The caller always emits its own `hardline + "end"` right after
235
+ // us, so the last comment must *not* also emit its own trailing
236
+ // newline — doing so produces a spurious blank line between the
237
+ // comment and the `end` keyword.
238
+ let hard_line_after = i != last_idx;
239
+ if cref.is_block {
240
+ let body = comment
241
+ .text
242
+ .strip_suffix('\n')
243
+ .and_then(|s| s.strip_suffix('\r').or(Some(s)))
244
+ .unwrap_or(&comment.text)
245
+ .to_string();
246
+ docs.push(literalline());
247
+ docs.push(text(body));
248
+ if hard_line_after {
249
+ docs.push(hardline());
250
+ }
251
+ } else {
252
+ docs.push(leading_comment(&comment.text, hard_line_after));
253
+ }
205
254
  }
206
255
  last_end_line = Some(cref.end_line);
207
256
  indices_to_mark.push(cref.idx);
@@ -233,6 +282,7 @@ pub fn format_remaining_comments(ctx: &mut FormatContext, last_code_line: usize)
233
282
  idx,
234
283
  start_line: c.location.start_line,
235
284
  end_line: c.location.end_line,
285
+ is_block: matches!(c.comment_type, CommentType::Block),
236
286
  })
237
287
  })
238
288
  .collect();
@@ -258,7 +308,18 @@ pub fn format_remaining_comments(ctx: &mut FormatContext, last_code_line: usize)
258
308
  }
259
309
 
260
310
  if let Some(comment) = ctx.get_comment(cref.idx) {
261
- docs.push(leading_comment(&comment.text, false));
311
+ if cref.is_block {
312
+ let body = comment
313
+ .text
314
+ .strip_suffix('\n')
315
+ .and_then(|s| s.strip_suffix('\r').or(Some(s)))
316
+ .unwrap_or(&comment.text)
317
+ .to_string();
318
+ docs.push(literalline());
319
+ docs.push(text(body));
320
+ } else {
321
+ docs.push(leading_comment(&comment.text, false));
322
+ }
262
323
  }
263
324
  last_end_line = cref.end_line;
264
325
  is_first = false;
@@ -298,15 +359,46 @@ pub fn format_statements(
298
359
  let child_doc = format_child(child, ctx, registry)?;
299
360
  docs.push(child_doc);
300
361
 
301
- // Add newlines between statements
362
+ // Add newlines between statements. A pure line-number diff would add
363
+ // a blank-line hardline whenever two consecutive statements sit on
364
+ // lines that are more than 1 apart — but that gap may be occupied
365
+ // by one or more standalone comments, each of which gets emitted as
366
+ // a leading comment of the next statement and already supplies its
367
+ // own line break. Subtract the lines consumed by comments so we
368
+ // only preserve *actually* blank lines between statements.
302
369
  if let Some(next_child) = node.children.get(i + 1) {
303
370
  let current_end_line = child.location.end_line;
304
371
  let next_start_line = next_child.location.start_line;
305
372
  let line_diff = next_start_line.saturating_sub(current_end_line);
306
373
 
307
374
  docs.push(hardline());
375
+
308
376
  if line_diff > 1 {
309
- docs.push(hardline());
377
+ let (comment_lines_in_gap, gap_has_block): (usize, bool) = ctx
378
+ .get_comment_indices_in_range(current_end_line + 1, next_start_line)
379
+ .filter_map(|idx| ctx.get_comment(idx).cloned())
380
+ .fold((0usize, false), |(lines, had_block), c| {
381
+ let span = c.location.end_line.saturating_sub(c.location.start_line) + 1;
382
+ let is_block = matches!(c.comment_type, CommentType::Block);
383
+ (lines + span, had_block || is_block)
384
+ });
385
+ // `line_diff - 1` is the count of lines strictly between the
386
+ // two statements. Subtract comment-occupied lines to get the
387
+ // count of truly blank lines.
388
+ let mut blank_lines = line_diff
389
+ .saturating_sub(1)
390
+ .saturating_sub(comment_lines_in_gap);
391
+ // A block comment (`=begin/=end`) is emitted via
392
+ // `literalline + text + hardline`. The leading `literalline`
393
+ // already supplies one line break, so the normal
394
+ // blank-line hardline added here would produce one extra
395
+ // blank line above `=begin`. Deduct one.
396
+ if gap_has_block && blank_lines > 0 {
397
+ blank_lines -= 1;
398
+ }
399
+ if blank_lines >= 1 {
400
+ docs.push(hardline());
401
+ }
310
402
  }
311
403
  }
312
404
  }
@@ -314,24 +406,47 @@ pub fn format_statements(
314
406
  Ok(concat(docs))
315
407
  }
316
408
 
409
+ /// Returns the number of leading space/tab characters on the line containing `offset`.
410
+ ///
411
+ /// The source text extracted by `FormatContext::extract_source` starts at the node's
412
+ /// offset and does not include the whitespace that precedes the first line in the
413
+ /// original source. `Doc::Text` is printed verbatim without re-indenting embedded
414
+ /// newlines, so any reformatting that emits a multi-line string must include the
415
+ /// original leading indent itself.
416
+ pub fn line_leading_indent(source: &str, offset: usize) -> usize {
417
+ let offset = offset.min(source.len());
418
+ let line_start = source[..offset].rfind('\n').map(|p| p + 1).unwrap_or(0);
419
+ source.as_bytes()[line_start..offset]
420
+ .iter()
421
+ .take_while(|&&b| b == b' ' || b == b'\t')
422
+ .count()
423
+ }
424
+
317
425
  /// Reformats multiline method chain text with indented style.
318
426
  ///
319
427
  /// Converts aligned method chains to indented style:
320
428
  /// - First line is kept as-is (trimmed at end)
321
- /// - Subsequent lines starting with `.` or `&.` are re-indented with one level of indentation
429
+ /// - Subsequent lines starting with `.` or `&.` are re-indented to
430
+ /// `base_indent + indent_width` spaces
322
431
  ///
323
- /// Returns `Cow::Borrowed` when no transformation is needed to avoid allocation.
432
+ /// `base_indent` is the column at which the first line starts in the original source
433
+ /// (obtain via `line_leading_indent`). Because `Doc::Text` is printed verbatim without
434
+ /// re-indenting embedded newlines, this indent must be included in the returned string.
324
435
  ///
325
- /// # Arguments
326
- /// * `source_text` - The source text containing a method chain
327
- /// * `indent_width` - The number of spaces for one level of indentation
436
+ /// Returns `Cow::Borrowed` when no transformation is needed to avoid allocation.
328
437
  ///
329
438
  /// # Example
330
439
  /// ```text
331
- /// Input (indent_width=2): "foo.bar\n .baz"
332
- /// Output: "foo.bar\n .baz"
440
+ /// Input (base_indent=4, indent_width=2):
441
+ /// "foo.bar\n .baz"
442
+ /// Output:
443
+ /// "foo.bar\n .baz"
333
444
  /// ```
334
- pub fn reformat_chain_lines(source_text: &str, indent_width: usize) -> std::borrow::Cow<'_, str> {
445
+ pub fn reformat_chain_lines(
446
+ source_text: &str,
447
+ base_indent: usize,
448
+ indent_width: usize,
449
+ ) -> std::borrow::Cow<'_, str> {
335
450
  use std::borrow::Cow;
336
451
 
337
452
  let lines: Vec<&str> = source_text.lines().collect();
@@ -339,6 +454,21 @@ pub fn reformat_chain_lines(source_text: &str, indent_width: usize) -> std::borr
339
454
  return Cow::Borrowed(source_text);
340
455
  }
341
456
 
457
+ // Skip reformatting when the first line opens a new scope (a `{` brace
458
+ // lambda, a `do` block, or a `do |params|` block). The `.method` lines
459
+ // that follow are chain continuations *inside the block body*, not of
460
+ // the outer call — re-indenting them relative to the outer
461
+ // `base_indent` collapses the nested chain one level to the left and
462
+ // breaks the visual structure of the block body.
463
+ //
464
+ // This deliberately keeps the reformat conservative: for a top-level
465
+ // `User.active.where(...)` chain, line 1 ends with an identifier so
466
+ // we still rewrite aligned → indented as PR #100 intended.
467
+ let first_line = lines[0].trim_end();
468
+ if first_line.ends_with('{') || first_line.ends_with(" do") || first_line.ends_with('|') {
469
+ return Cow::Borrowed(source_text);
470
+ }
471
+
342
472
  // Check if there are actual chain continuation lines (. or &.)
343
473
  let has_chain = lines[1..].iter().any(|l| {
344
474
  let t = l.trim_start();
@@ -350,7 +480,7 @@ pub fn reformat_chain_lines(source_text: &str, indent_width: usize) -> std::borr
350
480
  }
351
481
 
352
482
  // Build the indented chain with pre-allocated capacity
353
- let chain_indent = " ".repeat(indent_width);
483
+ let chain_indent = " ".repeat(base_indent + indent_width);
354
484
  let mut result = String::with_capacity(source_text.len());
355
485
  result.push_str(lines[0].trim_end());
356
486
 
@@ -369,6 +499,24 @@ pub fn reformat_chain_lines(source_text: &str, indent_width: usize) -> std::borr
369
499
  Cow::Owned(result)
370
500
  }
371
501
 
502
+ /// Removes at most one trailing `\n` (optionally preceded by a single `\r`)
503
+ /// from `s`. Spaces, tabs, and any additional preceding newlines are
504
+ /// preserved.
505
+ ///
506
+ /// This is used by source-extracting rules (FallbackRule, CallRule,
507
+ /// VariableWriteRule) to strip the terminator-line newline that Prism
508
+ /// includes in node extents for constructs like
509
+ /// `foo(<<~HEREDOC)\n…\nHEREDOC\n`. A full `trim_end` would also eat any
510
+ /// blank separator line that happens to fall inside the node's range,
511
+ /// collapsing the spacing between consecutive statements.
512
+ pub fn strip_one_trailing_newline(s: &str) -> &str {
513
+ if let Some(rest) = s.strip_suffix('\n') {
514
+ rest.strip_suffix('\r').unwrap_or(rest)
515
+ } else {
516
+ s
517
+ }
518
+ }
519
+
372
520
  /// Marks comments within a line range as emitted.
373
521
  ///
374
522
  /// This is used when source text is extracted directly, as any comments
@@ -528,28 +676,51 @@ mod tests {
528
676
  #[test]
529
677
  fn test_reformat_chain_lines_single_line() {
530
678
  let input = "foo.bar.baz";
531
- let result = reformat_chain_lines(input, 2);
679
+ let result = reformat_chain_lines(input, 0, 2);
532
680
  assert_eq!(result, "foo.bar.baz");
533
681
  }
534
682
 
535
683
  #[test]
536
684
  fn test_reformat_chain_lines_multiline_chain() {
537
685
  let input = "foo.bar\n .baz\n .qux";
538
- let result = reformat_chain_lines(input, 2);
686
+ let result = reformat_chain_lines(input, 0, 2);
539
687
  assert_eq!(result, "foo.bar\n .baz\n .qux");
540
688
  }
541
689
 
542
690
  #[test]
543
691
  fn test_reformat_chain_lines_safe_navigation() {
544
692
  let input = "foo&.bar\n &.baz";
545
- let result = reformat_chain_lines(input, 2);
693
+ let result = reformat_chain_lines(input, 0, 2);
546
694
  assert_eq!(result, "foo&.bar\n &.baz");
547
695
  }
548
696
 
549
697
  #[test]
550
698
  fn test_reformat_chain_lines_no_chain() {
551
699
  let input = "foo(\n arg1,\n arg2\n)";
552
- let result = reformat_chain_lines(input, 2);
700
+ let result = reformat_chain_lines(input, 0, 2);
553
701
  assert_eq!(result, input);
554
702
  }
703
+
704
+ #[test]
705
+ fn test_reformat_chain_lines_preserves_base_indent() {
706
+ // Simulates a chain inside a 4-space-indented method body:
707
+ // the caller must include base_indent so the printed continuation
708
+ // lines up with `base_indent + indent_width` columns.
709
+ let input = "foo.bar\n .baz\n .qux";
710
+ let result = reformat_chain_lines(input, 4, 2);
711
+ assert_eq!(result, "foo.bar\n .baz\n .qux");
712
+ }
713
+
714
+ #[test]
715
+ fn test_line_leading_indent_counts_spaces_and_tabs() {
716
+ let source = "def foo\n bar\n\tbaz\nqux\n";
717
+ let bar = source.find("bar").unwrap();
718
+ let baz = source.find("baz").unwrap();
719
+ let qux = source.find("qux").unwrap();
720
+ assert_eq!(line_leading_indent(source, bar), 4);
721
+ assert_eq!(line_leading_indent(source, baz), 1);
722
+ assert_eq!(line_leading_indent(source, qux), 0);
723
+ // Out-of-range offset is clamped.
724
+ assert_eq!(line_leading_indent(source, usize::MAX), 0);
725
+ }
555
726
  }