rfmt 1.7.0 → 2.0.0.beta1

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.
Files changed (42) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +25 -0
  3. data/Cargo.lock +90 -1225
  4. data/Cargo.toml +6 -0
  5. data/README.md +32 -28
  6. data/ext/rfmt/Cargo.toml +9 -28
  7. data/ext/rfmt/src/ast/mod.rs +2 -0
  8. data/ext/rfmt/src/config/mod.rs +267 -30
  9. data/ext/rfmt/src/error/mod.rs +14 -3
  10. data/ext/rfmt/src/format/formatter.rs +22 -11
  11. data/ext/rfmt/src/format/registry.rs +8 -0
  12. data/ext/rfmt/src/format/rule.rs +208 -136
  13. data/ext/rfmt/src/format/rules/call.rs +14 -22
  14. data/ext/rfmt/src/format/rules/fallback.rs +4 -11
  15. data/ext/rfmt/src/format/rules/if_unless.rs +25 -3
  16. data/ext/rfmt/src/format/rules/loops.rs +3 -1
  17. data/ext/rfmt/src/format/rules/variable_write.rs +16 -9
  18. data/ext/rfmt/src/lib.rs +45 -12
  19. data/ext/rfmt/src/parser/mod.rs +2 -0
  20. data/ext/rfmt/src/parser/native_adapter.rs +2735 -0
  21. data/ext/rfmt/src/parser/prism_adapter.rs +5 -0
  22. data/ext/rfmt/src/validation.rs +69 -0
  23. data/ext/rfmt/tests/fixtures/parity/comments_mixed.rb +9 -0
  24. data/ext/rfmt/tests/fixtures/parity/constructs.rb +50 -0
  25. data/ext/rfmt/tests/fixtures/parity/embdoc.rb +12 -0
  26. data/ext/rfmt/tests/fixtures/parity/heredoc_assign.rb +15 -0
  27. data/ext/rfmt/tests/fixtures/parity/heredoc_call_args.rb +17 -0
  28. data/ext/rfmt/tests/fixtures/parity/metadata_classes.rb +30 -0
  29. data/ext/rfmt/tests/fixtures/parity/metadata_conditionals.rb +14 -0
  30. data/ext/rfmt/tests/fixtures/parity/metadata_defs.rb +33 -0
  31. data/ext/rfmt/tests/fixtures/parity/multibyte.rb +14 -0
  32. data/ext/rfmt/tests/fixtures/parity/numeric.rb +6 -0
  33. data/ext/rfmt/tests/fixtures/parity/plain.rb +31 -0
  34. data/ext/rfmt/tests/native_parity.rs +245 -0
  35. data/ext/rfmt/tests/ruby_prism_smoke.rs +66 -0
  36. data/lib/rfmt/cli.rb +37 -7
  37. data/lib/rfmt/configuration.rb +2 -20
  38. data/lib/rfmt/version.rb +1 -1
  39. data/lib/rfmt.rb +47 -19
  40. metadata +17 -18
  41. data/lib/rfmt/prism_bridge.rb +0 -529
  42. data/lib/rfmt/prism_node_extractor.rb +0 -115
@@ -3,8 +3,12 @@
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 std::collections::VecDeque;
7
+
6
8
  use crate::ast::{CommentType, Node};
7
- use crate::doc::{concat, hardline, leading_comment, literalline, text, trailing_comment, Doc};
9
+ use crate::doc::{
10
+ concat, hardline, indent, leading_comment, literalline, text, trailing_comment, Doc,
11
+ };
8
12
  use crate::error::Result;
9
13
 
10
14
  use super::context::FormatContext;
