1use regex::Regex;
39use std::borrow::Cow;
40use std::sync::LazyLock;
41
42pub const HTML_TAG_NAME_PATTERN: &str = "[A-Za-z][A-Za-z0-9-]*";
44
45pub const HTML_TAG_ATTRIBUTES_PATTERN: &str = r#"(?:\s+[^\s"'>/=]+(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s"'=<>`]+))?)*"#;
50
51pub static HTML_OPEN_TAG: LazyLock<Regex> = LazyLock::new(|| {
53 Regex::new(&format!(
54 r"<({HTML_TAG_NAME_PATTERN}){HTML_TAG_ATTRIBUTES_PATTERN}\s*/?>"
55 ))
56 .unwrap()
57});
58
59pub const HTML_BLOCK_TAG_NAME_PATTERN: &str = r"[A-Za-z][^\s/>]*";
62
63pub static HTML_BLOCK_OPEN_TAG: LazyLock<Regex> = LazyLock::new(|| {
67 Regex::new(&format!(
68 r"<({HTML_BLOCK_TAG_NAME_PATTERN}){HTML_TAG_ATTRIBUTES_PATTERN}\s*/?>"
69 ))
70 .unwrap()
71});
72
73static HTML_ANCHOR_CLOSING_TAG: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(?i)^\s*</a\s*>").unwrap());
76
77static HEADER_ID_PATTERN: LazyLock<Regex> =
82 LazyLock::new(|| Regex::new(r"\s*\{\s*:?\s*([^}]*?#[^}]*?)\s*\}\s*$").unwrap());
83
84static ID_VALIDATE_PATTERN: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^[a-zA-Z0-9_\-:]+$").unwrap());
86
87static STANDALONE_ATTR_LIST_PATTERN: LazyLock<Regex> =
90 LazyLock::new(|| Regex::new(r"^\s*\{\s*:?\s*([^}]*#[a-zA-Z0-9_\-:]+[^}]*)\s*\}\s*$").unwrap());
91
92pub fn extract_header_id(line: &str) -> (String, Option<String>) {
113 let line = strip_html_anchor_elements(line);
116 let line = line.as_ref();
117
118 if let Some(captures) = HEADER_ID_PATTERN.captures(line)
119 && let Some(full_match) = captures.get(0)
120 && let Some(attr_content) = captures.get(1)
121 {
122 let attr_str = attr_content.as_str().trim();
123
124 if let Some(hash_pos) = attr_str.find('#') {
126 let after_hash = &attr_str[hash_pos + 1..];
128
129 let is_simple_format = !attr_str.contains(' ') && !attr_str.contains('=') && attr_str.starts_with('#');
134
135 if is_simple_format {
136 let potential_id = after_hash;
138 if ID_VALIDATE_PATTERN.is_match(potential_id) && !potential_id.is_empty() {
139 let clean_text = line[..full_match.start()].trim_end().to_string();
140 return (clean_text, Some(potential_id.to_string()));
141 }
142 } else {
144 if let Some(delimiter_pos) = after_hash.find(|c: char| c.is_whitespace() || c == '.' || c == '=') {
146 let potential_id = &after_hash[..delimiter_pos];
147 if ID_VALIDATE_PATTERN.is_match(potential_id) && !potential_id.is_empty() {
148 let clean_text = line[..full_match.start()].trim_end().to_string();
149 return (clean_text, Some(potential_id.to_string()));
150 }
151 } else {
152 let potential_id = after_hash;
154 if ID_VALIDATE_PATTERN.is_match(potential_id) && !potential_id.is_empty() {
155 let clean_text = line[..full_match.start()].trim_end().to_string();
156 return (clean_text, Some(potential_id.to_string()));
157 }
158 }
159 }
160 }
161 }
162 (line.to_string(), None)
163}
164
165fn strip_html_anchor_elements(text: &str) -> Cow<'_, str> {
172 let anchors = empty_anchor_elements(text);
173 if anchors.is_empty() {
174 return Cow::Borrowed(text);
175 }
176
177 let mut stripped = String::with_capacity(text.len());
178 let mut copied_up_to = 0;
179 for (range, _) in anchors {
180 stripped.push_str(&text[copied_up_to..range.start]);
181 copied_up_to = range.end;
182 }
183 stripped.push_str(&text[copied_up_to..]);
184 Cow::Owned(stripped.trim().to_string())
185}
186
187pub fn extract_html_anchor_ids(text: &str) -> Vec<String> {
193 empty_anchor_elements(text)
194 .into_iter()
195 .filter_map(|(_, open_tag)| html_tag_attribute(open_tag, "id").or_else(|| html_tag_attribute(open_tag, "name")))
196 .map(str::to_string)
197 .collect()
198}
199
200fn empty_anchor_elements(text: &str) -> Vec<(std::ops::Range<usize>, &str)> {
209 if !text.contains('<') {
210 return Vec::new();
211 }
212
213 let opaque = opaque_ranges(text);
214 let mut anchors = Vec::new();
215 let mut pos = 0;
216 while let Some(tag) = HTML_OPEN_TAG.captures_at(text, pos) {
217 let open_tag = tag.get(0).unwrap();
218 if is_within(&opaque, open_tag.start()) || is_backslash_escaped(text, open_tag.start()) {
219 pos = open_tag.start() + 1;
220 continue;
221 }
222 pos = open_tag.end();
223
224 if !tag[1].eq_ignore_ascii_case("a") {
225 continue;
226 }
227 if let Some(closing_tag) = HTML_ANCHOR_CLOSING_TAG.find(&text[open_tag.end()..]) {
228 pos = open_tag.end() + closing_tag.end();
229 anchors.push((open_tag.start()..pos, open_tag.as_str()));
230 }
231 }
232
233 anchors
234}
235
236fn opaque_ranges(text: &str) -> Vec<(usize, usize)> {
244 let bytes = text.as_bytes();
245 let mut ranges = Vec::new();
246 let mut pos = 0;
247
248 while pos < bytes.len() {
249 if bytes[pos] == b'`' {
250 let run_end = pos + bytes[pos..].iter().take_while(|&&b| b == b'`').count();
251 let run_len = run_end - pos;
252 match closing_backtick_run(bytes, run_end, run_len) {
253 Some(close_start) => {
254 ranges.push((pos, close_start + run_len));
255 pos = close_start + run_len;
256 }
257 None => pos = run_end,
258 }
259 } else if bytes[pos..].starts_with(b"<!--") {
260 let end = text[pos + 2..]
261 .find("-->")
262 .map_or(bytes.len(), |offset| pos + 2 + offset + 3);
263 ranges.push((pos, end));
264 pos = end;
265 } else {
266 pos += 1;
267 }
268 }
269
270 ranges
271}
272
273fn closing_backtick_run(bytes: &[u8], from: usize, run_len: usize) -> Option<usize> {
275 let mut pos = from;
276 while pos < bytes.len() {
277 if bytes[pos] != b'`' {
278 pos += 1;
279 continue;
280 }
281 let run_end = pos + bytes[pos..].iter().take_while(|&&b| b == b'`').count();
282 if run_end - pos == run_len {
283 return Some(pos);
284 }
285 pos = run_end;
286 }
287 None
288}
289
290fn is_within(ranges: &[(usize, usize)], pos: usize) -> bool {
291 ranges.iter().any(|&(start, end)| start <= pos && pos < end)
292}
293
294pub fn is_backslash_escaped(text: &str, pos: usize) -> bool {
300 text.as_bytes()[..pos].iter().rev().take_while(|&&b| b == b'\\').count() % 2 == 1
301}
302
303pub fn html_tag_attribute<'a>(tag: &'a str, name: &str) -> Option<&'a str> {
309 let bytes = tag.as_bytes();
310 if bytes.first() != Some(&b'<') {
311 return None;
312 }
313
314 let ends_name = |b: u8| b.is_ascii_whitespace() || matches!(b, b'=' | b'>' | b'/');
315 let mut pos = 1 + bytes[1..].iter().take_while(|&&b| !ends_name(b)).count();
316
317 loop {
318 pos += bytes[pos..].iter().take_while(|b| b.is_ascii_whitespace()).count();
319 if pos >= bytes.len() || matches!(bytes[pos], b'>' | b'/') {
320 return None;
321 }
322
323 let name_start = pos;
324 pos += bytes[pos..].iter().take_while(|&&b| !ends_name(b)).count();
325 let attribute = &tag[name_start..pos];
326
327 let after_name = pos + bytes[pos..].iter().take_while(|b| b.is_ascii_whitespace()).count();
328 let value = if bytes.get(after_name) == Some(&b'=') {
329 let value_start = after_name
330 + 1
331 + bytes[after_name + 1..]
332 .iter()
333 .take_while(|b| b.is_ascii_whitespace())
334 .count();
335 match bytes.get(value_start) {
336 Some("e @ (b'"' | b'\'')) => {
337 let value_end = value_start + 1 + tag[value_start + 1..].find(quote as char)?;
338 pos = value_end + 1;
339 &tag[value_start + 1..value_end]
340 }
341 _ => {
342 let value_end = value_start
343 + bytes[value_start..]
344 .iter()
345 .take_while(|&&b| !b.is_ascii_whitespace() && b != b'>')
346 .count();
347 pos = value_end;
348 &tag[value_start..value_end]
349 }
350 }
351 } else {
352 ""
353 };
354
355 if attribute.eq_ignore_ascii_case(name) {
356 return (!value.is_empty()).then_some(value);
357 }
358 }
359}
360
361pub fn is_standalone_attr_list(line: &str) -> bool {
376 STANDALONE_ATTR_LIST_PATTERN.is_match(line)
377}
378
379pub fn extract_standalone_attr_list_id(line: &str) -> Option<String> {
392 if let Some(captures) = STANDALONE_ATTR_LIST_PATTERN.captures(line)
393 && let Some(attr_content) = captures.get(1)
394 {
395 let attr_str = attr_content.as_str().trim();
396
397 if let Some(hash_pos) = attr_str.find('#') {
399 let after_hash = &attr_str[hash_pos + 1..];
400
401 let is_simple_format = !attr_str.contains(' ') && !attr_str.contains('=') && attr_str.starts_with('#');
403
404 if is_simple_format {
405 let potential_id = after_hash;
407 if ID_VALIDATE_PATTERN.is_match(potential_id) && !potential_id.is_empty() {
408 return Some(potential_id.to_string());
409 }
410 } else {
411 if let Some(delimiter_pos) = after_hash.find(|c: char| c.is_whitespace() || c == '.' || c == '=') {
413 let potential_id = &after_hash[..delimiter_pos];
414 if ID_VALIDATE_PATTERN.is_match(potential_id) && !potential_id.is_empty() {
415 return Some(potential_id.to_string());
416 }
417 } else {
418 let potential_id = after_hash;
420 if ID_VALIDATE_PATTERN.is_match(potential_id) && !potential_id.is_empty() {
421 return Some(potential_id.to_string());
422 }
423 }
424 }
425 }
426 }
427 None
428}
429
430#[cfg(test)]
431mod tests {
432 use super::*;
433
434 #[test]
435 fn test_kramdown_format_extraction() {
436 let (text, id) = extract_header_id("# Header {#simple}");
438 assert_eq!(text, "# Header");
439 assert_eq!(id, Some("simple".to_string()));
440
441 let (text, id) = extract_header_id("## Section {#section-id}");
442 assert_eq!(text, "## Section");
443 assert_eq!(id, Some("section-id".to_string()));
444 }
445
446 #[test]
447 fn test_python_markdown_attr_list_extraction() {
448 let (text, id) = extract_header_id("# Header {:#colon-id}");
450 assert_eq!(text, "# Header");
451 assert_eq!(id, Some("colon-id".to_string()));
452
453 let (text, id) = extract_header_id("# Header {: #spaced-id }");
454 assert_eq!(text, "# Header");
455 assert_eq!(id, Some("spaced-id".to_string()));
456 }
457
458 #[test]
459 fn test_extended_attr_list_extraction() {
460 let (text, id) = extract_header_id("# Header {: #with-class .highlight }");
462 assert_eq!(text, "# Header");
463 assert_eq!(id, Some("with-class".to_string()));
464
465 let (text, id) = extract_header_id("## Section {: #multi .class1 .class2 }");
467 assert_eq!(text, "## Section");
468 assert_eq!(id, Some("multi".to_string()));
469
470 let (text, id) = extract_header_id("### Subsection {: #with-attrs data-test=\"value\" style=\"color: red\" }");
472 assert_eq!(text, "### Subsection");
473 assert_eq!(id, Some("with-attrs".to_string()));
474
475 let (text, id) = extract_header_id("#### Complex {: #complex .highlight data-role=\"button\" title=\"Test\" }");
477 assert_eq!(text, "#### Complex");
478 assert_eq!(id, Some("complex".to_string()));
479
480 let (text, id) = extract_header_id("##### Quotes {: #quotes title=\"Has \\\"nested\\\" quotes\" }");
482 assert_eq!(text, "##### Quotes");
483 assert_eq!(id, Some("quotes".to_string()));
484 }
485
486 #[test]
487 fn test_attr_list_detection_edge_cases() {
488 let (text, id) = extract_header_id("# Header {: .class-only }");
490 assert_eq!(text, "# Header {: .class-only }");
491 assert_eq!(id, None);
492
493 let (text, id) = extract_header_id("# Header { no-hash }");
495 assert_eq!(text, "# Header { no-hash }");
496 assert_eq!(id, None);
497
498 let (text, id) = extract_header_id("# Header {: # }");
500 assert_eq!(text, "# Header {: # }");
501 assert_eq!(id, None);
502
503 let (text, id) = extract_header_id("# Header {: #middle } with more text");
505 assert_eq!(text, "# Header {: #middle } with more text");
506 assert_eq!(id, None);
507 }
508
509 #[test]
510 fn test_standalone_attr_list_detection() {
511 assert!(is_standalone_attr_list("{#custom-id}"));
513 assert!(is_standalone_attr_list("{ #spaced-id }"));
514 assert!(is_standalone_attr_list("{:#colon-id}"));
515 assert!(is_standalone_attr_list("{: #full-format }"));
516
517 assert!(is_standalone_attr_list("{: #with-class .highlight }"));
519 assert!(is_standalone_attr_list("{: #multi .class1 .class2 }"));
520 assert!(is_standalone_attr_list("{: #complex .highlight data-test=\"value\" }"));
521
522 assert!(!is_standalone_attr_list("Some text {#not-standalone}"));
524 assert!(!is_standalone_attr_list("Text before {#id}"));
525 assert!(!is_standalone_attr_list("{#id} text after"));
526 assert!(!is_standalone_attr_list(""));
527 assert!(!is_standalone_attr_list(" ")); assert!(!is_standalone_attr_list("{: .class-only }")); }
530
531 #[test]
532 fn test_standalone_attr_list_id_extraction() {
533 assert_eq!(extract_standalone_attr_list_id("{#simple}"), Some("simple".to_string()));
535 assert_eq!(
536 extract_standalone_attr_list_id("{ #spaced }"),
537 Some("spaced".to_string())
538 );
539 assert_eq!(extract_standalone_attr_list_id("{:#colon}"), Some("colon".to_string()));
540 assert_eq!(extract_standalone_attr_list_id("{: #full }"), Some("full".to_string()));
541
542 assert_eq!(
544 extract_standalone_attr_list_id("{: #with-class .highlight }"),
545 Some("with-class".to_string())
546 );
547 assert_eq!(
548 extract_standalone_attr_list_id("{: #complex .class1 .class2 data=\"value\" }"),
549 Some("complex".to_string())
550 );
551
552 assert_eq!(extract_standalone_attr_list_id("Not an attr-list"), None);
554 assert_eq!(extract_standalone_attr_list_id("Text {#not-standalone}"), None);
555 assert_eq!(extract_standalone_attr_list_id("{: .class-only }"), None);
556 assert_eq!(extract_standalone_attr_list_id(""), None);
557 }
558
559 #[test]
560 fn test_backward_compatibility() {
561 let test_cases = vec![
563 ("# Header {#a}", "# Header", Some("a".to_string())),
564 ("# Header {#simple-id}", "# Header", Some("simple-id".to_string())),
565 ("## Heading {#heading-2}", "## Heading", Some("heading-2".to_string())),
566 (
567 "### With-Hyphens {#with-hyphens}",
568 "### With-Hyphens",
569 Some("with-hyphens".to_string()),
570 ),
571 ];
572
573 for (input, expected_text, expected_id) in test_cases {
574 let (text, id) = extract_header_id(input);
575 assert_eq!(text, expected_text, "Text mismatch for input: {input}");
576 assert_eq!(id, expected_id, "ID mismatch for input: {input}");
577 }
578 }
579
580 #[test]
581 fn test_invalid_id_with_dots() {
582 let (text, id) = extract_header_id("## Another. {#id.with.dots}");
584 assert_eq!(text, "## Another. {#id.with.dots}"); assert_eq!(id, None); let (text, id) = extract_header_id("## Another. {#id.more.dots}");
590 assert_eq!(text, "## Another. {#id.more.dots}");
591 assert_eq!(id, None);
592 }
593
594 #[test]
595 fn test_html_anchor_stripping() {
596 let (text, id) = extract_header_id("<a name=\"cheatsheets\"></a>Cheat Sheets");
601 assert_eq!(text, "Cheat Sheets");
602 assert_eq!(id, None);
603
604 let (text, id) = extract_header_id("<a id=\"tools\"></a>Tools and session management");
606 assert_eq!(text, "Tools and session management");
607 assert_eq!(id, None);
608
609 let (text, id) = extract_header_id("<a name=\"foo\"></a> Heading with space");
611 assert_eq!(text, "Heading with space");
612 assert_eq!(id, None);
613
614 let (text, id) = extract_header_id("<a name=\"old\"></a>My Section {#my-custom-id}");
616 assert_eq!(text, "My Section");
617 assert_eq!(id, Some("my-custom-id".to_string()));
618 }
619
620 #[test]
621 fn test_html_anchor_ids_are_read_from_empty_anchor_elements_in_order() {
622 assert_eq!(extract_html_anchor_ids(r#"Heading<a id="target"></a>"#), ["target"]);
623 assert_eq!(
624 extract_html_anchor_ids(r#"<A class="legacy" NAME='fallback' ID='preferred'></A>Heading"#),
625 ["preferred"]
626 );
627 assert_eq!(
628 extract_html_anchor_ids(r#"<a name='legacy'></a><a id="newer"></a>Heading"#),
629 ["legacy", "newer"]
630 );
631 assert_eq!(extract_html_anchor_ids("<a id=plain></a>Heading"), ["plain"]);
632 assert!(extract_html_anchor_ids(r##"<a href="#target"></a>Heading"##).is_empty());
633 assert!(extract_html_anchor_ids(r#"<span id="target"></span>Heading"#).is_empty());
634 assert!(extract_html_anchor_ids(r#"<a id="target">text</a>Heading"#).is_empty());
635 }
636
637 #[test]
638 fn test_an_attribute_merely_ending_in_id_or_name_is_not_an_anchor() {
639 assert!(extract_html_anchor_ids(r#"Foo<a data-id="tracking" data-name="pixel"></a>"#).is_empty());
640 assert!(extract_html_anchor_ids(r#"Foo<a id=""></a>"#).is_empty());
641 }
642
643 #[test]
644 fn test_a_quoted_attribute_value_may_contain_a_closing_angle_bracket() {
645 let raw = r#"<a title="a > b" id="target"></a>Heading"#;
646 assert_eq!(extract_html_anchor_ids(raw), ["target"]);
647 assert_eq!(extract_header_id(raw), ("Heading".to_string(), None));
648 }
649
650 #[test]
651 fn test_anchor_markup_inside_a_code_span_is_heading_text() {
652 let raw = "Showing `<a id=\"literal\"></a>` syntax";
653 assert!(extract_html_anchor_ids(raw).is_empty());
654 assert_eq!(extract_header_id(raw), (raw.to_string(), None));
655
656 let raw = "Showing `<a id=\"literal\"></a>` syntax<a id=\"real\"></a>";
658 assert_eq!(extract_html_anchor_ids(raw), ["real"]);
659 assert_eq!(extract_header_id(raw).0, "Showing `<a id=\"literal\"></a>` syntax");
660 }
661
662 #[test]
663 fn test_anchor_markup_inside_an_html_comment_is_not_a_target() {
664 let raw = "Foo <!-- <a id=\"hidden\"></a> -->";
665 assert!(extract_html_anchor_ids(raw).is_empty());
666 assert_eq!(extract_header_id(raw), (raw.to_string(), None));
667 }
668
669 #[test]
670 fn test_html_tag_attribute_matches_whole_names_case_insensitively() {
671 assert_eq!(html_tag_attribute(r#"<div data-id="x" ID="y">"#, "id"), Some("y"));
672 assert_eq!(html_tag_attribute("<a name = 'legacy' >", "name"), Some("legacy"));
673 assert_eq!(html_tag_attribute("<a id=plain>", "id"), Some("plain"));
674 assert_eq!(html_tag_attribute(r#"<a id="first" id="second">"#, "id"), Some("first"));
675 assert_eq!(html_tag_attribute(r#"<a id="">"#, "id"), None);
676 assert_eq!(html_tag_attribute(r#"<a hidden id="x">"#, "hidden"), None);
677 assert_eq!(html_tag_attribute(r#"<a hidden id="x">"#, "id"), Some("x"));
678 assert_eq!(html_tag_attribute(r#"<a title="a > b" id="x">"#, "id"), Some("x"));
679 assert_eq!(html_tag_attribute(r#"<video title="id=fake">"#, "id"), None);
680 assert_eq!(html_tag_attribute("<br/>", "id"), None);
681 }
682
683 #[test]
684 fn test_html_anchor_stripping_handles_attribute_variations() {
685 let (text, id) = extract_header_id(r#"<A class="legacy" ID='target'></A>Heading"#);
686 assert_eq!(text, "Heading");
687 assert_eq!(id, None);
688 }
689
690 #[test]
691 fn test_a_backslash_escaped_anchor_element_is_heading_text() {
692 let raw = r#"Show \<a id="example"></a> syntax"#;
695 assert!(extract_html_anchor_ids(raw).is_empty());
696 assert_eq!(extract_header_id(raw), (raw.to_string(), None));
697
698 let raw = r#"Show \\<a id="example"></a> syntax"#;
699 assert_eq!(extract_html_anchor_ids(raw), ["example"]);
700 assert_eq!(extract_header_id(raw).0, r"Show \\ syntax");
701 }
702
703 #[test]
704 fn test_stripping_an_anchor_element_keeps_the_whitespace_beside_it() {
705 assert_eq!(extract_header_id(r#"Foo<a id="alias"></a> Bar"#).0, "Foo Bar");
709 assert_eq!(extract_header_id(r#"Foo <a id="alias"></a>Bar"#).0, "Foo Bar");
710 assert_eq!(extract_header_id(r#"<a id="alias"></a> Foo"#).0, "Foo");
711 assert_eq!(extract_header_id(r#"Foo <a id="alias"></a>"#).0, "Foo");
712 }
713
714 #[test]
715 fn test_an_anchor_inside_another_tags_attribute_value_is_not_an_element() {
716 let raw = r#"<span title='<a id="fake"></a>'>Foo</span>"#;
719 assert!(extract_html_anchor_ids(raw).is_empty());
720 assert_eq!(extract_header_id(raw), (raw.to_string(), None));
721
722 let raw = r#"<span title='x'><a id="real"></a>Foo</span>"#;
723 assert_eq!(extract_html_anchor_ids(raw), ["real"]);
724 assert_eq!(extract_header_id(raw).0, "<span title='x'>Foo</span>");
725 }
726
727 #[test]
728 fn test_an_escaped_tag_does_not_hide_the_element_written_inside_it() {
729 let raw = r#"\<span title='<a id="real"></a>'>Foo"#;
732 assert_eq!(extract_html_anchor_ids(raw), ["real"]);
733 assert_eq!(extract_header_id(raw).0, r#"\<span title=''>Foo"#);
734 }
735
736 #[test]
737 fn test_a_degenerate_comment_ends_at_its_own_closer() {
738 assert_eq!(extract_html_anchor_ids(r#"<!--> <a id="x"></a> --> Foo"#), ["x"]);
741 assert_eq!(extract_html_anchor_ids(r#"<!---> <a id="y"></a> --> Foo"#), ["y"]);
742 assert!(extract_html_anchor_ids(r#"<!-- <a id="z"></a> --> Foo"#).is_empty());
743 }
744
745 #[test]
746 fn test_is_backslash_escaped_counts_the_run_of_backslashes() {
747 assert!(!is_backslash_escaped("<a>", 0));
748 assert!(is_backslash_escaped(r"\<a>", 1));
749 assert!(!is_backslash_escaped(r"\\<a>", 2));
750 assert!(is_backslash_escaped(r"\\\<a>", 3));
751 assert!(!is_backslash_escaped(r"x<a>", 1));
752 }
753}