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,721 @@
1
+ //! Printer converts Doc IR to a formatted string.
2
+ //!
3
+ //! The printer implements a Prettier-style algorithm that:
4
+ //! 1. Tries to fit groups on a single line
5
+ //! 2. Breaks groups into multiple lines when they don't fit
6
+ //! 3. Manages indentation automatically
7
+ //!
8
+ //! # Algorithm
9
+ //!
10
+ //! The printer uses a stack-based approach to process Doc nodes.
11
+ //! For each Group, it measures whether the contents fit in the remaining
12
+ //! line width. If they fit, Line docs become spaces (flat mode).
13
+ //! If they don't fit, Line docs become newlines (break mode).
14
+
15
+ use super::Doc;
16
+ use crate::config::{Config, IndentStyle};
17
+
18
+ /// Print mode for Line docs within a group.
19
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
20
+ pub enum Mode {
21
+ /// Group fits on one line. Line docs become spaces.
22
+ Flat,
23
+ /// Group doesn't fit. Line docs become newlines.
24
+ Break,
25
+ }
26
+
27
+ /// A command in the print stack.
28
+ #[derive(Debug)]
29
+ struct PrintCommand<'a> {
30
+ /// Current indentation level (in spaces)
31
+ indent: usize,
32
+ /// Print mode (Flat or Break)
33
+ mode: Mode,
34
+ /// The Doc to print
35
+ doc: &'a Doc,
36
+ }
37
+
38
+ /// Converts Doc IR to a formatted string.
39
+ pub struct Printer<'a> {
40
+ /// Configuration for formatting
41
+ config: &'a Config,
42
+ /// Output buffer
43
+ output: String,
44
+ /// Current column position (0-indexed)
45
+ pos: usize,
46
+ /// Pre-computed indent strings by width (avoids allocation during print)
47
+ indent_cache: Vec<String>,
48
+ }
49
+
50
+ const MAX_PRECACHED_INDENT: usize = 32;
51
+
52
+ impl<'a> Printer<'a> {
53
+ /// Creates a new printer with the given configuration.
54
+ pub fn new(config: &'a Config) -> Self {
55
+ let indent_width = config.formatting.indent_width;
56
+ let indent_style = &config.formatting.indent_style;
57
+
58
+ let indent_cache = (0..=MAX_PRECACHED_INDENT)
59
+ .map(|width| Self::build_indent_string(width, indent_width, indent_style))
60
+ .collect();
61
+
62
+ Self {
63
+ config,
64
+ output: String::new(),
65
+ pos: 0,
66
+ indent_cache,
67
+ }
68
+ }
69
+
70
+ #[inline]
71
+ fn build_indent_string(width: usize, indent_width: usize, style: &IndentStyle) -> String {
72
+ match style {
73
+ IndentStyle::Spaces => " ".repeat(width),
74
+ IndentStyle::Tabs if indent_width == 0 => String::new(),
75
+ IndentStyle::Tabs => {
76
+ let tabs = width / indent_width;
77
+ let spaces = width % indent_width;
78
+ if spaces == 0 {
79
+ "\t".repeat(tabs)
80
+ } else {
81
+ let mut result = String::with_capacity(tabs + spaces);
82
+ result.extend(std::iter::repeat_n('\t', tabs));
83
+ result.extend(std::iter::repeat_n(' ', spaces));
84
+ result
85
+ }
86
+ }
87
+ }
88
+ }
89
+
90
+ /// Prints a Doc to a string.
91
+ #[inline]
92
+ pub fn print(&mut self, doc: &Doc) -> String {
93
+ self.output.clear();
94
+ self.pos = 0;
95
+
96
+ let mut commands: Vec<PrintCommand> = vec![PrintCommand {
97
+ indent: 0,
98
+ mode: Mode::Break, // Start in break mode
99
+ doc,
100
+ }];
101
+
102
+ while let Some(cmd) = commands.pop() {
103
+ self.process_command(cmd, &mut commands);
104
+ }
105
+
106
+ // Ensure trailing newline
107
+ if !self.output.is_empty() && !self.output.ends_with('\n') {
108
+ self.output.push('\n');
109
+ }
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
+
123
+ std::mem::take(&mut self.output)
124
+ }
125
+
126
+ /// Processes a single print command.
127
+ #[inline]
128
+ fn process_command<'b>(&mut self, cmd: PrintCommand<'b>, commands: &mut Vec<PrintCommand<'b>>) {
129
+ match cmd.doc {
130
+ Doc::Text(s) => {
131
+ self.output.push_str(s);
132
+ self.pos += s.chars().count();
133
+ }
134
+
135
+ Doc::Concat(docs) => {
136
+ // Push in reverse order so first doc is processed first
137
+ for doc in docs.iter().rev() {
138
+ commands.push(PrintCommand {
139
+ indent: cmd.indent,
140
+ mode: cmd.mode,
141
+ doc,
142
+ });
143
+ }
144
+ }
145
+
146
+ Doc::Group {
147
+ contents,
148
+ break_parent,
149
+ ..
150
+ } => {
151
+ // Determine if the group fits on the remaining line
152
+ let remaining = self.config.formatting.line_length.saturating_sub(self.pos);
153
+ let fits = !*break_parent && self.fits(contents, cmd.indent, remaining, cmd.mode);
154
+
155
+ let mode = if fits { Mode::Flat } else { Mode::Break };
156
+
157
+ commands.push(PrintCommand {
158
+ indent: cmd.indent,
159
+ mode,
160
+ doc: contents,
161
+ });
162
+ }
163
+
164
+ Doc::Line {
165
+ soft,
166
+ hard,
167
+ literal,
168
+ } => match (cmd.mode, *hard) {
169
+ (_, true) => {
170
+ self.output.push('\n');
171
+ if !*literal {
172
+ let indent_str = self.get_indent(cmd.indent);
173
+ self.output.push_str(&indent_str);
174
+ self.pos = cmd.indent;
175
+ } else {
176
+ self.pos = 0;
177
+ }
178
+ }
179
+ (Mode::Flat, false) if *soft => {}
180
+ (Mode::Flat, false) => {
181
+ self.output.push(' ');
182
+ self.pos += 1;
183
+ }
184
+ (Mode::Break, false) => {
185
+ self.output.push('\n');
186
+ let indent_str = self.get_indent(cmd.indent);
187
+ self.output.push_str(&indent_str);
188
+ self.pos = cmd.indent;
189
+ }
190
+ },
191
+
192
+ Doc::Indent(contents) => {
193
+ let new_indent = cmd.indent + self.config.formatting.indent_width;
194
+ commands.push(PrintCommand {
195
+ indent: new_indent,
196
+ mode: cmd.mode,
197
+ doc: contents,
198
+ });
199
+ }
200
+
201
+ Doc::IfBreak {
202
+ break_contents,
203
+ flat_contents,
204
+ ..
205
+ } => {
206
+ let doc = match cmd.mode {
207
+ Mode::Break => break_contents,
208
+ Mode::Flat => flat_contents,
209
+ };
210
+ commands.push(PrintCommand {
211
+ indent: cmd.indent,
212
+ mode: cmd.mode,
213
+ doc,
214
+ });
215
+ }
216
+
217
+ Doc::Empty => {}
218
+
219
+ Doc::TrailingComment(text) => {
220
+ self.output.push(' ');
221
+ self.output.push_str(text);
222
+ self.pos += 1 + text.chars().count();
223
+ }
224
+
225
+ Doc::LeadingComment {
226
+ text,
227
+ hard_line_after,
228
+ } => {
229
+ self.output.push_str(text);
230
+ self.pos += text.chars().count();
231
+ if *hard_line_after {
232
+ self.output.push('\n');
233
+ let indent_str = self.get_indent(cmd.indent);
234
+ self.output.push_str(&indent_str);
235
+ self.pos = cmd.indent;
236
+ }
237
+ }
238
+
239
+ Doc::Align { n, contents } => {
240
+ let new_indent = cmd.indent + n;
241
+ commands.push(PrintCommand {
242
+ indent: new_indent,
243
+ mode: cmd.mode,
244
+ doc: contents,
245
+ });
246
+ }
247
+
248
+ Doc::LineSuffix(contents) => {
249
+ // For now, just print inline. Proper line suffix handling
250
+ // would buffer these until end of line.
251
+ commands.push(PrintCommand {
252
+ indent: cmd.indent,
253
+ mode: cmd.mode,
254
+ doc: contents,
255
+ });
256
+ }
257
+
258
+ Doc::Fill(docs) => {
259
+ // Fill tries to fit as many items on each line as possible.
260
+ // For now, simple implementation: join with softline behavior.
261
+ for (i, doc) in docs.iter().rev().enumerate() {
262
+ if i > 0 {
263
+ commands.push(PrintCommand {
264
+ indent: cmd.indent,
265
+ mode: cmd.mode,
266
+ doc: &Doc::Line {
267
+ soft: true,
268
+ hard: false,
269
+ literal: false,
270
+ },
271
+ });
272
+ }
273
+ commands.push(PrintCommand {
274
+ indent: cmd.indent,
275
+ mode: cmd.mode,
276
+ doc,
277
+ });
278
+ }
279
+ }
280
+ }
281
+ }
282
+
283
+ /// Determines if a Doc fits within the remaining width.
284
+ ///
285
+ /// Uses flat mode for inner groups and returns early when width is exceeded.
286
+ /// This is a hot path, so we pre-allocate a reasonable stack size.
287
+ #[inline]
288
+ fn fits(&self, doc: &Doc, indent: usize, remaining: usize, mode: Mode) -> bool {
289
+ let mut width = 0usize;
290
+ // Pre-allocate stack with reasonable capacity for typical nesting depth
291
+ let mut stack: Vec<(&Doc, usize, Mode)> = Vec::with_capacity(16);
292
+ stack.push((doc, indent, mode));
293
+
294
+ while let Some((doc, indent, mode)) = stack.pop() {
295
+ // Early exit: use >= for slightly earlier termination
296
+ if width >= remaining {
297
+ return false;
298
+ }
299
+
300
+ match doc {
301
+ Doc::Text(s) => {
302
+ // Use len() for ASCII strings (common case), chars().count() for accuracy
303
+ width += if s.is_ascii() {
304
+ s.len()
305
+ } else {
306
+ s.chars().count()
307
+ };
308
+ }
309
+
310
+ Doc::Concat(docs) => {
311
+ for d in docs.iter().rev() {
312
+ stack.push((d, indent, mode));
313
+ }
314
+ }
315
+
316
+ Doc::Group { contents, .. } => {
317
+ // Assume nested groups stay flat during fit check
318
+ stack.push((contents, indent, Mode::Flat));
319
+ }
320
+
321
+ Doc::Line { soft, hard, .. } => {
322
+ if *hard {
323
+ // Hard line forces a break - content after fits on new line
324
+ return true;
325
+ }
326
+ match mode {
327
+ Mode::Flat if *soft => {} // soft line: nothing
328
+ Mode::Flat => width += 1, // regular line: space
329
+ Mode::Break => return true, // break mode: newline ends measurement
330
+ }
331
+ }
332
+
333
+ Doc::Indent(contents) => {
334
+ let new_indent = indent + self.config.formatting.indent_width;
335
+ stack.push((contents, new_indent, mode));
336
+ }
337
+
338
+ Doc::IfBreak {
339
+ flat_contents,
340
+ break_contents,
341
+ ..
342
+ } => {
343
+ let contents = match mode {
344
+ Mode::Flat => flat_contents,
345
+ Mode::Break => break_contents,
346
+ };
347
+ stack.push((contents, indent, mode));
348
+ }
349
+
350
+ Doc::Empty => {}
351
+
352
+ Doc::TrailingComment(s) => {
353
+ width += 1 + if s.is_ascii() {
354
+ s.len()
355
+ } else {
356
+ s.chars().count()
357
+ };
358
+ }
359
+
360
+ Doc::LeadingComment { text, .. } => {
361
+ width += if text.is_ascii() {
362
+ text.len()
363
+ } else {
364
+ text.chars().count()
365
+ };
366
+ }
367
+
368
+ Doc::Align { n, contents } => {
369
+ stack.push((contents, indent + n, mode));
370
+ }
371
+
372
+ Doc::LineSuffix(contents) => {
373
+ stack.push((contents, indent, mode));
374
+ }
375
+
376
+ Doc::Fill(docs) => {
377
+ for d in docs.iter().rev() {
378
+ stack.push((d, indent, mode));
379
+ }
380
+ }
381
+ }
382
+ }
383
+
384
+ width <= remaining
385
+ }
386
+
387
+ fn get_indent(&mut self, width: usize) -> String {
388
+ if width < self.indent_cache.len() {
389
+ return self.indent_cache[width].clone();
390
+ }
391
+
392
+ let indent_width = self.config.formatting.indent_width;
393
+ let indent_style = &self.config.formatting.indent_style;
394
+ while self.indent_cache.len() <= width {
395
+ let w = self.indent_cache.len();
396
+ self.indent_cache
397
+ .push(Self::build_indent_string(w, indent_width, indent_style));
398
+ }
399
+ self.indent_cache[width].clone()
400
+ }
401
+ }
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
+
428
+ #[cfg(test)]
429
+ mod tests {
430
+ use super::*;
431
+ use crate::doc::builders::*;
432
+
433
+ /// Helper to print a doc with default config.
434
+ fn print_doc(doc: &Doc) -> String {
435
+ let config = Config::default();
436
+ let mut printer = Printer::new(&config);
437
+ printer.print(doc)
438
+ }
439
+
440
+ #[test]
441
+ fn test_print_text() {
442
+ let doc = text("hello");
443
+ let result = print_doc(&doc);
444
+ assert_eq!(result, "hello\n");
445
+ }
446
+
447
+ #[test]
448
+ fn test_print_concat() {
449
+ let doc = concat(vec![text("a"), text("b"), text("c")]);
450
+ let result = print_doc(&doc);
451
+ assert_eq!(result, "abc\n");
452
+ }
453
+
454
+ #[test]
455
+ fn test_print_hardline() {
456
+ let doc = concat(vec![text("line1"), hardline(), text("line2")]);
457
+ let result = print_doc(&doc);
458
+ assert_eq!(result, "line1\nline2\n");
459
+ }
460
+
461
+ #[test]
462
+ fn test_print_indent() {
463
+ // Correct Doc structure: hardline inside indent
464
+ let doc = concat(vec![
465
+ text("def foo"),
466
+ indent(concat(vec![hardline(), text("body")])),
467
+ hardline(),
468
+ text("end"),
469
+ ]);
470
+ let result = print_doc(&doc);
471
+ assert_eq!(result, "def foo\n body\nend\n");
472
+ }
473
+
474
+ #[test]
475
+ fn test_print_nested_indent() {
476
+ // Correct Doc structure: hardline inside indent
477
+ let doc = concat(vec![
478
+ text("class Foo"),
479
+ indent(concat(vec![
480
+ hardline(),
481
+ text("def bar"),
482
+ indent(concat(vec![hardline(), text("puts 'hello'")])),
483
+ hardline(),
484
+ text("end"),
485
+ ])),
486
+ hardline(),
487
+ text("end"),
488
+ ]);
489
+ let result = print_doc(&doc);
490
+ assert_eq!(
491
+ result,
492
+ "class Foo\n def bar\n puts 'hello'\n end\nend\n"
493
+ );
494
+ }
495
+
496
+ #[test]
497
+ fn test_print_group_fits() {
498
+ // Short content should stay on one line
499
+ let doc = group(concat(vec![text("short"), line(), text("text")]));
500
+ let result = print_doc(&doc);
501
+ assert_eq!(result, "short text\n");
502
+ }
503
+
504
+ #[test]
505
+ fn test_print_group_breaks() {
506
+ // Create content that doesn't fit on one line
507
+ let long = "a".repeat(80);
508
+ let doc = group(concat(vec![text(&long), line(), text("more")]));
509
+ let result = print_doc(&doc);
510
+ assert!(result.contains('\n'));
511
+ // Should break: long\nmore
512
+ assert!(result.starts_with(&long));
513
+ }
514
+
515
+ #[test]
516
+ fn test_print_softline_flat() {
517
+ // Softline disappears in flat mode
518
+ let doc = group(concat(vec![
519
+ text("["),
520
+ softline(),
521
+ text("1"),
522
+ softline(),
523
+ text("]"),
524
+ ]));
525
+ let result = print_doc(&doc);
526
+ assert_eq!(result, "[1]\n");
527
+ }
528
+
529
+ #[test]
530
+ fn test_print_if_break_flat() {
531
+ let doc = group(concat(vec![
532
+ text("["),
533
+ if_break(text("BROKEN"), text("FLAT")),
534
+ text("]"),
535
+ ]));
536
+ let result = print_doc(&doc);
537
+ assert_eq!(result, "[FLAT]\n");
538
+ }
539
+
540
+ #[test]
541
+ fn test_print_if_break_broken() {
542
+ // Force break with group_break (break_parent = true)
543
+ let doc = group_break(concat(vec![
544
+ text("["),
545
+ line(),
546
+ if_break(text("BROKEN"), text("FLAT")),
547
+ text("]"),
548
+ ]));
549
+ let result = print_doc(&doc);
550
+ assert!(
551
+ result.contains("BROKEN"),
552
+ "Expected BROKEN but got: {}",
553
+ result
554
+ );
555
+ }
556
+
557
+ #[test]
558
+ fn test_print_trailing_comment() {
559
+ let doc = concat(vec![text("code"), trailing_comment("# comment")]);
560
+ let result = print_doc(&doc);
561
+ assert_eq!(result, "code # comment\n");
562
+ }
563
+
564
+ #[test]
565
+ fn test_print_leading_comment() {
566
+ let doc = concat(vec![leading_comment("# comment", true), text("code")]);
567
+ let result = print_doc(&doc);
568
+ assert_eq!(result, "# comment\ncode\n");
569
+ }
570
+
571
+ #[test]
572
+ fn test_print_empty() {
573
+ let doc = empty();
574
+ let result = print_doc(&doc);
575
+ assert_eq!(result, "");
576
+ }
577
+
578
+ #[test]
579
+ fn test_print_join() {
580
+ let doc = join(text(", "), vec![text("a"), text("b"), text("c")]);
581
+ let result = print_doc(&doc);
582
+ assert_eq!(result, "a, b, c\n");
583
+ }
584
+
585
+ #[test]
586
+ fn test_print_ruby_class() {
587
+ // Correct Doc structure: hardline inside indent
588
+ let doc = concat(vec![
589
+ text("class Foo"),
590
+ indent(concat(vec![
591
+ hardline(),
592
+ text("def initialize"),
593
+ indent(concat(vec![hardline(), text("@value = 1")])),
594
+ hardline(),
595
+ text("end"),
596
+ ])),
597
+ hardline(),
598
+ text("end"),
599
+ ]);
600
+ let result = print_doc(&doc);
601
+
602
+ let expected = "\
603
+ class Foo
604
+ def initialize
605
+ @value = 1
606
+ end
607
+ end
608
+ ";
609
+ assert_eq!(result, expected);
610
+ }
611
+
612
+ #[test]
613
+ fn test_print_ruby_array_fits() {
614
+ // Array that fits on one line
615
+ let doc = group(concat(vec![
616
+ text("["),
617
+ softline(),
618
+ join(
619
+ concat(vec![text(","), line()]),
620
+ vec![text("1"), text("2"), text("3")],
621
+ ),
622
+ softline(),
623
+ text("]"),
624
+ ]));
625
+ let result = print_doc(&doc);
626
+ assert_eq!(result, "[1, 2, 3]\n");
627
+ }
628
+
629
+ #[test]
630
+ fn test_print_literal_line() {
631
+ // Literal line should not add indentation
632
+ let doc = concat(vec![
633
+ text("<<~HEREDOC"),
634
+ indent(concat(vec![literalline(), text("content"), literalline()])),
635
+ text("HEREDOC"),
636
+ ]);
637
+ let result = print_doc(&doc);
638
+ // literalline should not add indent despite being inside indent()
639
+ assert!(result.contains("\ncontent\n"));
640
+ }
641
+
642
+ // Performance regression tests
643
+ // Run with: cargo test --release perf_
644
+
645
+ #[test]
646
+ fn perf_deep_nesting() {
647
+ let config = Config::default();
648
+
649
+ // Create 20-level deep nesting
650
+ let mut doc = text("deepest");
651
+ for _ in 0..20 {
652
+ doc = indent(concat(vec![hardline(), doc]));
653
+ }
654
+
655
+ let mut printer = Printer::new(&config);
656
+ let result = printer.print(&doc);
657
+
658
+ // Verify it produces valid output
659
+ assert!(result.lines().count() >= 20);
660
+ }
661
+
662
+ #[test]
663
+ fn perf_many_hardlines() {
664
+ let config = Config::default();
665
+
666
+ // Create 500 lines
667
+ let mut lines: Vec<Doc> = Vec::new();
668
+ for i in 0..500 {
669
+ if i > 0 {
670
+ lines.push(hardline());
671
+ }
672
+ lines.push(text("line"));
673
+ }
674
+ let doc = indent(concat(lines));
675
+
676
+ let mut printer = Printer::new(&config);
677
+ let result = printer.print(&doc);
678
+
679
+ assert_eq!(result.lines().count(), 500);
680
+ }
681
+
682
+ #[test]
683
+ fn perf_nested_groups() {
684
+ let config = Config::default();
685
+
686
+ // Create nested group structure
687
+ let inner = group(concat(vec![
688
+ text("["),
689
+ softline(),
690
+ join(
691
+ concat(vec![text(","), line()]),
692
+ vec![text("1"), text("2"), text("3")],
693
+ ),
694
+ softline(),
695
+ text("]"),
696
+ ]));
697
+
698
+ let doc = group(concat(vec![
699
+ text("{"),
700
+ softline(),
701
+ text("a: "),
702
+ inner.clone(),
703
+ text(","),
704
+ line(),
705
+ text("b: "),
706
+ inner.clone(),
707
+ text(","),
708
+ line(),
709
+ text("c: "),
710
+ inner,
711
+ softline(),
712
+ text("}"),
713
+ ]));
714
+
715
+ let mut printer = Printer::new(&config);
716
+ let result = printer.print(&doc);
717
+
718
+ // Should produce valid output
719
+ assert!(result.contains("[1, 2, 3]") || result.contains("[\n"));
720
+ }
721
+ }