1use rustdoc_types::{ItemEnum, Module, Visibility};
15
16use crate::config::Config;
17use crate::generate::Artifact;
18use crate::index::{IndexedCrate, IndexedWorkspace};
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub enum Level {
27 Warn,
29 Error,
31}
32
33#[derive(Debug, Clone)]
35pub struct Diagnostic {
36 pub level: Level,
38 pub code: &'static str,
41 pub location: String,
44 pub message: String,
46}
47
48pub const LLMS_FULL_SOFT_MAX_BYTES: usize = 512 * 1024;
56
57pub const CRATE_ROOT_MIN_NARRATIVE_LINES: usize = 5;
61
62pub fn lint(
69 workspace: &IndexedWorkspace,
70 artifacts: &[Artifact],
71 config: &Config,
72) -> Vec<Diagnostic> {
73 let mut diagnostics = Vec::new();
74
75 for krate in &workspace.crates {
76 lint_crate(krate, &mut diagnostics);
77 }
78
79 lint_artifacts(artifacts, &mut diagnostics);
80
81 if config.strict {
82 for diag in &mut diagnostics {
83 if diag.level == Level::Warn {
84 diag.level = Level::Error;
85 }
86 }
87 }
88
89 diagnostics
90}
91
92fn lint_crate(krate: &IndexedCrate, diagnostics: &mut Vec<Diagnostic>) {
93 match krate.root_module_doc.as_deref() {
94 None => {
95 diagnostics.push(Diagnostic {
96 level: Level::Warn,
97 code: "missing-crate-doc",
98 location: format!("crate:{}", krate.name),
99 message: format!(
100 "crate `{}` has no crate-root `//!` doc block; readers land in an empty index.md",
101 krate.name
102 ),
103 });
104 }
105 Some(doc) => {
106 let non_empty_lines = doc.lines().filter(|line| !line.trim().is_empty()).count();
107 if non_empty_lines < CRATE_ROOT_MIN_NARRATIVE_LINES {
108 diagnostics.push(Diagnostic {
109 level: Level::Warn,
110 code: "short-crate-doc",
111 location: format!("crate:{}", krate.name),
112 message: format!(
113 "crate `{name}` root doc is only {found} non-empty lines; \
114 consider expanding to at least {min} lines of narrative",
115 name = krate.name,
116 found = non_empty_lines,
117 min = CRATE_ROOT_MIN_NARRATIVE_LINES,
118 ),
119 });
120 }
121 }
122 }
123
124 lint_module_docs(krate, diagnostics);
125}
126
127fn lint_module_docs(krate: &IndexedCrate, diagnostics: &mut Vec<Diagnostic>) {
128 let index = &krate.crate_data.index;
129
130 let Some(root_item) = index.get(&krate.crate_data.root) else {
131 return;
132 };
133 let ItemEnum::Module(root_module) = &root_item.inner else {
134 return;
135 };
136
137 let crate_prefix = krate.name.replace('-', "_");
138 walk_lint_modules(index, root_module, &crate_prefix, &krate.name, diagnostics);
139}
140
141fn walk_lint_modules(
142 index: &std::collections::HashMap<rustdoc_types::Id, rustdoc_types::Item>,
143 module: &Module,
144 prefix: &str,
145 crate_name: &str,
146 diagnostics: &mut Vec<Diagnostic>,
147) {
148 for child_id in &module.items {
149 let Some(child) = index.get(child_id) else {
150 continue;
151 };
152 if !matches!(child.visibility, Visibility::Public) {
153 continue;
154 }
155 let ItemEnum::Module(child_module) = &child.inner else {
156 continue;
157 };
158 let Some(name) = child.name.as_deref() else {
159 continue;
160 };
161
162 let path = format!("{prefix}::{name}");
163
164 if child.docs.as_deref().is_none_or(str::is_empty) {
165 diagnostics.push(Diagnostic {
166 level: Level::Warn,
167 code: "missing-module-doc",
168 location: format!("module:{path}"),
169 message: format!(
170 "public module `{path}` in crate `{crate_name}` has no `//!` doc block"
171 ),
172 });
173 }
174
175 walk_lint_modules(index, child_module, &path, crate_name, diagnostics);
176 }
177}
178
179fn lint_artifacts(artifacts: &[Artifact], diagnostics: &mut Vec<Diagnostic>) {
180 if let Some(full) = artifacts.iter().find(|a| a.path == "llms-full.txt")
181 && full.body.len() > LLMS_FULL_SOFT_MAX_BYTES
182 {
183 diagnostics.push(Diagnostic {
184 level: Level::Warn,
185 code: "llms-full-too-large",
186 location: "artifact:llms-full.txt".to_owned(),
187 message: format!(
188 "llms-full.txt is {actual} bytes (> soft cap {cap}); \
189 consider trimming crate-root narratives or excluding low-signal crates",
190 actual = full.body.len(),
191 cap = LLMS_FULL_SOFT_MAX_BYTES,
192 ),
193 });
194 }
195}