use rustdoc_types::{ItemEnum, Module, Visibility};
use crate::config::Config;
use crate::generate::Artifact;
use crate::index::{IndexedCrate, IndexedWorkspace};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Level {
Warn,
Error,
}
#[derive(Debug, Clone)]
pub struct Diagnostic {
pub level: Level,
pub code: &'static str,
pub location: String,
pub message: String,
}
pub const LLMS_FULL_SOFT_MAX_BYTES: usize = 512 * 1024;
pub const CRATE_ROOT_MIN_NARRATIVE_LINES: usize = 5;
pub fn lint(
workspace: &IndexedWorkspace,
artifacts: &[Artifact],
config: &Config,
) -> Vec<Diagnostic> {
let mut diagnostics = Vec::new();
for krate in &workspace.crates {
lint_crate(krate, &mut diagnostics);
}
lint_artifacts(artifacts, &mut diagnostics);
if config.strict {
for diag in &mut diagnostics {
if diag.level == Level::Warn {
diag.level = Level::Error;
}
}
}
diagnostics
}
fn lint_crate(krate: &IndexedCrate, diagnostics: &mut Vec<Diagnostic>) {
match krate.root_module_doc.as_deref() {
None => {
diagnostics.push(Diagnostic {
level: Level::Warn,
code: "missing-crate-doc",
location: format!("crate:{}", krate.name),
message: format!(
"crate `{}` has no crate-root `//!` doc block; readers land in an empty index.md",
krate.name
),
});
}
Some(doc) => {
let non_empty_lines = doc.lines().filter(|line| !line.trim().is_empty()).count();
if non_empty_lines < CRATE_ROOT_MIN_NARRATIVE_LINES {
diagnostics.push(Diagnostic {
level: Level::Warn,
code: "short-crate-doc",
location: format!("crate:{}", krate.name),
message: format!(
"crate `{name}` root doc is only {found} non-empty lines; \
consider expanding to at least {min} lines of narrative",
name = krate.name,
found = non_empty_lines,
min = CRATE_ROOT_MIN_NARRATIVE_LINES,
),
});
}
}
}
lint_module_docs(krate, diagnostics);
}
fn lint_module_docs(krate: &IndexedCrate, diagnostics: &mut Vec<Diagnostic>) {
let index = &krate.crate_data.index;
let Some(root_item) = index.get(&krate.crate_data.root) else {
return;
};
let ItemEnum::Module(root_module) = &root_item.inner else {
return;
};
let crate_prefix = krate.name.replace('-', "_");
walk_lint_modules(index, root_module, &crate_prefix, &krate.name, diagnostics);
}
fn walk_lint_modules(
index: &std::collections::HashMap<rustdoc_types::Id, rustdoc_types::Item>,
module: &Module,
prefix: &str,
crate_name: &str,
diagnostics: &mut Vec<Diagnostic>,
) {
for child_id in &module.items {
let Some(child) = index.get(child_id) else {
continue;
};
if !matches!(child.visibility, Visibility::Public) {
continue;
}
let ItemEnum::Module(child_module) = &child.inner else {
continue;
};
let Some(name) = child.name.as_deref() else {
continue;
};
let path = format!("{prefix}::{name}");
if child.docs.as_deref().is_none_or(str::is_empty) {
diagnostics.push(Diagnostic {
level: Level::Warn,
code: "missing-module-doc",
location: format!("module:{path}"),
message: format!(
"public module `{path}` in crate `{crate_name}` has no `//!` doc block"
),
});
}
walk_lint_modules(index, child_module, &path, crate_name, diagnostics);
}
}
fn lint_artifacts(artifacts: &[Artifact], diagnostics: &mut Vec<Diagnostic>) {
if let Some(full) = artifacts.iter().find(|a| a.path == "llms-full.txt")
&& full.body.len() > LLMS_FULL_SOFT_MAX_BYTES
{
diagnostics.push(Diagnostic {
level: Level::Warn,
code: "llms-full-too-large",
location: "artifact:llms-full.txt".to_owned(),
message: format!(
"llms-full.txt is {actual} bytes (> soft cap {cap}); \
consider trimming crate-root narratives or excluding low-signal crates",
actual = full.body.len(),
cap = LLMS_FULL_SOFT_MAX_BYTES,
),
});
}
}