use std::collections::{HashMap, HashSet};
use std::sync::LazyLock;
use regex::Regex;
use crate::ast::{Block, Doc, Example};
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Reference {
pub path: String,
pub slug: String,
pub text: String,
}
#[derive(Clone, Default)]
pub struct OathWorkspace {
pub docs: HashMap<String, Doc>,
pub referenced: HashSet<String>,
}
static LINK_ONLY: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"^\[([^\]]*)\]\(\s*([^\s)]+)\s*\)$").unwrap());
static PROTOCOL: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"(?i)^[a-z][a-z0-9+.\-]*:").unwrap());
static NOT_SLUG: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"[^\p{L}\p{N} _-]").unwrap());
static CODE_SPAN: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"`([^`]*)`").unwrap());
static STRONG: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\*\*([^*]*)\*\*").unwrap());
static EMPH: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\*([^*]*)\*").unwrap());
static UNDER: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"_([^_]*)_").unwrap());
pub fn reference_of(text: &str, from_path: &str) -> Option<Reference> {
let caps = LINK_ONLY.captures(text.trim())?;
let link_text = caps.get(1).map_or("", |m| m.as_str()).to_string();
let target = caps.get(2).map_or("", |m| m.as_str());
if let Some(fragment) = target.strip_prefix('#') {
return Some(Reference {
path: from_path.to_string(),
slug: normalize_slug(fragment),
text: link_text,
});
}
let (file_part, fragment) = match target.find('#') {
Some(i) => (&target[..i], &target[i + 1..]),
None => (target, ""),
};
if !file_part.ends_with(".md") || PROTOCOL.is_match(file_part) || file_part.starts_with('/') {
return None;
}
Some(Reference {
path: join_posix(dirname_posix(from_path), file_part),
slug: normalize_slug(fragment),
text: link_text,
})
}
pub fn slugify(heading_text: &str) -> String {
let s = CODE_SPAN.replace_all(heading_text, "$1");
let s = STRONG.replace_all(&s, "$1");
let s = EMPH.replace_all(&s, "$1");
let s = UNDER.replace_all(&s, "$1");
normalize_slug(&s)
}
fn normalize_slug(s: &str) -> String {
NOT_SLUG
.replace_all(s.trim().to_lowercase().as_str(), "")
.replace(' ', "-")
}
fn dirname_posix(path: &str) -> &str {
match path.rfind('/') {
Some(i) => &path[..i],
None => "",
}
}
pub fn join_posix(dir: &str, rel: &str) -> String {
let mut segments: Vec<&str> = if dir.is_empty() {
Vec::new()
} else {
dir.split('/').collect()
};
for segment in rel.split('/') {
match segment {
"" | "." => continue,
".." => match segments.last() {
Some(&"..") | None => segments.push(".."),
Some(_) => {
segments.pop();
}
},
other => segments.push(other),
}
}
segments.join("/")
}
pub fn references(doc: &Doc) -> Vec<Reference> {
doc.examples
.iter()
.filter_map(|ex| block_text(ex.body.first()?).and_then(|t| reference_of(t, &doc.path)))
.collect()
}
fn block_text(block: &Block) -> Option<&str> {
match block {
Block::Paragraph(p) => Some(&p.text),
Block::ListItem(l) => Some(&l.text),
Block::Blockquote(b) => Some(&b.text),
_ => None,
}
}
pub fn section_key(path: &str, slug: &str) -> String {
format!("{path}#{slug}")
}
pub fn empty_workspace() -> OathWorkspace {
OathWorkspace::default()
}
pub fn build_workspace(docs: &[Doc]) -> OathWorkspace {
let mut ws = OathWorkspace::default();
for doc in docs {
ws.docs.insert(doc.path.clone(), doc.clone());
}
for doc in docs {
for r in references(doc) {
ws.referenced.insert(section_key(&r.path, &r.slug));
}
}
ws
}
pub fn section_candidates<'a>(doc: &'a Doc, slug: &str) -> Vec<&'a Example> {
if slug.is_empty() {
return doc.examples.iter().collect();
}
doc.examples
.iter()
.filter(|ex| ex.scope_stack.iter().any(|h| slugify(h) == slug))
.collect()
}