273 lines
8.4 KiB
Rust
273 lines
8.4 KiB
Rust
|
|
//! Rust documentation coverage adapter.
|
||
|
|
//!
|
||
|
|
//! Checks for:
|
||
|
|
//! - A `//!` module-level doc comment somewhere in every `.rs` file.
|
||
|
|
//! - A `///` doc comment immediately before every `pub` item (`fn`, `struct`,
|
||
|
|
//! `enum`, `trait`, `type`, `const`, `static`, `mod`).
|
||
|
|
|
||
|
|
use std::fs;
|
||
|
|
use std::path::Path;
|
||
|
|
|
||
|
|
use crate::{CheckFailure, CheckResult, LanguageAdapter};
|
||
|
|
|
||
|
|
/// Rust documentation coverage adapter.
|
||
|
|
pub struct RustAdapter;
|
||
|
|
|
||
|
|
impl RustAdapter {
|
||
|
|
fn check_file(&self, path: &Path) -> Vec<CheckFailure> {
|
||
|
|
let content = match fs::read_to_string(path) {
|
||
|
|
Ok(c) => c,
|
||
|
|
Err(_) => return vec![],
|
||
|
|
};
|
||
|
|
let lines: Vec<&str> = content.lines().collect();
|
||
|
|
let mut failures = Vec::new();
|
||
|
|
|
||
|
|
// Module-level doc comment (//!)
|
||
|
|
if !lines.iter().any(|l| l.trim_start().starts_with("//!")) {
|
||
|
|
failures.push(CheckFailure {
|
||
|
|
file_path: path.to_path_buf(),
|
||
|
|
line: 1,
|
||
|
|
item_kind: "module".to_string(),
|
||
|
|
item_name: module_name(path),
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
// Public items missing /// doc comments
|
||
|
|
for (i, &line) in lines.iter().enumerate() {
|
||
|
|
if let Some((kind, name)) = parse_pub_item(line)
|
||
|
|
&& !has_doc_before(&lines, i)
|
||
|
|
{
|
||
|
|
failures.push(CheckFailure {
|
||
|
|
file_path: path.to_path_buf(),
|
||
|
|
line: i + 1,
|
||
|
|
item_kind: kind,
|
||
|
|
item_name: name,
|
||
|
|
});
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
failures
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Extract public item signatures from a Rust file as `"kind name"` strings.
|
||
|
|
pub(crate) fn extract_items(path: &Path) -> Vec<String> {
|
||
|
|
let content = match fs::read_to_string(path) {
|
||
|
|
Ok(c) => c,
|
||
|
|
Err(_) => return vec![],
|
||
|
|
};
|
||
|
|
content
|
||
|
|
.lines()
|
||
|
|
.filter_map(|line| {
|
||
|
|
let (kind, name) = parse_pub_item(line)?;
|
||
|
|
Some(format!("{kind} {name}"))
|
||
|
|
})
|
||
|
|
.collect()
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
impl LanguageAdapter for RustAdapter {
|
||
|
|
fn check(&self, files: &[&Path]) -> CheckResult {
|
||
|
|
let failures: Vec<CheckFailure> = files.iter().flat_map(|&f| self.check_file(f)).collect();
|
||
|
|
if failures.is_empty() {
|
||
|
|
CheckResult::Ok
|
||
|
|
} else {
|
||
|
|
CheckResult::Failures(failures)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
fn update_source_map(
|
||
|
|
&self,
|
||
|
|
passing_files: &[&Path],
|
||
|
|
source_map_path: &Path,
|
||
|
|
) -> Result<(), String> {
|
||
|
|
let mut map = crate::read_map(source_map_path)?;
|
||
|
|
for &file in passing_files {
|
||
|
|
let key = file.to_string_lossy().to_string();
|
||
|
|
let items: Vec<serde_json::Value> = Self::extract_items(file)
|
||
|
|
.into_iter()
|
||
|
|
.map(serde_json::Value::String)
|
||
|
|
.collect();
|
||
|
|
map.insert(key, serde_json::Value::Array(items));
|
||
|
|
}
|
||
|
|
crate::write_map(source_map_path, map)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
fn module_name(path: &Path) -> String {
|
||
|
|
path.file_stem()
|
||
|
|
.and_then(|s| s.to_str())
|
||
|
|
.unwrap_or("unknown")
|
||
|
|
.to_string()
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Parse a line as a public Rust item declaration.
|
||
|
|
///
|
||
|
|
/// Returns `(kind, name)` if the line declares a public item, `None` otherwise.
|
||
|
|
fn parse_pub_item(line: &str) -> Option<(String, String)> {
|
||
|
|
let trimmed = line.trim();
|
||
|
|
|
||
|
|
// Strip visibility: "pub(…)" or "pub "
|
||
|
|
let rest = if let Some(r) = trimmed.strip_prefix("pub(") {
|
||
|
|
let end = r.find(')')?;
|
||
|
|
r[end + 1..].trim_start()
|
||
|
|
} else if let Some(r) = trimmed.strip_prefix("pub ") {
|
||
|
|
r.trim_start()
|
||
|
|
} else {
|
||
|
|
return None;
|
||
|
|
};
|
||
|
|
|
||
|
|
// Handle "async fn"
|
||
|
|
let rest = if let Some(r) = rest.strip_prefix("async ") {
|
||
|
|
r.trim_start()
|
||
|
|
} else {
|
||
|
|
rest
|
||
|
|
};
|
||
|
|
|
||
|
|
// Match item keyword and extract name part
|
||
|
|
let (kind, name_part) = if let Some(r) = rest.strip_prefix("fn ") {
|
||
|
|
("fn", r.trim_start())
|
||
|
|
} else if let Some(r) = rest.strip_prefix("struct ") {
|
||
|
|
("struct", r.trim_start())
|
||
|
|
} else if let Some(r) = rest.strip_prefix("enum ") {
|
||
|
|
("enum", r.trim_start())
|
||
|
|
} else if let Some(r) = rest.strip_prefix("trait ") {
|
||
|
|
("trait", r.trim_start())
|
||
|
|
} else if let Some(r) = rest.strip_prefix("type ") {
|
||
|
|
("type", r.trim_start())
|
||
|
|
} else if let Some(r) = rest.strip_prefix("const ") {
|
||
|
|
("const", r.trim_start())
|
||
|
|
} else if let Some(r) = rest.strip_prefix("static ") {
|
||
|
|
("static", r.trim_start())
|
||
|
|
} else if let Some(r) = rest.strip_prefix("mod ") {
|
||
|
|
("mod", r.trim_start())
|
||
|
|
} else {
|
||
|
|
return None;
|
||
|
|
};
|
||
|
|
|
||
|
|
let name: String = name_part
|
||
|
|
.chars()
|
||
|
|
.take_while(|&c| c.is_alphanumeric() || c == '_')
|
||
|
|
.collect();
|
||
|
|
|
||
|
|
if name.is_empty() {
|
||
|
|
return None;
|
||
|
|
}
|
||
|
|
|
||
|
|
Some((kind.to_string(), name))
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Return `true` if a `///` doc comment appears before the item at `item_idx`.
|
||
|
|
///
|
||
|
|
/// Scans backward from `item_idx`, skipping blank lines and `#[…]` attribute
|
||
|
|
/// lines. Returns `true` if the first substantive line is a `///` comment.
|
||
|
|
fn has_doc_before(lines: &[&str], item_idx: usize) -> bool {
|
||
|
|
let mut i = item_idx;
|
||
|
|
while i > 0 {
|
||
|
|
i -= 1;
|
||
|
|
let line = lines[i].trim();
|
||
|
|
if line.starts_with("///") {
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
if line.starts_with("#[") || line.starts_with("#![") || line.is_empty() {
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
false
|
||
|
|
}
|
||
|
|
|
||
|
|
#[cfg(test)]
|
||
|
|
mod tests {
|
||
|
|
use super::*;
|
||
|
|
use tempfile::TempDir;
|
||
|
|
|
||
|
|
fn write_rs(dir: &Path, name: &str, content: &str) -> std::path::PathBuf {
|
||
|
|
let path = dir.join(name);
|
||
|
|
std::fs::write(&path, content).unwrap();
|
||
|
|
path
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn check_fully_documented_file_returns_ok() {
|
||
|
|
let tmp = TempDir::new().unwrap();
|
||
|
|
let path = write_rs(
|
||
|
|
tmp.path(),
|
||
|
|
"lib.rs",
|
||
|
|
"//! Module doc.\n\n/// A function.\npub fn hello() {}\n\n/// A struct.\npub struct Foo;\n",
|
||
|
|
);
|
||
|
|
let adapter = RustAdapter;
|
||
|
|
assert_eq!(adapter.check(&[&path]), CheckResult::Ok);
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn check_detects_missing_module_doc() {
|
||
|
|
let tmp = TempDir::new().unwrap();
|
||
|
|
let path = write_rs(tmp.path(), "lib.rs", "/// A function.\npub fn hello() {}\n");
|
||
|
|
let adapter = RustAdapter;
|
||
|
|
let result = adapter.check(&[&path]);
|
||
|
|
assert!(
|
||
|
|
matches!(&result, CheckResult::Failures(v) if v.iter().any(|f| f.item_kind == "module")),
|
||
|
|
"expected module failure, got {result:?}"
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn check_detects_missing_fn_doc_with_correct_fields() {
|
||
|
|
let tmp = TempDir::new().unwrap();
|
||
|
|
let path = write_rs(tmp.path(), "bar.rs", "//! Module.\n\npub fn no_doc() {}\n");
|
||
|
|
let adapter = RustAdapter;
|
||
|
|
let result = adapter.check(&[&path]);
|
||
|
|
if let CheckResult::Failures(failures) = result {
|
||
|
|
let f = failures.iter().find(|f| f.item_kind == "fn").unwrap();
|
||
|
|
assert_eq!(f.item_name, "no_doc");
|
||
|
|
assert_eq!(f.line, 3);
|
||
|
|
assert_eq!(f.file_path, path);
|
||
|
|
} else {
|
||
|
|
panic!("expected failures");
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn check_passes_item_with_attribute_before_doc() {
|
||
|
|
let tmp = TempDir::new().unwrap();
|
||
|
|
// Attribute between doc and item is fine; doc between attribute and item is fine too
|
||
|
|
let path = write_rs(
|
||
|
|
tmp.path(),
|
||
|
|
"lib.rs",
|
||
|
|
"//! Module.\n\n/// Doc.\n#[derive(Debug)]\npub struct Foo;\n",
|
||
|
|
);
|
||
|
|
let adapter = RustAdapter;
|
||
|
|
assert_eq!(adapter.check(&[&path]), CheckResult::Ok);
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn parse_pub_item_recognises_various_kinds() {
|
||
|
|
assert_eq!(
|
||
|
|
parse_pub_item("pub fn foo()"),
|
||
|
|
Some(("fn".into(), "foo".into()))
|
||
|
|
);
|
||
|
|
assert_eq!(
|
||
|
|
parse_pub_item(" pub async fn bar()"),
|
||
|
|
Some(("fn".into(), "bar".into()))
|
||
|
|
);
|
||
|
|
assert_eq!(
|
||
|
|
parse_pub_item("pub struct Baz"),
|
||
|
|
Some(("struct".into(), "Baz".into()))
|
||
|
|
);
|
||
|
|
assert_eq!(
|
||
|
|
parse_pub_item("pub enum Qux"),
|
||
|
|
Some(("enum".into(), "Qux".into()))
|
||
|
|
);
|
||
|
|
assert_eq!(
|
||
|
|
parse_pub_item("pub trait MyTrait"),
|
||
|
|
Some(("trait".into(), "MyTrait".into()))
|
||
|
|
);
|
||
|
|
assert_eq!(
|
||
|
|
parse_pub_item("pub(crate) fn inner()"),
|
||
|
|
Some(("fn".into(), "inner".into()))
|
||
|
|
);
|
||
|
|
assert_eq!(parse_pub_item("fn private()"), None);
|
||
|
|
assert_eq!(parse_pub_item("let x = 1;"), None);
|
||
|
|
}
|
||
|
|
}
|