specfold-core 0.7.1

Library behind specfold: typed Markdown documents, owners, a derived graph and checks.
Documentation
//! L1: every [[reference]] resolves to one document.

use super::targets::{ids, remote_problem};
use crate::check::context::Ctx;
use crate::check::finding::Finding;
use crate::check::rule::skipped;
use crate::check::rule::{Rule, RuleInfo};
use crate::corpus::graph::Resolution;
use crate::model::RefText;
use crate::suggest::shell::quote;

pub struct LinksResolve;

/// The external root (an agent's memory, personal notes) holding a note named `target`.
fn external_note(ctx: &Ctx, target: &str) -> Option<String> {
    let cfg = &ctx.corpus.cfg;
    cfg.raw
        .external
        .iter()
        .find(|(_, ext)| cfg.expand_path(&ext.path).join(format!("{target}.md")).is_file())
        .map(|(name, _)| name.clone())
}

impl Rule for LinksResolve {
    fn info(&self) -> &RuleInfo {
        &RuleInfo {
            id: "L1",
            title: "every [[reference]] resolves to one document",
            explain: "Body references are written [[<id>]] or [[<id>#<anchor>]]. A reference that names nothing, an anchor that does not exist, or an alias shared by two documents is an error. A Markdown link whose target is a document id, `[text](<id>)`, is reported with the [[<id>|text]] to write instead (`spec fix --rule L1 --write` rewrites it); the built-in export rewrites the ones that resolve. An alias that resolves is accepted but reported, with the id to write instead; `spec fix --rule L1 --write` rewrites them all. [[<remote>:<id>]] names a document in a repository listed in [remotes]; it is checked when that directory is on disk and skipped when it is not.",
            on_commit: true,
        }
    }

    fn run(&self, ctx: &Ctx, out: &mut Vec<Finding>) {
        for (i, d) in ctx.corpus.docs.iter().enumerate() {
            if !ctx.scope.covers_links(i) || skipped(d, "L1") {
                continue;
            }
            for l in &d.links {
                match ctx.graph.resolve(&l.item) {
                    Resolution::Doc { idx, anchor, via_alias: true } => {
                        let mut id = ctx.corpus.docs[idx].id.to_string();
                        if let Some(a) = anchor {
                            id = format!("{id}#{a}");
                        }
                        let how = if ctx.graph.by_slug.contains_key(RefText::parse(&l.item).target.as_str()) {
                            "names by its slug"
                        } else {
                            "is an alias of"
                        };
                        out.push(
                            Finding::warn("L1", &l.path, l.line, format!("[[{}]] {how} {id}", l.item))
                                .fix_replace(format!("[[{}]]", l.item), format!("[[{id}]]")),
                        );
                    }
                    Resolution::Doc { .. } => {}
                    Resolution::Ambiguous(list) => {
                        let c = ids(ctx, &list);
                        out.push(
                            Finding::error(
                                "L1",
                                &l.path,
                                l.line,
                                format!("[[{}]] is ambiguous ({} documents)", l.item, c.len()),
                            )
                            .fix_cmd(format!(
                                "spec fix --rule L1 --pick {} --write {}:{}",
                                crate::suggest::shell::word(&c[0]),
                                quote(&l.path),
                                l.line
                            ))
                            .with_candidates(c),
                        );
                    }
                    Resolution::MissingAnchor { idx, anchor } => {
                        let have: Vec<String> = ctx.graph.anchors[idx].iter().cloned().collect();
                        out.push(
                            Finding::error(
                                "L1",
                                &l.path,
                                l.line,
                                format!("{} has no anchor #{anchor}", ctx.corpus.docs[idx].id),
                            )
                            .with_candidates(have),
                        );
                    }
                    Resolution::Missing => {
                        if let Some(msg) = remote_problem(ctx, &l.item, None) {
                            if let Some(m) = msg {
                                out.push(Finding::error("L1", &l.path, l.line, format!("[[{}]] {m}", l.item)));
                            }
                            continue;
                        }
                        let target = RefText::parse(&l.item).target;
                        if let Some(root) = external_note(ctx, &target) {
                            out.push(Finding::warn(
                                "L1",
                                &l.path,
                                l.line,
                                format!(
                                    "[[{}]] is a note in the external root {root}, which other readers cannot open",
                                    l.item
                                ),
                            ));
                            continue;
                        }
                        out.push(
                            Finding::error("L1", &l.path, l.line, format!("[[{}]] names no document", l.item))
                                .fix_cmd(format!("spec find {}", quote(&target))),
                        );
                    }
                }
            }
            md_id_links(ctx, d, out);
        }
    }
}

/// `[text](plan:root/…)` is a Markdown link, not a reference: nothing checks it and a reader's
/// tool cannot open it. Warns and offers `[[id|text]]`.
fn md_id_links(ctx: &Ctx, d: &crate::model::Doc, out: &mut Vec<Finding>) {
    let files = std::iter::once(d.path.as_str()).chain(d.chapters.iter().map(String::as_str));
    for path in files {
        let Some(text) = ctx.current(path) else { continue };
        for o in crate::export::links::md_id_occurrences(&text) {
            let ty = o.target.split(':').next().unwrap_or_default();
            if !ctx.corpus.docs.iter().any(|x| x.id.ty == ty) {
                continue;
            }
            let line = text[..o.span.0].matches('\n').count() + 1;
            let resolved = ctx.graph.resolve(&o.target);
            // A slug id or alias is written as the canonical id, so the fix needs no second pass.
            let target = match &resolved {
                Resolution::Doc { idx, anchor, .. } => {
                    let id = ctx.corpus.docs[*idx].id.to_string();
                    anchor.as_ref().map_or(id.clone(), |a| format!("{id}#{a}"))
                }
                _ => o.target.clone(),
            };
            let wiki = match &o.label {
                Some(l) => format!("[[{target}|{l}]]"),
                None => format!("[[{target}]]"),
            };
            let f = match resolved {
                Resolution::Doc { .. } => Finding::warn(
                    "L1",
                    path,
                    line,
                    format!("{} is a Markdown link to a document; write {wiki} so check and export follow it", o.raw),
                )
                .fix_replace(o.raw.clone(), wiki),
                _ => Finding::warn(
                    "L1",
                    path,
                    line,
                    format!(
                        "{} is a Markdown link to a document id that names no document; write {wiki} with the id from spec find",
                        o.raw
                    ),
                ),
            };
            out.push(f);
        }
    }
}