rumdl_lib/utils/
mkdocs_attr_list.rs1use regex::Regex;
35use std::sync::LazyLock;
36
37pub static ATTR_LIST_PATTERN: LazyLock<Regex> = LazyLock::new(|| {
44 Regex::new(r#"\{:?\s*(?:(?:#[a-zA-Z0-9_][a-zA-Z0-9_-]*|\.[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-Z0-9_][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]
151pub fn is_block_attribute_line(line: &str, flavor: crate::config::MarkdownFlavor) -> bool {
152 crate::utils::kramdown_utils::is_kramdown_block_attribute(line)
153 || (flavor.supports_attr_lists() && is_standalone_attr_list(line))
154}
155
156#[inline]
202pub fn is_mkdocs_anchor_line(line: &str) -> bool {
203 let trimmed = line.trim();
204
205 if !trimmed.starts_with("[]()") {
207 return false;
208 }
209
210 let after_link = &trimmed[4..];
212
213 if !after_link.contains('{') {
215 return false;
216 }
217
218 let attr_start = after_link.trim_start();
220
221 if !attr_start.starts_with('{') {
223 return false;
224 }
225
226 let Some(close_idx) = attr_start.find('}') else {
228 return false;
229 };
230
231 if !attr_start[close_idx + 1..].trim().is_empty() {
233 return false;
234 }
235
236 let attr_content = &attr_start[..=close_idx];
238
239 if !ATTR_LIST_PATTERN.is_match(attr_content) {
241 return false;
242 }
243
244 let attrs = find_attr_lists(attr_content);
246 attrs.iter().any(|a| a.has_id() || a.has_classes())
247}
248
249pub fn find_attr_lists(line: &str) -> Vec<AttrList> {
251 if !line.contains('{') {
252 return Vec::new();
253 }
254
255 let mut results = Vec::new();
256
257 for m in ATTR_LIST_PATTERN.find_iter(line) {
258 let attr_text = m.as_str();
259 let mut attr_list = AttrList {
260 start: m.start(),
261 end: m.end(),
262 ..Default::default()
263 };
264
265 if let Some(caps) = CUSTOM_ID_PATTERN.captures(attr_text)
267 && let Some(id_match) = caps.get(1)
268 {
269 attr_list.id = Some(id_match.as_str().to_string());
270 }
271
272 for caps in CLASS_PATTERN.captures_iter(attr_text) {
274 if let Some(class_match) = caps.get(1) {
275 attr_list.classes.push(class_match.as_str().to_string());
276 }
277 }
278
279 for caps in KEY_VALUE_PATTERN.captures_iter(attr_text) {
281 if let Some(key) = caps.get(1)
282 && let Some(value) = caps.get(2)
283 {
284 attr_list
285 .attributes
286 .push((key.as_str().to_string(), value.as_str().to_string()));
287 }
288 }
289
290 if !attr_list.is_empty() {
291 results.push(attr_list);
292 }
293 }
294
295 results
296}
297
298#[cfg(test)]
299mod tests {
300 use super::*;
301
302 #[test]
303 fn test_contains_attr_list() {
304 assert!(contains_attr_list("# Heading {#custom-id}"));
306 assert!(contains_attr_list("# Heading {.my-class}"));
307 assert!(contains_attr_list("# Heading {#id .class}"));
308 assert!(contains_attr_list("Text {: #id}"));
309 assert!(contains_attr_list("Link {target=\"_blank\"}"));
310
311 assert!(!contains_attr_list("# Regular heading"));
313 assert!(!contains_attr_list("Code with {braces}"));
314 assert!(!contains_attr_list("Empty {}"));
315 assert!(!contains_attr_list("Just text"));
316 }
317
318 #[test]
319 fn test_find_attr_lists_basic() {
320 let attrs = find_attr_lists("# Heading {#custom-id}");
321 assert_eq!(attrs.len(), 1);
322 assert_eq!(attrs[0].id, Some("custom-id".to_string()));
323 assert!(attrs[0].classes.is_empty());
324 }
325
326 #[test]
327 fn test_find_attr_lists_with_class() {
328 let attrs = find_attr_lists("# Heading {.highlight}");
329 assert_eq!(attrs.len(), 1);
330 assert!(attrs[0].id.is_none());
331 assert_eq!(attrs[0].classes, vec!["highlight"]);
332 }
333
334 #[test]
335 fn test_find_attr_lists_complex() {
336 let attrs = find_attr_lists("# Heading {#my-id .class1 .class2 data-value=\"test\"}");
337 assert_eq!(attrs.len(), 1);
338 assert_eq!(attrs[0].id, Some("my-id".to_string()));
339 assert_eq!(attrs[0].classes, vec!["class1", "class2"]);
340 assert_eq!(
341 attrs[0].attributes,
342 vec![("data-value".to_string(), "test".to_string())]
343 );
344 }
345
346 #[test]
347 fn test_find_attr_lists_kramdown_style() {
348 let attrs = find_attr_lists("Paragraph {: #para-id .special }");
350 assert_eq!(attrs.len(), 1);
351 assert_eq!(attrs[0].id, Some("para-id".to_string()));
352 assert_eq!(attrs[0].classes, vec!["special"]);
353 }
354
355 #[test]
356 fn test_multiple_attr_lists_same_line() {
357 let attrs = find_attr_lists("[link]{#link-id} and [other]{#other-id}");
358 assert_eq!(attrs.len(), 2);
359 assert_eq!(attrs[0].id, Some("link-id".to_string()));
360 assert_eq!(attrs[1].id, Some("other-id".to_string()));
361 }
362
363 #[test]
364 fn test_attr_list_positions() {
365 let line = "Text {#my-id} more";
366 let attrs = find_attr_lists(line);
367 assert_eq!(attrs.len(), 1);
368 assert_eq!(attrs[0].start, 5);
369 assert_eq!(attrs[0].end, 13);
370 assert_eq!(&line[attrs[0].start..attrs[0].end], "{#my-id}");
371 }
372
373 #[test]
374 fn test_underscore_in_identifiers() {
375 let attrs = find_attr_lists("# Heading {#my_custom_id .my_class}");
376 assert_eq!(attrs.len(), 1);
377 assert_eq!(attrs[0].id, Some("my_custom_id".to_string()));
378 assert_eq!(attrs[0].classes, vec!["my_class"]);
379 }
380
381 #[test]
384 fn test_is_standalone_attr_list() {
385 assert!(is_standalone_attr_list("{ .class-name }"));
387 assert!(is_standalone_attr_list("{: .class-name }"));
388 assert!(is_standalone_attr_list("{#custom-id}"));
389 assert!(is_standalone_attr_list("{: #custom-id .class }"));
390 assert!(is_standalone_attr_list(" { .indented } ")); assert!(!is_standalone_attr_list("Some text {#id}"));
394 assert!(!is_standalone_attr_list("{#id} more text"));
395 assert!(!is_standalone_attr_list("# Heading {#id}"));
396
397 assert!(!is_standalone_attr_list("{ }"));
399 assert!(!is_standalone_attr_list("{}"));
400 assert!(!is_standalone_attr_list("{ random text }"));
401
402 assert!(!is_standalone_attr_list(""));
404 assert!(!is_standalone_attr_list(" "));
405 }
406
407 #[test]
410 fn test_is_mkdocs_anchor_line_basic() {
411 assert!(is_mkdocs_anchor_line("[](){ #example }"));
413 assert!(is_mkdocs_anchor_line("[](){#example}"));
414 assert!(is_mkdocs_anchor_line("[](){ #my-anchor }"));
415 assert!(is_mkdocs_anchor_line("[](){ #anchor_with_underscore }"));
416
417 assert!(is_mkdocs_anchor_line("[](){ .highlight }"));
419 assert!(is_mkdocs_anchor_line("[](){.my-class}"));
420
421 assert!(is_mkdocs_anchor_line("[](){ #anchor .class }"));
423 assert!(is_mkdocs_anchor_line("[](){ .class #anchor }"));
424 assert!(is_mkdocs_anchor_line("[](){ #id .class1 .class2 }"));
425 }
426
427 #[test]
428 fn test_is_mkdocs_anchor_line_kramdown_style() {
429 assert!(is_mkdocs_anchor_line("[](){: #anchor }"));
431 assert!(is_mkdocs_anchor_line("[](){:#anchor}"));
432 assert!(is_mkdocs_anchor_line("[](){: .class }"));
433 assert!(is_mkdocs_anchor_line("[](){: #id .class }"));
434 }
435
436 #[test]
437 fn test_is_mkdocs_anchor_line_whitespace_variations() {
438 assert!(is_mkdocs_anchor_line(" [](){ #example }"));
440 assert!(is_mkdocs_anchor_line("[](){ #example } "));
441 assert!(is_mkdocs_anchor_line(" [](){ #example } "));
442 assert!(is_mkdocs_anchor_line("\t[](){ #example }\t"));
443
444 assert!(is_mkdocs_anchor_line("[]() { #example }"));
446 assert!(is_mkdocs_anchor_line("[]()\t{ #example }"));
447
448 assert!(is_mkdocs_anchor_line("[](){#example}"));
450 }
451
452 #[test]
453 fn test_is_mkdocs_anchor_line_not_anchor_lines() {
454 assert!(!is_mkdocs_anchor_line("[]()"));
456
457 assert!(!is_mkdocs_anchor_line("[](){ }"));
459 assert!(!is_mkdocs_anchor_line("[](){}"));
460
461 assert!(!is_mkdocs_anchor_line("[](url)"));
463 assert!(!is_mkdocs_anchor_line("[text](url)"));
464 assert!(!is_mkdocs_anchor_line("[text](url){ #id }"));
465
466 assert!(!is_mkdocs_anchor_line("[](){ #anchor } extra text"));
468 assert!(!is_mkdocs_anchor_line("[](){ #anchor } <!-- comment -->"));
469
470 assert!(!is_mkdocs_anchor_line("text [](){ #anchor }"));
472 assert!(!is_mkdocs_anchor_line("# Heading [](){ #anchor }"));
473
474 assert!(!is_mkdocs_anchor_line("# Heading"));
476 assert!(!is_mkdocs_anchor_line("Some paragraph text"));
477 assert!(!is_mkdocs_anchor_line("{ #standalone-attr }"));
478
479 assert!(!is_mkdocs_anchor_line("[]{#anchor}")); assert!(!is_mkdocs_anchor_line("[](#anchor)")); assert!(!is_mkdocs_anchor_line("[](){ #anchor")); }
484
485 #[test]
486 fn test_is_mkdocs_anchor_line_edge_cases() {
487 assert!(!is_mkdocs_anchor_line(""));
489 assert!(!is_mkdocs_anchor_line(" "));
490 assert!(!is_mkdocs_anchor_line("\t"));
491
492 assert!(!is_mkdocs_anchor_line("{}"));
494 assert!(!is_mkdocs_anchor_line("{ }"));
495
496 assert!(is_mkdocs_anchor_line("[](){ #id data-value=\"test\" }"));
498
499 assert!(is_mkdocs_anchor_line("[](){ #first #second }"));
501
502 }
505
506 #[test]
507 fn test_is_mkdocs_anchor_line_real_world_examples() {
508 assert!(is_mkdocs_anchor_line("[](){ #installation }"));
510 assert!(is_mkdocs_anchor_line("[](){ #getting-started }"));
511 assert!(is_mkdocs_anchor_line("[](){ #api-reference }"));
512
513 assert!(is_mkdocs_anchor_line("[](){ .annotate }"));
515 assert!(is_mkdocs_anchor_line("[](){ #note .warning }"));
516 }
517
518 #[test]
519 fn test_attr_list_pattern_digit_starting_ids() {
520 assert!(contains_attr_list("{#3rd-party}"));
522 assert!(contains_attr_list("{ #3rd-party }"));
523 assert!(contains_attr_list("{#1}"));
524 assert!(contains_attr_list("{#123-foo}"));
525 assert!(contains_attr_list("{#1st-section}"));
526 assert!(contains_attr_list("{#2nd_item}"));
527
528 assert!(contains_attr_list("{#3rd-party .glossary}"));
530
531 assert!(contains_attr_list("{: #3rd-party}"));
533 }
534
535 #[test]
536 fn test_custom_id_extraction_digit_starting() {
537 let attrs = find_attr_lists("{#3rd-party}");
539 assert_eq!(attrs.len(), 1);
540 assert_eq!(attrs[0].id, Some("3rd-party".to_string()));
541
542 let attrs = find_attr_lists("{#1}");
543 assert_eq!(attrs.len(), 1);
544 assert_eq!(attrs[0].id, Some("1".to_string()));
545
546 let attrs = find_attr_lists("{#123-foo}");
547 assert_eq!(attrs.len(), 1);
548 assert_eq!(attrs[0].id, Some("123-foo".to_string()));
549
550 let attrs = find_attr_lists("{#1st-section}");
551 assert_eq!(attrs.len(), 1);
552 assert_eq!(attrs[0].id, Some("1st-section".to_string()));
553
554 let attrs = find_attr_lists("{#2nd_item}");
555 assert_eq!(attrs.len(), 1);
556 assert_eq!(attrs[0].id, Some("2nd_item".to_string()));
557 }
558
559 #[test]
560 fn test_class_pattern_still_rejects_digit_starting() {
561 let attrs = find_attr_lists("{.3invalid}");
563 assert_eq!(attrs.len(), 0, "Digit-starting class names should not be matched");
564 }
565
566 #[test]
567 fn test_mkdocs_anchor_line_digit_starting_id() {
568 assert!(is_mkdocs_anchor_line("[](){ #3rd-party }"));
570 assert!(is_mkdocs_anchor_line("[](){ #1 }"));
571 assert!(is_mkdocs_anchor_line("[](){ #123-section }"));
572 }
573}