1use std::collections::HashMap;
7
8use sipha::red::{SyntaxElement, SyntaxNode, SyntaxToken};
9use sipha::types::{FromSyntaxKind, IntoSyntaxKind};
10
11use crate::syntax::Kind;
12
13#[derive(Clone, Debug, Default)]
15pub struct DocComment {
16 pub brief: Option<String>,
18 pub description: String,
20 pub details: Option<String>,
22 pub params: Vec<(String, String)>,
24 pub returns: Option<String>,
26 pub retvals: Vec<String>,
28 pub deprecated: Option<String>,
30 pub see: Vec<String>,
32 pub since: Option<String>,
34 pub notes: Vec<String>,
36 pub warnings: Vec<String>,
38 pub author: Option<String>,
40 pub version: Option<String>,
42 pub exceptions: Vec<String>,
44 pub pre: Option<String>,
46 pub post: Option<String>,
48 pub sections: Vec<(String, String)>,
50 pub complexity: Option<u8>,
52 pub class_name: Option<String>,
54 pub file: Option<String>,
56 pub copyright: Option<String>,
58 pub license: Option<String>,
60 pub todos: Vec<String>,
62 pub invariants: Vec<String>,
64 pub date: Option<String>,
66}
67
68fn 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 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 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#[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
120fn 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
126fn 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#[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 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
417const 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
431fn 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#[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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}