tinymist_analysis/syntax/
comment.rs

1//! Convenient utilities to match comment in code.
2
3use itertools::Itertools;
4
5use crate::prelude::*;
6
7/// Extracts the module-level documentation from a source.
8pub fn find_module_level_docs(src: &Source) -> Option<String> {
9    crate::log_debug_ct!("finding docs at: {id:?}", id = src.id());
10
11    let root = LinkedNode::new(src.root());
12    for n in root.children() {
13        if n.kind().is_trivia() {
14            continue;
15        }
16
17        return extract_mod_docs_between(&root, 0..n.offset(), true);
18    }
19
20    extract_mod_docs_between(&root, 0..src.text().len(), true)
21}
22
23/// Extracts the module-level documentation from a source.
24fn extract_mod_docs_between(
25    node: &LinkedNode,
26    rng: Range<usize>,
27    first_group: bool,
28) -> Option<String> {
29    let mut matcher = DocCommentMatcher {
30        strict: true,
31        ..Default::default()
32    };
33    let nodes = node.children();
34    'scan_comments: for n in nodes {
35        let offset = n.offset();
36        if offset < rng.start {
37            continue 'scan_comments;
38        }
39        if offset >= rng.end {
40            break 'scan_comments;
41        }
42
43        crate::log_debug_ct!(
44            "found comment for docs: {:?}: {:?}",
45            n.kind(),
46            n.leaf_text()
47        );
48        if matcher.process(n.get()) {
49            if first_group {
50                break 'scan_comments;
51            }
52            matcher.comments.clear();
53        }
54    }
55
56    matcher.collect()
57}
58
59/// A signal raised by the comment group matcher.
60pub enum CommentGroupSignal {
61    /// A hash marker is found.
62    Hash,
63    /// A space is found.
64    Space,
65    /// A line comment is found.
66    LineComment,
67    /// A block comment is found.
68    BlockComment,
69    /// The comment group should be broken.
70    BreakGroup,
71}
72
73/// A matcher that groups comments.
74#[derive(Default)]
75pub struct CommentGroupMatcher {
76    newline_count: u32,
77}
78
79impl CommentGroupMatcher {
80    /// Resets the matcher. This usually happens after a group is collected or
81    /// when some other child item is breaking the comment group manually.
82    pub fn reset(&mut self) {
83        self.newline_count = 0;
84    }
85
86    /// Processes a child relative to some [`SyntaxNode`].
87    ///
88    /// ## Example
89    ///
90    /// See [`DocCommentMatcher`] for a real-world example.
91    pub fn process(&mut self, n: &SyntaxNode) -> CommentGroupSignal {
92        match n.kind() {
93            SyntaxKind::Hash => {
94                self.newline_count = 0;
95
96                CommentGroupSignal::Hash
97            }
98            SyntaxKind::Space => {
99                if n.leaf_text().contains('\n') {
100                    self.newline_count += 1;
101                }
102                if self.newline_count > 1 {
103                    return CommentGroupSignal::BreakGroup;
104                }
105
106                CommentGroupSignal::Space
107            }
108            SyntaxKind::Parbreak => {
109                self.newline_count = 2;
110                CommentGroupSignal::BreakGroup
111            }
112            SyntaxKind::LineComment => {
113                self.newline_count = 0;
114                CommentGroupSignal::LineComment
115            }
116            SyntaxKind::BlockComment => {
117                self.newline_count = 0;
118                CommentGroupSignal::BlockComment
119            }
120            _ => {
121                self.newline_count = 0;
122                CommentGroupSignal::BreakGroup
123            }
124        }
125    }
126}
127
128/// A raw comment.
129enum RawComment {
130    /// A line comment.
131    Line(EcoString),
132    /// A block comment.
133    Block(EcoString),
134}
135
136/// A matcher that collects documentation comments.
137#[derive(Default)]
138pub struct DocCommentMatcher {
139    /// The collected comments.
140    comments: Vec<RawComment>,
141    /// The matcher for grouping comments.
142    group_matcher: CommentGroupMatcher,
143    /// Whether to strictly match the comment format.
144    strict: bool,
145}
146
147impl DocCommentMatcher {
148    /// Resets the matcher. This usually happens after a group is collected or
149    /// when some other child item is breaking the comment group manually.
150    pub fn reset(&mut self) {
151        self.comments.clear();
152        self.group_matcher.reset();
153    }
154
155    /// Processes a child relative to some [`SyntaxNode`].
156    pub fn process(&mut self, n: &SyntaxNode) -> bool {
157        match self.group_matcher.process(n) {
158            CommentGroupSignal::LineComment => {
159                let text = n.leaf_text();
160                if !self.strict || text.starts_with("///") {
161                    self.comments.push(RawComment::Line(text.clone()));
162                }
163            }
164            CommentGroupSignal::BlockComment => {
165                let text = n.leaf_text();
166                if !self.strict {
167                    self.comments.push(RawComment::Block(text.clone()));
168                }
169            }
170            CommentGroupSignal::BreakGroup => {
171                return true;
172            }
173            CommentGroupSignal::Hash | CommentGroupSignal::Space => {}
174        }
175
176        false
177    }
178
179    /// Collects the comments and returns the result.
180    pub fn collect(&mut self) -> Option<String> {
181        let comments = &self.comments;
182        if comments.is_empty() {
183            return None;
184        }
185
186        let comments = comments.iter().map(|comment| match comment {
187            RawComment::Line(line) => {
188                // strip all slash prefix
189                line.trim_start_matches('/')
190            }
191            RawComment::Block(block) => {
192                fn remove_comment(text: &str) -> Option<&str> {
193                    let mut text = text.strip_prefix("/*")?.strip_suffix("*/")?.trim();
194                    // trip start star
195                    if text.starts_with('*') {
196                        text = text.strip_prefix('*')?.trim();
197                    }
198                    Some(text)
199                }
200
201                remove_comment(block).unwrap_or(block.as_str())
202            }
203        });
204        let comments = comments.collect::<Vec<_>>();
205
206        let dedent = comments
207            .iter()
208            .flat_map(|line| {
209                let mut chars = line.chars();
210                let cnt = chars
211                    .by_ref()
212                    .peeking_take_while(|c| c.is_whitespace())
213                    .count();
214                chars.next().map(|_| cnt)
215            })
216            .min()
217            .unwrap_or(0);
218
219        let size_hint = comments.iter().map(|comment| comment.len()).sum::<usize>();
220        let mut comments = comments
221            .iter()
222            .map(|comment| comment.chars().skip(dedent).collect::<String>());
223
224        let res = comments.try_fold(String::with_capacity(size_hint), |mut acc, comment| {
225            if !acc.is_empty() {
226                acc.push('\n');
227            }
228
229            acc.push_str(&comment);
230            Some(acc)
231        });
232
233        self.comments.clear();
234        res
235    }
236}
237
238#[cfg(test)]
239mod tests {
240    use super::*;
241
242    fn test(it: &str) -> String {
243        find_module_level_docs(&Source::detached(it)).unwrap()
244    }
245
246    #[test]
247    fn simple() {
248        assert_eq!(
249            test(
250                r#"/// foo
251/// bar
252#let main() = printf("hello World")"#
253            ),
254            "foo\nbar"
255        );
256    }
257
258    #[test]
259    fn dedent() {
260        assert_eq!(
261            test(
262                r#"/// a
263/// b
264/// c
265#let main() = printf("hello World")"#
266            ),
267            "a\nb\nc"
268        );
269        assert_eq!(
270            test(
271                r#"///a
272/// b
273/// c
274#let main() = printf("hello World")"#
275            ),
276            "a\n b\n c"
277        );
278    }
279
280    #[test]
281    fn issue_1687_postive() {
282        assert_eq!(
283            test(
284                r#"/// Description.
285/// 
286/// Note.
287#let main() = printf("hello World")"#
288            ),
289            "Description.\n\nNote."
290        );
291    }
292
293    #[test]
294    fn issue_1687_negative() {
295        assert_eq!(
296            test(
297                r#"/// Description.
298///
299/// Note.
300#let main() = printf("hello World")"#
301            ),
302            "Description.\n\nNote."
303        );
304    }
305}