Skip to main content

foundry_common/comments/
mod.rs

1use crate::iter::IterDelimited;
2use solar::parse::{
3    ast::{CommentKind, Span},
4    interface::{BytePos, CharPos, SourceMap, source_map::SourceFile},
5    lexer::token::RawTokenKind as TokenKind,
6};
7use std::fmt;
8
9mod comment;
10pub use comment::{Comment, CommentStyle};
11
12pub mod inline_config;
13
14pub const DISABLE_START: &str = "forgefmt: disable-start";
15pub const DISABLE_END: &str = "forgefmt: disable-end";
16
17pub struct Comments {
18    comments: std::collections::VecDeque<Comment>,
19}
20
21impl fmt::Debug for Comments {
22    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
23        f.write_str("Comments")?;
24        f.debug_list().entries(self.iter()).finish()
25    }
26}
27
28impl Comments {
29    pub fn new(
30        sf: &SourceFile,
31        _sm: &SourceMap,
32        normalize_cmnts: bool,
33        group_cmnts: bool,
34        tab_width: Option<usize>,
35    ) -> Self {
36        let gatherer = CommentGatherer::new(sf, normalize_cmnts, tab_width).gather();
37
38        Self {
39            comments: if group_cmnts { gatherer.group().into() } else { gatherer.comments.into() },
40        }
41    }
42
43    pub fn peek(&self) -> Option<&Comment> {
44        self.comments.front()
45    }
46
47    #[allow(clippy::should_implement_trait)]
48    pub fn next(&mut self) -> Option<Comment> {
49        self.comments.pop_front()
50    }
51
52    pub fn iter(&self) -> impl Iterator<Item = &Comment> {
53        self.comments.iter()
54    }
55
56    /// Adds a new comment at the beginning of the list.
57    ///
58    /// Should only be used when comments are gathered scattered, and must be manually sorted.
59    ///
60    /// **WARNING:** This struct works under the assumption that comments are always sorted by
61    /// ascending span position. It is the caller's responsibility to ensure that this premise
62    /// always holds true.
63    pub fn push_front(&mut self, cmnt: Comment) {
64        self.comments.push_front(cmnt)
65    }
66
67    /// Finds the first trailing comment on the same line as `span_pos`, allowing for `Mixed`
68    /// style comments to appear before it.
69    ///
70    /// Returns the comment and its index in the buffer.
71    pub fn peek_trailing(
72        &self,
73        sm: &SourceMap,
74        span_pos: BytePos,
75        next_pos: Option<BytePos>,
76    ) -> Option<(&Comment, usize)> {
77        self.peek_trailing_with(span_pos, next_pos, |pos| {
78            sm.lookup_line(pos).ok().map(|line| line.line)
79        })
80    }
81
82    /// Finds a trailing comment as in [`Self::peek_trailing`], using a known source file.
83    ///
84    /// All comment positions and `span_pos` must belong to `file`.
85    pub fn peek_trailing_in_file(
86        &self,
87        file: &SourceFile,
88        span_pos: BytePos,
89        next_pos: Option<BytePos>,
90    ) -> Option<(&Comment, usize)> {
91        self.peek_trailing_with(span_pos, next_pos, |pos| {
92            file.lookup_line(file.relative_position(pos))
93        })
94    }
95
96    fn peek_trailing_with(
97        &self,
98        span_pos: BytePos,
99        next_pos: Option<BytePos>,
100        line_at: impl Fn(BytePos) -> Option<usize>,
101    ) -> Option<(&Comment, usize)> {
102        self.comments.front()?;
103        let span_line = line_at(span_pos);
104        for (i, cmnt) in self.iter().enumerate() {
105            // If we have moved to the next line, we can stop.
106            let comment_line = line_at(cmnt.pos());
107            if comment_line != span_line {
108                break;
109            }
110
111            // The comment must start after the given span position.
112            if cmnt.pos() < span_pos {
113                continue;
114            }
115
116            // The comment must be before the next element.
117            if cmnt.pos() >= next_pos.unwrap_or_else(|| cmnt.pos() + BytePos(1)) {
118                break;
119            }
120
121            // Stop when we find a trailing or a non-mixed comment
122            match cmnt.style {
123                CommentStyle::Mixed => {}
124                CommentStyle::Trailing => return Some((cmnt, i)),
125                _ => break,
126            }
127        }
128        None
129    }
130}
131
132struct CommentGatherer<'ast> {
133    sf: &'ast SourceFile,
134    text: &'ast str,
135    start_bpos: BytePos,
136    pos: usize,
137    comments: Vec<Comment>,
138    code_to_the_left: bool,
139    disabled_block_depth: usize,
140    tab_width: Option<usize>,
141}
142
143impl<'ast> CommentGatherer<'ast> {
144    fn new(sf: &'ast SourceFile, normalize_cmnts: bool, tab_width: Option<usize>) -> Self {
145        Self {
146            sf,
147            text: sf.src.as_str(),
148            start_bpos: sf.start_pos,
149            pos: 0,
150            comments: Vec::new(),
151            code_to_the_left: false,
152            disabled_block_depth: if normalize_cmnts { 0 } else { 1 },
153            tab_width,
154        }
155    }
156
157    /// Consumes the gatherer and returns the collected comments.
158    fn gather(mut self) -> Self {
159        for token in solar::parse::Cursor::new(&self.text[self.pos..]) {
160            self.process_token(token);
161        }
162        self
163    }
164
165    /// Post-processes a list of comments to group consecutive comments.
166    ///
167    /// Necessary for properly indenting multi-line trailing comments, which would
168    /// otherwise be parsed as a `Trailing` followed by several `Isolated`.
169    fn group(self) -> Vec<Comment> {
170        let mut processed = Vec::new();
171        let mut cursor = self.comments.into_iter().peekable();
172
173        while let Some(mut current) = cursor.next() {
174            if current.kind == CommentKind::Line
175                && (current.style.is_trailing() || current.style.is_isolated())
176            {
177                let mut ref_line =
178                    self.sf.lookup_line(self.sf.relative_position(current.span.hi())).unwrap();
179                while let Some(next_comment) = cursor.peek() {
180                    if !next_comment.style.is_isolated()
181                        || next_comment.kind != CommentKind::Line
182                        || ref_line + 1
183                            != self
184                                .sf
185                                .lookup_line(self.sf.relative_position(next_comment.span.lo()))
186                                .unwrap()
187                    {
188                        break;
189                    }
190
191                    let next_to_merge = cursor.next().unwrap();
192                    current.lines.extend(next_to_merge.lines);
193                    current.span = current.span.to(next_to_merge.span);
194                    ref_line += 1;
195                }
196            }
197
198            processed.push(current);
199        }
200
201        processed
202    }
203
204    /// Creates a `Span` relative to the source file's start position.
205    fn make_span(&self, range: std::ops::Range<usize>) -> Span {
206        Span::new(self.start_bpos + range.start as u32, self.start_bpos + range.end as u32)
207    }
208
209    /// Processes a single token from the source.
210    fn process_token(&mut self, token: solar::parse::lexer::token::RawToken) {
211        let token_range = self.pos..self.pos + token.len as usize;
212        let span = self.make_span(token_range.clone());
213        let token_text = &self.text[token_range];
214
215        // Keep track of disabled blocks
216        if token_text.trim_start().contains(DISABLE_START) {
217            self.disabled_block_depth += 1;
218        } else if token_text.trim_start().contains(DISABLE_END) {
219            self.disabled_block_depth -= 1;
220        }
221
222        #[allow(clippy::collapsible_match)]
223        match token.kind {
224            TokenKind::Whitespace => {
225                if let Some(mut idx) = token_text.find('\n') {
226                    self.code_to_the_left = false;
227
228                    while let Some(next_newline) = token_text[idx + 1..].find('\n') {
229                        idx += 1 + next_newline;
230                        let pos = self.pos + idx;
231                        self.comments.push(Comment {
232                            is_doc: false,
233                            kind: CommentKind::Line,
234                            style: CommentStyle::BlankLine,
235                            lines: vec![],
236                            span: self.make_span(pos..pos),
237                        });
238                        // If not disabled, early-exit as we want only a single blank line.
239                        if self.disabled_block_depth == 0 {
240                            break;
241                        }
242                    }
243                }
244            }
245            TokenKind::BlockComment { is_doc, .. } => {
246                let code_to_the_right = !matches!(
247                    self.text[self.pos + token.len as usize..].chars().next(),
248                    Some('\r' | '\n')
249                );
250                let style = match (self.code_to_the_left, code_to_the_right) {
251                    (_, true) => CommentStyle::Mixed,
252                    (false, false) => CommentStyle::Isolated,
253                    (true, false) => CommentStyle::Trailing,
254                };
255                let kind = CommentKind::Block;
256
257                // Measure the opening column, expanding tabs for non-doc comments.
258                let pos_in_file = self.start_bpos + BytePos(self.pos as u32);
259                let line_begin_in_file = line_begin_pos(self.sf, pos_in_file);
260                let line_begin_pos = (line_begin_in_file - self.start_bpos).to_usize();
261                let tab_width = if is_doc { 1 } else { self.tab_width.unwrap_or(1) };
262                let mut col =
263                    CharPos(estimate_line_width(&self.text[line_begin_pos..self.pos], tab_width));
264
265                // To preserve alignment in multi-line non-doc comments, normalize the block based
266                // on its least-indented line.
267                if !is_doc && token_text.contains('\n') {
268                    col = token_text.lines().skip(1).fold(col, |min, line| {
269                        if line.is_empty() {
270                            return min;
271                        }
272                        std::cmp::min(
273                            CharPos(estimate_line_width(
274                                &line[..line.len() - line.trim_start().len()],
275                                tab_width,
276                            )),
277                            min,
278                        )
279                    })
280                };
281
282                let lines = self.split_block_comment_into_lines(token_text, is_doc, col);
283                self.comments.push(Comment { is_doc, kind, style, lines, span })
284            }
285            TokenKind::LineComment { is_doc } => {
286                let line =
287                    if self.disabled_block_depth != 0 { token_text } else { token_text.trim_end() };
288                self.comments.push(Comment {
289                    is_doc,
290                    kind: CommentKind::Line,
291                    style: if self.code_to_the_left {
292                        CommentStyle::Trailing
293                    } else {
294                        CommentStyle::Isolated
295                    },
296                    lines: vec![line.into()],
297                    span,
298                });
299            }
300            _ => {
301                self.code_to_the_left = true;
302            }
303        }
304        self.pos += token.len as usize;
305    }
306
307    /// Splits a block comment into lines, ensuring that each line is properly formatted.
308    fn split_block_comment_into_lines(
309        &self,
310        text: &str,
311        is_doc: bool,
312        col: CharPos,
313    ) -> Vec<String> {
314        // if formatting is disabled, return as is
315        if self.disabled_block_depth != 0 {
316            return vec![text.into()];
317        }
318
319        let mut res: Vec<String> = vec![];
320        let mut lines = text.lines();
321        if let Some(line) = lines.next() {
322            let line = line.trim_end();
323            // Ensure first line of a doc comment only has the `/**` decorator
324            if is_doc && let Some((_, second)) = line.split_once("/**") {
325                res.push("/**".to_string());
326                if !second.trim().is_empty() {
327                    let line = normalize_block_comment_ws(second, col).trim_end();
328                    // Ensure last line of a doc comment only has the `*/` decorator
329                    if let Some((first, _)) = line.split_once("*/") {
330                        if !first.trim().is_empty() {
331                            res.push(format_doc_block_comment(first.trim_end(), self.tab_width));
332                        }
333                        res.push(" */".to_string());
334                    } else {
335                        res.push(format_doc_block_comment(line.trim_end(), self.tab_width));
336                    }
337                }
338            } else {
339                res.push(line.to_string());
340            }
341        }
342
343        for (pos, line) in lines.delimited() {
344            let indent_end = line.len() - line.trim_start().len();
345            let mut expanded = String::new();
346            let line = if !is_doc
347                && let Some(tab_width) = self.tab_width
348                && line[..indent_end].contains('\t')
349            {
350                // Trim tab indentation in the same display columns used by the printer.
351                expanded.extend(std::iter::repeat_n(
352                    ' ',
353                    estimate_line_width(&line[..indent_end], tab_width),
354                ));
355                expanded.push_str(&line[indent_end..]);
356                expanded.as_str()
357            } else {
358                line
359            };
360            let line = normalize_block_comment_ws(line, col).trim_end().to_string();
361            if !is_doc {
362                res.push(line);
363                continue;
364            }
365            if pos.is_last {
366                // Ensure last line of a doc comment only has the `*/` decorator
367                if let Some((first, _)) = line.split_once("*/")
368                    && !first.trim().is_empty()
369                {
370                    res.push(format_doc_block_comment(first.trim_end(), self.tab_width));
371                }
372                res.push(" */".to_string());
373            } else {
374                res.push(format_doc_block_comment(&line, self.tab_width));
375            }
376        }
377        res
378    }
379}
380
381/// Returns `None` if the first `col` chars of `s` contain a non-whitespace char.
382/// Otherwise returns `Some(k)` where `k` is first char offset after that leading
383/// whitespace. Note that `k` may be outside bounds of `s`.
384fn all_whitespace(s: &str, col: CharPos) -> Option<usize> {
385    let mut idx = 0;
386    for (i, ch) in s.char_indices().take(col.to_usize()) {
387        if !ch.is_whitespace() {
388            return None;
389        }
390        idx = i + ch.len_utf8();
391    }
392    Some(idx)
393}
394
395/// Returns `Some(k)` where `k` is the byte offset of the first non-whitespace char. Returns `k = 0`
396/// if `s` starts with a non-whitespace char. If `s` only contains whitespaces, returns `None`.
397fn first_non_whitespace(s: &str) -> Option<usize> {
398    let mut len = 0;
399    for (i, ch) in s.char_indices() {
400        if ch.is_whitespace() {
401            len = ch.len_utf8()
402        } else {
403            return if i == 0 { Some(0) } else { Some(i + 1 - len) };
404        }
405    }
406    None
407}
408
409/// Returns a slice of `s` with a whitespace prefix removed based on `col`. If the first `col` chars
410/// of `s` are all whitespace, returns a slice starting after that prefix.
411fn normalize_block_comment_ws(s: &str, col: CharPos) -> &str {
412    let len = s.len();
413    if let Some(col) = all_whitespace(s, col) {
414        return if col < len { &s[col..] } else { "" };
415    }
416    if let Some(col) = first_non_whitespace(s) {
417        return &s[col..];
418    }
419    s
420}
421
422/// Formats a doc block comment line so that they have the ` *` decorator.
423fn format_doc_block_comment(line: &str, tab_width: Option<usize>) -> String {
424    if line.is_empty() {
425        return (" *").to_string();
426    }
427
428    if let Some((_, rest_of_line)) = line.split_once('*') {
429        if rest_of_line.is_empty() {
430            (" *").to_string()
431        } else if let Some(tab_width) = tab_width {
432            let mut normalized = String::from(" *");
433            line_with_tabs(
434                &mut normalized,
435                rest_of_line,
436                tab_width,
437                Some(Consolidation::MinOneTab),
438            );
439            normalized
440        } else {
441            format!(" *{rest_of_line}",)
442        }
443    } else if let Some(tab_width) = tab_width {
444        let mut normalized = String::from(" *\t");
445        line_with_tabs(&mut normalized, line, tab_width, Some(Consolidation::WithoutSpaces));
446        normalized
447    } else {
448        format!(" * {line}")
449    }
450}
451
452pub enum Consolidation {
453    MinOneTab,
454    WithoutSpaces,
455}
456
457/// Normalizes the leading whitespace of a string slice according to a given tab width.
458///
459/// It aggregates and converts leading whitespace (spaces and tabs) into a representation that
460/// maximizes the amount of tabs.
461pub fn line_with_tabs(
462    output: &mut String,
463    line: &str,
464    tab_width: usize,
465    strategy: Option<Consolidation>,
466) {
467    // Find the end of the leading whitespace (any sequence of spaces and tabs)
468    let first_non_ws = line.find(|c| c != ' ' && c != '\t').unwrap_or(line.len());
469    let (leading_ws, rest_of_line) = line.split_at(first_non_ws);
470
471    // Compute its equivalent length and derive the required amount of tabs and spaces
472    let total_width =
473        leading_ws.chars().fold(0, |width, c| width + if c == ' ' { 1 } else { tab_width });
474    let (mut num_tabs, mut num_spaces) = (total_width / tab_width, total_width % tab_width);
475
476    // Adjust based on the desired config
477    match strategy {
478        Some(Consolidation::MinOneTab) => {
479            if num_tabs == 0 && num_spaces != 0 {
480                (num_tabs, num_spaces) = (1, 0);
481            } else if num_spaces != 0 {
482                (num_tabs, num_spaces) = (num_tabs + 1, 0);
483            }
484        }
485        Some(Consolidation::WithoutSpaces) if num_spaces != 0 => {
486            (num_tabs, num_spaces) = (num_tabs + 1, 0);
487        }
488        _ => (),
489    };
490
491    // Append the normalized indentation and the rest of the line to the output
492    output.extend(std::iter::repeat_n('\t', num_tabs));
493    output.extend(std::iter::repeat_n(' ', num_spaces));
494    output.push_str(rest_of_line);
495}
496
497/// Estimates the display width of a string, accounting for tabs.
498pub fn estimate_line_width(line: &str, tab_width: usize) -> usize {
499    line.chars().fold(0, |width, c| width + if c == '\t' { tab_width } else { 1 })
500}
501
502/// Returns the `BytePos` of the beginning of the current line.
503fn line_begin_pos(sf: &SourceFile, pos: BytePos) -> BytePos {
504    let pos = sf.relative_position(pos);
505    let line_index = sf.lookup_line(pos).unwrap();
506    let line_start_pos = sf.lines()[line_index];
507    sf.absolute_position(line_start_pos)
508}