1use crate::config::MarkdownFlavor;
7use crate::lint_context::{HtmlTag, LintContext, SourcePositions};
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::cell::OnceCell;
17use std::collections::HashMap;
18use std::sync::{Arc, LazyLock};
19
20static INLINE_MATH_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\$\$[^$]*\$\$|\$[^$\n]*\$").unwrap());
29
30#[derive(Debug, Clone, Copy)]
32pub struct ByteRange {
33 pub start: usize,
34 pub end: usize,
35}
36
37#[derive(Debug, Default)]
39pub struct HtmlCommentScan {
40 pub ranges: Vec<ByteRange>,
42 pub unterminated: Option<usize>,
45}
46
47pub fn compute_html_comment_ranges(content: &str) -> Vec<ByteRange> {
50 compute_html_comment_ranges_filtered(content, &[], &[])
51}
52
53pub fn compute_html_comment_ranges_filtered(
74 content: &str,
75 code_span_ranges: &[(usize, usize)],
76 code_block_ranges: &[(usize, usize)],
77) -> Vec<ByteRange> {
78 scan_html_comments(content, code_span_ranges, code_block_ranges, 0).ranges
79}
80
81pub fn scan_html_comments(
92 content: &str,
93 code_span_ranges: &[(usize, usize)],
94 code_block_ranges: &[(usize, usize)],
95 scan_from: usize,
96) -> HtmlCommentScan {
97 let code_ranges = OnceCell::new();
101 let in_code = |pos: usize| {
102 code_ranges
103 .get_or_init(|| SkipRegions::new(code_span_ranges.iter().chain(code_block_ranges).copied().collect()))
104 .contains(pos)
105 };
106
107 let mut ranges = Vec::new();
108 let mut search_from = scan_from.min(content.len());
109 while let Some(rel) = super::find_ascii_delimiter(&content[search_from..], "<!--") {
110 let open = search_from + rel;
111 if in_code(open) {
112 search_from = open + "<!--".len();
114 continue;
115 }
116 let mut close_from = open + 2;
121 let end = loop {
122 let Some(crel) = super::find_ascii_delimiter(&content[close_from..], "-->") else {
123 break None;
124 };
125 let close = close_from + crel;
126 if in_code(close) {
127 close_from = close + "-->".len();
128 continue;
129 }
130 break Some(close + "-->".len());
131 };
132 match end {
133 Some(end) => {
134 ranges.push(ByteRange { start: open, end });
135 search_from = end;
136 }
137 None => {
140 return HtmlCommentScan {
141 ranges,
142 unterminated: Some(open),
143 };
144 }
145 }
146 }
147 HtmlCommentScan {
148 ranges,
149 unterminated: None,
150 }
151}
152
153#[cfg(test)]
154mod comment_tests;
155
156pub fn unterminated_html_comment_outside(
170 reported: Option<usize>,
171 hidden_ranges: &[(usize, usize)],
172 content: &str,
173 code_span_ranges: &[(usize, usize)],
174 code_block_ranges: &[(usize, usize)],
175 scan_from: usize,
176) -> Option<usize> {
177 let offset = reported?;
178 if !hidden_ranges
179 .iter()
180 .any(|&(start, end)| offset >= start && offset < end)
181 {
182 return reported;
183 }
184 let mut literal_ranges = code_block_ranges.to_vec();
185 literal_ranges.extend_from_slice(hidden_ranges);
186 scan_html_comments(content, code_span_ranges, &literal_ranges, scan_from).unterminated
187}
188
189pub fn unterminated_comment_range(opener: usize, html_blocks: &[(usize, usize)]) -> Option<ByteRange> {
207 html_blocks
208 .iter()
209 .find(|&&(start, end)| opener >= start && opener < end)
210 .map(|&(_, end)| ByteRange { start: opener, end })
211}
212
213pub fn is_in_html_comment_ranges(ranges: &[ByteRange], byte_pos: usize) -> bool {
216 ranges
218 .binary_search_by(|range| {
219 if byte_pos < range.start {
220 std::cmp::Ordering::Greater
221 } else if byte_pos >= range.end {
222 std::cmp::Ordering::Less
223 } else {
224 std::cmp::Ordering::Equal
225 }
226 })
227 .is_ok()
228}
229
230pub fn is_line_entirely_in_html_comment(ranges: &[ByteRange], content_start: usize, content_end: usize) -> bool {
241 for range in ranges {
242 if content_start >= range.start && content_start < range.end {
244 return content_end <= range.end;
245 }
246 }
247 false
248}
249
250pub(crate) fn is_line_entirely_in_sorted_html_comment(
254 ranges: &[ByteRange],
255 content_start: usize,
256 content_end: usize,
257) -> bool {
258 let index = ranges.partition_point(|range| range.start <= content_start);
259 index > 0 && content_start < ranges[index - 1].end && content_end <= ranges[index - 1].end
260}
261
262#[inline]
264pub fn is_in_jsx_expression(ctx: &LintContext, byte_pos: usize) -> bool {
265 ctx.flavor == MarkdownFlavor::MDX && ctx.is_in_jsx_expression(byte_pos)
266}
267
268#[inline]
270pub fn is_in_mdx_comment(ctx: &LintContext, byte_pos: usize) -> bool {
271 ctx.flavor == MarkdownFlavor::MDX && ctx.is_in_mdx_comment(byte_pos)
272}
273
274pub fn is_mkdocs_snippet_line(line: &str, flavor: MarkdownFlavor) -> bool {
276 flavor == MarkdownFlavor::MkDocs && mkdocs_snippets::is_snippet_marker(line)
277}
278
279pub fn is_mkdocs_admonition_line(line: &str, flavor: MarkdownFlavor) -> bool {
281 flavor == MarkdownFlavor::MkDocs && mkdocs_admonitions::is_admonition_marker(line)
282}
283
284pub fn is_mkdocs_footnote_line(line: &str, flavor: MarkdownFlavor) -> bool {
286 flavor == MarkdownFlavor::MkDocs && mkdocs_footnotes::is_footnote_definition(line)
287}
288
289pub fn is_mkdocs_tab_line(line: &str, flavor: MarkdownFlavor) -> bool {
291 flavor == MarkdownFlavor::MkDocs && mkdocs_tabs::is_tab_marker(line)
292}
293
294pub fn is_mkdocs_critic_line(line: &str, flavor: MarkdownFlavor) -> bool {
296 flavor == MarkdownFlavor::MkDocs && mkdocs_critic::contains_critic_markup(line)
297}
298
299pub fn is_in_html_tag(ctx: &LintContext, byte_pos: usize) -> bool {
301 for html_tag in ctx.html_tags().iter() {
302 if html_tag.byte_offset <= byte_pos && byte_pos < html_tag.byte_end {
303 return true;
304 }
305 }
306 false
307}
308
309pub fn is_in_math_context(ctx: &LintContext, byte_pos: usize) -> bool {
316 ctx.math_byte_ranges()
319 .iter()
320 .any(|&(start, end)| byte_pos >= start && byte_pos < end)
321}
322
323pub(crate) fn math_block_ranges(content: &str) -> Vec<(usize, usize)> {
334 if !content.contains("$$") {
335 return Vec::new();
336 }
337 let bytes = content.as_bytes();
338 let mut ranges = Vec::new();
339 let mut open: Option<usize> = None;
340 let mut line_start = 0usize;
341 let mut i = 0;
342 while i < bytes.len() {
343 match bytes[i] {
344 b'\n' => {
345 line_start = i + 1;
346 i += 1;
347 }
348 b'$' if i + 1 < bytes.len() && bytes[i + 1] == b'$' => {
349 match open {
350 None => {
351 let starts_line = bytes[line_start..i]
354 .iter()
355 .all(|&b| b == b' ' || b == b'\t' || b == b'>');
356 if starts_line {
357 open = Some(i);
358 }
359 }
360 Some(start) => {
361 ranges.push((start, i + 2));
362 open = None;
363 }
364 }
365 i += 2;
366 }
367 _ => i += 1,
368 }
369 }
370 ranges
371}
372
373pub fn is_in_math_block(content: &str, byte_pos: usize) -> bool {
380 math_block_ranges(content)
381 .iter()
382 .any(|&(start, end)| byte_pos >= start && byte_pos < end)
383}
384
385pub fn is_in_inline_math(content: &str, byte_pos: usize) -> bool {
394 for m in INLINE_MATH_REGEX.find_iter(content) {
395 if content[m.start()..m.end()].starts_with("$$") {
396 continue;
397 }
398 if m.start() <= byte_pos && byte_pos < m.end() {
399 return true;
400 }
401 }
402 false
403}
404
405pub fn math_byte_ranges(content: &str) -> Vec<(usize, usize)> {
413 if !content.contains('$') {
414 return Vec::new();
415 }
416 let mut ranges = math_block_ranges(content);
417 for m in INLINE_MATH_REGEX.find_iter(content) {
418 if content[m.start()..m.end()].starts_with("$$") {
419 continue;
420 }
421 ranges.push((m.start(), m.end()));
422 }
423 ranges
424}
425
426pub fn is_table_line(line: &str) -> bool {
428 let trimmed = line.trim();
429
430 if trimmed
432 .chars()
433 .all(|c| c == '|' || c == '-' || c == ':' || c.is_whitespace())
434 && trimmed.contains('|')
435 && trimmed.contains('-')
436 {
437 return true;
438 }
439
440 if (trimmed.starts_with('|') || trimmed.ends_with('|')) && trimmed.matches('|').count() >= 2 {
442 return true;
443 }
444
445 false
446}
447
448pub fn is_in_icon_shortcode(line: &str, position: usize, _flavor: MarkdownFlavor) -> bool {
451 mkdocs_icons::is_in_any_shortcode(line, position)
454}
455
456pub fn is_in_pymdown_markup(line: &str, position: usize, flavor: MarkdownFlavor) -> bool {
462 match flavor {
463 MarkdownFlavor::MkDocs => mkdocs_extensions::is_in_pymdown_markup(line, position),
464 MarkdownFlavor::Obsidian => {
465 mkdocs_extensions::is_in_mark(line, position)
467 }
468 _ => false,
469 }
470}
471
472pub fn is_in_inline_html_code(line: &str, position: usize) -> bool {
477 visit_inline_html_code_ranges(line, |start, end| start <= position && position < end)
478}
479
480fn visit_inline_html_code_ranges(line: &str, mut visit: impl FnMut(usize, usize) -> bool) -> bool {
481 if !line.contains('<') {
482 return false;
483 }
484 const TAGS: &[&str] = &["code", "pre", "samp", "kbd", "var"];
486
487 let bytes = line.as_bytes();
488
489 for tag in TAGS {
490 let open_bytes = format!("<{tag}").into_bytes();
491 let close_pattern = format!("</{tag}>").into_bytes();
492
493 let mut search_from = 0;
494 while search_from + open_bytes.len() <= bytes.len() {
495 let Some(open_abs) = find_case_insensitive(bytes, &open_bytes, search_from) else {
497 break;
498 };
499
500 let after_tag = open_abs + open_bytes.len();
501
502 if after_tag < bytes.len() {
504 let next = bytes[after_tag];
505 if next != b'>' && next != b' ' && next != b'\t' {
506 search_from = after_tag;
507 continue;
508 }
509 }
510
511 let Some(tag_close) = bytes[after_tag..].iter().position(|&b| b == b'>') else {
513 break;
514 };
515 let content_start = after_tag + tag_close + 1;
516
517 let Some(close_start) = find_case_insensitive(bytes, &close_pattern, content_start) else {
519 break;
520 };
521 let content_end = close_start;
522
523 if visit(content_start, content_end) {
524 return true;
525 }
526
527 search_from = close_start + close_pattern.len();
528 }
529 }
530 false
531}
532
533fn find_case_insensitive(haystack: &[u8], needle: &[u8], from: usize) -> Option<usize> {
535 if needle.is_empty() || from + needle.len() > haystack.len() {
536 return None;
537 }
538 for i in from..=haystack.len() - needle.len() {
539 if haystack[i..i + needle.len()]
540 .iter()
541 .zip(needle.iter())
542 .all(|(h, n)| h.eq_ignore_ascii_case(n))
543 {
544 return Some(i);
545 }
546 }
547 None
548}
549
550pub fn is_in_mkdocs_markup(line: &str, position: usize, flavor: MarkdownFlavor) -> bool {
554 if is_in_icon_shortcode(line, position, flavor) {
555 return true;
556 }
557 if is_in_pymdown_markup(line, position, flavor) {
558 return true;
559 }
560 false
561}
562
563fn is_in_inline_code_on_line(line: &str, byte_pos: usize) -> bool {
569 visit_inline_code_ranges(line, |start, end| start <= byte_pos && byte_pos < end)
570}
571
572fn visit_inline_code_ranges(line: &str, mut visit: impl FnMut(usize, usize) -> bool) -> bool {
574 let bytes = line.as_bytes();
575 let mut i = 0;
576
577 while i < bytes.len() {
578 if bytes[i] == b'`' {
579 let open_start = i;
580 let mut backtick_count = 0;
581 while i < bytes.len() && bytes[i] == b'`' {
582 backtick_count += 1;
583 i += 1;
584 }
585
586 let mut j = i;
588 while j < bytes.len() {
589 if bytes[j] == b'`' {
590 let mut close_count = 0;
591 while j < bytes.len() && bytes[j] == b'`' {
592 close_count += 1;
593 j += 1;
594 }
595 if close_count == backtick_count {
596 if visit(open_start, j) {
598 return true;
599 }
600 i = j;
601 break;
602 }
603 } else {
604 j += 1;
605 }
606 }
607
608 if j >= bytes.len() {
609 break;
611 }
612 } else {
613 i += 1;
614 }
615 }
616
617 false
618}
619
620fn is_byte_in_html_tag(html_tags: &[HtmlTag], byte_pos: usize) -> bool {
622 let idx = html_tags.partition_point(|tag| tag.byte_offset <= byte_pos);
623 idx > 0 && byte_pos < html_tags[idx - 1].byte_end
624}
625
626fn is_byte_in_html_code_content(code_ranges: &[(usize, usize)], byte_pos: usize) -> bool {
629 let idx = code_ranges.partition_point(|&(start, _)| start <= byte_pos);
630 idx > 0 && byte_pos < code_ranges[idx - 1].1
631}
632
633pub(crate) fn compute_html_code_ranges(html_tags: &[HtmlTag]) -> Vec<(usize, usize)> {
636 let mut ranges = Vec::new();
637 let mut open_code_end: Option<usize> = None;
638
639 for tag in html_tags {
640 if tag.tag_name == "code" {
641 if tag.is_self_closing {
642 continue;
643 } else if !tag.is_closing {
644 open_code_end = Some(tag.byte_end);
645 } else if tag.is_closing {
646 if let Some(start) = open_code_end {
647 ranges.push((start, tag.byte_offset));
648 }
649 open_code_end = None;
650 }
651 }
652 }
653 if let Some(start) = open_code_end {
655 ranges.push((start, usize::MAX));
656 }
657 ranges
658}
659
660pub(crate) fn mask_mdx_inline_code<'a>(ctx: &LintContext, source: &'a str, start: usize) -> std::borrow::Cow<'a, str> {
664 let end = start + source.len();
665 let mut ranges = ctx.mdx_inline_code_ranges(start, end).peekable();
666 if ranges.peek().is_none() {
667 return std::borrow::Cow::Borrowed(source);
668 }
669 let mut bytes = source.as_bytes().to_vec();
670 for (range_start, range_end) in ranges {
671 bytes[range_start.max(start) - start..range_end.min(end) - start].fill(b'X');
672 }
673 std::borrow::Cow::Owned(String::from_utf8(bytes).expect("ASCII masking preserves UTF-8"))
675}
676
677#[derive(Default)]
680pub(crate) struct SkipRegions<T = usize>(Vec<(T, T)>);
681
682impl<T: Ord + Copy> SkipRegions<T> {
683 pub(crate) fn new(mut ranges: Vec<(T, T)>) -> Self {
684 ranges.sort_unstable_by_key(|&(start, _)| start);
685 let mut end = None;
686 for range in &mut ranges {
687 let maximum = end.map_or(range.1, |previous: T| previous.max(range.1));
688 range.1 = maximum;
689 end = Some(maximum);
690 }
691 Self(ranges)
692 }
693
694 pub(crate) fn contains_interior(&self, position: T) -> bool {
696 let index = self.0.partition_point(|&(start, _)| start < position);
697 index > 0 && position < self.0[index - 1].1
698 }
699
700 fn contains(&self, position: T) -> bool {
701 let index = self.0.partition_point(|&(start, _)| start <= position);
702 index > 0 && position < self.0[index - 1].1
703 }
704}
705
706impl SkipRegions {
707 fn on_line(line: &str, flavor: MarkdownFlavor) -> Self {
708 let mut ranges = Vec::new();
709 visit_inline_code_ranges(line, |start, end| {
710 ranges.push((start, end));
711 false
712 });
713 append_markup_ranges(&mut ranges, line, flavor);
714 Self::new(ranges)
715 }
716}
717
718fn append_markup_ranges(ranges: &mut Vec<(usize, usize)>, line: &str, flavor: MarkdownFlavor) {
719 ranges.extend(mkdocs_icons::shortcode_spans(line));
720 match flavor {
721 MarkdownFlavor::MkDocs => ranges.extend(mkdocs_extensions::pymdown_spans(line)),
722 MarkdownFlavor::Obsidian => ranges.extend(mkdocs_extensions::find_mark_spans(line)),
723 _ => {}
724 }
725}
726
727pub(crate) struct MarkupExclusions<'ctx, 'src> {
730 ctx: &'ctx LintContext<'src>,
731 lines: HashMap<usize, SkipRegions>,
732}
733
734impl<'ctx, 'src> MarkupExclusions<'ctx, 'src> {
735 pub(crate) fn new(ctx: &'ctx LintContext<'src>) -> Self {
736 Self {
737 ctx,
738 lines: HashMap::new(),
739 }
740 }
741
742 pub(crate) fn contains(&mut self, index: usize, byte: usize) -> bool {
743 let Some(line) = self.ctx.raw_lines().get(index) else {
744 return false;
745 };
746 if line.len() < 256 {
747 return is_in_mkdocs_markup(line, byte, self.ctx.flavor);
748 }
749 self.lines
750 .entry(index)
751 .or_insert_with(|| {
752 let mut ranges = Vec::new();
753 append_markup_ranges(&mut ranges, line, self.ctx.flavor);
754 SkipRegions::new(ranges)
755 })
756 .contains(byte)
757 }
758}
759
760pub(crate) struct SpacingExclusions<'ctx, 'src> {
763 ctx: &'ctx LintContext<'src>,
764 lines: HashMap<usize, SkipRegions>,
765 math: OnceCell<SkipRegions>,
766 images: OnceCell<SkipRegions>,
767 code: OnceCell<SkipRegions<(usize, usize)>>,
768}
769
770impl<'ctx, 'src> SpacingExclusions<'ctx, 'src> {
771 pub(crate) fn new(ctx: &'ctx LintContext<'src>) -> Self {
772 Self {
773 ctx,
774 lines: HashMap::new(),
775 math: OnceCell::new(),
776 images: OnceCell::new(),
777 code: OnceCell::new(),
778 }
779 }
780
781 pub(crate) fn in_image(&self, byte: usize) -> bool {
782 self.images
783 .get_or_init(|| {
784 SkipRegions::new(
785 self.ctx
786 .images()
787 .iter()
788 .map(|image| (image.byte_offset, image.byte_end))
789 .collect(),
790 )
791 })
792 .contains(byte)
793 }
794
795 pub(crate) fn in_math(&self, byte: usize) -> bool {
796 self.math
797 .get_or_init(|| SkipRegions::new(self.ctx.math_byte_ranges().to_vec()))
798 .contains(byte)
799 }
800
801 pub(crate) fn in_code_span(&self, line: usize, column: usize) -> bool {
802 if line == 0 || line > self.ctx.lines.len() {
803 return false;
804 }
805 self.code
806 .get_or_init(|| {
807 SkipRegions::new(
808 self.ctx
809 .source_code_spans()
810 .iter()
811 .map(|span| ((span.line, span.start_col), (span.end_line, span.end_col)))
812 .collect(),
813 )
814 })
815 .contains((line, column.saturating_sub(1)))
816 }
817
818 pub(crate) fn in_html_code_or_markup(&mut self, index: usize, byte: usize) -> bool {
819 let Some(line) = self.ctx.raw_lines().get(index) else {
820 return false;
821 };
822 if line.len() < 256 {
823 return is_in_inline_html_code(line, byte) || is_in_mkdocs_markup(line, byte, self.ctx.flavor);
824 }
825 self.lines
826 .entry(index)
827 .or_insert_with(|| {
828 let mut ranges = Vec::new();
829 visit_inline_html_code_ranges(line, |start, end| {
830 ranges.push((start, end));
831 false
832 });
833 append_markup_ranges(&mut ranges, line, self.ctx.flavor);
834 SkipRegions::new(ranges)
835 })
836 .contains(byte)
837 }
838}
839
840pub(crate) struct EmphasisContext<'ctx, 'src> {
843 ctx: &'ctx LintContext<'src>,
844 positions: SourcePositions<'ctx>,
845 html_tags: Arc<Vec<HtmlTag>>,
846 html_code_ranges: Vec<(usize, usize)>,
847 long_lines: HashMap<usize, SkipRegions>,
848 math: OnceCell<SkipRegions>,
849}
850
851impl<'ctx, 'src> EmphasisContext<'ctx, 'src> {
852 pub(crate) fn new(ctx: &'ctx LintContext<'src>) -> Self {
853 let html_tags = ctx.html_tags();
854 let html_code_ranges = compute_html_code_ranges(&html_tags);
855 Self {
856 ctx,
857 positions: SourcePositions::new(ctx.content, &ctx.lines),
858 html_tags,
859 html_code_ranges,
860 long_lines: HashMap::new(),
861 math: OnceCell::new(),
862 }
863 }
864
865 pub(crate) fn single_line_range(&mut self, start: usize, end: usize) -> (usize, usize, usize, usize) {
868 let (index, line, column) = self.positions.position(start);
869 (line, column + 1, line, self.positions.column_on_line(index, end) + 1)
870 }
871
872 pub(crate) fn should_skip(&mut self, span_start: usize, span_end: usize) -> bool {
874 let ctx = self.ctx;
875 let (line_index, line_num, column) = self.positions.position(span_start);
876 if ctx.line_info(line_num).is_some_and(|info| {
877 info.in_front_matter || info.in_mkdocstrings || info.in_esm_block || info.is_myst_comment
878 }) {
879 return true;
880 }
881 let in_line_markup = ctx.raw_lines().get(line_index).is_some_and(|line| {
885 if line.len() < 256 {
886 is_in_mkdocs_markup(line, column, ctx.flavor) || is_in_inline_code_on_line(line, column)
887 } else {
888 self.long_lines
889 .entry(line_index)
890 .or_insert_with(|| SkipRegions::on_line(line, ctx.flavor))
891 .contains(column)
892 }
893 });
894 ctx.is_in_jinja_string(span_start)
895 || ctx.is_in_jinja_string(span_end.saturating_sub(1))
896 || ctx.is_in_shortcode(span_start)
897 || ctx.is_in_shortcode(span_end.saturating_sub(1))
898 || ctx.is_in_code_block_or_span(span_start)
899 || in_line_markup
900 || ctx.is_in_link(span_start)
901 || is_byte_in_html_tag(&self.html_tags, span_start)
902 || ctx.is_in_jsx_component_tag(span_start)
903 || ctx.is_in_jsx_component_tag(span_end.saturating_sub(1))
904 || is_byte_in_html_code_content(&self.html_code_ranges, span_start)
905 || self
906 .math
907 .get_or_init(|| SkipRegions::new(ctx.math_byte_ranges().to_vec()))
908 .contains(span_start)
909 || is_in_jsx_expression(ctx, span_start)
910 || is_in_mdx_comment(ctx, span_start)
911 }
912}
913
914#[cfg(test)]
915mod tests {
916 use super::*;
917
918 #[test]
919 fn strict_regions_preserve_endpoints_and_extreme_offsets() {
920 let ranges = vec![
921 (20, 30),
922 (4, 4),
923 (10, 3),
924 (0, 2),
925 (2, 5),
926 (1, 8),
927 (20, 22),
928 (usize::MAX - 2, usize::MAX),
929 ];
930 let index = SkipRegions::new(ranges.clone());
931 for position in (0..=32).chain([usize::MAX - 2, usize::MAX - 1, usize::MAX]) {
932 assert_eq!(
933 index.contains_interior(position),
934 ranges.iter().any(|&(start, end)| start < position && position < end)
935 );
936 }
937 }
938
939 proptest::proptest! {
940 #[test]
941 fn strict_regions_match_scalar_membership(ranges in proptest::collection::vec((0usize..128, 0usize..128), 0..60)) {
942 let index = SkipRegions::new(ranges.clone());
943 for position in 0..=129 {
944 proptest::prop_assert_eq!(index.contains_interior(position), ranges.iter().any(|&(start, end)| start < position && position < end));
945 }
946 }
947 }
948
949 #[test]
950 fn markup_exclusions_match_scalar_at_each_byte() {
951 for padding in [0, 40] {
952 let prefix = "é日本🙂e\u{301}\t".repeat(padding);
953 let source = format!(
954 "{prefix}:smile: :material-check: :icon_with_underscore: ^^insert^^ ^super^ ~sub~ ~~strike~~\n\
955 {prefix}==mark== ++ctrl+alt++ {{--delete--}} {{++add++}} \\==escaped== ^unclosed\n"
956 );
957 for flavor in crate::format_proof::ALL_FLAVORS {
958 for ending in ["\n", "\r\n"] {
959 let content = source.replace('\n', ending);
960 let ctx = LintContext::new(&content, flavor, None);
961 let mut index = MarkupExclusions::new(&ctx);
962 for (line_index, line) in ctx.raw_lines().iter().enumerate().rev() {
963 for byte in (0..=line.len() + 1).rev() {
964 assert_eq!(
965 index.contains(line_index, byte),
966 is_in_mkdocs_markup(line, byte, flavor),
967 "{flavor:?}, line={line_index}, byte={byte}",
968 );
969 }
970 }
971 assert!(!index.contains(ctx.raw_lines().len(), 0));
972 if padding == 0 {
973 assert!(index.lines.is_empty(), "short lines should not allocate indexes");
974 }
975 }
976 }
977 }
978 }
979
980 fn legacy_should_skip_emphasis_span(
983 ctx: &LintContext,
984 html_tags: &[HtmlTag],
985 html_code_ranges: &[(usize, usize)],
986 span_start: usize,
987 span_end: usize,
988 ) -> bool {
989 let lines = ctx.raw_lines();
990 let (line_num, col) = ctx.offset_to_line_col(span_start);
991
992 if ctx.line_info(line_num).is_some_and(|info| {
994 info.in_front_matter || info.in_mkdocstrings || info.in_esm_block || info.is_myst_comment
995 }) {
996 return true;
997 }
998
999 let in_mkdocs_markup = lines
1001 .get(line_num.saturating_sub(1))
1002 .is_some_and(|line| is_in_mkdocs_markup(line, col.saturating_sub(1), ctx.flavor));
1003
1004 let in_inline_code = lines
1006 .get(line_num.saturating_sub(1))
1007 .is_some_and(|line| is_in_inline_code_on_line(line, col.saturating_sub(1)));
1008
1009 ctx.is_in_jinja_string(span_start)
1010 || ctx.is_in_jinja_string(span_end.saturating_sub(1))
1011 || ctx.is_in_shortcode(span_start)
1012 || ctx.is_in_shortcode(span_end.saturating_sub(1))
1013 || ctx.is_in_code_block_or_span(span_start)
1014 || in_inline_code
1015 || ctx.is_in_link(span_start)
1016 || is_byte_in_html_tag(html_tags, span_start)
1017 || ctx.is_in_jsx_component_tag(span_start)
1018 || ctx.is_in_jsx_component_tag(span_end.saturating_sub(1))
1019 || is_byte_in_html_code_content(html_code_ranges, span_start)
1020 || in_mkdocs_markup
1021 || is_in_math_context(ctx, span_start)
1022 || is_in_jsx_expression(ctx, span_start)
1023 || is_in_mdx_comment(ctx, span_start)
1024 }
1025
1026 fn legacy_inline_html_code(line: &str, position: usize) -> bool {
1027 const TAGS: &[&str] = &["code", "pre", "samp", "kbd", "var"];
1029
1030 let bytes = line.as_bytes();
1031
1032 for tag in TAGS {
1033 let open_bytes = format!("<{tag}").into_bytes();
1034 let close_pattern = format!("</{tag}>").into_bytes();
1035
1036 let mut search_from = 0;
1037 while search_from + open_bytes.len() <= bytes.len() {
1038 let Some(open_abs) = find_case_insensitive(bytes, &open_bytes, search_from) else {
1040 break;
1041 };
1042
1043 let after_tag = open_abs + open_bytes.len();
1044
1045 if after_tag < bytes.len() {
1047 let next = bytes[after_tag];
1048 if next != b'>' && next != b' ' && next != b'\t' {
1049 search_from = after_tag;
1050 continue;
1051 }
1052 }
1053
1054 let Some(tag_close) = bytes[after_tag..].iter().position(|&b| b == b'>') else {
1056 break;
1057 };
1058 let content_start = after_tag + tag_close + 1;
1059
1060 let Some(close_start) = find_case_insensitive(bytes, &close_pattern, content_start) else {
1062 break;
1063 };
1064 let content_end = close_start;
1065
1066 if position >= content_start && position < content_end {
1067 return true;
1068 }
1069
1070 search_from = close_start + close_pattern.len();
1071 }
1072 }
1073 false
1074 }
1075
1076 #[test]
1077 fn spacing_exclusions_preserve_scalar_membership() {
1078 let prefix = "é日本🙂e\u{301} ".repeat(40);
1079 let source = format!(
1080 "{prefix}<code>a <code>b</code> c</code> <VAR x='>'>literal</VAR> :smile: ^^insert^^ ==mark== ++ctrl+alt++ ^super^ ~sub~ ~~strike~~ {{--delete--}}
1081
1082{prefix} [](z) $x$ `é` ``a ` b``
1083
1084`multi
1085line
1086code` text
1087"
1088 );
1089 for flavor in crate::format_proof::ALL_FLAVORS {
1090 for ending in ["\n", "\r\n"] {
1091 let content = source.replace('\n', ending);
1092 let ctx = LintContext::new(&content, flavor, None);
1093 let mut index = SpacingExclusions::new(&ctx);
1094 for byte in (0..=content.len() + 1).rev() {
1095 assert_eq!(index.in_math(byte), is_in_math_context(&ctx, byte));
1096 assert_eq!(
1097 index.in_image(byte),
1098 ctx.images().iter().any(|s| s.byte_offset <= byte && byte < s.byte_end)
1099 );
1100 }
1101 for line in 0..=ctx.lines.len() + 1 {
1102 let width = line
1103 .checked_sub(1)
1104 .and_then(|i| ctx.raw_lines().get(i))
1105 .map_or(0, |s| s.chars().count());
1106 for column in (0..=width + 2).rev() {
1107 assert_eq!(
1108 index.in_code_span(line, column),
1109 ctx.is_in_code_span(line, column),
1110 "line={line}, column={column}"
1111 );
1112 }
1113 }
1114 for (i, line) in ctx.raw_lines().iter().enumerate().rev() {
1115 for byte in (0..=line.len() + 1).rev() {
1116 assert_eq!(
1117 index.in_html_code_or_markup(i, byte),
1118 legacy_inline_html_code(line, byte) || is_in_mkdocs_markup(line, byte, flavor)
1119 );
1120 }
1121 }
1122 }
1123 }
1124 }
1125
1126 proptest::proptest! {
1127 #![proptest_config(proptest::test_runner::Config::with_cases(128))]
1128 #[test]
1129 fn indexed_inline_html_matches_legacy(
1130 tokens in proptest::collection::vec(proptest::sample::select(vec!["text", "é日🙂", "<code>", "</code>", "<CODE x='>'>", "<codeword>", "<code/>", "<code", "<pre>", "</pre>", "<var>", "</VAR>", "<samp>", "</samp>", "<kbd\t>", "</kbd>", ">", "<", "\n"]), 0..60),
1131 ) {
1132 let source = tokens.concat();
1133 let mut ranges = Vec::new();
1134 visit_inline_html_code_ranges(&source, |start, end| { ranges.push((start, end)); false });
1135 let index = SkipRegions::new(ranges);
1136 for byte in 0..=source.len() + 1 {
1137 proptest::prop_assert_eq!(index.contains(byte), legacy_inline_html_code(&source, byte));
1138 proptest::prop_assert_eq!(is_in_inline_html_code(&source, byte), legacy_inline_html_code(&source, byte));
1139 }
1140 }
1141 }
1142
1143 #[test]
1144 fn inline_code_range_visitor_preserves_boundaries() {
1145 for (line, expected) in [
1146 ("plain", vec![]),
1147 ("`x`", vec![(0, 3)]),
1148 ("a `x` b ``y``", vec![(2, 5), (8, 13)]),
1149 ("`x `` y` z", vec![(0, 8)]),
1150 ("```unclosed `paired`", vec![]),
1152 ("`x` ``unclosed", vec![(0, 3)]),
1153 ("é `日`", vec![(3, 8)]),
1154 ] {
1155 let mut ranges = Vec::new();
1156 visit_inline_code_ranges(line, |start, end| {
1157 ranges.push((start, end));
1158 false
1159 });
1160 assert_eq!(ranges, expected, "{line:?}");
1161 for pos in 0..=line.len() + 1 {
1162 assert_eq!(
1163 is_in_inline_code_on_line(line, pos),
1164 expected.iter().any(|&(a, b)| a <= pos && pos < b)
1165 );
1166 }
1167 }
1168 }
1169
1170 proptest::proptest! {
1171 #![proptest_config(proptest::test_runner::Config::with_cases(64))]
1172 #[test]
1173 fn indexed_line_regions_match_legacy_predicates(
1174 tokens in proptest::collection::vec(0usize..18, 0..80),
1175 queries in proptest::collection::vec(0usize..10000, 1..40),
1176 ) {
1177 let choices = ["plain ", "é日本🙂 ", "`code` ", "``x ` y`` ", "`", ":smile:", ":material-check:",
1178 "++ctrl+alt++", "^^insert^^", "^super^", "~~strike~~", "~sub~", "==mark==", "...", "(c)", "**bold**", "{>>comment<<}", "{~~a~>b~~}"];
1179 let line: String = tokens.into_iter().map(|i| choices[i]).collect();
1180 for flavor in crate::format_proof::ALL_FLAVORS {
1181 let index = SkipRegions::on_line(&line, flavor);
1182 for pos in queries.iter().map(|q| q % (line.len() + 2)).chain([0, line.len()]) {
1183 proptest::prop_assert_eq!(index.contains(pos),
1184 is_in_inline_code_on_line(&line, pos) || is_in_mkdocs_markup(&line, pos, flavor));
1185 }
1186 }
1187 }
1188 }
1189
1190 #[test]
1191 fn emphasis_context_preserves_skip_decisions_and_clipped_ranges() {
1192 let prefix = "é日本🙂e\u{301} ".repeat(40);
1193 let source = format!(
1194 "---\ntitle: '**frontmatter**'\n---\n\n\
1195 {prefix}**good** __other__ `**literal**` :icon_with_underscore: $x$ **end**\n\n\
1196 {prefix}^^insert^^ ^super^ ++ctrl+alt++ ==mark== ~~strike~~ ~sub~ ... __visible__\n\n\
1197 {prefix}**multi\nline** __multi\nline__ [**link**](target) <code>__code__</code>\n\n\
1198 | a | b |\n| - | - |\n| `x|__code__` | **text** |\n\n\
1199 !!! note\n\n **note** __note__\n\n\
1200 {{/* **comment** */}}\n\n{{{{< note title=\"**literal**\" >}}}} __last__\n"
1201 );
1202 for flavor in crate::format_proof::ALL_FLAVORS {
1203 for newline in ["\n", "\r\n"] {
1204 let content = source.replace('\n', newline);
1205 let ctx = LintContext::new(&content, flavor, None);
1206 let mut indexed = EmphasisContext::new(&ctx);
1207 let html_tags = ctx.html_tags();
1208 let html_code_ranges = compute_html_code_ranges(&html_tags);
1209 let mut spans: Vec<_> = ctx.strong_spans.iter().map(|s| (s.start, s.end)).collect();
1210 spans.extend(ctx.source_emphasis_spans().iter().map(|s| (s.byte_offset, s.byte_end)));
1211 assert!(!spans.is_empty());
1212 for (start, end) in spans.iter().copied().chain(spans.iter().copied().rev()) {
1215 assert_eq!(
1216 indexed.should_skip(start, end),
1217 legacy_should_skip_emphasis_span(&ctx, &html_tags, &html_code_ranges, start, end),
1218 "{flavor:?} at {start}..{end}"
1219 );
1220 let (line, _) = ctx.offset_to_line_col(start);
1221 let line_start = ctx.line_start_byte(line).unwrap();
1222 let text = ctx.raw_lines()[line - 1];
1223 assert_eq!(
1224 indexed.single_line_range(start, end),
1225 crate::utils::range_utils::calculate_match_range(line, text, start - line_start, end - start),
1226 "{flavor:?} at {start}..{end}"
1227 );
1228 }
1229 }
1230 }
1231 }
1232
1233 #[test]
1234 fn test_html_comment_detection() {
1235 let content = "Text <!-- comment --> more text";
1236 let ranges = compute_html_comment_ranges(content);
1237 assert!(is_in_html_comment_ranges(&ranges, 10)); assert!(!is_in_html_comment_ranges(&ranges, 0)); assert!(!is_in_html_comment_ranges(&ranges, 25)); }
1241
1242 #[test]
1243 fn test_compute_html_comment_ranges_ignores_code_span_delimiters() {
1244 let content = "a `<!--` b\n\nc `-->` d";
1247 let open = content.find("<!--").unwrap();
1248 let close = content.find("-->").unwrap();
1249 let code_spans = [
1251 (content.find('`').unwrap(), open + "<!--".len() + 1),
1252 (content.rfind("` d").unwrap() - "-->".len(), close + "-->".len() + 1),
1253 ];
1254
1255 assert!(
1257 !compute_html_comment_ranges(content).is_empty(),
1258 "sanity: raw pattern matches across the code spans"
1259 );
1260 assert!(
1262 compute_html_comment_ranges_filtered(content, &code_spans, &[]).is_empty(),
1263 "a `<!--`/`-->` pair inside code spans must not be treated as a comment"
1264 );
1265 }
1266
1267 #[test]
1268 fn test_compute_html_comment_ranges_ignores_code_block_delimiters() {
1269 let content = "```\n<!-- literal\n```\n\nhttps://example.com\n\n-->\n";
1272 let block_end = content.find("```\n\n").unwrap() + "```".len();
1273 let code_blocks = [(0usize, block_end)];
1274 assert!(
1275 compute_html_comment_ranges_filtered(content, &[], &code_blocks).is_empty(),
1276 "a `<!--` inside a code block must not open a comment that spans to a later `-->`"
1277 );
1278 let real = "```\n<!-- literal\n```\n\n<!-- real --> tail";
1280 let real_block_end = real.find("```\n\n").unwrap() + "```".len();
1281 let ranges = compute_html_comment_ranges_filtered(real, &[], &[(0usize, real_block_end)]);
1282 assert_eq!(ranges.len(), 1);
1283 assert_eq!(ranges[0].start, real.find("<!-- real").unwrap());
1284 }
1285
1286 #[test]
1287 fn test_compute_html_comment_ranges_keeps_real_comments() {
1288 let content = "text `code` <!-- real comment --> more";
1291 let code_spans = [(content.find('`').unwrap(), content.find("` ").unwrap() + 1)];
1292 let ranges = compute_html_comment_ranges_filtered(content, &code_spans, &[]);
1293 assert_eq!(ranges.len(), 1, "the real comment must still be detected");
1294 let comment_start = content.find("<!--").unwrap();
1295 assert_eq!(ranges[0].start, comment_start);
1296 }
1297
1298 #[test]
1299 fn test_compute_html_comment_ranges_real_comment_after_code_span_opener() {
1300 let content = "a `<!--` then <!-- real --> end";
1304 let code_spans = [(content.find('`').unwrap(), content.find("` then").unwrap() + 1)];
1305 let ranges = compute_html_comment_ranges_filtered(content, &code_spans, &[]);
1306 assert_eq!(
1307 ranges.len(),
1308 1,
1309 "the real comment after a code-span opener must be detected"
1310 );
1311 let real_open = content.find("<!-- real").unwrap();
1312 assert_eq!(
1313 ranges[0].start, real_open,
1314 "range must start at the real comment, not the code-span opener"
1315 );
1316 assert_eq!(ranges[0].end, content.find("--> end").unwrap() + "-->".len());
1317 }
1318
1319 #[test]
1320 fn test_compute_html_comment_ranges_closer_inside_code_span_is_not_a_closer() {
1321 let content = "<!-- open `-->` still open --> done";
1324 let first_close = content.find("`-->`").unwrap() + 1;
1325 let code_spans = [(content.find('`').unwrap(), content.find("` still").unwrap() + 1)];
1326 let ranges = compute_html_comment_ranges_filtered(content, &code_spans, &[]);
1327 assert_eq!(ranges.len(), 1);
1328 assert_eq!(ranges[0].start, 0);
1329 let real_close_end = content.find("--> done").unwrap() + "-->".len();
1330 assert_eq!(
1331 ranges[0].end, real_close_end,
1332 "must close at the real --> ({real_close_end}), not the one in the code span ({first_close})"
1333 );
1334 }
1335
1336 #[test]
1337 fn test_scan_html_comments_starts_at_the_given_offset() {
1338 let content = "---\nauthor: \"a <!-- b\"\n---\n\nText --> text\n";
1341 let body_start = content.rfind("---\n").unwrap() + "---\n".len();
1342 let scan = scan_html_comments(content, &[], &[], body_start);
1343 assert!(scan.ranges.is_empty(), "got: {:?}", scan.ranges);
1344 assert_eq!(scan.unterminated, None);
1345
1346 let unscoped = scan_html_comments(content, &[], &[], 0);
1347 assert_eq!(unscoped.ranges.len(), 1, "without the offset the two pair up");
1348 }
1349
1350 #[test]
1351 fn test_scan_html_comments_reports_the_unterminated_opener() {
1352 let content = "<!-- closed --> text <!-- open";
1353 let scan = scan_html_comments(content, &[], &[], 0);
1354 assert_eq!(scan.ranges.len(), 1);
1355 assert_eq!(scan.unterminated, Some(content.rfind("<!--").unwrap()));
1356
1357 let closed = scan_html_comments("<!-- closed -->", &[], &[], 0);
1358 assert_eq!(closed.unterminated, None);
1359 }
1360
1361 #[test]
1362 fn test_unterminated_html_comment_outside_hidden_ranges() {
1363 let content = "%% note <!-- marker %%\n\n<!-- real\n";
1366 let hidden = [(0, content.find("\n\n").unwrap())];
1367 let reported = scan_html_comments(content, &[], &[], 0).unterminated;
1368 assert_eq!(reported, Some(content.find("<!--").unwrap()), "the hidden one is first");
1369 assert_eq!(
1370 unterminated_html_comment_outside(reported, &hidden, content, &[], &[], 0),
1371 Some(content.rfind("<!--").unwrap())
1372 );
1373
1374 let only_hidden = "%% note <!-- marker %%\n";
1376 let hidden_all = [(0, only_hidden.len())];
1377 let reported = scan_html_comments(only_hidden, &[], &[], 0).unterminated;
1378 assert!(reported.is_some(), "the scan sees the hidden opener");
1379 assert_eq!(
1380 unterminated_html_comment_outside(reported, &hidden_all, only_hidden, &[], &[], 0),
1381 None
1382 );
1383
1384 let visible = "<!-- open\n\n%% a note %%\n";
1386 let reported = scan_html_comments(visible, &[], &[], 0).unterminated;
1387 assert_eq!(
1388 unterminated_html_comment_outside(reported, &[(11, 23)], visible, &[], &[], 0),
1389 Some(0)
1390 );
1391 }
1392
1393 #[test]
1394 fn test_unterminated_comment_range_follows_the_block() {
1395 let blocks = [(0, 40)];
1397 let range = unterminated_comment_range(0, &blocks).expect("opener starts the block");
1398 assert_eq!((range.start, range.end), (0, 40));
1399
1400 let range = unterminated_comment_range(7, &blocks).expect("opener sits inside the block");
1403 assert_eq!((range.start, range.end), (7, 40));
1404
1405 assert!(unterminated_comment_range(60, &blocks).is_none());
1407 assert!(unterminated_comment_range(0, &[]).is_none());
1408
1409 assert!(unterminated_comment_range(40, &blocks).is_none());
1412 }
1413
1414 #[test]
1415 fn test_compute_html_comment_ranges_degenerate_comments_are_complete() {
1416 for (content, expected_end) in [
1420 ("<!--> text", 5),
1421 ("<!---> text", 6),
1422 ("<!----> text", 7),
1423 ("<!-- x --> text", 10),
1424 ] {
1425 let ranges = compute_html_comment_ranges(content);
1426 assert_eq!(ranges.len(), 1, "{content:?} holds exactly one comment");
1427 assert_eq!(ranges[0].start, 0);
1428 assert_eq!(
1429 ranges[0].end, expected_end,
1430 "{content:?} ends its comment at the first -->"
1431 );
1432 }
1433 }
1434
1435 #[test]
1436 fn test_compute_html_comment_ranges_degenerate_comment_before_a_real_one() {
1437 let content = "<!--> visible <!-- hidden --> visible";
1440 let ranges = compute_html_comment_ranges(content);
1441 assert_eq!(ranges.len(), 2);
1442 assert_eq!((ranges[0].start, ranges[0].end), (0, 5));
1443 assert_eq!(
1444 (ranges[1].start, ranges[1].end),
1445 (content.find("<!-- hidden").unwrap(), content.rfind("-->").unwrap() + 3)
1446 );
1447 }
1448
1449 #[test]
1450 fn test_is_line_entirely_in_html_comment() {
1451 let content = "<!--\ncomment\n--> Content after comment";
1453 let ranges = compute_html_comment_ranges(content);
1454 assert!(is_line_entirely_in_html_comment(&ranges, 0, 4));
1456 assert!(is_line_entirely_in_html_comment(&ranges, 5, 12));
1458 assert!(!is_line_entirely_in_html_comment(&ranges, 13, 38));
1460
1461 let content2 = "<!-- comment --> Not a comment";
1463 let ranges2 = compute_html_comment_ranges(content2);
1464 assert!(!is_line_entirely_in_html_comment(&ranges2, 0, 30));
1466
1467 let content3 = "<!-- comment -->";
1469 let ranges3 = compute_html_comment_ranges(content3);
1470 assert!(is_line_entirely_in_html_comment(&ranges3, 0, 16));
1472
1473 let content4 = "Text before <!-- comment -->";
1475 let ranges4 = compute_html_comment_ranges(content4);
1476 assert!(!is_line_entirely_in_html_comment(&ranges4, 0, 28));
1478 }
1479
1480 #[test]
1481 fn test_is_line_entirely_in_html_comment_indented() {
1482 let content = " <!-- comment -->";
1486 let ranges = compute_html_comment_ranges(content);
1487 let content_start = content.find("<!--").unwrap();
1488 let content_end = content.trim_end().len();
1489 assert!(is_line_entirely_in_html_comment(&ranges, content_start, content_end));
1490 assert!(!is_line_entirely_in_html_comment(&ranges, 0, content.len()));
1492 }
1493
1494 #[test]
1495 fn test_is_line_entirely_in_html_comment_trailing_whitespace() {
1496 let content = "<!-- comment --> ";
1498 let ranges = compute_html_comment_ranges(content);
1499 let content_end = content.trim_end().len();
1500 assert!(is_line_entirely_in_html_comment(&ranges, 0, content_end));
1501 assert!(!is_line_entirely_in_html_comment(&ranges, 0, content.len()));
1503 }
1504
1505 #[test]
1506 fn test_math_block_detection() {
1507 let content = "Text\n$$\nmath content\n$$\nmore text";
1508 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)); }
1513
1514 #[test]
1515 fn test_stray_double_dollar_in_prose_is_not_math() {
1516 let content = "Note: $$ is used for display math and $$ closes it";
1520 let between = content.find("is used").unwrap();
1521 assert!(
1522 !is_in_math_block(content, between),
1523 "stray paired `$$` in prose must not be treated as a math block"
1524 );
1525 assert!(math_block_ranges(content).is_empty());
1526 }
1527
1528 #[test]
1529 fn test_blockquoted_double_dollar_opens_block() {
1530 let content = "> $$\n> x = y\n> $$\n";
1532 let inside = content.find("x = y").unwrap();
1533 assert!(is_in_math_block(content, inside), "blockquoted math interior");
1534 }
1535
1536 #[test]
1537 fn test_self_contained_single_line_block_leaves_trailing_prose() {
1538 let content = "$$ a $$ and __not math__\n";
1540 let in_math = content.find('a').unwrap();
1541 assert!(is_in_math_block(content, in_math), "single-line math interior");
1542 let after = content.find("not math").unwrap();
1543 assert!(!is_in_math_block(content, after), "trailing prose is lintable");
1544 }
1545
1546 #[test]
1547 fn test_math_block_closes_with_content_before_fence() {
1548 let content = "$$\nx = y\n\\end{x}$$\nafter __text__ here";
1552
1553 let inside = content.find("x = y").unwrap();
1554 assert!(is_in_math_block(content, inside), "interior must be math");
1555
1556 let after = content.find("after").unwrap();
1557 assert!(
1558 !is_in_math_block(content, after),
1559 "content after a content-sharing closing fence must NOT be math"
1560 );
1561 }
1562
1563 #[test]
1564 fn test_inline_math_detection() {
1565 let content = "Text $x + y$ and $$a^2 + b^2$$ here";
1566 assert!(is_in_inline_math(content, 7), "inside the single-`$` inline span");
1567 assert!(!is_in_inline_math(content, 20), "mid-line $$...$$ is not inline math");
1571 assert!(
1572 !is_in_math_block(content, 20),
1573 "mid-line $$...$$ is not a line-start display block"
1574 );
1575 assert!(!is_in_inline_math(content, 0), "before any math");
1576 assert!(!is_in_inline_math(content, 35), "after the spans");
1577 }
1578
1579 #[test]
1580 fn test_table_line_detection() {
1581 assert!(is_table_line("| Header | Column |"));
1582 assert!(is_table_line("|--------|--------|"));
1583 assert!(is_table_line("| Cell 1 | Cell 2 |"));
1584 assert!(!is_table_line("Regular text"));
1585 assert!(!is_table_line("Just a pipe | here"));
1586 }
1587
1588 #[test]
1589 fn test_is_in_icon_shortcode() {
1590 let line = "Click :material-check: to confirm";
1591 assert!(!is_in_icon_shortcode(line, 0, MarkdownFlavor::MkDocs));
1593 assert!(is_in_icon_shortcode(line, 6, MarkdownFlavor::MkDocs));
1595 assert!(is_in_icon_shortcode(line, 15, MarkdownFlavor::MkDocs));
1596 assert!(is_in_icon_shortcode(line, 21, MarkdownFlavor::MkDocs));
1597 assert!(!is_in_icon_shortcode(line, 22, MarkdownFlavor::MkDocs));
1599 }
1600
1601 #[test]
1602 fn test_is_in_pymdown_markup() {
1603 let line = "Press ++ctrl+c++ to copy";
1605 assert!(!is_in_pymdown_markup(line, 0, MarkdownFlavor::MkDocs));
1606 assert!(is_in_pymdown_markup(line, 6, MarkdownFlavor::MkDocs));
1607 assert!(is_in_pymdown_markup(line, 10, MarkdownFlavor::MkDocs));
1608 assert!(!is_in_pymdown_markup(line, 17, MarkdownFlavor::MkDocs));
1609
1610 let line2 = "This is ==highlighted== text";
1612 assert!(!is_in_pymdown_markup(line2, 0, MarkdownFlavor::MkDocs));
1613 assert!(is_in_pymdown_markup(line2, 8, MarkdownFlavor::MkDocs));
1614 assert!(is_in_pymdown_markup(line2, 15, MarkdownFlavor::MkDocs));
1615 assert!(!is_in_pymdown_markup(line2, 23, MarkdownFlavor::MkDocs));
1616
1617 assert!(!is_in_pymdown_markup(line, 10, MarkdownFlavor::Standard));
1619 }
1620
1621 #[test]
1622 fn test_is_in_mkdocs_markup() {
1623 let line = ":material-check: and ++ctrl++";
1625 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)); }
1629
1630 #[test]
1633 fn test_obsidian_highlight_basic() {
1634 let line = "This is ==highlighted== text";
1636 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)); }
1643
1644 #[test]
1645 fn test_obsidian_highlight_multiple() {
1646 let line = "Both ==one== and ==two== here";
1648 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)); }
1653
1654 #[test]
1655 fn test_obsidian_highlight_not_standard_flavor() {
1656 let line = "This is ==highlighted== text";
1658 assert!(!is_in_pymdown_markup(line, 8, MarkdownFlavor::Standard));
1659 assert!(!is_in_pymdown_markup(line, 15, MarkdownFlavor::Standard));
1660 }
1661
1662 #[test]
1663 fn test_obsidian_highlight_with_spaces_inside() {
1664 let line = "This is ==text with spaces== here";
1666 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)); }
1670
1671 #[test]
1672 fn test_obsidian_does_not_support_keys_notation() {
1673 let line = "Press ++ctrl+c++ to copy";
1675 assert!(!is_in_pymdown_markup(line, 6, MarkdownFlavor::Obsidian));
1676 assert!(!is_in_pymdown_markup(line, 10, MarkdownFlavor::Obsidian));
1677 }
1678
1679 #[test]
1680 fn test_obsidian_mkdocs_markup_function() {
1681 let line = "This is ==highlighted== text";
1683 assert!(is_in_mkdocs_markup(line, 10, MarkdownFlavor::Obsidian)); assert!(!is_in_mkdocs_markup(line, 0, MarkdownFlavor::Obsidian)); }
1686
1687 #[test]
1688 fn test_obsidian_highlight_edge_cases() {
1689 let line = "Test ==== here";
1691 assert!(!is_in_pymdown_markup(line, 5, MarkdownFlavor::Obsidian)); assert!(!is_in_pymdown_markup(line, 6, MarkdownFlavor::Obsidian));
1693
1694 let line2 = "Test ==a== here";
1696 assert!(is_in_pymdown_markup(line2, 5, MarkdownFlavor::Obsidian));
1697 assert!(is_in_pymdown_markup(line2, 7, MarkdownFlavor::Obsidian)); assert!(is_in_pymdown_markup(line2, 9, MarkdownFlavor::Obsidian)); let line3 = "a === b";
1702 assert!(!is_in_pymdown_markup(line3, 3, MarkdownFlavor::Obsidian));
1703 }
1704
1705 #[test]
1706 fn test_obsidian_highlight_unclosed() {
1707 let line = "This ==starts but never ends";
1709 assert!(!is_in_pymdown_markup(line, 5, MarkdownFlavor::Obsidian));
1710 assert!(!is_in_pymdown_markup(line, 10, MarkdownFlavor::Obsidian));
1711 }
1712
1713 #[test]
1714 fn test_inline_html_code_basic() {
1715 let line = "The formula is <code>a * b * c</code> in math.";
1716 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)); }
1723
1724 #[test]
1725 fn test_inline_html_code_multiple_tags() {
1726 let line = "<kbd>Ctrl</kbd> + <samp>output</samp>";
1727 assert!(is_in_inline_html_code(line, 5)); assert!(is_in_inline_html_code(line, 24)); assert!(!is_in_inline_html_code(line, 16)); }
1731
1732 #[test]
1733 fn test_inline_html_code_with_attributes() {
1734 let line = r#"<code class="lang">x * y</code>"#;
1735 assert!(is_in_inline_html_code(line, 19)); assert!(is_in_inline_html_code(line, 23)); assert!(!is_in_inline_html_code(line, 0)); }
1739
1740 #[test]
1741 fn test_inline_html_code_case_insensitive() {
1742 let line = "<CODE>a * b</CODE>";
1743 assert!(is_in_inline_html_code(line, 6)); assert!(is_in_inline_html_code(line, 8)); }
1746
1747 #[test]
1748 fn test_inline_html_code_var_and_pre() {
1749 let line = "<var>x * y</var> and <pre>a * b</pre>";
1750 assert!(is_in_inline_html_code(line, 5)); assert!(is_in_inline_html_code(line, 26)); assert!(!is_in_inline_html_code(line, 17)); }
1754
1755 #[test]
1756 fn test_inline_html_code_unclosed() {
1757 let line = "<code>a * b without closing";
1759 assert!(!is_in_inline_html_code(line, 6));
1760 }
1761
1762 #[test]
1763 fn test_inline_html_code_no_substring_match() {
1764 let line = "<variable>a * b</variable>";
1766 assert!(!is_in_inline_html_code(line, 11));
1767
1768 let line2 = "<keyboard>x * y</keyboard>";
1770 assert!(!is_in_inline_html_code(line2, 11));
1771 }
1772}