Skip to main content

base_markdown/
node_syntax.rs

1// Released under the MIT License.
2// Copyright, 2026, by Samuel Williams.
3
4//! Optional authored syntax, kept outside the semantic event and node layouts.
5use crate::{
6    stream::{EventKind, Tag, Text},
7    CodeFence, ReferenceKind,
8};
9
10/// Source formatting that is useful for Markdown serialization but unnecessary for HTML.
11///
12/// Parsed documents derive this lazily from node provenance. Explicit overrides allocate only when a caller edits this metadata. Semantic values remain in the node's event kind.
13#[derive(Clone, Debug, PartialEq, Eq)]
14#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
15pub enum NodeSyntax<'a> {
16    /// The authored reference label and whether it was full, collapsed, or shortcut.
17    Reference {
18        kind: ReferenceKind,
19        label: Text<'a>,
20    },
21    /// The authored spelling of a normalized footnote identifier.
22    Footnote { label: Text<'a> },
23    /// The decoded complete code info string, or the metadata after a math fence.
24    Code { info: Text<'a> },
25    /// Frontmatter's raw info and original opening and closing fences.
26    Frontmatter {
27        info: Text<'a>,
28        fence: CodeFence,
29        closing_fence_length: usize,
30    },
31    /// Whether a list item contains blank lines between its own blocks.
32    ListItem { spread: bool },
33}
34
35impl NodeSyntax<'_> {
36    pub(crate) fn borrowed(&self) -> NodeSyntax<'_> {
37        match self {
38            Self::Reference { kind, label } => NodeSyntax::Reference {
39                kind: *kind,
40                label: label.borrowed(),
41            },
42            Self::Footnote { label } => NodeSyntax::Footnote {
43                label: label.borrowed(),
44            },
45            Self::Code { info } => NodeSyntax::Code {
46                info: info.borrowed(),
47            },
48            Self::Frontmatter {
49                info,
50                fence,
51                closing_fence_length,
52            } => NodeSyntax::Frontmatter {
53                info: info.borrowed(),
54                fence: *fence,
55                closing_fence_length: *closing_fence_length,
56            },
57            Self::ListItem { spread } => NodeSyntax::ListItem { spread: *spread },
58        }
59    }
60
61    // References cross documents as resolved resources: the destination buffer may have a conflicting or missing definition with the same label.
62    pub(crate) fn copied(self, source: &str) -> Option<NodeSyntax<'static>> {
63        let own = |text: Text<'_>| text.materialized(source);
64        Some(match self {
65            Self::Reference { .. } => return None,
66            Self::Footnote { label } => NodeSyntax::Footnote { label: own(label) },
67            Self::Code { info } => NodeSyntax::Code { info: own(info) },
68            Self::Frontmatter {
69                info,
70                fence,
71                closing_fence_length,
72            } => NodeSyntax::Frontmatter {
73                info: own(info),
74                fence,
75                closing_fence_length,
76            },
77            Self::ListItem { spread } => NodeSyntax::ListItem { spread },
78        })
79    }
80
81    pub(crate) fn valid_for(&self, kind: &EventKind<'_>, source: &str) -> bool {
82        match (self, kind) {
83            (
84                Self::Reference { label, .. },
85                EventKind::Start(Tag::Link { .. } | Tag::Image { .. }),
86            )
87            | (
88                Self::Footnote { label },
89                EventKind::Start(Tag::FootnoteDefinition(_)) | EventKind::FootnoteReference(_),
90            ) => label.resolve(source).is_ok(),
91            (Self::Code { info }, EventKind::CodeBlock { .. }) => info.resolve(source).is_ok(),
92            (
93                Self::Frontmatter {
94                    info,
95                    fence,
96                    closing_fence_length,
97                },
98                EventKind::Opaque { block: true, .. },
99            ) => {
100                info.resolve(source).is_ok()
101                    && matches!(fence.character, '`' | '~' | '-' | '+')
102                    && fence.length >= 3
103                    && *closing_fence_length >= fence.length
104            }
105            (Self::ListItem { .. }, EventKind::Start(Tag::Item { .. })) => true,
106            _ => false,
107        }
108    }
109}
110
111pub(crate) fn infer(
112    source: &str,
113    span: core::ops::Range<usize>,
114    kind: &EventKind<'_>,
115) -> Option<NodeSyntax<'static>> {
116    let raw = source
117        .get(span.clone())
118        .expect("document source extents are validated before syntax inference");
119    match kind {
120        EventKind::Start(Tag::Link { .. } | Tag::Image { .. }) => {
121            let prefix = if raw.starts_with("![") {
122                2
123            } else if raw.starts_with('[') {
124                1
125            } else {
126                return None;
127            };
128            if !raw.ends_with(']') {
129                return None;
130            }
131            // Match the final bracket pair, respecting nested and backslash-escaped brackets. Code inside the first label is irrelevant to matching the final reference suffix.
132            let bytes = raw.as_bytes();
133            let mut depth = 0;
134            let mut start = None;
135            // Only brackets affect nesting. Each preceding escape run is examined once.
136            for index in memchr::memrchr2_iter(b'[', b']', bytes) {
137                let mut back = index;
138                while back > 0 && bytes[back - 1] == b'\\' {
139                    back -= 1;
140                }
141                if (index - back) % 2 == 1 {
142                    continue;
143                }
144                if bytes[index] == b']' {
145                    depth += 1;
146                } else {
147                    depth -= 1;
148                    if depth == 0 {
149                        start = Some(index);
150                        break;
151                    }
152                }
153            }
154            let start = start?;
155            let (kind, label) = if start > prefix && bytes[start - 1] == b']' {
156                if start + 1 == bytes.len() - 1 {
157                    (
158                        ReferenceKind::Collapsed,
159                        span.start + prefix..span.start + start - 1,
160                    )
161                } else {
162                    (ReferenceKind::Full, span.start + start + 1..span.end - 1)
163                }
164            } else {
165                (ReferenceKind::Shortcut, span.start + prefix..span.end - 1)
166            };
167            Some(NodeSyntax::Reference {
168                kind,
169                label: Text::Source(label),
170            })
171        }
172        EventKind::Start(Tag::FootnoteDefinition(_)) | EventKind::FootnoteReference(_) => {
173            let start = raw.find("[^")? + 2;
174            let mut end = start;
175            while end < raw.len() {
176                match raw.as_bytes()[end] {
177                    b'\\' => end += 2,
178                    b']' => break,
179                    _ => end += 1,
180                }
181            }
182            if end >= raw.len() {
183                return None;
184            }
185            Some(NodeSyntax::Footnote {
186                label: Text::Source(span.start + start..span.start + end),
187            })
188        }
189        EventKind::CodeBlock {
190            fence: Some(fence), ..
191        } => {
192            let first = &raw[..raw.find(['\r', '\n']).unwrap_or(raw.len())];
193            let sequence = &first[first.find(fence.character)?..];
194            let info = sequence.get(fence.length..)?;
195            if !sequence[..fence.length]
196                .chars()
197                .all(|character| character == fence.character)
198            {
199                return None;
200            }
201            let info = info.trim_matches([' ', '\t']);
202            let start = span.start + (info.as_ptr() as usize - first.as_ptr() as usize);
203            let text = crate::value_builder::decode_string(source, start..start + info.len());
204            Some(NodeSyntax::Code { info: text })
205        }
206        EventKind::Opaque { block: true, .. } => frontmatter(raw).map(
207            |(info, fence, closing_fence_length, _)| NodeSyntax::Frontmatter {
208                info: Text::Source(span.start + info.start..span.start + info.end),
209                fence,
210                closing_fence_length,
211            },
212        ),
213        _ => None,
214    }
215}
216
217/// Return info and body ranges relative to a full frontmatter source extent.
218pub(crate) fn frontmatter(
219    raw: &str,
220) -> Option<(
221    core::ops::Range<usize>,
222    CodeFence,
223    usize,
224    core::ops::Range<usize>,
225)> {
226    let bytes = raw.as_bytes();
227    let fence = CodeFence::frontmatter_open(bytes)?;
228    let first_end = bytes.iter().position(|b| matches!(b, b'\r' | b'\n'))?;
229    let mut body_start = first_end + 1;
230    if bytes[first_end] == b'\r' && bytes.get(body_start) == Some(&b'\n') {
231        body_start += 1;
232    }
233    let raw_end = raw.trim_end_matches(['\r', '\n']).len();
234    let closing_start = raw[..raw_end].rfind(['\r', '\n']).map_or(0, |i| i + 1);
235    if closing_start < body_start {
236        return None;
237    }
238    let close = raw[closing_start..raw_end].trim_matches([' ', '\t']);
239    let closing_fence_length = fence.frontmatter_close(close.as_bytes())?;
240    let info = raw[fence.length..first_end].trim_matches([' ', '\t']);
241    if !fence.valid_frontmatter_info(info.as_bytes()) {
242        return None;
243    }
244    let start = info.as_ptr() as usize - raw.as_ptr() as usize;
245    Some((
246        start..start + info.len(),
247        fence,
248        closing_fence_length,
249        body_start..closing_start,
250    ))
251}