Skip to main content

leekscript_core/
doc_comment.rs

1//! Doxygen-style comment parsing and association with declarations.
2//!
3//! Parses comment content (from trivia) into structured fields (brief, description,
4//! @param, @return, @deprecated, etc.) and builds a map from declaration span to docs.
5
6use std::collections::HashMap;
7
8use sipha::red::{SyntaxElement, SyntaxNode, SyntaxToken};
9use sipha::types::{FromSyntaxKind, IntoSyntaxKind};
10
11use crate::syntax::Kind;
12
13/// Structured documentation parsed from a Doxygen-style comment.
14#[derive(Clone, Debug, Default)]
15pub struct DocComment {
16    /// Short summary (e.g. from @brief or first line).
17    pub brief: Option<String>,
18    /// Main description body.
19    pub description: String,
20    /// Extended description from @details (multi-line).
21    pub details: Option<String>,
22    /// Parameter name -> description.
23    pub params: Vec<(String, String)>,
24    /// Return value description.
25    pub returns: Option<String>,
26    /// Per-value return descriptions from @retval.
27    pub retvals: Vec<String>,
28    /// Deprecation message if @deprecated is present.
29    pub deprecated: Option<String>,
30    /// @see references.
31    pub see: Vec<String>,
32    /// @since version or similar.
33    pub since: Option<String>,
34    /// @note items.
35    pub notes: Vec<String>,
36    /// @warning items.
37    pub warnings: Vec<String>,
38    /// @author.
39    pub author: Option<String>,
40    /// @version.
41    pub version: Option<String>,
42    /// @exception / @throws descriptions.
43    pub exceptions: Vec<String>,
44    /// @pre precondition.
45    pub pre: Option<String>,
46    /// @post postcondition.
47    pub post: Option<String>,
48    /// Named sections from @par Title (and optional @code blocks). (title, content).
49    pub sections: Vec<(String, String)>,
50    /// Complexity code 1–13 from @complexity (e.g. in .sig files). See `crate::analysis::complexity_display_string`.
51    pub complexity: Option<u8>,
52    /// @class class name (for class documentation).
53    pub class_name: Option<String>,
54    /// @file source file name.
55    pub file: Option<String>,
56    /// @copyright notice.
57    pub copyright: Option<String>,
58    /// @license notice.
59    pub license: Option<String>,
60    /// @todo items.
61    pub todos: Vec<String>,
62    /// @invariant items.
63    pub invariants: Vec<String>,
64    /// @date date string.
65    pub date: Option<String>,
66}
67
68/// Strip comment markers and normalize line prefixes to get raw content.
69fn strip_comment_markers(raw: &str, is_block: bool) -> String {
70    let mut out = String::new();
71    let lines: Vec<&str> = raw.lines().collect();
72    for (i, line) in lines.iter().enumerate() {
73        let trimmed = line.trim();
74        if is_block {
75            // Block: strip /* from first line, */ from last, and leading * from each line
76            let content = if i == 0 {
77                trimmed.strip_prefix("/*").unwrap_or(trimmed).trim_start()
78            } else {
79                trimmed
80            };
81            let content = if i == lines.len() - 1 {
82                content.strip_suffix("*/").unwrap_or(content).trim_end()
83            } else {
84                content
85            };
86            let content = content
87                .strip_prefix('*')
88                .map_or(content, str::trim_start)
89                .trim();
90            if !content.is_empty() || !out.is_empty() {
91                if !out.is_empty() {
92                    out.push('\n');
93                }
94                out.push_str(content);
95            }
96        } else {
97            // Line comment: strip //, ///, //!
98            let content = trimmed
99                .trim_start_matches('/')
100                .trim_start_matches('!')
101                .trim_start_matches(' ')
102                .trim_start();
103            if !out.is_empty() {
104                out.push('\n');
105            }
106            out.push_str(content);
107        }
108    }
109    out
110}
111
112/// Parse a single raw comment string (e.g. `/// line` or `/** ... */`) into a `DocComment`.
113/// Used by .sig loader for Doxygen-style blocks. `is_block` is true for `/**` … `*/`.
114#[must_use]
115pub fn parse_comment_content(content: &str, is_block: bool) -> DocComment {
116    let normalized = strip_comment_markers(content, is_block);
117    parse_normalized_content(&normalized)
118}
119
120/// Strip @ or \ from start of tag for matching.
121fn tag_after<'a>(s: &'a str, prefix: &str) -> Option<&'a str> {
122    s.strip_prefix(prefix)
123        .or_else(|| s.strip_prefix(&prefix.replace('@', "\\")))
124}
125
126/// Parse normalized (marker-stripped) content into `DocComment`.
127fn parse_normalized_content(s: &str) -> DocComment {
128    let mut doc = DocComment::default();
129    let mut description_lines: Vec<&str> = Vec::new();
130    let mut in_description = true;
131
132    enum Section {
133        None,
134        Details,
135        Par { title: String, lines: Vec<String> },
136        Code { lines: Vec<String> },
137    }
138
139    let mut section = Section::None;
140
141    let flush_section = |section: &mut Section, doc: &mut DocComment| {
142        let old = std::mem::replace(section, Section::None);
143        match old {
144            Section::None | Section::Details => {}
145            Section::Par { title, lines } => {
146                let content = lines.join("\n").trim().to_string();
147                if !title.is_empty() || !content.is_empty() {
148                    doc.sections.push((title, content));
149                }
150            }
151            Section::Code { lines } => {
152                let content = lines.join("\n").trim().to_string();
153                if !content.is_empty() {
154                    doc.sections.push((String::new(), content));
155                }
156            }
157        }
158    };
159
160    let lines: Vec<&str> = s.lines().collect();
161    let mut i = 0;
162    while i < lines.len() {
163        let line = lines[i].trim();
164
165        if line.is_empty() {
166            if in_description && !description_lines.is_empty() {
167                in_description = false;
168            }
169            if let Section::Details = &mut section {
170                doc.details.get_or_insert_with(String::new).push('\n');
171            }
172            if let Section::Par {
173                lines: ref mut l, ..
174            } = &mut section
175            {
176                l.push(String::new());
177            }
178            if let Section::Code { lines: ref mut l } = &mut section {
179                l.push(String::new());
180            }
181            i += 1;
182            continue;
183        }
184
185        let (tag, after) = if let Some(after) = tag_after(line, "@brief") {
186            ("brief", after.trim())
187        } else if let Some(after) = tag_after(line, "@details") {
188            ("details", after.trim())
189        } else if let Some(after) = tag_after(line, "@param") {
190            ("param", after.trim())
191        } else if let Some(after) =
192            tag_after(line, "@return").or_else(|| tag_after(line, "@returns"))
193        {
194            ("return", after.trim())
195        } else if let Some(after) = tag_after(line, "@retval") {
196            ("retval", after.trim())
197        } else if let Some(after) = tag_after(line, "@deprecated") {
198            ("deprecated", after.trim())
199        } else if let Some(after) = tag_after(line, "@see").or_else(|| tag_after(line, "@sa")) {
200            ("see", after.trim())
201        } else if let Some(after) = tag_after(line, "@since") {
202            ("since", after.trim())
203        } else if let Some(after) = tag_after(line, "@note") {
204            ("note", after.trim())
205        } else if let Some(after) = tag_after(line, "@warning") {
206            ("warning", after.trim())
207        } else if let Some(after) = tag_after(line, "@author") {
208            ("author", after.trim())
209        } else if let Some(after) = tag_after(line, "@version") {
210            ("version", after.trim())
211        } else if let Some(after) =
212            tag_after(line, "@exception").or_else(|| tag_after(line, "@throws"))
213        {
214            ("exception", after.trim())
215        } else if let Some(after) = tag_after(line, "@pre") {
216            ("pre", after.trim())
217        } else if let Some(after) = tag_after(line, "@post") {
218            ("post", after.trim())
219        } else if let Some(after) = tag_after(line, "@par") {
220            ("par", after.trim())
221        } else if tag_after(line, "@code").is_some() {
222            ("code", "")
223        } else if tag_after(line, "@endcode").is_some() {
224            ("endcode", "")
225        } else if let Some(after) = tag_after(line, "@complexity") {
226            ("complexity", after.trim())
227        } else if let Some(after) = tag_after(line, "@class") {
228            ("class", after.trim())
229        } else if let Some(after) = tag_after(line, "@file") {
230            ("file", after.trim())
231        } else if let Some(after) = tag_after(line, "@copyright") {
232            ("copyright", after.trim())
233        } else if let Some(after) = tag_after(line, "@license") {
234            ("license", after.trim())
235        } else if let Some(after) = tag_after(line, "@todo") {
236            ("todo", after.trim())
237        } else if let Some(after) = tag_after(line, "@invariant") {
238            ("invariant", after.trim())
239        } else if let Some(after) = tag_after(line, "@date") {
240            ("date", after.trim())
241        } else {
242            match &mut section {
243                Section::None => {
244                    if in_description {
245                        description_lines.push(line);
246                    }
247                }
248                Section::Details => {
249                    let d = doc.details.get_or_insert_with(String::new);
250                    if !d.is_empty() {
251                        d.push('\n');
252                    }
253                    d.push_str(line);
254                }
255                Section::Par { lines: l, .. } | Section::Code { lines: l } => {
256                    l.push(line.to_string());
257                }
258            }
259            i += 1;
260            continue;
261        };
262
263        in_description = false;
264
265        match tag {
266            "details" => {
267                flush_section(&mut section, &mut doc);
268                if after.is_empty() {
269                    doc.details = Some(String::new());
270                    section = Section::Details;
271                } else {
272                    doc.details = Some(after.to_string());
273                }
274            }
275            "par" => {
276                flush_section(&mut section, &mut doc);
277                let title = after.strip_suffix(':').unwrap_or(after).trim().to_string();
278                section = Section::Par {
279                    title,
280                    lines: Vec::new(),
281                };
282            }
283            "code" => {
284                flush_section(&mut section, &mut doc);
285                section = Section::Code { lines: Vec::new() };
286            }
287            "endcode" => {
288                flush_section(&mut section, &mut doc);
289                section = Section::None;
290            }
291            "brief" => {
292                flush_section(&mut section, &mut doc);
293                doc.brief = Some(after.to_string());
294            }
295            "return" => {
296                flush_section(&mut section, &mut doc);
297                doc.returns = Some(after.to_string());
298            }
299            "retval" => doc.retvals.push(after.to_string()),
300            "deprecated" => {
301                flush_section(&mut section, &mut doc);
302                doc.deprecated = Some(after.to_string());
303            }
304            "since" => doc.since = Some(after.to_string()),
305            "note" => doc.notes.push(after.to_string()),
306            "warning" => doc.warnings.push(after.to_string()),
307            "author" => doc.author = Some(after.to_string()),
308            "version" => doc.version = Some(after.to_string()),
309            "exception" => doc.exceptions.push(after.to_string()),
310            "pre" => doc.pre = Some(after.to_string()),
311            "post" => doc.post = Some(after.to_string()),
312            "param" => {
313                flush_section(&mut section, &mut doc);
314                let mut it = after.splitn(2, char::is_whitespace);
315                let name = it.next().unwrap_or("").trim().to_string();
316                let desc = it.next().unwrap_or("").trim().to_string();
317                if !name.is_empty() {
318                    doc.params.push((name, desc));
319                }
320            }
321            "see" => doc.see.push(after.to_string()),
322            "complexity" => {
323                flush_section(&mut section, &mut doc);
324                if let Ok(n) = after
325                    .split_ascii_whitespace()
326                    .next()
327                    .unwrap_or("")
328                    .parse::<u8>()
329                {
330                    if (1..=13).contains(&n) {
331                        doc.complexity = Some(n);
332                    }
333                }
334            }
335            "class" => doc.class_name = Some(after.to_string()),
336            "file" => doc.file = Some(after.to_string()),
337            "copyright" => doc.copyright = Some(after.to_string()),
338            "license" => doc.license = Some(after.to_string()),
339            "todo" => doc.todos.push(after.to_string()),
340            "invariant" => doc.invariants.push(after.to_string()),
341            "date" => doc.date = Some(after.to_string()),
342            _ => {}
343        }
344        i += 1;
345    }
346
347    flush_section(&mut section, &mut doc);
348
349    doc.description = description_lines.join("\n").trim().to_string();
350    if doc.brief.is_none() && !doc.description.is_empty() {
351        let first_para = doc
352            .description
353            .split("\n\n")
354            .next()
355            .unwrap_or(&doc.description);
356        doc.brief = Some(first_para.replace('\n', " ").trim().to_string());
357    }
358    if let Some(ref mut d) = doc.details {
359        *d = d.trim().to_string();
360    }
361    doc
362}
363
364/// Parse one or more raw comment strings (e.g. from multiple preceding trivia tokens).
365/// If the combined text contains a block comment (`/** ... */`), only that block is used
366/// so that a preceding line comment (e.g. `// Comment`) does not corrupt the doc block.
367#[must_use]
368pub fn parse_doc_comment(parts: &[String]) -> Option<DocComment> {
369    if parts.is_empty() {
370        return None;
371    }
372    let combined = parts.join("\n");
373    let trimmed = combined.trim();
374    if trimmed.is_empty() {
375        return None;
376    }
377    // If there's a block comment in the combined string, use only from /** onward.
378    // This avoids treating "// Comment\n/**\n * @brief ... */" as line comments.
379    let content = if let Some(block_start) = trimmed.find("/**") {
380        trimmed[block_start..].trim()
381    } else {
382        trimmed
383    };
384    let is_block = content.starts_with("/*");
385    let doc = parse_comment_content(content, is_block);
386    if doc.brief.is_none()
387        && doc.description.is_empty()
388        && doc.details.is_none()
389        && doc.params.is_empty()
390        && doc.returns.is_none()
391        && doc.retvals.is_empty()
392        && doc.deprecated.is_none()
393        && doc.see.is_empty()
394        && doc.since.is_none()
395        && doc.notes.is_empty()
396        && doc.warnings.is_empty()
397        && doc.author.is_none()
398        && doc.version.is_none()
399        && doc.exceptions.is_empty()
400        && doc.pre.is_none()
401        && doc.post.is_none()
402        && doc.sections.is_empty()
403        && doc.complexity.is_none()
404        && doc.class_name.is_none()
405        && doc.file.is_none()
406        && doc.copyright.is_none()
407        && doc.license.is_none()
408        && doc.todos.is_empty()
409        && doc.invariants.is_empty()
410        && doc.date.is_none()
411    {
412        return None;
413    }
414    Some(doc)
415}
416
417/// Declaration node kinds we attach doc comments to.
418const DOC_DECL_KINDS: [Kind; 6] = [
419    Kind::NodeClassDecl,
420    Kind::NodeFunctionDecl,
421    Kind::NodeVarDecl,
422    Kind::NodeConstructorDecl,
423    Kind::NodeClassField,
424    Kind::NodeInclude,
425];
426
427fn is_comment_trivia(kind: Kind) -> bool {
428    kind == Kind::TriviaLineComment || kind == Kind::TriviaBlockComment
429}
430
431/// Collect contiguous comment trivia tokens that immediately precede `node`.
432/// Uses the node's own [`leading_trivia`](sipha::red::SyntaxNode::leading_trivia) when the tree
433/// builder has attached preceding trivia to the node (see sipha green tree). Otherwise walks up
434/// to an ancestor that has preceding sibling comment tokens.
435fn preceding_comment_tokens(node: &SyntaxNode, root: &SyntaxNode) -> Option<Vec<SyntaxToken>> {
436    let leading = node.leading_trivia();
437    let comments: Vec<SyntaxToken> = leading
438        .into_iter()
439        .filter(|t| Kind::from_syntax_kind(t.kind()).is_some_and(is_comment_trivia))
440        .collect();
441    if !comments.is_empty() {
442        return Some(comments);
443    }
444    let mut current = node.clone();
445    loop {
446        let parent = current.ancestors(root).into_iter().next()?;
447        let children: Vec<SyntaxElement> = parent.children().collect();
448        let pos = children.iter().position(|e| {
449            e.as_node()
450                .is_some_and(|n| n.offset() == current.offset() && n.kind() == current.kind())
451        })?;
452        let mut comments = Vec::new();
453        for i in (0..pos).rev() {
454            let el = &children[i];
455            if let Some(tok) = el.as_token() {
456                if let Some(k) = Kind::from_syntax_kind(tok.kind()) {
457                    if is_comment_trivia(k) {
458                        comments.push(tok.clone());
459                    } else if k != Kind::TriviaWs {
460                        break;
461                    }
462                } else {
463                    break;
464                }
465            } else {
466                break;
467            }
468        }
469        comments.reverse();
470        if !comments.is_empty() {
471            return Some(comments);
472        }
473        if parent.offset() == root.offset() && parent.kind() == root.kind() {
474            return None;
475        }
476        current = parent;
477    }
478}
479
480/// Build a map from declaration (`start_byte`, `end_byte`) to parsed documentation.
481#[must_use]
482pub fn build_doc_map(root: &SyntaxNode) -> HashMap<(u32, u32), DocComment> {
483    let mut map = HashMap::new();
484    for kind in DOC_DECL_KINDS {
485        for node in root.find_all_nodes(kind.into_syntax_kind()) {
486            let Some(tokens) = preceding_comment_tokens(&node, root) else {
487                continue;
488            };
489            if tokens.is_empty() {
490                continue;
491            }
492            let parts: Vec<String> = tokens.iter().map(|t| t.text().to_string()).collect();
493            if let Some(doc_comment) = parse_doc_comment(&parts) {
494                let span = node.text_range();
495                map.insert((span.start, span.end), doc_comment);
496            }
497        }
498    }
499    map
500}
501
502#[cfg(test)]
503mod tests {
504    use sipha::types::IntoSyntaxKind;
505
506    use crate::parse;
507    use crate::syntax::Kind;
508
509    use super::{
510        build_doc_map, parse_comment_content, parse_doc_comment, preceding_comment_tokens,
511        DocComment,
512    };
513
514    /// Asserts that a Doxygen block comment above a class is attached to that class in the doc map.
515    #[test]
516    fn test_doc_comment_attached_to_class() {
517        let source = r#"
518/**
519 * @brief Represents a position or object in the game world by cell ID and coordinates.
520 *
521 * The Cell class allows you to create a cell either using a unique ID or X/Y coordinates.
522 */
523class Cell {
524    integer id;
525}
526"#;
527        let root = parse(source).ok().flatten().expect("parse should succeed");
528        let doc_map = build_doc_map(&root);
529
530        let class_nodes: Vec<_> = root.find_all_nodes(Kind::NodeClassDecl.into_syntax_kind());
531        let class_node = class_nodes
532            .into_iter()
533            .next()
534            .expect("there should be one class decl");
535        let span = class_node.text_range();
536        let key = (span.start, span.end);
537
538        let doc = doc_map
539            .get(&key)
540            .expect("doc_map should contain an entry for the class declaration span");
541        assert_eq!(
542            doc.brief.as_deref(),
543            Some("Represents a position or object in the game world by cell ID and coordinates."),
544            "Doxygen @brief should be attached to the class"
545        );
546        assert!(
547            doc.brief.is_some() || !doc.description.is_empty(),
548            "doc should have brief or description"
549        );
550    }
551
552    #[test]
553    fn test_parse_block_brief_param_return() {
554        let s = r#"
555 * Brief line.
556 *
557 * More description here.
558 * @param x The first argument.
559 * @param y The second.
560 * @return The result.
561"#;
562        let doc: DocComment = parse_comment_content(&format!("/*{}*/", s.trim()), true);
563        assert_eq!(doc.brief.as_deref(), Some("Brief line."));
564        assert!(doc.description.contains("Brief line."));
565        assert_eq!(doc.params.len(), 2);
566        assert_eq!(doc.params[0].0, "x");
567        assert_eq!(doc.params[0].1, "The first argument.");
568        assert_eq!(doc.returns.as_deref(), Some("The result."));
569    }
570
571    #[test]
572    fn test_parse_line_comment() {
573        let s = "/// Brief.\n/// @param a desc";
574        let doc = parse_comment_content(s, false);
575        assert_eq!(doc.brief.as_deref(), Some("Brief."));
576        assert_eq!(doc.params.len(), 1);
577        assert_eq!(doc.params[0].0, "a");
578    }
579
580    /// Asserts that a Doxygen block comment immediately before a top-level function
581    /// is attached to that function in the doc map.
582    #[test]
583    fn test_doc_comment_attached_to_function() {
584        let source = r#"
585/**
586 * @brief Computes the sum of two numbers.
587 * @param a First operand.
588 * @param b Second operand.
589 * @return The sum.
590 */
591function add(a, b) -> integer {
592    return a + b;
593}
594"#;
595        let root = parse(source).ok().flatten().expect("parse should succeed");
596        let doc_map = build_doc_map(&root);
597
598        let func_nodes: Vec<_> = root.find_all_nodes(Kind::NodeFunctionDecl.into_syntax_kind());
599        let func_node = func_nodes
600            .into_iter()
601            .next()
602            .expect("there should be one function decl");
603        let span = func_node.text_range();
604        let key = (span.start, span.end);
605
606        let doc = doc_map
607            .get(&key)
608            .expect("doc_map should contain an entry for the function declaration span; ensure preceding comments are attached");
609        assert_eq!(
610            doc.brief.as_deref(),
611            Some("Computes the sum of two numbers."),
612            "Doxygen @brief should be attached to the function"
613        );
614        assert_eq!(doc.params.len(), 2, "expected @param a and @param b");
615        assert_eq!(doc.params[0].0, "a");
616        assert_eq!(doc.params[1].0, "b");
617        assert_eq!(doc.returns.as_deref(), Some("The sum."));
618    }
619
620    // --- Trivia attachment tests ---
621
622    /// When a declaration has no preceding comment, it has no doc and `preceding_comment_tokens` returns None.
623    #[test]
624    fn test_trivia_not_attached_when_no_comment() {
625        let source = r#"
626function no_doc() {
627    return 0;
628}
629"#;
630        let root = parse(source).ok().flatten().expect("parse should succeed");
631        let doc_map = build_doc_map(&root);
632
633        let func_nodes: Vec<_> = root.find_all_nodes(Kind::NodeFunctionDecl.into_syntax_kind());
634        let func_node = func_nodes.into_iter().next().expect("one function decl");
635        let span = func_node.text_range();
636        let key = (span.start, span.end);
637
638        assert!(
639            doc_map.get(&key).is_none(),
640            "decl with no preceding comment should not be in doc_map"
641        );
642        let tokens = preceding_comment_tokens(&func_node, &root);
643        assert!(
644            tokens.is_none() || tokens.as_ref().map(|t| t.is_empty()).unwrap_or(false),
645            "preceding_comment_tokens should return None or empty for decl with no comment"
646        );
647    }
648
649    /// Comment is attached only to the immediately following declaration; the next decl has no doc.
650    #[test]
651    fn test_trivia_attached_only_to_immediately_following_decl() {
652        let source = r#"
653/**
654 * @brief Only for first.
655 */
656function first() { return 1; }
657
658function second() { return 2; }
659"#;
660        let root = parse(source).ok().flatten().expect("parse should succeed");
661        let doc_map = build_doc_map(&root);
662
663        let func_nodes: Vec<_> = root.find_all_nodes(Kind::NodeFunctionDecl.into_syntax_kind());
664        let (first, second) = {
665            let mut it = func_nodes.into_iter();
666            let a = it.next().expect("first");
667            let b = it.next().expect("second");
668            (a, b)
669        };
670
671        let key_first = (first.text_range().start, first.text_range().end);
672        let key_second = (second.text_range().start, second.text_range().end);
673
674        let doc_first = doc_map
675            .get(&key_first)
676            .expect("first function should have doc");
677        assert_eq!(doc_first.brief.as_deref(), Some("Only for first."));
678
679        assert!(
680            doc_map.get(&key_second).is_none(),
681            "second function should not get the comment; trivia attached only to immediately following decl"
682        );
683        let tokens_second = preceding_comment_tokens(&second, &root);
684        assert!(
685            tokens_second.is_none() || tokens_second.as_ref().map(|t| t.is_empty()).unwrap_or(true),
686            "preceding_comment_tokens(second) should be None or empty"
687        );
688    }
689
690    /// Parser attaches preceding comment to the node (leading trivia or preceding sibling); we find it via preceding_comment_tokens.
691    #[test]
692    fn test_trivia_leading_comment_found_for_decl() {
693        let source = r#"
694/// Doc for foo.
695function foo() { return 0; }
696"#;
697        let root = parse(source).ok().flatten().expect("parse should succeed");
698        let func_nodes: Vec<_> = root.find_all_nodes(Kind::NodeFunctionDecl.into_syntax_kind());
699        let func_node = func_nodes.into_iter().next().expect("one function decl");
700
701        let tokens = preceding_comment_tokens(&func_node, &root)
702            .expect("should find preceding comment tokens");
703        assert!(!tokens.is_empty(), "should have at least one comment token");
704        let text: String = tokens.iter().map(|t| t.text().to_string()).collect();
705        assert!(
706            text.contains("Doc for foo"),
707            "trivia attached to decl should contain the comment text; got: {:?}",
708            text
709        );
710
711        let doc_map = build_doc_map(&root);
712        let span = func_node.text_range();
713        let key = (span.start, span.end);
714        let doc = doc_map
715            .get(&key)
716            .expect("doc_map should have entry for foo");
717        assert_eq!(doc.brief.as_deref(), Some("Doc for foo."));
718    }
719
720    /// When a line comment (e.g. "// Comment") precedes a block doc comment, only the block is parsed
721    /// so hover/docs show structured content, not raw comment text.
722    #[test]
723    fn test_line_comment_before_block_comment_uses_block_only() {
724        let parts = [
725            "// Comment".to_string(),
726            "/**\n * @class Obstacle\n * @brief Represents an obstacle.\n * @see Cell\n */"
727                .to_string(),
728        ];
729        let doc = parse_doc_comment(&parts).expect("should parse block doc");
730        assert_eq!(doc.class_name.as_deref(), Some("Obstacle"));
731        assert_eq!(doc.brief.as_deref(), Some("Represents an obstacle."));
732        assert_eq!(doc.see.len(), 1);
733        assert_eq!(doc.see[0], "Cell");
734        assert!(
735            !doc.description.to_lowercase().contains("comment")
736                || doc.brief.as_deref() == Some("Represents an obstacle."),
737            "description should not be raw 'Comment' from the line comment"
738        );
739    }
740
741    /// Multiple consecutive line comments are all collected and merged into one doc for the following decl.
742    #[test]
743    fn test_trivia_multiple_line_comments_attached_to_same_decl() {
744        let source = r#"
745/// First line.
746/// Second line.
747/// @param x desc
748function f(x) { return x; }
749"#;
750        let root = parse(source).ok().flatten().expect("parse should succeed");
751        let func_nodes: Vec<_> = root.find_all_nodes(Kind::NodeFunctionDecl.into_syntax_kind());
752        let func_node = func_nodes.into_iter().next().expect("one function decl");
753
754        let tokens = preceding_comment_tokens(&func_node, &root)
755            .expect("should find preceding comment tokens");
756        assert_eq!(tokens.len(), 3, "should have three /// comment tokens");
757
758        let doc_map = build_doc_map(&root);
759        let span = func_node.text_range();
760        let key = (span.start, span.end);
761        let doc = doc_map.get(&key).expect("doc_map should have entry for f");
762        assert!(doc
763            .brief
764            .as_deref()
765            .map(|b| b.contains("First line"))
766            .unwrap_or(false));
767        assert!(doc.description.contains("Second line."));
768        assert_eq!(doc.params.len(), 1);
769        assert_eq!(doc.params[0].0, "x");
770        assert_eq!(doc.params[0].1, "desc");
771    }
772}