Skip to main content

varar_core/
reference.rs

1//! Reuse is a link (ADR 0016). A candidate block whose entire content is a
2//! single Markdown link to an oath section is a REFERENCE BLOCK: it splices
3//! that section's steps in at its own position instead of being prose.
4//!
5//! Everything here is pure text and path arithmetic — no filesystem. The shell
6//! reads the documents; [`references`] tells it which ones to read, and
7//! [`build_workspace`] turns the collection into what `plan` needs.
8
9use std::collections::{HashMap, HashSet};
10use std::sync::LazyLock;
11
12use regex::Regex;
13
14use crate::ast::{Block, Doc, Example};
15
16/// One resolved reference block: the referenced oath's path (resolved against
17/// the referring doc's own path), the GFM slug of the heading (empty for a
18/// whole-file link), and the link's visible text.
19#[derive(Clone, Debug, PartialEq, Eq)]
20pub struct Reference {
21    pub path: String,
22    pub slug: String,
23    pub text: String,
24}
25
26/// What `plan` needs to resolve references: every oath by path, plus which
27/// sections a reference block consumes somewhere in the project. A section that
28/// is referenced stops being a standalone example, so this is whole-project
29/// knowledge — see ADR 0016 on why each runner builds it at its once-per-run
30/// discovery pass.
31#[derive(Clone, Default)]
32pub struct OathWorkspace {
33    pub docs: HashMap<String, Doc>,
34    /// `"{path}#{slug}"` for every referenced section; a whole-file reference
35    /// is recorded as `"{path}#"`.
36    pub referenced: HashSet<String>,
37}
38
39// A candidate is a reference block iff its whole text is one Markdown link
40// whose target is oath-shaped. Anything else — a link with surrounding words, a
41// link to https://…, to a .rs file, to a mailto: — is ordinary content, so
42// existing documents keep their meaning.
43static LINK_ONLY: LazyLock<Regex> =
44    LazyLock::new(|| Regex::new(r"^\[([^\]]*)\]\(\s*([^\s)]+)\s*\)$").unwrap());
45static PROTOCOL: LazyLock<Regex> =
46    LazyLock::new(|| Regex::new(r"(?i)^[a-z][a-z0-9+.\-]*:").unwrap());
47static NOT_SLUG: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"[^\p{L}\p{N} _-]").unwrap());
48static CODE_SPAN: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"`([^`]*)`").unwrap());
49static STRONG: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\*\*([^*]*)\*\*").unwrap());
50static EMPH: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\*([^*]*)\*").unwrap());
51static UNDER: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"_([^_]*)_").unwrap());
52
53/// The reference a block's text spells, or `None` when it is ordinary content.
54pub fn reference_of(text: &str, from_path: &str) -> Option<Reference> {
55    let caps = LINK_ONLY.captures(text.trim())?;
56    let link_text = caps.get(1).map_or("", |m| m.as_str()).to_string();
57    let target = caps.get(2).map_or("", |m| m.as_str());
58    if let Some(fragment) = target.strip_prefix('#') {
59        return Some(Reference {
60            path: from_path.to_string(),
61            slug: normalize_slug(fragment),
62            text: link_text,
63        });
64    }
65    let (file_part, fragment) = match target.find('#') {
66        Some(i) => (&target[..i], &target[i + 1..]),
67        None => (target, ""),
68    };
69    // Only a relative Markdown path is a reference. A protocol (https:, mailto:)
70    // or any other extension is left alone — remote references are deliberately
71    // out of scope (ADR 0016).
72    if !file_part.ends_with(".md") || PROTOCOL.is_match(file_part) || file_part.starts_with('/') {
73        return None;
74    }
75    Some(Reference {
76        path: join_posix(dirname_posix(from_path), file_part),
77        slug: normalize_slug(fragment),
78        text: link_text,
79    })
80}
81
82/// GitHub's heading anchors: inline markup dropped, lowercased, spaces to
83/// hyphens, everything else that isn't a word character or hyphen removed. The
84/// same function produces the slug of a heading and normalizes the slug written
85/// in a link, so the two meet in the middle.
86pub fn slugify(heading_text: &str) -> String {
87    let s = CODE_SPAN.replace_all(heading_text, "$1");
88    let s = STRONG.replace_all(&s, "$1");
89    let s = EMPH.replace_all(&s, "$1");
90    let s = UNDER.replace_all(&s, "$1");
91    normalize_slug(&s)
92}
93
94fn normalize_slug(s: &str) -> String {
95    // One hyphen per space, not per run of them: GitHub leaves the gap where it
96    // dropped punctuation, so "Fees, VAT & rounding" slugs with a double hyphen.
97    NOT_SLUG
98        .replace_all(s.trim().to_lowercase().as_str(), "")
99        .replace(' ', "-")
100}
101
102fn dirname_posix(path: &str) -> &str {
103    match path.rfind('/') {
104        Some(i) => &path[..i],
105        None => "",
106    }
107}
108
109/// POSIX path arithmetic on oath paths (always '/'-separated, relative to the
110/// workspace root). The core may not touch the filesystem.
111pub fn join_posix(dir: &str, rel: &str) -> String {
112    let mut segments: Vec<&str> = if dir.is_empty() {
113        Vec::new()
114    } else {
115        dir.split('/').collect()
116    };
117    for segment in rel.split('/') {
118        match segment {
119            "" | "." => continue,
120            // Climbing above the workspace root keeps its leading `..`: the
121            // oath-path convention deliberately spells an oath outside the root
122            // as `../x.md`, so the resolver must produce the same spelling.
123            ".." => match segments.last() {
124                Some(&"..") | None => segments.push(".."),
125                Some(_) => {
126                    segments.pop();
127                }
128            },
129            other => segments.push(other),
130        }
131    }
132    segments.join("/")
133}
134
135/// Every reference block in a document, in document order. The shell uses this
136/// to walk the closure of documents it must read before planning.
137pub fn references(doc: &Doc) -> Vec<Reference> {
138    doc.examples
139        .iter()
140        .filter_map(|ex| block_text(ex.body.first()?).and_then(|t| reference_of(t, &doc.path)))
141        .collect()
142}
143
144fn block_text(block: &Block) -> Option<&str> {
145    match block {
146        Block::Paragraph(p) => Some(&p.text),
147        Block::ListItem(l) => Some(&l.text),
148        Block::Blockquote(b) => Some(&b.text),
149        _ => None,
150    }
151}
152
153/// A section's identity across the project.
154pub fn section_key(path: &str, slug: &str) -> String {
155    format!("{path}#{slug}")
156}
157
158/// The workspace with no references at all: what a caller planning a single
159/// document in isolation passes.
160pub fn empty_workspace() -> OathWorkspace {
161    OathWorkspace::default()
162}
163
164/// Index every oath by path and record every consumed section.
165pub fn build_workspace(docs: &[Doc]) -> OathWorkspace {
166    let mut ws = OathWorkspace::default();
167    for doc in docs {
168        ws.docs.insert(doc.path.clone(), doc.clone());
169    }
170    for doc in docs {
171        for r in references(doc) {
172            ws.referenced.insert(section_key(&r.path, &r.slug));
173        }
174    }
175    ws
176}
177
178/// The candidates that make up a section: those whose heading chain contains
179/// the slug. A whole-file reference (empty slug) is every candidate in the
180/// document. Section membership follows the document outline exactly — a
181/// heading's section runs until the next heading of the same or higher level,
182/// which is precisely the range over which it stays on the scope stack.
183pub fn section_candidates<'a>(doc: &'a Doc, slug: &str) -> Vec<&'a Example> {
184    if slug.is_empty() {
185        return doc.examples.iter().collect();
186    }
187    doc.examples
188        .iter()
189        .filter(|ex| ex.scope_stack.iter().any(|h| slugify(h) == slug))
190        .collect()
191}