rfmt 1.5.2 → 1.6.0

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,555 @@
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::Node;
7
+ use crate::doc::{concat, hardline, leading_comment, 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
+ }
50
+
51
+ /// Formats leading comments before a given line.
52
+ ///
53
+ /// This helper collects all unemitted comments that appear before the given line,
54
+ /// formats them with hardlines, and marks them as emitted.
55
+ ///
56
+ /// # Arguments
57
+ /// * `ctx` - The formatting context
58
+ /// * `line` - The line number to get comments before
59
+ ///
60
+ /// # Returns
61
+ /// A Doc containing all leading comments with proper line breaks
62
+ pub fn format_leading_comments(ctx: &mut FormatContext, line: usize) -> Doc {
63
+ // Collect lightweight refs while borrowing immutably
64
+ let comment_refs: Vec<CommentRef> = ctx
65
+ .get_comment_indices_before(line)
66
+ .filter_map(|idx| {
67
+ ctx.get_comment(idx).map(|c| CommentRef {
68
+ idx,
69
+ start_line: c.location.start_line,
70
+ end_line: c.location.end_line,
71
+ })
72
+ })
73
+ .collect();
74
+
75
+ if comment_refs.is_empty() {
76
+ return Doc::Empty;
77
+ }
78
+
79
+ let mut docs: Vec<Doc> = Vec::with_capacity(comment_refs.len() * 2);
80
+ let mut last_end_line: Option<usize> = None;
81
+ let mut indices_to_mark: Vec<usize> = Vec::with_capacity(comment_refs.len());
82
+
83
+ for cref in &comment_refs {
84
+ // Preserve blank lines between comments
85
+ if let Some(prev_end) = last_end_line {
86
+ let gap = cref.start_line.saturating_sub(prev_end);
87
+ for _ in 1..gap {
88
+ docs.push(hardline());
89
+ }
90
+ }
91
+
92
+ if let Some(comment) = ctx.get_comment(cref.idx) {
93
+ docs.push(leading_comment(&comment.text, true));
94
+ }
95
+ last_end_line = Some(cref.end_line);
96
+ indices_to_mark.push(cref.idx);
97
+ }
98
+
99
+ // Mark comments as emitted in batch
100
+ ctx.mark_comments_emitted(indices_to_mark);
101
+
102
+ // Add blank line after comments if there's a gap before the node
103
+ if let Some(last_end) = last_end_line {
104
+ if line > last_end + 1 {
105
+ docs.push(hardline());
106
+ }
107
+ }
108
+
109
+ concat(docs)
110
+ }
111
+
112
+ /// Formats a trailing comment on the same line.
113
+ ///
114
+ /// # Arguments
115
+ /// * `ctx` - The formatting context
116
+ /// * `line` - The line number to get trailing comments for
117
+ ///
118
+ /// # Returns
119
+ /// A Doc containing the trailing comment, or Empty if none
120
+ pub fn format_trailing_comment(ctx: &mut FormatContext, line: usize) -> Doc {
121
+ // Collect indices while borrowing immutably
122
+ let indices: Vec<usize> = ctx.get_trailing_comment_indices(line).collect();
123
+
124
+ if indices.is_empty() {
125
+ return Doc::Empty;
126
+ }
127
+
128
+ let mut docs: Vec<Doc> = Vec::with_capacity(indices.len());
129
+
130
+ for &idx in &indices {
131
+ if let Some(comment) = ctx.get_comment(idx) {
132
+ docs.push(trailing_comment(&comment.text));
133
+ }
134
+ }
135
+
136
+ // Mark comments as emitted in batch
137
+ ctx.mark_comments_emitted(indices);
138
+
139
+ concat(docs)
140
+ }
141
+
142
+ /// Formats comments that appear before the `end` keyword of a construct.
143
+ ///
144
+ /// This is used for comments inside class/module/def bodies that appear
145
+ /// on standalone lines before the closing `end`.
146
+ ///
147
+ /// # Arguments
148
+ /// * `ctx` - The formatting context
149
+ /// * `start_line` - The start line of the construct
150
+ /// * `end_line` - The end line of the construct (where `end` appears)
151
+ ///
152
+ /// # Returns
153
+ /// A Doc containing the formatted comments
154
+ pub fn format_comments_before_end(
155
+ ctx: &mut FormatContext,
156
+ start_line: usize,
157
+ end_line: usize,
158
+ ) -> Doc {
159
+ // Collect indices for comments in range
160
+ let indices: Vec<usize> = ctx
161
+ .get_comment_indices_in_range(start_line + 1, end_line)
162
+ .collect();
163
+
164
+ if indices.is_empty() {
165
+ return Doc::Empty;
166
+ }
167
+
168
+ // Filter to only standalone comments
169
+ let standalone_refs: Vec<CommentRef> = indices
170
+ .iter()
171
+ .filter_map(|&idx| {
172
+ ctx.get_comment(idx).and_then(|c| {
173
+ if ctx.is_standalone_comment(c) && c.location.end_line < end_line {
174
+ Some(CommentRef {
175
+ idx,
176
+ start_line: c.location.start_line,
177
+ end_line: c.location.end_line,
178
+ })
179
+ } else {
180
+ None
181
+ }
182
+ })
183
+ })
184
+ .collect();
185
+
186
+ if standalone_refs.is_empty() {
187
+ return Doc::Empty;
188
+ }
189
+
190
+ let mut docs: Vec<Doc> = vec![hardline()];
191
+ let mut last_end_line: Option<usize> = None;
192
+ let mut indices_to_mark: Vec<usize> = Vec::with_capacity(standalone_refs.len());
193
+
194
+ for cref in &standalone_refs {
195
+ // Preserve blank lines between comments
196
+ if let Some(prev_end) = last_end_line {
197
+ let gap = cref.start_line.saturating_sub(prev_end);
198
+ for _ in 1..gap {
199
+ docs.push(hardline());
200
+ }
201
+ }
202
+
203
+ if let Some(comment) = ctx.get_comment(cref.idx) {
204
+ docs.push(leading_comment(&comment.text, true));
205
+ }
206
+ last_end_line = Some(cref.end_line);
207
+ indices_to_mark.push(cref.idx);
208
+ }
209
+
210
+ // Mark comments as emitted in batch
211
+ ctx.mark_comments_emitted(indices_to_mark);
212
+
213
+ concat(docs)
214
+ }
215
+
216
+ /// Formats remaining comments at the end of the file.
217
+ ///
218
+ /// This should be called after all nodes have been formatted to emit
219
+ /// any comments that weren't attached to specific nodes.
220
+ ///
221
+ /// # Arguments
222
+ /// * `ctx` - The formatting context
223
+ /// * `last_code_line` - The last line of code in the file
224
+ ///
225
+ /// # Returns
226
+ /// A Doc containing all remaining comments
227
+ pub fn format_remaining_comments(ctx: &mut FormatContext, last_code_line: usize) -> Doc {
228
+ // Collect remaining comment indices and their line info
229
+ let comment_refs: Vec<CommentRef> = ctx
230
+ .get_remaining_comment_indices()
231
+ .filter_map(|idx| {
232
+ ctx.get_comment(idx).map(|c| CommentRef {
233
+ idx,
234
+ start_line: c.location.start_line,
235
+ end_line: c.location.end_line,
236
+ })
237
+ })
238
+ .collect();
239
+
240
+ if comment_refs.is_empty() {
241
+ return Doc::Empty;
242
+ }
243
+
244
+ let mut docs: Vec<Doc> = Vec::with_capacity(comment_refs.len() * 2);
245
+ let mut last_end_line = last_code_line;
246
+ let mut is_first = true;
247
+ let mut indices_to_mark: Vec<usize> = Vec::with_capacity(comment_refs.len());
248
+
249
+ for cref in &comment_refs {
250
+ // Preserve blank lines
251
+ let gap = cref.start_line.saturating_sub(last_end_line);
252
+
253
+ // Only add newlines if not the first comment or if there's a gap
254
+ if !is_first || gap > 0 {
255
+ for _ in 0..gap.max(1) {
256
+ docs.push(hardline());
257
+ }
258
+ }
259
+
260
+ if let Some(comment) = ctx.get_comment(cref.idx) {
261
+ docs.push(leading_comment(&comment.text, false));
262
+ }
263
+ last_end_line = cref.end_line;
264
+ is_first = false;
265
+ indices_to_mark.push(cref.idx);
266
+ }
267
+
268
+ // Mark comments as emitted in batch
269
+ ctx.mark_comments_emitted(indices_to_mark);
270
+
271
+ concat(docs)
272
+ }
273
+
274
+ /// Formats a statements node as a sequence of children with proper line spacing.
275
+ ///
276
+ /// This is a shared helper used by multiple formatting rules (if_unless, case,
277
+ /// begin, call, loops) to format StatementsNode children consistently.
278
+ ///
279
+ /// # Arguments
280
+ /// * `node` - The StatementsNode to format
281
+ /// * `ctx` - The formatting context
282
+ /// * `registry` - The rule registry for recursive formatting
283
+ ///
284
+ /// # Returns
285
+ /// A Doc containing all statements with proper line breaks between them
286
+ pub fn format_statements(
287
+ node: &Node,
288
+ ctx: &mut FormatContext,
289
+ registry: &RuleRegistry,
290
+ ) -> Result<Doc> {
291
+ if node.children.is_empty() {
292
+ return Ok(Doc::Empty);
293
+ }
294
+
295
+ let mut docs: Vec<Doc> = Vec::with_capacity(node.children.len() * 2);
296
+
297
+ for (i, child) in node.children.iter().enumerate() {
298
+ let child_doc = format_child(child, ctx, registry)?;
299
+ docs.push(child_doc);
300
+
301
+ // Add newlines between statements
302
+ if let Some(next_child) = node.children.get(i + 1) {
303
+ let current_end_line = child.location.end_line;
304
+ let next_start_line = next_child.location.start_line;
305
+ let line_diff = next_start_line.saturating_sub(current_end_line);
306
+
307
+ docs.push(hardline());
308
+ if line_diff > 1 {
309
+ docs.push(hardline());
310
+ }
311
+ }
312
+ }
313
+
314
+ Ok(concat(docs))
315
+ }
316
+
317
+ /// Reformats multiline method chain text with indented style.
318
+ ///
319
+ /// Converts aligned method chains to indented style:
320
+ /// - First line is kept as-is (trimmed at end)
321
+ /// - Subsequent lines starting with `.` or `&.` are re-indented with one level of indentation
322
+ ///
323
+ /// Returns `Cow::Borrowed` when no transformation is needed to avoid allocation.
324
+ ///
325
+ /// # Arguments
326
+ /// * `source_text` - The source text containing a method chain
327
+ /// * `indent_width` - The number of spaces for one level of indentation
328
+ ///
329
+ /// # Example
330
+ /// ```text
331
+ /// Input (indent_width=2): "foo.bar\n .baz"
332
+ /// Output: "foo.bar\n .baz"
333
+ /// ```
334
+ pub fn reformat_chain_lines(source_text: &str, indent_width: usize) -> std::borrow::Cow<'_, str> {
335
+ use std::borrow::Cow;
336
+
337
+ let lines: Vec<&str> = source_text.lines().collect();
338
+ if lines.len() <= 1 {
339
+ return Cow::Borrowed(source_text);
340
+ }
341
+
342
+ // Check if there are actual chain continuation lines (. or &.)
343
+ let has_chain = lines[1..].iter().any(|l| {
344
+ let t = l.trim_start();
345
+ t.starts_with('.') || t.starts_with("&.")
346
+ });
347
+
348
+ if !has_chain {
349
+ return Cow::Borrowed(source_text);
350
+ }
351
+
352
+ // Build the indented chain with pre-allocated capacity
353
+ let chain_indent = " ".repeat(indent_width);
354
+ let mut result = String::with_capacity(source_text.len());
355
+ result.push_str(lines[0].trim_end());
356
+
357
+ for line in &lines[1..] {
358
+ result.push('\n');
359
+ let trimmed = line.trim();
360
+ if trimmed.starts_with('.') || trimmed.starts_with("&.") {
361
+ result.push_str(&chain_indent);
362
+ result.push_str(trimmed);
363
+ } else {
364
+ // Non-chain continuation (e.g., heredoc content): preserve as-is
365
+ result.push_str(line);
366
+ }
367
+ }
368
+
369
+ Cow::Owned(result)
370
+ }
371
+
372
+ /// Marks comments within a line range as emitted.
373
+ ///
374
+ /// This is used when source text is extracted directly, as any comments
375
+ /// within the extracted range are included in the output.
376
+ ///
377
+ /// # Arguments
378
+ /// * `ctx` - The formatting context
379
+ /// * `start_line` - The start line of the range
380
+ /// * `end_line` - The end line of the range
381
+ pub fn mark_comments_in_range_emitted(ctx: &mut FormatContext, start_line: usize, end_line: usize) {
382
+ let indices: Vec<usize> = ctx
383
+ .get_comment_indices_in_range(start_line, end_line)
384
+ .collect();
385
+ ctx.mark_comments_emitted(indices);
386
+ }
387
+
388
+ /// Checks if a node is a structural node (part of definition syntax, not body).
389
+ ///
390
+ /// Structural nodes are parts of class/module/method definitions that should
391
+ /// not be emitted as body content (e.g., constant names, parameter nodes).
392
+ pub fn is_structural_node(node: &Node) -> bool {
393
+ use crate::ast::NodeType;
394
+
395
+ matches!(
396
+ node.node_type,
397
+ NodeType::ConstantReadNode
398
+ | NodeType::ConstantWriteNode
399
+ | NodeType::ConstantPathNode
400
+ | NodeType::RequiredParameterNode
401
+ | NodeType::OptionalParameterNode
402
+ | NodeType::RestParameterNode
403
+ | NodeType::KeywordParameterNode
404
+ | NodeType::RequiredKeywordParameterNode
405
+ | NodeType::OptionalKeywordParameterNode
406
+ | NodeType::KeywordRestParameterNode
407
+ | NodeType::BlockParameterNode
408
+ | NodeType::ForwardingParameterNode
409
+ | NodeType::NoKeywordsParameterNode
410
+ )
411
+ }
412
+
413
+ #[cfg(test)]
414
+ mod tests {
415
+ use super::*;
416
+ use crate::ast::{
417
+ Comment, CommentPosition, CommentType, FormattingInfo, Location, Node, NodeType,
418
+ };
419
+ use crate::config::Config;
420
+ use std::collections::HashMap;
421
+
422
+ fn make_comment(text: &str, line: usize, start_offset: usize) -> Comment {
423
+ Comment {
424
+ text: text.to_string(),
425
+ location: Location::new(
426
+ line,
427
+ 0,
428
+ line,
429
+ text.len(),
430
+ start_offset,
431
+ start_offset + text.len(),
432
+ ),
433
+ comment_type: CommentType::Line,
434
+ position: CommentPosition::Leading,
435
+ }
436
+ }
437
+
438
+ fn make_node_with_comments(comments: Vec<Comment>) -> Node {
439
+ Node {
440
+ node_type: NodeType::ProgramNode,
441
+ location: Location::new(1, 0, 10, 0, 0, 100),
442
+ children: Vec::new(),
443
+ metadata: HashMap::new(),
444
+ comments,
445
+ formatting: FormattingInfo::default(),
446
+ }
447
+ }
448
+
449
+ #[test]
450
+ fn test_format_leading_comments() {
451
+ let config = Config::default();
452
+ let source = "# comment\nclass Foo\nend";
453
+ let mut ctx = FormatContext::new(&config, source);
454
+
455
+ let comment = make_comment("# comment", 1, 0);
456
+ let node = make_node_with_comments(vec![comment]);
457
+ ctx.collect_comments(&node);
458
+
459
+ let doc = format_leading_comments(&mut ctx, 5);
460
+ assert!(!matches!(doc, Doc::Empty));
461
+ }
462
+
463
+ #[test]
464
+ fn test_format_trailing_comment() {
465
+ let config = Config::default();
466
+ let source = "code # trailing";
467
+ let mut ctx = FormatContext::new(&config, source);
468
+
469
+ let comment = Comment {
470
+ text: "# trailing".to_string(),
471
+ location: Location::new(1, 5, 1, 15, 5, 15),
472
+ comment_type: CommentType::Line,
473
+ position: CommentPosition::Trailing,
474
+ };
475
+ let node = make_node_with_comments(vec![comment]);
476
+ ctx.collect_comments(&node);
477
+
478
+ let doc = format_trailing_comment(&mut ctx, 1);
479
+ assert!(!matches!(doc, Doc::Empty));
480
+ }
481
+
482
+ #[test]
483
+ fn test_is_structural_node() {
484
+ let structural_node = Node {
485
+ node_type: NodeType::ConstantReadNode,
486
+ location: Location::new(1, 0, 1, 3, 0, 3),
487
+ children: Vec::new(),
488
+ metadata: HashMap::new(),
489
+ comments: Vec::new(),
490
+ formatting: FormattingInfo::default(),
491
+ };
492
+
493
+ let non_structural_node = Node {
494
+ node_type: NodeType::CallNode,
495
+ location: Location::new(1, 0, 1, 10, 0, 10),
496
+ children: Vec::new(),
497
+ metadata: HashMap::new(),
498
+ comments: Vec::new(),
499
+ formatting: FormattingInfo::default(),
500
+ };
501
+
502
+ assert!(is_structural_node(&structural_node));
503
+ assert!(!is_structural_node(&non_structural_node));
504
+ }
505
+
506
+ #[test]
507
+ fn test_format_child() {
508
+ let config = Config::default();
509
+ let source = "puts 'hello'";
510
+ let mut ctx = FormatContext::new(&config, source);
511
+ let registry = RuleRegistry::default_registry();
512
+
513
+ let node = Node {
514
+ node_type: NodeType::CallNode,
515
+ location: Location::new(1, 0, 1, 12, 0, 12),
516
+ children: Vec::new(),
517
+ metadata: HashMap::new(),
518
+ comments: Vec::new(),
519
+ formatting: FormattingInfo::default(),
520
+ };
521
+
522
+ ctx.collect_comments(&node);
523
+
524
+ let doc = format_child(&node, &mut ctx, &registry).unwrap();
525
+ assert!(!matches!(doc, Doc::Empty));
526
+ }
527
+
528
+ #[test]
529
+ fn test_reformat_chain_lines_single_line() {
530
+ let input = "foo.bar.baz";
531
+ let result = reformat_chain_lines(input, 2);
532
+ assert_eq!(result, "foo.bar.baz");
533
+ }
534
+
535
+ #[test]
536
+ fn test_reformat_chain_lines_multiline_chain() {
537
+ let input = "foo.bar\n .baz\n .qux";
538
+ let result = reformat_chain_lines(input, 2);
539
+ assert_eq!(result, "foo.bar\n .baz\n .qux");
540
+ }
541
+
542
+ #[test]
543
+ fn test_reformat_chain_lines_safe_navigation() {
544
+ let input = "foo&.bar\n &.baz";
545
+ let result = reformat_chain_lines(input, 2);
546
+ assert_eq!(result, "foo&.bar\n &.baz");
547
+ }
548
+
549
+ #[test]
550
+ fn test_reformat_chain_lines_no_chain() {
551
+ let input = "foo(\n arg1,\n arg2\n)";
552
+ let result = reformat_chain_lines(input, 2);
553
+ assert_eq!(result, input);
554
+ }
555
+ }