Skip to main content

omgbase_store/
macros.rs

1//! Macros (`spec/mutate/README.md` §5): each expands deterministically to
2//! kernel ops the caller then applies (and sees). The expansions read the
3//! store — live hashes for the CAS tokens, the section runs, the `nodes`
4//! rows for `node_set`, the candidate blocks for `links_repair`.
5
6use std::collections::{BTreeMap, HashMap};
7use std::sync::LazyLock;
8
9use omgbase_format::hash::hex;
10use omgbase_mutate::{At, ErrorCode, Expect, MutationError, Op, Parent, To};
11use regex::Regex;
12use rusqlite::{Connection, OptionalExtension, params};
13use serde_json::{Map, Value, json};
14
15use crate::Store;
16use crate::error::{Error, Result};
17use crate::links::{glob_clause, rewrite_link_destinations, split_destination};
18use crate::read::blob_text;
19
20fn raw_hash_of_block(conn: &Connection, block_id: &str) -> Result<Option<String>> {
21    let h: Option<Vec<u8>> = conn
22        .query_row(
23            "SELECT raw_hash FROM blocks WHERE block_id = ?1 AND deleted_commit IS NULL",
24            params![block_id],
25            |r| r.get(0),
26        )
27        .optional()?;
28    Ok(h.map(|h| hex(&h)))
29}
30
31fn raw_of_block(conn: &Connection, block_id: &str) -> Result<Option<String>> {
32    let h: Option<Vec<u8>> = conn
33        .query_row(
34            "SELECT raw_hash FROM blocks WHERE block_id = ?1 AND deleted_commit IS NULL",
35            params![block_id],
36            |r| r.get(0),
37        )
38        .optional()?;
39    h.map(|h| blob_text(conn, &h)).transpose()
40}
41
42fn update_markdown(block: &str, markdown: String, hash: Option<String>) -> Op {
43    Op::Update {
44        block: block.to_owned(),
45        markdown: Some(markdown),
46        attrs: None,
47        expect: hash.map(Expect::content),
48        trivia: None,
49        child_ids: None,
50    }
51}
52
53/// One rewritten block of a link repair (a dry-run preview).
54#[derive(Clone, Debug, PartialEq, Eq)]
55pub struct RetargetHit {
56    pub block: String,
57    /// The block's document path.
58    pub path: String,
59    pub old_raw: String,
60    pub new_raw: String,
61}
62
63/// A `from` → `to` destination rewrite.
64#[derive(Clone, Debug, PartialEq, Eq)]
65pub struct LinkRepair {
66    pub from: String,
67    pub to: String,
68}
69
70/// A pair with how many destinations it matched.
71#[derive(Clone, Debug, PartialEq, Eq)]
72pub struct LinkRepairCount {
73    pub from: String,
74    pub to: String,
75    pub hits: usize,
76}
77
78/// What `links_repair` returns (§5).
79#[derive(Clone, Debug, PartialEq)]
80pub struct LinkRepairPlan {
81    /// One `update` per rewritten top-most block.
82    pub ops: Vec<Op>,
83    pub hits: Vec<RetargetHit>,
84    pub pairs: Vec<LinkRepairCount>,
85}
86
87impl LinkRepairPlan {
88    /// `{ hits: [{ block, path, old_raw, new_raw }], pairs: [{ from, to, hits }] }`
89    /// — the extra the fixture records beside `ops`.
90    #[must_use]
91    pub fn extras_json(&self) -> Map<String, Value> {
92        let mut m = Map::new();
93        m.insert(
94            "hits".to_owned(),
95            Value::Array(
96                self.hits
97                    .iter()
98                    .map(|h| json!({ "block": h.block, "path": h.path, "old_raw": h.old_raw, "new_raw": h.new_raw }))
99                    .collect(),
100            ),
101        );
102        m.insert(
103            "pairs".to_owned(),
104            Value::Array(
105                self.pairs
106                    .iter()
107                    .map(|p| json!({ "from": p.from, "to": p.to, "hits": p.hits }))
108                    .collect(),
109            ),
110        );
111        m
112    }
113}
114
115fn strip_slash(s: &str) -> &str {
116    s.strip_prefix('/').unwrap_or(s)
117}
118
119/// Whether authored destination `dest` names the same target as `from`
120/// (whole destination first, then the path part with its fragment riding
121/// along); the fragment to re-append.
122fn destination_match<'a>(dest: &'a str, from: &str) -> Option<&'a str> {
123    if strip_slash(dest) == strip_slash(from) {
124        return Some("");
125    }
126    let (path, fragment) = split_destination(dest);
127    (!fragment.is_empty() && strip_slash(path) == strip_slash(from)).then_some(fragment)
128}
129
130static HEADING_LEVEL: LazyLock<Regex> =
131    LazyLock::new(|| Regex::new(r"^(#{1,6})\s").expect("regex"));
132
133/// The editable property names for a `(format, kind)`.
134#[must_use]
135pub fn editable_props_for(format: &str, kind: &str) -> Vec<&'static str> {
136    if format != "markdown" {
137        return Vec::new();
138    }
139    match kind {
140        "md:link" => vec!["name", "value"],
141        "md:task" => vec!["checked"],
142        _ => Vec::new(),
143    }
144}
145
146static LINK_TEXT: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^\[[^\]]*\]").expect("regex"));
147static LINK_TARGET: LazyLock<Regex> =
148    LazyLock::new(|| Regex::new(r#"\]\(([^)\s]+)(\s+"[^"]*")?\)$"#).expect("regex"));
149
150impl Store {
151    /// §5 `tasks_complete`: one `update { attrs: { checked: true } }` per
152    /// block, with the live CAS token when the block is known.
153    pub fn tasks_complete(&self, blocks: &[String]) -> Result<Vec<Op>> {
154        let mut attrs = Map::new();
155        attrs.insert("checked".to_owned(), json!(true));
156        blocks
157            .iter()
158            .map(|b| {
159                Ok(Op::Update {
160                    block: b.clone(),
161                    markdown: None,
162                    attrs: Some(attrs.clone()),
163                    expect: raw_hash_of_block(&self.conn, b)?.map(Expect::content),
164                    trivia: None,
165                    child_ids: None,
166                })
167            })
168            .collect()
169    }
170
171    /// §5 `sections_append`.
172    #[must_use]
173    pub fn sections_append(heading: &str, markdown: &str) -> Vec<Op> {
174        vec![Op::Insert {
175            doc: None,
176            to: To {
177                parent: Parent::Section {
178                    heading: heading.to_owned(),
179                },
180                at: At::End,
181            },
182            markdown: markdown.to_owned(),
183            expect: None,
184        }]
185    }
186
187    /// §5 `docs_append`.
188    #[must_use]
189    pub fn docs_append(doc: &str, markdown: &str) -> Vec<Op> {
190        vec![Op::Insert {
191            doc: Some(doc.to_owned()),
192            to: To {
193                parent: Parent::Doc,
194                at: At::End,
195            },
196            markdown: markdown.to_owned(),
197            expect: None,
198        }]
199    }
200
201    /// §5 `sections_rename`: the level from the live raw's leading `#` run.
202    pub fn sections_rename(&self, heading: &str, title: &str) -> Result<Vec<Op>> {
203        let raw = raw_of_block(&self.conn, heading)?.unwrap_or_default();
204        let level = HEADING_LEVEL.captures(&raw).map_or(1, |c| c[1].len());
205        let hash = raw_hash_of_block(&self.conn, heading)?;
206        Ok(vec![update_markdown(
207            heading,
208            format!("{} {title}", "#".repeat(level)),
209            hash,
210        )])
211    }
212
213    /// §5 `sections_move`: the heading's section run (top-level ids from the
214    /// heading to before the next heading of level ≤ its own); `[]` when the
215    /// heading is unknown.
216    pub fn sections_move(&self, heading: &str, to: &To) -> Result<Vec<Op>> {
217        let row: Option<(String, Option<i64>)> = self
218            .conn
219            .query_row(
220                "SELECT doc_id, json_extract(attrs, '$.level') FROM blocks WHERE block_id = ?1",
221                params![heading],
222                |r| Ok((r.get(0)?, r.get(1)?)),
223            )
224            .optional()?;
225        let Some((doc_id, level)) = row else {
226            return Ok(Vec::new());
227        };
228        let level = level.unwrap_or(1);
229        let tops: Vec<(String, String, Option<i64>)> = {
230            let mut stmt = self.conn.prepare(
231                "SELECT block_id, type, json_extract(attrs, '$.level') FROM blocks
232                 WHERE doc_id = ?1 AND parent_block IS NULL AND deleted_commit IS NULL ORDER BY ordinal",
233            )?;
234            let it = stmt.query_map(params![doc_id], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)))?;
235            it.collect::<std::result::Result<Vec<_>, _>>()?
236        };
237        // JavaScript `findIndex`/`slice` semantics, including a -1 start.
238        let start_idx: i64 = tops
239            .iter()
240            .position(|t| t.0 == heading)
241            .map_or(-1, |i| i as i64);
242        let mut end = tops.len();
243        for (i, t) in tops.iter().enumerate() {
244            if (i as i64) <= start_idx {
245                continue;
246            }
247            if t.1 == "heading" && t.2.unwrap_or(1) <= level {
248                end = i;
249                break;
250            }
251        }
252        let start = if start_idx < 0 {
253            (tops.len() as i64 + start_idx).max(0) as usize
254        } else {
255            start_idx as usize
256        };
257        let run: Vec<String> = if start < end {
258            tops[start..end].iter().map(|t| t.0.clone()).collect()
259        } else {
260            Vec::new()
261        };
262        Ok(vec![Op::Move {
263            blocks: run,
264            to: to.clone(),
265            expect: None,
266        }])
267    }
268
269    /// §5 `lists_insert_item`.
270    #[must_use]
271    pub fn lists_insert_item(anchor: &str, at: At, markdown: &str) -> Vec<Op> {
272        let item = if markdown.trim_start().starts_with("- ") {
273            markdown.to_owned()
274        } else {
275            format!("- {markdown}")
276        };
277        vec![Op::Insert {
278            doc: None,
279            to: To {
280                parent: Parent::Block(anchor.to_owned()),
281                at,
282            },
283            markdown: item,
284            expect: None,
285        }]
286    }
287
288    /// §5 `node_set`: the adapter's editor for `(kind, prop)` over the node's
289    /// block raw and byte span → one `update` with the CAS token.
290    pub fn node_set(&self, node_id: &str, prop: &str, value: &str) -> Result<Vec<Op>> {
291        type NodeRow = (
292            String,
293            Option<String>,
294            Option<String>,
295            String,
296            Option<i64>,
297            Option<i64>,
298            Option<String>,
299            String,
300        );
301        let row: Option<NodeRow> = self
302            .conn
303            .query_row(
304                "SELECT n.kind, n.name, n.value, n.attrs, n.span_start, n.span_end, n.block_id, d.format
305                 FROM nodes n JOIN docs d ON d.doc_id = n.doc_id WHERE n.node_id = ?1",
306                params![node_id],
307                |r| {
308                    Ok((
309                        r.get(0)?,
310                        r.get(1)?,
311                        r.get(2)?,
312                        r.get(3)?,
313                        r.get(4)?,
314                        r.get(5)?,
315                        r.get(6)?,
316                        r.get(7)?,
317                    ))
318                },
319            )
320            .optional()?;
321        let Some((kind, _name, _value, _attrs, span_start, span_end, block_id, format)) = row
322        else {
323            return Err(MutationError::new(
324                ErrorCode::BlockMissing,
325                format!("node {node_id} not found"),
326            )
327            .into());
328        };
329        let Some(block_id) = block_id else {
330            return Err(MutationError::new(
331                ErrorCode::NodeNotEditable,
332                format!("node {node_id} is not anchored to a block"),
333            )
334            .into());
335        };
336        let editable = editable_props_for(&format, &kind);
337        if !editable.contains(&prop) {
338            return Err(MutationError::with_data(
339                ErrorCode::NodeNotEditable,
340                format!("no editor for {kind}.{prop}"),
341                json!({ "kind": kind, "prop": prop, "editable": editable }),
342            )
343            .into());
344        }
345        let Some(block_raw) = raw_of_block(&self.conn, &block_id)? else {
346            return Err(MutationError::new(
347                ErrorCode::BlockMissing,
348                format!("block {block_id} not found"),
349            )
350            .into());
351        };
352        let hash = raw_hash_of_block(&self.conn, &block_id)?;
353        let span = match (span_start, span_end) {
354            (Some(s), Some(e)) => {
355                let s = usize::try_from(s).unwrap_or(0).min(block_raw.len());
356                let e = usize::try_from(e).unwrap_or(0).clamp(s, block_raw.len());
357                Some((s, e))
358            }
359            _ => None,
360        };
361        let seg = |(s, e): (usize, usize)| -> Result<&str> {
362            block_raw.get(s..e).ok_or_else(|| {
363                Error::Other(format!("node span {s}..{e} is not on a char boundary"))
364            })
365        };
366        match (kind.as_str(), prop) {
367            ("md:link", "name") => {
368                let span = span
369                    .ok_or_else(|| Error::Other("md:link.name requires a recorded span".into()))?;
370                let seg = seg(span)?;
371                let rebuilt = LINK_TEXT
372                    .replace(seg, format!("[{value}]").as_str())
373                    .into_owned();
374                if rebuilt == seg {
375                    return Err(Error::Other(format!(
376                        "could not locate link text in {}",
377                        serde_json::to_string(seg).unwrap_or_default()
378                    )));
379                }
380                let md = format!("{}{rebuilt}{}", &block_raw[..span.0], &block_raw[span.1..]);
381                Ok(vec![update_markdown(&block_id, md, hash)])
382            }
383            ("md:link", "value") => {
384                let span = span
385                    .ok_or_else(|| Error::Other("md:link.value requires a recorded span".into()))?;
386                let seg = seg(span)?;
387                let rebuilt = LINK_TARGET
388                    .replace(seg, |caps: &regex::Captures<'_>| {
389                        format!("]({value}{})", caps.get(2).map_or("", |m| m.as_str()))
390                    })
391                    .into_owned();
392                if rebuilt == seg {
393                    return Err(Error::Other(format!(
394                        "could not locate link target in {}",
395                        serde_json::to_string(seg).unwrap_or_default()
396                    )));
397                }
398                let md = format!("{}{rebuilt}{}", &block_raw[..span.0], &block_raw[span.1..]);
399                Ok(vec![update_markdown(&block_id, md, hash)])
400            }
401            ("md:task", "checked") => {
402                let mut attrs = Map::new();
403                attrs.insert("checked".to_owned(), json!(value == "true" || value == "1"));
404                Ok(vec![Op::Update {
405                    block: block_id,
406                    markdown: None,
407                    attrs: Some(attrs),
408                    expect: hash.map(Expect::content),
409                    trivia: None,
410                    child_ids: None,
411                }])
412            }
413            _ => unreachable!("editable props are enumerated above"),
414        }
415    }
416
417    /// §5 `links_retarget`: the one-pair form of [`Store::links_repair`].
418    pub fn links_retarget(
419        &self,
420        repo_id: &str,
421        from: &str,
422        to: &str,
423        path_glob: Option<&str>,
424    ) -> Result<LinkRepairPlan> {
425        self.links_repair(
426            repo_id,
427            &[LinkRepair {
428                from: from.to_owned(),
429                to: to.to_owned(),
430            }],
431            path_glob,
432        )
433    }
434
435    /// §5 `links_repair`: for every live non-`code_fence` block whose raw
436    /// contains a slash-less `from`, rewrite each whole link destination that
437    /// names it (first matching pair wins, fragment re-appended, never inside
438    /// code spans); one `update` per changed top-most block.
439    pub fn links_repair(
440        &self,
441        repo_id: &str,
442        repairs: &[LinkRepair],
443        path_glob: Option<&str>,
444    ) -> Result<LinkRepairPlan> {
445        let mut pairs: Vec<LinkRepairCount> = repairs
446            .iter()
447            .map(|r| LinkRepairCount {
448                from: r.from.clone(),
449                to: r.to.clone(),
450                hits: 0,
451            })
452            .collect();
453        let effective: Vec<usize> = pairs
454            .iter()
455            .enumerate()
456            .filter(|(_, r)| !strip_slash(&r.from).is_empty() && r.from != r.to)
457            .map(|(i, _)| i)
458            .collect();
459        if effective.is_empty() {
460            return Ok(LinkRepairPlan {
461                ops: Vec::new(),
462                hits: Vec::new(),
463                pairs,
464            });
465        }
466        let (glob_sql, glob_param) = match path_glob {
467            Some(g) => {
468                let (clause, param) = glob_clause("d.path", g);
469                (format!("AND {clause}"), Some(param))
470            }
471            None => (String::new(), None),
472        };
473        let sql = format!(
474            "SELECT bl.block_id, bl.parent_block, d.path, b.bytes
475             FROM blocks bl
476             JOIN blobs b ON b.hash = bl.raw_hash
477             JOIN docs d ON d.doc_id = bl.doc_id
478             WHERE bl.repo_id = ?1 AND bl.deleted_commit IS NULL AND d.deleted_commit IS NULL
479               AND bl.type != 'code_fence' AND instr(b.bytes, ?2) > 0 {glob_sql}
480             ORDER BY d.path, bl.depth, bl.ordinal, bl.block_id"
481        );
482        struct Candidate {
483            parent: Option<String>,
484            path: String,
485            raw: String,
486        }
487        let mut order: Vec<String> = Vec::new();
488        let mut candidates: HashMap<String, Candidate> = HashMap::new();
489        let mut keys_seen: Vec<String> = Vec::new();
490        for &i in &effective {
491            let key = strip_slash(&pairs[i].from).to_owned();
492            if keys_seen.contains(&key) {
493                continue;
494            }
495            keys_seen.push(key.clone());
496            let mut stmt = self.conn.prepare(&sql)?;
497            let rows: Vec<(String, Option<String>, String, Vec<u8>)> = match &glob_param {
498                Some(p) => stmt
499                    .query_map(params![repo_id, key, p], |r| {
500                        Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?))
501                    })?
502                    .collect::<std::result::Result<Vec<_>, _>>()?,
503                None => stmt
504                    .query_map(params![repo_id, key], |r| {
505                        Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?))
506                    })?
507                    .collect::<std::result::Result<Vec<_>, _>>()?,
508            };
509            for (block_id, parent, path, bytes) in rows {
510                if let std::collections::hash_map::Entry::Vacant(slot) =
511                    candidates.entry(block_id.clone())
512                {
513                    order.push(block_id);
514                    slot.insert(Candidate {
515                        parent,
516                        path,
517                        raw: String::from_utf8_lossy(&bytes).into_owned(),
518                    });
519                }
520            }
521        }
522        struct Changed {
523            parent: Option<String>,
524            path: String,
525            old_raw: String,
526            new_raw: String,
527            tally: Vec<usize>,
528        }
529        let mut changed_order: Vec<String> = Vec::new();
530        let mut changed: HashMap<String, Changed> = HashMap::new();
531        for block_id in &order {
532            let c = &candidates[block_id];
533            let mut tally = vec![0usize; effective.len()];
534            let new_raw = rewrite_link_destinations(&c.raw, |dest| {
535                for (k, &i) in effective.iter().enumerate() {
536                    if let Some(fragment) = destination_match(dest, &pairs[i].from) {
537                        tally[k] += 1;
538                        return Some(format!("{}{fragment}", pairs[i].to));
539                    }
540                }
541                None
542            });
543            if new_raw != c.raw {
544                changed_order.push(block_id.clone());
545                changed.insert(
546                    block_id.clone(),
547                    Changed {
548                        parent: c.parent.clone(),
549                        path: c.path.clone(),
550                        old_raw: c.raw.clone(),
551                        new_raw,
552                        tally,
553                    },
554                );
555            }
556        }
557        let parent_of = |id: &str| -> Result<Option<String>> {
558            Ok(self
559                .conn
560                .query_row(
561                    "SELECT parent_block FROM blocks WHERE block_id = ?1 AND deleted_commit IS NULL",
562                    params![id],
563                    |r| r.get::<_, Option<String>>(0),
564                )
565                .optional()?
566                .flatten())
567        };
568        let mut ops = Vec::new();
569        let mut hits = Vec::new();
570        for block_id in &changed_order {
571            let c = &changed[block_id];
572            let mut p = c.parent.clone();
573            let mut has_changed_ancestor = false;
574            while let Some(pid) = p {
575                if changed.contains_key(&pid) {
576                    has_changed_ancestor = true;
577                    break;
578                }
579                p = parent_of(&pid)?;
580            }
581            if has_changed_ancestor {
582                continue;
583            }
584            for (k, &i) in effective.iter().enumerate() {
585                pairs[i].hits += c.tally[k];
586            }
587            let hash = raw_hash_of_block(&self.conn, block_id)?;
588            ops.push(update_markdown(block_id, c.new_raw.clone(), hash));
589            hits.push(RetargetHit {
590                block: block_id.clone(),
591                path: c.path.clone(),
592                old_raw: c.old_raw.clone(),
593                new_raw: c.new_raw.clone(),
594            });
595        }
596        Ok(LinkRepairPlan { ops, hits, pairs })
597    }
598}
599
600/// Parse a macro's `expect`-free op list back from JSON (for callers that
601/// carry expansions as JSON).
602pub fn ops_from_json(v: &Value) -> Result<Vec<Op>> {
603    v.as_array()
604        .ok_or_else(|| Error::Other("ops must be an array".into()))?
605        .iter()
606        .map(|o| Op::from_json(o).map_err(Error::Other))
607        .collect()
608}
609
610/// A `BTreeMap` of per-block expectations from JSON.
611pub fn expect_map_from_json(v: &Value) -> Result<BTreeMap<String, Expect>> {
612    let obj = v
613        .as_object()
614        .ok_or_else(|| Error::Other("expect must be an object".into()))?;
615    obj.iter()
616        .map(|(k, e)| {
617            let h = e
618                .get("content_hash")
619                .and_then(Value::as_str)
620                .map(str::to_owned);
621            Ok((
622                k.clone(),
623                Expect {
624                    content_hash: h,
625                    parent_children_hash: e
626                        .get("parent_children_hash")
627                        .and_then(Value::as_str)
628                        .map(str::to_owned),
629                },
630            ))
631        })
632        .collect()
633}
634
635#[cfg(test)]
636mod tests {
637    use super::*;
638
639    #[test]
640    fn destination_matching_ignores_one_leading_slash_and_keeps_fragments() {
641        assert_eq!(destination_match("/x.md", "x.md"), Some(""));
642        assert_eq!(destination_match("x.md", "/x.md"), Some(""));
643        assert_eq!(destination_match("/x.md#S", "/x.md"), Some("#S"));
644        assert_eq!(destination_match("/x.md^r", "x.md"), Some("^r"));
645        assert_eq!(destination_match("/xx.md", "x.md"), None);
646        assert_eq!(destination_match("/a/x.md", "x.md"), None);
647    }
648
649    #[test]
650    fn pure_expansions() {
651        let ops = Store::sections_append("b_1", "text");
652        assert!(
653            matches!(&ops[0], Op::Insert { doc: None, to, markdown, .. } if to.parent == Parent::Section { heading: "b_1".into() } && markdown == "text")
654        );
655        let ops = Store::docs_append("a.md", "text");
656        assert!(matches!(&ops[0], Op::Insert { doc: Some(d), .. } if d == "a.md"));
657        let ops = Store::lists_insert_item("b_0", At::End, "four");
658        assert!(matches!(&ops[0], Op::Insert { markdown, .. } if markdown == "- four"));
659        let ops = Store::lists_insert_item("b_0", At::Start, "  - zero");
660        assert!(matches!(&ops[0], Op::Insert { markdown, .. } if markdown == "  - zero"));
661        assert_eq!(editable_props_for("markdown", "md:link"), ["name", "value"]);
662        assert_eq!(editable_props_for("markdown", "md:task"), ["checked"]);
663        assert!(editable_props_for("markdown", "md:section").is_empty());
664        assert!(editable_props_for("yaml", "md:link").is_empty());
665    }
666}