use regex::Regex;
use serde::{Deserialize, Serialize};
use std::sync::OnceLock;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Link {
pub text: String,
pub line: usize,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Anchor {
pub name: String,
pub heading: String,
pub line: usize,
}
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
pub struct Body {
pub links: Vec<Link>,
pub anchors: Vec<Anchor>,
pub path_cites: Vec<Link>,
pub first_heading: Option<String>,
pub lines: usize,
}
pub fn wiki_re() -> &'static Regex {
static R: OnceLock<Regex> = OnceLock::new();
R.get_or_init(|| Regex::new(r"\[\[([^\[\]\n|]+)(?:\|[^\[\]\n]*)?\]\]").expect("static regex is valid"))
}
fn anchor_re() -> &'static Regex {
static R: OnceLock<Regex> = OnceLock::new();
R.get_or_init(|| Regex::new(r"\{#([A-Za-z0-9][A-Za-z0-9_.-]*)\}\s*$").expect("static regex is valid"))
}
pub fn strip_code_spans(line: &str) -> String {
let b = line.as_bytes();
let run_at = |i: usize| b[i..].iter().take_while(|c| **c == b'`').count();
let (mut out, mut i, mut kept) = (String::new(), 0, 0);
while i < b.len() {
if b[i] != b'`' {
i += 1;
continue;
}
let n = run_at(i);
let mut j = i + n;
let close = loop {
match b[j..].iter().position(|c| *c == b'`') {
None => break None,
Some(p) => {
let at = j + p;
let m = run_at(at);
if m == n {
break Some(at + m);
}
j = at + m;
}
}
};
match close {
Some(end) => {
out.push_str(&line[kept..i]);
kept = end;
i = end;
}
None => i += n,
}
}
out.push_str(&line[kept..]);
out
}
pub fn path_cite_re(spec_dir: &str) -> Regex {
let dir = regex::escape(spec_dir);
Regex::new(&format!(r"(?:[A-Za-z0-9_.@-]+/)*{dir}/[A-Za-z0-9_./@-]+\.md"))
.expect("an escaped spec dir always forms a valid regex")
}
pub fn scan(body: &str, first_line: usize, path_re: &Regex) -> Body {
let mut out = Body::default();
let mut fence = crate::markdown::Fence::default();
for (i, raw_line) in body.lines().enumerate() {
let line_no = first_line + i;
out.lines += 1;
let trimmed = raw_line.trim_start();
if fence.step(trimmed) || fence.inside() {
continue;
}
let line = strip_code_spans(raw_line);
if let Some((_, heading_text)) = crate::markdown::atx(raw_line) {
let heading_clean = anchor_re().replace(heading_text, "").trim().to_string();
if out.first_heading.is_none() && !heading_clean.is_empty() {
out.first_heading = Some(heading_clean.clone());
}
if let Some(c) = anchor_re().captures(heading_text) {
out.anchors.push(Anchor { name: c[1].to_string(), heading: heading_clean, line: line_no });
}
}
for c in wiki_re().captures_iter(&line) {
out.links.push(Link { text: c[1].trim().to_string(), line: line_no });
}
for m in path_re.find_iter(&line) {
out.path_cites.push(Link { text: m.as_str().to_string(), line: line_no });
}
}
out
}
#[cfg(test)]
mod tests {
#[test]
fn c201_code_spans_pair_equal_backtick_runs() {
use super::strip_code_spans as s;
assert_eq!(s("Also ``c ` d`` with [[x]] and `y`."), "Also with [[x]] and .");
assert_eq!(s("`` `id` ``"), "");
assert_eq!(s("a ` b [[x]]"), "a ` b [[x]]", "an unpaired run is text");
assert_eq!(s("`a` ``b"), " ``b");
}
use super::*;
#[test]
fn finds_links_anchors_and_cites() {
let body = "# Checkout\n\nSee [[adr:shop/20260101000000]] and [[sot:shop/cart#CO-1|the rule]].\n\n## Totals {#CO-1}\n\n```\n[[not:a/link]]\n```\nInline `[[not:a/link]]` too.\nOld path services/shop/.spec/plan/x.plan.md here.\n";
let b = scan(body, 5, &path_cite_re(".spec"));
assert_eq!(b.links.len(), 2);
assert_eq!(b.links[0].line, 7);
assert_eq!(b.links[1].text, "sot:shop/cart#CO-1");
assert_eq!(b.anchors[0].name, "CO-1");
assert_eq!(b.anchors[0].heading, "Totals");
assert_eq!(b.first_heading.as_deref(), Some("Checkout"));
assert_eq!(b.path_cites[0].text, "services/shop/.spec/plan/x.plan.md");
}
}