rfmt 1.5.3 → 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.
@@ -0,0 +1,726 @@
1
+ //! FormatRule trait and helper functions
2
+ //!
3
+ //! This module defines the core FormatRule trait that all formatting rules implement,
4
+ //! along with shared helper functions for common formatting patterns.
5
+
6
+ use crate::ast::{CommentType, Node};
7
+ use crate::doc::{concat, hardline, leading_comment, literalline, text, trailing_comment, Doc};
8
+ use crate::error::Result;
9
+
10
+ use super::context::FormatContext;
11
+ use super::registry::RuleRegistry;
12
+
13
+ /// Trait for formatting rules.
14
+ ///
15
+ /// Each rule handles a specific node type (or set of node types) and produces
16
+ /// a Doc IR representation of that node.
17
+ ///
18
+ /// Rules are stateless and can be shared across multiple formatting contexts.
19
+ pub trait FormatRule: Send + Sync {
20
+ /// Formats a node and returns the Doc IR.
21
+ ///
22
+ /// # Arguments
23
+ /// * `node` - The AST node to format
24
+ /// * `ctx` - The formatting context with source, config, and comment tracking
25
+ /// * `registry` - The rule registry for recursive formatting
26
+ ///
27
+ /// # Returns
28
+ /// A Doc IR representing the formatted node
29
+ fn format(&self, node: &Node, ctx: &mut FormatContext, registry: &RuleRegistry) -> Result<Doc>;
30
+ }
31
+
32
+ /// Formats a child node by dispatching to the appropriate rule.
33
+ ///
34
+ /// This is the primary way to recursively format child nodes within rules.
35
+ pub fn format_child(child: &Node, ctx: &mut FormatContext, registry: &RuleRegistry) -> Result<Doc> {
36
+ let rule = registry.get_rule(&child.node_type);
37
+ rule.format(child, ctx, registry)
38
+ }
39
+
40
+ /// Boxed rule type for dynamic dispatch.
41
+ pub type BoxedRule = Box<dyn FormatRule>;
42
+
43
+ /// Lightweight comment reference using index instead of cloning.
44
+ #[derive(Clone, Copy)]
45
+ struct CommentRef {
46
+ idx: usize,
47
+ start_line: usize,
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
+ }
76
+ }
77
+
78
+ /// Formats leading comments before a given line.
79
+ ///
80
+ /// This helper collects all unemitted comments that appear before the given line,
81
+ /// formats them with hardlines, and marks them as emitted.
82
+ ///
83
+ /// # Arguments
84
+ /// * `ctx` - The formatting context
85
+ /// * `line` - The line number to get comments before
86
+ ///
87
+ /// # Returns
88
+ /// A Doc containing all leading comments with proper line breaks
89
+ pub fn format_leading_comments(ctx: &mut FormatContext, line: usize) -> Doc {
90
+ // Collect lightweight refs while borrowing immutably
91
+ let comment_refs: Vec<CommentRef> = ctx
92
+ .get_comment_indices_before(line)
93
+ .filter_map(|idx| {
94
+ ctx.get_comment(idx).map(|c| CommentRef {
95
+ idx,
96
+ start_line: c.location.start_line,
97
+ end_line: c.location.end_line,
98
+ is_block: matches!(c.comment_type, CommentType::Block),
99
+ })
100
+ })
101
+ .collect();
102
+
103
+ if comment_refs.is_empty() {
104
+ return Doc::Empty;
105
+ }
106
+
107
+ let mut docs: Vec<Doc> = Vec::with_capacity(comment_refs.len() * 2);
108
+ let mut last_end_line: Option<usize> = None;
109
+ let mut indices_to_mark: Vec<usize> = Vec::with_capacity(comment_refs.len());
110
+
111
+ for cref in &comment_refs {
112
+ // Preserve blank lines between comments
113
+ if let Some(prev_end) = last_end_line {
114
+ let gap = cref.start_line.saturating_sub(prev_end);
115
+ for _ in 1..gap {
116
+ docs.push(hardline());
117
+ }
118
+ }
119
+
120
+ if let Some(comment) = ctx.get_comment(cref.idx) {
121
+ push_leading_comment(&mut docs, &comment.text, cref.is_block);
122
+ }
123
+ last_end_line = Some(cref.end_line);
124
+ indices_to_mark.push(cref.idx);
125
+ }
126
+
127
+ // Mark comments as emitted in batch
128
+ ctx.mark_comments_emitted(indices_to_mark);
129
+
130
+ // Add blank line after comments if there's a gap before the node
131
+ if let Some(last_end) = last_end_line {
132
+ if line > last_end + 1 {
133
+ docs.push(hardline());
134
+ }
135
+ }
136
+
137
+ concat(docs)
138
+ }
139
+
140
+ /// Formats a trailing comment on the same line.
141
+ ///
142
+ /// # Arguments
143
+ /// * `ctx` - The formatting context
144
+ /// * `line` - The line number to get trailing comments for
145
+ ///
146
+ /// # Returns
147
+ /// A Doc containing the trailing comment, or Empty if none
148
+ pub fn format_trailing_comment(ctx: &mut FormatContext, line: usize) -> Doc {
149
+ // Collect indices while borrowing immutably
150
+ let indices: Vec<usize> = ctx.get_trailing_comment_indices(line).collect();
151
+
152
+ if indices.is_empty() {
153
+ return Doc::Empty;
154
+ }
155
+
156
+ let mut docs: Vec<Doc> = Vec::with_capacity(indices.len());
157
+
158
+ for &idx in &indices {
159
+ if let Some(comment) = ctx.get_comment(idx) {
160
+ docs.push(trailing_comment(&comment.text));
161
+ }
162
+ }
163
+
164
+ // Mark comments as emitted in batch
165
+ ctx.mark_comments_emitted(indices);
166
+
167
+ concat(docs)
168
+ }
169
+
170
+ /// Formats comments that appear before the `end` keyword of a construct.
171
+ ///
172
+ /// This is used for comments inside class/module/def bodies that appear
173
+ /// on standalone lines before the closing `end`.
174
+ ///
175
+ /// # Arguments
176
+ /// * `ctx` - The formatting context
177
+ /// * `start_line` - The start line of the construct
178
+ /// * `end_line` - The end line of the construct (where `end` appears)
179
+ ///
180
+ /// # Returns
181
+ /// A Doc containing the formatted comments
182
+ pub fn format_comments_before_end(
183
+ ctx: &mut FormatContext,
184
+ start_line: usize,
185
+ end_line: usize,
186
+ ) -> Doc {
187
+ // Collect indices for comments in range
188
+ let indices: Vec<usize> = ctx
189
+ .get_comment_indices_in_range(start_line + 1, end_line)
190
+ .collect();
191
+
192
+ if indices.is_empty() {
193
+ return Doc::Empty;
194
+ }
195
+
196
+ // Filter to only standalone comments
197
+ let standalone_refs: Vec<CommentRef> = indices
198
+ .iter()
199
+ .filter_map(|&idx| {
200
+ ctx.get_comment(idx).and_then(|c| {
201
+ if ctx.is_standalone_comment(c) && c.location.end_line < end_line {
202
+ Some(CommentRef {
203
+ idx,
204
+ start_line: c.location.start_line,
205
+ end_line: c.location.end_line,
206
+ is_block: matches!(c.comment_type, CommentType::Block),
207
+ })
208
+ } else {
209
+ None
210
+ }
211
+ })
212
+ })
213
+ .collect();
214
+
215
+ if standalone_refs.is_empty() {
216
+ return Doc::Empty;
217
+ }
218
+
219
+ let mut docs: Vec<Doc> = vec![hardline()];
220
+ let mut last_end_line: Option<usize> = None;
221
+ let mut indices_to_mark: Vec<usize> = Vec::with_capacity(standalone_refs.len());
222
+
223
+ let last_idx = standalone_refs.len().saturating_sub(1);
224
+ for (i, cref) in standalone_refs.iter().enumerate() {
225
+ // Preserve blank lines between comments
226
+ if let Some(prev_end) = last_end_line {
227
+ let gap = cref.start_line.saturating_sub(prev_end);
228
+ for _ in 1..gap {
229
+ docs.push(hardline());
230
+ }
231
+ }
232
+
233
+ if let Some(comment) = ctx.get_comment(cref.idx) {
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
+ }
254
+ }
255
+ last_end_line = Some(cref.end_line);
256
+ indices_to_mark.push(cref.idx);
257
+ }
258
+
259
+ // Mark comments as emitted in batch
260
+ ctx.mark_comments_emitted(indices_to_mark);
261
+
262
+ concat(docs)
263
+ }
264
+
265
+ /// Formats remaining comments at the end of the file.
266
+ ///
267
+ /// This should be called after all nodes have been formatted to emit
268
+ /// any comments that weren't attached to specific nodes.
269
+ ///
270
+ /// # Arguments
271
+ /// * `ctx` - The formatting context
272
+ /// * `last_code_line` - The last line of code in the file
273
+ ///
274
+ /// # Returns
275
+ /// A Doc containing all remaining comments
276
+ pub fn format_remaining_comments(ctx: &mut FormatContext, last_code_line: usize) -> Doc {
277
+ // Collect remaining comment indices and their line info
278
+ let comment_refs: Vec<CommentRef> = ctx
279
+ .get_remaining_comment_indices()
280
+ .filter_map(|idx| {
281
+ ctx.get_comment(idx).map(|c| CommentRef {
282
+ idx,
283
+ start_line: c.location.start_line,
284
+ end_line: c.location.end_line,
285
+ is_block: matches!(c.comment_type, CommentType::Block),
286
+ })
287
+ })
288
+ .collect();
289
+
290
+ if comment_refs.is_empty() {
291
+ return Doc::Empty;
292
+ }
293
+
294
+ let mut docs: Vec<Doc> = Vec::with_capacity(comment_refs.len() * 2);
295
+ let mut last_end_line = last_code_line;
296
+ let mut is_first = true;
297
+ let mut indices_to_mark: Vec<usize> = Vec::with_capacity(comment_refs.len());
298
+
299
+ for cref in &comment_refs {
300
+ // Preserve blank lines
301
+ let gap = cref.start_line.saturating_sub(last_end_line);
302
+
303
+ // Only add newlines if not the first comment or if there's a gap
304
+ if !is_first || gap > 0 {
305
+ for _ in 0..gap.max(1) {
306
+ docs.push(hardline());
307
+ }
308
+ }
309
+
310
+ if let Some(comment) = ctx.get_comment(cref.idx) {
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
+ }
323
+ }
324
+ last_end_line = cref.end_line;
325
+ is_first = false;
326
+ indices_to_mark.push(cref.idx);
327
+ }
328
+
329
+ // Mark comments as emitted in batch
330
+ ctx.mark_comments_emitted(indices_to_mark);
331
+
332
+ concat(docs)
333
+ }
334
+
335
+ /// Formats a statements node as a sequence of children with proper line spacing.
336
+ ///
337
+ /// This is a shared helper used by multiple formatting rules (if_unless, case,
338
+ /// begin, call, loops) to format StatementsNode children consistently.
339
+ ///
340
+ /// # Arguments
341
+ /// * `node` - The StatementsNode to format
342
+ /// * `ctx` - The formatting context
343
+ /// * `registry` - The rule registry for recursive formatting
344
+ ///
345
+ /// # Returns
346
+ /// A Doc containing all statements with proper line breaks between them
347
+ pub fn format_statements(
348
+ node: &Node,
349
+ ctx: &mut FormatContext,
350
+ registry: &RuleRegistry,
351
+ ) -> Result<Doc> {
352
+ if node.children.is_empty() {
353
+ return Ok(Doc::Empty);
354
+ }
355
+
356
+ let mut docs: Vec<Doc> = Vec::with_capacity(node.children.len() * 2);
357
+
358
+ for (i, child) in node.children.iter().enumerate() {
359
+ let child_doc = format_child(child, ctx, registry)?;
360
+ docs.push(child_doc);
361
+
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.
369
+ if let Some(next_child) = node.children.get(i + 1) {
370
+ let current_end_line = child.location.end_line;
371
+ let next_start_line = next_child.location.start_line;
372
+ let line_diff = next_start_line.saturating_sub(current_end_line);
373
+
374
+ docs.push(hardline());
375
+
376
+ if line_diff > 1 {
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
+ }
402
+ }
403
+ }
404
+ }
405
+
406
+ Ok(concat(docs))
407
+ }
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
+
425
+ /// Reformats multiline method chain text with indented style.
426
+ ///
427
+ /// Converts aligned method chains to indented style:
428
+ /// - First line is kept as-is (trimmed at end)
429
+ /// - Subsequent lines starting with `.` or `&.` are re-indented to
430
+ /// `base_indent + indent_width` spaces
431
+ ///
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.
435
+ ///
436
+ /// Returns `Cow::Borrowed` when no transformation is needed to avoid allocation.
437
+ ///
438
+ /// # Example
439
+ /// ```text
440
+ /// Input (base_indent=4, indent_width=2):
441
+ /// "foo.bar\n .baz"
442
+ /// Output:
443
+ /// "foo.bar\n .baz"
444
+ /// ```
445
+ pub fn reformat_chain_lines(
446
+ source_text: &str,
447
+ base_indent: usize,
448
+ indent_width: usize,
449
+ ) -> std::borrow::Cow<'_, str> {
450
+ use std::borrow::Cow;
451
+
452
+ let lines: Vec<&str> = source_text.lines().collect();
453
+ if lines.len() <= 1 {
454
+ return Cow::Borrowed(source_text);
455
+ }
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
+
472
+ // Check if there are actual chain continuation lines (. or &.)
473
+ let has_chain = lines[1..].iter().any(|l| {
474
+ let t = l.trim_start();
475
+ t.starts_with('.') || t.starts_with("&.")
476
+ });
477
+
478
+ if !has_chain {
479
+ return Cow::Borrowed(source_text);
480
+ }
481
+
482
+ // Build the indented chain with pre-allocated capacity
483
+ let chain_indent = " ".repeat(base_indent + indent_width);
484
+ let mut result = String::with_capacity(source_text.len());
485
+ result.push_str(lines[0].trim_end());
486
+
487
+ for line in &lines[1..] {
488
+ result.push('\n');
489
+ let trimmed = line.trim();
490
+ if trimmed.starts_with('.') || trimmed.starts_with("&.") {
491
+ result.push_str(&chain_indent);
492
+ result.push_str(trimmed);
493
+ } else {
494
+ // Non-chain continuation (e.g., heredoc content): preserve as-is
495
+ result.push_str(line);
496
+ }
497
+ }
498
+
499
+ Cow::Owned(result)
500
+ }
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
+
520
+ /// Marks comments within a line range as emitted.
521
+ ///
522
+ /// This is used when source text is extracted directly, as any comments
523
+ /// within the extracted range are included in the output.
524
+ ///
525
+ /// # Arguments
526
+ /// * `ctx` - The formatting context
527
+ /// * `start_line` - The start line of the range
528
+ /// * `end_line` - The end line of the range
529
+ pub fn mark_comments_in_range_emitted(ctx: &mut FormatContext, start_line: usize, end_line: usize) {
530
+ let indices: Vec<usize> = ctx
531
+ .get_comment_indices_in_range(start_line, end_line)
532
+ .collect();
533
+ ctx.mark_comments_emitted(indices);
534
+ }
535
+
536
+ /// Checks if a node is a structural node (part of definition syntax, not body).
537
+ ///
538
+ /// Structural nodes are parts of class/module/method definitions that should
539
+ /// not be emitted as body content (e.g., constant names, parameter nodes).
540
+ pub fn is_structural_node(node: &Node) -> bool {
541
+ use crate::ast::NodeType;
542
+
543
+ matches!(
544
+ node.node_type,
545
+ NodeType::ConstantReadNode
546
+ | NodeType::ConstantWriteNode
547
+ | NodeType::ConstantPathNode
548
+ | NodeType::RequiredParameterNode
549
+ | NodeType::OptionalParameterNode
550
+ | NodeType::RestParameterNode
551
+ | NodeType::KeywordParameterNode
552
+ | NodeType::RequiredKeywordParameterNode
553
+ | NodeType::OptionalKeywordParameterNode
554
+ | NodeType::KeywordRestParameterNode
555
+ | NodeType::BlockParameterNode
556
+ | NodeType::ForwardingParameterNode
557
+ | NodeType::NoKeywordsParameterNode
558
+ )
559
+ }
560
+
561
+ #[cfg(test)]
562
+ mod tests {
563
+ use super::*;
564
+ use crate::ast::{
565
+ Comment, CommentPosition, CommentType, FormattingInfo, Location, Node, NodeType,
566
+ };
567
+ use crate::config::Config;
568
+ use std::collections::HashMap;
569
+
570
+ fn make_comment(text: &str, line: usize, start_offset: usize) -> Comment {
571
+ Comment {
572
+ text: text.to_string(),
573
+ location: Location::new(
574
+ line,
575
+ 0,
576
+ line,
577
+ text.len(),
578
+ start_offset,
579
+ start_offset + text.len(),
580
+ ),
581
+ comment_type: CommentType::Line,
582
+ position: CommentPosition::Leading,
583
+ }
584
+ }
585
+
586
+ fn make_node_with_comments(comments: Vec<Comment>) -> Node {
587
+ Node {
588
+ node_type: NodeType::ProgramNode,
589
+ location: Location::new(1, 0, 10, 0, 0, 100),
590
+ children: Vec::new(),
591
+ metadata: HashMap::new(),
592
+ comments,
593
+ formatting: FormattingInfo::default(),
594
+ }
595
+ }
596
+
597
+ #[test]
598
+ fn test_format_leading_comments() {
599
+ let config = Config::default();
600
+ let source = "# comment\nclass Foo\nend";
601
+ let mut ctx = FormatContext::new(&config, source);
602
+
603
+ let comment = make_comment("# comment", 1, 0);
604
+ let node = make_node_with_comments(vec![comment]);
605
+ ctx.collect_comments(&node);
606
+
607
+ let doc = format_leading_comments(&mut ctx, 5);
608
+ assert!(!matches!(doc, Doc::Empty));
609
+ }
610
+
611
+ #[test]
612
+ fn test_format_trailing_comment() {
613
+ let config = Config::default();
614
+ let source = "code # trailing";
615
+ let mut ctx = FormatContext::new(&config, source);
616
+
617
+ let comment = Comment {
618
+ text: "# trailing".to_string(),
619
+ location: Location::new(1, 5, 1, 15, 5, 15),
620
+ comment_type: CommentType::Line,
621
+ position: CommentPosition::Trailing,
622
+ };
623
+ let node = make_node_with_comments(vec![comment]);
624
+ ctx.collect_comments(&node);
625
+
626
+ let doc = format_trailing_comment(&mut ctx, 1);
627
+ assert!(!matches!(doc, Doc::Empty));
628
+ }
629
+
630
+ #[test]
631
+ fn test_is_structural_node() {
632
+ let structural_node = Node {
633
+ node_type: NodeType::ConstantReadNode,
634
+ location: Location::new(1, 0, 1, 3, 0, 3),
635
+ children: Vec::new(),
636
+ metadata: HashMap::new(),
637
+ comments: Vec::new(),
638
+ formatting: FormattingInfo::default(),
639
+ };
640
+
641
+ let non_structural_node = Node {
642
+ node_type: NodeType::CallNode,
643
+ location: Location::new(1, 0, 1, 10, 0, 10),
644
+ children: Vec::new(),
645
+ metadata: HashMap::new(),
646
+ comments: Vec::new(),
647
+ formatting: FormattingInfo::default(),
648
+ };
649
+
650
+ assert!(is_structural_node(&structural_node));
651
+ assert!(!is_structural_node(&non_structural_node));
652
+ }
653
+
654
+ #[test]
655
+ fn test_format_child() {
656
+ let config = Config::default();
657
+ let source = "puts 'hello'";
658
+ let mut ctx = FormatContext::new(&config, source);
659
+ let registry = RuleRegistry::default_registry();
660
+
661
+ let node = Node {
662
+ node_type: NodeType::CallNode,
663
+ location: Location::new(1, 0, 1, 12, 0, 12),
664
+ children: Vec::new(),
665
+ metadata: HashMap::new(),
666
+ comments: Vec::new(),
667
+ formatting: FormattingInfo::default(),
668
+ };
669
+
670
+ ctx.collect_comments(&node);
671
+
672
+ let doc = format_child(&node, &mut ctx, &registry).unwrap();
673
+ assert!(!matches!(doc, Doc::Empty));
674
+ }
675
+
676
+ #[test]
677
+ fn test_reformat_chain_lines_single_line() {
678
+ let input = "foo.bar.baz";
679
+ let result = reformat_chain_lines(input, 0, 2);
680
+ assert_eq!(result, "foo.bar.baz");
681
+ }
682
+
683
+ #[test]
684
+ fn test_reformat_chain_lines_multiline_chain() {
685
+ let input = "foo.bar\n .baz\n .qux";
686
+ let result = reformat_chain_lines(input, 0, 2);
687
+ assert_eq!(result, "foo.bar\n .baz\n .qux");
688
+ }
689
+
690
+ #[test]
691
+ fn test_reformat_chain_lines_safe_navigation() {
692
+ let input = "foo&.bar\n &.baz";
693
+ let result = reformat_chain_lines(input, 0, 2);
694
+ assert_eq!(result, "foo&.bar\n &.baz");
695
+ }
696
+
697
+ #[test]
698
+ fn test_reformat_chain_lines_no_chain() {
699
+ let input = "foo(\n arg1,\n arg2\n)";
700
+ let result = reformat_chain_lines(input, 0, 2);
701
+ assert_eq!(result, input);
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
+ }
726
+ }