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) = super::find_ascii_delimiter(&content[search_from..], "<!--") {
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) = super::find_ascii_delimiter(&content[close_from..], "-->") 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 if !content.contains("$$") {
313 return Vec::new();
314 }
315 let bytes = content.as_bytes();
316 let mut ranges = Vec::new();
317 let mut open: Option<usize> = None;
318 let mut line_start = 0usize;
319 let mut i = 0;
320 while i < bytes.len() {
321 match bytes[i] {
322 b'\n' => {
323 line_start = i + 1;
324 i += 1;
325 }
326 b'$' if i + 1 < bytes.len() && bytes[i + 1] == b'$' => {
327 match open {
328 None => {
329 let starts_line = bytes[line_start..i]
332 .iter()
333 .all(|&b| b == b' ' || b == b'\t' || b == b'>');
334 if starts_line {
335 open = Some(i);
336 }
337 }
338 Some(start) => {
339 ranges.push((start, i + 2));
340 open = None;
341 }
342 }
343 i += 2;
344 }
345 _ => i += 1,
346 }
347 }
348 ranges
349}
350
351pub fn is_in_math_block(content: &str, byte_pos: usize) -> bool {
358 math_block_ranges(content)
359 .iter()
360 .any(|&(start, end)| byte_pos >= start && byte_pos < end)
361}
362
363pub fn is_in_inline_math(content: &str, byte_pos: usize) -> bool {
372 for m in INLINE_MATH_REGEX.find_iter(content) {
373 if content[m.start()..m.end()].starts_with("$$") {
374 continue;
375 }
376 if m.start() <= byte_pos && byte_pos < m.end() {
377 return true;
378 }
379 }
380 false
381}
382
383pub fn math_byte_ranges(content: &str) -> Vec<(usize, usize)> {
391 if !content.contains('$') {
392 return Vec::new();
393 }
394 let mut ranges = math_block_ranges(content);
395 for m in INLINE_MATH_REGEX.find_iter(content) {
396 if content[m.start()..m.end()].starts_with("$$") {
397 continue;
398 }
399 ranges.push((m.start(), m.end()));
400 }
401 ranges
402}
403
404pub fn is_table_line(line: &str) -> bool {
406 let trimmed = line.trim();
407
408 if trimmed
410 .chars()
411 .all(|c| c == '|' || c == '-' || c == ':' || c.is_whitespace())
412 && trimmed.contains('|')
413 && trimmed.contains('-')
414 {
415 return true;
416 }
417
418 if (trimmed.starts_with('|') || trimmed.ends_with('|')) && trimmed.matches('|').count() >= 2 {
420 return true;
421 }
422
423 false
424}
425
426pub fn is_in_icon_shortcode(line: &str, position: usize, _flavor: MarkdownFlavor) -> bool {
429 mkdocs_icons::is_in_any_shortcode(line, position)
432}
433
434pub fn is_in_pymdown_markup(line: &str, position: usize, flavor: MarkdownFlavor) -> bool {
440 match flavor {
441 MarkdownFlavor::MkDocs => mkdocs_extensions::is_in_pymdown_markup(line, position),
442 MarkdownFlavor::Obsidian => {
443 mkdocs_extensions::is_in_mark(line, position)
445 }
446 _ => false,
447 }
448}
449
450pub fn is_in_inline_html_code(line: &str, position: usize) -> bool {
455 const TAGS: &[&str] = &["code", "pre", "samp", "kbd", "var"];
457
458 let bytes = line.as_bytes();
459
460 for tag in TAGS {
461 let open_bytes = format!("<{tag}").into_bytes();
462 let close_pattern = format!("</{tag}>").into_bytes();
463
464 let mut search_from = 0;
465 while search_from + open_bytes.len() <= bytes.len() {
466 let Some(open_abs) = find_case_insensitive(bytes, &open_bytes, search_from) else {
468 break;
469 };
470
471 let after_tag = open_abs + open_bytes.len();
472
473 if after_tag < bytes.len() {
475 let next = bytes[after_tag];
476 if next != b'>' && next != b' ' && next != b'\t' {
477 search_from = after_tag;
478 continue;
479 }
480 }
481
482 let Some(tag_close) = bytes[after_tag..].iter().position(|&b| b == b'>') else {
484 break;
485 };
486 let content_start = after_tag + tag_close + 1;
487
488 let Some(close_start) = find_case_insensitive(bytes, &close_pattern, content_start) else {
490 break;
491 };
492 let content_end = close_start;
493
494 if position >= content_start && position < content_end {
495 return true;
496 }
497
498 search_from = close_start + close_pattern.len();
499 }
500 }
501 false
502}
503
504fn find_case_insensitive(haystack: &[u8], needle: &[u8], from: usize) -> Option<usize> {
506 if needle.is_empty() || from + needle.len() > haystack.len() {
507 return None;
508 }
509 for i in from..=haystack.len() - needle.len() {
510 if haystack[i..i + needle.len()]
511 .iter()
512 .zip(needle.iter())
513 .all(|(h, n)| h.eq_ignore_ascii_case(n))
514 {
515 return Some(i);
516 }
517 }
518 None
519}
520
521pub fn is_in_mkdocs_markup(line: &str, position: usize, flavor: MarkdownFlavor) -> bool {
525 if is_in_icon_shortcode(line, position, flavor) {
526 return true;
527 }
528 if is_in_pymdown_markup(line, position, flavor) {
529 return true;
530 }
531 false
532}
533
534fn is_in_inline_code_on_line(line: &str, byte_pos: usize) -> bool {
540 let bytes = line.as_bytes();
541 let mut i = 0;
542
543 while i < bytes.len() {
544 if bytes[i] == b'`' {
545 let open_start = i;
546 let mut backtick_count = 0;
547 while i < bytes.len() && bytes[i] == b'`' {
548 backtick_count += 1;
549 i += 1;
550 }
551
552 let mut j = i;
554 while j < bytes.len() {
555 if bytes[j] == b'`' {
556 let mut close_count = 0;
557 while j < bytes.len() && bytes[j] == b'`' {
558 close_count += 1;
559 j += 1;
560 }
561 if close_count == backtick_count {
562 if byte_pos >= open_start && byte_pos < j {
564 return true;
565 }
566 i = j;
567 break;
568 }
569 } else {
570 j += 1;
571 }
572 }
573
574 if j >= bytes.len() {
575 break;
577 }
578 } else {
579 i += 1;
580 }
581 }
582
583 false
584}
585
586fn is_byte_in_html_tag(html_tags: &[HtmlTag], byte_pos: usize) -> bool {
588 let idx = html_tags.partition_point(|tag| tag.byte_offset <= byte_pos);
589 idx > 0 && byte_pos < html_tags[idx - 1].byte_end
590}
591
592fn is_byte_in_html_code_content(code_ranges: &[(usize, usize)], byte_pos: usize) -> bool {
595 let idx = code_ranges.partition_point(|&(start, _)| start <= byte_pos);
596 idx > 0 && byte_pos < code_ranges[idx - 1].1
597}
598
599pub(crate) fn compute_html_code_ranges(html_tags: &[HtmlTag]) -> Vec<(usize, usize)> {
602 let mut ranges = Vec::new();
603 let mut open_code_end: Option<usize> = None;
604
605 for tag in html_tags {
606 if tag.tag_name == "code" {
607 if tag.is_self_closing {
608 continue;
609 } else if !tag.is_closing {
610 open_code_end = Some(tag.byte_end);
611 } else if tag.is_closing {
612 if let Some(start) = open_code_end {
613 ranges.push((start, tag.byte_offset));
614 }
615 open_code_end = None;
616 }
617 }
618 }
619 if let Some(start) = open_code_end {
621 ranges.push((start, usize::MAX));
622 }
623 ranges
624}
625
626pub(crate) fn mask_mdx_inline_code<'a>(ctx: &LintContext, source: &'a str, start: usize) -> std::borrow::Cow<'a, str> {
630 let end = start + source.len();
631 let mut ranges = ctx.mdx_inline_code_ranges(start, end).peekable();
632 if ranges.peek().is_none() {
633 return std::borrow::Cow::Borrowed(source);
634 }
635 let mut bytes = source.as_bytes().to_vec();
636 for (range_start, range_end) in ranges {
637 bytes[range_start.max(start) - start..range_end.min(end) - start].fill(b'X');
638 }
639 std::borrow::Cow::Owned(String::from_utf8(bytes).expect("ASCII masking preserves UTF-8"))
641}
642
643pub(crate) fn should_skip_emphasis_span(
652 ctx: &LintContext,
653 html_tags: &[HtmlTag],
654 html_code_ranges: &[(usize, usize)],
655 span_start: usize,
656 span_end: usize,
657) -> bool {
658 let lines = ctx.raw_lines();
659 let (line_num, col) = ctx.offset_to_line_col(span_start);
660
661 if ctx
663 .line_info(line_num)
664 .is_some_and(|info| info.in_front_matter || info.in_mkdocstrings || info.in_esm_block || info.is_myst_comment)
665 {
666 return true;
667 }
668
669 let in_mkdocs_markup = lines
671 .get(line_num.saturating_sub(1))
672 .is_some_and(|line| is_in_mkdocs_markup(line, col.saturating_sub(1), ctx.flavor));
673
674 let in_inline_code = lines
676 .get(line_num.saturating_sub(1))
677 .is_some_and(|line| is_in_inline_code_on_line(line, col.saturating_sub(1)));
678
679 ctx.is_in_jinja_string(span_start)
680 || ctx.is_in_jinja_string(span_end.saturating_sub(1))
681 || ctx.is_in_shortcode(span_start)
682 || ctx.is_in_shortcode(span_end.saturating_sub(1))
683 || ctx.is_in_code_block_or_span(span_start)
684 || in_inline_code
685 || ctx.is_in_link(span_start)
686 || is_byte_in_html_tag(html_tags, span_start)
687 || ctx.is_in_jsx_component_tag(span_start)
688 || ctx.is_in_jsx_component_tag(span_end.saturating_sub(1))
689 || is_byte_in_html_code_content(html_code_ranges, span_start)
690 || in_mkdocs_markup
691 || is_in_math_context(ctx, span_start)
692 || is_in_jsx_expression(ctx, span_start)
693 || is_in_mdx_comment(ctx, span_start)
694}
695
696#[cfg(test)]
697mod tests {
698 use super::*;
699
700 #[test]
701 fn test_html_comment_detection() {
702 let content = "Text <!-- comment --> more text";
703 let ranges = compute_html_comment_ranges(content);
704 assert!(is_in_html_comment_ranges(&ranges, 10)); assert!(!is_in_html_comment_ranges(&ranges, 0)); assert!(!is_in_html_comment_ranges(&ranges, 25)); }
708
709 #[test]
710 fn test_compute_html_comment_ranges_ignores_code_span_delimiters() {
711 let content = "a `<!--` b\n\nc `-->` d";
714 let open = content.find("<!--").unwrap();
715 let close = content.find("-->").unwrap();
716 let code_spans = [
718 (content.find('`').unwrap(), open + "<!--".len() + 1),
719 (content.rfind("` d").unwrap() - "-->".len(), close + "-->".len() + 1),
720 ];
721
722 assert!(
724 !compute_html_comment_ranges(content).is_empty(),
725 "sanity: raw pattern matches across the code spans"
726 );
727 assert!(
729 compute_html_comment_ranges_filtered(content, &code_spans, &[]).is_empty(),
730 "a `<!--`/`-->` pair inside code spans must not be treated as a comment"
731 );
732 }
733
734 #[test]
735 fn test_compute_html_comment_ranges_ignores_code_block_delimiters() {
736 let content = "```\n<!-- literal\n```\n\nhttps://example.com\n\n-->\n";
739 let block_end = content.find("```\n\n").unwrap() + "```".len();
740 let code_blocks = [(0usize, block_end)];
741 assert!(
742 compute_html_comment_ranges_filtered(content, &[], &code_blocks).is_empty(),
743 "a `<!--` inside a code block must not open a comment that spans to a later `-->`"
744 );
745 let real = "```\n<!-- literal\n```\n\n<!-- real --> tail";
747 let real_block_end = real.find("```\n\n").unwrap() + "```".len();
748 let ranges = compute_html_comment_ranges_filtered(real, &[], &[(0usize, real_block_end)]);
749 assert_eq!(ranges.len(), 1);
750 assert_eq!(ranges[0].start, real.find("<!-- real").unwrap());
751 }
752
753 #[test]
754 fn test_compute_html_comment_ranges_keeps_real_comments() {
755 let content = "text `code` <!-- real comment --> more";
758 let code_spans = [(content.find('`').unwrap(), content.find("` ").unwrap() + 1)];
759 let ranges = compute_html_comment_ranges_filtered(content, &code_spans, &[]);
760 assert_eq!(ranges.len(), 1, "the real comment must still be detected");
761 let comment_start = content.find("<!--").unwrap();
762 assert_eq!(ranges[0].start, comment_start);
763 }
764
765 #[test]
766 fn test_compute_html_comment_ranges_real_comment_after_code_span_opener() {
767 let content = "a `<!--` then <!-- real --> end";
771 let code_spans = [(content.find('`').unwrap(), content.find("` then").unwrap() + 1)];
772 let ranges = compute_html_comment_ranges_filtered(content, &code_spans, &[]);
773 assert_eq!(
774 ranges.len(),
775 1,
776 "the real comment after a code-span opener must be detected"
777 );
778 let real_open = content.find("<!-- real").unwrap();
779 assert_eq!(
780 ranges[0].start, real_open,
781 "range must start at the real comment, not the code-span opener"
782 );
783 assert_eq!(ranges[0].end, content.find("--> end").unwrap() + "-->".len());
784 }
785
786 #[test]
787 fn test_compute_html_comment_ranges_closer_inside_code_span_is_not_a_closer() {
788 let content = "<!-- open `-->` still open --> done";
791 let first_close = content.find("`-->`").unwrap() + 1;
792 let code_spans = [(content.find('`').unwrap(), content.find("` still").unwrap() + 1)];
793 let ranges = compute_html_comment_ranges_filtered(content, &code_spans, &[]);
794 assert_eq!(ranges.len(), 1);
795 assert_eq!(ranges[0].start, 0);
796 let real_close_end = content.find("--> done").unwrap() + "-->".len();
797 assert_eq!(
798 ranges[0].end, real_close_end,
799 "must close at the real --> ({real_close_end}), not the one in the code span ({first_close})"
800 );
801 }
802
803 #[test]
804 fn test_scan_html_comments_starts_at_the_given_offset() {
805 let content = "---\nauthor: \"a <!-- b\"\n---\n\nText --> text\n";
808 let body_start = content.rfind("---\n").unwrap() + "---\n".len();
809 let scan = scan_html_comments(content, &[], &[], body_start);
810 assert!(scan.ranges.is_empty(), "got: {:?}", scan.ranges);
811 assert_eq!(scan.unterminated, None);
812
813 let unscoped = scan_html_comments(content, &[], &[], 0);
814 assert_eq!(unscoped.ranges.len(), 1, "without the offset the two pair up");
815 }
816
817 #[test]
818 fn test_scan_html_comments_reports_the_unterminated_opener() {
819 let content = "<!-- closed --> text <!-- open";
820 let scan = scan_html_comments(content, &[], &[], 0);
821 assert_eq!(scan.ranges.len(), 1);
822 assert_eq!(scan.unterminated, Some(content.rfind("<!--").unwrap()));
823
824 let closed = scan_html_comments("<!-- closed -->", &[], &[], 0);
825 assert_eq!(closed.unterminated, None);
826 }
827
828 #[test]
829 fn test_unterminated_html_comment_outside_hidden_ranges() {
830 let content = "%% note <!-- marker %%\n\n<!-- real\n";
833 let hidden = [(0, content.find("\n\n").unwrap())];
834 let reported = scan_html_comments(content, &[], &[], 0).unterminated;
835 assert_eq!(reported, Some(content.find("<!--").unwrap()), "the hidden one is first");
836 assert_eq!(
837 unterminated_html_comment_outside(reported, &hidden, content, &[], &[], 0),
838 Some(content.rfind("<!--").unwrap())
839 );
840
841 let only_hidden = "%% note <!-- marker %%\n";
843 let hidden_all = [(0, only_hidden.len())];
844 let reported = scan_html_comments(only_hidden, &[], &[], 0).unterminated;
845 assert!(reported.is_some(), "the scan sees the hidden opener");
846 assert_eq!(
847 unterminated_html_comment_outside(reported, &hidden_all, only_hidden, &[], &[], 0),
848 None
849 );
850
851 let visible = "<!-- open\n\n%% a note %%\n";
853 let reported = scan_html_comments(visible, &[], &[], 0).unterminated;
854 assert_eq!(
855 unterminated_html_comment_outside(reported, &[(11, 23)], visible, &[], &[], 0),
856 Some(0)
857 );
858 }
859
860 #[test]
861 fn test_unterminated_comment_range_follows_the_block() {
862 let blocks = [(0, 40)];
864 let range = unterminated_comment_range(0, &blocks).expect("opener starts the block");
865 assert_eq!((range.start, range.end), (0, 40));
866
867 let range = unterminated_comment_range(7, &blocks).expect("opener sits inside the block");
870 assert_eq!((range.start, range.end), (7, 40));
871
872 assert!(unterminated_comment_range(60, &blocks).is_none());
874 assert!(unterminated_comment_range(0, &[]).is_none());
875
876 assert!(unterminated_comment_range(40, &blocks).is_none());
879 }
880
881 #[test]
882 fn test_compute_html_comment_ranges_degenerate_comments_are_complete() {
883 for (content, expected_end) in [
887 ("<!--> text", 5),
888 ("<!---> text", 6),
889 ("<!----> text", 7),
890 ("<!-- x --> text", 10),
891 ] {
892 let ranges = compute_html_comment_ranges(content);
893 assert_eq!(ranges.len(), 1, "{content:?} holds exactly one comment");
894 assert_eq!(ranges[0].start, 0);
895 assert_eq!(
896 ranges[0].end, expected_end,
897 "{content:?} ends its comment at the first -->"
898 );
899 }
900 }
901
902 #[test]
903 fn test_compute_html_comment_ranges_degenerate_comment_before_a_real_one() {
904 let content = "<!--> visible <!-- hidden --> visible";
907 let ranges = compute_html_comment_ranges(content);
908 assert_eq!(ranges.len(), 2);
909 assert_eq!((ranges[0].start, ranges[0].end), (0, 5));
910 assert_eq!(
911 (ranges[1].start, ranges[1].end),
912 (content.find("<!-- hidden").unwrap(), content.rfind("-->").unwrap() + 3)
913 );
914 }
915
916 #[test]
917 fn test_is_line_entirely_in_html_comment() {
918 let content = "<!--\ncomment\n--> Content after comment";
920 let ranges = compute_html_comment_ranges(content);
921 assert!(is_line_entirely_in_html_comment(&ranges, 0, 4));
923 assert!(is_line_entirely_in_html_comment(&ranges, 5, 12));
925 assert!(!is_line_entirely_in_html_comment(&ranges, 13, 38));
927
928 let content2 = "<!-- comment --> Not a comment";
930 let ranges2 = compute_html_comment_ranges(content2);
931 assert!(!is_line_entirely_in_html_comment(&ranges2, 0, 30));
933
934 let content3 = "<!-- comment -->";
936 let ranges3 = compute_html_comment_ranges(content3);
937 assert!(is_line_entirely_in_html_comment(&ranges3, 0, 16));
939
940 let content4 = "Text before <!-- comment -->";
942 let ranges4 = compute_html_comment_ranges(content4);
943 assert!(!is_line_entirely_in_html_comment(&ranges4, 0, 28));
945 }
946
947 #[test]
948 fn test_is_line_entirely_in_html_comment_indented() {
949 let content = " <!-- comment -->";
953 let ranges = compute_html_comment_ranges(content);
954 let content_start = content.find("<!--").unwrap();
955 let content_end = content.trim_end().len();
956 assert!(is_line_entirely_in_html_comment(&ranges, content_start, content_end));
957 assert!(!is_line_entirely_in_html_comment(&ranges, 0, content.len()));
959 }
960
961 #[test]
962 fn test_is_line_entirely_in_html_comment_trailing_whitespace() {
963 let content = "<!-- comment --> ";
965 let ranges = compute_html_comment_ranges(content);
966 let content_end = content.trim_end().len();
967 assert!(is_line_entirely_in_html_comment(&ranges, 0, content_end));
968 assert!(!is_line_entirely_in_html_comment(&ranges, 0, content.len()));
970 }
971
972 #[test]
973 fn test_math_block_detection() {
974 let content = "Text\n$$\nmath content\n$$\nmore text";
975 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)); }
980
981 #[test]
982 fn test_stray_double_dollar_in_prose_is_not_math() {
983 let content = "Note: $$ is used for display math and $$ closes it";
987 let between = content.find("is used").unwrap();
988 assert!(
989 !is_in_math_block(content, between),
990 "stray paired `$$` in prose must not be treated as a math block"
991 );
992 assert!(math_block_ranges(content).is_empty());
993 }
994
995 #[test]
996 fn test_blockquoted_double_dollar_opens_block() {
997 let content = "> $$\n> x = y\n> $$\n";
999 let inside = content.find("x = y").unwrap();
1000 assert!(is_in_math_block(content, inside), "blockquoted math interior");
1001 }
1002
1003 #[test]
1004 fn test_self_contained_single_line_block_leaves_trailing_prose() {
1005 let content = "$$ a $$ and __not math__\n";
1007 let in_math = content.find('a').unwrap();
1008 assert!(is_in_math_block(content, in_math), "single-line math interior");
1009 let after = content.find("not math").unwrap();
1010 assert!(!is_in_math_block(content, after), "trailing prose is lintable");
1011 }
1012
1013 #[test]
1014 fn test_math_block_closes_with_content_before_fence() {
1015 let content = "$$\nx = y\n\\end{x}$$\nafter __text__ here";
1019
1020 let inside = content.find("x = y").unwrap();
1021 assert!(is_in_math_block(content, inside), "interior must be math");
1022
1023 let after = content.find("after").unwrap();
1024 assert!(
1025 !is_in_math_block(content, after),
1026 "content after a content-sharing closing fence must NOT be math"
1027 );
1028 }
1029
1030 #[test]
1031 fn test_inline_math_detection() {
1032 let content = "Text $x + y$ and $$a^2 + b^2$$ here";
1033 assert!(is_in_inline_math(content, 7), "inside the single-`$` inline span");
1034 assert!(!is_in_inline_math(content, 20), "mid-line $$...$$ is not inline math");
1038 assert!(
1039 !is_in_math_block(content, 20),
1040 "mid-line $$...$$ is not a line-start display block"
1041 );
1042 assert!(!is_in_inline_math(content, 0), "before any math");
1043 assert!(!is_in_inline_math(content, 35), "after the spans");
1044 }
1045
1046 #[test]
1047 fn test_table_line_detection() {
1048 assert!(is_table_line("| Header | Column |"));
1049 assert!(is_table_line("|--------|--------|"));
1050 assert!(is_table_line("| Cell 1 | Cell 2 |"));
1051 assert!(!is_table_line("Regular text"));
1052 assert!(!is_table_line("Just a pipe | here"));
1053 }
1054
1055 #[test]
1056 fn test_is_in_icon_shortcode() {
1057 let line = "Click :material-check: to confirm";
1058 assert!(!is_in_icon_shortcode(line, 0, MarkdownFlavor::MkDocs));
1060 assert!(is_in_icon_shortcode(line, 6, MarkdownFlavor::MkDocs));
1062 assert!(is_in_icon_shortcode(line, 15, MarkdownFlavor::MkDocs));
1063 assert!(is_in_icon_shortcode(line, 21, MarkdownFlavor::MkDocs));
1064 assert!(!is_in_icon_shortcode(line, 22, MarkdownFlavor::MkDocs));
1066 }
1067
1068 #[test]
1069 fn test_is_in_pymdown_markup() {
1070 let line = "Press ++ctrl+c++ to copy";
1072 assert!(!is_in_pymdown_markup(line, 0, MarkdownFlavor::MkDocs));
1073 assert!(is_in_pymdown_markup(line, 6, MarkdownFlavor::MkDocs));
1074 assert!(is_in_pymdown_markup(line, 10, MarkdownFlavor::MkDocs));
1075 assert!(!is_in_pymdown_markup(line, 17, MarkdownFlavor::MkDocs));
1076
1077 let line2 = "This is ==highlighted== text";
1079 assert!(!is_in_pymdown_markup(line2, 0, MarkdownFlavor::MkDocs));
1080 assert!(is_in_pymdown_markup(line2, 8, MarkdownFlavor::MkDocs));
1081 assert!(is_in_pymdown_markup(line2, 15, MarkdownFlavor::MkDocs));
1082 assert!(!is_in_pymdown_markup(line2, 23, MarkdownFlavor::MkDocs));
1083
1084 assert!(!is_in_pymdown_markup(line, 10, MarkdownFlavor::Standard));
1086 }
1087
1088 #[test]
1089 fn test_is_in_mkdocs_markup() {
1090 let line = ":material-check: and ++ctrl++";
1092 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)); }
1096
1097 #[test]
1100 fn test_obsidian_highlight_basic() {
1101 let line = "This is ==highlighted== text";
1103 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)); }
1110
1111 #[test]
1112 fn test_obsidian_highlight_multiple() {
1113 let line = "Both ==one== and ==two== here";
1115 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)); }
1120
1121 #[test]
1122 fn test_obsidian_highlight_not_standard_flavor() {
1123 let line = "This is ==highlighted== text";
1125 assert!(!is_in_pymdown_markup(line, 8, MarkdownFlavor::Standard));
1126 assert!(!is_in_pymdown_markup(line, 15, MarkdownFlavor::Standard));
1127 }
1128
1129 #[test]
1130 fn test_obsidian_highlight_with_spaces_inside() {
1131 let line = "This is ==text with spaces== here";
1133 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)); }
1137
1138 #[test]
1139 fn test_obsidian_does_not_support_keys_notation() {
1140 let line = "Press ++ctrl+c++ to copy";
1142 assert!(!is_in_pymdown_markup(line, 6, MarkdownFlavor::Obsidian));
1143 assert!(!is_in_pymdown_markup(line, 10, MarkdownFlavor::Obsidian));
1144 }
1145
1146 #[test]
1147 fn test_obsidian_mkdocs_markup_function() {
1148 let line = "This is ==highlighted== text";
1150 assert!(is_in_mkdocs_markup(line, 10, MarkdownFlavor::Obsidian)); assert!(!is_in_mkdocs_markup(line, 0, MarkdownFlavor::Obsidian)); }
1153
1154 #[test]
1155 fn test_obsidian_highlight_edge_cases() {
1156 let line = "Test ==== here";
1158 assert!(!is_in_pymdown_markup(line, 5, MarkdownFlavor::Obsidian)); assert!(!is_in_pymdown_markup(line, 6, MarkdownFlavor::Obsidian));
1160
1161 let line2 = "Test ==a== here";
1163 assert!(is_in_pymdown_markup(line2, 5, MarkdownFlavor::Obsidian));
1164 assert!(is_in_pymdown_markup(line2, 7, MarkdownFlavor::Obsidian)); assert!(is_in_pymdown_markup(line2, 9, MarkdownFlavor::Obsidian)); let line3 = "a === b";
1169 assert!(!is_in_pymdown_markup(line3, 3, MarkdownFlavor::Obsidian));
1170 }
1171
1172 #[test]
1173 fn test_obsidian_highlight_unclosed() {
1174 let line = "This ==starts but never ends";
1176 assert!(!is_in_pymdown_markup(line, 5, MarkdownFlavor::Obsidian));
1177 assert!(!is_in_pymdown_markup(line, 10, MarkdownFlavor::Obsidian));
1178 }
1179
1180 #[test]
1181 fn test_inline_html_code_basic() {
1182 let line = "The formula is <code>a * b * c</code> in math.";
1183 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)); }
1190
1191 #[test]
1192 fn test_inline_html_code_multiple_tags() {
1193 let line = "<kbd>Ctrl</kbd> + <samp>output</samp>";
1194 assert!(is_in_inline_html_code(line, 5)); assert!(is_in_inline_html_code(line, 24)); assert!(!is_in_inline_html_code(line, 16)); }
1198
1199 #[test]
1200 fn test_inline_html_code_with_attributes() {
1201 let line = r#"<code class="lang">x * y</code>"#;
1202 assert!(is_in_inline_html_code(line, 19)); assert!(is_in_inline_html_code(line, 23)); assert!(!is_in_inline_html_code(line, 0)); }
1206
1207 #[test]
1208 fn test_inline_html_code_case_insensitive() {
1209 let line = "<CODE>a * b</CODE>";
1210 assert!(is_in_inline_html_code(line, 6)); assert!(is_in_inline_html_code(line, 8)); }
1213
1214 #[test]
1215 fn test_inline_html_code_var_and_pre() {
1216 let line = "<var>x * y</var> and <pre>a * b</pre>";
1217 assert!(is_in_inline_html_code(line, 5)); assert!(is_in_inline_html_code(line, 26)); assert!(!is_in_inline_html_code(line, 17)); }
1221
1222 #[test]
1223 fn test_inline_html_code_unclosed() {
1224 let line = "<code>a * b without closing";
1226 assert!(!is_in_inline_html_code(line, 6));
1227 }
1228
1229 #[test]
1230 fn test_inline_html_code_no_substring_match() {
1231 let line = "<variable>a * b</variable>";
1233 assert!(!is_in_inline_html_code(line, 11));
1234
1235 let line2 = "<keyboard>x * y</keyboard>";
1237 assert!(!is_in_inline_html_code(line2, 11));
1238 }
1239}