Skip to main content

cairo_lang_doc/
db.rs

1use std::fmt::Write;
2
3use cairo_lang_defs::db::DefsGroup;
4use cairo_lang_defs::ids::{ImplItemId, LookupItemId, ModuleId, ModuleItemId, TraitItemId};
5use cairo_lang_filesystem::db::FilesGroup;
6use cairo_lang_filesystem::ids::{CrateId, FileId, Tracked};
7use cairo_lang_filesystem::span::{TextOffset, TextSpan, TextWidth};
8use cairo_lang_syntax::node::SyntaxNode;
9use cairo_lang_syntax::node::kind::SyntaxKind;
10use itertools::{Itertools, intersperse};
11use salsa::Database;
12
13use crate::documentable_item::DocumentableItemId;
14use crate::location_links::LocationLink;
15use crate::parser::{DocumentationCommentToken, MarkdownLink, parse_documentation_comment};
16
17pub trait DocGroup: Database {
18    // TODO(mkaput): Support #[doc] attribute. This will be a bigger chunk of work because it would
19    //   be the best to convert all /// comments to #[doc] attrs before processing items by plugins,
20    //   so that plugins would get a nice and clean syntax of documentation to manipulate further.
21    /// Gets the documentation of an item.
22    fn get_item_documentation<'db>(&'db self, item_id: DocumentableItemId<'db>) -> Option<String> {
23        get_item_documentation(self.as_dyn_database(), (), item_id)
24    }
25
26    /// Gets the documentation of an item as a vector of continuous tokens.
27    fn get_item_documentation_as_tokens<'db>(
28        &'db self,
29        item_id: DocumentableItemId<'db>,
30    ) -> Option<Vec<DocumentationCommentToken>> {
31        get_item_documentation_as_tokens(self.as_dyn_database(), (), item_id)
32    }
33
34    /// Gets the signature of an item (i.e., the item without its body).
35    fn get_item_signature<'db>(&'db self, item_id: DocumentableItemId<'db>) -> Option<String> {
36        self.get_item_signature_with_links(item_id).0
37    }
38
39    /// Gets the signature of an item and a list of [`LocationLink`]s to enable mapping
40    /// signature slices to documentable items.
41    fn get_item_signature_with_links<'db>(
42        &'db self,
43        item_id: DocumentableItemId<'db>,
44    ) -> (Option<String>, Vec<LocationLink<'db>>) {
45        crate::documentable_formatter::get_item_signature_with_links(
46            self.as_dyn_database(),
47            (),
48            item_id,
49        )
50    }
51
52    /// Extracts embedded Markdown links from a documentation comment syntax node.
53    fn get_embedded_markdown_links<'db>(&'db self, node: SyntaxNode<'db>) -> Vec<MarkdownLink> {
54        get_embedded_markdown_links(self.as_dyn_database(), (), node)
55    }
56}
57
58impl<T: Database + ?Sized> DocGroup for T {}
59
60#[salsa::tracked]
61fn get_item_documentation<'db>(
62    db: &'db dyn Database,
63    _tracked: Tracked,
64    item_id: DocumentableItemId<'db>,
65) -> Option<String> {
66    let tokens = db.get_item_documentation_as_tokens(item_id)?;
67    let mut buff = String::new();
68    for doc_token in &tokens {
69        match doc_token {
70            DocumentationCommentToken::Content(content) => buff.push_str(content.as_str()),
71            DocumentationCommentToken::Link(link) => {
72                write!(&mut buff, "[{}]", link.label).ok()?;
73                if let Some(path) = &link.path {
74                    write!(&mut buff, "({path})").ok()?;
75                }
76            }
77        }
78    }
79    Some(buff)
80}
81
82#[salsa::tracked]
83fn get_item_documentation_as_tokens<'db>(
84    db: &'db dyn Database,
85    _tracked: Tracked,
86    item_id: DocumentableItemId<'db>,
87) -> Option<Vec<DocumentationCommentToken>> {
88    let (outer_comment, inner_comment, module_level_comment) = match item_id {
89        DocumentableItemId::Crate(crate_id) => {
90            (None, None, get_crate_root_module_documentation(db, crate_id))
91        }
92        item_id => (
93            // We check for different types of comments for the item. Even modules can have both
94            // inner and module level comments.
95            extract_item_outer_documentation(db, item_id),
96            // In case if item_id is a module, there are 2 possible cases:
97            // 1. Inline module: It could have inner comments, but not the module_level.
98            // 2. Non-inline Module (module as a file): It could have module level comments, but
99            //    not the inner ones.
100            extract_item_inner_documentation(db, item_id),
101            extract_item_module_level_documentation(db, item_id),
102        ),
103    };
104
105    let outer_comment_tokens =
106        outer_comment.map(|comment| parse_documentation_comment(comment.as_str()));
107    let inner_comment_tokens =
108        inner_comment.map(|comment| parse_documentation_comment(comment.as_str()));
109    let module_level_comment_tokens =
110        module_level_comment.map(|comment| parse_documentation_comment(comment.as_str()));
111
112    let mut result: Vec<Vec<DocumentationCommentToken>> =
113        [module_level_comment_tokens, outer_comment_tokens, inner_comment_tokens]
114            .into_iter()
115            .flatten()
116            .collect();
117    result.retain(|v| !v.is_empty());
118    if result.is_empty() {
119        return None;
120    }
121    let separator_token = vec![DocumentationCommentToken::Content(" ".to_string())];
122    Some(intersperse(result, separator_token).flatten().collect())
123}
124
125/// Gets the crate level documentation.
126fn get_crate_root_module_documentation<'db>(
127    db: &'db dyn Database,
128    crate_id: CrateId<'db>,
129) -> Option<String> {
130    let module_id = db.module_main_file(ModuleId::CrateRoot(crate_id)).ok()?;
131    extract_item_module_level_documentation_from_file(db, module_id)
132}
133
134/// Gets the "//!" inner comment of the item (only if the item supports inner comments).
135fn extract_item_inner_documentation<'db>(
136    db: &'db dyn Database,
137    item_id: DocumentableItemId<'db>,
138) -> Option<String> {
139    if matches!(
140        item_id,
141        DocumentableItemId::LookupItem(
142            LookupItemId::ModuleItem(ModuleItemId::FreeFunction(_) | ModuleItemId::Submodule(_))
143                | LookupItemId::ImplItem(ImplItemId::Function(_))
144                | LookupItemId::TraitItem(TraitItemId::Function(_))
145        )
146    ) {
147        let raw_text = item_id
148            .stable_location(db)?
149            .syntax_node(db)
150            .get_text_without_inner_commentable_children(db);
151        Some(extract_item_inner_documentation_from_raw_text(raw_text))
152    } else {
153        None
154    }
155}
156
157/// Gets only the doc comments above the item.
158fn extract_item_outer_documentation<'db>(
159    db: &'db dyn Database,
160    item_id: DocumentableItemId<'db>,
161) -> Option<String> {
162    // Get the text of the item (trivia + definition)
163    let raw_text = item_id.stable_location(db)?.syntax_node(db).get_text(db);
164    Some(
165        raw_text
166        .lines()
167        .filter(|line| !line.trim().is_empty())
168        // Takes all the lines before the definition.
169        // Anything other than doc comments will be filtered out later.
170        .take_while_ref(|line| is_comment_line(line) || line.trim_start().starts_with("#"))
171        .filter_map(|line| extract_comment_from_code_line(line, &["///"]))
172        .join("\n"),
173    )
174}
175
176/// Gets the module-level comments of the item.
177fn extract_item_module_level_documentation<'db>(
178    db: &'db dyn Database,
179    item_id: DocumentableItemId<'db>,
180) -> Option<String> {
181    match item_id {
182        DocumentableItemId::LookupItem(LookupItemId::ModuleItem(ModuleItemId::Submodule(
183            submodule_id,
184        ))) => {
185            if db.is_submodule_inline(submodule_id) {
186                return None;
187            }
188            let module_id = db.module_main_file(ModuleId::Submodule(submodule_id)).ok()?;
189            extract_item_module_level_documentation_from_file(db, module_id)
190        }
191        _ => None,
192    }
193}
194
195/// Gets only the comments inside the item.
196fn extract_item_inner_documentation_from_raw_text(raw_text: String) -> String {
197    raw_text
198        .lines()
199        .filter(|line| !line.trim().is_empty())
200        .skip_while(|line| is_comment_line(line))
201        .filter_map(|line| extract_comment_from_code_line(line, &["//!"]))
202        .join("\n")
203}
204
205/// Gets the module-level comments of a file.
206fn extract_item_module_level_documentation_from_file<'db>(
207    db: &'db dyn Database,
208    file_id: FileId<'db>,
209) -> Option<String> {
210    let file_content = db.file_content(file_id)?.to_string();
211    Some(
212        file_content
213            .lines()
214            .filter(|line| !line.trim().is_empty())
215            .take_while_ref(|line| is_comment_line(line))
216            .filter_map(|line| extract_comment_from_code_line(line, &["//!"]))
217            .join("\n"),
218    )
219}
220
221/// This function does 3 things to a comment line:
222/// 1. Removes indentation
223/// 2. If it starts with one of the passed prefixes, removes the given prefixes (including the space
224///    after the prefix).
225/// 3. If the comment starts with a slash, returns None.
226fn extract_comment_from_code_line(line: &str, comment_markers: &[&'static str]) -> Option<String> {
227    // Remove indentation.
228    let dedent = line.trim_start();
229    // Check if this is a doc comment.
230    for comment_marker in comment_markers {
231        if let Some(content) = dedent.strip_prefix(*comment_marker) {
232            // TODO(mkaput): The way how removing this indentation is performed is probably
233            //   wrong. The code should probably learn how many spaces are used in the first
234            //   line of comments block, and then remove the same amount of spaces in the
235            //   block, instead of assuming just one space.
236            // Remove inner indentation if one exists.
237            if content.starts_with('/') {
238                return None;
239            }
240            return Some(content.strip_prefix(' ').unwrap_or(content).to_string());
241        }
242    }
243    None
244}
245
246/// Checks whether the code line is a comment line.
247fn is_comment_line(line: &str) -> bool {
248    line.trim_start().starts_with("//")
249}
250
251#[salsa::tracked]
252/// Extracts embedded Markdown links from a documentation comment syntax node.
253fn get_embedded_markdown_links<'db>(
254    db: &'db dyn Database,
255    _tracked: Tracked,
256    node: SyntaxNode<'db>,
257) -> Vec<MarkdownLink> {
258    match node.kind(db) {
259        SyntaxKind::TokenSingleLineDocComment | SyntaxKind::TokenSingleLineInnerComment => {
260            let content = node.get_text(db);
261            let base_span = node.span(db);
262            parse_embedded_markdown_links(content, base_span.start)
263        }
264        _ => vec![],
265    }
266}
267
268/// Parses links from the Markdown content and shifts spans to file-relative offsets.
269fn parse_embedded_markdown_links(
270    content: &str,
271    comment_node_offset: TextOffset,
272) -> Vec<MarkdownLink> {
273    let tokens = parse_documentation_comment(content);
274    let mut results = Vec::new();
275
276    for token in tokens {
277        let DocumentationCommentToken::Link(link) = token else { continue };
278        results.push(MarkdownLink {
279            link_span: shift_right_span(comment_node_offset, link.md_link.link_span),
280            dest_span: link
281                .md_link
282                .dest_span
283                .map(|span| shift_right_span(comment_node_offset, span)),
284            dest_text: link.md_link.dest_text,
285        });
286    }
287
288    results
289}
290
291// When parsing a comment, we work with offset from the beginning of the comment node.
292// This function shifts the given span by the given offset so that it's relative to the beginning
293// of the file.
294fn shift_right_span(shift_offset: TextOffset, relative_span: TextSpan) -> TextSpan {
295    TextSpan::new(
296        shift_offset.add_width(TextWidth::new_for_testing(relative_span.start.as_u32())),
297        shift_offset.add_width(TextWidth::new_for_testing(relative_span.end.as_u32())),
298    )
299}