Skip to main content

cargo_quality/analyzers/
inline_comments.rs

1// SPDX-FileCopyrightText: 2025 RAprogramm <andrey.rozanov.vl@gmail.com>
2// SPDX-License-Identifier: MIT
3
4//! Inline comments analyzer for detecting non-doc comments in function bodies.
5//!
6//! This analyzer identifies inline comments (`//`) within function and method
7//! bodies, which violate the documentation standards. All explanations should
8//! be in doc comments (`///`), specifically in the `# Notes` section.
9
10use std::collections::{BTreeMap, HashSet};
11
12use masterror::AppResult;
13use proc_macro2::Span;
14use syn::{
15    Attribute, Expr, ExprLit, File, ImplItem, ImplItemFn, ItemFn, ItemImpl, Lit, Meta,
16    spanned::Spanned, visit::Visit
17};
18
19use super::{
20    line_deletion_range, line_offsets,
21    visitor::{FunctionVisitor, ItemCheckers, SourceView}
22};
23use crate::analyzer::{AnalysisResult, Analyzer, Fix, Issue, Suggestion, TextEdit};
24
25/// Maximum width of generated doc comment lines, matching the format profile.
26const DOC_WIDTH: usize = 80;
27
28/// Analyzer for detecting inline comments inside functions and methods.
29///
30/// Finds non-doc comments within function bodies and suggests moving them
31/// to the function's doc block `# Notes` section with code context.
32///
33/// # Examples
34///
35/// Detects this pattern:
36/// ```ignore
37/// fn calculate() {
38///     let x = read_data();
39///     // Process the data
40///     let y = transform(x);
41/// }
42/// ```
43///
44/// Suggests adding to doc block:
45/// ```ignore
46/// /// Calculate something
47/// ///
48/// /// # Notes
49/// ///
50/// /// - Line 3: `let y = transform(x);` - Process the data
51/// fn calculate() {
52///     let x = read_data();
53///     let y = transform(x);
54/// }
55/// ```
56pub struct InlineCommentsAnalyzer;
57
58impl InlineCommentsAnalyzer {
59    /// Create new inline comments analyzer instance.
60    #[inline]
61    pub fn new() -> Self {
62        Self
63    }
64
65    /// Check function body for inline comments.
66    ///
67    /// Analyzes source code to find inline comments within function boundaries
68    /// and creates issues with suggestions to move them to doc blocks.
69    ///
70    /// # Arguments
71    ///
72    /// * `start_line` - First line of function body
73    /// * `end_line` - Last line of function body
74    /// * `lines` - Source code split into lines
75    ///
76    /// # Returns
77    ///
78    /// Vector of issues found
79    fn check_block(
80        start_line: usize,
81        end_line: usize,
82        lines: &[&str],
83        excluded: &HashSet<usize>
84    ) -> Vec<Issue> {
85        let mut issues = Vec::new();
86
87        if start_line >= end_line {
88            return issues;
89        }
90
91        for line_num in start_line..end_line {
92            if excluded.contains(&line_num) {
93                continue;
94            }
95
96            let idx = line_num.saturating_sub(1);
97
98            let Some(line) = lines.get(idx) else {
99                continue;
100            };
101
102            let trimmed = line.trim();
103
104            if trimmed.starts_with("//") && !trimmed.starts_with("///") {
105                let comment_text = trimmed.trim_start_matches("//").trim();
106
107                let code_line = Self::find_related_code_line(lines, idx);
108
109                let suggestion = if let Some((_code_idx, code)) = code_line {
110                    format!(
111                        "Move to doc block # Notes section:\n/// - {} - `{}`",
112                        comment_text,
113                        code.trim()
114                    )
115                } else {
116                    format!("Move to doc block # Notes section:\n/// - {}", comment_text)
117                };
118
119                issues.push(Issue::new(
120                    line_num,
121                    1,
122                    format!("Inline comment found: \"{}\"\n{}", comment_text, suggestion),
123                    Fix::Simple("Move comment to doc block # Notes section".to_string())
124                ));
125            }
126        }
127
128        issues
129    }
130
131    /// Find the code line that this comment describes.
132    ///
133    /// Looks for the next non-empty, non-comment line after the comment.
134    ///
135    /// # Arguments
136    ///
137    /// * `lines` - All source code lines
138    /// * `comment_idx` - Index of the comment line (0-based)
139    ///
140    /// # Returns
141    ///
142    /// Option with (line_index, line_content) of related code
143    fn find_related_code_line<'a>(
144        lines: &[&'a str],
145        comment_idx: usize
146    ) -> Option<(usize, &'a str)> {
147        for (offset, line) in lines.iter().enumerate().skip(comment_idx + 1) {
148            let trimmed = line.trim();
149
150            if trimmed.is_empty() || trimmed.starts_with("//") {
151                continue;
152            }
153
154            if !trimmed.starts_with('}') {
155                return Some((offset, line));
156            }
157        }
158
159        None
160    }
161
162    /// Check standalone function for inline comments.
163    ///
164    /// # Arguments
165    ///
166    /// * `func` - Function item to analyze
167    /// * `lines` - Source code split into lines
168    fn check_function(func: &ItemFn, lines: &[&str], excluded: &HashSet<usize>) -> Vec<Issue> {
169        let span = func.block.span();
170        let start_line = span.start().line;
171        let end_line = span.end().line;
172
173        Self::check_block(start_line, end_line, lines, excluded)
174    }
175
176    /// Check impl block methods for inline comments.
177    ///
178    /// # Arguments
179    ///
180    /// * `impl_block` - Impl block to analyze
181    /// * `lines` - Source code split into lines
182    fn check_impl_block(
183        impl_block: &ItemImpl,
184        lines: &[&str],
185        excluded: &HashSet<usize>
186    ) -> Vec<Issue> {
187        let mut issues = Vec::new();
188
189        for item in &impl_block.items {
190            if let ImplItem::Fn(method) = item {
191                let span = method.block.span();
192                let start_line = span.start().line;
193                let end_line = span.end().line;
194
195                issues.extend(Self::check_block(start_line, end_line, lines, excluded));
196            }
197        }
198
199        issues
200    }
201}
202
203/// Location facts about one function needed to relocate its comments.
204struct FnSite {
205    /// First line of the function body span
206    body_start: usize,
207    /// Last line of the function body span
208    body_end:   usize,
209    /// First line of the item, including attributes and doc comments
210    item_line:  usize,
211    /// Last line of the existing doc comment block, if any
212    doc_end:    Option<usize>,
213    /// Line of an existing `# Notes` heading inside the doc block, if any
214    notes_line: Option<usize>
215}
216
217/// Collects [`FnSite`] entries for every function and method in a file.
218struct FnSiteCollector {
219    /// Sites collected so far
220    sites: Vec<FnSite>
221}
222
223impl<'ast> Visit<'ast> for FnSiteCollector {
224    fn visit_item_fn(&mut self, node: &'ast ItemFn) {
225        self.sites
226            .push(fn_site(&node.attrs, node.sig.span(), node.block.span()));
227        syn::visit::visit_item_fn(self, node);
228    }
229
230    fn visit_impl_item_fn(&mut self, node: &'ast ImplItemFn) {
231        self.sites
232            .push(fn_site(&node.attrs, node.sig.span(), node.block.span()));
233        syn::visit::visit_impl_item_fn(self, node);
234    }
235}
236
237/// Builds the [`FnSite`] for one function from its attributes and spans.
238///
239/// # Arguments
240///
241/// * `attrs` - Attributes of the function, including doc comments
242/// * `sig_span` - Span of the function signature
243/// * `body_span` - Span of the function body block
244///
245/// # Returns
246///
247/// Site describing where the function's doc block fix must be placed
248fn fn_site(attrs: &[Attribute], sig_span: Span, body_span: Span) -> FnSite {
249    let sig_line = sig_span.start().line;
250    let mut item_line = sig_line;
251    let mut doc_end = None;
252    let mut notes_line = None;
253    for attr in attrs {
254        let span = attr.span();
255        item_line = item_line.min(span.start().line);
256        if let Some(text) = doc_attr_text(attr) {
257            let end = span.end().line;
258            doc_end = Some(doc_end.map_or(end, |current: usize| current.max(end)));
259            if text.trim() == "# Notes" {
260                notes_line = Some(span.start().line);
261            }
262        }
263    }
264    FnSite {
265        body_start: body_span.start().line,
266        body_end: body_span.end().line,
267        item_line,
268        doc_end,
269        notes_line
270    }
271}
272
273/// Extracts the text of an outer doc comment attribute.
274///
275/// # Arguments
276///
277/// * `attr` - Attribute to inspect
278///
279/// # Returns
280///
281/// The doc string, or `None` for non-doc attributes
282fn doc_attr_text(attr: &Attribute) -> Option<String> {
283    if !attr.path().is_ident("doc") {
284        return None;
285    }
286    if let Meta::NameValue(name_value) = &attr.meta
287        && let Expr::Lit(ExprLit {
288            lit: Lit::Str(value),
289            ..
290        }) = &name_value.value
291    {
292        return Some(value.value());
293    }
294    None
295}
296
297/// Checks whether a source line is an inline `//` comment.
298///
299/// Doc comments (`///`) and `////` separators are not inline comments.
300///
301/// # Arguments
302///
303/// * `line` - Source line to test
304///
305/// # Returns
306///
307/// `true` when the line holds a plain `//` comment
308fn is_inline_comment(line: &str) -> bool {
309    let trimmed = line.trim();
310    trimmed.starts_with("//") && !trimmed.starts_with("///")
311}
312
313/// Extracts the comment text of an inline comment line.
314///
315/// # Arguments
316///
317/// * `line` - Source line holding the comment
318///
319/// # Returns
320///
321/// The comment text without the `//` marker and surrounding whitespace
322fn comment_text(line: &str) -> &str {
323    line.trim().trim_start_matches("//").trim()
324}
325
326/// Assigns each inline comment line to its smallest enclosing function.
327///
328/// Nested functions overlap their parents' body spans, so every comment line
329/// is claimed exactly once by the tightest containing site.
330///
331/// # Arguments
332///
333/// * `sites` - Function sites collected from the file
334/// * `lines` - Source code split into lines
335/// * `excluded` - Line numbers inside multi-line literals to skip
336///
337/// # Returns
338///
339/// Map from site index to its sorted comment line numbers
340fn assign_comment_lines(
341    sites: &[FnSite],
342    lines: &[&str],
343    excluded: &HashSet<usize>
344) -> BTreeMap<usize, Vec<usize>> {
345    let mut grouped: BTreeMap<usize, Vec<usize>> = BTreeMap::new();
346    for line_num in 1..=lines.len() {
347        if excluded.contains(&line_num) {
348            continue;
349        }
350        let Some(line) = lines.get(line_num.saturating_sub(1)) else {
351            continue;
352        };
353        if !is_inline_comment(line) {
354            continue;
355        }
356        let owner = sites
357            .iter()
358            .enumerate()
359            .filter(|(_, site)| {
360                site.body_start < site.body_end
361                    && site.body_start <= line_num
362                    && line_num < site.body_end
363            })
364            .min_by_key(|(_, site)| site.body_end - site.body_start)
365            .map(|(index, _)| index);
366        if let Some(index) = owner {
367            grouped.entry(index).or_default().push(line_num);
368        }
369    }
370    grouped
371}
372
373/// Merges a function's comment lines into `# Notes` paragraphs.
374///
375/// Consecutive comment lines join into one paragraph; a gap in line numbers or
376/// an empty `//` line closes the current paragraph.
377///
378/// # Arguments
379///
380/// * `comment_lines` - Sorted comment line numbers of one function
381/// * `lines` - Source code split into lines
382///
383/// # Returns
384///
385/// Paragraph texts in source order, empty paragraphs dropped
386fn comment_paragraphs(comment_lines: &[usize], lines: &[&str]) -> Vec<String> {
387    let mut paragraphs = Vec::new();
388    let mut current = String::new();
389    let mut prev: Option<usize> = None;
390    for &line_num in comment_lines {
391        let text = lines
392            .get(line_num.saturating_sub(1))
393            .map_or("", |line| comment_text(line));
394        let adjacent = prev.is_some_and(|previous| line_num == previous + 1);
395        if (!adjacent || text.is_empty()) && !current.is_empty() {
396            paragraphs.push(std::mem::take(&mut current));
397        }
398        if !text.is_empty() {
399            if !current.is_empty() {
400                current.push(' ');
401            }
402            current.push_str(text);
403        }
404        prev = Some(line_num);
405    }
406    if !current.is_empty() {
407        paragraphs.push(current);
408    }
409    paragraphs
410}
411
412/// Returns the leading whitespace of a source line.
413///
414/// # Arguments
415///
416/// * `lines` - Source code split into lines
417/// * `line_num` - 1-based line number
418///
419/// # Returns
420///
421/// The line's indentation string
422fn indent_of(lines: &[&str], line_num: usize) -> String {
423    lines
424        .get(line_num.saturating_sub(1))
425        .map_or(String::new(), |line| {
426            line[..line.len() - line.trim_start().len()].to_string()
427        })
428}
429
430/// Finds the last content line of an existing `# Notes` section.
431///
432/// Scans doc lines after the heading until the doc block ends or the next
433/// heading starts.
434///
435/// # Arguments
436///
437/// * `lines` - Source code split into lines
438/// * `heading` - Line number of the `# Notes` heading
439///
440/// # Returns
441///
442/// Line number of the last non-blank doc line in the section, if any
443fn notes_section_last_content(lines: &[&str], heading: usize) -> Option<usize> {
444    let mut last = None;
445    let mut line_num = heading + 1;
446    while let Some(line) = lines.get(line_num.saturating_sub(1)) {
447        let trimmed = line.trim();
448        if !trimmed.starts_with("///") || trimmed.starts_with("/// #") {
449            break;
450        }
451        if trimmed != "///" {
452            last = Some(line_num);
453        }
454        line_num += 1;
455    }
456    last
457}
458
459/// Renders paragraphs as wrapped `/// - ` doc bullets.
460///
461/// Lines wrap at [`DOC_WIDTH`] columns; continuation lines align under the
462/// bullet text.
463///
464/// # Arguments
465///
466/// * `indent` - Indentation of the target doc block
467/// * `paragraphs` - Paragraph texts to render
468///
469/// # Returns
470///
471/// Rendered bullet lines, each terminated by a newline
472fn render_bullets(indent: &str, paragraphs: &[String]) -> String {
473    let mut output = String::new();
474    let continuation = format!("{}///   ", indent);
475    for paragraph in paragraphs {
476        let mut line = format!("{}/// - ", indent);
477        let mut has_words = false;
478        for word in paragraph.split_whitespace() {
479            if has_words && line.len() + 1 + word.len() > DOC_WIDTH {
480                output.push_str(&line);
481                output.push('\n');
482                line = continuation.clone();
483                has_words = false;
484            }
485            if has_words {
486                line.push(' ');
487            }
488            line.push_str(word);
489            has_words = true;
490        }
491        output.push_str(&line);
492        output.push('\n');
493    }
494    output
495}
496
497/// Computes the insertion point and text of a function's `# Notes` fix.
498///
499/// Appends to an existing `# Notes` section, extends an existing doc block
500/// with a new section, or starts a fresh doc block above the item.
501///
502/// # Arguments
503///
504/// * `site` - Function site to fix
505/// * `lines` - Source code split into lines
506/// * `paragraphs` - Paragraph texts to insert
507///
508/// # Returns
509///
510/// Insertion line number and the block to insert there
511fn insertion_block(site: &FnSite, lines: &[&str], paragraphs: &[String]) -> (usize, String) {
512    if let Some(heading) = site.notes_line {
513        let indent = indent_of(lines, heading);
514        return match notes_section_last_content(lines, heading) {
515            Some(last_content) => (last_content + 1, render_bullets(&indent, paragraphs)),
516            None => (
517                heading + 1,
518                format!("{}///\n{}", indent, render_bullets(&indent, paragraphs))
519            )
520        };
521    }
522    if let Some(doc_end) = site.doc_end {
523        let indent = indent_of(lines, doc_end);
524        return (
525            doc_end + 1,
526            format!(
527                "{indent}///\n{indent}/// # Notes\n{indent}///\n{}",
528                render_bullets(&indent, paragraphs)
529            )
530        );
531    }
532    let indent = indent_of(lines, site.item_line);
533    (
534        site.item_line,
535        format!(
536            "{indent}/// # Notes\n{indent}///\n{}",
537            render_bullets(&indent, paragraphs)
538        )
539    )
540}
541
542impl Analyzer for InlineCommentsAnalyzer {
543    fn name(&self) -> &'static str {
544        "inline_comments"
545    }
546
547    fn analyze(&self, ast: &File, content: &str) -> AppResult<AnalysisResult> {
548        let lines: Vec<&str> = content.lines().collect();
549        let excluded = crate::analyzers::multiline_literal_lines(ast);
550        let mut visitor = FunctionVisitor {
551            issues:   Vec::new(),
552            source:   SourceView {
553                lines:    &lines,
554                excluded: &excluded
555            },
556            checkers: ItemCheckers {
557                function:   Self::check_function,
558                impl_block: Self::check_impl_block
559            }
560        };
561        visitor.visit_file(ast);
562        let fixable_count = visitor.issues.len();
563
564        Ok(AnalysisResult {
565            issues: visitor.issues,
566            fixable_count
567        })
568    }
569
570    fn suggestions(&self, ast: &File, content: &str) -> AppResult<Vec<Suggestion>> {
571        let lines: Vec<&str> = content.lines().collect();
572        let excluded = crate::analyzers::multiline_literal_lines(ast);
573        let mut collector = FnSiteCollector {
574            sites: Vec::new()
575        };
576        collector.visit_file(ast);
577        let offsets = line_offsets(content);
578        let grouped = assign_comment_lines(&collector.sites, &lines, &excluded);
579        let mut suggestions = Vec::new();
580        for (site_index, comment_lines) in &grouped {
581            let Some(site) = collector.sites.get(*site_index) else {
582                continue;
583            };
584            for &line in comment_lines {
585                let Some(range) = line_deletion_range(&offsets, content.len(), line) else {
586                    continue;
587                };
588                suggestions.push(Suggestion {
589                    edit:   TextEdit {
590                        range,
591                        replacement: String::new()
592                    },
593                    import: None
594                });
595            }
596            let paragraphs = comment_paragraphs(comment_lines, &lines);
597            if paragraphs.is_empty() {
598                continue;
599            }
600            let (insert_line, block) = insertion_block(site, &lines, &paragraphs);
601            let offset = offsets
602                .get(insert_line.saturating_sub(1))
603                .copied()
604                .unwrap_or(content.len());
605            suggestions.push(Suggestion {
606                edit:   TextEdit {
607                    range:       offset..offset,
608                    replacement: block
609                },
610                import: None
611            });
612        }
613        Ok(suggestions)
614    }
615}
616
617impl Default for InlineCommentsAnalyzer {
618    fn default() -> Self {
619        Self::new()
620    }
621}
622
623#[cfg(test)]
624mod tests {
625    use super::*;
626
627    #[test]
628    fn test_analyzer_name() {
629        let analyzer = InlineCommentsAnalyzer::new();
630        assert_eq!(analyzer.name(), "inline_comments");
631    }
632
633    #[test]
634    fn test_ignore_double_slash_inside_string_literal() {
635        let analyzer = InlineCommentsAnalyzer::new();
636        let content =
637            "fn f() {\n    let s = \"first\n// not a comment\nlast\";\n    let _ = s;\n}";
638        let code = syn::parse_str(content).unwrap();
639
640        let result = analyzer.analyze(&code, content).unwrap();
641        assert_eq!(result.issues.len(), 0);
642    }
643
644    #[test]
645    fn test_detect_inline_comment_in_function() {
646        let analyzer = InlineCommentsAnalyzer::new();
647        let content = r#"fn main() {
648    let x = 1;
649    // This is a comment
650    let y = 2;
651}"#;
652        let code = syn::parse_str(content).unwrap();
653
654        let result = analyzer.analyze(&code, content).unwrap();
655        assert_eq!(result.issues.len(), 1);
656        assert!(
657            result.issues[0]
658                .diagnostic
659                .message
660                .contains("This is a comment")
661        );
662    }
663
664    #[test]
665    fn test_ignore_doc_comments() {
666        let analyzer = InlineCommentsAnalyzer::new();
667        let content = r#"fn main() {
668    let x = 1;
669    /// This is a doc comment
670    let y = 2;
671}"#;
672        let code = syn::parse_str(content).unwrap();
673
674        let result = analyzer.analyze(&code, content).unwrap();
675        assert_eq!(result.issues.len(), 0);
676    }
677
678    #[test]
679    fn test_ignore_function_without_comments() {
680        let analyzer = InlineCommentsAnalyzer::new();
681        let content = r#"fn main() {
682    let x = 1;
683    let y = 2;
684}"#;
685        let code = syn::parse_str(content).unwrap();
686
687        let result = analyzer.analyze(&code, content).unwrap();
688        assert_eq!(result.issues.len(), 0);
689    }
690
691    #[test]
692    fn test_detect_multiple_comments() {
693        let analyzer = InlineCommentsAnalyzer::new();
694        let content = r#"fn process() {
695    // Read data
696    let x = read();
697    // Transform
698    let y = transform(x);
699    // Write result
700    write(y);
701}"#;
702        let code = syn::parse_str(content).unwrap();
703
704        let result = analyzer.analyze(&code, content).unwrap();
705        assert_eq!(result.issues.len(), 3);
706    }
707
708    #[test]
709    fn test_comment_with_code_context() {
710        let analyzer = InlineCommentsAnalyzer::new();
711        let content = r#"fn main() {
712    // Calculate sum
713    let sum = a + b;
714}"#;
715        let code = syn::parse_str(content).unwrap();
716
717        let result = analyzer.analyze(&code, content).unwrap();
718        assert_eq!(result.issues.len(), 1);
719        assert!(
720            result.issues[0]
721                .diagnostic
722                .message
723                .contains("Calculate sum")
724        );
725        assert!(
726            result.issues[0]
727                .diagnostic
728                .message
729                .contains("`let sum = a + b;`")
730        );
731    }
732
733    #[test]
734    fn test_detect_comment_in_method() {
735        let analyzer = InlineCommentsAnalyzer::new();
736        let content = r#"struct Foo;
737
738impl Foo {
739    fn method(&self) {
740        // Process data
741        let x = 1;
742    }
743}"#;
744        let code = syn::parse_str(content).unwrap();
745
746        let result = analyzer.analyze(&code, content).unwrap();
747        assert_eq!(result.issues.len(), 1);
748        assert!(result.issues[0].diagnostic.message.contains("Process data"));
749    }
750
751    #[test]
752    fn test_multiple_methods_with_comments() {
753        let analyzer = InlineCommentsAnalyzer::new();
754        let content = r#"struct Foo;
755
756impl Foo {
757    fn first(&self) {
758        // Comment 1
759        let a = 1;
760    }
761
762    fn second(&self) {
763        // Comment 2
764        let b = 2;
765    }
766}"#;
767        let code = syn::parse_str(content).unwrap();
768
769        let result = analyzer.analyze(&code, content).unwrap();
770        assert_eq!(result.issues.len(), 2);
771    }
772
773    #[test]
774    fn test_issues_are_fixable() {
775        let analyzer = InlineCommentsAnalyzer::new();
776        let content = r#"fn main() {
777    // Comment
778    let x = 1;
779}"#;
780        let code = syn::parse_str(content).unwrap();
781
782        let result = analyzer.analyze(&code, content).unwrap();
783        assert_eq!(result.fixable_count, 1);
784        assert!(result.issues[0].fix.is_available());
785    }
786
787    fn apply(content: &str) -> String {
788        let analyzer = InlineCommentsAnalyzer::new();
789        let code = syn::parse_str(content).unwrap();
790        let suggestions = analyzer.suggestions(&code, content).unwrap();
791        crate::fixer::apply_suggestions(content, &suggestions)
792    }
793
794    #[test]
795    fn test_fix_moves_comment_to_new_doc_block() {
796        let fixed = apply("fn main() {\n    // Comment\n    let x = 1;\n}");
797        assert_eq!(
798            fixed,
799            "/// # Notes\n///\n/// - Comment\nfn main() {\n    let x = 1;\n}"
800        );
801    }
802
803    #[test]
804    fn test_fix_merges_consecutive_comment_lines() {
805        let fixed = apply("fn main() {\n    // first part\n    // second part\n    let x = 1;\n}");
806        assert_eq!(
807            fixed,
808            "/// # Notes\n///\n/// - first part second part\nfn main() {\n    let x = 1;\n}"
809        );
810    }
811
812    #[test]
813    fn test_fix_splits_paragraphs_on_empty_comment() {
814        let fixed = apply("fn main() {\n    // first\n    //\n    // second\n    let x = 1;\n}");
815        assert_eq!(
816            fixed,
817            "/// # Notes\n///\n/// - first\n/// - second\nfn main() {\n    let x = 1;\n}"
818        );
819    }
820
821    #[test]
822    fn test_fix_separate_runs_become_separate_bullets() {
823        let fixed =
824            apply("fn main() {\n    // read\n    let x = 1;\n    // write\n    let y = 2;\n}");
825        assert_eq!(
826            fixed,
827            "/// # Notes\n///\n/// - read\n/// - write\nfn main() {\n    let x = 1;\n    let y = 2;\n}"
828        );
829    }
830
831    #[test]
832    fn test_fix_extends_existing_doc_block() {
833        let fixed = apply("/// Does things.\nfn main() {\n    // Comment\n    let x = 1;\n}");
834        assert_eq!(
835            fixed,
836            "/// Does things.\n///\n/// # Notes\n///\n/// - Comment\nfn main() {\n    let x = 1;\n}"
837        );
838    }
839
840    #[test]
841    fn test_fix_appends_to_existing_notes_section() {
842        let content = "/// Does things.\n///\n/// # Notes\n///\n/// - existing\nfn main() {\n    // Comment\n    let x = 1;\n}";
843        let fixed = apply(content);
844        assert_eq!(
845            fixed,
846            "/// Does things.\n///\n/// # Notes\n///\n/// - existing\n/// - Comment\nfn main() {\n    let x = 1;\n}"
847        );
848    }
849
850    #[test]
851    fn test_fix_keeps_notes_before_following_heading() {
852        let content = "/// Does things.\n///\n/// # Notes\n///\n/// - existing\n///\n/// # Errors\n///\n/// - never\nfn main() {\n    // Comment\n    let x = 1;\n}";
853        let fixed = apply(content);
854        assert_eq!(
855            fixed,
856            "/// Does things.\n///\n/// # Notes\n///\n/// - existing\n/// - Comment\n///\n/// # Errors\n///\n/// - never\nfn main() {\n    let x = 1;\n}"
857        );
858    }
859
860    #[test]
861    fn test_fix_indents_method_doc_block() {
862        let content = "struct Foo;\n\nimpl Foo {\n    fn method(&self) {\n        // Process data\n        let x = 1;\n    }\n}";
863        let fixed = apply(content);
864        assert_eq!(
865            fixed,
866            "struct Foo;\n\nimpl Foo {\n    /// # Notes\n    ///\n    /// - Process data\n    fn method(&self) {\n        let x = 1;\n    }\n}"
867        );
868    }
869
870    #[test]
871    fn test_fix_inserts_before_attributes() {
872        let content = "#[inline]\nfn main() {\n    // Comment\n    let x = 1;\n}";
873        let fixed = apply(content);
874        assert_eq!(
875            fixed,
876            "/// # Notes\n///\n/// - Comment\n#[inline]\nfn main() {\n    let x = 1;\n}"
877        );
878    }
879
880    #[test]
881    fn test_fix_targets_nested_function() {
882        let content = "fn outer() {\n    fn inner() {\n        // nested\n        let x = 1;\n    }\n    inner();\n}";
883        let fixed = apply(content);
884        assert_eq!(
885            fixed,
886            "fn outer() {\n    /// # Notes\n    ///\n    /// - nested\n    fn inner() {\n        let x = 1;\n    }\n    inner();\n}"
887        );
888    }
889
890    #[test]
891    fn test_fix_deletes_empty_comment_without_bullet() {
892        let fixed = apply("fn main() {\n    //\n    let x = 1;\n}");
893        assert_eq!(fixed, "fn main() {\n    let x = 1;\n}");
894    }
895
896    #[test]
897    fn test_fix_ignores_quadruple_slash() {
898        let content = "fn main() {\n    //// Comment\n    let x = 1;\n}";
899        let analyzer = InlineCommentsAnalyzer::new();
900        let code = syn::parse_str(content).unwrap();
901        let suggestions = analyzer.suggestions(&code, content).unwrap();
902        assert!(suggestions.is_empty());
903    }
904
905    #[test]
906    fn test_fix_wraps_long_comment() {
907        let long = "a".repeat(40);
908        let content = format!(
909            "fn main() {{\n    // {long} {long} {long}\n    let x = 1;\n}}",
910            long = long
911        );
912        let fixed = apply(&content);
913        let expected = format!(
914            "/// # Notes\n///\n/// - {long}\n///   {long}\n///   {long}\nfn main() {{\n    let x = 1;\n}}",
915            long = long
916        );
917        assert_eq!(fixed, expected);
918    }
919
920    #[test]
921    fn test_default_implementation() {
922        let analyzer = InlineCommentsAnalyzer;
923        assert_eq!(analyzer.name(), "inline_comments");
924    }
925
926    #[test]
927    fn test_comment_before_closing_brace() {
928        let analyzer = InlineCommentsAnalyzer::new();
929        let content = r#"fn main() {
930    let x = 1;
931    // Final comment
932}"#;
933        let code = syn::parse_str(content).unwrap();
934
935        let result = analyzer.analyze(&code, content).unwrap();
936        assert_eq!(result.issues.len(), 1);
937    }
938
939    #[test]
940    fn test_empty_comment() {
941        let analyzer = InlineCommentsAnalyzer::new();
942        let content = r#"fn main() {
943    //
944    let x = 1;
945}"#;
946        let code = syn::parse_str(content).unwrap();
947
948        let result = analyzer.analyze(&code, content).unwrap();
949        assert_eq!(result.issues.len(), 1);
950    }
951
952    #[test]
953    fn test_comment_with_multiple_slashes() {
954        let analyzer = InlineCommentsAnalyzer::new();
955        let content = r#"fn main() {
956    //// Comment
957    let x = 1;
958}"#;
959        let code = syn::parse_str(content).unwrap();
960
961        let result = analyzer.analyze(&code, content).unwrap();
962        assert_eq!(result.issues.len(), 0);
963    }
964
965    #[test]
966    fn test_nested_blocks_with_comments() {
967        let analyzer = InlineCommentsAnalyzer::new();
968        let content = r#"fn main() {
969    if true {
970        // Nested comment
971        let x = 1;
972    }
973}"#;
974        let code = syn::parse_str(content).unwrap();
975
976        let result = analyzer.analyze(&code, content).unwrap();
977        assert_eq!(result.issues.len(), 1);
978    }
979}