Skip to main content

ailint_core/parser/
source_comments.rs

1//! Synthesize a [`MarkdownDoc`] from source-code comments so prose-oriented
2//! rules (bloat, vague instruction, negative overload) can run against them
3//! without change.
4//!
5//! Each extracted comment becomes one `Paragraph`. Byte ranges and line
6//! numbers refer to the original source file so reporters point at the real
7//! line the offending comment lives on.
8
9use ailint_extractor::{extract, Comment, CommentKind, Language};
10
11use crate::parser::markdown::{ListItem, MarkdownDoc, Paragraph};
12
13/// Extract comments from `source` for `language` and pack them into a
14/// [`MarkdownDoc`] whose `paragraphs` list one comment each. Each comment
15/// is also mirrored into `list_items` so rules that currently scan bullets
16/// (AIL100 vague-instruction, AIL104 negative-constraint-overload) fire on
17/// source-code comments without change.
18pub fn synthesize(source: &str, language: Language) -> MarkdownDoc {
19    let mut doc = MarkdownDoc::default();
20    for c in extract(source, language) {
21        let byte_range = c.byte_range.clone();
22        let line = c.line;
23        let Some(text) = comment_prose(c) else {
24            continue;
25        };
26        doc.paragraphs.push(Paragraph {
27            text: text.clone(),
28            byte_range: byte_range.clone(),
29            line,
30        });
31        doc.list_items.push(ListItem {
32            text,
33            byte_range,
34            line,
35        });
36    }
37    doc
38}
39
40fn comment_prose(c: Comment) -> Option<String> {
41    let text = normalize_body(c.body(), c.kind);
42    if text.is_empty() {
43        None
44    } else {
45        Some(text)
46    }
47}
48
49/// Reduce a comment body to a single line of prose for rule inspection:
50/// - Line and doc comments -> raw body trimmed.
51/// - Block, doc-block, and Python docstrings -> collapse internal newlines
52///   and strip the leading `*` gutter common in JSDoc / Rustdoc block
53///   comments so each `*` line does not become a false-positive "paragraph
54///   break" once we join.
55fn normalize_body(body: &str, kind: CommentKind) -> String {
56    match kind {
57        CommentKind::Line => body.trim().to_string(),
58        CommentKind::Doc | CommentKind::Block | CommentKind::Docstring => body
59            .lines()
60            .map(strip_star_gutter)
61            .map(str::trim)
62            .filter(|s| !s.is_empty())
63            .collect::<Vec<_>>()
64            .join(" "),
65    }
66}
67
68fn strip_star_gutter(line: &str) -> &str {
69    let trimmed = line.trim_start();
70    trimmed
71        .strip_prefix("* ")
72        .unwrap_or_else(|| if trimmed == "*" { "" } else { line })
73}
74
75#[cfg(test)]
76mod tests {
77    use super::*;
78
79    #[test]
80    fn line_comments_become_paragraphs() {
81        let doc = synthesize("// hello\n// world\n", Language::Rust);
82        assert_eq!(doc.paragraphs.len(), 2);
83        assert_eq!(doc.list_items.len(), 2);
84        assert_eq!(doc.paragraphs[0].text, "hello");
85        assert_eq!(doc.paragraphs[0].line, 1);
86        assert_eq!(doc.paragraphs[1].text, "world");
87        assert_eq!(doc.paragraphs[1].line, 2);
88        assert_eq!(doc.list_items[0].text, "hello");
89    }
90
91    #[test]
92    fn jsdoc_block_gutter_stripped_and_joined() {
93        let src = "\
94/**
95 * First line.
96 * Second line.
97 */
98const x = 1;
99";
100        let doc = synthesize(src, Language::TypeScript);
101        assert_eq!(doc.paragraphs.len(), 1);
102        assert_eq!(doc.paragraphs[0].text, "First line. Second line.");
103        assert_eq!(doc.paragraphs[0].line, 1);
104    }
105
106    #[test]
107    fn python_docstring_folded_to_single_paragraph() {
108        let src = "\
109def f():
110    \"\"\"
111    First sentence.
112    Second sentence.
113    \"\"\"
114    return 1
115";
116        let doc = synthesize(src, Language::Python);
117        assert_eq!(doc.paragraphs.len(), 1);
118        assert_eq!(doc.paragraphs[0].text, "First sentence. Second sentence.");
119    }
120
121    #[test]
122    fn empty_comment_is_skipped() {
123        let doc = synthesize("//\n// real\n//    \n", Language::Rust);
124        assert_eq!(doc.paragraphs.len(), 1);
125        assert_eq!(doc.paragraphs[0].text, "real");
126    }
127}