Skip to main content

specfold_core/authoring/
fix.rs

1//! Applying the fixes that need no judgement: exact replacements reported by rules, missing
2//! frontmatter keys, and a chosen target for an ambiguous reference.
3
4use super::plan::Plan;
5use crate::check::finding::{Finding, Fix};
6use crate::corpus::Corpus;
7use crate::markdown::frontmatter::edit;
8use crate::model::stamp;
9use crate::workspace::vcs;
10use anyhow::Result;
11use serde_json::Value;
12use std::collections::BTreeMap;
13
14#[derive(Debug, Clone, Default)]
15pub struct Pick {
16    /// The id to use instead of an ambiguous reference.
17    pub id: String,
18    pub path: String,
19    pub line: usize,
20}
21
22fn replace_on_line(text: &str, line: usize, from: &str, to: &str) -> Option<String> {
23    let mut lines: Vec<String> = text.split_inclusive('\n').map(str::to_string).collect();
24    let l = lines.get_mut(line.checked_sub(1)?)?;
25    if !l.contains(from) {
26        return None;
27    }
28    *l = l.replacen(from, to, 1);
29    Some(lines.concat())
30}
31
32/// Fills id, title, status, created and updated where they are missing.
33fn fill_frontmatter(c: &Corpus, idx: usize, text: &str) -> Result<String> {
34    let d = &c.docs[idx];
35    let initial = c.cfg.registry.get(&d.id.ty).map(|t| t.initial.clone()).unwrap_or_else(|| "draft".into());
36    let created = vcs::first_commit_stamp(&c.cfg.root, &d.path)
37        .and_then(|s| stamp::normalize_date(&s))
38        .unwrap_or_else(stamp::today);
39    let title = d.title.clone().unwrap_or_else(|| d.slug.replace('-', " "));
40    edit::rewrite(text, |m| {
41        m.entry("id").or_insert_with(|| Value::String(d.id.to_string()));
42        m.entry("title").or_insert_with(|| Value::String(title));
43        m.entry("status").or_insert_with(|| Value::String(initial));
44        m.entry("created").or_insert_with(|| Value::String(created));
45        m.entry("updated").or_insert_with(|| Value::String(stamp::now_value()));
46        let ordered = edit::ordered(std::mem::take(m));
47        *m = ordered;
48    })
49}
50
51/// Keeps `alias` on the document `keep` and removes it from every other document that carries
52/// it, in frontmatter `aliases` and in the alias table. Returns the number of files changed.
53fn drop_alias(c: &Corpus, texts: &mut BTreeMap<String, String>, alias: &str, keep: &str) -> Result<usize> {
54    let mut changed = 0;
55    for d in &c.docs {
56        if d.id.to_string() == keep || !d.list_field("aliases").iter().any(|a| a == alias) {
57            continue;
58        }
59        let Some(t) = texts.get(&d.path).cloned().or_else(|| std::fs::read_to_string(c.cfg.root.join(&d.path)).ok())
60        else {
61            continue;
62        };
63        let n = edit::rewrite(&t, |m| {
64            if let Some(Value::Array(list)) = m.get_mut("aliases") {
65                list.retain(|v| v.as_str() != Some(alias));
66                if list.is_empty() {
67                    m.remove("aliases");
68                }
69            }
70        })?;
71        if n != t {
72            texts.insert(d.path.clone(), n);
73            changed += 1;
74        }
75    }
76    if let Some(file) = &c.aliases.file {
77        let t = texts.get(file).cloned().or_else(|| std::fs::read_to_string(c.cfg.root.join(file)).ok());
78        if let Some(t) = t {
79            let n: String = t
80                .split_inclusive('\n')
81                .filter(|l| match l.trim_end().split_once('\t') {
82                    Some((old, new)) => old != alias || new.trim() == keep,
83                    None => true,
84                })
85                .collect();
86            if n != t {
87                texts.insert(file.clone(), n);
88                changed += 1;
89            }
90        }
91    }
92    Ok(changed)
93}
94
95pub fn plan(c: &Corpus, findings: &[Finding], pick: Option<&Pick>) -> Result<(Plan, usize)> {
96    let mut texts: BTreeMap<String, String> = BTreeMap::new();
97    let mut applied = 0;
98    let read = |texts: &mut BTreeMap<String, String>, p: &str| -> Option<String> {
99        if let Some(t) = texts.get(p) {
100            return Some(t.clone());
101        }
102        std::fs::read_to_string(c.cfg.root.join(p)).ok()
103    };
104    for f in findings {
105        match (&f.fix, f.rule.as_str()) {
106            (Some(Fix::Replace { path, line, from, to }), _) => {
107                let Some(t) = read(&mut texts, path) else { continue };
108                if let Some(n) = replace_on_line(&t, *line, from, to) {
109                    texts.insert(path.clone(), n);
110                    applied += 1;
111                }
112            }
113            (_, "S1") if f.message.starts_with("missing") || f.message == "no frontmatter block" => {
114                let Some(idx) = c.docs.iter().position(|d| d.path == f.path) else { continue };
115                let Some(t) = read(&mut texts, &f.path) else { continue };
116                texts.insert(f.path.clone(), fill_frontmatter(c, idx, &t)?);
117                applied += 1;
118            }
119            (_, "L1") if !f.candidates.is_empty() => {
120                let Some(p) = pick.filter(|p| p.path == f.path && p.line == f.line && f.candidates.contains(&p.id))
121                else {
122                    continue;
123                };
124                let Some(t) = read(&mut texts, &f.path) else { continue };
125                let Some(target) = f.message.split("]]").next().and_then(|s| s.strip_prefix("[[")) else { continue };
126                let target_base = target.split('#').next().unwrap_or(target);
127                if let Some(n) = replace_on_line(&t, f.line, &format!("[[{target_base}"), &format!("[[{}", p.id)) {
128                    texts.insert(f.path.clone(), n);
129                    applied += 1;
130                }
131            }
132            (_, "I2") if f.candidates.len() > 1 => {
133                let Some(p) = pick.filter(|p| p.path == f.path && f.candidates.contains(&p.id)) else { continue };
134                let Some(alias) = f.message.strip_prefix("alias '").and_then(|m| m.split_once("' names")).map(|x| x.0)
135                else {
136                    continue;
137                };
138                applied += drop_alias(c, &mut texts, alias, &p.id)?;
139            }
140            _ => {}
141        }
142    }
143    let mut plan = Plan::default();
144    for (path, content) in texts {
145        plan.write(path, content, false);
146    }
147    Ok((plan, applied))
148}
149
150/// Writes `rel.translation_of` and `lang` into every translation the `[translations]` layout
151/// pairs by name alone, so the pair survives a rename or a layout change.
152pub fn translation_pairs(c: &Corpus) -> Result<(Plan, usize)> {
153    let mut plan = Plan::default();
154    let mut n = 0;
155    for (i, d) in c.docs.iter().enumerate() {
156        if crate::translation::source_ref(c, i).is_some() {
157            continue;
158        }
159        let Some(inf) = crate::translation::inferred(c, i) else { continue };
160        let Some(src) = inf.source else { continue };
161        let Ok(t) = std::fs::read_to_string(c.cfg.root.join(&d.path)) else { continue };
162        let source = c.docs[src].id.to_string();
163        let new = edit::rewrite(&t, |m| {
164            m.entry("lang").or_insert_with(|| Value::String(inf.lang.clone()));
165            let rel = m.entry("rel").or_insert_with(|| Value::Object(Default::default()));
166            if let Value::Object(r) = rel {
167                r.insert(crate::translation::RELATION.into(), Value::Array(vec![Value::String(source.clone())]));
168            }
169            let ordered = edit::ordered(std::mem::take(m));
170            *m = ordered;
171        })?;
172        if new != t {
173            plan.write(d.path.clone(), new, false);
174            n += 1;
175        }
176    }
177    Ok((plan, n))
178}