1use 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
25const DOC_WIDTH: usize = 80;
27
28pub struct InlineCommentsAnalyzer;
57
58impl InlineCommentsAnalyzer {
59 #[inline]
61 pub fn new() -> Self {
62 Self
63 }
64
65 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 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 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 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
203struct FnSite {
205 body_start: usize,
207 body_end: usize,
209 item_line: usize,
211 doc_end: Option<usize>,
213 notes_line: Option<usize>
215}
216
217struct FnSiteCollector {
219 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
237fn 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
273fn 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
297fn is_inline_comment(line: &str) -> bool {
309 let trimmed = line.trim();
310 trimmed.starts_with("//") && !trimmed.starts_with("///")
311}
312
313fn comment_text(line: &str) -> &str {
323 line.trim().trim_start_matches("//").trim()
324}
325
326fn 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
373fn 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
412fn 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
430fn 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
459fn 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
497fn 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, ¶graphs);
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}