use super::{doc_blocks, links_in_block};
use std::collections::BTreeSet;
pub const FOREIGN_ROOTS: &[&str] = &["std", "core", "alloc"];
pub const VOID_ELEMENTS: &[&str] = &[
"area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "param", "source",
"track", "wbr",
];
#[derive(Debug, PartialEq, Eq)]
pub struct BrokenLink {
pub file: String,
pub line: usize,
pub link: String,
pub leaf: String,
}
#[derive(Debug, PartialEq, Eq)]
pub struct StrayTag {
pub file: String,
pub line: usize,
pub tag: String,
pub problem: &'static str,
}
#[derive(Debug, PartialEq, Eq)]
pub struct HtmlTag {
pub name: String,
pub raw: String,
pub closing: bool,
pub self_closing: bool,
}
pub fn code_identifiers(sources: &[(String, String)]) -> BTreeSet<String> {
let mut out = BTreeSet::new();
for (_, source) in sources {
for line in source.lines() {
if line.trim_start().starts_with("//") {
continue;
}
let mut current = String::new();
for ch in line.chars() {
if ch.is_alphanumeric() || ch == '_' {
current.push(ch);
continue;
}
if !current.is_empty() {
out.insert(std::mem::take(&mut current));
}
}
if !current.is_empty() {
out.insert(current);
}
}
}
out
}
pub fn broken_links(sources: &[(String, String)]) -> Vec<BrokenLink> {
let corpus = code_identifiers(sources);
let mut out = Vec::new();
for (file, source) in sources {
for block in doc_blocks(source, true) {
for (line, link) in links_in_block(&block) {
let segments: Vec<&str> = link.split("::").filter(|s| !s.is_empty()).collect();
let (Some(first), Some(leaf)) = (segments.first(), segments.last()) else {
continue;
};
if FOREIGN_ROOTS.contains(first) {
continue;
}
if corpus.contains(*leaf) {
continue;
}
out.push(BrokenLink {
file: file.clone(),
line,
link: link.clone(),
leaf: (*leaf).to_string(),
});
}
}
}
out
}
pub fn prose_lines(lines: &[(usize, String)]) -> Vec<(usize, String)> {
let mut out = Vec::new();
let mut span = 0usize;
let mut fence: Option<char> = None;
for (number, text) in lines {
let trimmed = text.trim_start();
let fence_char = fence_marker(trimmed);
match (fence, fence_char) {
(None, Some(ch)) if span == 0 => {
fence = Some(ch);
out.push((*number, String::new()));
continue;
}
(Some(open), Some(ch)) if open == ch => {
fence = None;
out.push((*number, String::new()));
continue;
}
(Some(_), _) => {
out.push((*number, String::new()));
continue;
}
_ => {}
}
let mut kept = String::new();
let chars: Vec<char> = text.chars().collect();
let mut index = 0usize;
while index < chars.len() {
if chars[index] == '`' {
let mut end = index;
while end < chars.len() && chars[end] == '`' {
end += 1;
}
let run = end - index;
if span == 0 {
span = run;
} else if span == run {
span = 0;
}
index = end;
continue;
}
kept.push(if span == 0 { chars[index] } else { ' ' });
index += 1;
}
out.push((*number, kept));
}
out
}
pub fn fence_marker(trimmed: &str) -> Option<char> {
for marker in ['`', '~'] {
let run = trimmed.chars().take_while(|c| *c == marker).count();
if run >= 3 {
return Some(marker);
}
}
None
}
pub fn html_tags(text: &str) -> Vec<HtmlTag> {
let chars: Vec<char> = text.chars().collect();
let mut out = Vec::new();
let mut index = 0usize;
while index < chars.len() {
if chars[index] != '<' {
index += 1;
continue;
}
let mut cursor = index + 1;
let closing = chars.get(cursor) == Some(&'/');
if closing {
cursor += 1;
}
let start = cursor;
while cursor < chars.len()
&& (chars[cursor].is_alphanumeric() || chars[cursor] == '-' || chars[cursor] == '_')
{
cursor += 1;
}
if cursor == start {
index += 1;
continue;
}
let Some(end) = (cursor..chars.len()).find(|i| chars[*i] == '>') else {
break;
};
let raw: String = chars[start..cursor].iter().collect();
let rest: String = chars[cursor..end].iter().collect();
index = end + 1;
if rest.starts_with(':') || rest.contains('@') {
continue;
}
out.push(HtmlTag {
name: raw.to_lowercase(),
raw,
closing,
self_closing: rest.trim_end().ends_with('/'),
});
}
out
}
pub fn stray_html_tags(sources: &[(String, String)]) -> Vec<StrayTag> {
let mut out = Vec::new();
for (file, source) in sources {
for block in doc_blocks(source, true) {
let mut open: Vec<(String, String, usize)> = Vec::new();
for (number, text) in prose_lines(&block.lines) {
for tag in html_tags(&text) {
if tag.self_closing || VOID_ELEMENTS.contains(&tag.name.as_str()) {
continue;
}
if !tag.closing {
open.push((tag.name, tag.raw, number));
continue;
}
match open.iter().rposition(|(name, _, _)| *name == tag.name) {
Some(at) => {
open.truncate(at);
}
None => out.push(StrayTag {
file: file.clone(),
line: number,
tag: format!("</{}>", tag.raw),
problem: "closing tag with nothing open",
}),
}
}
}
for (_, raw, number) in open {
out.push(StrayTag {
file: file.clone(),
line: number,
tag: format!("<{raw}>"),
problem: "unclosed tag; wrap the type in backticks",
});
}
}
}
out
}