Skip to main content

specfold_core/authoring/
refs.rs

1//! Rewriting references to a document when its id changes.
2
3use regex::Regex;
4use serde_json::{Map, Value};
5
6/// Replaces `[[old]]` and `[[old#anchor]]` (and `[[old|label]]`) with the new id in Markdown text.
7pub fn rewrite_body(text: &str, old: &str, new: &str) -> String {
8    let re = Regex::new(&format!(r"\[\[{}(#[^\]|]*)?(\|[^\]]*)?\]\]", regex::escape(old)))
9        .expect("an escaped id always forms a valid regex");
10    re.replace_all(text, |c: &regex::Captures| {
11        format!("[[{new}{}{}]]", c.get(1).map_or("", |m| m.as_str()), c.get(2).map_or("", |m| m.as_str()))
12    })
13    .into_owned()
14}
15
16/// Cites `id` where `text` names `path`: a path already written as a reference, `[[path]]` or
17/// `[[path|label]]`, keeps its brackets and label instead of gaining a second pair (C257).
18pub fn cite_path_as_id(text: &str, path: &str, id: &str) -> String {
19    text.replace(&format!("[[{path}"), &format!("[[{id}")).replace(path, &format!("[[{id}]]"))
20}
21
22/// Whether a cited path names `path` (root-relative), ignoring leading `./` and `../`.
23pub fn same_path(cited: &str, path: &str) -> bool {
24    let mut t = cited.trim_end_matches('/');
25    while let Some(rest) = t.strip_prefix("../").or_else(|| t.strip_prefix("./")) {
26        t = rest;
27    }
28    t == path
29}
30
31/// Replaces the id inside `rel.*` lists, keeping anchors.
32pub fn rewrite_rel(map: &mut Map<String, Value>, old: &str, new: &str) -> bool {
33    let Some(Value::Object(rel)) = map.get_mut("rel") else { return false };
34    let mut changed = false;
35    for v in rel.values_mut() {
36        let Value::Array(items) = v else { continue };
37        for item in items.iter_mut() {
38            let Some(s) = item.as_str() else { continue };
39            let (target, anchor) = s.split_once('#').map(|(t, a)| (t, Some(a))).unwrap_or((s, None));
40            if target == old {
41                *item = Value::String(match anchor {
42                    Some(a) => format!("{new}#{a}"),
43                    None => new.to_string(),
44                });
45                changed = true;
46            }
47        }
48    }
49    changed
50}
51
52/// `a/./b/../c` as `a/c`; `None` when it climbs above the root.
53fn normalize(path: &str) -> Option<String> {
54    let mut out: Vec<&str> = vec![];
55    for part in path.split('/') {
56        match part {
57            "" | "." => {}
58            ".." => {
59                out.pop()?;
60            }
61            p => out.push(p),
62        }
63    }
64    Some(out.join("/"))
65}
66
67/// The path of `to` written relative to the folder holding `from`.
68fn relative(from: &str, to: &str) -> String {
69    let base: Vec<&str> =
70        from.rsplit_once('/').map(|x| x.0).unwrap_or("").split('/').filter(|p| !p.is_empty()).collect();
71    let target: Vec<&str> = to.split('/').collect();
72    let common = base.iter().zip(&target).take_while(|(a, b)| a == b).count();
73    let mut parts: Vec<&str> = vec![".."; base.len() - common];
74    parts.extend(&target[common..]);
75    parts.join("/")
76}
77
78/// Rewrites local Markdown links in `file` that point at `old` so they point at `new`:
79/// inline, angle-bracket, percent-encoded and reference-definition forms, outside code.
80pub fn rewrite_relative_links(body: &str, file: &str, old: &str, new: &str) -> String {
81    let dir = file.rsplit_once('/').map(|x| x.0).unwrap_or("");
82    crate::markdown::link_dest::rewrite(body, |target| {
83        let local = !target.is_empty() && !target.contains("://") && !target.starts_with('/');
84        let joined = if dir.is_empty() { target.to_string() } else { format!("{dir}/{target}") };
85        (local && normalize(&joined).as_deref() == Some(old)).then(|| relative(file, new))
86    })
87    .0
88}
89
90/// C234: relative links to anything under a moved bundle directory (its attachments, its
91/// chapters) follow the directory to its new place.
92pub fn rewrite_bundle_links(body: &str, file: &str, old_dir: &str, new_dir: &str) -> String {
93    let dir = file.rsplit_once('/').map(|x| x.0).unwrap_or("");
94    let prefix = format!("{old_dir}/");
95    crate::markdown::link_dest::rewrite(body, |target| {
96        let local = !target.is_empty() && !target.contains("://") && !target.starts_with('/');
97        let joined = if dir.is_empty() { target.to_string() } else { format!("{dir}/{target}") };
98        let rest = normalize(&joined).filter(|_| local)?.strip_prefix(&prefix)?.to_string();
99        Some(relative(file, &format!("{new_dir}/{rest}")))
100    })
101    .0
102}
103
104#[cfg(test)]
105mod tests {
106    use super::*;
107
108    #[test]
109    fn rewrites() {
110        let t = "a [[adr:x/1]] b [[adr:x/1#K-1|see]] c [[adr:x/10]]";
111        assert_eq!(rewrite_body(t, "adr:x/1", "adr:y/1"), "a [[adr:y/1]] b [[adr:y/1#K-1|see]] c [[adr:x/10]]");
112        let mut m: Map<String, Value> =
113            serde_json::from_str(r#"{"rel":{"implements":["adr:x/1#A","sot:z/q"]}}"#).unwrap();
114        assert!(rewrite_rel(&mut m, "adr:x/1", "adr:y/1"));
115        assert_eq!(m["rel"]["implements"][0], "adr:y/1#A");
116    }
117}