@@ -437,139 +441,186 @@ pub fn format_statements(
437
441
 
438
442
  /// Returns the number of leading space/tab characters on the line containing `offset`.
439
443
  ///
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
-
454
- /// Reformats multiline method chain text with indented style.
455
- ///
456
- /// Converts aligned method chains to indented style:
444
+ /// Reformats a multiline method chain into a Doc that lets the printer own
445
+ /// line anchoring, so the output is independent of the statement's column
446
+ /// in the INPUT source (misindented input formats the same as clean input):
457
447
  /// - First line is kept as-is (trimmed at end)
458
- /// - Subsequent lines starting with `.` or `&.` are re-indented to
459
- /// `base_indent + indent_width` spaces
460
- ///
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.
448
+ /// - Chain continuation lines (`.` / `&.`) become `hardline` + trimmed body
449
+ /// inside `indent(...)`, rendering one indent level below wherever the
450
+ /// statement lands in the output
451
+ /// - Other lines at or beyond the original chain indent (multi-line
452
+ /// arguments of a chain call) keep their offset RELATIVE to the chain
453
+ /// lines, baked as leading spaces on top of the hardline anchor
454
+ /// - Heredoc body lines and shallower lines are emitted verbatim after a
455
+ /// `literalline` — for plain/dash heredocs the leading whitespace is
456
+ /// string content, so shifting it would change program semantics
464
457
  ///
465
- /// Returns `Cow::Borrowed` when no transformation is needed to avoid allocation.
466
- ///
467
- /// # Example
468
- /// ```text
469
- /// Input (base_indent=4, indent_width=2):
470
- /// "foo.bar\n .baz"
471
- /// Output:
472
- /// "foo.bar\n .baz"
473
- /// ```
474
- pub fn reformat_chain_lines(
475
- source_text: &str,
476
- base_indent: usize,
477
- indent_width: usize,
478
- ) -> std::borrow::Cow<'_, str> {
479
- use std::borrow::Cow;
480
-
458
+ /// Returns `None` when no transformation applies; callers fall back to the
459
+ /// verbatim source text.
460
+ pub fn reformat_chain_doc(source_text: &str) -> Option<Doc> {
481
461
  let lines: Vec<&str> = source_text.lines().collect();
482
462
  if lines.len() <= 1 {
483
- return Cow::Borrowed(source_text);
463
+ return None;
484
464
  }
485
465
 
486
466
  // Skip reformatting when the first line opens a new scope (a `{` brace
487
467
  // lambda, a `do` block, or a `do |params|` block). The `.method` lines
488
468
  // 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.
469
+ // the outer call — re-indenting them relative to the outer statement
470
+ // collapses the nested chain one level to the left and breaks the
471
+ // visual structure of the block body.
492
472
  //
493
473
  // This deliberately keeps the reformat conservative: for a top-level
494
474
  // `User.active.where(...)` chain, line 1 ends with an identifier so
495
475
  // we still rewrite aligned → indented as PR #100 intended.
496
476
  let first_line = lines[0].trim_end();
497
477
  if first_line.ends_with('{') || first_line.ends_with(" do") || first_line.ends_with('|') {
498
- return Cow::Borrowed(source_text);
478
+ return None;
499
479
  }
500
480
 
501
- // Check if there are actual chain continuation lines (. or &.)
502
- let has_chain = lines[1..].iter().any(|l| {
503
- let t = l.trim_start();
504
- t.starts_with('.') || t.starts_with("&.")
505
- });
506
-
507
- if !has_chain {
508
- return Cow::Borrowed(source_text);
481
+ // Cheap pre-check before the heredoc scan; a false positive from a
482
+ // `.`-leading heredoc body line only costs the scan below, whose masked
483
+ // chain_indent search then bails.
484
+ if !lines[1..]
485
+ .iter()
486
+ .any(|l| is_chain_continuation(l.trim_start()))
487
+ {
488
+ return None;
509
489
  }
510
490
 
511
- // Determine how much the chain is moving left. Before this pass, every
512
- // `.method` line sat at some original "chain indent" (most commonly
513
- // aligned under the first receiver's dot). We rewrite those lines to
514
- // `base_indent + indent_width` but that also means any multi-line
515
- // *arguments* that lived inside a chain call like `.select( … )` used
516
- // to be deeper than the original chain indent, and will now look
517
- // orphaned off to the right if we leave them alone:
518
- //
519
- // @users = User.left_joins(...)
520
- // .select( <- was col 17, becomes col 6
521
- // 'users.*, ' \ <- still at col 19 — orphaned
522
- // )
523
- // .having(...)
524
- //
525
- // Compute the delta between the original chain indent and the new
526
- // one, and shift every non-chain continuation line that lives at
527
- // (or below) the original chain indent by the same amount. Lines
528
- // shallower than the original chain indent (e.g. a heredoc body
529
- // whose squiggly indent is measured from the terminator) are left
530
- // alone, so we don't accidentally eat through them.
531
- let new_chain_indent = base_indent + indent_width;
532
- let chain_indent_str = " ".repeat(new_chain_indent);
533
-
534
- let original_chain_indent = lines[1..].iter().find_map(|l| {
491
+ let heredoc_body = heredoc_body_lines(&lines);
492
+
493
+ // The original chain indent anchors the relative offsets of multi-line
494
+ // argument lines; without any chain continuation line there is nothing
495
+ // to reformat.
496
+ let chain_indent = lines.iter().enumerate().skip(1).find_map(|(i, l)| {
497
+ if heredoc_body[i] {
498
+ return None;
499
+ }
535
500
  let t = l.trim_start();
536
- if t.starts_with('.') || t.starts_with("&.") {
501
+ if is_chain_continuation(t) {
537
502
  Some(l.len() - t.len())
538
503
  } else {
539
504
  None
540
505
  }
541
- });
542
-
543
- let arg_shift = original_chain_indent
544
- .map(|orig| orig.saturating_sub(new_chain_indent))
545
- .unwrap_or(0);
546
-
547
- let mut result = String::with_capacity(source_text.len());
548
- result.push_str(lines[0].trim_end());
549
-
550
- for line in &lines[1..] {
551
- result.push('\n');
552
- let trimmed = line.trim();
553
- if trimmed.starts_with('.') || trimmed.starts_with("&.") {
554
- result.push_str(&chain_indent_str);
555
- result.push_str(trimmed);
556
- } else if arg_shift > 0 && !trimmed.is_empty() {
557
- let indent = line.len() - line.trim_start().len();
558
- let chain_base = original_chain_indent.unwrap_or(0);
559
- if indent >= chain_base {
560
- let new_indent = indent - arg_shift;
561
- result.push_str(&" ".repeat(new_indent));
562
- result.push_str(line.trim_start());
506
+ })?;
507
+
508
+ let mut rest: Vec<Doc> = Vec::with_capacity((lines.len() - 1) * 2);
509
+ for (i, line) in lines.iter().enumerate().skip(1) {
510
+ let body = line.trim_start();
511
+ let indent_cols = line.len() - body.len();
512
+ if heredoc_body[i] {
513
+ rest.push(literalline());
514
+ rest.push(text((*line).to_string()));
515
+ } else if is_chain_continuation(body) {
516
+ rest.push(hardline());
517
+ rest.push(text(body.trim_end().to_string()));
518
+ } else if !body.is_empty() && indent_cols >= chain_indent {
519
+ // Multi-line arguments inside a chain call like `.select()`
520
+ // move together with the chain lines they belong to.
521
+ rest.push(hardline());
522
+ rest.push(text(format!(
523
+ "{}{}",
524
+ " ".repeat(indent_cols - chain_indent),
525
+ body.trim_end()
526
+ )));
527
+ } else {
528
+ rest.push(literalline());
529
+ rest.push(text((*line).to_string()));
530
+ }
531
+ }
532
+
533
+ Some(concat(vec![
534
+ text(first_line.to_string()),
535
+ indent(concat(rest)),
536
+ ]))
537
+ }
538
+
539
+ /// `reformat_chain_doc` with the shared fallback: verbatim source text,
540
+ /// stripped of one trailing newline.
541
+ pub fn chain_doc_or_verbatim(source_text: &str) -> Doc {
542
+ reformat_chain_doc(source_text)
543
+ .unwrap_or_else(|| text(strip_one_trailing_newline(source_text).to_string()))
544
+ }
545
+
546
+ fn is_chain_continuation(trimmed: &str) -> bool {
547
+ trimmed.starts_with('.') || trimmed.starts_with("&.")
548
+ }
549
+
550
+ struct HeredocOpener {
551
+ terminator: String,
552
+ /// `<<-` / `<<~` allow the terminator line to be indented.
553
+ indented_terminator: bool,
554
+ }
555
+
556
+ /// Lexically marks lines that are heredoc bodies (including terminator
557
+ /// lines). Openers on one line stack in source order, and each body runs
558
+ /// until its terminator. When a terminator is ambiguous (e.g. a stray `\r`)
559
+ /// this errs toward marking more lines as body, which only makes the
560
+ /// reformat emit them verbatim — the semantics-safe direction.
561
+ fn heredoc_body_lines(lines: &[&str]) -> Vec<bool> {
562
+ let mut body = vec![false; lines.len()];
563
+ let mut pending: VecDeque<HeredocOpener> = VecDeque::new();
564
+ for (i, line) in lines.iter().enumerate() {
565
+ if let Some(front) = pending.front() {
566
+ body[i] = true;
567
+ let candidate = if front.indented_terminator {
568
+ line.trim_start()
563
569
  } else {
564
- result.push_str(line);
570
+ line
571
+ };
572
+ if candidate.trim_end_matches('\r') == front.terminator {
573
+ pending.pop_front();
565
574
  }
566
- } else {
567
- // Non-chain continuation (e.g., heredoc content): preserve as-is
568
- result.push_str(line);
575
+ continue;
569
576
  }
577
+ scan_heredoc_openers(line, &mut pending);
570
578
  }
579
+ body
580
+ }
571
581
 
572
- Cow::Owned(result)
582
+ /// Finds `<<ID` / `<<-ID` / `<<~ID` openers (identifier optionally wrapped
583
+ /// in `"` / `'` / `` ` ``) in a code line. Purely lexical: openers inside
584
+ /// string literals or comments are false positives, tolerated because they
585
+ /// only cause verbatim emission.
586
+ fn scan_heredoc_openers(line: &str, pending: &mut VecDeque<HeredocOpener>) {
587
+ let bytes = line.as_bytes();
588
+ let mut i = 0;
589
+ while i + 2 < bytes.len() {
590
+ if bytes[i] != b'<' || bytes[i + 1] != b'<' {
591
+ i += 1;
592
+ continue;
593
+ }
594
+ let mut j = i + 2;
595
+ let indented = matches!(bytes.get(j), Some(b'-') | Some(b'~'));
596
+ if indented {
597
+ j += 1;
598
+ }
599
+ let quote = match bytes.get(j) {
600
+ Some(&q @ (b'"' | b'\'' | b'`')) => {
601
+ j += 1;
602
+ Some(q)
603
+ }
604
+ _ => None,
605
+ };
606
+ let start = j;
607
+ while j < bytes.len() && (bytes[j].is_ascii_alphanumeric() || bytes[j] == b'_') {
608
+ j += 1;
609
+ }
610
+ let valid = j > start
611
+ && (bytes[start].is_ascii_alphabetic() || bytes[start] == b'_')
612
+ && quote.is_none_or(|q| bytes.get(j) == Some(&q));
613
+ if !valid {
614
+ // Skip both `<` so `a <<< b` or `x <<= y` cannot rescan.
615
+ i += 2;
616
+ continue;
617
+ }
618
+ pending.push_back(HeredocOpener {
619
+ terminator: line[start..j].to_string(),
620
+ indented_terminator: indented,
621
+ });
622
+ i = j + usize::from(quote.is_some());
623
+ }
573
624
  }
574
625
 
575
626
  /// Returns true when the given 1-based `line` in `source` contains only
@@ -767,54 +818,75 @@ mod tests {
767
818
  assert!(!matches!(doc, Doc::Empty));
768
819
  }
769
820
 
821
+ fn print(doc: Doc) -> String {
822
+ let config = Config::default();
823
+ let mut printer = crate::doc::Printer::new(&config);
824
+ printer.print(&doc)
825
+ }
826
+
770
827
  #[test]
771
- fn test_reformat_chain_lines_single_line() {
772
- let input = "foo.bar.baz";
773
- let result = reformat_chain_lines(input, 0, 2);
774
- assert_eq!(result, "foo.bar.baz");
828
+ fn test_reformat_chain_doc_single_line() {
829
+ assert!(reformat_chain_doc("foo.bar.baz").is_none());
775
830
  }
776
831
 
777
832
  #[test]
778
- fn test_reformat_chain_lines_multiline_chain() {
833
+ fn test_reformat_chain_doc_multiline_chain() {
779
834
  let input = "foo.bar\n .baz\n .qux";
780
- let result = reformat_chain_lines(input, 0, 2);
781
- assert_eq!(result, "foo.bar\n .baz\n .qux");
835
+ let doc = reformat_chain_doc(input).unwrap();
836
+ assert_eq!(print(doc), "foo.bar\n .baz\n .qux\n");
782
837
  }
783
838
 
784
839
  #[test]
785
- fn test_reformat_chain_lines_safe_navigation() {
840
+ fn test_reformat_chain_doc_safe_navigation() {
786
841
  let input = "foo&.bar\n &.baz";
787
- let result = reformat_chain_lines(input, 0, 2);
788
- assert_eq!(result, "foo&.bar\n &.baz");
842
+ let doc = reformat_chain_doc(input).unwrap();
843
+ assert_eq!(print(doc), "foo&.bar\n &.baz\n");
789
844
  }
790
845
 
791
846
  #[test]
792
- fn test_reformat_chain_lines_no_chain() {
793
- let input = "foo(\n arg1,\n arg2\n)";
794
- let result = reformat_chain_lines(input, 0, 2);
795
- assert_eq!(result, input);
847
+ fn test_reformat_chain_doc_no_chain() {
848
+ assert!(reformat_chain_doc("foo(\n arg1,\n arg2\n)").is_none());
796
849
  }
797
850
 
798
851
  #[test]
799
- fn test_reformat_chain_lines_preserves_base_indent() {
800
- // Simulates a chain inside a 4-space-indented method body:
801
- // the caller must include base_indent so the printed continuation
802
- // lines up with `base_indent + indent_width` columns.
803
- let input = "foo.bar\n .baz\n .qux";
804
- let result = reformat_chain_lines(input, 4, 2);
805
- assert_eq!(result, "foo.bar\n .baz\n .qux");
852
+ fn test_reformat_chain_doc_independent_of_input_column() {
853
+ // The doc carries no absolute indent: differently misindented
854
+ // inputs of the same chain print identically.
855
+ let aligned = "foo.bar\n .baz";
856
+ let flush = "foo.bar\n.baz";
857
+ let a = print(reformat_chain_doc(aligned).unwrap());
858
+ let b = print(reformat_chain_doc(flush).unwrap());
859
+ assert_eq!(a, b);
860
+ assert_eq!(a, "foo.bar\n .baz\n");
861
+ }
862
+
863
+ #[test]
864
+ fn test_reformat_chain_doc_argument_lines_stay_relative_to_chain() {
865
+ let input = "u = User.all\n .select(\n :id,\n )\n .to_a";
866
+ let doc = reformat_chain_doc(input).unwrap();
867
+ assert_eq!(
868
+ print(doc),
869
+ "u = User.all\n .select(\n :id,\n )\n .to_a\n"
870
+ );
871
+ }
872
+
873
+ #[test]
874
+ fn test_reformat_chain_doc_keeps_plain_heredoc_body_verbatim() {
875
+ let input = "result = base\n .where(<<SQL)\n a = 1\nSQL\n .order(:id)";
876
+ let doc = reformat_chain_doc(input).unwrap();
877
+ assert_eq!(
878
+ print(doc),
879
+ "result = base\n .where(<<SQL)\n a = 1\nSQL\n .order(:id)\n"
880
+ );
806
881
  }
807
882
 
808
883
  #[test]
809
- fn test_line_leading_indent_counts_spaces_and_tabs() {
810
- let source = "def foo\n bar\n\tbaz\nqux\n";
811
- let bar = source.find("bar").unwrap();
812
- let baz = source.find("baz").unwrap();
813
- let qux = source.find("qux").unwrap();
814
- assert_eq!(line_leading_indent(source, bar), 4);
815
- assert_eq!(line_leading_indent(source, baz), 1);
816
- assert_eq!(line_leading_indent(source, qux), 0);
817
- // Out-of-range offset is clamped.
818
- assert_eq!(line_leading_indent(source, usize::MAX), 0);
884
+ fn test_reformat_chain_doc_stacked_heredoc_openers() {
885
+ let input = "r = base\n .merge(<<-A, <<~\"B\")\n one\n A\n two\n B\n .to_a";
886
+ let doc = reformat_chain_doc(input).unwrap();
887
+ assert_eq!(
888
+ print(doc),
889
+ "r = base\n .merge(<<-A, <<~\"B\")\n one\n A\n two\n B\n .to_a\n"
890
+ );
819
891
  }
820
892
  }
@@ -5,17 +5,15 @@
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
-
10
8
  use crate::ast::{Node, NodeType};
11
9
  use crate::doc::{align, concat, hardline, indent, text, Doc};
12
10
  use crate::error::Result;
13
11
  use crate::format::context::FormatContext;
14
12
  use crate::format::registry::RuleRegistry;
15
13
  use crate::format::rule::{
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,
14
+ chain_doc_or_verbatim, format_child, format_comments_before_end, format_leading_comments,
15
+ format_statements, format_trailing_comment, mark_comments_in_range_emitted, reformat_chain_doc,
16
+ FormatRule,
19
17
  };
20
18
 
21
19
  /// Rule for formatting method calls.
@@ -114,14 +112,7 @@ fn format_call(node: &Node, ctx: &mut FormatContext, registry: &RuleRegistry) ->
114
112
  // the full `trim_end` here would instead eat a blank separator line
115
113
  // that legitimately belongs between statements.
116
114
  if let Some(source_text) = ctx.extract_source(node) {
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()));
115
+ docs.push(chain_doc_or_verbatim(source_text));
125
116
  }
126
117
 
127
118
  // Mark comments in this range as emitted (they're in source extraction)
@@ -148,15 +139,16 @@ fn format_call(node: &Node, ctx: &mut FormatContext, registry: &RuleRegistry) ->
148
139
  .source()
149
140
  .get(node.location.start_offset..call_end_offset)
150
141
  {
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(_));
158
- docs.push(text(reformatted));
159
- changed
142
+ match reformat_chain_doc(call_text.trim_end()) {
143
+ Some(chain_doc) => {
144
+ docs.push(chain_doc);
145
+ true
146
+ }
147
+ None => {
148
+ docs.push(text(call_text.trim_end().to_string()));
149
+ false
150
+ }
151
+ }
160
152
  } else {
161
153
  false
162
154
  };
@@ -5,13 +5,13 @@
5
5
  //! net for node types that haven't been implemented yet.
6
6
 
7
7
  use crate::ast::Node;
8
- use crate::doc::{concat, text, Doc};
8
+ use crate::doc::{concat, Doc};
9
9
  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, line_leading_indent,
14
- mark_comments_in_range_emitted, reformat_chain_lines, strip_one_trailing_newline, FormatRule,
13
+ chain_doc_or_verbatim, format_leading_comments, format_trailing_comment,
14
+ mark_comments_in_range_emitted, FormatRule,
15
15
  };
16
16
 
17
17
  /// Fallback rule that extracts source text directly.
@@ -47,14 +47,7 @@ impl FormatRule for FallbackRule {
47
47
  // could swallow an intentional blank line captured by the node's
48
48
  // extent).
49
49
  if let Some(source_text) = ctx.extract_source(node) {
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()));
50
+ docs.push(chain_doc_or_verbatim(source_text));
58
51
 
59
52
  // Mark any comments within this node's range as emitted
60
53
  // (they are included in the source extraction)
@@ -14,7 +14,7 @@ use crate::format::context::FormatContext;
14
14
  use crate::format::registry::RuleRegistry;
15
15
  use crate::format::rule::{
16
16
  format_leading_comments, format_statements, format_trailing_comment,
17
- strip_one_trailing_newline, FormatRule,
17
+ mark_comments_in_range_emitted, strip_one_trailing_newline, FormatRule,
18
18
  };
19
19
 
20
20
  /// Rule for formatting if conditionals.
@@ -115,8 +115,13 @@ fn format_postfix(
115
115
  // the modifier is already baked into the source text right where
116
116
  // Ruby expects it.
117
117
  if let Some(statements) = node.children.get(1) {
118
- if let Some(source_text) = ctx.extract_source(statements) {
119
- if statement_contains_heredoc_tail(source_text) {
118
+ if let Some(source_text) = ctx.extract_source(statements).map(str::to_string) {
119
+ mark_comments_in_range_emitted(
120
+ ctx,
121
+ statements.location.start_line,
122
+ statements.location.end_line,
123
+ );
124
+ if statement_contains_heredoc_tail(&source_text) {
120
125
  docs.push(text(source_text.trim_end_matches('\n').to_string()));
121
126
 
122
127
  let trailing = format_trailing_comment(ctx, node.location.end_line);
@@ -193,6 +198,11 @@ fn format_ternary(node: &Node, ctx: &mut FormatContext) -> Result<Doc> {
193
198
  if let Some(statements) = node.children.get(1) {
194
199
  if let Some(source_text) = ctx.extract_source(statements) {
195
200
  docs.push(text(source_text.trim()));
201
+ mark_comments_in_range_emitted(
202
+ ctx,
203
+ statements.location.start_line,
204
+ statements.location.end_line,
205
+ );
196
206
  }
197
207
  }
198
208
 
@@ -203,6 +213,11 @@ fn format_ternary(node: &Node, ctx: &mut FormatContext) -> Result<Doc> {
203
213
  if let Some(else_statements) = else_node.children.first() {
204
214
  if let Some(source_text) = ctx.extract_source(else_statements) {
205
215
  docs.push(text(source_text.trim()));
216
+ mark_comments_in_range_emitted(
217
+ ctx,
218
+ else_statements.location.start_line,
219
+ else_statements.location.end_line,
220
+ );
206
221
  }
207
222
  }
208
223
  }
@@ -322,6 +337,13 @@ fn format_normal(
322
337
  docs.push(hardline());
323
338
  docs.push(text("else"));
324
339
 
340
+ // Trailing comment on the else line (ElseNode starts at the
341
+ // `else` keyword)
342
+ let else_trailing = format_trailing_comment(ctx, consequent.location.start_line);
343
+ if !else_trailing.is_empty() {
344
+ docs.push(else_trailing);
345
+ }
346
+
325
347
  // Emit else body (first child of ElseNode)
326
348
  if let Some(else_statements) = consequent.children.first() {
327
349
  if matches!(else_statements.node_type, NodeType::StatementsNode) {
@@ -12,7 +12,8 @@ use crate::error::Result;
12
12
  use crate::format::context::FormatContext;
13
13
  use crate::format::registry::RuleRegistry;
14
14
  use crate::format::rule::{
15
- format_leading_comments, format_statements, format_trailing_comment, FormatRule,
15
+ format_leading_comments, format_statements, format_trailing_comment,
16
+ mark_comments_in_range_emitted, FormatRule,
16
17
  };
17
18
 
18
19
  /// Rule for formatting while loops.
@@ -80,6 +81,7 @@ fn format_postfix_while_until(node: &Node, ctx: &mut FormatContext, keyword: &st
80
81
  // Extract from source for postfix form
81
82
  if let Some(source_text) = ctx.extract_source(node) {
82
83
  docs.push(text(source_text));
84
+ mark_comments_in_range_emitted(ctx, node.location.start_line, node.location.end_line);
83
85
  }
84
86
 
85
87
  // Trailing comment
@@ -12,8 +12,8 @@ use crate::error::Result;
12
12
  use crate::format::context::FormatContext;
13
13
  use crate::format::registry::RuleRegistry;
14
14
  use crate::format::rule::{
15
- format_child, format_leading_comments, format_trailing_comment, line_leading_indent,
16
- reformat_chain_lines, strip_one_trailing_newline, FormatRule,
15
+ chain_doc_or_verbatim, format_child, format_leading_comments, format_trailing_comment,
16
+ mark_comments_in_range_emitted, FormatRule,
17
17
  };
18
18
 
19
19
  /// Rule for formatting local variable write expressions.
@@ -66,6 +66,7 @@ fn format_variable_write(
66
66
  // No value: fallback to source extraction
67
67
  if let Some(source_text) = ctx.extract_source(node) {
68
68
  docs.push(text(source_text.to_string()));
69
+ mark_comments_in_range_emitted(ctx, start_line, end_line);
69
70
  }
70
71
  let trailing = format_trailing_comment(ctx, end_line);
71
72
  if !trailing.is_empty() {
@@ -124,19 +125,25 @@ fn format_variable_write(
124
125
  // (`x = foo(<<~SQL)\n…\nSQL\n`) so it doesn't compound with the
125
126
  // surrounding hardline.
126
127
  if let Some(source_text) = ctx.extract_source(value) {
127
- let base_indent = line_leading_indent(ctx.source(), node.location.start_offset);
128
- let reformatted = reformat_chain_lines(
129
- source_text,
130
- base_indent,
131
- ctx.config().formatting.indent_width,
128
+ docs.push(chain_doc_or_verbatim(source_text.trim_start()));
129
+ // The extracted text carries any interior comments verbatim
130
+ // (e.g. inside a `do…end` block); without marking them they
131
+ // get re-emitted at EOF by format_remaining_comments.
132
+ mark_comments_in_range_emitted(
133
+ ctx,
134
+ value.location.start_line,
135
+ value.location.end_line,
132
136
  );
133
- let trimmed = strip_one_trailing_newline(reformatted.trim_start());
134
- docs.push(text(trimmed.to_string()));
135
137
  }
136
138
  } else {
137
139
  // Simple value: extract from source trimmed
138
140
  if let Some(source_text) = ctx.extract_source(value) {
139
141
  docs.push(text(source_text.trim().to_string()));
142
+ mark_comments_in_range_emitted(
143
+ ctx,
144
+ value.location.start_line,
145
+ value.location.end_line,
146
+ );
140
147
  }
141
148
  }
142
149
  }