Skip to main content

rs_hack/commands/
doc_coverage.rs

1//! `doc-coverage` command: report missing doc-comments for public items.
2
3use std::path::PathBuf;
4
5use anyhow::Result;
6use syn::spanned::Spanned;
7
8use crate::files::collect_rust_files_with_exclusions;
9
10#[derive(Debug)]
11pub struct DocCoverageReport {
12    pub missing_items: Vec<MissingDoc>,
13    pub total_items: usize,
14    pub total_fields: usize,
15    pub missing_item_count: usize,
16    pub missing_field_count: usize,
17}
18
19#[derive(Debug)]
20pub struct MissingDoc {
21    pub file_path: String,
22    pub line: usize,
23    pub label: String, // e.g. "AppState (struct)", "RenderedCell::width (field)"
24}
25
26pub fn run(paths: &[PathBuf], check_fields: bool, exclude: &[String]) -> Result<DocCoverageReport> {
27    let files = collect_rust_files_with_exclusions(paths, exclude)?;
28
29    let mut missing_items: Vec<MissingDoc> = Vec::new();
30    let mut total_items = 0usize;
31    let mut total_fields = 0usize;
32    let mut missing_item_count = 0usize;
33    let mut missing_field_count = 0usize;
34
35    for file in &files {
36        let content = match std::fs::read_to_string(file) {
37            Ok(c) => c,
38            Err(e) => {
39                eprintln!("⚠️  Skipping {}: {}", file.display(), e);
40                continue;
41            }
42        };
43        let syntax = match syn::parse_file(&content) {
44            Ok(s) => s,
45            Err(e) => {
46                eprintln!("⚠️  Skipping {} (parse error): {}", file.display(), e);
47                continue;
48            }
49        };
50
51        let fp = file.to_string_lossy().to_string();
52
53        for item in &syntax.items {
54            process_item(
55                item,
56                &fp,
57                check_fields,
58                &mut missing_items,
59                &mut total_items,
60                &mut total_fields,
61                &mut missing_item_count,
62                &mut missing_field_count,
63            );
64        }
65    }
66
67    Ok(DocCoverageReport {
68        missing_items,
69        total_items,
70        total_fields,
71        missing_item_count,
72        missing_field_count,
73    })
74}
75
76pub fn render(report: &DocCoverageReport) {
77    println!("Missing docs (items): {}", report.missing_item_count);
78    println!("Missing docs (fields): {}", report.missing_field_count);
79
80    if report.missing_items.is_empty() {
81        println!("All public items are documented.");
82        return;
83    }
84
85    // Sort by file then line
86    let mut offenders: Vec<&MissingDoc> = report.missing_items.iter().collect();
87    offenders.sort_by(|a, b| a.file_path.cmp(&b.file_path).then(a.line.cmp(&b.line)));
88
89    let top: Vec<_> = offenders.iter().take(10).collect();
90    println!("\nTop offenders:");
91    for doc in top {
92        println!("  {}:{}: {}", doc.file_path, doc.line, doc.label);
93    }
94    if offenders.len() > 10 {
95        println!("  ... and {} more", offenders.len() - 10);
96    }
97}
98
99// ---- helpers ----------------------------------------------------------------
100
101fn has_doc(attrs: &[syn::Attribute]) -> bool {
102    attrs.iter().any(|attr| attr.path().is_ident("doc"))
103}
104
105fn line_of(span: proc_macro2::Span) -> usize {
106    span.start().line
107}
108
109#[allow(clippy::too_many_arguments)]
110fn process_item(
111    item: &syn::Item,
112    file_path: &str,
113    check_fields: bool,
114    missing: &mut Vec<MissingDoc>,
115    total_items: &mut usize,
116    total_fields: &mut usize,
117    missing_item_count: &mut usize,
118    missing_field_count: &mut usize,
119) {
120    use syn::Item;
121
122    match item {
123        Item::Struct(s) => {
124            *total_items += 1;
125            if !has_doc(&s.attrs) {
126                *missing_item_count += 1;
127                missing.push(MissingDoc {
128                    file_path: file_path.to_string(),
129                    line: line_of(s.struct_token.span),
130                    label: format!("{} (struct)", s.ident),
131                });
132            }
133            if check_fields {
134                if let syn::Fields::Named(ref named) = s.fields {
135                    for field in &named.named {
136                        *total_fields += 1;
137                        if !has_doc(&field.attrs) {
138                            *missing_field_count += 1;
139                            let field_name = field
140                                .ident
141                                .as_ref()
142                                .map(|i| i.to_string())
143                                .unwrap_or_default();
144                            missing.push(MissingDoc {
145                                file_path: file_path.to_string(),
146                                line: line_of(field.span()),
147                                label: format!("{}::{} (field)", s.ident, field_name),
148                            });
149                        }
150                    }
151                }
152            }
153        }
154        Item::Enum(e) => {
155            *total_items += 1;
156            if !has_doc(&e.attrs) {
157                *missing_item_count += 1;
158                missing.push(MissingDoc {
159                    file_path: file_path.to_string(),
160                    line: line_of(e.enum_token.span),
161                    label: format!("{} (enum)", e.ident),
162                });
163            }
164            if check_fields {
165                for variant in &e.variants {
166                    *total_fields += 1;
167                    if !has_doc(&variant.attrs) {
168                        *missing_field_count += 1;
169                        missing.push(MissingDoc {
170                            file_path: file_path.to_string(),
171                            line: line_of(variant.ident.span()),
172                            label: format!("{}::{} (variant)", e.ident, variant.ident),
173                        });
174                    }
175                }
176            }
177        }
178        Item::Fn(f) => {
179            *total_items += 1;
180            if !has_doc(&f.attrs) {
181                *missing_item_count += 1;
182                missing.push(MissingDoc {
183                    file_path: file_path.to_string(),
184                    line: line_of(f.sig.fn_token.span),
185                    label: format!("{} (fn)", f.sig.ident),
186                });
187            }
188        }
189        Item::Trait(t) => {
190            *total_items += 1;
191            if !has_doc(&t.attrs) {
192                *missing_item_count += 1;
193                missing.push(MissingDoc {
194                    file_path: file_path.to_string(),
195                    line: line_of(t.trait_token.span),
196                    label: format!("{} (trait)", t.ident),
197                });
198            }
199            if check_fields {
200                for ti in &t.items {
201                    if let syn::TraitItem::Fn(tf) = ti {
202                        *total_fields += 1;
203                        if !has_doc(&tf.attrs) {
204                            *missing_field_count += 1;
205                            missing.push(MissingDoc {
206                                file_path: file_path.to_string(),
207                                line: line_of(tf.sig.fn_token.span),
208                                label: format!("{}::{} (trait method)", t.ident, tf.sig.ident),
209                            });
210                        }
211                    }
212                }
213            }
214        }
215        Item::Mod(m) if m.content.is_some() => {
216            *total_items += 1;
217            if !has_doc(&m.attrs) {
218                *missing_item_count += 1;
219                missing.push(MissingDoc {
220                    file_path: file_path.to_string(),
221                    line: line_of(m.mod_token.span),
222                    label: format!("{} (mod)", m.ident),
223                });
224            }
225        }
226        Item::Type(t) => {
227            *total_items += 1;
228            if !has_doc(&t.attrs) {
229                *missing_item_count += 1;
230                missing.push(MissingDoc {
231                    file_path: file_path.to_string(),
232                    line: line_of(t.type_token.span),
233                    label: format!("{} (type alias)", t.ident),
234                });
235            }
236        }
237        Item::Const(c) => {
238            *total_items += 1;
239            if !has_doc(&c.attrs) {
240                *missing_item_count += 1;
241                missing.push(MissingDoc {
242                    file_path: file_path.to_string(),
243                    line: line_of(c.const_token.span),
244                    label: format!("{} (const)", c.ident),
245                });
246            }
247        }
248        Item::Static(s) => {
249            *total_items += 1;
250            if !has_doc(&s.attrs) {
251                *missing_item_count += 1;
252                missing.push(MissingDoc {
253                    file_path: file_path.to_string(),
254                    line: line_of(s.static_token.span),
255                    label: format!("{} (static)", s.ident),
256                });
257            }
258        }
259        Item::Impl(impl_block) if check_fields => {
260            // Walk impl methods
261            for impl_item in &impl_block.items {
262                if let syn::ImplItem::Fn(f) = impl_item {
263                    *total_fields += 1;
264                    if !has_doc(&f.attrs) {
265                        *missing_field_count += 1;
266                        let type_name = match &*impl_block.self_ty {
267                            syn::Type::Path(tp) => tp
268                                .path
269                                .segments
270                                .last()
271                                .map(|s| s.ident.to_string())
272                                .unwrap_or_default(),
273                            _ => String::new(),
274                        };
275                        missing.push(MissingDoc {
276                            file_path: file_path.to_string(),
277                            line: line_of(f.sig.fn_token.span),
278                            label: format!("{}::{} (impl method)", type_name, f.sig.ident),
279                        });
280                    }
281                }
282            }
283        }
284        // Skip: Use, Impl (without --fields), Macro, ExternCrate, ForeignMod, TraitAlias, Union, Verbatim
285        _ => {}
286    }
287}
288