rumdl_lib/utils/
mkdocs_attr_list.rs1use regex::Regex;
35use std::sync::LazyLock;
36
37static ATTR_LIST_PATTERN: LazyLock<Regex> = LazyLock::new(|| {
44 Regex::new(r#"\{:?\s*(?:(?:[#.][a-zA-Z_][a-zA-Z0-9_-]*|[a-zA-Z_][a-zA-Z0-9_-]*=["'][^"']*["'])\s*)+\}"#).unwrap()
47});
48
49static CUSTOM_ID_PATTERN: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"#([a-zA-Z_][a-zA-Z0-9_-]*)").unwrap());
51
52static CLASS_PATTERN: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\.([a-zA-Z_][a-zA-Z0-9_-]*)").unwrap());
54
55static KEY_VALUE_PATTERN: LazyLock<Regex> =
57 LazyLock::new(|| Regex::new(r#"([a-zA-Z_][a-zA-Z0-9_-]*)=["']([^"']*)["']"#).unwrap());
58
59#[derive(Debug, Clone, Default, PartialEq)]
61pub struct AttrList {
62 pub id: Option<String>,
64 pub classes: Vec<String>,
66 pub attributes: Vec<(String, String)>,
68 pub start: usize,
70 pub end: usize,
72}
73
74impl AttrList {
75 pub fn new() -> Self {
77 Self::default()
78 }
79
80 #[inline]
82 pub fn has_id(&self) -> bool {
83 self.id.is_some()
84 }
85
86 #[inline]
88 pub fn has_classes(&self) -> bool {
89 !self.classes.is_empty()
90 }
91
92 #[inline]
94 pub fn has_attributes(&self) -> bool {
95 !self.attributes.is_empty()
96 }
97
98 #[inline]
100 pub fn is_empty(&self) -> bool {
101 self.id.is_none() && self.classes.is_empty() && self.attributes.is_empty()
102 }
103}
104
105#[inline]
107pub fn contains_attr_list(line: &str) -> bool {
108 if !line.contains('{') {
110 return false;
111 }
112 ATTR_LIST_PATTERN.is_match(line)
113}
114
115#[inline]
127pub fn is_standalone_attr_list(line: &str) -> bool {
128 let trimmed = line.trim();
129 if !trimmed.starts_with('{') || !trimmed.ends_with('}') {
131 return false;
132 }
133 ATTR_LIST_PATTERN.is_match(trimmed)
135}
136
137pub fn find_attr_lists(line: &str) -> Vec<AttrList> {
139 if !line.contains('{') {
140 return Vec::new();
141 }
142
143 let mut results = Vec::new();
144
145 for m in ATTR_LIST_PATTERN.find_iter(line) {
146 let attr_text = m.as_str();
147 let mut attr_list = AttrList {
148 start: m.start(),
149 end: m.end(),
150 ..Default::default()
151 };
152
153 if let Some(caps) = CUSTOM_ID_PATTERN.captures(attr_text)
155 && let Some(id_match) = caps.get(1)
156 {
157 attr_list.id = Some(id_match.as_str().to_string());
158 }
159
160 for caps in CLASS_PATTERN.captures_iter(attr_text) {
162 if let Some(class_match) = caps.get(1) {
163 attr_list.classes.push(class_match.as_str().to_string());
164 }
165 }
166
167 for caps in KEY_VALUE_PATTERN.captures_iter(attr_text) {
169 if let Some(key) = caps.get(1)
170 && let Some(value) = caps.get(2)
171 {
172 attr_list
173 .attributes
174 .push((key.as_str().to_string(), value.as_str().to_string()));
175 }
176 }
177
178 if !attr_list.is_empty() {
179 results.push(attr_list);
180 }
181 }
182
183 results
184}
185
186pub fn extract_heading_custom_id(line: &str) -> Option<String> {
199 let attrs = find_attr_lists(line);
200 attrs.into_iter().find_map(|a| a.id)
201}
202
203pub fn strip_attr_list_from_heading(text: &str) -> String {
216 if let Some(m) = ATTR_LIST_PATTERN.find(text) {
217 let after = &text[m.end()..];
219 if after.trim().is_empty() {
220 return text[..m.start()].trim_end().to_string();
221 }
222 }
223 text.to_string()
224}
225
226pub fn is_in_attr_list(line: &str, position: usize) -> bool {
228 for m in ATTR_LIST_PATTERN.find_iter(line) {
229 if m.start() <= position && position < m.end() {
230 return true;
231 }
232 }
233 false
234}
235
236pub fn extract_all_custom_anchors(content: &str) -> Vec<(String, usize)> {
247 let mut anchors = Vec::new();
248
249 for (line_idx, line) in content.lines().enumerate() {
250 let line_num = line_idx + 1;
251
252 for attr_list in find_attr_lists(line) {
253 if let Some(id) = attr_list.id {
254 anchors.push((id, line_num));
255 }
256 }
257 }
258
259 anchors
260}
261
262#[cfg(test)]
263mod tests {
264 use super::*;
265
266 #[test]
267 fn test_contains_attr_list() {
268 assert!(contains_attr_list("# Heading {#custom-id}"));
270 assert!(contains_attr_list("# Heading {.my-class}"));
271 assert!(contains_attr_list("# Heading {#id .class}"));
272 assert!(contains_attr_list("Text {: #id}"));
273 assert!(contains_attr_list("Link {target=\"_blank\"}"));
274
275 assert!(!contains_attr_list("# Regular heading"));
277 assert!(!contains_attr_list("Code with {braces}"));
278 assert!(!contains_attr_list("Empty {}"));
279 assert!(!contains_attr_list("Just text"));
280 }
281
282 #[test]
283 fn test_find_attr_lists_basic() {
284 let attrs = find_attr_lists("# Heading {#custom-id}");
285 assert_eq!(attrs.len(), 1);
286 assert_eq!(attrs[0].id, Some("custom-id".to_string()));
287 assert!(attrs[0].classes.is_empty());
288 }
289
290 #[test]
291 fn test_find_attr_lists_with_class() {
292 let attrs = find_attr_lists("# Heading {.highlight}");
293 assert_eq!(attrs.len(), 1);
294 assert!(attrs[0].id.is_none());
295 assert_eq!(attrs[0].classes, vec!["highlight"]);
296 }
297
298 #[test]
299 fn test_find_attr_lists_complex() {
300 let attrs = find_attr_lists("# Heading {#my-id .class1 .class2 data-value=\"test\"}");
301 assert_eq!(attrs.len(), 1);
302 assert_eq!(attrs[0].id, Some("my-id".to_string()));
303 assert_eq!(attrs[0].classes, vec!["class1", "class2"]);
304 assert_eq!(
305 attrs[0].attributes,
306 vec![("data-value".to_string(), "test".to_string())]
307 );
308 }
309
310 #[test]
311 fn test_find_attr_lists_kramdown_style() {
312 let attrs = find_attr_lists("Paragraph {: #para-id .special }");
314 assert_eq!(attrs.len(), 1);
315 assert_eq!(attrs[0].id, Some("para-id".to_string()));
316 assert_eq!(attrs[0].classes, vec!["special"]);
317 }
318
319 #[test]
320 fn test_extract_heading_custom_id() {
321 assert_eq!(
322 extract_heading_custom_id("# Heading {#my-anchor}"),
323 Some("my-anchor".to_string())
324 );
325 assert_eq!(
326 extract_heading_custom_id("## Title {#title .class}"),
327 Some("title".to_string())
328 );
329 assert_eq!(extract_heading_custom_id("# No ID {.class-only}"), None);
330 assert_eq!(extract_heading_custom_id("# Plain heading"), None);
331 }
332
333 #[test]
334 fn test_strip_attr_list_from_heading() {
335 assert_eq!(strip_attr_list_from_heading("Heading {#my-id}"), "Heading");
336 assert_eq!(strip_attr_list_from_heading("Title {#id .class}"), "Title");
337 assert_eq!(
338 strip_attr_list_from_heading("Multi Word Title {#anchor}"),
339 "Multi Word Title"
340 );
341 assert_eq!(strip_attr_list_from_heading("No attributes"), "No attributes");
342 assert_eq!(strip_attr_list_from_heading("Before {#id} after"), "Before {#id} after");
344 }
345
346 #[test]
347 fn test_is_in_attr_list() {
348 let line = "Some text {#my-id} more text";
349 assert!(!is_in_attr_list(line, 0)); assert!(!is_in_attr_list(line, 8)); assert!(is_in_attr_list(line, 10)); assert!(is_in_attr_list(line, 15)); assert!(!is_in_attr_list(line, 19)); }
355
356 #[test]
357 fn test_extract_all_custom_anchors() {
358 let content = r#"# First Heading {#first}
359
360Some paragraph {: #para-id}
361
362## Second {#second .class}
363
364No ID here.
365
366### Third {.class-only}
367
368{#standalone-id}
369"#;
370 let anchors = extract_all_custom_anchors(content);
371
372 assert_eq!(anchors.len(), 4);
373 assert_eq!(anchors[0], ("first".to_string(), 1));
374 assert_eq!(anchors[1], ("para-id".to_string(), 3));
375 assert_eq!(anchors[2], ("second".to_string(), 5));
376 assert_eq!(anchors[3], ("standalone-id".to_string(), 11));
377 }
378
379 #[test]
380 fn test_multiple_attr_lists_same_line() {
381 let attrs = find_attr_lists("[link]{#link-id} and [other]{#other-id}");
382 assert_eq!(attrs.len(), 2);
383 assert_eq!(attrs[0].id, Some("link-id".to_string()));
384 assert_eq!(attrs[1].id, Some("other-id".to_string()));
385 }
386
387 #[test]
388 fn test_attr_list_positions() {
389 let line = "Text {#my-id} more";
390 let attrs = find_attr_lists(line);
391 assert_eq!(attrs.len(), 1);
392 assert_eq!(attrs[0].start, 5);
393 assert_eq!(attrs[0].end, 13);
394 assert_eq!(&line[attrs[0].start..attrs[0].end], "{#my-id}");
395 }
396
397 #[test]
398 fn test_underscore_in_identifiers() {
399 let attrs = find_attr_lists("# Heading {#my_custom_id .my_class}");
400 assert_eq!(attrs.len(), 1);
401 assert_eq!(attrs[0].id, Some("my_custom_id".to_string()));
402 assert_eq!(attrs[0].classes, vec!["my_class"]);
403 }
404
405 #[test]
408 fn test_is_standalone_attr_list() {
409 assert!(is_standalone_attr_list("{ .class-name }"));
411 assert!(is_standalone_attr_list("{: .class-name }"));
412 assert!(is_standalone_attr_list("{#custom-id}"));
413 assert!(is_standalone_attr_list("{: #custom-id .class }"));
414 assert!(is_standalone_attr_list(" { .indented } ")); assert!(!is_standalone_attr_list("Some text {#id}"));
418 assert!(!is_standalone_attr_list("{#id} more text"));
419 assert!(!is_standalone_attr_list("# Heading {#id}"));
420
421 assert!(!is_standalone_attr_list("{ }"));
423 assert!(!is_standalone_attr_list("{}"));
424 assert!(!is_standalone_attr_list("{ random text }"));
425
426 assert!(!is_standalone_attr_list(""));
428 assert!(!is_standalone_attr_list(" "));
429 }
430}