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, IntTypes};
12use solar::parse::{
13    ast::{self, Span},
14    interface::{BytePos, source_map::SourceFile},
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    file: &'sess SourceFile,
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        file: &'sess SourceFile,
212        config: Arc<FormatterConfig>,
213        inline_config: InlineConfig<()>,
214        comments: Comments,
215    ) -> Self {
216        Self {
217            s: pp::Printer::new(
218                config.line_length,
219                matches!(config.style, IndentStyle::Tab).then(|| config.tab_width),
220            ),
221            ind: config.tab_width as isize,
222            file,
223            comments,
224            config,
225            inline_config,
226            cursor: SourcePos { pos: file.start_pos, enabled: true },
227            has_crlf: false,
228            contract: None,
229            single_line_stmt: None,
230            call_with_opts_and_args: false,
231            chained_named_call: None,
232            skip_index_break: false,
233            binary_expr: None,
234            return_bin_expr: false,
235            emit_or_revert: false,
236            var_init: false,
237            block_depth: 0,
238            call_stack: CallStack::default(),
239        }
240    }
241
242    /// Checks a span of the source for a carriage return (`\r`) to determine if the file
243    /// uses CRLF line endings.
244    ///
245    /// If a `\r` is found, `self.has_crlf` is set to `true`. This is intended to be
246    /// called once at the beginning of the formatting process for efficiency.
247    fn check_crlf(&mut self, span: Span) {
248        if let Some(snip) = self.snippet(span)
249            && snip.contains('\r')
250        {
251            self.has_crlf = true;
252        }
253    }
254
255    /// Checks if the cursor is currently positioned at the start of a CRLF sequence (`\r\n`).
256    /// The check is only meaningful if `self.has_crlf` is true.
257    fn is_at_crlf(&self) -> bool {
258        self.has_crlf && self.char_at(self.cursor.pos) == Some('\r')
259    }
260
261    /// Advances the cursor past the line break assumed to be represented by a printed separator.
262    ///
263    /// While the cursor is disabled it marks the exact start of not-yet-printed source, so it may
264    /// only advance if it actually sits at a line break; otherwise the separator does not consume
265    /// any source (e.g. it was already printed verbatim by a disabled trailing comment).
266    fn cursor_next_line(&mut self) {
267        if self.cursor.enabled || matches!(self.char_at(self.cursor.pos), Some('\n' | '\r')) {
268            self.cursor.next_line(self.is_at_crlf());
269        }
270    }
271
272    /// Computes the space left, bounded by the max space left.
273    fn space_left(&self) -> usize {
274        std::cmp::min(self.s.space_left(), self.max_space_left(0))
275    }
276
277    /// Computes the maximum space left given the context information available:
278    /// `block_depth`, `tab_width`, and a user-defined unavailable size `prefix_len`.
279    fn max_space_left(&self, prefix_len: usize) -> usize {
280        self.config
281            .line_length
282            .saturating_sub(self.block_depth * self.config.tab_width + prefix_len)
283    }
284
285    fn break_offset_if_not_bol(&mut self, n: usize, off: isize, search: bool) {
286        // When searching, the break token is expected to be inside a closed box. Thus, we will
287        // traverse the buffer and evaluate the first non-end token.
288        if search {
289            // We do something pretty sketchy here: tuck the nonzero offset-adjustment we
290            // were going to deposit along with the break into the previous hardbreak.
291            self.find_and_replace_last_token_still_buffered(
292                pp::Printer::hardbreak_tok_offset(off),
293                |token| token.is_hardbreak(),
294            );
295            return;
296        }
297
298        // When not explicitly searching, the break token is expected to be the last token.
299        if !self.is_beginning_of_line() {
300            self.break_offset(n, off)
301        } else if off != 0
302            && let Some(last_token) = self.last_token_still_buffered()
303            && last_token.is_hardbreak()
304        {
305            // We do something pretty sketchy here: tuck the nonzero offset-adjustment we
306            // were going to deposit along with the break into the previous hardbreak.
307            self.replace_last_token_still_buffered(pp::Printer::hardbreak_tok_offset(off));
308        }
309    }
310
311    fn braces_break(&mut self) {
312        if self.config.bracket_spacing {
313            self.space();
314        } else {
315            self.zerobreak();
316        }
317    }
318}
319
320/// Span to source.
321impl<'sess> State<'sess, '_> {
322    fn snippet(&self, span: Span) -> Option<&'sess str> {
323        let start = span.lo().0.checked_sub(self.file.start_pos.0)? as usize;
324        let end = span.hi().0.checked_sub(self.file.start_pos.0)? as usize;
325        self.file.src.get(start..end)
326    }
327
328    fn source_line(&self, pos: BytePos) -> usize {
329        self.file.lookup_line(self.file.relative_position(pos)).map_or(0, |line| line + 1)
330    }
331
332    fn char_at(&self, pos: BytePos) -> Option<char> {
333        let offset = pos.0.checked_sub(self.file.start_pos.0)? as usize;
334        self.file.src.get(offset..)?.chars().next()
335    }
336
337    /// Returns the position of the first `{` within the span, ignoring the ones inside comments.
338    fn find_opening_brace(&self, span: Span) -> Option<BytePos> {
339        self.find_uncommented_char(span, '{')
340    }
341
342    /// Returns the position of the first matching character within the span, ignoring comments.
343    fn find_uncommented_char(&self, span: Span, needle: char) -> Option<BytePos> {
344        let snip = self.snippet(span)?;
345        let mut idx = 0;
346        while idx < snip.len() {
347            let rest = &snip[idx..];
348            if rest.starts_with(needle) {
349                return Some(span.lo() + idx as u32);
350            }
351            idx += if let Some(line) = rest.strip_prefix("//") {
352                2 + line.find('\n').unwrap_or(line.len())
353            } else if let Some(block) = rest.strip_prefix("/*") {
354                2 + block.find("*/").map_or(block.len(), |end| end + 2)
355            } else {
356                rest.chars().next().map_or(1, char::len_utf8)
357            };
358        }
359        None
360    }
361
362    fn print_span(&mut self, span: Span) {
363        match self.snippet(span) {
364            Some(s) => self.s.word(if matches!(self.config.style, IndentStyle::Tab) {
365                snippet_with_tabs(s.to_owned(), self.config.tab_width)
366            } else {
367                s.to_owned()
368            }),
369            None => panic!("failed to print {span:?}: invalid source span"),
370        }
371        // Drop comments that are included in the span.
372        while let Some(cmnt) = self.peek_comment() {
373            if cmnt.pos() >= span.hi() {
374                break;
375            }
376            let _ = self.next_comment().unwrap();
377        }
378        // Update cursor
379        self.cursor.advance_to(span.hi(), false);
380    }
381
382    /// Returns `true` if the span is disabled and has been printed as-is.
383    #[must_use]
384    fn handle_span(&mut self, span: Span, skip_prev_cmnts: bool) -> bool {
385        if !skip_prev_cmnts {
386            self.print_comments(span.lo(), CommentConfig::default());
387        }
388        self.print_span_if_disabled(span)
389    }
390
391    /// Returns `true` if the span is disabled and has been printed as-is.
392    #[inline]
393    #[must_use]
394    fn print_span_if_disabled(&mut self, span: Span) -> bool {
395        let cursor_span = self.cursor.span(span.hi());
396        if self.inline_config.is_disabled(cursor_span) {
397            self.print_span_cold(cursor_span);
398            return true;
399        }
400        if self.inline_config.is_disabled(span) {
401            self.print_span_cold(span);
402            return true;
403        }
404        false
405    }
406
407    #[cold]
408    fn print_span_cold(&mut self, span: Span) {
409        self.print_span(span);
410    }
411
412    fn print_tokens(&mut self, tokens: &[token::Token]) {
413        // Leave unchanged.
414        let span = Span::join_first_last(tokens.iter().map(|t| t.span));
415        self.print_span(span);
416    }
417
418    fn print_word(&mut self, w: impl Into<Cow<'static, str>>) {
419        let cow = w.into();
420        self.cursor.advance(cow.len() as u32);
421        self.word(cow);
422    }
423
424    fn print_sep(&mut self, sep: Separator) {
425        if self.handle_span(
426            self.cursor.span(self.cursor.pos + if self.is_at_crlf() { 2 } else { 1 }),
427            true,
428        ) {
429            return;
430        }
431
432        self.print_sep_unhandled(sep);
433    }
434
435    fn print_sep_unhandled(&mut self, sep: Separator) {
436        sep.print(&mut self.s);
437        self.cursor_next_line();
438    }
439
440    fn print_ident(&mut self, ident: &ast::Ident) {
441        if self.handle_span(ident.span, true) {
442            return;
443        }
444
445        self.print_comments(ident.span.lo(), CommentConfig::skip_ws());
446        self.word(ident.to_string());
447    }
448
449    fn print_inside_parens<F>(&mut self, f: F)
450    where
451        F: FnOnce(&mut Self),
452    {
453        self.print_word("(");
454        f(self);
455        self.print_word(")");
456    }
457
458    /// Account for brace spacing, ignoring literal and comment text.
459    fn brace_spacing_delta(
460        &self,
461        line: &str,
462        in_block_comment: &mut bool,
463        quote: &mut Option<u8>,
464    ) -> isize {
465        let bytes = line.as_bytes();
466        let mut delta = 0;
467        let mut i = 0;
468        while i < bytes.len() {
469            if let Some(delimiter) = *quote {
470                match bytes[i] {
471                    b'\\' => i = (i + 2).min(bytes.len()),
472                    c if c == delimiter => {
473                        *quote = None;
474                        i += 1;
475                    }
476                    _ => i += 1,
477                }
478                continue;
479            }
480            if *in_block_comment {
481                if bytes.get(i..i + 2) == Some(b"*/") {
482                    *in_block_comment = false;
483                    i += 2;
484                } else {
485                    i += 1;
486                }
487                continue;
488            }
489            match bytes[i] {
490                b'/' if bytes.get(i + 1) == Some(&b'/') => break,
491                b'/' if bytes.get(i + 1) == Some(&b'*') => {
492                    *in_block_comment = true;
493                    i += 2;
494                }
495                delimiter @ (b'\'' | b'"') => {
496                    *quote = Some(delimiter);
497                    i += 1;
498                }
499                b'{' | b'}' => {
500                    let inner = if bytes[i] == b'{' {
501                        bytes.get(i + 1)
502                    } else {
503                        i.checked_sub(1).and_then(|j| bytes.get(j))
504                    };
505                    if let Some(&inner) = inner
506                        && !matches!(inner, b'{' | b'}')
507                    {
508                        delta += match (matches!(inner, b' ' | b'\t'), self.config.bracket_spacing)
509                        {
510                            (true, false) => -1,
511                            (false, true) => 1,
512                            _ => 0,
513                        };
514                    }
515                    i += 1;
516                }
517                _ => i += 1,
518            }
519        }
520        delta
521    }
522
523    /// Measure integer type spelling from parsed types so identifiers and literal text do not
524    /// affect the estimate.
525    fn int_type_delta(&self, ty: &ast::Type<'_>) -> isize {
526        match &ty.kind {
527            ast::TypeKind::Elementary(
528                ast::ElementaryType::Int(size) | ast::ElementaryType::UInt(size),
529            ) => match (self.config.int_types, size.bits_raw()) {
530                (IntTypes::Long, 0) => 3,
531                (IntTypes::Short, 256) => -3,
532                _ => 0,
533            },
534            ast::TypeKind::Array(array) => self.int_type_delta(&array.element),
535            ast::TypeKind::Mapping(mapping) => {
536                self.int_type_delta(&mapping.key) + self.int_type_delta(&mapping.value)
537            }
538            ast::TypeKind::Function(function) => {
539                let params: isize = function
540                    .parameters
541                    .vars
542                    .iter()
543                    .map(|param| self.int_type_delta(&param.ty))
544                    .sum();
545                let returns: isize = function.returns.as_ref().map_or(0, |returns| {
546                    returns.vars.iter().map(|ret| self.int_type_delta(&ret.ty)).sum()
547                });
548                params + returns
549            }
550            _ => 0,
551        }
552    }
553
554    fn estimate_type_size(&self, ty: &ast::Type<'_>) -> usize {
555        self.estimate_size(ty.span).saturating_add_signed(self.int_type_delta(ty))
556    }
557
558    fn estimate_size(&self, span: Span) -> usize {
559        self.estimate_size_inner(span, false)
560    }
561
562    /// Call argument layout needs printed delimiter widths. Keep other layout decisions on their
563    /// existing estimate so already stable source does not change formatting.
564    fn estimate_call_args_size(&self, span: Span) -> usize {
565        self.estimate_size_inner(span, true)
566    }
567
568    /// Parentheses and index brackets have no inner padding in the printed assignment.
569    /// Keep comment-bearing expressions on their existing estimate.
570    fn estimate_assignment_size(&self, span: Span) -> usize {
571        self.estimate_size_inner(span, !self.has_comment_between(span.lo(), span.hi()))
572    }
573
574    fn estimate_size_inner(&self, span: Span, normalize_delimiters: bool) -> usize {
575        if let Some(snip) = self.snippet(span) {
576            let (mut size, mut first, mut prev_needs_space) = (0, true, false);
577            let (mut in_block_comment, mut quote) = (false, None);
578
579            for line in snip.lines() {
580                let line = line.trim();
581
582                if prev_needs_space {
583                    size += 1;
584                } else if !first && let Some(char) = line.chars().next() {
585                    // A line break or a space are required if this line:
586                    // - starts with an operator.
587                    // - starts with one of the ternary operators
588                    // - starts with a bracket and fmt config forces bracket spacing.
589                    match char {
590                        '&' | '|' | '=' | '>' | '<' | '+' | '-' | '*' | '/' | '%' | '^' | '?'
591                        | ':' => size += 1,
592                        ')' | ']'
593                            if self.config.bracket_spacing
594                                && (!normalize_delimiters || quote.is_some()) =>
595                        {
596                            size += 1
597                        }
598                        '}' if self.config.bracket_spacing => size += 1,
599                        _ => (),
600                    }
601                }
602                first = false;
603
604                // trim spaces before and after mixed comments
605                let mut search = line;
606                loop {
607                    if let Some((lhs, comment)) = search.split_once(r#"/*"#) {
608                        size += lhs.trim_end().len() + 2;
609                        search = comment;
610                    } else if let Some((comment, rhs)) = search.split_once(r#"*/"#) {
611                        size += comment.len() + 2;
612                        search = rhs;
613                    } else {
614                        size += search.trim().len();
615                        break;
616                    }
617                }
618                let brace_delta = self.brace_spacing_delta(line, &mut in_block_comment, &mut quote);
619                size = size.saturating_add_signed(brace_delta);
620
621                // Next line requires a line break if this one:
622                // - ends with a bracket and fmt config forces bracket spacing.
623                // - ends with ',' a line break or a space are required.
624                // - ends with ';' a line break is required.
625                // - ends with an operator, mirroring lines that start with one.
626                prev_needs_space = match line.chars().next_back() {
627                    Some('{') => self.config.bracket_spacing,
628                    Some('[' | '(') => {
629                        self.config.bracket_spacing && (!normalize_delimiters || quote.is_some())
630                    }
631                    Some(',' | ';') => true,
632                    Some(
633                        '&' | '|' | '=' | '>' | '<' | '+' | '-' | '*' | '/' | '%' | '^' | '?' | ':',
634                    ) => !line.ends_with("*/"),
635                    _ => false,
636                };
637            }
638            return size;
639        }
640
641        span.to_range().len()
642    }
643
644    fn same_source_line(&self, a: BytePos, b: BytePos) -> bool {
645        self.source_line(a) == self.source_line(b)
646    }
647}
648
649/// Comment-related methods.
650impl<'sess> State<'sess, '_> {
651    /// Returns `None` if the span is disabled and has been printed as-is.
652    #[must_use]
653    fn handle_comment(&mut self, cmnt: Comment, skip_break: bool) -> Option<Comment> {
654        if self.cursor.enabled {
655            if self.inline_config.is_disabled(cmnt.span) {
656                // The comment is copied verbatim below, which appends it to whatever was printed
657                // last. An isolated comment had a line of its own in the source, so give it that
658                // line back rather than gluing it to the previous statement.
659                if cmnt.style.is_isolated() {
660                    self.hardbreak_if_not_bol();
661                } else if cmnt.style.is_trailing() && !self.last_token_is_space() {
662                    self.nbsp();
663                }
664                self.print_span_cold(cmnt.span);
665                if !skip_break && (cmnt.style.is_isolated() || cmnt.style.is_trailing()) {
666                    self.print_sep(Separator::Hardbreak);
667                }
668                return None;
669            }
670        } else if self.print_span_if_disabled(cmnt.span) {
671            if !skip_break && (cmnt.style.is_isolated() || cmnt.style.is_trailing()) {
672                self.print_sep(Separator::Hardbreak);
673            }
674            return None;
675        }
676        Some(cmnt)
677    }
678
679    fn cmnt_config(&self) -> CommentConfig {
680        Default::default()
681    }
682
683    const fn print_docs(&mut self, docs: &'_ ast::DocComments<'_>) {
684        // Intentionally no-op. Handled with `self.comments`.
685        let _ = docs;
686    }
687
688    /// Prints comments that are before the given position.
689    ///
690    /// Returns `Some` with the style of the last comment printed, or `None` if no comment was
691    /// printed.
692    fn print_comments(&mut self, pos: BytePos, mut config: CommentConfig) -> Option<CommentStyle> {
693        let mut last_style: Option<CommentStyle> = None;
694        let mut is_leading = true;
695        let config_cache = config;
696        let mut buffered_blank = None;
697        let mut previous_mixed_at_bol = false;
698        while self.peek_comment().is_some_and(|c| c.pos() < pos) {
699            let mut cmnt = self.next_comment().unwrap();
700            let style_cache = cmnt.style;
701
702            // Merge consecutive line doc comments when converting to block style
703            if self.config.docs_style == foundry_config::fmt::DocCommentStyle::Block
704                && cmnt.is_doc
705                && cmnt.kind == ast::CommentKind::Line
706            {
707                let mut ref_line = self.source_line(cmnt.span.hi());
708                while let Some(next_cmnt) = self.peek_comment() {
709                    if !next_cmnt.is_doc
710                        || next_cmnt.kind != ast::CommentKind::Line
711                        || ref_line + 1 != self.source_line(next_cmnt.span.lo())
712                    {
713                        break;
714                    }
715
716                    let next_to_merge = self.next_comment().unwrap();
717                    cmnt.lines.extend(next_to_merge.lines);
718                    cmnt.span = cmnt.span.to(next_to_merge.span);
719                    ref_line += 1;
720                }
721            }
722
723            // Ensure breaks are never skipped when there are multiple comments
724            if self.peek_comment_before(pos).is_some() {
725                config.iso_no_break = false;
726                config.trailing_no_break = false;
727            }
728
729            // Handle disabled comments
730            let Some(mut cmnt) = self.handle_comment(
731                cmnt,
732                if style_cache.is_isolated() {
733                    config.iso_no_break
734                } else {
735                    config.trailing_no_break
736                },
737            ) else {
738                last_style = Some(style_cache);
739                continue;
740            };
741
742            if cmnt.style.is_blank() {
743                match config.skip_blanks {
744                    Some(Skip::All) => continue,
745                    Some(Skip::Leading { resettable: true }) if is_leading => continue,
746                    Some(Skip::Leading { resettable: false }) if last_style.is_none() => continue,
747                    Some(Skip::Trailing) => {
748                        buffered_blank = Some(cmnt);
749                        continue;
750                    }
751                    _ => (),
752                }
753            // Never print blank lines after docs comments
754            } else if !cmnt.is_doc {
755                is_leading = false;
756            }
757
758            if let Some(blank) = buffered_blank.take() {
759                self.print_comment(blank, config);
760            }
761
762            if previous_mixed_at_bol
763                && cmnt.style.is_trailing()
764                && matches!(cmnt.kind, ast::CommentKind::Line)
765            {
766                self.hardbreak_if_not_bol();
767            }
768
769            // A trailing comment moved onto its own line is isolated on the next run.
770            // Use that style now so following comments receive the same indentation.
771            if cmnt.style.is_trailing() && self.is_bol_or_only_ind() {
772                cmnt.style = CommentStyle::Isolated;
773            }
774
775            // Handle mixed with follow-up comment
776            if cmnt.style.is_mixed() {
777                if let Some(cmnt) = self.peek_comment_before(pos) {
778                    config.mixed_no_break_prev = true;
779                    config.mixed_no_break_post = true;
780                    config.mixed_post_nbsp = false;
781                    // The separator within a run of mixed comments must never break, even with
782                    // `wrap_comments`: a breakable space inside a broken consistent box always
783                    // breaks, which splits the run and reclassifies its comments on the next run.
784                    if cmnt.style.is_mixed() {
785                        config = config.mixed_post_glued();
786                    }
787                }
788
789                // Ensure consecutive mixed comments don't have a double-space
790                if last_style.is_some_and(|s| s.is_mixed()) {
791                    config.mixed_no_break_prev = true;
792                    config.mixed_no_break_post = true;
793                    config.mixed_prev_space = false;
794                }
795            } else if config.offset != 0
796                && cmnt.style.is_isolated()
797                && last_style.is_some_and(|s| s.is_isolated())
798            {
799                self.offset(config.offset);
800            }
801
802            last_style = Some(cmnt.style);
803            previous_mixed_at_bol = cmnt.style.is_mixed() && self.is_bol_or_only_ind();
804            self.print_comment(cmnt, config);
805            config = config_cache;
806        }
807        last_style
808    }
809
810    /// Prints a line, wrapping it if it starts with the given prefix.
811    fn print_wrapped_line(
812        &mut self,
813        line: &str,
814        prefix: &'static str,
815        break_offset: isize,
816        is_doc: bool,
817    ) {
818        if !line.starts_with(prefix) {
819            self.word(line.to_owned());
820            return;
821        }
822
823        fn post_break_prefix(prefix: &'static str, has_content: bool) -> &'static str {
824            if !has_content {
825                return prefix;
826            }
827            match prefix {
828                "///" => "/// ",
829                "//" => "// ",
830                "/*" => "/* ",
831                " *" => " * ",
832                _ => prefix,
833            }
834        }
835
836        self.ibox(0);
837        self.word(prefix);
838
839        let content = &line[prefix.len()..];
840        let content = if is_doc {
841            // Doc comments preserve leading whitespaces (right after the prefix) as nbps.
842            let ws_len = content
843                .char_indices()
844                .take_while(|(_, c)| c.is_whitespace())
845                .last()
846                .map_or(0, |(idx, c)| idx + c.len_utf8());
847            let (leading_ws, rest) = content.split_at(ws_len);
848            if !leading_ws.is_empty() {
849                self.word(leading_ws.to_owned());
850            }
851            rest
852        } else {
853            // Non-doc comments: replace first whitespace with nbsp, rest of content continues
854            if let Some(first_char) = content.chars().next() {
855                if first_char.is_whitespace() {
856                    self.nbsp();
857                    &content[first_char.len_utf8()..]
858                } else {
859                    content
860                }
861            } else {
862                ""
863            }
864        };
865
866        let post_break = post_break_prefix(prefix, !content.is_empty());
867
868        // Process content character by character to preserve consecutive whitespaces
869        let (mut chars, mut current_word) = (content.chars().peekable(), String::new());
870        while let Some(ch) = chars.next() {
871            if ch.is_whitespace() {
872                // Print current word
873                if !current_word.is_empty() {
874                    self.word(std::mem::take(&mut current_word));
875                }
876
877                // Preserve multiple spaces while adding a single break
878                let mut ws_count = 1;
879                while chars.peek().is_some_and(|c| c.is_whitespace()) {
880                    ws_count += 1;
881                    chars.next();
882                }
883                self.s.scan_break(BreakToken {
884                    offset: break_offset,
885                    blank_space: ws_count,
886                    post_break: if post_break.starts_with("/*") { None } else { Some(post_break) },
887                    ..Default::default()
888                });
889                continue;
890            }
891
892            current_word.push(ch);
893        }
894
895        // Print final word
896        if !current_word.is_empty() {
897            self.word(current_word);
898        }
899
900        self.end();
901    }
902
903    /// Merges consecutive line comments to avoid orphan words.
904    fn merge_comment_lines(&self, lines: &[String], prefix: &str) -> Vec<String> {
905        // Do not apply smart merging to block comments
906        if lines.is_empty() || lines.len() < 2 || !prefix.starts_with("//") {
907            return lines.to_vec();
908        }
909
910        let mut result = Vec::new();
911        let mut i = 0;
912
913        while i < lines.len() {
914            let current_line = &lines[i];
915
916            // Keep empty lines, and non-prefixed lines, untouched
917            if current_line.trim().is_empty() || !current_line.starts_with(prefix) {
918                result.push(current_line.clone());
919                i += 1;
920                continue;
921            }
922
923            if i + 1 < lines.len() {
924                let next_line = &lines[i + 1];
925
926                // Check if next line has the same prefix and is not empty.
927                if next_line.starts_with(prefix) && !next_line.trim().is_empty() {
928                    let next_content = next_line[prefix.len()..].trim_start();
929
930                    // Keep each NatSpec tag on its own doc-comment line. Merging a wrapped
931                    // `@dev`/`@param` line with the following tag changes the tag boundary.
932                    if next_content.starts_with('@') {
933                        result.push(current_line.clone());
934                        i += 1;
935                        continue;
936                    }
937
938                    // Only merge if the current line doesn't fit within available width
939                    if estimate_line_width(current_line, self.config.tab_width) > self.space_left()
940                    {
941                        // Merge the lines and let the wrapper handle breaking if needed
942                        let merged_line = format!("{current_line} {next_content}");
943                        result.push(merged_line);
944
945                        // Skip both lines since they are merged
946                        i += 2;
947                        continue;
948                    }
949                }
950            }
951
952            // No merge possible, keep the line as-is
953            result.push(current_line.clone());
954            i += 1;
955        }
956
957        result
958    }
959
960    fn print_comment(&mut self, mut cmnt: Comment, mut config: CommentConfig) {
961        self.cursor.advance_to(cmnt.span.hi(), true);
962
963        if cmnt.is_doc {
964            cmnt = style_doc_comment(self.config.docs_style, cmnt);
965        }
966
967        match cmnt.style {
968            CommentStyle::Mixed => {
969                let Some(prefix) = cmnt.prefix() else { return };
970                let never_break = self.last_token_is_neverbreak();
971                let starts_line = self.is_bol_or_only_ind() || self.last_token_is_break();
972                if !self.is_bol_or_only_ind() {
973                    match (never_break || config.mixed_no_break_prev, config.mixed_prev_space) {
974                        (false, true) => config.space(&mut self.s),
975                        (false, false) => config.zerobreak(&mut self.s),
976                        (true, true) => self.nbsp(),
977                        (true, false) => (),
978                    };
979                }
980                if self.config.wrap_comments {
981                    // Merge and wrap comments
982                    if starts_line {
983                        self.ibox(config.offset);
984                    }
985                    let merged_lines = self.merge_comment_lines(&cmnt.lines, prefix);
986                    for (pos, line) in merged_lines.into_iter().delimited() {
987                        self.print_wrapped_line(&line, prefix, 0, cmnt.is_doc);
988                        if !pos.is_last {
989                            self.hardbreak();
990                        }
991                    }
992                    if starts_line {
993                        self.end();
994                    }
995                } else {
996                    // Match the opening-column normalization of continuation lines.
997                    self.visual_align();
998                    for (pos, line) in cmnt.lines.into_iter().delimited() {
999                        if !line.is_empty() {
1000                            self.word(line);
1001                        }
1002                        if !pos.is_last {
1003                            self.hardbreak();
1004                        }
1005                    }
1006                    self.end();
1007                }
1008                if config.mixed_post_nbsp {
1009                    if config.mixed_post_glued {
1010                        self.nbsp();
1011                    } else {
1012                        config.nbsp_or_space(self.config.wrap_comments, &mut self.s);
1013                    }
1014                    self.cursor.advance(1);
1015                } else if !config.mixed_no_break_post {
1016                    config.space(&mut self.s);
1017                    self.cursor.advance(1);
1018                }
1019            }
1020            CommentStyle::Isolated => {
1021                let Some(mut prefix) = cmnt.prefix() else { return };
1022                if !config.iso_no_break {
1023                    config.hardbreak_if_not_bol(self.is_bol_or_only_ind(), &mut self.s);
1024                }
1025
1026                if self.config.wrap_comments {
1027                    // Merge and wrap comments
1028                    let merged_lines = self.merge_comment_lines(&cmnt.lines, prefix);
1029                    for (pos, line) in merged_lines.into_iter().delimited() {
1030                        let hb = |this: &mut Self| {
1031                            this.hardbreak();
1032                            if pos.is_last {
1033                                this.cursor.next_line(this.is_at_crlf());
1034                            }
1035                        };
1036                        if line.is_empty() {
1037                            hb(self);
1038                            continue;
1039                        }
1040                        if pos.is_first {
1041                            self.ibox(config.offset);
1042                            if cmnt.is_doc && matches!(prefix, "/**") {
1043                                self.word(prefix);
1044                                hb(self);
1045                                prefix = " * ";
1046                                continue;
1047                            }
1048                        }
1049
1050                        self.print_wrapped_line(&line, prefix, 0, cmnt.is_doc);
1051
1052                        if pos.is_last {
1053                            self.end();
1054                            if !config.iso_no_break {
1055                                hb(self);
1056                            }
1057                        } else {
1058                            hb(self);
1059                        }
1060                    }
1061                } else {
1062                    // No wrapping, print as-is
1063                    for (pos, line) in cmnt.lines.into_iter().delimited() {
1064                        let hb = |this: &mut Self| {
1065                            this.hardbreak();
1066                            if pos.is_last {
1067                                this.cursor.next_line(this.is_at_crlf());
1068                            }
1069                        };
1070                        if line.is_empty() {
1071                            hb(self);
1072                            continue;
1073                        }
1074                        if pos.is_first {
1075                            self.ibox(config.offset);
1076                            if cmnt.is_doc && matches!(prefix, "/**") {
1077                                self.word(prefix);
1078                                hb(self);
1079                                prefix = " * ";
1080                                continue;
1081                            }
1082                        }
1083
1084                        self.word(line);
1085
1086                        if pos.is_last {
1087                            self.end();
1088                            if !config.iso_no_break {
1089                                hb(self);
1090                            }
1091                        } else {
1092                            hb(self);
1093                        }
1094                    }
1095                }
1096            }
1097            CommentStyle::Trailing => {
1098                let Some(prefix) = cmnt.prefix() else { return };
1099                self.neverbreak();
1100                if !self.is_bol_or_only_ind() {
1101                    self.nbsp();
1102                }
1103
1104                if !self.config.wrap_comments && cmnt.lines.len() == 1 {
1105                    self.word(cmnt.lines.pop().unwrap());
1106                } else if self.config.wrap_comments {
1107                    if cmnt.is_doc || matches!(cmnt.kind, ast::CommentKind::Line) {
1108                        config.offset = 0;
1109                    } else {
1110                        config.offset = self.ind;
1111                    }
1112                    for (lpos, line) in cmnt.lines.into_iter().delimited() {
1113                        if !line.is_empty() {
1114                            self.print_wrapped_line(&line, prefix, config.offset, cmnt.is_doc);
1115                        }
1116                        if !lpos.is_last {
1117                            config.hardbreak(&mut self.s);
1118                        }
1119                    }
1120                } else {
1121                    self.visual_align();
1122                    for (pos, line) in cmnt.lines.into_iter().delimited() {
1123                        if !line.is_empty() {
1124                            self.word(line);
1125                            if !pos.is_last {
1126                                self.hardbreak();
1127                            }
1128                        }
1129                    }
1130                    self.end();
1131                }
1132
1133                if !config.trailing_no_break {
1134                    self.print_sep(Separator::Hardbreak);
1135                }
1136            }
1137
1138            CommentStyle::BlankLine => {
1139                // Pre-requisite: ensure that blank links are printed at the beginning of new line.
1140                if !self.last_token_is_break() && !self.is_bol_or_only_ind() {
1141                    config.hardbreak(&mut self.s);
1142                    self.cursor.next_line(self.is_at_crlf());
1143                }
1144
1145                // We need to do at least one, possibly two hardbreaks.
1146                let twice = match self.last_token() {
1147                    Some(Token::String(s)) => ";" == s,
1148                    Some(Token::Begin(_)) => true,
1149                    Some(Token::End) => true,
1150                    _ => false,
1151                };
1152                if twice {
1153                    config.hardbreak(&mut self.s);
1154                    self.cursor.next_line(self.is_at_crlf());
1155                }
1156                config.hardbreak(&mut self.s);
1157                self.cursor.next_line(self.is_at_crlf());
1158            }
1159        }
1160    }
1161
1162    fn peek_comment<'b>(&'b self) -> Option<&'b Comment>
1163    where
1164        'sess: 'b,
1165    {
1166        self.comments.peek()
1167    }
1168
1169    fn peek_comment_before<'b>(&'b self, pos: BytePos) -> Option<&'b Comment>
1170    where
1171        'sess: 'b,
1172    {
1173        self.comments.iter().take_while(|c| c.pos() < pos).find(|c| !c.style.is_blank())
1174    }
1175
1176    /// Returns `true` if the next comment is a mixed comment that starts before the given
1177    /// position.
1178    fn peek_mixed_comment_before(&self, pos: Option<BytePos>) -> bool {
1179        pos.is_some_and(|pos| {
1180            self.peek_comment().is_some_and(|cmnt| cmnt.pos() < pos && cmnt.style.is_mixed())
1181        })
1182    }
1183
1184    fn has_comment_before_with<F>(&self, pos: BytePos, f: F) -> bool
1185    where
1186        F: FnMut(&Comment) -> bool,
1187    {
1188        self.comments.iter().take_while(|c| c.pos() < pos).any(f)
1189    }
1190
1191    fn peek_comment_between<'b>(&'b self, pos_lo: BytePos, pos_hi: BytePos) -> Option<&'b Comment>
1192    where
1193        'sess: 'b,
1194    {
1195        self.comments
1196            .iter()
1197            .skip_while(|c| c.pos() < pos_lo)
1198            .take_while(|c| c.pos() < pos_hi)
1199            .find(|c| !c.style.is_blank())
1200    }
1201
1202    fn has_comment_between(&self, start_pos: BytePos, end_pos: BytePos) -> bool {
1203        self.comments.iter().filter(|c| c.pos() > start_pos && c.pos() < end_pos).any(|_| true)
1204    }
1205
1206    fn has_breakable_comment_between(&self, start_pos: BytePos, end_pos: BytePos) -> bool {
1207        self.comments
1208            .iter()
1209            .filter(|comment| comment.pos() >= start_pos && comment.pos() < end_pos)
1210            .any(|comment| !comment.style.is_blank())
1211    }
1212
1213    pub(crate) fn next_comment(&mut self) -> Option<Comment> {
1214        self.comments.next()
1215    }
1216
1217    fn peek_trailing_comment<'b>(
1218        &'b self,
1219        span_pos: BytePos,
1220        next_pos: Option<BytePos>,
1221    ) -> Option<&'b Comment>
1222    where
1223        'sess: 'b,
1224    {
1225        self.comments.peek_trailing_in_file(self.file, span_pos, next_pos).map(|(cmnt, _)| cmnt)
1226    }
1227
1228    fn print_trailing_comment_inner(
1229        &mut self,
1230        span_pos: BytePos,
1231        next_pos: Option<BytePos>,
1232        config: Option<CommentConfig>,
1233    ) -> bool {
1234        let mut printed = 0;
1235        if let Some((_, n)) = self.comments.peek_trailing_in_file(self.file, span_pos, next_pos) {
1236            let config =
1237                config.unwrap_or(CommentConfig::skip_ws().mixed_no_break().mixed_prev_space());
1238            while printed <= n {
1239                let cmnt = self.comments.next().unwrap();
1240                if let Some(cmnt) = self.handle_comment(cmnt, config.trailing_no_break) {
1241                    self.print_comment(cmnt, config);
1242                };
1243                printed += 1;
1244            }
1245        }
1246        printed != 0
1247    }
1248
1249    fn print_trailing_comment(&mut self, span_pos: BytePos, next_pos: Option<BytePos>) -> bool {
1250        self.print_trailing_comment_inner(span_pos, next_pos, None)
1251    }
1252
1253    fn print_trailing_comment_no_break(&mut self, span_pos: BytePos, next_pos: Option<BytePos>) {
1254        self.print_trailing_comment_inner(
1255            span_pos,
1256            next_pos,
1257            Some(CommentConfig::skip_ws().trailing_no_break().mixed_no_break().mixed_prev_space()),
1258        );
1259    }
1260
1261    fn print_remaining_comments(&mut self, skip_leading_ws: bool) {
1262        // If there aren't any remaining comments, then we need to manually
1263        // make sure there is a line break at the end.
1264        if self.peek_comment().is_none() && !self.is_bol_or_only_ind() {
1265            self.hardbreak();
1266            return;
1267        }
1268
1269        let mut is_leading = true;
1270        while let Some(cmnt) = self.next_comment() {
1271            if cmnt.style.is_blank() && skip_leading_ws && is_leading {
1272                continue;
1273            }
1274
1275            is_leading = false;
1276            if let Some(cmnt) = self.handle_comment(cmnt, false) {
1277                self.print_comment(cmnt, CommentConfig::default());
1278            } else if self.peek_comment().is_none() && !self.is_bol_or_only_ind() {
1279                self.hardbreak();
1280            }
1281        }
1282    }
1283}
1284
1285#[derive(Clone, Copy)]
1286enum Skip {
1287    All,
1288    Leading { resettable: bool },
1289    Trailing,
1290}
1291
1292#[derive(Default, Clone, Copy)]
1293pub(crate) struct CommentConfig {
1294    // Config: all
1295    skip_blanks: Option<Skip>,
1296    offset: isize,
1297
1298    // Config: isolated comments
1299    iso_no_break: bool,
1300    // Config: trailing comments
1301    trailing_no_break: bool,
1302    // Config: mixed comments
1303    mixed_prev_space: bool,
1304    mixed_post_nbsp: bool,
1305    /// Makes `mixed_post_nbsp` emit a hard space even with `wrap_comments`, which would
1306    /// otherwise use a breakable one. Required when the comment is glued to a closing token, as
1307    /// a break in between detaches it and reclassifies the comment on the next run.
1308    mixed_post_glued: bool,
1309    mixed_no_break_prev: bool,
1310    mixed_no_break_post: bool,
1311}
1312
1313impl CommentConfig {
1314    pub(crate) fn skip_ws() -> Self {
1315        Self { skip_blanks: Some(Skip::All), ..Default::default() }
1316    }
1317
1318    /// Config for comments that are the sole content of an otherwise empty block, so that they
1319    /// are surrounded by spaces: `{ /* comment */ }`.
1320    pub(crate) fn empty_block() -> Self {
1321        Self::skip_ws().mixed_no_break().mixed_prev_space().mixed_post_glued()
1322    }
1323
1324    pub(crate) fn skip_leading_ws(resettable: bool) -> Self {
1325        Self { skip_blanks: Some(Skip::Leading { resettable }), ..Default::default() }
1326    }
1327
1328    pub(crate) fn skip_trailing_ws() -> Self {
1329        Self { skip_blanks: Some(Skip::Trailing), ..Default::default() }
1330    }
1331
1332    pub(crate) const fn offset(mut self, off: isize) -> Self {
1333        self.offset = off;
1334        self
1335    }
1336
1337    pub(crate) const fn no_breaks(mut self) -> Self {
1338        self.iso_no_break = true;
1339        self.trailing_no_break = true;
1340        self.mixed_no_break_prev = true;
1341        self.mixed_no_break_post = true;
1342        self
1343    }
1344
1345    pub(crate) const fn trailing_no_break(mut self) -> Self {
1346        self.trailing_no_break = true;
1347        self
1348    }
1349
1350    pub(crate) const fn mixed_no_break(mut self) -> Self {
1351        self.mixed_no_break_prev = true;
1352        self.mixed_no_break_post = true;
1353        self
1354    }
1355
1356    pub(crate) const fn mixed_no_break_post(mut self) -> Self {
1357        self.mixed_no_break_post = true;
1358        self
1359    }
1360
1361    pub(crate) const fn mixed_prev_space(mut self) -> Self {
1362        self.mixed_prev_space = true;
1363        self
1364    }
1365
1366    pub(crate) const fn mixed_post_nbsp(mut self) -> Self {
1367        self.mixed_post_nbsp = true;
1368        self
1369    }
1370
1371    pub(crate) const fn mixed_post_glued(mut self) -> Self {
1372        self.mixed_post_nbsp = true;
1373        self.mixed_post_glued = true;
1374        self
1375    }
1376
1377    pub(crate) fn hardbreak_if_not_bol(&self, is_bol: bool, p: &mut pp::Printer) {
1378        if self.offset != 0 && !is_bol {
1379            self.hardbreak(p);
1380        } else {
1381            p.hardbreak_if_not_bol();
1382        }
1383    }
1384
1385    pub(crate) fn hardbreak(&self, p: &mut pp::Printer) {
1386        p.break_offset(SIZE_INFINITY as usize, self.offset);
1387    }
1388
1389    pub(crate) fn space(&self, p: &mut pp::Printer) {
1390        p.break_offset(1, self.offset);
1391    }
1392
1393    pub(crate) fn nbsp_or_space(&self, breaks: bool, p: &mut pp::Printer) {
1394        if breaks {
1395            self.space(p);
1396        } else {
1397            p.nbsp();
1398        }
1399    }
1400
1401    pub(crate) fn zerobreak(&self, p: &mut pp::Printer) {
1402        p.break_offset(0, self.offset);
1403    }
1404}
1405
1406fn snippet_with_tabs(s: String, tab_width: usize) -> String {
1407    let mut formatted = String::with_capacity(s.len());
1408    for line in s.split_inclusive('\n') {
1409        let (line, has_newline) =
1410            line.strip_suffix('\n').map_or((line, false), |line| (line, true));
1411        let line = line.strip_suffix('\r').unwrap_or(line);
1412        line_with_tabs(&mut formatted, line, tab_width, None);
1413        if has_newline {
1414            formatted.push('\n');
1415        }
1416    }
1417
1418    formatted
1419}
1420
1421/// Formats a doc comment with the requested style.
1422///
1423/// NOTE: assumes comments have already been normalized.
1424fn style_doc_comment(style: DocCommentStyle, mut cmnt: Comment) -> Comment {
1425    match style {
1426        DocCommentStyle::Line if cmnt.kind == ast::CommentKind::Block => {
1427            let mut new_lines = Vec::new();
1428            for (pos, line) in cmnt.lines.iter().delimited() {
1429                if pos.is_first || pos.is_last {
1430                    // Skip the opening '/**' and closing '*/' lines
1431                    continue;
1432                }
1433
1434                // Convert ' * {content}' to '/// {content}'
1435                let trimmed = line.trim_start();
1436                if let Some(content) = trimmed.strip_prefix('*') {
1437                    new_lines.push(format!("///{content}"));
1438                } else if !trimmed.is_empty() {
1439                    new_lines.push(format!("/// {trimmed}"));
1440                }
1441            }
1442
1443            cmnt.lines = new_lines;
1444            cmnt.kind = ast::CommentKind::Line;
1445            cmnt
1446        }
1447        DocCommentStyle::Block if cmnt.kind == ast::CommentKind::Line => {
1448            let mut new_lines = vec!["/**".to_string()];
1449
1450            for line in &cmnt.lines {
1451                // Convert '/// {content}' to ' * {content}'
1452                new_lines.push(format!(" *{content}", content = &line[3..]))
1453            }
1454
1455            new_lines.push(" */".to_string());
1456            cmnt.lines = new_lines;
1457            cmnt.kind = ast::CommentKind::Block;
1458            cmnt
1459        }
1460        // Otherwise, no conversion needed.
1461        _ => cmnt,
1462    }
1463}