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 && let syn::Fields::Named(ref named) = s.fields {
134                for field in &named.named {
135                    *total_fields += 1;
136                    if !has_doc(&field.attrs) {
137                        *missing_field_count += 1;
138                        let field_name = field
139                            .ident
140                            .as_ref()
141                            .map(|i| i.to_string())
142                            .unwrap_or_default();
143                        missing.push(MissingDoc {
144                            file_path: file_path.to_string(),
145                            line: line_of(field.span()),
146                            label: format!("{}::{} (field)", s.ident, field_name),
147                        });
148                    }
149                }
150            }
151        }
152        Item::Enum(e) => {
153            *total_items += 1;
154            if !has_doc(&e.attrs) {
155                *missing_item_count += 1;
156                missing.push(MissingDoc {
157                    file_path: file_path.to_string(),
158                    line: line_of(e.enum_token.span),
159                    label: format!("{} (enum)", e.ident),
160                });
161            }
162            if check_fields {
163                for variant in &e.variants {
164                    *total_fields += 1;
165                    if !has_doc(&variant.attrs) {
166                        *missing_field_count += 1;
167                        missing.push(MissingDoc {
168                            file_path: file_path.to_string(),
169                            line: line_of(variant.ident.span()),
170                            label: format!("{}::{} (variant)", e.ident, variant.ident),
171                        });
172                    }
173                }
174            }
175        }
176        Item::Fn(f) => {
177            *total_items += 1;
178            if !has_doc(&f.attrs) {
179                *missing_item_count += 1;
180                missing.push(MissingDoc {
181                    file_path: file_path.to_string(),
182                    line: line_of(f.sig.fn_token.span),
183                    label: format!("{} (fn)", f.sig.ident),
184                });
185            }
186        }
187        Item::Trait(t) => {
188            *total_items += 1;
189            if !has_doc(&t.attrs) {
190                *missing_item_count += 1;
191                missing.push(MissingDoc {
192                    file_path: file_path.to_string(),
193                    line: line_of(t.trait_token.span),
194                    label: format!("{} (trait)", t.ident),
195                });
196            }
197            if check_fields {
198                for ti in &t.items {
199                    if let syn::TraitItem::Fn(tf) = ti {
200                        *total_fields += 1;
201                        if !has_doc(&tf.attrs) {
202                            *missing_field_count += 1;
203                            missing.push(MissingDoc {
204                                file_path: file_path.to_string(),
205                                line: line_of(tf.sig.fn_token.span),
206                                label: format!("{}::{} (trait method)", t.ident, tf.sig.ident),
207                            });
208                        }
209                    }
210                }
211            }
212        }
213        Item::Mod(m) if m.content.is_some() => {
214            *total_items += 1;
215            if !has_doc(&m.attrs) {
216                *missing_item_count += 1;
217                missing.push(MissingDoc {
218                    file_path: file_path.to_string(),
219                    line: line_of(m.mod_token.span),
220                    label: format!("{} (mod)", m.ident),
221                });
222            }
223        }
224        Item::Type(t) => {
225            *total_items += 1;
226            if !has_doc(&t.attrs) {
227                *missing_item_count += 1;
228                missing.push(MissingDoc {
229                    file_path: file_path.to_string(),
230                    line: line_of(t.type_token.span),
231                    label: format!("{} (type alias)", t.ident),
232                });
233            }
234        }
235        Item::Const(c) => {
236            *total_items += 1;
237            if !has_doc(&c.attrs) {
238                *missing_item_count += 1;
239                missing.push(MissingDoc {
240                    file_path: file_path.to_string(),
241                    line: line_of(c.const_token.span),
242                    label: format!("{} (const)", c.ident),
243                });
244            }
245        }
246        Item::Static(s) => {
247            *total_items += 1;
248            if !has_doc(&s.attrs) {
249                *missing_item_count += 1;
250                missing.push(MissingDoc {
251                    file_path: file_path.to_string(),
252                    line: line_of(s.static_token.span),
253                    label: format!("{} (static)", s.ident),
254                });
255            }
256        }
257        Item::Impl(impl_block) if check_fields => {
258            // Walk impl methods
259            for impl_item in &impl_block.items {
260                if let syn::ImplItem::Fn(f) = impl_item {
261                    *total_fields += 1;
262                    if !has_doc(&f.attrs) {
263                        *missing_field_count += 1;
264                        let type_name = match &*impl_block.self_ty {
265                            syn::Type::Path(tp) => tp
266                                .path
267                                .segments
268                                .last()
269                                .map(|s| s.ident.to_string())
270                                .unwrap_or_default(),
271                            _ => String::new(),
272                        };
273                        missing.push(MissingDoc {
274                            file_path: file_path.to_string(),
275                            line: line_of(f.sig.fn_token.span),
276                            label: format!("{}::{} (impl method)", type_name, f.sig.ident),
277                        });
278                    }
279                }
280            }
281        }
282        // Skip: Use, Impl (without --fields), Macro, ExternCrate, ForeignMod, TraitAlias, Union,
283        // Verbatim
284        _ => {}
285    }
286}