base_markdown/code_fence.rs
1// Released under the MIT License.
2// Copyright, 2026, by Samuel Williams.
3
4/// Opening fence metadata, matching Markly's `Node::Fence` structure.
5///
6/// This describes the source opening fence. The closing fence can be longer or absent for ordinary code blocks. Markdown serialization may use a different fence according to its formatting options and content safety.
7///
8/// Code-block events retain this metadata independently of source offsets:
9///
10/// ```
11/// use base_markdown::{stream::EventKind, Document, ParseOptions};
12///
13/// let document = Document::parse(" ~~~~rust\n let x = 1;\n ~~~~", &ParseOptions::default()).unwrap();
14/// let code = document.children(document.root()).next().unwrap();
15/// let EventKind::CodeBlock { fence: Some(fence), .. } = &document.node(code).unwrap().kind else { panic!() };
16/// assert_eq!((fence.character, fence.length, fence.indent), ('~', 4, 2));
17/// ```
18#[derive(Clone, Copy, Debug, Eq, PartialEq)]
19#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
20pub struct CodeFence {
21 /// Fence marker: a backtick or tilde for code, or a dash/plus for frontmatter.
22 pub character: char,
23 /// Number of markers in the opening fence.
24 pub length: usize,
25 /// Opening indentation in columns, relative to the containing block.
26 /// Container markers and their required whitespace are excluded.
27 pub indent: usize,
28}
29
30impl CodeFence {
31 pub(crate) fn frontmatter_open(bytes: &[u8]) -> Option<Self> {
32 let marker = *bytes.first()?;
33 if !matches!(marker, b'-' | b'+' | b'`' | b'~') {
34 return None;
35 }
36 let length = bytes.iter().take_while(|&&byte| byte == marker).count();
37 if length < 3 || (matches!(marker, b'-' | b'+') && length != 3) {
38 return None;
39 }
40 Some(Self {
41 character: char::from(marker),
42 length,
43 indent: 0,
44 })
45 }
46
47 pub(crate) fn valid_frontmatter_info(self, info: &[u8]) -> bool {
48 !info.iter().any(|byte| matches!(byte, b'\r' | b'\n'))
49 && (!matches!(self.character, '`' | '~')
50 || !info.iter().all(|byte| matches!(byte, b' ' | b'\t')))
51 && (self.character != '`' || !info.contains(&b'`'))
52 }
53
54 // The caller strips only the indentation permitted in its source context.
55 pub(crate) fn frontmatter_close(self, line: &[u8]) -> Option<usize> {
56 let count = line
57 .iter()
58 .take_while(|&&byte| char::from(byte) == self.character)
59 .count();
60 (count >= self.length
61 && (!matches!(self.character, '-' | '+') || count == self.length)
62 && line[count..]
63 .iter()
64 .all(|byte| matches!(byte, b' ' | b'\t')))
65 .then_some(count)
66 }
67}