mdwright_document/
heading.rs1use std::ops::Range;
10
11#[derive(Clone, Debug, PartialEq, Eq)]
13pub struct HeadingAttrs {
14 pub id: Option<String>,
17 pub classes: Vec<String>,
19 pub attrs: Vec<(String, Option<String>)>,
22 pub source_trailer: String,
27}
28
29impl HeadingAttrs {
30 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
62pub(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}