1use crate::config::MarkdownFlavor;
7use crate::lint_context::{HtmlTag, LintContext};
8use crate::utils::mkdocs_admonitions;
9use crate::utils::mkdocs_critic;
10use crate::utils::mkdocs_extensions;
11use crate::utils::mkdocs_footnotes;
12use crate::utils::mkdocs_icons;
13use crate::utils::mkdocs_snippets;
14use crate::utils::mkdocs_tabs;
15use regex::Regex;
16use std::sync::LazyLock;
17
18static INLINE_MATH_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\$\$[^$]*\$\$|\$[^$\n]*\$").unwrap());
27
28#[derive(Debug, Clone, Copy)]
30pub struct ByteRange {
31 pub start: usize,
32 pub end: usize,
33}
34
35#[derive(Debug, Default)]
37pub struct HtmlCommentScan {
38 pub ranges: Vec<ByteRange>,
40 pub unterminated: Option<usize>,
43}
44
45pub fn compute_html_comment_ranges(content: &str) -> Vec<ByteRange> {
48 compute_html_comment_ranges_filtered(content, &[], &[])
49}
50
51pub fn compute_html_comment_ranges_filtered(
72 content: &str,
73 code_span_ranges: &[(usize, usize)],
74 code_block_ranges: &[(usize, usize)],
75) -> Vec<ByteRange> {
76 scan_html_comments(content, code_span_ranges, code_block_ranges, 0).ranges
77}
78
79pub fn scan_html_comments(
90 content: &str,
91 code_span_ranges: &[(usize, usize)],
92 code_block_ranges: &[(usize, usize)],
93 scan_from: usize,
94) -> HtmlCommentScan {
95 let in_code = |pos: usize| {
96 code_span_ranges.iter().any(|&(start, end)| pos >= start && pos < end)
97 || code_block_ranges.iter().any(|&(start, end)| pos >= start && pos < end)
98 };
99
100 let mut ranges = Vec::new();
101 let mut search_from = scan_from.min(content.len());
102 while let Some(rel) = content[search_from..].find("<!--") {
103 let open = search_from + rel;
104 if in_code(open) {
105 search_from = open + "<!--".len();
107 continue;
108 }
109 let mut close_from = open + 2;
114 let end = loop {
115 let Some(crel) = content[close_from..].find("-->") else {
116 break None;
117 };
118 let close = close_from + crel;
119 if in_code(close) {
120 close_from = close + "-->".len();
121 continue;
122 }
123 break Some(close + "-->".len());
124 };
125 match end {
126 Some(end) => {
127 ranges.push(ByteRange { start: open, end });
128 search_from = end;
129 }
130 None => {
133 return HtmlCommentScan {
134 ranges,
135 unterminated: Some(open),
136 };
137 }
138 }
139 }
140 HtmlCommentScan {
141 ranges,
142 unterminated: None,
143 }
144}
145
146pub fn unterminated_html_comment_outside(
160 reported: Option<usize>,
161 hidden_ranges: &[(usize, usize)],
162 content: &str,
163 code_span_ranges: &[(usize, usize)],
164 code_block_ranges: &[(usize, usize)],
165 scan_from: usize,
166) -> Option<usize> {
167 let offset = reported?;
168 if !hidden_ranges
169 .iter()
170 .any(|&(start, end)| offset >= start && offset < end)
171 {
172 return reported;
173 }
174 let mut literal_ranges = code_block_ranges.to_vec();
175 literal_ranges.extend_from_slice(hidden_ranges);
176 scan_html_comments(content, code_span_ranges, &literal_ranges, scan_from).unterminated
177}
178
179pub fn unterminated_comment_range(opener: usize, html_blocks: &[(usize, usize)]) -> Option<ByteRange> {
197 html_blocks
198 .iter()
199 .find(|&&(start, end)| opener >= start && opener < end)
200 .map(|&(_, end)| ByteRange { start: opener, end })
201}
202
203pub fn is_in_html_comment_ranges(ranges: &[ByteRange], byte_pos: usize) -> bool {
206 ranges
208 .binary_search_by(|range| {
209 if byte_pos < range.start {
210 std::cmp::Ordering::Greater
211 } else if byte_pos >= range.end {
212 std::cmp::Ordering::Less
213 } else {
214 std::cmp::Ordering::Equal
215 }
216 })
217 .is_ok()
218}
219
220pub fn is_line_entirely_in_html_comment(ranges: &[ByteRange], content_start: usize, content_end: usize) -> bool {
231 for range in ranges {
232 if content_start >= range.start && content_start < range.end {
234 return content_end <= range.end;
235 }
236 }
237 false
238}
239
240#[inline]
242pub fn is_in_jsx_expression(ctx: &LintContext, byte_pos: usize) -> bool {
243 ctx.flavor == MarkdownFlavor::MDX && ctx.is_in_jsx_expression(byte_pos)
244}
245
246#[inline]
248pub fn is_in_mdx_comment(ctx: &LintContext, byte_pos: usize) -> bool {
249 ctx.flavor == MarkdownFlavor::MDX && ctx.is_in_mdx_comment(byte_pos)
250}
251
252pub fn is_mkdocs_snippet_line(line: &str, flavor: MarkdownFlavor) -> bool {
254 flavor == MarkdownFlavor::MkDocs && mkdocs_snippets::is_snippet_marker(line)
255}
256
257pub fn is_mkdocs_admonition_line(line: &str, flavor: MarkdownFlavor) -> bool {
259 flavor == MarkdownFlavor::MkDocs && mkdocs_admonitions::is_admonition_marker(line)
260}
261
262pub fn is_mkdocs_footnote_line(line: &str, flavor: MarkdownFlavor) -> bool {
264 flavor == MarkdownFlavor::MkDocs && mkdocs_footnotes::is_footnote_definition(line)
265}
266
267pub fn is_mkdocs_tab_line(line: &str, flavor: MarkdownFlavor) -> bool {
269 flavor == MarkdownFlavor::MkDocs && mkdocs_tabs::is_tab_marker(line)
270}
271
272pub fn is_mkdocs_critic_line(line: &str, flavor: MarkdownFlavor) -> bool {
274 flavor == MarkdownFlavor::MkDocs && mkdocs_critic::contains_critic_markup(line)
275}
276
277pub fn is_in_html_tag(ctx: &LintContext, byte_pos: usize) -> bool {
279 for html_tag in ctx.html_tags().iter() {
280 if html_tag.byte_offset <= byte_pos && byte_pos < html_tag.byte_end {
281 return true;
282 }
283 }
284 false
285}
286
287pub fn is_in_math_context(ctx: &LintContext, byte_pos: usize) -> bool {
294 ctx.math_byte_ranges()
297 .iter()
298 .any(|&(start, end)| byte_pos >= start && byte_pos < end)
299}
300
301pub(crate) fn math_block_ranges(content: &str) -> Vec<(usize, usize)> {
312 let bytes = content.as_bytes();
313 let mut ranges = Vec::new();
314 let mut open: Option<usize> = None;
315 let mut line_start = 0usize;
316 let mut i = 0;
317 while i < bytes.len() {
318 match bytes[i] {
319 b'\n' => {
320 line_start = i + 1;
321 i += 1;
322 }
323 b'$' if i + 1 < bytes.len() && bytes[i + 1] == b'$' => {
324 match open {
325 None => {
326 let starts_line = bytes[line_start..i]
329 .iter()
330 .all(|&b| b == b' ' || b == b'\t' || b == b'>');
331 if starts_line {
332 open = Some(i);
333 }
334 }
335 Some(start) => {
336 ranges.push((start, i + 2));
337 open = None;
338 }
339 }
340 i += 2;
341 }
342 _ => i += 1,
343 }
344 }
345 ranges
346}
347
348pub fn is_in_math_block(content: &str, byte_pos: usize) -> bool {
355 math_block_ranges(content)
356 .iter()
357 .any(|&(start, end)| byte_pos >= start && byte_pos < end)
358}
359
360pub fn is_in_inline_math(content: &str, byte_pos: usize) -> bool {
369 for m in INLINE_MATH_REGEX.find_iter(content) {
370 if content[m.start()..m.end()].starts_with("$$") {
371 continue;
372 }
373 if m.start() <= byte_pos && byte_pos < m.end() {
374 return true;
375 }
376 }
377 false
378}
379
380pub fn math_byte_ranges(content: &str) -> Vec<(usize, usize)> {
388 let mut ranges = math_block_ranges(content);
389 for m in INLINE_MATH_REGEX.find_iter(content) {
390 if content[m.start()..m.end()].starts_with("$$") {
391 continue;
392 }
393 ranges.push((m.start(), m.end()));
394 }
395 ranges
396}
397
398pub fn is_table_line(line: &str) -> bool {
400 let trimmed = line.trim();
401
402 if trimmed
404 .chars()
405 .all(|c| c == '|' || c == '-' || c == ':' || c.is_whitespace())
406 && trimmed.contains('|')
407 && trimmed.contains('-')
408 {
409 return true;
410 }
411
412 if (trimmed.starts_with('|') || trimmed.ends_with('|')) && trimmed.matches('|').count() >= 2 {
414 return true;
415 }
416
417 false
418}
419
420pub fn is_in_icon_shortcode(line: &str, position: usize, _flavor: MarkdownFlavor) -> bool {
423 mkdocs_icons::is_in_any_shortcode(line, position)
426}
427
428pub fn is_in_pymdown_markup(line: &str, position: usize, flavor: MarkdownFlavor) -> bool {
434 match flavor {
435 MarkdownFlavor::MkDocs => mkdocs_extensions::is_in_pymdown_markup(line, position),
436 MarkdownFlavor::Obsidian => {
437 mkdocs_extensions::is_in_mark(line, position)
439 }
440 _ => false,
441 }
442}
443
444pub fn is_in_inline_html_code(line: &str, position: usize) -> bool {
449 const TAGS: &[&str] = &["code", "pre", "samp", "kbd", "var"];
451
452 let bytes = line.as_bytes();
453
454 for tag in TAGS {
455 let open_bytes = format!("<{tag}").into_bytes();
456 let close_pattern = format!("</{tag}>").into_bytes();
457
458 let mut search_from = 0;
459 while search_from + open_bytes.len() <= bytes.len() {
460 let Some(open_abs) = find_case_insensitive(bytes, &open_bytes, search_from) else {
462 break;
463 };
464
465 let after_tag = open_abs + open_bytes.len();
466
467 if after_tag < bytes.len() {
469 let next = bytes[after_tag];
470 if next != b'>' && next != b' ' && next != b'\t' {
471 search_from = after_tag;
472 continue;
473 }
474 }
475
476 let Some(tag_close) = bytes[after_tag..].iter().position(|&b| b == b'>') else {
478 break;
479 };
480 let content_start = after_tag + tag_close + 1;
481
482 let Some(close_start) = find_case_insensitive(bytes, &close_pattern, content_start) else {
484 break;
485 };
486 let content_end = close_start;
487
488 if position >= content_start && position < content_end {
489 return true;
490 }
491
492 search_from = close_start + close_pattern.len();
493 }
494 }
495 false
496}
497
498fn find_case_insensitive(haystack: &[u8], needle: &[u8], from: usize) -> Option<usize> {
500 if needle.is_empty() || from + needle.len() > haystack.len() {
501 return None;
502 }
503 for i in from..=haystack.len() - needle.len() {
504 if haystack[i..i + needle.len()]
505 .iter()
506 .zip(needle.iter())
507 .all(|(h, n)| h.eq_ignore_ascii_case(n))
508 {
509 return Some(i);
510 }
511 }
512 None
513}
514
515pub fn is_in_mkdocs_markup(line: &str, position: usize, flavor: MarkdownFlavor) -> bool {
519 if is_in_icon_shortcode(line, position, flavor) {
520 return true;
521 }
522 if is_in_pymdown_markup(line, position, flavor) {
523 return true;
524 }
525 false
526}
527
528fn is_in_inline_code_on_line(line: &str, byte_pos: usize) -> bool {
534 let bytes = line.as_bytes();
535 let mut i = 0;
536
537 while i < bytes.len() {
538 if bytes[i] == b'`' {
539 let open_start = i;
540 let mut backtick_count = 0;
541 while i < bytes.len() && bytes[i] == b'`' {
542 backtick_count += 1;
543 i += 1;
544 }
545
546 let mut j = i;
548 while j < bytes.len() {
549 if bytes[j] == b'`' {
550 let mut close_count = 0;
551 while j < bytes.len() && bytes[j] == b'`' {
552 close_count += 1;
553 j += 1;
554 }
555 if close_count == backtick_count {
556 if byte_pos >= open_start && byte_pos < j {
558 return true;
559 }
560 i = j;
561 break;
562 }
563 } else {
564 j += 1;
565 }
566 }
567
568 if j >= bytes.len() {
569 break;
571 }
572 } else {
573 i += 1;
574 }
575 }
576
577 false
578}
579
580fn is_byte_in_html_tag(html_tags: &[HtmlTag], byte_pos: usize) -> bool {
582 let idx = html_tags.partition_point(|tag| tag.byte_offset <= byte_pos);
583 idx > 0 && byte_pos < html_tags[idx - 1].byte_end
584}
585
586fn is_byte_in_html_code_content(code_ranges: &[(usize, usize)], byte_pos: usize) -> bool {
589 let idx = code_ranges.partition_point(|&(start, _)| start <= byte_pos);
590 idx > 0 && byte_pos < code_ranges[idx - 1].1
591}
592
593pub(crate) fn compute_html_code_ranges(html_tags: &[HtmlTag]) -> Vec<(usize, usize)> {
596 let mut ranges = Vec::new();
597 let mut open_code_end: Option<usize> = None;
598
599 for tag in html_tags {
600 if tag.tag_name == "code" {
601 if tag.is_self_closing {
602 continue;
603 } else if !tag.is_closing {
604 open_code_end = Some(tag.byte_end);
605 } else if tag.is_closing {
606 if let Some(start) = open_code_end {
607 ranges.push((start, tag.byte_offset));
608 }
609 open_code_end = None;
610 }
611 }
612 }
613 if let Some(start) = open_code_end {
615 ranges.push((start, usize::MAX));
616 }
617 ranges
618}
619
620pub(crate) fn should_skip_emphasis_span(
628 ctx: &LintContext,
629 html_tags: &[HtmlTag],
630 html_code_ranges: &[(usize, usize)],
631 span_start: usize,
632) -> bool {
633 let lines = ctx.raw_lines();
634 let (line_num, col) = ctx.offset_to_line_col(span_start);
635
636 if ctx
638 .line_info(line_num)
639 .is_some_and(|info| info.in_front_matter || info.in_mkdocstrings)
640 {
641 return true;
642 }
643
644 let in_mkdocs_markup = lines
646 .get(line_num.saturating_sub(1))
647 .is_some_and(|line| is_in_mkdocs_markup(line, col.saturating_sub(1), ctx.flavor));
648
649 let in_inline_code = lines
651 .get(line_num.saturating_sub(1))
652 .is_some_and(|line| is_in_inline_code_on_line(line, col.saturating_sub(1)));
653
654 ctx.is_in_code_block_or_span(span_start)
655 || in_inline_code
656 || ctx.is_in_link(span_start)
657 || is_byte_in_html_tag(html_tags, span_start)
658 || is_byte_in_html_code_content(html_code_ranges, span_start)
659 || in_mkdocs_markup
660 || is_in_math_context(ctx, span_start)
661 || is_in_jsx_expression(ctx, span_start)
662 || is_in_mdx_comment(ctx, span_start)
663}
664
665#[cfg(test)]
666mod tests {
667 use super::*;
668
669 #[test]
670 fn test_html_comment_detection() {
671 let content = "Text <!-- comment --> more text";
672 let ranges = compute_html_comment_ranges(content);
673 assert!(is_in_html_comment_ranges(&ranges, 10)); assert!(!is_in_html_comment_ranges(&ranges, 0)); assert!(!is_in_html_comment_ranges(&ranges, 25)); }
677
678 #[test]
679 fn test_compute_html_comment_ranges_ignores_code_span_delimiters() {
680 let content = "a `<!--` b\n\nc `-->` d";
683 let open = content.find("<!--").unwrap();
684 let close = content.find("-->").unwrap();
685 let code_spans = [
687 (content.find('`').unwrap(), open + "<!--".len() + 1),
688 (content.rfind("` d").unwrap() - "-->".len(), close + "-->".len() + 1),
689 ];
690
691 assert!(
693 !compute_html_comment_ranges(content).is_empty(),
694 "sanity: raw pattern matches across the code spans"
695 );
696 assert!(
698 compute_html_comment_ranges_filtered(content, &code_spans, &[]).is_empty(),
699 "a `<!--`/`-->` pair inside code spans must not be treated as a comment"
700 );
701 }
702
703 #[test]
704 fn test_compute_html_comment_ranges_ignores_code_block_delimiters() {
705 let content = "```\n<!-- literal\n```\n\nhttps://example.com\n\n-->\n";
708 let block_end = content.find("```\n\n").unwrap() + "```".len();
709 let code_blocks = [(0usize, block_end)];
710 assert!(
711 compute_html_comment_ranges_filtered(content, &[], &code_blocks).is_empty(),
712 "a `<!--` inside a code block must not open a comment that spans to a later `-->`"
713 );
714 let real = "```\n<!-- literal\n```\n\n<!-- real --> tail";
716 let real_block_end = real.find("```\n\n").unwrap() + "```".len();
717 let ranges = compute_html_comment_ranges_filtered(real, &[], &[(0usize, real_block_end)]);
718 assert_eq!(ranges.len(), 1);
719 assert_eq!(ranges[0].start, real.find("<!-- real").unwrap());
720 }
721
722 #[test]
723 fn test_compute_html_comment_ranges_keeps_real_comments() {
724 let content = "text `code` <!-- real comment --> more";
727 let code_spans = [(content.find('`').unwrap(), content.find("` ").unwrap() + 1)];
728 let ranges = compute_html_comment_ranges_filtered(content, &code_spans, &[]);
729 assert_eq!(ranges.len(), 1, "the real comment must still be detected");
730 let comment_start = content.find("<!--").unwrap();
731 assert_eq!(ranges[0].start, comment_start);
732 }
733
734 #[test]
735 fn test_compute_html_comment_ranges_real_comment_after_code_span_opener() {
736 let content = "a `<!--` then <!-- real --> end";
740 let code_spans = [(content.find('`').unwrap(), content.find("` then").unwrap() + 1)];
741 let ranges = compute_html_comment_ranges_filtered(content, &code_spans, &[]);
742 assert_eq!(
743 ranges.len(),
744 1,
745 "the real comment after a code-span opener must be detected"
746 );
747 let real_open = content.find("<!-- real").unwrap();
748 assert_eq!(
749 ranges[0].start, real_open,
750 "range must start at the real comment, not the code-span opener"
751 );
752 assert_eq!(ranges[0].end, content.find("--> end").unwrap() + "-->".len());
753 }
754
755 #[test]
756 fn test_compute_html_comment_ranges_closer_inside_code_span_is_not_a_closer() {
757 let content = "<!-- open `-->` still open --> done";
760 let first_close = content.find("`-->`").unwrap() + 1;
761 let code_spans = [(content.find('`').unwrap(), content.find("` still").unwrap() + 1)];
762 let ranges = compute_html_comment_ranges_filtered(content, &code_spans, &[]);
763 assert_eq!(ranges.len(), 1);
764 assert_eq!(ranges[0].start, 0);
765 let real_close_end = content.find("--> done").unwrap() + "-->".len();
766 assert_eq!(
767 ranges[0].end, real_close_end,
768 "must close at the real --> ({real_close_end}), not the one in the code span ({first_close})"
769 );
770 }
771
772 #[test]
773 fn test_scan_html_comments_starts_at_the_given_offset() {
774 let content = "---\nauthor: \"a <!-- b\"\n---\n\nText --> text\n";
777 let body_start = content.rfind("---\n").unwrap() + "---\n".len();
778 let scan = scan_html_comments(content, &[], &[], body_start);
779 assert!(scan.ranges.is_empty(), "got: {:?}", scan.ranges);
780 assert_eq!(scan.unterminated, None);
781
782 let unscoped = scan_html_comments(content, &[], &[], 0);
783 assert_eq!(unscoped.ranges.len(), 1, "without the offset the two pair up");
784 }
785
786 #[test]
787 fn test_scan_html_comments_reports_the_unterminated_opener() {
788 let content = "<!-- closed --> text <!-- open";
789 let scan = scan_html_comments(content, &[], &[], 0);
790 assert_eq!(scan.ranges.len(), 1);
791 assert_eq!(scan.unterminated, Some(content.rfind("<!--").unwrap()));
792
793 let closed = scan_html_comments("<!-- closed -->", &[], &[], 0);
794 assert_eq!(closed.unterminated, None);
795 }
796
797 #[test]
798 fn test_unterminated_html_comment_outside_hidden_ranges() {
799 let content = "%% note <!-- marker %%\n\n<!-- real\n";
802 let hidden = [(0, content.find("\n\n").unwrap())];
803 let reported = scan_html_comments(content, &[], &[], 0).unterminated;
804 assert_eq!(reported, Some(content.find("<!--").unwrap()), "the hidden one is first");
805 assert_eq!(
806 unterminated_html_comment_outside(reported, &hidden, content, &[], &[], 0),
807 Some(content.rfind("<!--").unwrap())
808 );
809
810 let only_hidden = "%% note <!-- marker %%\n";
812 let hidden_all = [(0, only_hidden.len())];
813 let reported = scan_html_comments(only_hidden, &[], &[], 0).unterminated;
814 assert!(reported.is_some(), "the scan sees the hidden opener");
815 assert_eq!(
816 unterminated_html_comment_outside(reported, &hidden_all, only_hidden, &[], &[], 0),
817 None
818 );
819
820 let visible = "<!-- open\n\n%% a note %%\n";
822 let reported = scan_html_comments(visible, &[], &[], 0).unterminated;
823 assert_eq!(
824 unterminated_html_comment_outside(reported, &[(11, 23)], visible, &[], &[], 0),
825 Some(0)
826 );
827 }
828
829 #[test]
830 fn test_unterminated_comment_range_follows_the_block() {
831 let blocks = [(0, 40)];
833 let range = unterminated_comment_range(0, &blocks).expect("opener starts the block");
834 assert_eq!((range.start, range.end), (0, 40));
835
836 let range = unterminated_comment_range(7, &blocks).expect("opener sits inside the block");
839 assert_eq!((range.start, range.end), (7, 40));
840
841 assert!(unterminated_comment_range(60, &blocks).is_none());
843 assert!(unterminated_comment_range(0, &[]).is_none());
844
845 assert!(unterminated_comment_range(40, &blocks).is_none());
848 }
849
850 #[test]
851 fn test_compute_html_comment_ranges_degenerate_comments_are_complete() {
852 for (content, expected_end) in [
856 ("<!--> text", 5),
857 ("<!---> text", 6),
858 ("<!----> text", 7),
859 ("<!-- x --> text", 10),
860 ] {
861 let ranges = compute_html_comment_ranges(content);
862 assert_eq!(ranges.len(), 1, "{content:?} holds exactly one comment");
863 assert_eq!(ranges[0].start, 0);
864 assert_eq!(
865 ranges[0].end, expected_end,
866 "{content:?} ends its comment at the first -->"
867 );
868 }
869 }
870
871 #[test]
872 fn test_compute_html_comment_ranges_degenerate_comment_before_a_real_one() {
873 let content = "<!--> visible <!-- hidden --> visible";
876 let ranges = compute_html_comment_ranges(content);
877 assert_eq!(ranges.len(), 2);
878 assert_eq!((ranges[0].start, ranges[0].end), (0, 5));
879 assert_eq!(
880 (ranges[1].start, ranges[1].end),
881 (content.find("<!-- hidden").unwrap(), content.rfind("-->").unwrap() + 3)
882 );
883 }
884
885 #[test]
886 fn test_is_line_entirely_in_html_comment() {
887 let content = "<!--\ncomment\n--> Content after comment";
889 let ranges = compute_html_comment_ranges(content);
890 assert!(is_line_entirely_in_html_comment(&ranges, 0, 4));
892 assert!(is_line_entirely_in_html_comment(&ranges, 5, 12));
894 assert!(!is_line_entirely_in_html_comment(&ranges, 13, 38));
896
897 let content2 = "<!-- comment --> Not a comment";
899 let ranges2 = compute_html_comment_ranges(content2);
900 assert!(!is_line_entirely_in_html_comment(&ranges2, 0, 30));
902
903 let content3 = "<!-- comment -->";
905 let ranges3 = compute_html_comment_ranges(content3);
906 assert!(is_line_entirely_in_html_comment(&ranges3, 0, 16));
908
909 let content4 = "Text before <!-- comment -->";
911 let ranges4 = compute_html_comment_ranges(content4);
912 assert!(!is_line_entirely_in_html_comment(&ranges4, 0, 28));
914 }
915
916 #[test]
917 fn test_is_line_entirely_in_html_comment_indented() {
918 let content = " <!-- comment -->";
922 let ranges = compute_html_comment_ranges(content);
923 let content_start = content.find("<!--").unwrap();
924 let content_end = content.trim_end().len();
925 assert!(is_line_entirely_in_html_comment(&ranges, content_start, content_end));
926 assert!(!is_line_entirely_in_html_comment(&ranges, 0, content.len()));
928 }
929
930 #[test]
931 fn test_is_line_entirely_in_html_comment_trailing_whitespace() {
932 let content = "<!-- comment --> ";
934 let ranges = compute_html_comment_ranges(content);
935 let content_end = content.trim_end().len();
936 assert!(is_line_entirely_in_html_comment(&ranges, 0, content_end));
937 assert!(!is_line_entirely_in_html_comment(&ranges, 0, content.len()));
939 }
940
941 #[test]
942 fn test_math_block_detection() {
943 let content = "Text\n$$\nmath content\n$$\nmore text";
944 assert!(is_in_math_block(content, 8)); assert!(is_in_math_block(content, 15)); assert!(!is_in_math_block(content, 0)); assert!(!is_in_math_block(content, 30)); }
949
950 #[test]
951 fn test_stray_double_dollar_in_prose_is_not_math() {
952 let content = "Note: $$ is used for display math and $$ closes it";
956 let between = content.find("is used").unwrap();
957 assert!(
958 !is_in_math_block(content, between),
959 "stray paired `$$` in prose must not be treated as a math block"
960 );
961 assert!(math_block_ranges(content).is_empty());
962 }
963
964 #[test]
965 fn test_blockquoted_double_dollar_opens_block() {
966 let content = "> $$\n> x = y\n> $$\n";
968 let inside = content.find("x = y").unwrap();
969 assert!(is_in_math_block(content, inside), "blockquoted math interior");
970 }
971
972 #[test]
973 fn test_self_contained_single_line_block_leaves_trailing_prose() {
974 let content = "$$ a $$ and __not math__\n";
976 let in_math = content.find('a').unwrap();
977 assert!(is_in_math_block(content, in_math), "single-line math interior");
978 let after = content.find("not math").unwrap();
979 assert!(!is_in_math_block(content, after), "trailing prose is lintable");
980 }
981
982 #[test]
983 fn test_math_block_closes_with_content_before_fence() {
984 let content = "$$\nx = y\n\\end{x}$$\nafter __text__ here";
988
989 let inside = content.find("x = y").unwrap();
990 assert!(is_in_math_block(content, inside), "interior must be math");
991
992 let after = content.find("after").unwrap();
993 assert!(
994 !is_in_math_block(content, after),
995 "content after a content-sharing closing fence must NOT be math"
996 );
997 }
998
999 #[test]
1000 fn test_inline_math_detection() {
1001 let content = "Text $x + y$ and $$a^2 + b^2$$ here";
1002 assert!(is_in_inline_math(content, 7), "inside the single-`$` inline span");
1003 assert!(!is_in_inline_math(content, 20), "mid-line $$...$$ is not inline math");
1007 assert!(
1008 !is_in_math_block(content, 20),
1009 "mid-line $$...$$ is not a line-start display block"
1010 );
1011 assert!(!is_in_inline_math(content, 0), "before any math");
1012 assert!(!is_in_inline_math(content, 35), "after the spans");
1013 }
1014
1015 #[test]
1016 fn test_table_line_detection() {
1017 assert!(is_table_line("| Header | Column |"));
1018 assert!(is_table_line("|--------|--------|"));
1019 assert!(is_table_line("| Cell 1 | Cell 2 |"));
1020 assert!(!is_table_line("Regular text"));
1021 assert!(!is_table_line("Just a pipe | here"));
1022 }
1023
1024 #[test]
1025 fn test_is_in_icon_shortcode() {
1026 let line = "Click :material-check: to confirm";
1027 assert!(!is_in_icon_shortcode(line, 0, MarkdownFlavor::MkDocs));
1029 assert!(is_in_icon_shortcode(line, 6, MarkdownFlavor::MkDocs));
1031 assert!(is_in_icon_shortcode(line, 15, MarkdownFlavor::MkDocs));
1032 assert!(is_in_icon_shortcode(line, 21, MarkdownFlavor::MkDocs));
1033 assert!(!is_in_icon_shortcode(line, 22, MarkdownFlavor::MkDocs));
1035 }
1036
1037 #[test]
1038 fn test_is_in_pymdown_markup() {
1039 let line = "Press ++ctrl+c++ to copy";
1041 assert!(!is_in_pymdown_markup(line, 0, MarkdownFlavor::MkDocs));
1042 assert!(is_in_pymdown_markup(line, 6, MarkdownFlavor::MkDocs));
1043 assert!(is_in_pymdown_markup(line, 10, MarkdownFlavor::MkDocs));
1044 assert!(!is_in_pymdown_markup(line, 17, MarkdownFlavor::MkDocs));
1045
1046 let line2 = "This is ==highlighted== text";
1048 assert!(!is_in_pymdown_markup(line2, 0, MarkdownFlavor::MkDocs));
1049 assert!(is_in_pymdown_markup(line2, 8, MarkdownFlavor::MkDocs));
1050 assert!(is_in_pymdown_markup(line2, 15, MarkdownFlavor::MkDocs));
1051 assert!(!is_in_pymdown_markup(line2, 23, MarkdownFlavor::MkDocs));
1052
1053 assert!(!is_in_pymdown_markup(line, 10, MarkdownFlavor::Standard));
1055 }
1056
1057 #[test]
1058 fn test_is_in_mkdocs_markup() {
1059 let line = ":material-check: and ++ctrl++";
1061 assert!(is_in_mkdocs_markup(line, 5, MarkdownFlavor::MkDocs)); assert!(is_in_mkdocs_markup(line, 23, MarkdownFlavor::MkDocs)); assert!(!is_in_mkdocs_markup(line, 17, MarkdownFlavor::MkDocs)); }
1065
1066 #[test]
1069 fn test_obsidian_highlight_basic() {
1070 let line = "This is ==highlighted== text";
1072 assert!(!is_in_pymdown_markup(line, 0, MarkdownFlavor::Obsidian)); assert!(is_in_pymdown_markup(line, 8, MarkdownFlavor::Obsidian)); assert!(is_in_pymdown_markup(line, 10, MarkdownFlavor::Obsidian)); assert!(is_in_pymdown_markup(line, 15, MarkdownFlavor::Obsidian)); assert!(is_in_pymdown_markup(line, 22, MarkdownFlavor::Obsidian)); assert!(!is_in_pymdown_markup(line, 23, MarkdownFlavor::Obsidian)); }
1079
1080 #[test]
1081 fn test_obsidian_highlight_multiple() {
1082 let line = "Both ==one== and ==two== here";
1084 assert!(is_in_pymdown_markup(line, 5, MarkdownFlavor::Obsidian)); assert!(is_in_pymdown_markup(line, 8, MarkdownFlavor::Obsidian)); assert!(!is_in_pymdown_markup(line, 12, MarkdownFlavor::Obsidian)); assert!(is_in_pymdown_markup(line, 17, MarkdownFlavor::Obsidian)); }
1089
1090 #[test]
1091 fn test_obsidian_highlight_not_standard_flavor() {
1092 let line = "This is ==highlighted== text";
1094 assert!(!is_in_pymdown_markup(line, 8, MarkdownFlavor::Standard));
1095 assert!(!is_in_pymdown_markup(line, 15, MarkdownFlavor::Standard));
1096 }
1097
1098 #[test]
1099 fn test_obsidian_highlight_with_spaces_inside() {
1100 let line = "This is ==text with spaces== here";
1102 assert!(is_in_pymdown_markup(line, 10, MarkdownFlavor::Obsidian)); assert!(is_in_pymdown_markup(line, 15, MarkdownFlavor::Obsidian)); assert!(is_in_pymdown_markup(line, 27, MarkdownFlavor::Obsidian)); }
1106
1107 #[test]
1108 fn test_obsidian_does_not_support_keys_notation() {
1109 let line = "Press ++ctrl+c++ to copy";
1111 assert!(!is_in_pymdown_markup(line, 6, MarkdownFlavor::Obsidian));
1112 assert!(!is_in_pymdown_markup(line, 10, MarkdownFlavor::Obsidian));
1113 }
1114
1115 #[test]
1116 fn test_obsidian_mkdocs_markup_function() {
1117 let line = "This is ==highlighted== text";
1119 assert!(is_in_mkdocs_markup(line, 10, MarkdownFlavor::Obsidian)); assert!(!is_in_mkdocs_markup(line, 0, MarkdownFlavor::Obsidian)); }
1122
1123 #[test]
1124 fn test_obsidian_highlight_edge_cases() {
1125 let line = "Test ==== here";
1127 assert!(!is_in_pymdown_markup(line, 5, MarkdownFlavor::Obsidian)); assert!(!is_in_pymdown_markup(line, 6, MarkdownFlavor::Obsidian));
1129
1130 let line2 = "Test ==a== here";
1132 assert!(is_in_pymdown_markup(line2, 5, MarkdownFlavor::Obsidian));
1133 assert!(is_in_pymdown_markup(line2, 7, MarkdownFlavor::Obsidian)); assert!(is_in_pymdown_markup(line2, 9, MarkdownFlavor::Obsidian)); let line3 = "a === b";
1138 assert!(!is_in_pymdown_markup(line3, 3, MarkdownFlavor::Obsidian));
1139 }
1140
1141 #[test]
1142 fn test_obsidian_highlight_unclosed() {
1143 let line = "This ==starts but never ends";
1145 assert!(!is_in_pymdown_markup(line, 5, MarkdownFlavor::Obsidian));
1146 assert!(!is_in_pymdown_markup(line, 10, MarkdownFlavor::Obsidian));
1147 }
1148
1149 #[test]
1150 fn test_inline_html_code_basic() {
1151 let line = "The formula is <code>a * b * c</code> in math.";
1152 assert!(is_in_inline_html_code(line, 21)); assert!(is_in_inline_html_code(line, 25)); assert!(!is_in_inline_html_code(line, 0)); assert!(!is_in_inline_html_code(line, 40)); }
1159
1160 #[test]
1161 fn test_inline_html_code_multiple_tags() {
1162 let line = "<kbd>Ctrl</kbd> + <samp>output</samp>";
1163 assert!(is_in_inline_html_code(line, 5)); assert!(is_in_inline_html_code(line, 24)); assert!(!is_in_inline_html_code(line, 16)); }
1167
1168 #[test]
1169 fn test_inline_html_code_with_attributes() {
1170 let line = r#"<code class="lang">x * y</code>"#;
1171 assert!(is_in_inline_html_code(line, 19)); assert!(is_in_inline_html_code(line, 23)); assert!(!is_in_inline_html_code(line, 0)); }
1175
1176 #[test]
1177 fn test_inline_html_code_case_insensitive() {
1178 let line = "<CODE>a * b</CODE>";
1179 assert!(is_in_inline_html_code(line, 6)); assert!(is_in_inline_html_code(line, 8)); }
1182
1183 #[test]
1184 fn test_inline_html_code_var_and_pre() {
1185 let line = "<var>x * y</var> and <pre>a * b</pre>";
1186 assert!(is_in_inline_html_code(line, 5)); assert!(is_in_inline_html_code(line, 26)); assert!(!is_in_inline_html_code(line, 17)); }
1190
1191 #[test]
1192 fn test_inline_html_code_unclosed() {
1193 let line = "<code>a * b without closing";
1195 assert!(!is_in_inline_html_code(line, 6));
1196 }
1197
1198 #[test]
1199 fn test_inline_html_code_no_substring_match() {
1200 let line = "<variable>a * b</variable>";
1202 assert!(!is_in_inline_html_code(line, 11));
1203
1204 let line2 = "<keyboard>x * y</keyboard>";
1206 assert!(!is_in_inline_html_code(line2, 11));
1207 }
1208}