Skip to main content

i_slint_compiler/
doc_comments.rs

1// Copyright © SixtyFPS GmbH <info@slint.dev>
2// SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-Slint-Royalty-free-2.0 OR LicenseRef-Slint-Software-3.0
3
4//! Extract `///` doc comments from the syntax tree.
5
6use crate::diagnostics::{BuildDiagnostics, Spanned};
7use crate::langtype::BuiltinElement;
8use crate::parser::{SyntaxKind, SyntaxNode, identifier_text, syntax_nodes};
9use smol_str::SmolStr;
10
11/// One entry in the documentation of a builtin element, preserving the
12/// source order from `builtins.slint`.
13#[derive(Debug, Clone)]
14pub enum ElementDocEntry {
15    /// Free-form documentation text (from `///` or `//!` comments).
16    Text(String),
17    /// Reference to a property, callback, or function by name.
18    Member(SmolStr),
19}
20
21/// Strip a doc-comment prefix (`///` or `//!`) from a line.
22/// Returns the content after the prefix if the line matches exactly
23/// `prefix` or `prefix` followed by a space and content.
24/// Rejects lines like `////` or `//!!`.
25fn strip_doc_prefix<'a>(line: &'a str, prefix: &str) -> Option<&'a str> {
26    let rest = line.strip_prefix(prefix)?;
27    match rest.strip_prefix(' ') {
28        Some(content) => Some(content),
29        None if rest.is_empty() => Some(""),
30        None => None,
31    }
32}
33
34/// Walk backwards across sibling tokens/nodes collecting consecutive
35/// `///` doc comment lines immediately before `anchor`. Returns the
36/// concatenated text with the `/// ` prefix stripped, or `None` if
37/// no doc comment was present.
38fn collect_before(anchor: &SyntaxNode) -> Option<String> {
39    let mut lines = Vec::new();
40    let mut cursor = anchor.node.prev_sibling_or_token();
41    while let Some(cur) = cursor {
42        match cur.kind() {
43            SyntaxKind::Whitespace => {}
44            SyntaxKind::Comment => {
45                let text = cur.as_token().unwrap().text();
46                if let Some(content) = strip_doc_prefix(text, "///") {
47                    lines.push(content.to_string());
48                } else if text.starts_with("//") {
49                    // Skip regular comments and //-annotations.
50                } else {
51                    break;
52                }
53            }
54            SyntaxKind::ExportsList => {
55                // Doc comments may sit inside a preceding `export { ... }` list.
56                if let Some(list) = cur.as_node() {
57                    let mut last = list.last_child_or_token();
58                    while let Some(child) = last {
59                        match child.kind() {
60                            SyntaxKind::Whitespace => {}
61                            SyntaxKind::Comment => {
62                                let t = child.as_token().unwrap().text();
63                                if let Some(content) = strip_doc_prefix(t, "///") {
64                                    lines.push(content.to_string());
65                                } else if t.starts_with("//") {
66                                    // skip
67                                } else {
68                                    break;
69                                }
70                            }
71                            _ => break,
72                        }
73                        last = child.prev_sibling_or_token();
74                    }
75                }
76                break;
77            }
78            _ => break,
79        }
80        cursor = cur.prev_sibling_or_token();
81    }
82    if lines.is_empty() {
83        return None;
84    }
85    lines.reverse();
86    Some(lines.join("\n"))
87}
88
89/// Extract the `///` doc comment before a syntax node. Also checks
90/// above the enclosing `ExportsList` when the node is inside one.
91pub(crate) fn doc_comment(anchor: &SyntaxNode) -> Option<String> {
92    if let Some(doc) = collect_before(anchor) {
93        return Some(doc);
94    }
95    if let Some(parent) = anchor.parent()
96        && parent.kind() == SyntaxKind::ExportsList
97    {
98        return collect_before(&parent);
99    }
100    None
101}
102
103/// Extract the `///` description before the component and the ordered
104/// body entries (`//!` text and member references) from inside it.
105/// The description is included as the first `Text` entry.
106pub(crate) fn element_doc_entries(
107    component: &SyntaxNode,
108    element: &syntax_nodes::Element,
109    diag: &mut BuildDiagnostics,
110) -> Vec<ElementDocEntry> {
111    let description = doc_comment(component).unwrap_or_default();
112
113    let mut entries = vec![ElementDocEntry::Text(description)];
114    let mut section_lines: Vec<String> = Vec::new();
115    let flush_section = |lines: &mut Vec<String>, entries: &mut Vec<ElementDocEntry>| {
116        if !lines.is_empty() {
117            entries.push(ElementDocEntry::Text(lines.join("\n")));
118            lines.clear();
119        }
120    };
121
122    let mut doc_comment_span = None;
123    for child in element.children_with_tokens() {
124        match child.kind() {
125            SyntaxKind::Whitespace => {}
126            SyntaxKind::Comment => {
127                if let Some(t) = child.as_token() {
128                    let text = t.text();
129                    if strip_doc_prefix(text, "///").is_some() {
130                        doc_comment_span = Some(child.to_source_location());
131                    } else if let Some(content) = strip_doc_prefix(text, "//!") {
132                        if let Some(span) = doc_comment_span.take() {
133                            diag.push_warning_with_span(
134                                "`///` doc comment not attached to a declaration".into(),
135                                span,
136                            );
137                        }
138                        section_lines.push(content.to_string());
139                    }
140                }
141            }
142            SyntaxKind::PropertyDeclaration => {
143                doc_comment_span = None;
144                flush_section(&mut section_lines, &mut entries);
145                let p = syntax_nodes::PropertyDeclaration::from(child.into_node().unwrap());
146                let name = identifier_text(&p.DeclaredIdentifier()).unwrap();
147                entries.push(ElementDocEntry::Member(name));
148            }
149            SyntaxKind::CallbackDeclaration => {
150                doc_comment_span = None;
151                flush_section(&mut section_lines, &mut entries);
152                let cb = syntax_nodes::CallbackDeclaration::from(child.into_node().unwrap());
153                let name = identifier_text(&cb.DeclaredIdentifier()).unwrap();
154                entries.push(ElementDocEntry::Member(name));
155            }
156            SyntaxKind::Function => {
157                doc_comment_span = None;
158                let f = syntax_nodes::Function::from(child.into_node().unwrap());
159                flush_section(&mut section_lines, &mut entries);
160                let name = identifier_text(&f.DeclaredIdentifier()).unwrap();
161                entries.push(ElementDocEntry::Member(name));
162            }
163            _ => {
164                if let Some(span) = doc_comment_span.take() {
165                    diag.push_warning_with_span(
166                        "`///` doc comment not attached to a declaration".into(),
167                        span,
168                    );
169                }
170            }
171        }
172    }
173    if let Some(span) = doc_comment_span.take() {
174        diag.push_warning_with_span("`///` doc comment not attached to a declaration".into(), span);
175    }
176    flush_section(&mut section_lines, &mut entries);
177    entries
178}
179
180/// Assemble the final doc entries for an element, prepending inherited
181/// parent entries after the description.
182pub(crate) fn assemble(
183    mut entries: Vec<ElementDocEntry>,
184    parent: Option<&BuiltinElement>,
185) -> Vec<ElementDocEntry> {
186    let skip_inherited = matches!(entries.first(), Some(ElementDocEntry::Text(desc)) if desc.contains("\\skip_inherited"));
187
188    if !skip_inherited && let Some(parent) = parent {
189        // Splice inherited parent body (everything after parent's description)
190        // right after our own description (entries[0]).
191        entries.splice(1..1, parent.docs[1..].iter().cloned());
192    }
193    entries
194}
195
196#[cfg(test)]
197mod tests {
198    use super::*;
199    use crate::diagnostics::BuildDiagnostics;
200    use crate::parser::{self, syntax_nodes};
201
202    /// Parse a mini `.slint` document and return the first Component node
203    /// and its Element, along with diagnostics.
204    fn parse_component(source: &str) -> (SyntaxNode, syntax_nodes::Element, BuildDiagnostics) {
205        let mut diag = BuildDiagnostics::default();
206        let node = parser::parse(source.into(), None, &mut diag);
207        assert!(!diag.has_errors(), "parse errors: {:?}", diag.to_string_vec());
208        let doc: syntax_nodes::Document = node.into();
209        let comp = doc.Component().next().expect("no component found");
210        let elem = comp.Element();
211        (comp.into(), elem, BuildDiagnostics::default())
212    }
213
214    #[test]
215    fn test_strip_doc_prefix() {
216        assert_eq!(strip_doc_prefix("/// hello", "///"), Some("hello"));
217        assert_eq!(strip_doc_prefix("///", "///"), Some(""));
218        assert_eq!(strip_doc_prefix("////", "///"), None);
219        assert_eq!(strip_doc_prefix("//! section", "//!"), Some("section"));
220        assert_eq!(strip_doc_prefix("//!", "//!"), Some(""));
221        assert_eq!(strip_doc_prefix("//!!", "//!"), None);
222    }
223
224    #[test]
225    fn test_doc_comment_before_component() {
226        let (comp, _, _) = parse_component("/// My component\ncomponent Foo inherits Rectangle {}");
227        assert_eq!(doc_comment(&comp), Some("My component".into()));
228    }
229
230    #[test]
231    fn test_element_doc_entries_basic() {
232        let (comp, elem, mut diag) =
233            parse_component("/// Description\ncomponent Foo {\n  in property <int> bar;\n}");
234        let entries = element_doc_entries(&comp, &elem, &mut diag);
235        assert!(diag.is_empty(), "unexpected diag: {:?}", diag.to_string_vec());
236        assert!(matches!(&entries[0], ElementDocEntry::Text(t) if t == "Description"));
237        assert!(matches!(&entries[1], ElementDocEntry::Member(n) if n == "bar"));
238    }
239
240    #[test]
241    fn test_element_doc_entries_section_text() {
242        let (comp, elem, mut diag) =
243            parse_component("component Foo {\n  //! section\n  in property <int> x;\n}");
244        let entries = element_doc_entries(&comp, &elem, &mut diag);
245        assert!(diag.is_empty(), "unexpected diag: {:?}", diag.to_string_vec());
246        // entries[0] = empty description, entries[1] = section text, entries[2] = member
247        assert!(matches!(&entries[0], ElementDocEntry::Text(t) if t.is_empty()));
248        assert!(matches!(&entries[1], ElementDocEntry::Text(t) if t == "section"));
249        assert!(matches!(&entries[2], ElementDocEntry::Member(n) if n == "x"));
250    }
251
252    #[test]
253    fn test_element_doc_entries_warns_orphan_doc_comment() {
254        let (comp, elem, mut diag) = parse_component("component Foo {\n  /// orphan\n}");
255        let _entries = element_doc_entries(&comp, &elem, &mut diag);
256        assert!(
257            diag.to_string_vec().iter().any(|m| m.contains("not attached to a declaration")),
258            "expected warning about orphan doc comment, got: {:?}",
259            diag.to_string_vec(),
260        );
261    }
262
263    #[test]
264    fn test_element_doc_entries_callback_and_function() {
265        let (comp, elem, mut diag) =
266            parse_component("component Foo {\n  callback clicked();\n  function do-stuff() {}\n}");
267        let entries = element_doc_entries(&comp, &elem, &mut diag);
268        assert!(diag.is_empty(), "unexpected diag: {:?}", diag.to_string_vec());
269        assert!(matches!(&entries[1], ElementDocEntry::Member(n) if n == "clicked"));
270        assert!(matches!(&entries[2], ElementDocEntry::Member(n) if n == "do-stuff"));
271    }
272}