Skip to main content

mdwright_document/
heading.rs

1//! Heading attribute trailer support.
2//!
3//! Pulldown-cmark parses ATX heading attribute lists into structured
4//! `id` / `classes` / `attrs` fields. mdwright also keeps the source
5//! trailer bytes so preserve-default formatting can leave the trailer
6//! untouched and the opt-in canonicalise pass can rewrite just that
7//! byte range.
8
9use std::ops::Range;
10
11/// Heading attribute trailer recognised on an ATX heading.
12#[derive(Clone, Debug, PartialEq, Eq)]
13pub struct HeadingAttrs {
14    /// `{#id ...}`. Only the first id in source order is kept (pulldown
15    /// drops subsequent ids).
16    pub id: Option<String>,
17    /// `.class` tokens in source order.
18    pub classes: Vec<String>,
19    /// `key=value` pairs in source order. The value is `None` for a
20    /// bare `key` token with no `=`.
21    pub attrs: Vec<(String, Option<String>)>,
22    /// Source bytes of the `{...}` trailer (including braces). Empty
23    /// only when the trailer scanner failed to relocate the braces in
24    /// the heading source; the canonicalise pass then falls back to a
25    /// structured render.
26    pub source_trailer: String,
27}
28
29impl HeadingAttrs {
30    /// Render the trailer in canonical order: `#id`, then classes in
31    /// source order, then `key=value` pairs in source order. Values
32    /// containing whitespace are double-quoted; values containing a
33    /// double quote are double-quoted with embedded `\"`.
34    pub fn canonical_trailer(&self) -> String {
35        let mut tokens: Vec<String> = Vec::new();
36        if let Some(id) = &self.id {
37            tokens.push(format!("#{id}"));
38        }
39        for class in &self.classes {
40            tokens.push(format!(".{class}"));
41        }
42        for (k, v) in &self.attrs {
43            match v {
44                Some(v) if v.chars().any(|c| c.is_ascii_whitespace() || c == '"') => {
45                    let escaped: String = v
46                        .chars()
47                        .flat_map(|c| match c {
48                            '"' => vec!['\\', '"'],
49                            c => vec![c],
50                        })
51                        .collect();
52                    tokens.push(format!("{k}=\"{escaped}\""));
53                }
54                Some(v) => tokens.push(format!("{k}={v}")),
55                None => tokens.push(k.clone()),
56            }
57        }
58        format!("{{{}}}", tokens.join(" "))
59    }
60}
61
62/// Locate the `{...}` attribute trailer at the end of `raw`. Returns
63/// the byte range of the trailer (braces included) relative to `raw`.
64pub(crate) fn find_attr_trailer_range(raw: &str) -> Option<Range<usize>> {
65    let bytes = raw.as_bytes();
66    let mut end = bytes.len();
67    while end > 0 && matches!(bytes.get(end.saturating_sub(1)), Some(b' ' | b'\t' | b'\n' | b'\r')) {
68        end = end.saturating_sub(1);
69    }
70    if end == 0 || bytes.get(end.saturating_sub(1)) != Some(&b'}') {
71        return None;
72    }
73    let close = end.saturating_sub(1);
74    let mut depth = 1i32;
75    let mut i = close;
76    while i > 0 {
77        i = i.saturating_sub(1);
78        match bytes.get(i) {
79            Some(b'}') => depth = depth.saturating_add(1),
80            Some(b'{') => {
81                depth = depth.saturating_sub(1);
82                if depth == 0 {
83                    return Some(i..end);
84                }
85            }
86            _ => {}
87        }
88    }
89    None
90}
91
92#[cfg(test)]
93mod tests {
94    use super::*;
95
96    #[test]
97    fn canonical_trailer_id_then_classes_then_attrs() {
98        let attrs = HeadingAttrs {
99            id: Some("section".to_owned()),
100            classes: vec!["warn".to_owned(), "imp".to_owned()],
101            attrs: vec![("data-x".to_owned(), Some("1".to_owned())), ("flag".to_owned(), None)],
102            source_trailer: "{.imp #section .warn data-x=1 flag}".to_owned(),
103        };
104        assert_eq!(attrs.canonical_trailer(), "{#section .warn .imp data-x=1 flag}");
105    }
106
107    #[test]
108    fn canonical_trailer_quotes_value_with_whitespace() {
109        let attrs = HeadingAttrs {
110            id: None,
111            classes: Vec::new(),
112            attrs: vec![("title".to_owned(), Some("hello world".to_owned()))],
113            source_trailer: "{title=\"hello world\"}".to_owned(),
114        };
115        assert_eq!(attrs.canonical_trailer(), "{title=\"hello world\"}");
116    }
117
118    #[test]
119    fn canonical_trailer_omits_missing_id_and_empty_lists() {
120        let attrs = HeadingAttrs {
121            id: None,
122            classes: vec!["only".to_owned()],
123            attrs: Vec::new(),
124            source_trailer: "{.only}".to_owned(),
125        };
126        assert_eq!(attrs.canonical_trailer(), "{.only}");
127    }
128
129    #[test]
130    fn attr_trailer_range_finds_final_braces() {
131        let raw = "## Heading {#id .class}\n";
132        let found = find_attr_trailer_range(raw).map(|range| &raw[range]);
133        assert_eq!(found, Some("{#id .class}"));
134    }
135}