Skip to main content

sim_codec_doc/
markdown.rs

1//! Markdown backend implementation over `pulldown-cmark`.
2
3use std::collections::BTreeMap;
4use std::fmt;
5use std::mem;
6use std::ops::Range;
7
8use pulldown_cmark::{CodeBlockKind, Event, HeadingLevel, Options, Parser, Tag, TagEnd};
9use serde::de::{Deserializer, MapAccess, Visitor};
10use serde_json::Value as JsonValue;
11use sim_codec::{DecodeBudget, DecodeLimits};
12use sim_kernel::CodecId;
13
14use crate::backend::{
15    MarkupBackend, MarkupDecodeOptions, MarkupEncodeOptions, MarkupError, MarkupFidelity,
16    MarkupLoss,
17};
18use crate::markdown_writer::MarkdownEncoder;
19use crate::markup::{BackendId, Inline, MarkupBlock, MarkupDoc, MathSource, SourceDoc, Span};
20
21type MarkdownEvent = (Event<'static>, Range<usize>);
22
23/// CommonMark/GFM-compatible Markdown backend.
24#[derive(Clone, Debug, Default)]
25pub struct MarkdownBackend;
26
27/// Attribute spelling accepted around a Markdown document body.
28#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
29pub enum AttributeEnvelope {
30    /// Do not read or write document attributes.
31    #[default]
32    None,
33    /// A canonical JSON object between `---json` and `---` lines.
34    JsonFrontMatter,
35    /// Decode-only legacy YAML string properties between `---` lines.
36    ///
37    /// This deliberately accepts only the frozen `key: "JSON string"` subset
38    /// emitted by the former Index vault renderer. Encoding is rejected so a
39    /// compatibility reader cannot become a second live v1 writer.
40    LegacyYamlStringFrontMatter,
41    /// Decode-only legacy `key:: value` string properties.
42    LegacyDoubleColonStrings,
43    /// Consecutive `key:: JSON-value` lines followed by a blank line.
44    DoubleColon,
45}
46
47/// Link spelling used by a Markdown dialect.
48#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
49pub enum LinkDialect {
50    /// Standard `[label](target)` links.
51    #[default]
52    CommonMark,
53    /// Bounded `[[target]]` and `[[target|label]]` links.
54    WikiLink,
55}
56
57/// Generic, bounded Markdown syntax policy.
58#[derive(Clone, Copy, Debug, PartialEq, Eq)]
59pub struct MarkdownDialect {
60    /// Attribute envelope syntax.
61    pub attributes: AttributeEnvelope,
62    /// Link syntax.
63    pub links: LinkDialect,
64    /// Maximum encoded attribute-envelope bytes.
65    pub max_attribute_bytes: usize,
66    /// Maximum number of attributes.
67    pub max_attributes: usize,
68    /// Maximum bytes in one wikilink.
69    pub max_wikilink_bytes: usize,
70}
71
72impl Default for MarkdownDialect {
73    fn default() -> Self {
74        Self {
75            attributes: AttributeEnvelope::None,
76            links: LinkDialect::CommonMark,
77            max_attribute_bytes: 64 * 1024,
78            max_attributes: 256,
79            max_wikilink_bytes: 4 * 1024,
80        }
81    }
82}
83
84/// Markdown backend configured with an opt-in generic dialect.
85#[derive(Clone, Debug)]
86pub struct DialectMarkdownBackend {
87    dialect: MarkdownDialect,
88}
89
90impl DialectMarkdownBackend {
91    /// Construct a backend after validating all resource bounds.
92    pub fn new(dialect: MarkdownDialect) -> Result<Self, MarkupError> {
93        if dialect.max_attribute_bytes == 0
94            || dialect.max_attributes == 0
95            || dialect.max_wikilink_bytes == 0
96        {
97            return Err(MarkupError::InvalidDocument(
98                "Markdown dialect bounds must be non-zero".to_owned(),
99            ));
100        }
101        Ok(Self { dialect })
102    }
103}
104
105impl MarkupBackend for MarkdownBackend {
106    fn id(&self) -> BackendId {
107        markdown_id()
108    }
109
110    fn decode(
111        &self,
112        input: &str,
113        opts: &MarkupDecodeOptions,
114    ) -> Result<(MarkupDoc, MarkupFidelity), MarkupError> {
115        DialectMarkdownBackend::new(MarkdownDialect::default())
116            .expect("default bounds")
117            .decode(input, opts)
118    }
119
120    fn encode(
121        &self,
122        doc: &MarkupDoc,
123        opts: &MarkupEncodeOptions,
124    ) -> Result<(String, MarkupFidelity), MarkupError> {
125        DialectMarkdownBackend::new(MarkdownDialect::default())
126            .expect("default bounds")
127            .encode(doc, opts)
128    }
129}
130
131impl MarkupBackend for DialectMarkdownBackend {
132    fn id(&self) -> BackendId {
133        markdown_id()
134    }
135
136    fn decode(
137        &self,
138        input: &str,
139        opts: &MarkupDecodeOptions,
140    ) -> Result<(MarkupDoc, MarkupFidelity), MarkupError> {
141        let (attrs, body_start) = decode_attributes(input, self.dialect)?;
142        let body = &input[body_start..];
143        let events = Parser::new_ext(body, markdown_options())
144            .into_offset_iter()
145            .map(|(event, range)| {
146                (
147                    event.into_static(),
148                    range.start + body_start..range.end + body_start,
149                )
150            })
151            .collect();
152        let mut parser = MarkdownParser::new(input, events, opts);
153        let mut blocks = parser.parse_blocks_until(|_| false);
154        if self.dialect.links == LinkDialect::WikiLink {
155            rewrite_wikilinks(&mut blocks, self.dialect.max_wikilink_bytes)?;
156        }
157        let title = blocks.iter().find_map(|block| match block {
158            MarkupBlock::Heading { level: 1, text, .. } => Some(inline_plain_text(text)),
159            _ => None,
160        });
161        let source = opts.preserve_source.then(|| SourceDoc {
162            backend: markdown_id(),
163            text: input.to_owned(),
164        });
165        Ok((
166            MarkupDoc {
167                title,
168                blocks,
169                attrs,
170                source,
171            },
172            parser.fidelity,
173        ))
174    }
175
176    fn encode(
177        &self,
178        doc: &MarkupDoc,
179        opts: &MarkupEncodeOptions,
180    ) -> Result<(String, MarkupFidelity), MarkupError> {
181        let mut encoder = MarkdownEncoder::new(opts, self.dialect.links);
182        let mut source = encode_attributes(&doc.attrs, self.dialect)?;
183        source.push_str(&encoder.write_doc(doc));
184        if opts.fail_on_loss && !encoder.fidelity.dropped.is_empty() {
185            return Err(MarkupError::Encode(format!(
186                "markdown encode dropped {} raw fragment(s)",
187                encoder.fidelity.dropped.len()
188            )));
189        }
190        Ok((source, encoder.fidelity))
191    }
192}
193
194fn decode_attributes(
195    input: &str,
196    dialect: MarkdownDialect,
197) -> Result<(BTreeMap<String, sim_kernel::Expr>, usize), MarkupError> {
198    match dialect.attributes {
199        AttributeEnvelope::None => Ok((BTreeMap::new(), 0)),
200        AttributeEnvelope::JsonFrontMatter => {
201            if !input.starts_with("---json\n") {
202                return Ok((BTreeMap::new(), 0));
203            }
204            let end = input[8..]
205                .find("\n---\n")
206                .ok_or_else(|| MarkupError::Decode("unterminated JSON front matter".to_owned()))?
207                + 8;
208            if end > dialect.max_attribute_bytes {
209                return Err(MarkupError::Decode(
210                    "JSON front matter exceeds dialect byte bound".to_owned(),
211                ));
212            }
213            let pairs = parse_json_object_pairs(&input[8..end])?;
214            attrs_from_pairs(pairs, dialect, end + 5)
215        }
216        AttributeEnvelope::LegacyYamlStringFrontMatter => {
217            if !input.starts_with("---\n") {
218                return Ok((BTreeMap::new(), 0));
219            }
220            let end = input[4..].find("\n---\n").ok_or_else(|| {
221                MarkupError::Decode("unterminated legacy YAML front matter".to_owned())
222            })? + 4;
223            if end > dialect.max_attribute_bytes {
224                return Err(MarkupError::Decode(
225                    "legacy YAML front matter exceeds dialect byte bound".to_owned(),
226                ));
227            }
228            let mut pairs = Vec::new();
229            for line in input[4..end].lines() {
230                let (key, value) = line.split_once(':').ok_or_else(|| {
231                    MarkupError::Decode(format!("invalid legacy YAML property {line:?}"))
232                })?;
233                let key = key.trim();
234                validate_key(key)?;
235                let value = serde_json::from_str(value.trim()).map_err(|error| {
236                    MarkupError::Decode(format!(
237                        "legacy YAML property {key:?} is not a quoted string: {error}"
238                    ))
239                })?;
240                if !matches!(value, JsonValue::String(_)) {
241                    return Err(MarkupError::Decode(format!(
242                        "legacy YAML property {key:?} is not a string"
243                    )));
244                }
245                pairs.push((key.to_owned(), value));
246            }
247            legacy_string_attrs(pairs, dialect, end + 5)
248        }
249        AttributeEnvelope::DoubleColon => {
250            let Some(end) = input.find("\n\n") else {
251                return Ok((BTreeMap::new(), 0));
252            };
253            let prelude = &input[..end];
254            if prelude.is_empty() || !prelude.lines().all(|line| line.contains("::")) {
255                return Ok((BTreeMap::new(), 0));
256            }
257            if end > dialect.max_attribute_bytes {
258                return Err(MarkupError::Decode(
259                    "property prelude exceeds dialect byte bound".to_owned(),
260                ));
261            }
262            let mut pairs = Vec::new();
263            for line in prelude.lines() {
264                let (key, value) = line.split_once("::").expect("checked above");
265                let key = key.trim();
266                validate_key(key)?;
267                let value = serde_json::from_str(value.trim()).map_err(|error| {
268                    MarkupError::Decode(format!("invalid JSON property {key:?}: {error}"))
269                })?;
270                pairs.push((key.to_owned(), value));
271            }
272            attrs_from_pairs(pairs, dialect, end + 2)
273        }
274        AttributeEnvelope::LegacyDoubleColonStrings => {
275            let Some(end) = input.find("\n\n") else {
276                return Ok((BTreeMap::new(), 0));
277            };
278            let prelude = &input[..end];
279            if prelude.is_empty() || !prelude.lines().all(|line| line.contains("::")) {
280                return Ok((BTreeMap::new(), 0));
281            }
282            if end > dialect.max_attribute_bytes {
283                return Err(MarkupError::Decode(
284                    "legacy property prelude exceeds dialect byte bound".to_owned(),
285                ));
286            }
287            let pairs = prelude
288                .lines()
289                .map(|line| {
290                    let (key, value) = line.split_once("::").expect("checked above");
291                    let key = key.trim();
292                    validate_key(key)?;
293                    Ok((key.to_owned(), JsonValue::String(value.trim().to_owned())))
294                })
295                .collect::<Result<Vec<_>, MarkupError>>()?;
296            legacy_string_attrs(pairs, dialect, end + 2)
297        }
298    }
299}
300
301fn legacy_string_attrs(
302    pairs: Vec<(String, JsonValue)>,
303    dialect: MarkdownDialect,
304    body_start: usize,
305) -> Result<(BTreeMap<String, sim_kernel::Expr>, usize), MarkupError> {
306    if pairs.len() > dialect.max_attributes {
307        return Err(MarkupError::Decode(
308            "legacy attribute count exceeds dialect bound".to_owned(),
309        ));
310    }
311    let mut attrs = BTreeMap::new();
312    for (key, value) in pairs {
313        let JsonValue::String(value) = value else {
314            return Err(MarkupError::Decode(format!(
315                "legacy property {key:?} is not a string"
316            )));
317        };
318        if attrs
319            .insert(key.clone(), sim_kernel::Expr::String(value))
320            .is_some()
321        {
322            return Err(MarkupError::Decode(format!(
323                "duplicate legacy attribute key {key:?}"
324            )));
325        }
326    }
327    Ok((attrs, body_start))
328}
329
330fn attrs_from_pairs(
331    pairs: Vec<(String, JsonValue)>,
332    dialect: MarkdownDialect,
333    body_start: usize,
334) -> Result<(BTreeMap<String, sim_kernel::Expr>, usize), MarkupError> {
335    if pairs.len() > dialect.max_attributes {
336        return Err(MarkupError::Decode(
337            "attribute count exceeds dialect bound".to_owned(),
338        ));
339    }
340    let mut attrs = BTreeMap::new();
341    for (key, value) in pairs {
342        validate_key(&key)?;
343        let mut budget = DecodeBudget::new(DecodeLimits::default());
344        let expr =
345            sim_codec_json::json_to_expr(CodecId(0), &value, &mut budget, 0).map_err(|error| {
346                MarkupError::Decode(format!("invalid Expr attribute {key:?}: {error}"))
347            })?;
348        if attrs.insert(key.clone(), expr).is_some() {
349            return Err(MarkupError::Decode(format!(
350                "duplicate attribute key {key:?}"
351            )));
352        }
353    }
354    Ok((attrs, body_start))
355}
356
357fn encode_attributes(
358    attrs: &BTreeMap<String, sim_kernel::Expr>,
359    dialect: MarkdownDialect,
360) -> Result<String, MarkupError> {
361    if dialect.attributes == AttributeEnvelope::None || attrs.is_empty() {
362        return Ok(String::new());
363    }
364    if attrs.len() > dialect.max_attributes {
365        return Err(MarkupError::Encode(
366            "attribute count exceeds dialect bound".to_owned(),
367        ));
368    }
369    let mut object = serde_json::Map::new();
370    for (key, expr) in attrs {
371        validate_key(key).map_err(|error| MarkupError::Encode(error.to_string()))?;
372        object.insert(key.clone(), sim_codec_json::expr_to_json(expr));
373    }
374    let out = match dialect.attributes {
375        AttributeEnvelope::None => String::new(),
376        AttributeEnvelope::JsonFrontMatter => format!(
377            "---json\n{}\n---\n",
378            serde_json::to_string(&JsonValue::Object(object))
379                .map_err(|e| MarkupError::Encode(e.to_string()))?
380        ),
381        AttributeEnvelope::LegacyYamlStringFrontMatter => {
382            return Err(MarkupError::Encode(
383                "legacy YAML front matter is decode-only".to_owned(),
384            ));
385        }
386        AttributeEnvelope::LegacyDoubleColonStrings => {
387            return Err(MarkupError::Encode(
388                "legacy double-colon properties are decode-only".to_owned(),
389            ));
390        }
391        AttributeEnvelope::DoubleColon => {
392            let mut out = String::new();
393            for (key, value) in object {
394                out.push_str(&key);
395                out.push_str(":: ");
396                out.push_str(
397                    &serde_json::to_string(&value)
398                        .map_err(|e| MarkupError::Encode(e.to_string()))?,
399                );
400                out.push('\n');
401            }
402            out.push('\n');
403            out
404        }
405    };
406    if out.len() > dialect.max_attribute_bytes {
407        return Err(MarkupError::Encode(
408            "attribute envelope exceeds dialect byte bound".to_owned(),
409        ));
410    }
411    Ok(out)
412}
413
414fn validate_key(key: &str) -> Result<(), MarkupError> {
415    if key.is_empty() || key.contains(['\n', '\r', '\0']) {
416        return Err(MarkupError::InvalidDocument(
417            "attribute keys must be non-empty, single-line, and NUL-free".to_owned(),
418        ));
419    }
420    Ok(())
421}
422
423struct PairVisitor;
424impl<'de> Visitor<'de> for PairVisitor {
425    type Value = Vec<(String, JsonValue)>;
426    fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
427        f.write_str("a JSON object")
428    }
429    fn visit_map<A: MapAccess<'de>>(self, mut map: A) -> Result<Self::Value, A::Error> {
430        let mut pairs = Vec::new();
431        while let Some(pair) = map.next_entry()? {
432            pairs.push(pair);
433        }
434        Ok(pairs)
435    }
436}
437
438fn parse_json_object_pairs(source: &str) -> Result<Vec<(String, JsonValue)>, MarkupError> {
439    let mut decoder = serde_json::Deserializer::from_str(source);
440    let pairs = decoder
441        .deserialize_map(PairVisitor)
442        .map_err(|e| MarkupError::Decode(format!("invalid JSON front matter: {e}")))?;
443    decoder
444        .end()
445        .map_err(|e| MarkupError::Decode(format!("trailing JSON front matter: {e}")))?;
446    Ok(pairs)
447}
448
449fn rewrite_wikilinks(blocks: &mut [MarkupBlock], max_bytes: usize) -> Result<(), MarkupError> {
450    for block in blocks {
451        match block {
452            MarkupBlock::Heading { text, .. } => rewrite_inline_list(text, max_bytes)?,
453            MarkupBlock::Paragraph { content, .. } => rewrite_inline_list(content, max_bytes)?,
454            MarkupBlock::Quote { blocks, .. } => rewrite_wikilinks(blocks, max_bytes)?,
455            MarkupBlock::List { items, .. } => {
456                for blocks in items {
457                    rewrite_wikilinks(blocks, max_bytes)?;
458                }
459            }
460            MarkupBlock::Table { header, rows, .. } => {
461                for cell in header {
462                    rewrite_inline_list(cell, max_bytes)?;
463                }
464                for row in rows {
465                    for cell in row {
466                        rewrite_inline_list(cell, max_bytes)?;
467                    }
468                }
469            }
470            MarkupBlock::Figure { caption, .. } => rewrite_inline_list(caption, max_bytes)?,
471            _ => {}
472        }
473    }
474    Ok(())
475}
476
477fn rewrite_inline_list(items: &mut Vec<Inline>, max_bytes: usize) -> Result<(), MarkupError> {
478    let mut rewritten = Vec::new();
479    let mut combined = Vec::new();
480    for item in mem::take(items) {
481        if let Inline::Text(text) = item {
482            if let Some(Inline::Text(previous)) = combined.last_mut() {
483                previous.push_str(&text);
484            } else {
485                combined.push(Inline::Text(text));
486            }
487        } else {
488            combined.push(item);
489        }
490    }
491    for mut item in combined {
492        match &mut item {
493            Inline::Text(text) => rewritten.extend(parse_wikilink_text(text, max_bytes)?),
494            Inline::Emph(children) | Inline::Strong(children) => {
495                rewrite_inline_list(children, max_bytes)?;
496                rewritten.push(item);
497            }
498            _ => rewritten.push(item),
499        }
500    }
501    *items = rewritten;
502    Ok(())
503}
504
505fn parse_wikilink_text(text: &str, max_bytes: usize) -> Result<Vec<Inline>, MarkupError> {
506    let mut out = Vec::new();
507    let mut rest = text;
508    while let Some(start) = rest.find("[[") {
509        if start > 0 {
510            out.push(Inline::Text(rest[..start].to_owned()));
511        }
512        let after = &rest[start + 2..];
513        let end = find_unescaped(after, "]]", max_bytes)
514            .ok_or_else(|| MarkupError::Decode("malformed or oversized wikilink".to_owned()))?;
515        let body = &after[..end];
516        if body.contains(['\n', '\r', '\0']) {
517            return Err(MarkupError::Decode(
518                "wikilinks must be single-line and NUL-free".to_owned(),
519            ));
520        }
521        let split = find_unescaped(body, "|", max_bytes);
522        let (target, label) = split.map_or((body, body), |at| (&body[..at], &body[at + 1..]));
523        let target = unescape_wikilink(target)?;
524        let label = unescape_wikilink(label)?;
525        if target.is_empty() {
526            return Err(MarkupError::Decode("wikilink target is empty".to_owned()));
527        }
528        out.push(Inline::Link {
529            label: vec![Inline::Text(label)],
530            target,
531        });
532        rest = &after[end + 2..];
533    }
534    if !rest.is_empty() {
535        out.push(Inline::Text(rest.to_owned()));
536    }
537    Ok(out)
538}
539
540fn find_unescaped(text: &str, needle: &str, max_bytes: usize) -> Option<usize> {
541    let limit = text.len().min(max_bytes + needle.len());
542    text[..limit]
543        .match_indices(needle)
544        .find(|(at, _)| {
545            text[..*at]
546                .bytes()
547                .rev()
548                .take_while(|b| *b == b'\\')
549                .count()
550                % 2
551                == 0
552        })
553        .map(|(at, _)| at)
554}
555
556fn unescape_wikilink(text: &str) -> Result<String, MarkupError> {
557    let mut out = String::new();
558    let mut chars = text.chars();
559    while let Some(ch) = chars.next() {
560        if ch == '\\' {
561            let Some(next) = chars.next() else {
562                return Err(MarkupError::Decode("trailing wikilink escape".to_owned()));
563            };
564            if !matches!(next, '\\' | '|' | ']') {
565                return Err(MarkupError::Decode(
566                    "unsupported wikilink escape".to_owned(),
567                ));
568            }
569            out.push(next);
570        } else {
571            out.push(ch);
572        }
573    }
574    percent_unescape_wikilink(&out)
575}
576
577fn percent_unescape_wikilink(text: &str) -> Result<String, MarkupError> {
578    let bytes = text.as_bytes();
579    let mut out = Vec::with_capacity(bytes.len());
580    let mut index = 0;
581    while index < bytes.len() {
582        if bytes[index] == b'%' {
583            if index + 2 >= bytes.len() {
584                return Err(MarkupError::Decode(
585                    "truncated wikilink percent escape".to_owned(),
586                ));
587            }
588            let value = match &text[index..index + 3] {
589                "%25" => b'%',
590                "%5C" | "%5c" => b'\\',
591                "%7C" | "%7c" => b'|',
592                "%5D" | "%5d" => b']',
593                _ => {
594                    return Err(MarkupError::Decode(
595                        "unsupported wikilink percent escape".to_owned(),
596                    ));
597                }
598            };
599            out.push(value);
600            index += 3;
601        } else {
602            let ch = text[index..].chars().next().expect("valid UTF-8");
603            let mut encoded = [0; 4];
604            out.extend_from_slice(ch.encode_utf8(&mut encoded).as_bytes());
605            index += ch.len_utf8();
606        }
607    }
608    String::from_utf8(out).map_err(|_| MarkupError::Decode("invalid wikilink UTF-8".to_owned()))
609}
610
611mod parser;
612
613use parser::MarkdownParser;
614
615fn markdown_options() -> Options {
616    let mut options = Options::empty();
617    options.insert(Options::ENABLE_TABLES);
618    options.insert(Options::ENABLE_FOOTNOTES);
619    options.insert(Options::ENABLE_TASKLISTS);
620    options.insert(Options::ENABLE_HEADING_ATTRIBUTES);
621    options.insert(Options::ENABLE_MATH);
622    options
623}
624
625fn markdown_id() -> BackendId {
626    BackendId::new("markdown")
627}
628
629fn heading_level(level: HeadingLevel) -> u8 {
630    match level {
631        HeadingLevel::H1 => 1,
632        HeadingLevel::H2 => 2,
633        HeadingLevel::H3 => 3,
634        HeadingLevel::H4 => 4,
635        HeadingLevel::H5 => 5,
636        HeadingLevel::H6 => 6,
637    }
638}
639
640fn tex_math(text: pulldown_cmark::CowStr<'static>) -> MathSource {
641    MathSource {
642        notation: "tex".to_owned(),
643        text: text.trim_matches('\n').to_owned(),
644    }
645}
646
647fn span(start: usize, end: usize) -> Span {
648    Span {
649        start,
650        end,
651        state: crate::SpanState::Preserved,
652    }
653}
654
655fn inline_plain_text(items: &[Inline]) -> String {
656    let mut text = String::new();
657    for item in items {
658        match item {
659            Inline::Text(value) | Inline::Code(value) => text.push_str(value),
660            Inline::Emph(children) | Inline::Strong(children) => {
661                text.push_str(&inline_plain_text(children));
662            }
663            Inline::Link { label, .. } => text.push_str(&inline_plain_text(label)),
664            Inline::Math(source) => text.push_str(&source.text),
665            Inline::Raw { text: raw, .. } => text.push_str(raw),
666        }
667    }
668    text
669}