1use crate::lint_context::{LineContextMask, LineInfo, LintContext};
36
37#[derive(Debug, Clone)]
39pub struct FilteredLine<'a> {
40 pub line_num: usize,
42 pub line_info: &'a LineInfo,
44 pub content: &'a str,
46}
47
48#[derive(Debug, Clone, Default)]
65pub struct LineFilterConfig {
66 pub skip_front_matter: bool,
68 pub skip_code_blocks: bool,
70 pub skip_html_blocks: bool,
72 pub skip_html_comments: bool,
74 pub skip_mkdocstrings: bool,
76 pub skip_esm_blocks: bool,
78 pub skip_math_blocks: bool,
80 pub skip_quarto_divs: bool,
82 pub skip_jsx_expressions: bool,
84 pub skip_mdx_comments: bool,
86 pub skip_admonitions: bool,
88 pub skip_content_tabs: bool,
90 pub skip_mkdocs_html_markdown: bool,
92 pub skip_definition_lists: bool,
94 pub skip_obsidian_comments: bool,
96 pub skip_pymdown_blocks: bool,
98 pub skip_div_markers: bool,
102 pub skip_jsx_blocks: bool,
104}
105
106impl LineFilterConfig {
107 #[must_use]
109 pub fn new() -> Self {
110 Self::default()
111 }
112
113 #[must_use]
118 pub fn skip_front_matter(mut self) -> Self {
119 self.skip_front_matter = true;
120 self
121 }
122
123 #[must_use]
128 pub fn skip_code_blocks(mut self) -> Self {
129 self.skip_code_blocks = true;
130 self
131 }
132
133 #[must_use]
138 pub fn skip_html_blocks(mut self) -> Self {
139 self.skip_html_blocks = true;
140 self
141 }
142
143 #[must_use]
148 pub fn skip_html_comments(mut self) -> Self {
149 self.skip_html_comments = true;
150 self
151 }
152
153 #[must_use]
158 pub fn skip_mkdocstrings(mut self) -> Self {
159 self.skip_mkdocstrings = true;
160 self
161 }
162
163 #[must_use]
168 pub fn skip_esm_blocks(mut self) -> Self {
169 self.skip_esm_blocks = true;
170 self
171 }
172
173 #[must_use]
178 pub fn skip_math_blocks(mut self) -> Self {
179 self.skip_math_blocks = true;
180 self
181 }
182
183 #[must_use]
188 pub fn skip_quarto_divs(mut self) -> Self {
189 self.skip_quarto_divs = true;
190 self
191 }
192
193 #[must_use]
198 pub fn skip_jsx_expressions(mut self) -> Self {
199 self.skip_jsx_expressions = true;
200 self
201 }
202
203 #[must_use]
208 pub fn skip_mdx_comments(mut self) -> Self {
209 self.skip_mdx_comments = true;
210 self
211 }
212
213 #[must_use]
218 pub fn skip_admonitions(mut self) -> Self {
219 self.skip_admonitions = true;
220 self
221 }
222
223 #[must_use]
227 pub fn skip_content_tabs(mut self) -> Self {
228 self.skip_content_tabs = true;
229 self
230 }
231
232 #[must_use]
236 pub fn skip_mkdocs_html_markdown(mut self) -> Self {
237 self.skip_mkdocs_html_markdown = true;
238 self
239 }
240
241 #[must_use]
247 pub fn skip_mkdocs_containers(mut self) -> Self {
248 self.skip_admonitions = true;
249 self.skip_content_tabs = true;
250 self.skip_mkdocs_html_markdown = true;
251 self
252 }
253
254 #[must_use]
259 pub fn skip_definition_lists(mut self) -> Self {
260 self.skip_definition_lists = true;
261 self
262 }
263
264 #[must_use]
269 pub fn skip_obsidian_comments(mut self) -> Self {
270 self.skip_obsidian_comments = true;
271 self
272 }
273
274 #[must_use]
280 pub fn skip_pymdown_blocks(mut self) -> Self {
281 self.skip_pymdown_blocks = true;
282 self
283 }
284
285 #[must_use]
291 pub fn skip_div_markers(mut self) -> Self {
292 self.skip_div_markers = true;
293 self
294 }
295
296 #[must_use]
301 pub fn skip_jsx_blocks(mut self) -> Self {
302 self.skip_jsx_blocks = true;
303 self
304 }
305
306 #[cfg(test)]
308 fn should_filter(&self, line_info: &LineInfo) -> bool {
309 line_info.in_kramdown_extension_block
312 || (self.skip_front_matter && line_info.in_front_matter)
313 || (self.skip_code_blocks && line_info.in_code_block)
314 || (self.skip_html_blocks && line_info.in_html_block)
315 || (self.skip_html_comments && line_info.in_html_comment)
316 || (self.skip_mkdocstrings && line_info.in_mkdocstrings)
317 || (self.skip_esm_blocks && line_info.in_esm_block)
318 || (self.skip_math_blocks && line_info.in_math_block)
319 || (self.skip_quarto_divs && line_info.in_pandoc_div)
320 || (self.skip_jsx_expressions && line_info.in_jsx_expression)
321 || (self.skip_mdx_comments && line_info.in_mdx_comment)
322 || (self.skip_admonitions && line_info.in_admonition)
323 || (self.skip_content_tabs && line_info.in_content_tab)
324 || (self.skip_mkdocs_html_markdown && line_info.in_mkdocs_html_markdown)
325 || (self.skip_definition_lists && line_info.in_definition_list)
326 || (self.skip_obsidian_comments && line_info.in_obsidian_comment)
327 || (self.skip_pymdown_blocks && line_info.in_pymdown_block)
328 || (self.skip_div_markers && line_info.is_div_marker)
329 || (self.skip_jsx_blocks && line_info.in_jsx_block)
330 }
331}
332
333pub struct FilteredLinesIter<'a> {
335 ctx: &'a LintContext<'a>,
336 contexts: &'a [LineContextMask],
337 mask: LineContextMask,
338 current_index: usize,
339}
340
341impl<'a> FilteredLinesIter<'a> {
342 fn new(ctx: &'a LintContext<'a>, config: &LineFilterConfig) -> Self {
344 Self {
345 ctx,
346 contexts: ctx.line_context_masks(),
347 mask: LineContextMask::for_config(config),
348 current_index: 0,
349 }
350 }
351}
352
353impl<'a> Iterator for FilteredLinesIter<'a> {
354 type Item = FilteredLine<'a>;
355
356 fn next(&mut self) -> Option<Self::Item> {
357 let lines = &self.ctx.lines;
358 let raw_lines = self.ctx.raw_lines();
359
360 while self.current_index < lines.len() {
361 let idx = self.current_index;
362 self.current_index += 1;
363
364 if self.mask.excludes(self.contexts[idx]) {
366 continue;
367 }
368
369 let line_content = raw_lines.get(idx).copied().unwrap_or("");
371
372 return Some(FilteredLine {
374 line_num: idx + 1, line_info: &lines[idx],
376 content: line_content,
377 });
378 }
379
380 None
381 }
382}
383
384pub trait FilteredLinesExt {
389 fn filtered_lines(&self) -> FilteredLinesBuilder<'_>;
408
409 fn content_lines(&self) -> FilteredLinesIter<'_>;
432}
433
434pub struct FilteredLinesBuilder<'a> {
436 ctx: &'a LintContext<'a>,
437 config: LineFilterConfig,
438}
439
440impl<'a> FilteredLinesBuilder<'a> {
441 fn new(ctx: &'a LintContext<'a>) -> Self {
442 Self {
443 ctx,
444 config: LineFilterConfig::new(),
445 }
446 }
447
448 pub(crate) fn candidates(
451 self,
452 kind: crate::lint_context::LineCandidateKind,
453 ) -> impl Iterator<Item = FilteredLine<'a>> + 'a {
454 let contexts = self.ctx.line_context_masks();
455 let mask = LineContextMask::for_config(&self.config);
456 self.ctx.candidate_line_indices(kind).iter().filter_map(move |&index| {
457 let line_info = &self.ctx.lines[index];
458 (!mask.excludes(contexts[index])).then(|| FilteredLine {
459 line_num: index + 1,
460 line_info,
461 content: self.ctx.raw_lines().get(index).copied().unwrap_or(""),
462 })
463 })
464 }
465
466 #[must_use]
468 pub fn skip_front_matter(mut self) -> Self {
469 self.config = self.config.skip_front_matter();
470 self
471 }
472
473 #[must_use]
475 pub fn skip_code_blocks(mut self) -> Self {
476 self.config = self.config.skip_code_blocks();
477 self
478 }
479
480 #[must_use]
482 pub fn skip_html_blocks(mut self) -> Self {
483 self.config = self.config.skip_html_blocks();
484 self
485 }
486
487 #[must_use]
489 pub fn skip_html_comments(mut self) -> Self {
490 self.config = self.config.skip_html_comments();
491 self
492 }
493
494 #[must_use]
496 pub fn skip_mkdocstrings(mut self) -> Self {
497 self.config = self.config.skip_mkdocstrings();
498 self
499 }
500
501 #[must_use]
503 pub fn skip_esm_blocks(mut self) -> Self {
504 self.config = self.config.skip_esm_blocks();
505 self
506 }
507
508 #[must_use]
510 pub fn skip_math_blocks(mut self) -> Self {
511 self.config = self.config.skip_math_blocks();
512 self
513 }
514
515 #[must_use]
517 pub fn skip_quarto_divs(mut self) -> Self {
518 self.config = self.config.skip_quarto_divs();
519 self
520 }
521
522 #[must_use]
524 pub fn skip_jsx_expressions(mut self) -> Self {
525 self.config = self.config.skip_jsx_expressions();
526 self
527 }
528
529 #[must_use]
531 pub fn skip_mdx_comments(mut self) -> Self {
532 self.config = self.config.skip_mdx_comments();
533 self
534 }
535
536 #[must_use]
538 pub fn skip_admonitions(mut self) -> Self {
539 self.config = self.config.skip_admonitions();
540 self
541 }
542
543 #[must_use]
545 pub fn skip_content_tabs(mut self) -> Self {
546 self.config = self.config.skip_content_tabs();
547 self
548 }
549
550 #[must_use]
552 pub fn skip_mkdocs_html_markdown(mut self) -> Self {
553 self.config = self.config.skip_mkdocs_html_markdown();
554 self
555 }
556
557 #[must_use]
563 pub fn skip_mkdocs_containers(mut self) -> Self {
564 self.config = self.config.skip_mkdocs_containers();
565 self
566 }
567
568 #[must_use]
570 pub fn skip_definition_lists(mut self) -> Self {
571 self.config = self.config.skip_definition_lists();
572 self
573 }
574
575 #[must_use]
577 pub fn skip_obsidian_comments(mut self) -> Self {
578 self.config = self.config.skip_obsidian_comments();
579 self
580 }
581
582 #[must_use]
584 pub fn skip_pymdown_blocks(mut self) -> Self {
585 self.config = self.config.skip_pymdown_blocks();
586 self
587 }
588
589 #[must_use]
594 pub fn skip_div_markers(mut self) -> Self {
595 self.config = self.config.skip_div_markers();
596 self
597 }
598
599 #[must_use]
601 pub fn skip_jsx_blocks(mut self) -> Self {
602 self.config = self.config.skip_jsx_blocks();
603 self
604 }
605}
606
607impl<'a> IntoIterator for FilteredLinesBuilder<'a> {
608 type Item = FilteredLine<'a>;
609 type IntoIter = FilteredLinesIter<'a>;
610
611 fn into_iter(self) -> Self::IntoIter {
612 FilteredLinesIter::new(self.ctx, &self.config)
613 }
614}
615
616impl<'a> FilteredLinesExt for LintContext<'a> {
617 fn filtered_lines(&self) -> FilteredLinesBuilder<'_> {
618 FilteredLinesBuilder::new(self)
619 }
620
621 fn content_lines(&self) -> FilteredLinesIter<'_> {
622 FilteredLinesIter::new(self, &LineFilterConfig::new().skip_front_matter())
623 }
624}
625
626#[cfg(test)]
627mod tests {
628 use super::*;
629 use crate::config::MarkdownFlavor;
630
631 proptest::proptest! {
632 #[test]
633 fn compact_context_matches_boolean_filter(
634 membership in proptest::prelude::any::<u32>(),
635 selected in proptest::prelude::any::<u32>(),
636 ) {
637 let ctx = LintContext::new("text", MarkdownFlavor::Standard, None);
638 let mut line = ctx.lines[0].clone();
639 let mut config = LineFilterConfig::new();
640 line.in_kramdown_extension_block = membership & (1 << 0) != 0;
641 line.in_front_matter = membership & (1 << 1) != 0;
642 config.skip_front_matter = selected & (1 << 1) != 0;
643 line.in_code_block = membership & (1 << 2) != 0;
644 config.skip_code_blocks = selected & (1 << 2) != 0;
645 line.in_html_block = membership & (1 << 3) != 0;
646 config.skip_html_blocks = selected & (1 << 3) != 0;
647 line.in_html_comment = membership & (1 << 4) != 0;
648 config.skip_html_comments = selected & (1 << 4) != 0;
649 line.in_mkdocstrings = membership & (1 << 5) != 0;
650 config.skip_mkdocstrings = selected & (1 << 5) != 0;
651 line.in_esm_block = membership & (1 << 6) != 0;
652 config.skip_esm_blocks = selected & (1 << 6) != 0;
653 line.in_math_block = membership & (1 << 7) != 0;
654 config.skip_math_blocks = selected & (1 << 7) != 0;
655 line.in_pandoc_div = membership & (1 << 8) != 0;
656 config.skip_quarto_divs = selected & (1 << 8) != 0;
657 line.in_jsx_expression = membership & (1 << 9) != 0;
658 config.skip_jsx_expressions = selected & (1 << 9) != 0;
659 line.in_mdx_comment = membership & (1 << 10) != 0;
660 config.skip_mdx_comments = selected & (1 << 10) != 0;
661 line.in_admonition = membership & (1 << 11) != 0;
662 config.skip_admonitions = selected & (1 << 11) != 0;
663 line.in_content_tab = membership & (1 << 12) != 0;
664 config.skip_content_tabs = selected & (1 << 12) != 0;
665 line.in_mkdocs_html_markdown = membership & (1 << 13) != 0;
666 config.skip_mkdocs_html_markdown = selected & (1 << 13) != 0;
667 line.in_definition_list = membership & (1 << 14) != 0;
668 config.skip_definition_lists = selected & (1 << 14) != 0;
669 line.in_obsidian_comment = membership & (1 << 15) != 0;
670 config.skip_obsidian_comments = selected & (1 << 15) != 0;
671 line.in_pymdown_block = membership & (1 << 16) != 0;
672 config.skip_pymdown_blocks = selected & (1 << 16) != 0;
673 line.is_div_marker = membership & (1 << 17) != 0;
674 config.skip_div_markers = selected & (1 << 17) != 0;
675 line.in_jsx_block = membership & (1 << 18) != 0;
676 config.skip_jsx_blocks = selected & (1 << 18) != 0;
677 proptest::prop_assert_eq!(
678 LineContextMask::for_config(&config).excludes(LineContextMask::from_line(&line)),
679 config.should_filter(&line),
680 );
681 }
682 }
683
684 #[test]
685 fn test_filtered_line_structure() {
686 let content = "# Title\n\nContent";
687 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
688
689 let line = ctx.content_lines().next().unwrap();
690 assert_eq!(line.line_num, 1);
691 assert_eq!(line.content, "# Title");
692 assert!(!line.line_info.in_front_matter);
693 }
694
695 #[test]
696 fn test_skip_front_matter_yaml() {
697 let content = "---\ntitle: Test\nurl: http://example.com\n---\n\n# Content\n\nMore content";
698 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
699
700 let lines: Vec<_> = ctx.content_lines().collect();
701 assert_eq!(lines.len(), 4);
703 assert_eq!(lines[0].line_num, 5); assert_eq!(lines[0].content, "");
705 assert_eq!(lines[1].line_num, 6);
706 assert_eq!(lines[1].content, "# Content");
707 assert_eq!(lines[2].line_num, 7);
708 assert_eq!(lines[2].content, "");
709 assert_eq!(lines[3].line_num, 8);
710 assert_eq!(lines[3].content, "More content");
711 }
712
713 #[test]
714 fn test_skip_front_matter_toml() {
715 let content = "+++\ntitle = \"Test\"\nurl = \"http://example.com\"\n+++\n\n# Content";
716 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
717
718 let lines: Vec<_> = ctx.content_lines().collect();
719 assert_eq!(lines.len(), 2); assert_eq!(lines[0].line_num, 5);
721 assert_eq!(lines[1].line_num, 6);
722 assert_eq!(lines[1].content, "# Content");
723 }
724
725 #[test]
726 fn test_skip_front_matter_json() {
727 let content = "{\n\"title\": \"Test\",\n\"url\": \"http://example.com\"\n}\n\n# Content";
728 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
729
730 let lines: Vec<_> = ctx.content_lines().collect();
731 assert_eq!(lines.len(), 2); assert_eq!(lines[0].line_num, 5);
733 assert_eq!(lines[1].line_num, 6);
734 assert_eq!(lines[1].content, "# Content");
735 }
736
737 #[test]
738 fn test_skip_code_blocks() {
739 let content = "# Title\n\n```rust\nlet x = 1;\nlet y = 2;\n```\n\nContent";
740 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
741
742 let lines: Vec<_> = ctx.filtered_lines().skip_code_blocks().into_iter().collect();
743
744 assert!(lines.iter().any(|l| l.content == "# Title"));
749 assert!(lines.iter().any(|l| l.content == "Content"));
750 assert!(!lines.iter().any(|l| l.content == "let x = 1;"));
752 assert!(!lines.iter().any(|l| l.content == "let y = 2;"));
753 }
754
755 #[test]
756 fn test_no_filters() {
757 let content = "---\ntitle: Test\n---\n\n# Content";
758 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
759
760 let lines: Vec<_> = ctx.filtered_lines().into_iter().collect();
762 assert_eq!(lines.len(), ctx.lines.len());
763 }
764
765 #[test]
766 fn test_multiple_filters() {
767 let content = "---\ntitle: Test\n---\n\n# Title\n\n```rust\ncode\n```\n\nContent";
768 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
769
770 let lines: Vec<_> = ctx
771 .filtered_lines()
772 .skip_front_matter()
773 .skip_code_blocks()
774 .into_iter()
775 .collect();
776
777 assert!(lines.iter().any(|l| l.content == "# Title"));
779 assert!(lines.iter().any(|l| l.content == "Content"));
780 assert!(!lines.iter().any(|l| l.content == "title: Test"));
781 assert!(!lines.iter().any(|l| l.content == "code"));
782 }
783
784 #[test]
785 fn test_line_numbering_is_1_indexed() {
786 let content = "First\nSecond\nThird";
787 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
788
789 let lines: Vec<_> = ctx.content_lines().collect();
790 assert_eq!(lines[0].line_num, 1);
791 assert_eq!(lines[0].content, "First");
792 assert_eq!(lines[1].line_num, 2);
793 assert_eq!(lines[1].content, "Second");
794 assert_eq!(lines[2].line_num, 3);
795 assert_eq!(lines[2].content, "Third");
796 }
797
798 #[test]
799 fn test_content_lines_convenience_method() {
800 let content = "---\nfoo: bar\n---\n\nContent";
801 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
802
803 let lines: Vec<_> = ctx.content_lines().collect();
805 assert!(!lines.iter().any(|l| l.content.contains("foo")));
806 assert!(lines.iter().any(|l| l.content == "Content"));
807 }
808
809 #[test]
810 fn test_empty_document() {
811 let content = "";
812 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
813
814 let lines: Vec<_> = ctx.content_lines().collect();
815 assert_eq!(lines.len(), 0);
816 }
817
818 #[test]
819 fn test_only_front_matter() {
820 let content = "---\ntitle: Test\n---";
821 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
822
823 let lines: Vec<_> = ctx.content_lines().collect();
824 assert_eq!(
825 lines.len(),
826 0,
827 "Document with only front matter should have no content lines"
828 );
829 }
830
831 #[test]
832 fn test_builder_pattern_ergonomics() {
833 let content = "# Title\n\n```\ncode\n```\n\nContent";
834 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
835
836 let _lines: Vec<_> = ctx
838 .filtered_lines()
839 .skip_front_matter()
840 .skip_code_blocks()
841 .skip_html_blocks()
842 .into_iter()
843 .collect();
844
845 }
847
848 #[test]
849 fn test_filtered_line_access_to_line_info() {
850 let content = "# Title\n\nContent";
851 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
852
853 for line in ctx.content_lines() {
854 assert!(!line.line_info.in_front_matter);
856 assert!(!line.line_info.in_code_block);
857 }
858 }
859
860 #[test]
861 fn test_skip_mkdocstrings() {
862 let content = r#"# API Documentation
863
864::: mymodule.MyClass
865 options:
866 show_root_heading: true
867 show_source: false
868
869Some regular content here.
870
871::: mymodule.function
872 options:
873 show_signature: true
874
875More content."#;
876 let ctx = LintContext::new(content, MarkdownFlavor::MkDocs, None);
877 let lines: Vec<_> = ctx.filtered_lines().skip_mkdocstrings().into_iter().collect();
878
879 assert!(
881 lines.iter().any(|l| l.content.contains("# API Documentation")),
882 "Should include lines outside mkdocstrings blocks"
883 );
884 assert!(
885 lines.iter().any(|l| l.content.contains("Some regular content")),
886 "Should include content between mkdocstrings blocks"
887 );
888 assert!(
889 lines.iter().any(|l| l.content.contains("More content")),
890 "Should include content after mkdocstrings blocks"
891 );
892
893 assert!(
895 !lines.iter().any(|l| l.content.contains("::: mymodule")),
896 "Should exclude mkdocstrings marker lines"
897 );
898 assert!(
899 !lines.iter().any(|l| l.content.contains("show_root_heading")),
900 "Should exclude mkdocstrings option lines"
901 );
902 assert!(
903 !lines.iter().any(|l| l.content.contains("show_signature")),
904 "Should exclude all mkdocstrings option lines"
905 );
906
907 assert_eq!(lines[0].line_num, 1, "First line should be line 1");
909 }
910
911 #[test]
912 fn test_skip_esm_blocks() {
913 let content = r#"import {Chart} from './components.js'
915import {Table} from './table.js'
916export const year = 2023
917
918# Last year's snowfall
919
920Content about snowfall data.
921
922import {Footer} from './footer.js'
923
924More content."#;
925 let ctx = LintContext::new(content, MarkdownFlavor::MDX, None);
926 let lines: Vec<_> = ctx.filtered_lines().skip_esm_blocks().into_iter().collect();
927
928 assert!(
930 lines.iter().any(|l| l.content.contains("# Last year's snowfall")),
931 "Should include markdown headings"
932 );
933 assert!(
934 lines.iter().any(|l| l.content.contains("Content about snowfall")),
935 "Should include markdown content"
936 );
937 assert!(
938 lines.iter().any(|l| l.content.contains("More content")),
939 "Should include content after ESM blocks"
940 );
941
942 assert!(
944 !lines.iter().any(|l| l.content.contains("import {Chart}")),
945 "Should exclude import statements at top of file"
946 );
947 assert!(
948 !lines.iter().any(|l| l.content.contains("import {Table}")),
949 "Should exclude all import statements at top of file"
950 );
951 assert!(
952 !lines.iter().any(|l| l.content.contains("export const year")),
953 "Should exclude export statements at top of file"
954 );
955 assert!(
957 !lines.iter().any(|l| l.content.contains("import {Footer}")),
958 "Should exclude import statements even after markdown content (MDX 2.0+ ESM anywhere)"
959 );
960
961 let heading_line = lines
963 .iter()
964 .find(|l| l.content.contains("# Last year's snowfall"))
965 .unwrap();
966 assert_eq!(heading_line.line_num, 5, "Heading should be on line 5");
967 }
968
969 #[test]
970 fn test_all_filters_combined() {
971 let content = r#"---
972title: Test
973---
974
975# Title
976
977```
978code
979```
980
981<!-- HTML comment here -->
982
983::: mymodule.Class
984 options:
985 show_root_heading: true
986
987<div>
988HTML block
989</div>
990
991Content"#;
992 let ctx = LintContext::new(content, MarkdownFlavor::MkDocs, None);
993
994 let lines: Vec<_> = ctx
995 .filtered_lines()
996 .skip_front_matter()
997 .skip_code_blocks()
998 .skip_html_blocks()
999 .skip_html_comments()
1000 .skip_mkdocstrings()
1001 .into_iter()
1002 .collect();
1003
1004 assert!(
1006 lines.iter().any(|l| l.content == "# Title"),
1007 "Should include markdown headings"
1008 );
1009 assert!(
1010 lines.iter().any(|l| l.content == "Content"),
1011 "Should include markdown content"
1012 );
1013
1014 assert!(
1016 !lines.iter().any(|l| l.content == "title: Test"),
1017 "Should exclude front matter"
1018 );
1019 assert!(
1020 !lines.iter().any(|l| l.content == "code"),
1021 "Should exclude code block content"
1022 );
1023 assert!(
1024 !lines.iter().any(|l| l.content.contains("HTML comment")),
1025 "Should exclude HTML comments"
1026 );
1027 assert!(
1028 !lines.iter().any(|l| l.content.contains("::: mymodule")),
1029 "Should exclude mkdocstrings blocks"
1030 );
1031 assert!(
1032 !lines.iter().any(|l| l.content.contains("show_root_heading")),
1033 "Should exclude mkdocstrings options"
1034 );
1035 assert!(
1036 !lines.iter().any(|l| l.content.contains("HTML block")),
1037 "Should exclude HTML blocks"
1038 );
1039 }
1040
1041 #[test]
1042 fn test_skip_math_blocks() {
1043 let content = r#"# Heading
1044
1045Some regular text.
1046
1047$$
1048A = \left[
1049\begin{array}{c}
10501 \\
1051-D
1052\end{array}
1053\right]
1054$$
1055
1056More content after math."#;
1057 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1058 let lines: Vec<_> = ctx.filtered_lines().skip_math_blocks().into_iter().collect();
1059
1060 assert!(
1062 lines.iter().any(|l| l.content.contains("# Heading")),
1063 "Should include markdown headings"
1064 );
1065 assert!(
1066 lines.iter().any(|l| l.content.contains("Some regular text")),
1067 "Should include regular text before math block"
1068 );
1069 assert!(
1070 lines.iter().any(|l| l.content.contains("More content after math")),
1071 "Should include content after math block"
1072 );
1073
1074 assert!(
1076 !lines.iter().any(|l| l.content == "$$"),
1077 "Should exclude math block delimiters"
1078 );
1079 assert!(
1080 !lines.iter().any(|l| l.content.contains("\\left[")),
1081 "Should exclude LaTeX content inside math block"
1082 );
1083 assert!(
1084 !lines.iter().any(|l| l.content.contains("-D")),
1085 "Should exclude content that looks like list items inside math block"
1086 );
1087 assert!(
1088 !lines.iter().any(|l| l.content.contains("\\begin{array}")),
1089 "Should exclude LaTeX array content"
1090 );
1091 }
1092
1093 #[test]
1094 fn test_math_blocks_not_confused_with_code_blocks() {
1095 let content = r#"# Title
1096
1097```python
1098# This $$ is inside a code block
1099x = 1
1100```
1101
1102$$
1103y = 2
1104$$
1105
1106Regular text."#;
1107 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1108
1109 let lines: Vec<_> = ctx.filtered_lines().skip_math_blocks().into_iter().collect();
1111
1112 assert!(
1115 lines.iter().any(|l| l.content.contains("# This $$")),
1116 "Code block content with $$ should not be detected as math block"
1117 );
1118
1119 assert!(
1121 !lines.iter().any(|l| l.content == "y = 2"),
1122 "Actual math block content should be excluded"
1123 );
1124 }
1125
1126 #[test]
1127 fn test_skip_quarto_divs() {
1128 let content = r#"# Heading
1129
1130::: {.callout-note}
1131This is a callout note.
1132With multiple lines.
1133:::
1134
1135Regular text outside.
1136
1137::: {.bordered}
1138Content inside bordered div.
1139:::
1140
1141More content."#;
1142 let ctx = LintContext::new(content, MarkdownFlavor::Quarto, None);
1143 let lines: Vec<_> = ctx.filtered_lines().skip_quarto_divs().into_iter().collect();
1144
1145 assert!(
1147 lines.iter().any(|l| l.content.contains("# Heading")),
1148 "Should include markdown headings"
1149 );
1150 assert!(
1151 lines.iter().any(|l| l.content.contains("Regular text outside")),
1152 "Should include content between divs"
1153 );
1154 assert!(
1155 lines.iter().any(|l| l.content.contains("More content")),
1156 "Should include content after divs"
1157 );
1158
1159 assert!(
1161 !lines.iter().any(|l| l.content.contains("::: {.callout-note}")),
1162 "Should exclude callout div markers"
1163 );
1164 assert!(
1165 !lines.iter().any(|l| l.content.contains("This is a callout note")),
1166 "Should exclude callout content"
1167 );
1168 assert!(
1169 !lines.iter().any(|l| l.content.contains("Content inside bordered")),
1170 "Should exclude bordered div content"
1171 );
1172 }
1173
1174 #[test]
1175 fn test_skip_jsx_expressions() {
1176 let content = r#"# MDX Document
1177
1178Here is some content with {myVariable} inline.
1179
1180{items.map(item => (
1181 <Item key={item.id} />
1182))}
1183
1184Regular paragraph after expression.
1185
1186{/* This should NOT be skipped by jsx_expressions filter */}
1187{/* MDX comments have their own filter */}
1188
1189More content."#;
1190 let ctx = LintContext::new(content, MarkdownFlavor::MDX, None);
1191 let lines: Vec<_> = ctx.filtered_lines().skip_jsx_expressions().into_iter().collect();
1192
1193 assert!(
1195 lines.iter().any(|l| l.content.contains("# MDX Document")),
1196 "Should include markdown headings"
1197 );
1198 assert!(
1199 lines.iter().any(|l| l.content.contains("Regular paragraph")),
1200 "Should include regular paragraphs"
1201 );
1202 assert!(
1203 lines.iter().any(|l| l.content.contains("More content")),
1204 "Should include content after expressions"
1205 );
1206
1207 assert!(
1209 !lines.iter().any(|l| l.content.contains("{myVariable}")),
1210 "Should exclude lines with inline JSX expressions"
1211 );
1212 assert!(
1213 !lines.iter().any(|l| l.content.contains("items.map")),
1214 "Should exclude multi-line JSX expression content"
1215 );
1216 assert!(
1217 !lines.iter().any(|l| l.content.contains("<Item key")),
1218 "Should exclude JSX inside expressions"
1219 );
1220 }
1221
1222 #[test]
1223 fn test_skip_quarto_divs_nested() {
1224 let content = r#"# Title
1225
1226::: {.outer}
1227Outer content.
1228
1229::: {.inner}
1230Inner content.
1231:::
1232
1233Back to outer.
1234:::
1235
1236Outside text."#;
1237 let ctx = LintContext::new(content, MarkdownFlavor::Quarto, None);
1238 let lines: Vec<_> = ctx.filtered_lines().skip_quarto_divs().into_iter().collect();
1239
1240 assert!(
1242 lines.iter().any(|l| l.content.contains("# Title")),
1243 "Should include heading"
1244 );
1245 assert!(
1246 lines.iter().any(|l| l.content.contains("Outside text")),
1247 "Should include text after divs"
1248 );
1249
1250 assert!(
1252 !lines.iter().any(|l| l.content.contains("Outer content")),
1253 "Should exclude outer div content"
1254 );
1255 assert!(
1256 !lines.iter().any(|l| l.content.contains("Inner content")),
1257 "Should exclude inner div content"
1258 );
1259 }
1260
1261 #[test]
1262 fn test_skip_quarto_divs_not_in_standard_flavor() {
1263 let content = r#"::: {.callout-note}
1264This should NOT be skipped in standard flavor.
1265:::"#;
1266 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1267 let lines: Vec<_> = ctx.filtered_lines().skip_quarto_divs().into_iter().collect();
1268
1269 assert!(
1271 lines.iter().any(|l| l.content.contains("This should NOT be skipped")),
1272 "Standard flavor should not detect Quarto divs"
1273 );
1274 }
1275
1276 #[test]
1277 fn test_skip_mdx_comments() {
1278 let content = r#"# MDX Document
1279
1280{/* This is an MDX comment */}
1281
1282Regular content here.
1283
1284{/*
1285 Multi-line
1286 MDX comment
1287*/}
1288
1289More content after comment."#;
1290 let ctx = LintContext::new(content, MarkdownFlavor::MDX, None);
1291 let lines: Vec<_> = ctx.filtered_lines().skip_mdx_comments().into_iter().collect();
1292
1293 assert!(
1295 lines.iter().any(|l| l.content.contains("# MDX Document")),
1296 "Should include markdown headings"
1297 );
1298 assert!(
1299 lines.iter().any(|l| l.content.contains("Regular content")),
1300 "Should include regular content"
1301 );
1302 assert!(
1303 lines.iter().any(|l| l.content.contains("More content")),
1304 "Should include content after comments"
1305 );
1306
1307 assert!(
1309 !lines.iter().any(|l| l.content.contains("{/* This is")),
1310 "Should exclude single-line MDX comments"
1311 );
1312 assert!(
1313 !lines.iter().any(|l| l.content.contains("Multi-line")),
1314 "Should exclude multi-line MDX comment content"
1315 );
1316 }
1317
1318 #[test]
1319 fn test_jsx_expressions_with_nested_braces() {
1320 let content = r#"# Document
1322
1323{props.style || {color: "red", background: "blue"}}
1324
1325Regular content."#;
1326 let ctx = LintContext::new(content, MarkdownFlavor::MDX, None);
1327 let lines: Vec<_> = ctx.filtered_lines().skip_jsx_expressions().into_iter().collect();
1328
1329 assert!(
1331 !lines.iter().any(|l| l.content.contains("props.style")),
1332 "Should exclude JSX expression with nested braces"
1333 );
1334 assert!(
1335 lines.iter().any(|l| l.content.contains("Regular content")),
1336 "Should include content after nested expression"
1337 );
1338 }
1339
1340 #[test]
1341 fn test_jsx_and_mdx_comments_combined() {
1342 let content = r#"# Title
1344
1345{variable}
1346
1347{/* comment */}
1348
1349Content."#;
1350 let ctx = LintContext::new(content, MarkdownFlavor::MDX, None);
1351 let lines: Vec<_> = ctx
1352 .filtered_lines()
1353 .skip_jsx_expressions()
1354 .skip_mdx_comments()
1355 .into_iter()
1356 .collect();
1357
1358 assert!(
1359 lines.iter().any(|l| l.content.contains("# Title")),
1360 "Should include heading"
1361 );
1362 assert!(
1363 lines.iter().any(|l| l.content.contains("Content")),
1364 "Should include regular content"
1365 );
1366 assert!(
1367 !lines.iter().any(|l| l.content.contains("{variable}")),
1368 "Should exclude JSX expression"
1369 );
1370 assert!(
1371 !lines.iter().any(|l| l.content.contains("{/* comment */")),
1372 "Should exclude MDX comment"
1373 );
1374 }
1375
1376 #[test]
1377 fn test_jsx_expressions_not_detected_in_standard_flavor() {
1378 let content = r#"# Document
1380
1381{this is not JSX in standard markdown}
1382
1383Content."#;
1384 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1385 let lines: Vec<_> = ctx.filtered_lines().skip_jsx_expressions().into_iter().collect();
1386
1387 assert!(
1389 lines.iter().any(|l| l.content.contains("{this is not JSX")),
1390 "Should NOT exclude brace content in standard markdown"
1391 );
1392 }
1393
1394 #[test]
1397 fn test_skip_obsidian_comments_simple_inline() {
1398 let content = r#"# Heading
1400
1401This is visible %%this is hidden%% and visible again.
1402
1403More content."#;
1404 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1405 let lines: Vec<_> = ctx.filtered_lines().skip_obsidian_comments().into_iter().collect();
1406
1407 assert!(
1409 lines.iter().any(|l| l.content.contains("# Heading")),
1410 "Should include heading"
1411 );
1412 assert!(
1413 lines.iter().any(|l| l.content.contains("This is visible")),
1414 "Should include line with inline comment"
1415 );
1416 assert!(
1417 lines.iter().any(|l| l.content.contains("More content")),
1418 "Should include content after comment"
1419 );
1420 }
1421
1422 #[test]
1423 fn test_skip_obsidian_comments_multiline_block() {
1424 let content = r#"# Heading
1426
1427%%
1428This is a multi-line
1429comment block
1430%%
1431
1432Content after."#;
1433 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1434 let lines: Vec<_> = ctx.filtered_lines().skip_obsidian_comments().into_iter().collect();
1435
1436 assert!(
1438 lines.iter().any(|l| l.content.contains("# Heading")),
1439 "Should include heading"
1440 );
1441 assert!(
1442 lines.iter().any(|l| l.content.contains("Content after")),
1443 "Should include content after comment block"
1444 );
1445
1446 assert!(
1448 !lines.iter().any(|l| l.content.contains("This is a multi-line")),
1449 "Should exclude multi-line comment content"
1450 );
1451 assert!(
1452 !lines.iter().any(|l| l.content.contains("comment block")),
1453 "Should exclude multi-line comment content"
1454 );
1455 }
1456
1457 #[test]
1458 fn test_skip_obsidian_comments_in_code_block() {
1459 let content = r#"# Heading
1461
1462```
1463%% This is NOT a comment
1464It's inside a code block
1465%%
1466```
1467
1468Content."#;
1469 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1470 let lines: Vec<_> = ctx
1471 .filtered_lines()
1472 .skip_obsidian_comments()
1473 .skip_code_blocks()
1474 .into_iter()
1475 .collect();
1476
1477 assert!(
1479 lines.iter().any(|l| l.content.contains("# Heading")),
1480 "Should include heading"
1481 );
1482 assert!(
1483 lines.iter().any(|l| l.content.contains("Content")),
1484 "Should include content after code block"
1485 );
1486 }
1487
1488 #[test]
1489 fn test_skip_obsidian_comments_in_html_comment() {
1490 let content = r#"# Heading
1492
1493<!-- %% This is inside HTML comment %% -->
1494
1495Content."#;
1496 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1497 let lines: Vec<_> = ctx
1498 .filtered_lines()
1499 .skip_obsidian_comments()
1500 .skip_html_comments()
1501 .into_iter()
1502 .collect();
1503
1504 assert!(
1505 lines.iter().any(|l| l.content.contains("# Heading")),
1506 "Should include heading"
1507 );
1508 assert!(
1509 lines.iter().any(|l| l.content.contains("Content")),
1510 "Should include content"
1511 );
1512 }
1513
1514 #[test]
1515 fn test_skip_obsidian_comments_empty() {
1516 let content = r#"# Heading
1518
1519%%%% empty comment
1520
1521Content."#;
1522 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1523 let lines: Vec<_> = ctx.filtered_lines().skip_obsidian_comments().into_iter().collect();
1524
1525 assert!(
1527 lines.iter().any(|l| l.content.contains("# Heading")),
1528 "Should include heading"
1529 );
1530 }
1531
1532 #[test]
1533 fn test_skip_obsidian_comments_unclosed() {
1534 let content = r#"# Heading
1536
1537%% starts but never ends
1538This should be hidden
1539Until end of document"#;
1540 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1541 let lines: Vec<_> = ctx.filtered_lines().skip_obsidian_comments().into_iter().collect();
1542
1543 assert!(
1545 lines.iter().any(|l| l.content.contains("# Heading")),
1546 "Should include heading before unclosed comment"
1547 );
1548
1549 assert!(
1551 !lines.iter().any(|l| l.content.contains("This should be hidden")),
1552 "Should exclude content in unclosed comment"
1553 );
1554 assert!(
1555 !lines.iter().any(|l| l.content.contains("Until end of document")),
1556 "Should exclude content until end of document"
1557 );
1558 }
1559
1560 #[test]
1561 fn test_skip_obsidian_comments_multiple_on_same_line() {
1562 let content = r#"# Heading
1564
1565First %%hidden1%% middle %%hidden2%% last
1566
1567Content."#;
1568 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1569 let lines: Vec<_> = ctx.filtered_lines().skip_obsidian_comments().into_iter().collect();
1570
1571 assert!(
1573 lines.iter().any(|l| l.content.contains("First")),
1574 "Should include line with multiple inline comments"
1575 );
1576 assert!(
1577 lines.iter().any(|l| l.content.contains("middle")),
1578 "Should include visible text between comments"
1579 );
1580 }
1581
1582 #[test]
1583 fn test_skip_obsidian_comments_at_start_of_line() {
1584 let content = r#"# Heading
1586
1587%%comment at start%%
1588
1589Content."#;
1590 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1591 let lines: Vec<_> = ctx.filtered_lines().skip_obsidian_comments().into_iter().collect();
1592
1593 assert!(
1594 lines.iter().any(|l| l.content.contains("# Heading")),
1595 "Should include heading"
1596 );
1597 assert!(
1598 lines.iter().any(|l| l.content.contains("Content")),
1599 "Should include content"
1600 );
1601 }
1602
1603 #[test]
1604 fn test_skip_obsidian_comments_at_end_of_line() {
1605 let content = r#"# Heading
1607
1608Some text %%comment at end%%
1609
1610Content."#;
1611 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1612 let lines: Vec<_> = ctx.filtered_lines().skip_obsidian_comments().into_iter().collect();
1613
1614 assert!(
1615 lines.iter().any(|l| l.content.contains("Some text")),
1616 "Should include text before comment"
1617 );
1618 }
1619
1620 #[test]
1621 fn test_skip_obsidian_comments_with_markdown_inside() {
1622 let content = r#"# Heading
1624
1625%%
1626# hidden heading
1627[hidden link](url)
1628**hidden bold**
1629%%
1630
1631Content."#;
1632 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1633 let lines: Vec<_> = ctx.filtered_lines().skip_obsidian_comments().into_iter().collect();
1634
1635 assert!(
1636 !lines.iter().any(|l| l.content.contains("# hidden heading")),
1637 "Should exclude heading inside comment"
1638 );
1639 assert!(
1640 !lines.iter().any(|l| l.content.contains("[hidden link]")),
1641 "Should exclude link inside comment"
1642 );
1643 assert!(
1644 !lines.iter().any(|l| l.content.contains("**hidden bold**")),
1645 "Should exclude bold inside comment"
1646 );
1647 }
1648
1649 #[test]
1650 fn test_skip_obsidian_comments_with_unicode() {
1651 let content = r#"# Heading
1653
1654%%日本語コメント%%
1655
1656%%Комментарий%%
1657
1658Content."#;
1659 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1660 let lines: Vec<_> = ctx.filtered_lines().skip_obsidian_comments().into_iter().collect();
1661
1662 assert!(
1664 lines.iter().any(|l| l.content.contains("# Heading")),
1665 "Should include heading"
1666 );
1667 assert!(
1668 lines.iter().any(|l| l.content.contains("Content")),
1669 "Should include content"
1670 );
1671 }
1672
1673 #[test]
1674 fn test_skip_obsidian_comments_triple_percent() {
1675 let content = r#"# Heading
1677
1678%%% odd percent
1679
1680Content."#;
1681 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1682 let lines: Vec<_> = ctx.filtered_lines().skip_obsidian_comments().into_iter().collect();
1683
1684 assert!(
1686 lines.iter().any(|l| l.content.contains("# Heading")),
1687 "Should include heading"
1688 );
1689 }
1690
1691 #[test]
1692 fn test_skip_obsidian_comments_not_in_standard_flavor() {
1693 let content = r#"# Heading
1695
1696%%this is not hidden in standard%%
1697
1698Content."#;
1699 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1700 let lines: Vec<_> = ctx.filtered_lines().skip_obsidian_comments().into_iter().collect();
1701
1702 assert!(
1704 lines.iter().any(|l| l.content.contains("%%this is not hidden")),
1705 "Should NOT hide %% content in Standard flavor"
1706 );
1707 }
1708
1709 #[test]
1710 fn test_skip_obsidian_comments_integration_with_other_filters() {
1711 let content = r#"---
1713title: Test
1714---
1715
1716# Heading
1717
1718```
1719code
1720```
1721
1722%%hidden comment%%
1723
1724Content."#;
1725 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1726 let lines: Vec<_> = ctx
1727 .filtered_lines()
1728 .skip_front_matter()
1729 .skip_code_blocks()
1730 .skip_obsidian_comments()
1731 .into_iter()
1732 .collect();
1733
1734 assert!(
1736 !lines.iter().any(|l| l.content.contains("title: Test")),
1737 "Should skip frontmatter"
1738 );
1739 assert!(
1740 !lines.iter().any(|l| l.content == "code"),
1741 "Should skip code block content"
1742 );
1743 assert!(
1744 lines.iter().any(|l| l.content.contains("# Heading")),
1745 "Should include heading"
1746 );
1747 assert!(
1748 lines.iter().any(|l| l.content.contains("Content")),
1749 "Should include content"
1750 );
1751 }
1752
1753 #[test]
1754 fn test_skip_obsidian_comments_whole_line_only() {
1755 let content = "start %%\nfully hidden\n%% end";
1757 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1758 let lines: Vec<_> = ctx.filtered_lines().skip_obsidian_comments().into_iter().collect();
1759
1760 assert!(
1762 lines.iter().any(|l| l.content.contains("start")),
1763 "First line should be included (starts outside comment)"
1764 );
1765 assert!(
1767 !lines.iter().any(|l| l.content == "fully hidden"),
1768 "Middle line should be excluded (entirely within comment)"
1769 );
1770 assert!(
1772 lines.iter().any(|l| l.content.contains("end")),
1773 "Last line should be included (ends outside comment)"
1774 );
1775 }
1776
1777 #[test]
1778 fn test_skip_obsidian_comments_in_inline_code() {
1779 let content = r#"# Heading
1781
1782The syntax is `%%comment%%` in Obsidian.
1783
1784Content."#;
1785 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1786 let lines: Vec<_> = ctx.filtered_lines().skip_obsidian_comments().into_iter().collect();
1787
1788 assert!(
1790 lines.iter().any(|l| l.content.contains("The syntax is")),
1791 "Should include line with %% in code span"
1792 );
1793 assert!(
1794 lines.iter().any(|l| l.content.contains("in Obsidian")),
1795 "Should include text after code span"
1796 );
1797 }
1798
1799 #[test]
1800 fn test_skip_obsidian_comments_in_inline_code_multi_backtick() {
1801 let content = r#"# Heading
1803
1804The syntax is ``%%comment%%`` in Obsidian.
1805
1806Content."#;
1807 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1808 let lines: Vec<_> = ctx.filtered_lines().skip_obsidian_comments().into_iter().collect();
1809
1810 assert!(
1811 lines.iter().any(|l| l.content.contains("The syntax is")),
1812 "Should include line with %% in multi-backtick code span"
1813 );
1814 assert!(
1815 lines.iter().any(|l| l.content.contains("Content")),
1816 "Should include content after code span"
1817 );
1818 }
1819
1820 #[test]
1821 fn test_skip_obsidian_comments_consecutive_blocks() {
1822 let content = r#"# Heading
1824
1825%%comment 1%%
1826
1827%%comment 2%%
1828
1829Content."#;
1830 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1831 let lines: Vec<_> = ctx.filtered_lines().skip_obsidian_comments().into_iter().collect();
1832
1833 assert!(
1834 lines.iter().any(|l| l.content.contains("# Heading")),
1835 "Should include heading"
1836 );
1837 assert!(
1838 lines.iter().any(|l| l.content.contains("Content")),
1839 "Should include content after comments"
1840 );
1841 }
1842
1843 #[test]
1844 fn test_skip_obsidian_comments_spanning_many_lines() {
1845 let content = r#"# Title
1847
1848%%
1849Line 1 of comment
1850Line 2 of comment
1851Line 3 of comment
1852Line 4 of comment
1853Line 5 of comment
1854%%
1855
1856After comment."#;
1857 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1858 let lines: Vec<_> = ctx.filtered_lines().skip_obsidian_comments().into_iter().collect();
1859
1860 for i in 1..=5 {
1862 assert!(
1863 !lines
1864 .iter()
1865 .any(|l| l.content.contains(&format!("Line {i} of comment"))),
1866 "Should exclude line {i} of comment"
1867 );
1868 }
1869
1870 assert!(
1871 lines.iter().any(|l| l.content.contains("# Title")),
1872 "Should include title"
1873 );
1874 assert!(
1875 lines.iter().any(|l| l.content.contains("After comment")),
1876 "Should include content after comment"
1877 );
1878 }
1879
1880 #[test]
1881 fn test_obsidian_comment_line_info_field() {
1882 let content = "visible\n%%\nhidden\n%%\nvisible";
1884 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1885
1886 assert!(
1888 !ctx.lines[0].in_obsidian_comment,
1889 "Line 0 should not be marked as in_obsidian_comment"
1890 );
1891
1892 assert!(
1894 ctx.lines[2].in_obsidian_comment,
1895 "Line 2 (hidden) should be marked as in_obsidian_comment"
1896 );
1897
1898 assert!(
1900 !ctx.lines[4].in_obsidian_comment,
1901 "Line 4 should not be marked as in_obsidian_comment"
1902 );
1903 }
1904
1905 #[test]
1908 fn test_skip_pymdown_blocks_basic() {
1909 let content = r#"# Heading
1911
1912/// caption
1913Table caption here.
1914///
1915
1916Content after."#;
1917 let ctx = LintContext::new(content, MarkdownFlavor::MkDocs, None);
1918 let lines: Vec<_> = ctx.filtered_lines().skip_pymdown_blocks().into_iter().collect();
1919
1920 assert!(
1922 lines.iter().any(|l| l.content.contains("# Heading")),
1923 "Should include heading"
1924 );
1925 assert!(
1926 lines.iter().any(|l| l.content.contains("Content after")),
1927 "Should include content after block"
1928 );
1929
1930 assert!(
1932 !lines.iter().any(|l| l.content.contains("Table caption")),
1933 "Should exclude content inside block"
1934 );
1935 }
1936
1937 #[test]
1938 fn test_skip_pymdown_blocks_details() {
1939 let content = r#"# Heading
1941
1942/// details | Click to expand
1943 open: True
1944Hidden content here.
1945More hidden content.
1946///
1947
1948Visible content."#;
1949 let ctx = LintContext::new(content, MarkdownFlavor::MkDocs, None);
1950 let lines: Vec<_> = ctx.filtered_lines().skip_pymdown_blocks().into_iter().collect();
1951
1952 assert!(
1953 !lines.iter().any(|l| l.content.contains("Hidden content")),
1954 "Should exclude hidden content"
1955 );
1956 assert!(
1957 !lines.iter().any(|l| l.content.contains("open: True")),
1958 "Should exclude YAML options"
1959 );
1960 assert!(
1961 lines.iter().any(|l| l.content.contains("Visible content")),
1962 "Should include visible content"
1963 );
1964 }
1965
1966 #[test]
1967 fn test_skip_pymdown_blocks_nested() {
1968 let content = r#"# Title
1970
1971/// details | Outer
1972Outer content.
1973
1974 /// caption
1975 Inner caption.
1976 ///
1977
1978More outer content.
1979///
1980
1981After all blocks."#;
1982 let ctx = LintContext::new(content, MarkdownFlavor::MkDocs, None);
1983 let lines: Vec<_> = ctx.filtered_lines().skip_pymdown_blocks().into_iter().collect();
1984
1985 assert!(
1986 !lines.iter().any(|l| l.content.contains("Outer content")),
1987 "Should exclude outer block content"
1988 );
1989 assert!(
1990 !lines.iter().any(|l| l.content.contains("Inner caption")),
1991 "Should exclude inner block content"
1992 );
1993 assert!(
1994 lines.iter().any(|l| l.content.contains("After all blocks")),
1995 "Should include content after all blocks"
1996 );
1997 }
1998
1999 #[test]
2000 fn test_pymdown_block_line_info_field() {
2001 let content = "visible\n/// caption\nhidden\n///\nvisible";
2003 let ctx = LintContext::new(content, MarkdownFlavor::MkDocs, None);
2004
2005 assert!(
2007 !ctx.lines[0].in_pymdown_block,
2008 "Line 0 should not be marked as in_pymdown_block"
2009 );
2010
2011 assert!(
2013 ctx.lines[1].in_pymdown_block,
2014 "Line 1 (/// caption) should be marked as in_pymdown_block"
2015 );
2016
2017 assert!(
2019 ctx.lines[2].in_pymdown_block,
2020 "Line 2 (hidden) should be marked as in_pymdown_block"
2021 );
2022
2023 assert!(
2025 ctx.lines[3].in_pymdown_block,
2026 "Line 3 (closing ///) should be marked as in_pymdown_block"
2027 );
2028
2029 assert!(
2031 !ctx.lines[4].in_pymdown_block,
2032 "Line 4 should not be marked as in_pymdown_block"
2033 );
2034 }
2035
2036 #[test]
2037 fn test_pymdown_blocks_only_for_mkdocs_flavor() {
2038 let content = "/// caption\nCaption text\n///";
2040
2041 let ctx_mkdocs = LintContext::new(content, MarkdownFlavor::MkDocs, None);
2043 assert!(
2044 ctx_mkdocs.lines[1].in_pymdown_block,
2045 "MkDocs flavor should detect pymdown blocks"
2046 );
2047
2048 let ctx_standard = LintContext::new(content, MarkdownFlavor::Standard, None);
2050 assert!(
2051 !ctx_standard.lines[1].in_pymdown_block,
2052 "Standard flavor should NOT detect pymdown blocks"
2053 );
2054 }
2055}