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
137#[inline]
183pub fn is_mkdocs_anchor_line(line: &str) -> bool {
184 let trimmed = line.trim();
185
186 if !trimmed.starts_with("[]()") {
188 return false;
189 }
190
191 let after_link = &trimmed[4..];
193
194 if !after_link.contains('{') {
196 return false;
197 }
198
199 let attr_start = after_link.trim_start();
201
202 if !attr_start.starts_with('{') {
204 return false;
205 }
206
207 let Some(close_idx) = attr_start.find('}') else {
209 return false;
210 };
211
212 if !attr_start[close_idx + 1..].trim().is_empty() {
214 return false;
215 }
216
217 let attr_content = &attr_start[..=close_idx];
219
220 if !ATTR_LIST_PATTERN.is_match(attr_content) {
222 return false;
223 }
224
225 let attrs = find_attr_lists(attr_content);
227 attrs.iter().any(|a| a.has_id() || a.has_classes())
228}
229
230pub fn find_attr_lists(line: &str) -> Vec<AttrList> {
232 if !line.contains('{') {
233 return Vec::new();
234 }
235
236 let mut results = Vec::new();
237
238 for m in ATTR_LIST_PATTERN.find_iter(line) {
239 let attr_text = m.as_str();
240 let mut attr_list = AttrList {
241 start: m.start(),
242 end: m.end(),
243 ..Default::default()
244 };
245
246 if let Some(caps) = CUSTOM_ID_PATTERN.captures(attr_text)
248 && let Some(id_match) = caps.get(1)
249 {
250 attr_list.id = Some(id_match.as_str().to_string());
251 }
252
253 for caps in CLASS_PATTERN.captures_iter(attr_text) {
255 if let Some(class_match) = caps.get(1) {
256 attr_list.classes.push(class_match.as_str().to_string());
257 }
258 }
259
260 for caps in KEY_VALUE_PATTERN.captures_iter(attr_text) {
262 if let Some(key) = caps.get(1)
263 && let Some(value) = caps.get(2)
264 {
265 attr_list
266 .attributes
267 .push((key.as_str().to_string(), value.as_str().to_string()));
268 }
269 }
270
271 if !attr_list.is_empty() {
272 results.push(attr_list);
273 }
274 }
275
276 results
277}
278
279pub fn extract_heading_custom_id(line: &str) -> Option<String> {
292 let attrs = find_attr_lists(line);
293 attrs.into_iter().find_map(|a| a.id)
294}
295
296pub fn strip_attr_list_from_heading(text: &str) -> String {
309 if let Some(m) = ATTR_LIST_PATTERN.find(text) {
310 let after = &text[m.end()..];
312 if after.trim().is_empty() {
313 return text[..m.start()].trim_end().to_string();
314 }
315 }
316 text.to_string()
317}
318
319pub fn is_in_attr_list(line: &str, position: usize) -> bool {
321 for m in ATTR_LIST_PATTERN.find_iter(line) {
322 if m.start() <= position && position < m.end() {
323 return true;
324 }
325 }
326 false
327}
328
329pub fn extract_all_custom_anchors(content: &str) -> Vec<(String, usize)> {
340 let mut anchors = Vec::new();
341
342 for (line_idx, line) in content.lines().enumerate() {
343 let line_num = line_idx + 1;
344
345 for attr_list in find_attr_lists(line) {
346 if let Some(id) = attr_list.id {
347 anchors.push((id, line_num));
348 }
349 }
350 }
351
352 anchors
353}
354
355#[cfg(test)]
356mod tests {
357 use super::*;
358
359 #[test]
360 fn test_contains_attr_list() {
361 assert!(contains_attr_list("# Heading {#custom-id}"));
363 assert!(contains_attr_list("# Heading {.my-class}"));
364 assert!(contains_attr_list("# Heading {#id .class}"));
365 assert!(contains_attr_list("Text {: #id}"));
366 assert!(contains_attr_list("Link {target=\"_blank\"}"));
367
368 assert!(!contains_attr_list("# Regular heading"));
370 assert!(!contains_attr_list("Code with {braces}"));
371 assert!(!contains_attr_list("Empty {}"));
372 assert!(!contains_attr_list("Just text"));
373 }
374
375 #[test]
376 fn test_find_attr_lists_basic() {
377 let attrs = find_attr_lists("# Heading {#custom-id}");
378 assert_eq!(attrs.len(), 1);
379 assert_eq!(attrs[0].id, Some("custom-id".to_string()));
380 assert!(attrs[0].classes.is_empty());
381 }
382
383 #[test]
384 fn test_find_attr_lists_with_class() {
385 let attrs = find_attr_lists("# Heading {.highlight}");
386 assert_eq!(attrs.len(), 1);
387 assert!(attrs[0].id.is_none());
388 assert_eq!(attrs[0].classes, vec!["highlight"]);
389 }
390
391 #[test]
392 fn test_find_attr_lists_complex() {
393 let attrs = find_attr_lists("# Heading {#my-id .class1 .class2 data-value=\"test\"}");
394 assert_eq!(attrs.len(), 1);
395 assert_eq!(attrs[0].id, Some("my-id".to_string()));
396 assert_eq!(attrs[0].classes, vec!["class1", "class2"]);
397 assert_eq!(
398 attrs[0].attributes,
399 vec![("data-value".to_string(), "test".to_string())]
400 );
401 }
402
403 #[test]
404 fn test_find_attr_lists_kramdown_style() {
405 let attrs = find_attr_lists("Paragraph {: #para-id .special }");
407 assert_eq!(attrs.len(), 1);
408 assert_eq!(attrs[0].id, Some("para-id".to_string()));
409 assert_eq!(attrs[0].classes, vec!["special"]);
410 }
411
412 #[test]
413 fn test_extract_heading_custom_id() {
414 assert_eq!(
415 extract_heading_custom_id("# Heading {#my-anchor}"),
416 Some("my-anchor".to_string())
417 );
418 assert_eq!(
419 extract_heading_custom_id("## Title {#title .class}"),
420 Some("title".to_string())
421 );
422 assert_eq!(extract_heading_custom_id("# No ID {.class-only}"), None);
423 assert_eq!(extract_heading_custom_id("# Plain heading"), None);
424 }
425
426 #[test]
427 fn test_strip_attr_list_from_heading() {
428 assert_eq!(strip_attr_list_from_heading("Heading {#my-id}"), "Heading");
429 assert_eq!(strip_attr_list_from_heading("Title {#id .class}"), "Title");
430 assert_eq!(
431 strip_attr_list_from_heading("Multi Word Title {#anchor}"),
432 "Multi Word Title"
433 );
434 assert_eq!(strip_attr_list_from_heading("No attributes"), "No attributes");
435 assert_eq!(strip_attr_list_from_heading("Before {#id} after"), "Before {#id} after");
437 }
438
439 #[test]
440 fn test_is_in_attr_list() {
441 let line = "Some text {#my-id} more text";
442 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)); }
448
449 #[test]
450 fn test_extract_all_custom_anchors() {
451 let content = r#"# First Heading {#first}
452
453Some paragraph {: #para-id}
454
455## Second {#second .class}
456
457No ID here.
458
459### Third {.class-only}
460
461{#standalone-id}
462"#;
463 let anchors = extract_all_custom_anchors(content);
464
465 assert_eq!(anchors.len(), 4);
466 assert_eq!(anchors[0], ("first".to_string(), 1));
467 assert_eq!(anchors[1], ("para-id".to_string(), 3));
468 assert_eq!(anchors[2], ("second".to_string(), 5));
469 assert_eq!(anchors[3], ("standalone-id".to_string(), 11));
470 }
471
472 #[test]
473 fn test_multiple_attr_lists_same_line() {
474 let attrs = find_attr_lists("[link]{#link-id} and [other]{#other-id}");
475 assert_eq!(attrs.len(), 2);
476 assert_eq!(attrs[0].id, Some("link-id".to_string()));
477 assert_eq!(attrs[1].id, Some("other-id".to_string()));
478 }
479
480 #[test]
481 fn test_attr_list_positions() {
482 let line = "Text {#my-id} more";
483 let attrs = find_attr_lists(line);
484 assert_eq!(attrs.len(), 1);
485 assert_eq!(attrs[0].start, 5);
486 assert_eq!(attrs[0].end, 13);
487 assert_eq!(&line[attrs[0].start..attrs[0].end], "{#my-id}");
488 }
489
490 #[test]
491 fn test_underscore_in_identifiers() {
492 let attrs = find_attr_lists("# Heading {#my_custom_id .my_class}");
493 assert_eq!(attrs.len(), 1);
494 assert_eq!(attrs[0].id, Some("my_custom_id".to_string()));
495 assert_eq!(attrs[0].classes, vec!["my_class"]);
496 }
497
498 #[test]
501 fn test_is_standalone_attr_list() {
502 assert!(is_standalone_attr_list("{ .class-name }"));
504 assert!(is_standalone_attr_list("{: .class-name }"));
505 assert!(is_standalone_attr_list("{#custom-id}"));
506 assert!(is_standalone_attr_list("{: #custom-id .class }"));
507 assert!(is_standalone_attr_list(" { .indented } ")); assert!(!is_standalone_attr_list("Some text {#id}"));
511 assert!(!is_standalone_attr_list("{#id} more text"));
512 assert!(!is_standalone_attr_list("# Heading {#id}"));
513
514 assert!(!is_standalone_attr_list("{ }"));
516 assert!(!is_standalone_attr_list("{}"));
517 assert!(!is_standalone_attr_list("{ random text }"));
518
519 assert!(!is_standalone_attr_list(""));
521 assert!(!is_standalone_attr_list(" "));
522 }
523
524 #[test]
527 fn test_is_mkdocs_anchor_line_basic() {
528 assert!(is_mkdocs_anchor_line("[](){ #example }"));
530 assert!(is_mkdocs_anchor_line("[](){#example}"));
531 assert!(is_mkdocs_anchor_line("[](){ #my-anchor }"));
532 assert!(is_mkdocs_anchor_line("[](){ #anchor_with_underscore }"));
533
534 assert!(is_mkdocs_anchor_line("[](){ .highlight }"));
536 assert!(is_mkdocs_anchor_line("[](){.my-class}"));
537
538 assert!(is_mkdocs_anchor_line("[](){ #anchor .class }"));
540 assert!(is_mkdocs_anchor_line("[](){ .class #anchor }"));
541 assert!(is_mkdocs_anchor_line("[](){ #id .class1 .class2 }"));
542 }
543
544 #[test]
545 fn test_is_mkdocs_anchor_line_kramdown_style() {
546 assert!(is_mkdocs_anchor_line("[](){: #anchor }"));
548 assert!(is_mkdocs_anchor_line("[](){:#anchor}"));
549 assert!(is_mkdocs_anchor_line("[](){: .class }"));
550 assert!(is_mkdocs_anchor_line("[](){: #id .class }"));
551 }
552
553 #[test]
554 fn test_is_mkdocs_anchor_line_whitespace_variations() {
555 assert!(is_mkdocs_anchor_line(" [](){ #example }"));
557 assert!(is_mkdocs_anchor_line("[](){ #example } "));
558 assert!(is_mkdocs_anchor_line(" [](){ #example } "));
559 assert!(is_mkdocs_anchor_line("\t[](){ #example }\t"));
560
561 assert!(is_mkdocs_anchor_line("[]() { #example }"));
563 assert!(is_mkdocs_anchor_line("[]()\t{ #example }"));
564
565 assert!(is_mkdocs_anchor_line("[](){#example}"));
567 }
568
569 #[test]
570 fn test_is_mkdocs_anchor_line_not_anchor_lines() {
571 assert!(!is_mkdocs_anchor_line("[]()"));
573
574 assert!(!is_mkdocs_anchor_line("[](){ }"));
576 assert!(!is_mkdocs_anchor_line("[](){}"));
577
578 assert!(!is_mkdocs_anchor_line("[](url)"));
580 assert!(!is_mkdocs_anchor_line("[text](url)"));
581 assert!(!is_mkdocs_anchor_line("[text](url){ #id }"));
582
583 assert!(!is_mkdocs_anchor_line("[](){ #anchor } extra text"));
585 assert!(!is_mkdocs_anchor_line("[](){ #anchor } <!-- comment -->"));
586
587 assert!(!is_mkdocs_anchor_line("text [](){ #anchor }"));
589 assert!(!is_mkdocs_anchor_line("# Heading [](){ #anchor }"));
590
591 assert!(!is_mkdocs_anchor_line("# Heading"));
593 assert!(!is_mkdocs_anchor_line("Some paragraph text"));
594 assert!(!is_mkdocs_anchor_line("{ #standalone-attr }"));
595
596 assert!(!is_mkdocs_anchor_line("[]{#anchor}")); assert!(!is_mkdocs_anchor_line("[](#anchor)")); assert!(!is_mkdocs_anchor_line("[](){ #anchor")); }
601
602 #[test]
603 fn test_is_mkdocs_anchor_line_edge_cases() {
604 assert!(!is_mkdocs_anchor_line(""));
606 assert!(!is_mkdocs_anchor_line(" "));
607 assert!(!is_mkdocs_anchor_line("\t"));
608
609 assert!(!is_mkdocs_anchor_line("{}"));
611 assert!(!is_mkdocs_anchor_line("{ }"));
612
613 assert!(is_mkdocs_anchor_line("[](){ #id data-value=\"test\" }"));
615
616 assert!(is_mkdocs_anchor_line("[](){ #first #second }"));
618
619 }
622
623 #[test]
624 fn test_is_mkdocs_anchor_line_real_world_examples() {
625 assert!(is_mkdocs_anchor_line("[](){ #installation }"));
627 assert!(is_mkdocs_anchor_line("[](){ #getting-started }"));
628 assert!(is_mkdocs_anchor_line("[](){ #api-reference }"));
629
630 assert!(is_mkdocs_anchor_line("[](){ .annotate }"));
632 assert!(is_mkdocs_anchor_line("[](){ #note .warning }"));
633 }
634}