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