1use 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#[derive(Clone, Debug, Default)]
25pub struct MarkdownBackend;
26
27#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
29pub enum AttributeEnvelope {
30 #[default]
32 None,
33 JsonFrontMatter,
35 LegacyYamlStringFrontMatter,
41 LegacyDoubleColonStrings,
43 DoubleColon,
45}
46
47#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
49pub enum LinkDialect {
50 #[default]
52 CommonMark,
53 WikiLink,
55}
56
57#[derive(Clone, Copy, Debug, PartialEq, Eq)]
59pub struct MarkdownDialect {
60 pub attributes: AttributeEnvelope,
62 pub links: LinkDialect,
64 pub max_attribute_bytes: usize,
66 pub max_attributes: usize,
68 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#[derive(Clone, Debug)]
86pub struct DialectMarkdownBackend {
87 dialect: MarkdownDialect,
88}
89
90impl DialectMarkdownBackend {
91 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}