Skip to main content

forge_fmt/state/
mod.rs

1#![allow(clippy::too_many_arguments)]
2use crate::{
3    FormatterConfig, InlineConfig,
4    pp::{self, BreakToken, SIZE_INFINITY, Token},
5    state::sol::BinOpGroup,
6};
7use foundry_common::{
8    comments::{Comment, CommentStyle, Comments, estimate_line_width, line_with_tabs},
9    iter::IterDelimited,
10};
11use foundry_config::fmt::{DocCommentStyle, IndentStyle};
12use solar::parse::{
13    ast::{self, Span},
14    interface::{BytePos, SourceMap},
15    token,
16};
17use std::{borrow::Cow, ops::Deref, sync::Arc};
18
19mod common;
20mod sol;
21mod yul;
22
23/// Specifies the nature of a complex call.
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub(super) enum CallContextKind {
26    /// A chained method call, `a().b()`.
27    Chained,
28
29    /// A nested function call, `a(b())`.
30    Nested,
31}
32
33/// Formatting context for a call expression.
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub(super) struct CallContext {
36    /// The kind call.
37    pub(super) kind: CallContextKind,
38
39    /// The size of the callee's head, excluding its arguments.
40    pub(super) size: usize,
41
42    /// Whether this chain context added its own indentation box.
43    pub(super) has_indent: bool,
44}
45
46impl CallContext {
47    pub(super) const fn nested(size: usize) -> Self {
48        Self { kind: CallContextKind::Nested, size, has_indent: false }
49    }
50
51    pub(super) const fn chained(size: usize, has_indent: bool) -> Self {
52        Self { kind: CallContextKind::Chained, size, has_indent }
53    }
54
55    pub(super) const fn is_nested(&self) -> bool {
56        matches!(self.kind, CallContextKind::Nested)
57    }
58
59    pub(super) const fn is_chained(&self) -> bool {
60        matches!(self.kind, CallContextKind::Chained)
61    }
62}
63
64#[derive(Debug, Default)]
65pub(super) struct CallStack {
66    stack: Vec<CallContext>,
67}
68
69impl Deref for CallStack {
70    type Target = [CallContext];
71    fn deref(&self) -> &Self::Target {
72        &self.stack
73    }
74}
75
76impl CallStack {
77    pub(crate) fn push(&mut self, call: CallContext) {
78        self.stack.push(call);
79    }
80
81    pub(crate) fn pop(&mut self) -> Option<CallContext> {
82        self.stack.pop()
83    }
84
85    pub(crate) fn is_nested(&self) -> bool {
86        self.last().is_some_and(|call| call.is_nested())
87    }
88
89    /// Returns true if the direct parent chain has its own indentation.
90    /// Used to determine if commasep should skip its own indentation (to avoid double indent).
91    pub(crate) const fn has_indented_parent_chain(&self) -> bool {
92        matches!(
93            self.stack.as_slice(),
94            [.., parent, last] if last.is_nested() && parent.is_chained() && parent.has_indent
95        )
96    }
97}
98
99#[derive(Clone, Copy)]
100struct ChainedNamedCall {
101    callee: Span,
102    keep_inline: bool,
103}
104
105pub(super) struct State<'sess, 'ast> {
106    // CORE COMPONENTS
107    pub(super) s: pp::Printer,
108    ind: isize,
109
110    sm: &'sess SourceMap,
111    pub(super) comments: Comments,
112    config: Arc<FormatterConfig>,
113    inline_config: InlineConfig<()>,
114    cursor: SourcePos,
115
116    // FORMATTING CONTEXT:
117    // Whether the source file uses CRLF (`\r\n`) line endings.
118    has_crlf: bool,
119    // The current contract being formatted, if inside a contract definition.
120    contract: Option<&'ast ast::ItemContract<'ast>>,
121    // Current block nesting depth (incremented for each `{...}` block entered).
122    block_depth: usize,
123    // Stack tracking nested and chained function calls.
124    call_stack: CallStack,
125
126    // Whether the current statement should be formatted as a single line, or not.
127    single_line_stmt: Option<bool>,
128    // The current binary expression chain context, if inside one.
129    binary_expr: Option<BinOpGroup>,
130    // Whether inside a `return` statement that contains a binary expression, or not.
131    return_bin_expr: bool,
132    // Whether inside a call with call options and at least one argument.
133    call_with_opts_and_args: bool,
134    // Callee of the current chained call with named arguments, if any.
135    chained_named_call: Option<ChainedNamedCall>,
136    // Whether to skip the index soft breaks because the callee fits inline.
137    skip_index_break: bool,
138    // Whether inside an `emit` or `revert` call with a qualified path, or not.
139    emit_or_revert: bool,
140    // Whether inside a variable initialization expression, or not.
141    var_init: bool,
142}
143
144impl std::ops::Deref for State<'_, '_> {
145    type Target = pp::Printer;
146
147    #[inline(always)]
148    fn deref(&self) -> &Self::Target {
149        &self.s
150    }
151}
152
153impl std::ops::DerefMut for State<'_, '_> {
154    #[inline(always)]
155    fn deref_mut(&mut self) -> &mut Self::Target {
156        &mut self.s
157    }
158}
159
160struct SourcePos {
161    pos: BytePos,
162    enabled: bool,
163}
164
165impl SourcePos {
166    /// While `enabled`, the position is a loose lower bound that is resynced by `advance_to`.
167    /// While disabled, it is the exact start of the not-yet-printed source of a disabled region.
168    pub(super) fn advance(&mut self, bytes: u32) {
169        self.pos += BytePos(bytes);
170    }
171
172    pub(super) fn advance_to(&mut self, pos: BytePos, enabled: bool) {
173        // Ignore stale updates while disabled, as the exact position must be preserved until the
174        // remainder of the disabled region has been printed.
175        if self.enabled || pos >= self.pos {
176            self.pos = std::cmp::max(pos, self.pos);
177            self.enabled = enabled;
178        }
179    }
180
181    pub(super) fn next_line(&mut self, is_at_crlf: bool) {
182        self.pos += if is_at_crlf { 2 } else { 1 };
183    }
184
185    pub(super) fn span(&self, to: BytePos) -> Span {
186        Span::new(self.pos, to)
187    }
188}
189
190pub(super) enum Separator {
191    Nbsp,
192    Space,
193    Hardbreak,
194    SpaceOrNbsp(bool),
195}
196
197impl Separator {
198    fn print(&self, p: &mut pp::Printer) {
199        match self {
200            Self::Nbsp => p.nbsp(),
201            Self::Space => p.space(),
202            Self::Hardbreak => p.hardbreak(),
203            Self::SpaceOrNbsp(breaks) => p.space_or_nbsp(*breaks),
204        }
205    }
206}
207
208/// Generic methods
209impl<'sess> State<'sess, '_> {
210    pub(super) fn new(
211        sm: &'sess SourceMap,
212        start_pos: BytePos,
213        config: Arc<FormatterConfig>,
214        inline_config: InlineConfig<()>,
215        comments: Comments,
216    ) -> Self {
217        Self {
218            s: pp::Printer::new(
219                config.line_length,
220                matches!(config.style, IndentStyle::Tab).then(|| config.tab_width),
221            ),
222            ind: config.tab_width as isize,
223            sm,
224            comments,
225            config,
226            inline_config,
227            cursor: SourcePos { pos: start_pos, enabled: true },
228            has_crlf: false,
229            contract: None,
230            single_line_stmt: None,
231            call_with_opts_and_args: false,
232            chained_named_call: None,
233            skip_index_break: false,
234            binary_expr: None,
235            return_bin_expr: false,
236            emit_or_revert: false,
237            var_init: false,
238            block_depth: 0,
239            call_stack: CallStack::default(),
240        }
241    }
242
243    /// Checks a span of the source for a carriage return (`\r`) to determine if the file
244    /// uses CRLF line endings.
245    ///
246    /// If a `\r` is found, `self.has_crlf` is set to `true`. This is intended to be
247    /// called once at the beginning of the formatting process for efficiency.
248    fn check_crlf(&mut self, span: Span) {
249        if let Ok(snip) = self.sm.span_to_snippet(span)
250            && snip.contains('\r')
251        {
252            self.has_crlf = true;
253        }
254    }
255
256    /// Checks if the cursor is currently positioned at the start of a CRLF sequence (`\r\n`).
257    /// The check is only meaningful if `self.has_crlf` is true.
258    fn is_at_crlf(&self) -> bool {
259        self.has_crlf && self.char_at(self.cursor.pos) == Some('\r')
260    }
261
262    /// Advances the cursor past the line break assumed to be represented by a printed separator.
263    ///
264    /// While the cursor is disabled it marks the exact start of not-yet-printed source, so it may
265    /// only advance if it actually sits at a line break; otherwise the separator does not consume
266    /// any source (e.g. it was already printed verbatim by a disabled trailing comment).
267    fn cursor_next_line(&mut self) {
268        if self.cursor.enabled || matches!(self.char_at(self.cursor.pos), Some('\n' | '\r')) {
269            self.cursor.next_line(self.is_at_crlf());
270        }
271    }
272
273    /// Computes the space left, bounded by the max space left.
274    fn space_left(&self) -> usize {
275        std::cmp::min(self.s.space_left(), self.max_space_left(0))
276    }
277
278    /// Computes the maximum space left given the context information available:
279    /// `block_depth`, `tab_width`, and a user-defined unavailable size `prefix_len`.
280    fn max_space_left(&self, prefix_len: usize) -> usize {
281        self.config
282            .line_length
283            .saturating_sub(self.block_depth * self.config.tab_width + prefix_len)
284    }
285
286    fn break_offset_if_not_bol(&mut self, n: usize, off: isize, search: bool) {
287        // When searching, the break token is expected to be inside a closed box. Thus, we will
288        // traverse the buffer and evaluate the first non-end token.
289        if search {
290            // We do something pretty sketchy here: tuck the nonzero offset-adjustment we
291            // were going to deposit along with the break into the previous hardbreak.
292            self.find_and_replace_last_token_still_buffered(
293                pp::Printer::hardbreak_tok_offset(off),
294                |token| token.is_hardbreak(),
295            );
296            return;
297        }
298
299        // When not explicitly searching, the break token is expected to be the last token.
300        if !self.is_beginning_of_line() {
301            self.break_offset(n, off)
302        } else if off != 0
303            && let Some(last_token) = self.last_token_still_buffered()
304            && last_token.is_hardbreak()
305        {
306            // We do something pretty sketchy here: tuck the nonzero offset-adjustment we
307            // were going to deposit along with the break into the previous hardbreak.
308            self.replace_last_token_still_buffered(pp::Printer::hardbreak_tok_offset(off));
309        }
310    }
311
312    fn braces_break(&mut self) {
313        if self.config.bracket_spacing {
314            self.space();
315        } else {
316            self.zerobreak();
317        }
318    }
319}
320
321/// Span to source.
322impl State<'_, '_> {
323    fn char_at(&self, pos: BytePos) -> Option<char> {
324        let res = self.sm.lookup_byte_offset(pos);
325        res.sf.src.get(res.pos.to_usize()..)?.chars().next()
326    }
327
328    /// Returns the position of the first `{` within the span, ignoring the ones inside comments.
329    fn find_opening_brace(&self, span: Span) -> Option<BytePos> {
330        let snip = self.sm.span_to_snippet(span).ok()?;
331        let mut idx = 0;
332        while idx < snip.len() {
333            let rest = &snip[idx..];
334            if rest.starts_with('{') {
335                return Some(span.lo() + idx as u32);
336            }
337            idx += if let Some(line) = rest.strip_prefix("//") {
338                2 + line.find('\n').unwrap_or(line.len())
339            } else if let Some(block) = rest.strip_prefix("/*") {
340                2 + block.find("*/").map_or(block.len(), |end| end + 2)
341            } else {
342                rest.chars().next().map_or(1, char::len_utf8)
343            };
344        }
345        None
346    }
347
348    fn print_span(&mut self, span: Span) {
349        match self.sm.span_to_snippet(span) {
350            Ok(s) => self.s.word(if matches!(self.config.style, IndentStyle::Tab) {
351                snippet_with_tabs(s, self.config.tab_width)
352            } else {
353                s
354            }),
355            Err(e) => panic!("failed to print {span:?}: {e:#?}"),
356        }
357        // Drop comments that are included in the span.
358        while let Some(cmnt) = self.peek_comment() {
359            if cmnt.pos() >= span.hi() {
360                break;
361            }
362            let _ = self.next_comment().unwrap();
363        }
364        // Update cursor
365        self.cursor.advance_to(span.hi(), false);
366    }
367
368    /// Returns `true` if the span is disabled and has been printed as-is.
369    #[must_use]
370    fn handle_span(&mut self, span: Span, skip_prev_cmnts: bool) -> bool {
371        if !skip_prev_cmnts {
372            self.print_comments(span.lo(), CommentConfig::default());
373        }
374        self.print_span_if_disabled(span)
375    }
376
377    /// Returns `true` if the span is disabled and has been printed as-is.
378    #[inline]
379    #[must_use]
380    fn print_span_if_disabled(&mut self, span: Span) -> bool {
381        let cursor_span = self.cursor.span(span.hi());
382        if self.inline_config.is_disabled(cursor_span) {
383            self.print_span_cold(cursor_span);
384            return true;
385        }
386        if self.inline_config.is_disabled(span) {
387            self.print_span_cold(span);
388            return true;
389        }
390        false
391    }
392
393    #[cold]
394    fn print_span_cold(&mut self, span: Span) {
395        self.print_span(span);
396    }
397
398    fn print_tokens(&mut self, tokens: &[token::Token]) {
399        // Leave unchanged.
400        let span = Span::join_first_last(tokens.iter().map(|t| t.span));
401        self.print_span(span);
402    }
403
404    fn print_word(&mut self, w: impl Into<Cow<'static, str>>) {
405        let cow = w.into();
406        self.cursor.advance(cow.len() as u32);
407        self.word(cow);
408    }
409
410    fn print_sep(&mut self, sep: Separator) {
411        if self.handle_span(
412            self.cursor.span(self.cursor.pos + if self.is_at_crlf() { 2 } else { 1 }),
413            true,
414        ) {
415            return;
416        }
417
418        self.print_sep_unhandled(sep);
419    }
420
421    fn print_sep_unhandled(&mut self, sep: Separator) {
422        sep.print(&mut self.s);
423        self.cursor_next_line();
424    }
425
426    fn print_ident(&mut self, ident: &ast::Ident) {
427        if self.handle_span(ident.span, true) {
428            return;
429        }
430
431        self.print_comments(ident.span.lo(), CommentConfig::skip_ws());
432        self.word(ident.to_string());
433    }
434
435    fn print_inside_parens<F>(&mut self, f: F)
436    where
437        F: FnOnce(&mut Self),
438    {
439        self.print_word("(");
440        f(self);
441        self.print_word(")");
442    }
443
444    fn estimate_size(&self, span: Span) -> usize {
445        if let Ok(snip) = self.sm.span_to_snippet(span) {
446            let (mut size, mut first, mut prev_needs_space) = (0, true, false);
447
448            for line in snip.lines() {
449                let line = line.trim();
450
451                if prev_needs_space {
452                    size += 1;
453                } else if !first && let Some(char) = line.chars().next() {
454                    // A line break or a space are required if this line:
455                    // - starts with an operator.
456                    // - starts with one of the ternary operators
457                    // - starts with a bracket and fmt config forces bracket spacing.
458                    match char {
459                        '&' | '|' | '=' | '>' | '<' | '+' | '-' | '*' | '/' | '%' | '^' | '?'
460                        | ':' => size += 1,
461                        '}' | ')' | ']' if self.config.bracket_spacing => size += 1,
462                        _ => (),
463                    }
464                }
465                first = false;
466
467                // trim spaces before and after mixed comments
468                let mut search = line;
469                loop {
470                    if let Some((lhs, comment)) = search.split_once(r#"/*"#) {
471                        size += lhs.trim_end().len() + 2;
472                        search = comment;
473                    } else if let Some((comment, rhs)) = search.split_once(r#"*/"#) {
474                        size += comment.len() + 2;
475                        search = rhs;
476                    } else {
477                        size += search.trim().len();
478                        break;
479                    }
480                }
481
482                // Next line requires a line break if this one:
483                // - ends with a bracket and fmt config forces bracket spacing.
484                // - ends with ',' a line break or a space are required.
485                // - ends with ';' a line break is required.
486                prev_needs_space = match line.chars().next_back() {
487                    Some('[' | '(' | '{') => self.config.bracket_spacing,
488                    Some(',' | ';') => true,
489                    _ => false,
490                };
491            }
492            return size;
493        }
494
495        span.to_range().len()
496    }
497
498    fn same_source_line(&self, a: BytePos, b: BytePos) -> bool {
499        self.sm.lookup_char_pos(a).line == self.sm.lookup_char_pos(b).line
500    }
501}
502
503/// Comment-related methods.
504impl<'sess> State<'sess, '_> {
505    /// Returns `None` if the span is disabled and has been printed as-is.
506    #[must_use]
507    fn handle_comment(&mut self, cmnt: Comment, skip_break: bool) -> Option<Comment> {
508        if self.cursor.enabled {
509            if self.inline_config.is_disabled(cmnt.span) {
510                if cmnt.style.is_trailing() && !self.last_token_is_space() {
511                    self.nbsp();
512                }
513                self.print_span_cold(cmnt.span);
514                if !skip_break && (cmnt.style.is_isolated() || cmnt.style.is_trailing()) {
515                    self.print_sep(Separator::Hardbreak);
516                }
517                return None;
518            }
519        } else if self.print_span_if_disabled(cmnt.span) {
520            if !skip_break && (cmnt.style.is_isolated() || cmnt.style.is_trailing()) {
521                self.print_sep(Separator::Hardbreak);
522            }
523            return None;
524        }
525        Some(cmnt)
526    }
527
528    fn cmnt_config(&self) -> CommentConfig {
529        Default::default()
530    }
531
532    const fn print_docs(&mut self, docs: &'_ ast::DocComments<'_>) {
533        // Intentionally no-op. Handled with `self.comments`.
534        let _ = docs;
535    }
536
537    /// Prints comments that are before the given position.
538    ///
539    /// Returns `Some` with the style of the last comment printed, or `None` if no comment was
540    /// printed.
541    fn print_comments(&mut self, pos: BytePos, mut config: CommentConfig) -> Option<CommentStyle> {
542        let mut last_style: Option<CommentStyle> = None;
543        let mut is_leading = true;
544        let config_cache = config;
545        let mut buffered_blank = None;
546        while self.peek_comment().is_some_and(|c| c.pos() < pos) {
547            let mut cmnt = self.next_comment().unwrap();
548            let style_cache = cmnt.style;
549
550            // Merge consecutive line doc comments when converting to block style
551            if self.config.docs_style == foundry_config::fmt::DocCommentStyle::Block
552                && cmnt.is_doc
553                && cmnt.kind == ast::CommentKind::Line
554            {
555                let mut ref_line = self.sm.lookup_char_pos(cmnt.span.hi()).line;
556                while let Some(next_cmnt) = self.peek_comment() {
557                    if !next_cmnt.is_doc
558                        || next_cmnt.kind != ast::CommentKind::Line
559                        || ref_line + 1 != self.sm.lookup_char_pos(next_cmnt.span.lo()).line
560                    {
561                        break;
562                    }
563
564                    let next_to_merge = self.next_comment().unwrap();
565                    cmnt.lines.extend(next_to_merge.lines);
566                    cmnt.span = cmnt.span.to(next_to_merge.span);
567                    ref_line += 1;
568                }
569            }
570
571            // Ensure breaks are never skipped when there are multiple comments
572            if self.peek_comment_before(pos).is_some() {
573                config.iso_no_break = false;
574                config.trailing_no_break = false;
575            }
576
577            // Handle disabled comments
578            let Some(cmnt) = self.handle_comment(
579                cmnt,
580                if style_cache.is_isolated() {
581                    config.iso_no_break
582                } else {
583                    config.trailing_no_break
584                },
585            ) else {
586                last_style = Some(style_cache);
587                continue;
588            };
589
590            if cmnt.style.is_blank() {
591                match config.skip_blanks {
592                    Some(Skip::All) => continue,
593                    Some(Skip::Leading { resettable: true }) if is_leading => continue,
594                    Some(Skip::Leading { resettable: false }) if last_style.is_none() => continue,
595                    Some(Skip::Trailing) => {
596                        buffered_blank = Some(cmnt);
597                        continue;
598                    }
599                    _ => (),
600                }
601            // Never print blank lines after docs comments
602            } else if !cmnt.is_doc {
603                is_leading = false;
604            }
605
606            if let Some(blank) = buffered_blank.take() {
607                self.print_comment(blank, config);
608            }
609
610            // Handle mixed with follow-up comment
611            if cmnt.style.is_mixed() {
612                if let Some(cmnt) = self.peek_comment_before(pos) {
613                    config.mixed_no_break_prev = true;
614                    config.mixed_no_break_post = true;
615                    config.mixed_post_nbsp = false;
616                    // The separator within a run of mixed comments must never break, even with
617                    // `wrap_comments`: a breakable space inside a broken consistent box always
618                    // breaks, which splits the run and reclassifies its comments on the next run.
619                    if cmnt.style.is_mixed() {
620                        config = config.mixed_post_glued();
621                    }
622                }
623
624                // Ensure consecutive mixed comments don't have a double-space
625                if last_style.is_some_and(|s| s.is_mixed()) {
626                    config.mixed_no_break_prev = true;
627                    config.mixed_no_break_post = true;
628                    config.mixed_prev_space = false;
629                }
630            } else if config.offset != 0
631                && cmnt.style.is_isolated()
632                && last_style.is_some_and(|s| s.is_isolated())
633            {
634                self.offset(config.offset);
635            }
636
637            last_style = Some(cmnt.style);
638            self.print_comment(cmnt, config);
639            config = config_cache;
640        }
641        last_style
642    }
643
644    /// Prints a line, wrapping it if it starts with the given prefix.
645    fn print_wrapped_line(
646        &mut self,
647        line: &str,
648        prefix: &'static str,
649        break_offset: isize,
650        is_doc: bool,
651    ) {
652        if !line.starts_with(prefix) {
653            self.word(line.to_owned());
654            return;
655        }
656
657        fn post_break_prefix(prefix: &'static str, has_content: bool) -> &'static str {
658            if !has_content {
659                return prefix;
660            }
661            match prefix {
662                "///" => "/// ",
663                "//" => "// ",
664                "/*" => "/* ",
665                " *" => " * ",
666                _ => prefix,
667            }
668        }
669
670        self.ibox(0);
671        self.word(prefix);
672
673        let content = &line[prefix.len()..];
674        let content = if is_doc {
675            // Doc comments preserve leading whitespaces (right after the prefix) as nbps.
676            let ws_len = content
677                .char_indices()
678                .take_while(|(_, c)| c.is_whitespace())
679                .last()
680                .map_or(0, |(idx, c)| idx + c.len_utf8());
681            let (leading_ws, rest) = content.split_at(ws_len);
682            if !leading_ws.is_empty() {
683                self.word(leading_ws.to_owned());
684            }
685            rest
686        } else {
687            // Non-doc comments: replace first whitespace with nbsp, rest of content continues
688            if let Some(first_char) = content.chars().next() {
689                if first_char.is_whitespace() {
690                    self.nbsp();
691                    &content[first_char.len_utf8()..]
692                } else {
693                    content
694                }
695            } else {
696                ""
697            }
698        };
699
700        let post_break = post_break_prefix(prefix, !content.is_empty());
701
702        // Process content character by character to preserve consecutive whitespaces
703        let (mut chars, mut current_word) = (content.chars().peekable(), String::new());
704        while let Some(ch) = chars.next() {
705            if ch.is_whitespace() {
706                // Print current word
707                if !current_word.is_empty() {
708                    self.word(std::mem::take(&mut current_word));
709                }
710
711                // Preserve multiple spaces while adding a single break
712                let mut ws_count = 1;
713                while chars.peek().is_some_and(|c| c.is_whitespace()) {
714                    ws_count += 1;
715                    chars.next();
716                }
717                self.s.scan_break(BreakToken {
718                    offset: break_offset,
719                    blank_space: ws_count,
720                    post_break: if post_break.starts_with("/*") { None } else { Some(post_break) },
721                    ..Default::default()
722                });
723                continue;
724            }
725
726            current_word.push(ch);
727        }
728
729        // Print final word
730        if !current_word.is_empty() {
731            self.word(current_word);
732        }
733
734        self.end();
735    }
736
737    /// Merges consecutive line comments to avoid orphan words.
738    fn merge_comment_lines(&self, lines: &[String], prefix: &str) -> Vec<String> {
739        // Do not apply smart merging to block comments
740        if lines.is_empty() || lines.len() < 2 || !prefix.starts_with("//") {
741            return lines.to_vec();
742        }
743
744        let mut result = Vec::new();
745        let mut i = 0;
746
747        while i < lines.len() {
748            let current_line = &lines[i];
749
750            // Keep empty lines, and non-prefixed lines, untouched
751            if current_line.trim().is_empty() || !current_line.starts_with(prefix) {
752                result.push(current_line.clone());
753                i += 1;
754                continue;
755            }
756
757            if i + 1 < lines.len() {
758                let next_line = &lines[i + 1];
759
760                // Check if next line has the same prefix and is not empty.
761                if next_line.starts_with(prefix) && !next_line.trim().is_empty() {
762                    let next_content = next_line[prefix.len()..].trim_start();
763
764                    // Keep each NatSpec tag on its own doc-comment line. Merging a wrapped
765                    // `@dev`/`@param` line with the following tag changes the tag boundary.
766                    if next_content.starts_with('@') {
767                        result.push(current_line.clone());
768                        i += 1;
769                        continue;
770                    }
771
772                    // Only merge if the current line doesn't fit within available width
773                    if estimate_line_width(current_line, self.config.tab_width) > self.space_left()
774                    {
775                        // Merge the lines and let the wrapper handle breaking if needed
776                        let merged_line = format!("{current_line} {next_content}");
777                        result.push(merged_line);
778
779                        // Skip both lines since they are merged
780                        i += 2;
781                        continue;
782                    }
783                }
784            }
785
786            // No merge possible, keep the line as-is
787            result.push(current_line.clone());
788            i += 1;
789        }
790
791        result
792    }
793
794    fn print_comment(&mut self, mut cmnt: Comment, mut config: CommentConfig) {
795        self.cursor.advance_to(cmnt.span.hi(), true);
796
797        if cmnt.is_doc {
798            cmnt = style_doc_comment(self.config.docs_style, cmnt);
799        }
800
801        match cmnt.style {
802            CommentStyle::Mixed => {
803                let Some(prefix) = cmnt.prefix() else { return };
804                let never_break = self.last_token_is_neverbreak();
805                if !self.is_bol_or_only_ind() {
806                    match (never_break || config.mixed_no_break_prev, config.mixed_prev_space) {
807                        (false, true) => config.space(&mut self.s),
808                        (false, false) => config.zerobreak(&mut self.s),
809                        (true, true) => self.nbsp(),
810                        (true, false) => (),
811                    };
812                }
813                if self.config.wrap_comments {
814                    // Merge and wrap comments
815                    let merged_lines = self.merge_comment_lines(&cmnt.lines, prefix);
816                    for (pos, line) in merged_lines.into_iter().delimited() {
817                        self.print_wrapped_line(&line, prefix, 0, cmnt.is_doc);
818                        if !pos.is_last {
819                            self.hardbreak();
820                        }
821                    }
822                } else {
823                    // No wrapping, print as-is
824                    for (pos, line) in cmnt.lines.into_iter().delimited() {
825                        self.word(line);
826                        if !pos.is_last {
827                            self.hardbreak();
828                        }
829                    }
830                }
831                if config.mixed_post_nbsp {
832                    if config.mixed_post_glued {
833                        self.nbsp();
834                    } else {
835                        config.nbsp_or_space(self.config.wrap_comments, &mut self.s);
836                    }
837                    self.cursor.advance(1);
838                } else if !config.mixed_no_break_post {
839                    config.space(&mut self.s);
840                    self.cursor.advance(1);
841                }
842            }
843            CommentStyle::Isolated => {
844                let Some(mut prefix) = cmnt.prefix() else { return };
845                if !config.iso_no_break {
846                    config.hardbreak_if_not_bol(self.is_bol_or_only_ind(), &mut self.s);
847                }
848
849                if self.config.wrap_comments {
850                    // Merge and wrap comments
851                    let merged_lines = self.merge_comment_lines(&cmnt.lines, prefix);
852                    for (pos, line) in merged_lines.into_iter().delimited() {
853                        let hb = |this: &mut Self| {
854                            this.hardbreak();
855                            if pos.is_last {
856                                this.cursor.next_line(this.is_at_crlf());
857                            }
858                        };
859                        if line.is_empty() {
860                            hb(self);
861                            continue;
862                        }
863                        if pos.is_first {
864                            self.ibox(config.offset);
865                            if cmnt.is_doc && matches!(prefix, "/**") {
866                                self.word(prefix);
867                                hb(self);
868                                prefix = " * ";
869                                continue;
870                            }
871                        }
872
873                        self.print_wrapped_line(&line, prefix, 0, cmnt.is_doc);
874
875                        if pos.is_last {
876                            self.end();
877                            if !config.iso_no_break {
878                                hb(self);
879                            }
880                        } else {
881                            hb(self);
882                        }
883                    }
884                } else {
885                    // No wrapping, print as-is
886                    for (pos, line) in cmnt.lines.into_iter().delimited() {
887                        let hb = |this: &mut Self| {
888                            this.hardbreak();
889                            if pos.is_last {
890                                this.cursor.next_line(this.is_at_crlf());
891                            }
892                        };
893                        if line.is_empty() {
894                            hb(self);
895                            continue;
896                        }
897                        if pos.is_first {
898                            self.ibox(config.offset);
899                            if cmnt.is_doc && matches!(prefix, "/**") {
900                                self.word(prefix);
901                                hb(self);
902                                prefix = " * ";
903                                continue;
904                            }
905                        }
906
907                        self.word(line);
908
909                        if pos.is_last {
910                            self.end();
911                            if !config.iso_no_break {
912                                hb(self);
913                            }
914                        } else {
915                            hb(self);
916                        }
917                    }
918                }
919            }
920            CommentStyle::Trailing => {
921                let Some(prefix) = cmnt.prefix() else { return };
922                self.neverbreak();
923                if !self.is_bol_or_only_ind() {
924                    self.nbsp();
925                }
926
927                if !self.config.wrap_comments && cmnt.lines.len() == 1 {
928                    self.word(cmnt.lines.pop().unwrap());
929                } else if self.config.wrap_comments {
930                    if cmnt.is_doc || matches!(cmnt.kind, ast::CommentKind::Line) {
931                        config.offset = 0;
932                    } else {
933                        config.offset = self.ind;
934                    }
935                    for (lpos, line) in cmnt.lines.into_iter().delimited() {
936                        if !line.is_empty() {
937                            self.print_wrapped_line(&line, prefix, config.offset, cmnt.is_doc);
938                        }
939                        if !lpos.is_last {
940                            config.hardbreak(&mut self.s);
941                        }
942                    }
943                } else {
944                    self.visual_align();
945                    for (pos, line) in cmnt.lines.into_iter().delimited() {
946                        if !line.is_empty() {
947                            self.word(line);
948                            if !pos.is_last {
949                                self.hardbreak();
950                            }
951                        }
952                    }
953                    self.end();
954                }
955
956                if !config.trailing_no_break {
957                    self.print_sep(Separator::Hardbreak);
958                }
959            }
960
961            CommentStyle::BlankLine => {
962                // Pre-requisite: ensure that blank links are printed at the beginning of new line.
963                if !self.last_token_is_break() && !self.is_bol_or_only_ind() {
964                    config.hardbreak(&mut self.s);
965                    self.cursor.next_line(self.is_at_crlf());
966                }
967
968                // We need to do at least one, possibly two hardbreaks.
969                let twice = match self.last_token() {
970                    Some(Token::String(s)) => ";" == s,
971                    Some(Token::Begin(_)) => true,
972                    Some(Token::End) => true,
973                    _ => false,
974                };
975                if twice {
976                    config.hardbreak(&mut self.s);
977                    self.cursor.next_line(self.is_at_crlf());
978                }
979                config.hardbreak(&mut self.s);
980                self.cursor.next_line(self.is_at_crlf());
981            }
982        }
983    }
984
985    fn peek_comment<'b>(&'b self) -> Option<&'b Comment>
986    where
987        'sess: 'b,
988    {
989        self.comments.peek()
990    }
991
992    fn peek_comment_before<'b>(&'b self, pos: BytePos) -> Option<&'b Comment>
993    where
994        'sess: 'b,
995    {
996        self.comments.iter().take_while(|c| c.pos() < pos).find(|c| !c.style.is_blank())
997    }
998
999    /// Returns `true` if the next comment is a mixed comment that starts before the given
1000    /// position.
1001    fn peek_mixed_comment_before(&self, pos: Option<BytePos>) -> bool {
1002        pos.is_some_and(|pos| {
1003            self.peek_comment().is_some_and(|cmnt| cmnt.pos() < pos && cmnt.style.is_mixed())
1004        })
1005    }
1006
1007    fn has_comment_before_with<F>(&self, pos: BytePos, f: F) -> bool
1008    where
1009        F: FnMut(&Comment) -> bool,
1010    {
1011        self.comments.iter().take_while(|c| c.pos() < pos).any(f)
1012    }
1013
1014    fn peek_comment_between<'b>(&'b self, pos_lo: BytePos, pos_hi: BytePos) -> Option<&'b Comment>
1015    where
1016        'sess: 'b,
1017    {
1018        self.comments
1019            .iter()
1020            .skip_while(|c| c.pos() < pos_lo)
1021            .take_while(|c| c.pos() < pos_hi)
1022            .find(|c| !c.style.is_blank())
1023    }
1024
1025    fn has_comment_between(&self, start_pos: BytePos, end_pos: BytePos) -> bool {
1026        self.comments.iter().filter(|c| c.pos() > start_pos && c.pos() < end_pos).any(|_| true)
1027    }
1028
1029    fn has_breakable_comment_between(&self, start_pos: BytePos, end_pos: BytePos) -> bool {
1030        self.comments
1031            .iter()
1032            .filter(|comment| comment.pos() >= start_pos && comment.pos() < end_pos)
1033            .any(|comment| !comment.style.is_blank())
1034    }
1035
1036    pub(crate) fn next_comment(&mut self) -> Option<Comment> {
1037        self.comments.next()
1038    }
1039
1040    fn peek_trailing_comment<'b>(
1041        &'b self,
1042        span_pos: BytePos,
1043        next_pos: Option<BytePos>,
1044    ) -> Option<&'b Comment>
1045    where
1046        'sess: 'b,
1047    {
1048        self.comments.peek_trailing(self.sm, span_pos, next_pos).map(|(cmnt, _)| cmnt)
1049    }
1050
1051    fn print_trailing_comment_inner(
1052        &mut self,
1053        span_pos: BytePos,
1054        next_pos: Option<BytePos>,
1055        config: Option<CommentConfig>,
1056    ) -> bool {
1057        let mut printed = 0;
1058        if let Some((_, n)) = self.comments.peek_trailing(self.sm, span_pos, next_pos) {
1059            let config =
1060                config.unwrap_or(CommentConfig::skip_ws().mixed_no_break().mixed_prev_space());
1061            while printed <= n {
1062                let cmnt = self.comments.next().unwrap();
1063                if let Some(cmnt) = self.handle_comment(cmnt, config.trailing_no_break) {
1064                    self.print_comment(cmnt, config);
1065                };
1066                printed += 1;
1067            }
1068        }
1069        printed != 0
1070    }
1071
1072    fn print_trailing_comment(&mut self, span_pos: BytePos, next_pos: Option<BytePos>) -> bool {
1073        self.print_trailing_comment_inner(span_pos, next_pos, None)
1074    }
1075
1076    fn print_trailing_comment_no_break(&mut self, span_pos: BytePos, next_pos: Option<BytePos>) {
1077        self.print_trailing_comment_inner(
1078            span_pos,
1079            next_pos,
1080            Some(CommentConfig::skip_ws().trailing_no_break().mixed_no_break().mixed_prev_space()),
1081        );
1082    }
1083
1084    fn print_remaining_comments(&mut self, skip_leading_ws: bool) {
1085        // If there aren't any remaining comments, then we need to manually
1086        // make sure there is a line break at the end.
1087        if self.peek_comment().is_none() && !self.is_bol_or_only_ind() {
1088            self.hardbreak();
1089            return;
1090        }
1091
1092        let mut is_leading = true;
1093        while let Some(cmnt) = self.next_comment() {
1094            if cmnt.style.is_blank() && skip_leading_ws && is_leading {
1095                continue;
1096            }
1097
1098            is_leading = false;
1099            if let Some(cmnt) = self.handle_comment(cmnt, false) {
1100                self.print_comment(cmnt, CommentConfig::default());
1101            } else if self.peek_comment().is_none() && !self.is_bol_or_only_ind() {
1102                self.hardbreak();
1103            }
1104        }
1105    }
1106}
1107
1108#[derive(Clone, Copy)]
1109enum Skip {
1110    All,
1111    Leading { resettable: bool },
1112    Trailing,
1113}
1114
1115#[derive(Default, Clone, Copy)]
1116pub(crate) struct CommentConfig {
1117    // Config: all
1118    skip_blanks: Option<Skip>,
1119    offset: isize,
1120
1121    // Config: isolated comments
1122    iso_no_break: bool,
1123    // Config: trailing comments
1124    trailing_no_break: bool,
1125    // Config: mixed comments
1126    mixed_prev_space: bool,
1127    mixed_post_nbsp: bool,
1128    /// Makes `mixed_post_nbsp` emit a hard space even with `wrap_comments`, which would
1129    /// otherwise use a breakable one. Required when the comment is glued to a closing token, as
1130    /// a break in between detaches it and reclassifies the comment on the next run.
1131    mixed_post_glued: bool,
1132    mixed_no_break_prev: bool,
1133    mixed_no_break_post: bool,
1134}
1135
1136impl CommentConfig {
1137    pub(crate) fn skip_ws() -> Self {
1138        Self { skip_blanks: Some(Skip::All), ..Default::default() }
1139    }
1140
1141    /// Config for comments that are the sole content of an otherwise empty block, so that they
1142    /// are surrounded by spaces: `{ /* comment */ }`.
1143    pub(crate) fn empty_block() -> Self {
1144        Self::skip_ws().mixed_no_break().mixed_prev_space().mixed_post_glued()
1145    }
1146
1147    pub(crate) fn skip_leading_ws(resettable: bool) -> Self {
1148        Self { skip_blanks: Some(Skip::Leading { resettable }), ..Default::default() }
1149    }
1150
1151    pub(crate) fn skip_trailing_ws() -> Self {
1152        Self { skip_blanks: Some(Skip::Trailing), ..Default::default() }
1153    }
1154
1155    pub(crate) const fn offset(mut self, off: isize) -> Self {
1156        self.offset = off;
1157        self
1158    }
1159
1160    pub(crate) const fn no_breaks(mut self) -> Self {
1161        self.iso_no_break = true;
1162        self.trailing_no_break = true;
1163        self.mixed_no_break_prev = true;
1164        self.mixed_no_break_post = true;
1165        self
1166    }
1167
1168    pub(crate) const fn trailing_no_break(mut self) -> Self {
1169        self.trailing_no_break = true;
1170        self
1171    }
1172
1173    pub(crate) const fn mixed_no_break(mut self) -> Self {
1174        self.mixed_no_break_prev = true;
1175        self.mixed_no_break_post = true;
1176        self
1177    }
1178
1179    pub(crate) const fn mixed_no_break_post(mut self) -> Self {
1180        self.mixed_no_break_post = true;
1181        self
1182    }
1183
1184    pub(crate) const fn mixed_prev_space(mut self) -> Self {
1185        self.mixed_prev_space = true;
1186        self
1187    }
1188
1189    pub(crate) const fn mixed_post_nbsp(mut self) -> Self {
1190        self.mixed_post_nbsp = true;
1191        self
1192    }
1193
1194    pub(crate) const fn mixed_post_glued(mut self) -> Self {
1195        self.mixed_post_nbsp = true;
1196        self.mixed_post_glued = true;
1197        self
1198    }
1199
1200    pub(crate) fn hardbreak_if_not_bol(&self, is_bol: bool, p: &mut pp::Printer) {
1201        if self.offset != 0 && !is_bol {
1202            self.hardbreak(p);
1203        } else {
1204            p.hardbreak_if_not_bol();
1205        }
1206    }
1207
1208    pub(crate) fn hardbreak(&self, p: &mut pp::Printer) {
1209        p.break_offset(SIZE_INFINITY as usize, self.offset);
1210    }
1211
1212    pub(crate) fn space(&self, p: &mut pp::Printer) {
1213        p.break_offset(1, self.offset);
1214    }
1215
1216    pub(crate) fn nbsp_or_space(&self, breaks: bool, p: &mut pp::Printer) {
1217        if breaks {
1218            self.space(p);
1219        } else {
1220            p.nbsp();
1221        }
1222    }
1223
1224    pub(crate) fn zerobreak(&self, p: &mut pp::Printer) {
1225        p.break_offset(0, self.offset);
1226    }
1227}
1228
1229fn snippet_with_tabs(s: String, tab_width: usize) -> String {
1230    // process leading breaks
1231    let trimmed = s.trim_start_matches('\n');
1232    let num_breaks = s.len() - trimmed.len();
1233    let mut formatted = std::iter::repeat_n('\n', num_breaks).collect::<String>();
1234
1235    // process lines
1236    for (pos, line) in trimmed.lines().delimited() {
1237        line_with_tabs(&mut formatted, line, tab_width, None);
1238        if !pos.is_last {
1239            formatted.push('\n');
1240        }
1241    }
1242
1243    formatted
1244}
1245
1246/// Formats a doc comment with the requested style.
1247///
1248/// NOTE: assumes comments have already been normalized.
1249fn style_doc_comment(style: DocCommentStyle, mut cmnt: Comment) -> Comment {
1250    match style {
1251        DocCommentStyle::Line if cmnt.kind == ast::CommentKind::Block => {
1252            let mut new_lines = Vec::new();
1253            for (pos, line) in cmnt.lines.iter().delimited() {
1254                if pos.is_first || pos.is_last {
1255                    // Skip the opening '/**' and closing '*/' lines
1256                    continue;
1257                }
1258
1259                // Convert ' * {content}' to '/// {content}'
1260                let trimmed = line.trim_start();
1261                if let Some(content) = trimmed.strip_prefix('*') {
1262                    new_lines.push(format!("///{content}"));
1263                } else if !trimmed.is_empty() {
1264                    new_lines.push(format!("/// {trimmed}"));
1265                }
1266            }
1267
1268            cmnt.lines = new_lines;
1269            cmnt.kind = ast::CommentKind::Line;
1270            cmnt
1271        }
1272        DocCommentStyle::Block if cmnt.kind == ast::CommentKind::Line => {
1273            let mut new_lines = vec!["/**".to_string()];
1274
1275            for line in &cmnt.lines {
1276                // Convert '/// {content}' to ' * {content}'
1277                new_lines.push(format!(" *{content}", content = &line[3..]))
1278            }
1279
1280            new_lines.push(" */".to_string());
1281            cmnt.lines = new_lines;
1282            cmnt.kind = ast::CommentKind::Block;
1283            cmnt
1284        }
1285        // Otherwise, no conversion needed.
1286        _ => cmnt,
1287    }
1288}