Skip to main content

prov_graph/
content.rs

1//! Body-prose parsing via `twig` — prov's answer to the `content_format`
2//! knob deferred in `docs/next-steps.md`, and the ingredient that makes
3//! body-link findings code-aware (DESIGN §8's principle: a `[[…]]` that is
4//! really code, e.g. `[[inf] * n for _ in range(m)]]` inside backticks, must
5//! never be treated as a link).
6//!
7//! `twig` (a sister Zig-backed project) parses Markdown/Djot into a shared AST.
8//! [`render_html`] and [`code_spans`] are direct FFI calls into it — `twig`'s C
9//! ABI exposes `twig_document_render_html` and `twig_document_nodes`, no
10//! subprocess involved. (`code_spans` used to bind a code-block-specific
11//! accessor, then a selector query per code-bearing kind; it now filters the
12//! flat node array by kind, which needs one call and reaches the detached
13//! definition subtrees a query does not — see `spans_where`.) `twig` is a
14//! required dependency, so these are always available.
15//!
16//! Pair [`code_spans`] with [`crate::link::scan_wikilinks`] (which is what
17//! actually uses it) to keep a body-link scan from ever treating code as
18//! prose.
19
20use std::ops::Range;
21use std::path::Path;
22
23/// Which body-prose grammar a document is written in. Maps to a `twig`
24/// [`twig::Format`] one-to-one; kept as prov's own type so callers can name
25/// a format without depending on `twig` directly, e.g. for the `content_format`
26/// config knob.
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum ContentFormat {
29    Markdown,
30    Djot,
31    Html,
32}
33
34impl ContentFormat {
35    /// Infer the content format from a path's extension. `None` for anything
36    /// unrecognized (including config extensions, which have no body).
37    pub fn from_extension(path: &Path) -> Option<Self> {
38        match path.extension()?.to_str()? {
39            "md" | "markdown" => Some(Self::Markdown),
40            "dj" | "djot" => Some(Self::Djot),
41            "html" | "htm" => Some(Self::Html),
42            _ => None,
43        }
44    }
45
46    fn twig_format(self) -> twig::Format {
47        match self {
48            Self::Markdown => twig::Format::Markdown,
49            Self::Djot => twig::Format::Djot,
50            Self::Html => twig::Format::Html,
51        }
52    }
53
54    /// The canonical file extension for this grammar (no leading dot) — what a
55    /// freshly authored document's filename gets when prov derives a name
56    /// from a title (`prov new "A Title"`). The inverse of the primary
57    /// [`from_extension`](Self::from_extension) spelling.
58    pub fn extension(self) -> &'static str {
59        match self {
60            Self::Markdown => "md",
61            Self::Djot => "dj",
62            Self::Html => "html",
63        }
64    }
65
66    /// The `content_format` config-document spelling for this grammar.
67    pub fn as_config_str(self) -> &'static str {
68        match self {
69            Self::Markdown => "markdown",
70            Self::Djot => "djot",
71            Self::Html => "html",
72        }
73    }
74
75    /// Parse a `content_format` config value. Unknown → `None` (keep default).
76    pub fn from_config_str(value: &str) -> Option<Self> {
77        match value {
78            "markdown" | "md" => Some(Self::Markdown),
79            "djot" | "dj" => Some(Self::Djot),
80            "html" | "htm" => Some(Self::Html),
81            _ => None,
82        }
83    }
84
85    /// Whether [transcoding](transcode) between `self` and `other` loses authored
86    /// structure badly enough to need an explicit `--force`.
87    ///
88    /// HTML is the lossy endpoint, in both directions. *Into* HTML is a one-way
89    /// trip: the result is a rendering, and the Markdown or Djot the author wrote
90    /// — the `#`, the `_emph_`, the fence — is gone from the file, recoverable
91    /// only by re-deriving a guess at it. *Out of* HTML is that trip run
92    /// backwards, and everything HTML carries that a prose grammar has no spelling
93    /// for (attributes, nested inline markup, whole elements) survives only as a
94    /// raw-HTML escape, or not at all.
95    ///
96    /// Markdown ↔ Djot is deliberately not gated: twig re-spells emphasis,
97    /// headings and raw HTML into the target grammar and carries footnotes,
98    /// tables, code fences and `[[wikilinks]]` through intact. The one wart is a
99    /// reference-style link, which is inlined (`[x][ref]` → `[x](notes/b.md)`),
100    /// leaving its now-unused `[ref]:` definition behind — untidy, but nothing a
101    /// reader loses.
102    pub fn is_lossy_to(self, other: Self) -> bool {
103        self != other && (self == Self::Html || other == Self::Html)
104    }
105}
106
107/// Parse `body` as `format` with `twig`. Shared by [`render_html`] and
108/// [`code_spans`] so both go through the same error mapping.
109fn parse(body: &str, format: ContentFormat) -> crate::error::Result<twig::Document> {
110    twig::Document::parse_str(body, format.twig_format())
111        .map_err(|e| crate::error::Error::Content(format!("twig parse: {e}")))
112}
113
114/// Transcode a body from the `from` grammar into the `to` grammar.
115///
116/// Two callers, one move. It is what every page prov *authors* rather than reads
117/// goes through — `prov`'s `about` page, the history store's index and event
118/// bodies are written as Markdown in the Rust source, where they are legible to
119/// whoever maintains them, and converted here to whatever grammar the workspace
120/// actually uses (without which an HTML workspace ends up holding `.html` files
121/// whose bodies are literal `# Heading` Markdown: prov reads them back fine, and
122/// every other tool in the world does not). It is also the engine behind
123/// `convert <file> content_format`, where `from` is the grammar the document's
124/// own extension declares rather than always Markdown.
125///
126/// A body already in the target grammar is returned untouched: twig's serializer
127/// is idempotent here, but round-tripping would buy nothing and risks reflowing
128/// prose the author deliberately wrapped.
129pub fn transcode(
130    body: &str,
131    from: ContentFormat,
132    to: ContentFormat,
133) -> crate::error::Result<String> {
134    if from == to {
135        return Ok(body.to_string());
136    }
137    let mut doc = parse(body, from)?;
138    let out = doc
139        .serialize(to.twig_format())
140        .map_err(|e| crate::error::Error::Content(format!("twig serialize: {e}")))?;
141    String::from_utf8(out)
142        .map_err(|e| crate::error::Error::Content(format!("twig produced non-UTF-8: {e}")))
143}
144
145/// Parse `body` as `format` and render it to HTML, via `twig`'s FFI.
146pub fn render_html(body: &str, format: ContentFormat) -> crate::error::Result<String> {
147    let mut doc = parse(body, format)?;
148    let html = doc
149        .render_html()
150        .map_err(|e| crate::error::Error::Content(format!("twig render: {e}")))?;
151    String::from_utf8(html)
152        .map_err(|e| crate::error::Error::Content(format!("twig produced non-UTF-8 HTML: {e}")))
153}
154
155/// Whether a node kind is one `twig` parses as opaque code — inline code spans
156/// (`Verbatim`), fenced/indented code blocks (`CodeBlock`), and raw
157/// inline/block escapes (`RawInline` / `RawBlock`).
158fn is_code(kind: &twig::Kind) -> bool {
159    matches!(
160        kind,
161        twig::Kind::Verbatim | twig::Kind::CodeBlock | twig::Kind::RawInline | twig::Kind::RawBlock
162    )
163}
164
165/// What one parse of a body yields for a link scan: the spans twig reads as
166/// code, the spans it reads as inline links, and the spans it reads as images,
167/// each sorted by start offset.
168///
169/// A named struct rather than a bare tuple because the lists are the same
170/// shape, and telling them apart at a call site should not depend on getting
171/// their order right.
172#[derive(Debug, Clone, Default, PartialEq, Eq)]
173pub struct BodySpans {
174    /// Everything a link scan must treat as opaque — see [`code_spans`].
175    pub code: Vec<Range<usize>>,
176    /// Each inline `[label](target)` construct — see [`link_spans`].
177    pub links: Vec<Range<usize>>,
178    /// Each `![alt](target)` construct, the leading `!` included. twig parses an
179    /// image as its own node kind, not as a link, so a scan that collected only
180    /// [`Self::links`] never saw one — and a move rewrote every link in a page's
181    /// body while leaving its pictures pointing at the old directory.
182    pub images: Vec<Range<usize>>,
183}
184
185/// The code spans, the inline-link spans and the image spans of `body`, in
186/// **one parse**.
187///
188/// All are read off the same node array, because a body-link scan wants each
189/// and twig's parse is the expensive part: [`crate::link::scan_body_links`]
190/// asked for them separately and so parsed every document's prose twice, which
191/// a profile of `check` put at 41% of the run. The two lists come back sorted
192/// by start offset, each exactly as its single-kind accessor would have
193/// returned it.
194pub fn code_and_link_spans(body: &str, format: ContentFormat) -> crate::error::Result<BodySpans> {
195    let mut doc = parse(body, format)?;
196    let nodes = doc
197        .nodes()
198        .map_err(|e| crate::error::Error::Content(format!("twig nodes: {e}")))?;
199    let mut code = Vec::new();
200    let mut links = Vec::new();
201    let mut images = Vec::new();
202    for node in nodes {
203        if is_code(&node.kind) {
204            code.push(node.span);
205        } else if node.kind == twig::Kind::Link {
206            links.push(node.span);
207        } else if node.kind == twig::Kind::Image {
208            images.push(node.span);
209        }
210    }
211    code.sort_by_key(|s: &Range<usize>| s.start);
212    links.sort_by_key(|s: &Range<usize>| s.start);
213    images.sort_by_key(|s: &Range<usize>| s.start);
214    Ok(BodySpans {
215        code,
216        links,
217        images,
218    })
219}
220
221/// The spans of every node in `body` whose kind satisfies `want`, sorted by
222/// start offset. The one walk [`code_spans`] and [`link_spans`] share.
223///
224/// **Why the flat node array and not `twig`'s selector query.** A parsed
225/// document is not one tree. Footnote and link-reference definitions resolve by
226/// *label* rather than by position, so twig attaches them to no parent — and
227/// `twig_document_query` walks from the root, which means it never enters them.
228/// Both of prov's uses were wrong inside a footnote in the two opposite
229/// directions at once: a `` `[[x]]` `` in a footnote body was not reported as
230/// code, so the lexical wikilink scan promoted it to a link (DESIGN §8's
231/// false positive, the exact thing [`code_spans`] exists to prevent), and a real
232/// `[a](b.md)` in a footnote body was not reported as a link, so a rename never
233/// rewrote it and no broken-link finding was ever raised for it.
234///
235/// `Document::nodes` is indexed over the whole arena rather than walked from the
236/// root, so the detached definition subtrees are simply in it. It also replaces
237/// the four separate queries [`code_spans`] used to issue — twig's selector
238/// grammar has no union combinator, and a kind predicate needs none.
239fn spans_where(
240    body: &str,
241    format: ContentFormat,
242    want: impl Fn(&twig::Kind) -> bool,
243) -> crate::error::Result<Vec<std::ops::Range<usize>>> {
244    let mut doc = parse(body, format)?;
245    let nodes = doc
246        .nodes()
247        .map_err(|e| crate::error::Error::Content(format!("twig nodes: {e}")))?;
248    let mut spans: Vec<_> = nodes
249        .into_iter()
250        .filter(|n| want(&n.kind))
251        .map(|n| n.span)
252        .collect();
253    spans.sort_by_key(|s| s.start);
254    Ok(spans)
255}
256
257/// The byte ranges in `body` that `twig` parses as code (inline code spans,
258/// fenced code blocks, raw inline/block escapes) — everything a link scan
259/// should treat as opaque; see [`crate::link::scan_wikilinks`]. Spans are
260/// returned sorted by start offset, and cover code inside a footnote definition
261/// as well as code in the document body (see `spans_where`).
262pub fn code_spans(
263    body: &str,
264    format: ContentFormat,
265) -> crate::error::Result<Vec<std::ops::Range<usize>>> {
266    spans_where(body, format, is_code)
267}
268
269/// The byte ranges in `body` that `twig` parses as inline links — the whole
270/// `[text](target)` construct of each `link` node, in source order. This is the
271/// syntax-aware, code-aware complement to prov's lexical `[[…]]` scan: twig
272/// never reports a `[x](y)` inside a code fence, an autolink's angle brackets,
273/// or bracket text that is not actually a link, so a body-link scan built on
274/// these spans cannot mistake prose or code for a link. The caller slices each
275/// span and parses it with [`crate::link::Link::parse`] to read the target —
276/// each span holds exactly one link, so the parse never over-reaches.
277///
278/// Reference-style and autolink forms also surface as `link` nodes; the caller
279/// keeps only the inline `[label](target)` ones (a successful markdown parse),
280/// which is the form prov can resolve and rewrite in place.
281///
282/// Links inside a footnote definition are included, which a walk from the
283/// document root does not reach — see `spans_where`.
284pub fn link_spans(
285    body: &str,
286    format: ContentFormat,
287) -> crate::error::Result<Vec<std::ops::Range<usize>>> {
288    spans_where(body, format, |k| *k == twig::Kind::Link)
289}
290
291#[cfg(test)]
292mod tests {
293    use super::*;
294
295    #[test]
296    fn infers_format_from_extension() {
297        assert_eq!(
298            ContentFormat::from_extension(Path::new("a.md")),
299            Some(ContentFormat::Markdown)
300        );
301        assert_eq!(
302            ContentFormat::from_extension(Path::new("a.markdown")),
303            Some(ContentFormat::Markdown)
304        );
305        assert_eq!(
306            ContentFormat::from_extension(Path::new("a.dj")),
307            Some(ContentFormat::Djot)
308        );
309        assert_eq!(
310            ContentFormat::from_extension(Path::new("a.djot")),
311            Some(ContentFormat::Djot)
312        );
313        assert_eq!(
314            ContentFormat::from_extension(Path::new("a.html")),
315            Some(ContentFormat::Html)
316        );
317        assert_eq!(
318            ContentFormat::from_extension(Path::new("a.htm")),
319            Some(ContentFormat::Html)
320        );
321        assert_eq!(ContentFormat::from_extension(Path::new("a.yaml")), None);
322        assert_eq!(ContentFormat::from_extension(Path::new("noext")), None);
323    }
324
325    #[test]
326    fn extension_matches_the_primary_from_extension_spelling() {
327        // The derived filename's extension must round-trip back to the same format.
328        for f in [
329            ContentFormat::Markdown,
330            ContentFormat::Djot,
331            ContentFormat::Html,
332        ] {
333            let name = format!("derived.{}", f.extension());
334            assert_eq!(ContentFormat::from_extension(Path::new(&name)), Some(f));
335        }
336    }
337
338    #[test]
339    fn transcode_respells_the_grammar_and_leaves_a_matching_body_alone() {
340        // Markdown → Djot is a genuine re-spelling, not a copy: setext headings
341        // become ATX and emphasis takes djot's markers. `[[wikilinks]]` are prov's
342        // notation rather than twig's, and must ride through untouched — the
343        // `convert` engine renames files on this promise.
344        let md = "Title\n=====\n\n*emph* and [[a:b]] and `code`.\n";
345        let dj = transcode(md, ContentFormat::Markdown, ContentFormat::Djot).unwrap();
346        assert!(dj.contains("# Title"), "{dj}");
347        assert!(dj.contains("_emph_"), "{dj}");
348        assert!(dj.contains("[[a:b]]"), "wikilink verbatim: {dj}");
349        assert!(dj.contains("`code`"), "{dj}");
350
351        // Same grammar in and out: returned verbatim, so prose the author wrapped
352        // by hand is never silently reflowed.
353        assert_eq!(
354            transcode(md, ContentFormat::Markdown, ContentFormat::Markdown).unwrap(),
355            md
356        );
357
358        // And the source grammar is honoured, not assumed: djot's `_emph_` read as
359        // djot survives, where reading it as Markdown would not.
360        let back = transcode(&dj, ContentFormat::Djot, ContentFormat::Markdown).unwrap();
361        assert!(back.contains("*emph*"), "djot read as djot: {back}");
362    }
363
364    #[test]
365    fn html_is_the_lossy_endpoint_in_both_directions() {
366        // What `convert --force` gates: the prose grammars interconvert freely,
367        // and every pairing with HTML is lossy whichever way it runs.
368        assert!(!ContentFormat::Markdown.is_lossy_to(ContentFormat::Djot));
369        assert!(!ContentFormat::Djot.is_lossy_to(ContentFormat::Markdown));
370        assert!(ContentFormat::Markdown.is_lossy_to(ContentFormat::Html));
371        assert!(ContentFormat::Html.is_lossy_to(ContentFormat::Djot));
372        // A conversion to the grammar already in use is not a conversion at all.
373        for f in [
374            ContentFormat::Markdown,
375            ContentFormat::Djot,
376            ContentFormat::Html,
377        ] {
378            assert!(!f.is_lossy_to(f));
379        }
380    }
381
382    #[test]
383    fn code_and_link_spans_reach_inside_a_footnote_definition() {
384        // A footnote definition resolves by label, so twig attaches it to no
385        // parent and a walk from the document root never enters it. Both spans
386        // are read out of the flat node array for exactly this case.
387        //
388        // The code half is DESIGN §8's false positive: unmasked, the lexical
389        // `[[…]]` scan promotes this code to a link.
390        for format in [ContentFormat::Markdown, ContentFormat::Djot] {
391            let body = "Body.[^n]\n\n[^n]: A note with `[[inf] * n]` code.\n";
392            let spans = code_spans(body, format).unwrap();
393            assert_eq!(
394                spans.iter().map(|s| &body[s.clone()]).collect::<Vec<_>>(),
395                ["`[[inf] * n]`"],
396                "{format:?}"
397            );
398
399            // The link half: unreported, a rename never rewrites it and no
400            // broken-link finding is ever raised for it.
401            let body = "Body.[^n]\n\n[^n]: See [b](notes/b.md) here.\n";
402            let spans = link_spans(body, format).unwrap();
403            assert_eq!(
404                spans.iter().map(|s| &body[s.clone()]).collect::<Vec<_>>(),
405                ["[b](notes/b.md)"],
406                "{format:?}"
407            );
408        }
409    }
410
411    /// The combined accessor exists only to save a parse, so it must answer
412    /// exactly what the two single-kind ones do — including the sort order and
413    /// the footnote-definition subtrees a walk from the root would miss.
414    #[test]
415    fn one_parse_reports_what_the_two_separate_ones_do() {
416        let body = concat!(
417            "A [real](a.md) link and `[[code]]` and a [second](b.md).\n\n",
418            "```\n[fenced](c.md)\n```\n\n",
419            "Text[^fn]\n\n[^fn]: a note with [a link](d.md) and `code`.\n",
420        );
421        let spans = code_and_link_spans(body, ContentFormat::Markdown).unwrap();
422        assert_eq!(
423            spans.code,
424            code_spans(body, ContentFormat::Markdown).unwrap()
425        );
426        assert_eq!(
427            spans.links,
428            link_spans(body, ContentFormat::Markdown).unwrap()
429        );
430        assert!(
431            !spans.code.is_empty() && !spans.links.is_empty(),
432            "fixture found nothing"
433        );
434    }
435
436    #[test]
437    fn image_spans_cover_the_whole_construct_in_both_grammars() {
438        // The `!` is in the span, an empty alt is still an image, a footnote
439        // definition is reached, and an image in a code span is code.
440        for format in [ContentFormat::Markdown, ContentFormat::Djot] {
441            let body = "![A photo](a.jpg) and ![](b.jpg) and `![c](c.jpg)`.[^n]\n\n\
442                        [^n]: ![n](n.png)\n";
443            let spans = code_and_link_spans(body, format).unwrap();
444            assert_eq!(
445                spans
446                    .images
447                    .iter()
448                    .map(|s| &body[s.clone()])
449                    .collect::<Vec<_>>(),
450                ["![A photo](a.jpg)", "![](b.jpg)", "![n](n.png)"],
451                "{format:?}"
452            );
453            assert!(spans.links.is_empty(), "{format:?}: an image is not a link");
454        }
455    }
456
457    #[test]
458    fn code_spans_covers_every_code_bearing_kind_in_one_pass() {
459        // One kind predicate over the flat array replaces four separate
460        // selector queries; all four kinds must still be reported, in source
461        // order regardless of the order the arena holds them in.
462        let body = "`v`\n\n```\nfenced\n```\n\n<span>raw</span>\n\n<div>\nblock\n</div>\n";
463        let spans = code_spans(body, ContentFormat::Markdown).unwrap();
464        assert!(
465            spans.windows(2).all(|w| w[0].start <= w[1].start),
466            "sorted by start: {spans:?}"
467        );
468        let covered: String = spans.iter().map(|s| &body[s.clone()]).collect();
469        // One fixture per kind: verbatim, code_block, raw_inline, raw_block.
470        // A raw *inline* is the tag alone — the prose between `<span>` and
471        // `</span>` is not code and is deliberately left unmasked — whereas a
472        // raw *block* is the whole element, its interior included.
473        for expected in ["`v`", "fenced", "<span>", "<div>\nblock\n</div>"] {
474            assert!(
475                covered.contains(expected),
476                "{expected} missing: {covered:?}"
477            );
478        }
479    }
480
481    #[test]
482    fn renders_markdown_to_html_via_twig_ffi() {
483        let html = render_html("# hi\n", ContentFormat::Markdown).unwrap();
484        assert_eq!(html, "<h1>hi</h1>\n");
485    }
486
487    #[test]
488    fn renders_djot_to_html_via_twig_ffi() {
489        let html = render_html("_hi_\n", ContentFormat::Djot).unwrap();
490        assert_eq!(html, "<p><em>hi</em></p>\n");
491    }
492
493    #[test]
494    fn renders_html_via_twig_ffi() {
495        let html = render_html("<p>hi</p>", ContentFormat::Html).unwrap();
496        assert!(html.contains("hi"));
497    }
498
499    #[test]
500    fn link_spans_find_inline_links_but_not_code_or_prose() {
501        let body = "See [the doc](notes/a.md) and `[not](a link)` and plain [text].";
502        let spans = link_spans(body, ContentFormat::Markdown).unwrap();
503        // The real inline link is found, its span the whole `[label](target)`.
504        let want = body.find("[the doc](notes/a.md)").unwrap();
505        assert!(
506            spans
507                .iter()
508                .any(|s| s.start == want && &body[s.clone()] == "[the doc](notes/a.md)"),
509            "expected the inline link span, got {spans:?}"
510        );
511        // The backtick-wrapped `[not](a link)` is code, not a link.
512        let code_at = body.find("[not]").unwrap();
513        assert!(
514            !spans.iter().any(|s| s.contains(&code_at)),
515            "code must not be a link: {spans:?}"
516        );
517        // `[text]` with no destination is not a link either.
518        let bracket_at = body.find("[text]").unwrap();
519        assert!(
520            !spans.iter().any(|s| s.contains(&bracket_at)),
521            "bare brackets are not a link"
522        );
523    }
524
525    #[test]
526    fn link_spans_read_djot_links_too() {
527        let body = "Here is [a link](../b.dj) inline.\n";
528        let spans = link_spans(body, ContentFormat::Djot).unwrap();
529        assert!(
530            spans
531                .iter()
532                .any(|s| &body[s.clone()] == "[a link](../b.dj)"),
533            "djot inline link span, got {spans:?}"
534        );
535    }
536
537    #[test]
538    fn code_spans_cover_verbatim_but_not_prose() {
539        let body = "See [[colophon:abc123]] and `[[inf] * n for _ in range(m)]]` here.";
540        let spans = code_spans(body, ContentFormat::Markdown).unwrap();
541
542        // The plain wikilink is untouched by any code span...
543        let wikilink_span = body.find("[[colophon:abc123]]").unwrap()
544            ..body.find("[[colophon:abc123]]").unwrap() + "[[colophon:abc123]]".len();
545        assert!(
546            !spans
547                .iter()
548                .any(|cs| cs.start < wikilink_span.end && wikilink_span.start < cs.end)
549        );
550
551        // ...but the backtick-wrapped one is inside exactly one code span.
552        let code_start = body.find('`').unwrap();
553        let code_end = body.rfind('`').unwrap() + 1;
554        assert!(
555            spans
556                .iter()
557                .any(|cs| cs.start <= code_start && code_end <= cs.end)
558        );
559    }
560}