pub mod strict;
use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
pub const UNRESOLVED: &[(&str, &str)] = &[];
pub const SKIPPED_ROOTS: &[&str] = &["Self", "self", "super", "std", "core", "alloc"];
pub const ITEM_KEYWORDS: &[&str] = &[
"fn", "struct", "enum", "trait", "type", "const", "static", "union", "mod",
];
pub const ITEM_MODIFIERS: &[&str] = &["const", "async", "unsafe", "default", "extern"];
#[derive(Debug, Clone)]
pub struct Decl {
pub owner: Option<String>,
pub name: String,
pub is_public: bool,
pub vis: String,
pub file: String,
pub line: usize,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Carrier {
Public { line: usize, decl: String },
Private,
}
#[derive(Debug, Clone)]
pub struct DocBlock {
pub lines: Vec<(usize, String)>,
pub carrier: Carrier,
}
#[derive(Debug, PartialEq, Eq)]
pub enum Verdict {
Public,
Private {
file: String,
line: usize,
vis: String,
},
Unresolved,
Skipped,
}
#[derive(Debug)]
pub struct Finding {
pub file: String,
pub line: usize,
pub link: String,
pub carrier: String,
pub carrier_line: usize,
pub target_file: String,
pub target_line: usize,
pub target_vis: String,
}
pub fn visibility_is_public(vis: &str) -> bool {
vis == "pub"
}
pub fn split_visibility(line: &str) -> (String, &str) {
let rest = line.trim_start();
let Some(after) = rest.strip_prefix("pub") else {
return (String::new(), rest);
};
if after
.chars()
.next()
.is_some_and(|c| c.is_alphanumeric() || c == '_')
{
return (String::new(), rest);
}
let after = after.trim_start();
if let Some(open) = after.strip_prefix('(') {
if let Some(close) = open.find(')') {
let inner = &open[..close];
return (format!("pub({inner})"), open[close + 1..].trim_start());
}
}
("pub".to_string(), after)
}
pub fn parse_item(line: &str) -> Option<(String, &'static str, String)> {
let (vis, rest) = split_visibility(line);
let tokens: Vec<&str> = rest.split_whitespace().collect();
let mut index = 0usize;
while index < tokens.len() {
let token = tokens[index];
let is_modifier = ITEM_MODIFIERS.contains(&token);
let modifier_applies = is_modifier
&& match token {
"const" => tokens.get(index + 1).is_some_and(|next| *next == "fn"),
"extern" => true,
_ => true,
};
if modifier_applies {
index += 1;
if token == "extern" && tokens.get(index).is_some_and(|t| t.starts_with('"')) {
index += 1;
}
continue;
}
let keyword = ITEM_KEYWORDS.iter().find(|kw| **kw == token)?;
let raw = tokens.get(index + 1)?;
let name: String = raw
.chars()
.take_while(|c| c.is_alphanumeric() || *c == '_')
.collect();
if name.is_empty() {
return None;
}
return Some((vis, keyword, name));
}
None
}
pub fn brace_delta(line: &str) -> i32 {
let mut delta = 0i32;
let mut in_string = false;
let mut escaped = false;
for ch in line.chars() {
if escaped {
escaped = false;
continue;
}
match ch {
'\\' if in_string => escaped = true,
'"' => in_string = !in_string,
'{' if !in_string => delta += 1,
'}' if !in_string => delta -= 1,
_ => {}
}
}
delta
}
pub fn impl_owner(line: &str) -> Option<String> {
let rest = line.trim_start().strip_prefix("impl")?;
if rest
.chars()
.next()
.is_some_and(|c| c.is_alphanumeric() || c == '_')
{
return None;
}
let head = rest.split('{').next().unwrap_or(rest);
let subject = head.rsplit(" for ").next().unwrap_or(head);
let subject = subject.trim().trim_start_matches('<');
let name: String = subject
.trim_start()
.chars()
.skip_while(|c| !c.is_alphabetic() && *c != '_')
.take_while(|c| c.is_alphanumeric() || *c == '_')
.collect();
(!name.is_empty()).then_some(name)
}
pub fn enum_variant(line: &str) -> Option<String> {
let trimmed = line.trim_start();
if trimmed.starts_with("//") || trimmed.starts_with('#') {
return None;
}
let name: String = trimmed
.chars()
.take_while(|c| c.is_alphanumeric() || *c == '_')
.collect();
if name.is_empty() || !name.starts_with(|c: char| c.is_uppercase()) {
return None;
}
let after = trimmed[name.len()..].trim_start();
let opens_variant = after.is_empty()
|| after.starts_with(',')
|| after.starts_with('(')
|| after.starts_with('{')
|| after.starts_with('=');
opens_variant.then_some(name)
}
pub fn index_declarations(
source: &str,
file: &str,
out: &mut Vec<Decl>,
public_modules: &mut BTreeSet<String>,
) {
let mut depth = 0i32;
let mut owners: Vec<(String, i32, bool)> = Vec::new();
let mut enums: Vec<(String, i32, bool)> = Vec::new();
for (offset, line) in source.lines().enumerate() {
let number = offset + 1;
let trimmed = line.trim_start();
owners.retain(|(_, at, _)| depth > *at);
enums.retain(|(_, at, _)| depth > *at);
if !trimmed.starts_with("//") {
if let Some(owner) = impl_owner(line) {
let public = out
.iter()
.any(|d| d.name == owner && d.owner.is_none() && d.is_public);
owners.push((owner, depth, public));
depth += brace_delta(line);
continue;
}
if let Some((vis, keyword, name)) = parse_item(line) {
let is_public = visibility_is_public(&vis);
if keyword == "mod" && is_public {
public_modules.insert(name.clone());
}
let owner = owners
.iter()
.rev()
.find(|(_, at, _)| depth == *at + 1)
.map(|(name, _, _)| name.clone());
if keyword == "enum" {
enums.push((name.clone(), depth, is_public));
}
out.push(Decl {
owner,
name,
is_public,
vis,
file: file.to_string(),
line: number,
});
depth += brace_delta(line);
continue;
}
if let Some((enum_name, at, enum_public)) = enums.last().cloned() {
if depth == at + 1 {
if let Some(variant) = enum_variant(line) {
out.push(Decl {
owner: Some(enum_name),
name: variant,
is_public: enum_public,
vis: if enum_public { "pub" } else { "" }.to_string(),
file: file.to_string(),
line: number,
});
}
}
}
}
depth += brace_delta(line);
}
}
pub fn doc_text(line: &str) -> Option<(bool, String)> {
let trimmed = line.trim_start();
if let Some(rest) = trimmed.strip_prefix("///") {
return Some((false, rest.to_string()));
}
if let Some(rest) = trimmed.strip_prefix("//!") {
return Some((true, rest.to_string()));
}
None
}
pub fn doc_blocks(source: &str, module_is_public: bool) -> Vec<DocBlock> {
let lines: Vec<&str> = source.lines().collect();
let mut blocks = Vec::new();
let mut inner: Vec<(usize, String)> = Vec::new();
let mut index = 0usize;
while index < lines.len() {
let Some((is_inner, text)) = doc_text(lines[index]) else {
index += 1;
continue;
};
if is_inner {
inner.push((index + 1, text));
index += 1;
continue;
}
let mut collected = vec![(index + 1, text)];
let mut cursor = index + 1;
while cursor < lines.len() {
if let Some((false, more)) = doc_text(lines[cursor]) {
collected.push((cursor + 1, more));
cursor += 1;
continue;
}
let trimmed = lines[cursor].trim_start();
if trimmed.starts_with('#') || trimmed.is_empty() {
cursor += 1;
continue;
}
break;
}
let carrier = lines
.get(cursor)
.and_then(|line| parse_item(line).map(|(vis, _, _)| (vis, line)))
.filter(|_| module_is_public)
.and_then(|(vis, line)| {
visibility_is_public(&vis).then(|| Carrier::Public {
line: cursor + 1,
decl: line.trim().to_string(),
})
})
.unwrap_or(Carrier::Private);
blocks.push(DocBlock {
lines: collected,
carrier,
});
index = cursor.max(index + 1);
}
if !inner.is_empty() {
blocks.push(DocBlock {
lines: inner,
carrier: if module_is_public {
Carrier::Public {
line: 1,
decl: "module".to_string(),
}
} else {
Carrier::Private
},
});
}
blocks
}
pub fn reference_label(text: &str) -> Option<String> {
let trimmed = text.trim_start();
let rest = trimmed.strip_prefix('[')?;
let close = rest.find(']')?;
if !rest[close + 1..].starts_with(':') {
return None;
}
Some(rest[..close].trim_matches('`').to_string())
}
pub fn links_in_block(block: &DocBlock) -> Vec<(usize, String)> {
let defined: BTreeSet<String> = block
.lines
.iter()
.filter_map(|(_, text)| reference_label(text))
.collect();
let mut out = Vec::new();
for (number, text) in &block.lines {
if reference_label(text).is_some() {
continue;
}
for target in extract_targets(text) {
if defined.contains(&target) {
continue;
}
out.push((*number, target));
}
}
out
}
pub fn extract_targets(text: &str) -> Vec<String> {
let bytes: Vec<char> = text.chars().collect();
let mut out = Vec::new();
let mut index = 0usize;
while index < bytes.len() {
if bytes[index] != '[' {
index += 1;
continue;
}
let Some(close) = (index + 1..bytes.len()).find(|i| bytes[*i] == ']') else {
break;
};
let inside: String = bytes[index + 1..close].iter().collect();
let after: String = bytes[close + 1..].iter().collect();
if let Some(rest) = after.strip_prefix('(') {
if let Some(end) = rest.find(')') {
let target = rest[..end].trim();
if is_path_like(target) {
out.push(target.to_string());
}
index = close + 1;
continue;
}
}
let target = inside.trim().trim_matches('`').trim();
if is_path_like(target) {
out.push(target.to_string());
}
index = close + 1;
}
out
}
pub fn is_path_like(text: &str) -> bool {
!text.is_empty()
&& !text.contains(char::is_whitespace)
&& text
.chars()
.all(|c| c.is_alphanumeric() || c == '_' || c == ':')
&& text
.chars()
.next()
.is_some_and(|c| c.is_alphabetic() || c == '_')
}
pub fn resolve(index: &BTreeMap<(Option<String>, String), Vec<Decl>>, link: &str) -> Verdict {
let segments: Vec<&str> = link.split("::").filter(|s| !s.is_empty()).collect();
let Some(first) = segments.first() else {
return Verdict::Skipped;
};
if SKIPPED_ROOTS.contains(first) {
return Verdict::Skipped;
}
let qualified = *first == "crate";
let path: Vec<&str> = if qualified {
segments[1..].to_vec()
} else {
segments.clone()
};
if path.is_empty() || (!qualified && path.len() == 1) {
return Verdict::Skipped;
}
let name = path[path.len() - 1].to_string();
let owner = path.get(path.len().wrapping_sub(2)).and_then(|seg| {
seg.starts_with(|c: char| c.is_uppercase())
.then(|| (*seg).to_string())
});
let Some(candidates) = index.get(&(owner, name)) else {
return Verdict::Unresolved;
};
if candidates.iter().any(|d| d.is_public) {
return Verdict::Public;
}
let first = &candidates[0];
Verdict::Private {
file: first.file.clone(),
line: first.line,
vis: first.vis.clone(),
}
}
pub fn analyze(sources: &[(String, String)]) -> (Vec<Finding>, Vec<(String, String)>) {
let mut decls = Vec::new();
let mut public_modules = BTreeSet::new();
for (file, source) in sources {
index_declarations(source, file, &mut decls, &mut public_modules);
}
let mut index: BTreeMap<(Option<String>, String), Vec<Decl>> = BTreeMap::new();
for decl in decls {
index
.entry((decl.owner.clone(), decl.name.clone()))
.or_default()
.push(decl);
}
let mut findings = Vec::new();
let mut unresolved = Vec::new();
for (file, source) in sources {
let public = module_is_public(Path::new(file), &public_modules);
for block in doc_blocks(source, public) {
let Carrier::Public {
line: carrier_line,
ref decl,
} = block.carrier
else {
continue;
};
for (line, link) in links_in_block(&block) {
match resolve(&index, &link) {
Verdict::Private {
file: target_file,
line: target_line,
vis,
} => findings.push(Finding {
file: file.clone(),
line,
link,
carrier: decl.clone(),
carrier_line,
target_file,
target_line,
target_vis: vis,
}),
Verdict::Unresolved if link.starts_with("crate::") => {
unresolved.push((file.clone(), link));
}
_ => {}
}
}
}
}
(findings, unresolved)
}
pub fn module_is_public(path: &Path, public_modules: &BTreeSet<String>) -> bool {
let name = path.file_stem().map(|s| s.to_string_lossy().to_string());
match name.as_deref() {
Some("lib") | Some("main") => true,
Some("mod") => path
.parent()
.and_then(Path::file_name)
.map(|s| public_modules.contains(&s.to_string_lossy().to_string()))
.unwrap_or(false),
Some(stem) => public_modules.contains(stem),
None => false,
}
}
pub fn rust_files(root: &Path, out: &mut Vec<PathBuf>) {
let Ok(entries) = std::fs::read_dir(root) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
rust_files(&path, out);
} else if path.extension().is_some_and(|ext| ext == "rs") {
out.push(path);
}
}
}
pub fn relative(path: &Path, repo: &Path) -> String {
path.strip_prefix(repo)
.unwrap_or(path)
.to_string_lossy()
.replace('\\', "/")
}
pub fn repo_root() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
}
pub fn is_test_only(path: &Path) -> bool {
let text = path.to_string_lossy().replace('\\', "/");
text.contains("/tests/") || text.ends_with("_tests.rs") || text.ends_with("/tests.rs")
}
pub fn library_sources() -> Vec<(String, String)> {
let repo = repo_root();
let mut files = Vec::new();
rust_files(&repo.join("src"), &mut files);
files.sort();
files
.iter()
.filter(|path| !is_test_only(path))
.filter_map(|path| {
std::fs::read_to_string(path)
.ok()
.map(|text| (relative(path, &repo), text))
})
.collect()
}