1use crate::lint_context::{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}
103
104impl LineFilterConfig {
105 #[must_use]
107 pub fn new() -> Self {
108 Self::default()
109 }
110
111 #[must_use]
116 pub fn skip_front_matter(mut self) -> Self {
117 self.skip_front_matter = true;
118 self
119 }
120
121 #[must_use]
126 pub fn skip_code_blocks(mut self) -> Self {
127 self.skip_code_blocks = true;
128 self
129 }
130
131 #[must_use]
136 pub fn skip_html_blocks(mut self) -> Self {
137 self.skip_html_blocks = true;
138 self
139 }
140
141 #[must_use]
146 pub fn skip_html_comments(mut self) -> Self {
147 self.skip_html_comments = true;
148 self
149 }
150
151 #[must_use]
156 pub fn skip_mkdocstrings(mut self) -> Self {
157 self.skip_mkdocstrings = true;
158 self
159 }
160
161 #[must_use]
166 pub fn skip_esm_blocks(mut self) -> Self {
167 self.skip_esm_blocks = true;
168 self
169 }
170
171 #[must_use]
176 pub fn skip_math_blocks(mut self) -> Self {
177 self.skip_math_blocks = true;
178 self
179 }
180
181 #[must_use]
186 pub fn skip_quarto_divs(mut self) -> Self {
187 self.skip_quarto_divs = true;
188 self
189 }
190
191 #[must_use]
196 pub fn skip_jsx_expressions(mut self) -> Self {
197 self.skip_jsx_expressions = true;
198 self
199 }
200
201 #[must_use]
206 pub fn skip_mdx_comments(mut self) -> Self {
207 self.skip_mdx_comments = true;
208 self
209 }
210
211 #[must_use]
216 pub fn skip_admonitions(mut self) -> Self {
217 self.skip_admonitions = true;
218 self
219 }
220
221 #[must_use]
225 pub fn skip_content_tabs(mut self) -> Self {
226 self.skip_content_tabs = true;
227 self
228 }
229
230 #[must_use]
234 pub fn skip_mkdocs_html_markdown(mut self) -> Self {
235 self.skip_mkdocs_html_markdown = true;
236 self
237 }
238
239 #[must_use]
245 pub fn skip_mkdocs_containers(mut self) -> Self {
246 self.skip_admonitions = true;
247 self.skip_content_tabs = true;
248 self.skip_mkdocs_html_markdown = true;
249 self
250 }
251
252 #[must_use]
257 pub fn skip_definition_lists(mut self) -> Self {
258 self.skip_definition_lists = true;
259 self
260 }
261
262 #[must_use]
267 pub fn skip_obsidian_comments(mut self) -> Self {
268 self.skip_obsidian_comments = true;
269 self
270 }
271
272 #[must_use]
278 pub fn skip_pymdown_blocks(mut self) -> Self {
279 self.skip_pymdown_blocks = true;
280 self
281 }
282
283 #[must_use]
289 pub fn skip_div_markers(mut self) -> Self {
290 self.skip_div_markers = true;
291 self
292 }
293
294 fn should_filter(&self, line_info: &LineInfo) -> bool {
296 (self.skip_front_matter && line_info.in_front_matter)
297 || (self.skip_code_blocks && line_info.in_code_block)
298 || (self.skip_html_blocks && line_info.in_html_block)
299 || (self.skip_html_comments && line_info.in_html_comment)
300 || (self.skip_mkdocstrings && line_info.in_mkdocstrings)
301 || (self.skip_esm_blocks && line_info.in_esm_block)
302 || (self.skip_math_blocks && line_info.in_math_block)
303 || (self.skip_quarto_divs && line_info.in_quarto_div)
304 || (self.skip_jsx_expressions && line_info.in_jsx_expression)
305 || (self.skip_mdx_comments && line_info.in_mdx_comment)
306 || (self.skip_admonitions && line_info.in_admonition)
307 || (self.skip_content_tabs && line_info.in_content_tab)
308 || (self.skip_mkdocs_html_markdown && line_info.in_mkdocs_html_markdown)
309 || (self.skip_definition_lists && line_info.in_definition_list)
310 || (self.skip_obsidian_comments && line_info.in_obsidian_comment)
311 || (self.skip_pymdown_blocks && line_info.in_pymdown_block)
312 || (self.skip_div_markers && line_info.is_div_marker)
313 }
314}
315
316pub struct FilteredLinesIter<'a> {
318 ctx: &'a LintContext<'a>,
319 config: LineFilterConfig,
320 current_index: usize,
321}
322
323impl<'a> FilteredLinesIter<'a> {
324 fn new(ctx: &'a LintContext<'a>, config: LineFilterConfig) -> Self {
326 Self {
327 ctx,
328 config,
329 current_index: 0,
330 }
331 }
332}
333
334impl<'a> Iterator for FilteredLinesIter<'a> {
335 type Item = FilteredLine<'a>;
336
337 fn next(&mut self) -> Option<Self::Item> {
338 let lines = &self.ctx.lines;
339 let raw_lines = self.ctx.raw_lines();
340
341 while self.current_index < lines.len() {
342 let idx = self.current_index;
343 self.current_index += 1;
344
345 if self.config.should_filter(&lines[idx]) {
347 continue;
348 }
349
350 let line_content = raw_lines.get(idx).copied().unwrap_or("");
352
353 return Some(FilteredLine {
355 line_num: idx + 1, line_info: &lines[idx],
357 content: line_content,
358 });
359 }
360
361 None
362 }
363}
364
365pub trait FilteredLinesExt {
370 fn filtered_lines(&self) -> FilteredLinesBuilder<'_>;
389
390 fn content_lines(&self) -> FilteredLinesIter<'_>;
413}
414
415pub struct FilteredLinesBuilder<'a> {
417 ctx: &'a LintContext<'a>,
418 config: LineFilterConfig,
419}
420
421impl<'a> FilteredLinesBuilder<'a> {
422 fn new(ctx: &'a LintContext<'a>) -> Self {
423 Self {
424 ctx,
425 config: LineFilterConfig::new(),
426 }
427 }
428
429 #[must_use]
431 pub fn skip_front_matter(mut self) -> Self {
432 self.config = self.config.skip_front_matter();
433 self
434 }
435
436 #[must_use]
438 pub fn skip_code_blocks(mut self) -> Self {
439 self.config = self.config.skip_code_blocks();
440 self
441 }
442
443 #[must_use]
445 pub fn skip_html_blocks(mut self) -> Self {
446 self.config = self.config.skip_html_blocks();
447 self
448 }
449
450 #[must_use]
452 pub fn skip_html_comments(mut self) -> Self {
453 self.config = self.config.skip_html_comments();
454 self
455 }
456
457 #[must_use]
459 pub fn skip_mkdocstrings(mut self) -> Self {
460 self.config = self.config.skip_mkdocstrings();
461 self
462 }
463
464 #[must_use]
466 pub fn skip_esm_blocks(mut self) -> Self {
467 self.config = self.config.skip_esm_blocks();
468 self
469 }
470
471 #[must_use]
473 pub fn skip_math_blocks(mut self) -> Self {
474 self.config = self.config.skip_math_blocks();
475 self
476 }
477
478 #[must_use]
480 pub fn skip_quarto_divs(mut self) -> Self {
481 self.config = self.config.skip_quarto_divs();
482 self
483 }
484
485 #[must_use]
487 pub fn skip_jsx_expressions(mut self) -> Self {
488 self.config = self.config.skip_jsx_expressions();
489 self
490 }
491
492 #[must_use]
494 pub fn skip_mdx_comments(mut self) -> Self {
495 self.config = self.config.skip_mdx_comments();
496 self
497 }
498
499 #[must_use]
501 pub fn skip_admonitions(mut self) -> Self {
502 self.config = self.config.skip_admonitions();
503 self
504 }
505
506 #[must_use]
508 pub fn skip_content_tabs(mut self) -> Self {
509 self.config = self.config.skip_content_tabs();
510 self
511 }
512
513 #[must_use]
515 pub fn skip_mkdocs_html_markdown(mut self) -> Self {
516 self.config = self.config.skip_mkdocs_html_markdown();
517 self
518 }
519
520 #[must_use]
526 pub fn skip_mkdocs_containers(mut self) -> Self {
527 self.config = self.config.skip_mkdocs_containers();
528 self
529 }
530
531 #[must_use]
533 pub fn skip_definition_lists(mut self) -> Self {
534 self.config = self.config.skip_definition_lists();
535 self
536 }
537
538 #[must_use]
540 pub fn skip_obsidian_comments(mut self) -> Self {
541 self.config = self.config.skip_obsidian_comments();
542 self
543 }
544
545 #[must_use]
547 pub fn skip_pymdown_blocks(mut self) -> Self {
548 self.config = self.config.skip_pymdown_blocks();
549 self
550 }
551
552 #[must_use]
557 pub fn skip_div_markers(mut self) -> Self {
558 self.config = self.config.skip_div_markers();
559 self
560 }
561}
562
563impl<'a> IntoIterator for FilteredLinesBuilder<'a> {
564 type Item = FilteredLine<'a>;
565 type IntoIter = FilteredLinesIter<'a>;
566
567 fn into_iter(self) -> Self::IntoIter {
568 FilteredLinesIter::new(self.ctx, self.config)
569 }
570}
571
572impl<'a> FilteredLinesExt for LintContext<'a> {
573 fn filtered_lines(&self) -> FilteredLinesBuilder<'_> {
574 FilteredLinesBuilder::new(self)
575 }
576
577 fn content_lines(&self) -> FilteredLinesIter<'_> {
578 FilteredLinesIter::new(self, LineFilterConfig::new().skip_front_matter())
579 }
580}
581
582#[cfg(test)]
583mod tests {
584 use super::*;
585 use crate::config::MarkdownFlavor;
586
587 #[test]
588 fn test_filtered_line_structure() {
589 let content = "# Title\n\nContent";
590 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
591
592 let line = ctx.content_lines().next().unwrap();
593 assert_eq!(line.line_num, 1);
594 assert_eq!(line.content, "# Title");
595 assert!(!line.line_info.in_front_matter);
596 }
597
598 #[test]
599 fn test_skip_front_matter_yaml() {
600 let content = "---\ntitle: Test\nurl: http://example.com\n---\n\n# Content\n\nMore content";
601 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
602
603 let lines: Vec<_> = ctx.content_lines().collect();
604 assert_eq!(lines.len(), 4);
606 assert_eq!(lines[0].line_num, 5); assert_eq!(lines[0].content, "");
608 assert_eq!(lines[1].line_num, 6);
609 assert_eq!(lines[1].content, "# Content");
610 assert_eq!(lines[2].line_num, 7);
611 assert_eq!(lines[2].content, "");
612 assert_eq!(lines[3].line_num, 8);
613 assert_eq!(lines[3].content, "More content");
614 }
615
616 #[test]
617 fn test_skip_front_matter_toml() {
618 let content = "+++\ntitle = \"Test\"\nurl = \"http://example.com\"\n+++\n\n# Content";
619 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
620
621 let lines: Vec<_> = ctx.content_lines().collect();
622 assert_eq!(lines.len(), 2); assert_eq!(lines[0].line_num, 5);
624 assert_eq!(lines[1].line_num, 6);
625 assert_eq!(lines[1].content, "# Content");
626 }
627
628 #[test]
629 fn test_skip_front_matter_json() {
630 let content = "{\n\"title\": \"Test\",\n\"url\": \"http://example.com\"\n}\n\n# Content";
631 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
632
633 let lines: Vec<_> = ctx.content_lines().collect();
634 assert_eq!(lines.len(), 2); assert_eq!(lines[0].line_num, 5);
636 assert_eq!(lines[1].line_num, 6);
637 assert_eq!(lines[1].content, "# Content");
638 }
639
640 #[test]
641 fn test_skip_code_blocks() {
642 let content = "# Title\n\n```rust\nlet x = 1;\nlet y = 2;\n```\n\nContent";
643 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
644
645 let lines: Vec<_> = ctx.filtered_lines().skip_code_blocks().into_iter().collect();
646
647 assert!(lines.iter().any(|l| l.content == "# Title"));
652 assert!(lines.iter().any(|l| l.content == "Content"));
653 assert!(!lines.iter().any(|l| l.content == "let x = 1;"));
655 assert!(!lines.iter().any(|l| l.content == "let y = 2;"));
656 }
657
658 #[test]
659 fn test_no_filters() {
660 let content = "---\ntitle: Test\n---\n\n# Content";
661 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
662
663 let lines: Vec<_> = ctx.filtered_lines().into_iter().collect();
665 assert_eq!(lines.len(), ctx.lines.len());
666 }
667
668 #[test]
669 fn test_multiple_filters() {
670 let content = "---\ntitle: Test\n---\n\n# Title\n\n```rust\ncode\n```\n\nContent";
671 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
672
673 let lines: Vec<_> = ctx
674 .filtered_lines()
675 .skip_front_matter()
676 .skip_code_blocks()
677 .into_iter()
678 .collect();
679
680 assert!(lines.iter().any(|l| l.content == "# Title"));
682 assert!(lines.iter().any(|l| l.content == "Content"));
683 assert!(!lines.iter().any(|l| l.content == "title: Test"));
684 assert!(!lines.iter().any(|l| l.content == "code"));
685 }
686
687 #[test]
688 fn test_line_numbering_is_1_indexed() {
689 let content = "First\nSecond\nThird";
690 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
691
692 let lines: Vec<_> = ctx.content_lines().collect();
693 assert_eq!(lines[0].line_num, 1);
694 assert_eq!(lines[0].content, "First");
695 assert_eq!(lines[1].line_num, 2);
696 assert_eq!(lines[1].content, "Second");
697 assert_eq!(lines[2].line_num, 3);
698 assert_eq!(lines[2].content, "Third");
699 }
700
701 #[test]
702 fn test_content_lines_convenience_method() {
703 let content = "---\nfoo: bar\n---\n\nContent";
704 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
705
706 let lines: Vec<_> = ctx.content_lines().collect();
708 assert!(!lines.iter().any(|l| l.content.contains("foo")));
709 assert!(lines.iter().any(|l| l.content == "Content"));
710 }
711
712 #[test]
713 fn test_empty_document() {
714 let content = "";
715 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
716
717 let lines: Vec<_> = ctx.content_lines().collect();
718 assert_eq!(lines.len(), 0);
719 }
720
721 #[test]
722 fn test_only_front_matter() {
723 let content = "---\ntitle: Test\n---";
724 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
725
726 let lines: Vec<_> = ctx.content_lines().collect();
727 assert_eq!(
728 lines.len(),
729 0,
730 "Document with only front matter should have no content lines"
731 );
732 }
733
734 #[test]
735 fn test_builder_pattern_ergonomics() {
736 let content = "# Title\n\n```\ncode\n```\n\nContent";
737 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
738
739 let _lines: Vec<_> = ctx
741 .filtered_lines()
742 .skip_front_matter()
743 .skip_code_blocks()
744 .skip_html_blocks()
745 .into_iter()
746 .collect();
747
748 }
750
751 #[test]
752 fn test_filtered_line_access_to_line_info() {
753 let content = "# Title\n\nContent";
754 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
755
756 for line in ctx.content_lines() {
757 assert!(!line.line_info.in_front_matter);
759 assert!(!line.line_info.in_code_block);
760 }
761 }
762
763 #[test]
764 fn test_skip_mkdocstrings() {
765 let content = r#"# API Documentation
766
767::: mymodule.MyClass
768 options:
769 show_root_heading: true
770 show_source: false
771
772Some regular content here.
773
774::: mymodule.function
775 options:
776 show_signature: true
777
778More content."#;
779 let ctx = LintContext::new(content, MarkdownFlavor::MkDocs, None);
780 let lines: Vec<_> = ctx.filtered_lines().skip_mkdocstrings().into_iter().collect();
781
782 assert!(
784 lines.iter().any(|l| l.content.contains("# API Documentation")),
785 "Should include lines outside mkdocstrings blocks"
786 );
787 assert!(
788 lines.iter().any(|l| l.content.contains("Some regular content")),
789 "Should include content between mkdocstrings blocks"
790 );
791 assert!(
792 lines.iter().any(|l| l.content.contains("More content")),
793 "Should include content after mkdocstrings blocks"
794 );
795
796 assert!(
798 !lines.iter().any(|l| l.content.contains("::: mymodule")),
799 "Should exclude mkdocstrings marker lines"
800 );
801 assert!(
802 !lines.iter().any(|l| l.content.contains("show_root_heading")),
803 "Should exclude mkdocstrings option lines"
804 );
805 assert!(
806 !lines.iter().any(|l| l.content.contains("show_signature")),
807 "Should exclude all mkdocstrings option lines"
808 );
809
810 assert_eq!(lines[0].line_num, 1, "First line should be line 1");
812 }
813
814 #[test]
815 fn test_skip_esm_blocks() {
816 let content = r#"import {Chart} from './components.js'
818import {Table} from './table.js'
819export const year = 2023
820
821# Last year's snowfall
822
823Content about snowfall data.
824
825import {Footer} from './footer.js'
826
827More content."#;
828 let ctx = LintContext::new(content, MarkdownFlavor::MDX, None);
829 let lines: Vec<_> = ctx.filtered_lines().skip_esm_blocks().into_iter().collect();
830
831 assert!(
833 lines.iter().any(|l| l.content.contains("# Last year's snowfall")),
834 "Should include markdown headings"
835 );
836 assert!(
837 lines.iter().any(|l| l.content.contains("Content about snowfall")),
838 "Should include markdown content"
839 );
840 assert!(
841 lines.iter().any(|l| l.content.contains("More content")),
842 "Should include content after ESM blocks"
843 );
844
845 assert!(
847 !lines.iter().any(|l| l.content.contains("import {Chart}")),
848 "Should exclude import statements at top of file"
849 );
850 assert!(
851 !lines.iter().any(|l| l.content.contains("import {Table}")),
852 "Should exclude all import statements at top of file"
853 );
854 assert!(
855 !lines.iter().any(|l| l.content.contains("export const year")),
856 "Should exclude export statements at top of file"
857 );
858 assert!(
860 !lines.iter().any(|l| l.content.contains("import {Footer}")),
861 "Should exclude import statements even after markdown content (MDX 2.0+ ESM anywhere)"
862 );
863
864 let heading_line = lines
866 .iter()
867 .find(|l| l.content.contains("# Last year's snowfall"))
868 .unwrap();
869 assert_eq!(heading_line.line_num, 5, "Heading should be on line 5");
870 }
871
872 #[test]
873 fn test_all_filters_combined() {
874 let content = r#"---
875title: Test
876---
877
878# Title
879
880```
881code
882```
883
884<!-- HTML comment here -->
885
886::: mymodule.Class
887 options:
888 show_root_heading: true
889
890<div>
891HTML block
892</div>
893
894Content"#;
895 let ctx = LintContext::new(content, MarkdownFlavor::MkDocs, None);
896
897 let lines: Vec<_> = ctx
898 .filtered_lines()
899 .skip_front_matter()
900 .skip_code_blocks()
901 .skip_html_blocks()
902 .skip_html_comments()
903 .skip_mkdocstrings()
904 .into_iter()
905 .collect();
906
907 assert!(
909 lines.iter().any(|l| l.content == "# Title"),
910 "Should include markdown headings"
911 );
912 assert!(
913 lines.iter().any(|l| l.content == "Content"),
914 "Should include markdown content"
915 );
916
917 assert!(
919 !lines.iter().any(|l| l.content == "title: Test"),
920 "Should exclude front matter"
921 );
922 assert!(
923 !lines.iter().any(|l| l.content == "code"),
924 "Should exclude code block content"
925 );
926 assert!(
927 !lines.iter().any(|l| l.content.contains("HTML comment")),
928 "Should exclude HTML comments"
929 );
930 assert!(
931 !lines.iter().any(|l| l.content.contains("::: mymodule")),
932 "Should exclude mkdocstrings blocks"
933 );
934 assert!(
935 !lines.iter().any(|l| l.content.contains("show_root_heading")),
936 "Should exclude mkdocstrings options"
937 );
938 assert!(
939 !lines.iter().any(|l| l.content.contains("HTML block")),
940 "Should exclude HTML blocks"
941 );
942 }
943
944 #[test]
945 fn test_skip_math_blocks() {
946 let content = r#"# Heading
947
948Some regular text.
949
950$$
951A = \left[
952\begin{array}{c}
9531 \\
954-D
955\end{array}
956\right]
957$$
958
959More content after math."#;
960 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
961 let lines: Vec<_> = ctx.filtered_lines().skip_math_blocks().into_iter().collect();
962
963 assert!(
965 lines.iter().any(|l| l.content.contains("# Heading")),
966 "Should include markdown headings"
967 );
968 assert!(
969 lines.iter().any(|l| l.content.contains("Some regular text")),
970 "Should include regular text before math block"
971 );
972 assert!(
973 lines.iter().any(|l| l.content.contains("More content after math")),
974 "Should include content after math block"
975 );
976
977 assert!(
979 !lines.iter().any(|l| l.content == "$$"),
980 "Should exclude math block delimiters"
981 );
982 assert!(
983 !lines.iter().any(|l| l.content.contains("\\left[")),
984 "Should exclude LaTeX content inside math block"
985 );
986 assert!(
987 !lines.iter().any(|l| l.content.contains("-D")),
988 "Should exclude content that looks like list items inside math block"
989 );
990 assert!(
991 !lines.iter().any(|l| l.content.contains("\\begin{array}")),
992 "Should exclude LaTeX array content"
993 );
994 }
995
996 #[test]
997 fn test_math_blocks_not_confused_with_code_blocks() {
998 let content = r#"# Title
999
1000```python
1001# This $$ is inside a code block
1002x = 1
1003```
1004
1005$$
1006y = 2
1007$$
1008
1009Regular text."#;
1010 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1011
1012 let lines: Vec<_> = ctx.filtered_lines().skip_math_blocks().into_iter().collect();
1014
1015 assert!(
1018 lines.iter().any(|l| l.content.contains("# This $$")),
1019 "Code block content with $$ should not be detected as math block"
1020 );
1021
1022 assert!(
1024 !lines.iter().any(|l| l.content == "y = 2"),
1025 "Actual math block content should be excluded"
1026 );
1027 }
1028
1029 #[test]
1030 fn test_skip_quarto_divs() {
1031 let content = r#"# Heading
1032
1033::: {.callout-note}
1034This is a callout note.
1035With multiple lines.
1036:::
1037
1038Regular text outside.
1039
1040::: {.bordered}
1041Content inside bordered div.
1042:::
1043
1044More content."#;
1045 let ctx = LintContext::new(content, MarkdownFlavor::Quarto, None);
1046 let lines: Vec<_> = ctx.filtered_lines().skip_quarto_divs().into_iter().collect();
1047
1048 assert!(
1050 lines.iter().any(|l| l.content.contains("# Heading")),
1051 "Should include markdown headings"
1052 );
1053 assert!(
1054 lines.iter().any(|l| l.content.contains("Regular text outside")),
1055 "Should include content between divs"
1056 );
1057 assert!(
1058 lines.iter().any(|l| l.content.contains("More content")),
1059 "Should include content after divs"
1060 );
1061
1062 assert!(
1064 !lines.iter().any(|l| l.content.contains("::: {.callout-note}")),
1065 "Should exclude callout div markers"
1066 );
1067 assert!(
1068 !lines.iter().any(|l| l.content.contains("This is a callout note")),
1069 "Should exclude callout content"
1070 );
1071 assert!(
1072 !lines.iter().any(|l| l.content.contains("Content inside bordered")),
1073 "Should exclude bordered div content"
1074 );
1075 }
1076
1077 #[test]
1078 fn test_skip_jsx_expressions() {
1079 let content = r#"# MDX Document
1080
1081Here is some content with {myVariable} inline.
1082
1083{items.map(item => (
1084 <Item key={item.id} />
1085))}
1086
1087Regular paragraph after expression.
1088
1089{/* This should NOT be skipped by jsx_expressions filter */}
1090{/* MDX comments have their own filter */}
1091
1092More content."#;
1093 let ctx = LintContext::new(content, MarkdownFlavor::MDX, None);
1094 let lines: Vec<_> = ctx.filtered_lines().skip_jsx_expressions().into_iter().collect();
1095
1096 assert!(
1098 lines.iter().any(|l| l.content.contains("# MDX Document")),
1099 "Should include markdown headings"
1100 );
1101 assert!(
1102 lines.iter().any(|l| l.content.contains("Regular paragraph")),
1103 "Should include regular paragraphs"
1104 );
1105 assert!(
1106 lines.iter().any(|l| l.content.contains("More content")),
1107 "Should include content after expressions"
1108 );
1109
1110 assert!(
1112 !lines.iter().any(|l| l.content.contains("{myVariable}")),
1113 "Should exclude lines with inline JSX expressions"
1114 );
1115 assert!(
1116 !lines.iter().any(|l| l.content.contains("items.map")),
1117 "Should exclude multi-line JSX expression content"
1118 );
1119 assert!(
1120 !lines.iter().any(|l| l.content.contains("<Item key")),
1121 "Should exclude JSX inside expressions"
1122 );
1123 }
1124
1125 #[test]
1126 fn test_skip_quarto_divs_nested() {
1127 let content = r#"# Title
1128
1129::: {.outer}
1130Outer content.
1131
1132::: {.inner}
1133Inner content.
1134:::
1135
1136Back to outer.
1137:::
1138
1139Outside text."#;
1140 let ctx = LintContext::new(content, MarkdownFlavor::Quarto, None);
1141 let lines: Vec<_> = ctx.filtered_lines().skip_quarto_divs().into_iter().collect();
1142
1143 assert!(
1145 lines.iter().any(|l| l.content.contains("# Title")),
1146 "Should include heading"
1147 );
1148 assert!(
1149 lines.iter().any(|l| l.content.contains("Outside text")),
1150 "Should include text after divs"
1151 );
1152
1153 assert!(
1155 !lines.iter().any(|l| l.content.contains("Outer content")),
1156 "Should exclude outer div content"
1157 );
1158 assert!(
1159 !lines.iter().any(|l| l.content.contains("Inner content")),
1160 "Should exclude inner div content"
1161 );
1162 }
1163
1164 #[test]
1165 fn test_skip_quarto_divs_not_in_standard_flavor() {
1166 let content = r#"::: {.callout-note}
1167This should NOT be skipped in standard flavor.
1168:::"#;
1169 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1170 let lines: Vec<_> = ctx.filtered_lines().skip_quarto_divs().into_iter().collect();
1171
1172 assert!(
1174 lines.iter().any(|l| l.content.contains("This should NOT be skipped")),
1175 "Standard flavor should not detect Quarto divs"
1176 );
1177 }
1178
1179 #[test]
1180 fn test_skip_mdx_comments() {
1181 let content = r#"# MDX Document
1182
1183{/* This is an MDX comment */}
1184
1185Regular content here.
1186
1187{/*
1188 Multi-line
1189 MDX comment
1190*/}
1191
1192More content after comment."#;
1193 let ctx = LintContext::new(content, MarkdownFlavor::MDX, None);
1194 let lines: Vec<_> = ctx.filtered_lines().skip_mdx_comments().into_iter().collect();
1195
1196 assert!(
1198 lines.iter().any(|l| l.content.contains("# MDX Document")),
1199 "Should include markdown headings"
1200 );
1201 assert!(
1202 lines.iter().any(|l| l.content.contains("Regular content")),
1203 "Should include regular content"
1204 );
1205 assert!(
1206 lines.iter().any(|l| l.content.contains("More content")),
1207 "Should include content after comments"
1208 );
1209
1210 assert!(
1212 !lines.iter().any(|l| l.content.contains("{/* This is")),
1213 "Should exclude single-line MDX comments"
1214 );
1215 assert!(
1216 !lines.iter().any(|l| l.content.contains("Multi-line")),
1217 "Should exclude multi-line MDX comment content"
1218 );
1219 }
1220
1221 #[test]
1222 fn test_jsx_expressions_with_nested_braces() {
1223 let content = r#"# Document
1225
1226{props.style || {color: "red", background: "blue"}}
1227
1228Regular content."#;
1229 let ctx = LintContext::new(content, MarkdownFlavor::MDX, None);
1230 let lines: Vec<_> = ctx.filtered_lines().skip_jsx_expressions().into_iter().collect();
1231
1232 assert!(
1234 !lines.iter().any(|l| l.content.contains("props.style")),
1235 "Should exclude JSX expression with nested braces"
1236 );
1237 assert!(
1238 lines.iter().any(|l| l.content.contains("Regular content")),
1239 "Should include content after nested expression"
1240 );
1241 }
1242
1243 #[test]
1244 fn test_jsx_and_mdx_comments_combined() {
1245 let content = r#"# Title
1247
1248{variable}
1249
1250{/* comment */}
1251
1252Content."#;
1253 let ctx = LintContext::new(content, MarkdownFlavor::MDX, None);
1254 let lines: Vec<_> = ctx
1255 .filtered_lines()
1256 .skip_jsx_expressions()
1257 .skip_mdx_comments()
1258 .into_iter()
1259 .collect();
1260
1261 assert!(
1262 lines.iter().any(|l| l.content.contains("# Title")),
1263 "Should include heading"
1264 );
1265 assert!(
1266 lines.iter().any(|l| l.content.contains("Content")),
1267 "Should include regular content"
1268 );
1269 assert!(
1270 !lines.iter().any(|l| l.content.contains("{variable}")),
1271 "Should exclude JSX expression"
1272 );
1273 assert!(
1274 !lines.iter().any(|l| l.content.contains("{/* comment */")),
1275 "Should exclude MDX comment"
1276 );
1277 }
1278
1279 #[test]
1280 fn test_jsx_expressions_not_detected_in_standard_flavor() {
1281 let content = r#"# Document
1283
1284{this is not JSX in standard markdown}
1285
1286Content."#;
1287 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1288 let lines: Vec<_> = ctx.filtered_lines().skip_jsx_expressions().into_iter().collect();
1289
1290 assert!(
1292 lines.iter().any(|l| l.content.contains("{this is not JSX")),
1293 "Should NOT exclude brace content in standard markdown"
1294 );
1295 }
1296
1297 #[test]
1300 fn test_skip_obsidian_comments_simple_inline() {
1301 let content = r#"# Heading
1303
1304This is visible %%this is hidden%% and visible again.
1305
1306More content."#;
1307 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1308 let lines: Vec<_> = ctx.filtered_lines().skip_obsidian_comments().into_iter().collect();
1309
1310 assert!(
1312 lines.iter().any(|l| l.content.contains("# Heading")),
1313 "Should include heading"
1314 );
1315 assert!(
1316 lines.iter().any(|l| l.content.contains("This is visible")),
1317 "Should include line with inline comment"
1318 );
1319 assert!(
1320 lines.iter().any(|l| l.content.contains("More content")),
1321 "Should include content after comment"
1322 );
1323 }
1324
1325 #[test]
1326 fn test_skip_obsidian_comments_multiline_block() {
1327 let content = r#"# Heading
1329
1330%%
1331This is a multi-line
1332comment block
1333%%
1334
1335Content after."#;
1336 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1337 let lines: Vec<_> = ctx.filtered_lines().skip_obsidian_comments().into_iter().collect();
1338
1339 assert!(
1341 lines.iter().any(|l| l.content.contains("# Heading")),
1342 "Should include heading"
1343 );
1344 assert!(
1345 lines.iter().any(|l| l.content.contains("Content after")),
1346 "Should include content after comment block"
1347 );
1348
1349 assert!(
1351 !lines.iter().any(|l| l.content.contains("This is a multi-line")),
1352 "Should exclude multi-line comment content"
1353 );
1354 assert!(
1355 !lines.iter().any(|l| l.content.contains("comment block")),
1356 "Should exclude multi-line comment content"
1357 );
1358 }
1359
1360 #[test]
1361 fn test_skip_obsidian_comments_in_code_block() {
1362 let content = r#"# Heading
1364
1365```
1366%% This is NOT a comment
1367It's inside a code block
1368%%
1369```
1370
1371Content."#;
1372 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1373 let lines: Vec<_> = ctx
1374 .filtered_lines()
1375 .skip_obsidian_comments()
1376 .skip_code_blocks()
1377 .into_iter()
1378 .collect();
1379
1380 assert!(
1382 lines.iter().any(|l| l.content.contains("# Heading")),
1383 "Should include heading"
1384 );
1385 assert!(
1386 lines.iter().any(|l| l.content.contains("Content")),
1387 "Should include content after code block"
1388 );
1389 }
1390
1391 #[test]
1392 fn test_skip_obsidian_comments_in_html_comment() {
1393 let content = r#"# Heading
1395
1396<!-- %% This is inside HTML comment %% -->
1397
1398Content."#;
1399 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1400 let lines: Vec<_> = ctx
1401 .filtered_lines()
1402 .skip_obsidian_comments()
1403 .skip_html_comments()
1404 .into_iter()
1405 .collect();
1406
1407 assert!(
1408 lines.iter().any(|l| l.content.contains("# Heading")),
1409 "Should include heading"
1410 );
1411 assert!(
1412 lines.iter().any(|l| l.content.contains("Content")),
1413 "Should include content"
1414 );
1415 }
1416
1417 #[test]
1418 fn test_skip_obsidian_comments_empty() {
1419 let content = r#"# Heading
1421
1422%%%% empty comment
1423
1424Content."#;
1425 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1426 let lines: Vec<_> = ctx.filtered_lines().skip_obsidian_comments().into_iter().collect();
1427
1428 assert!(
1430 lines.iter().any(|l| l.content.contains("# Heading")),
1431 "Should include heading"
1432 );
1433 }
1434
1435 #[test]
1436 fn test_skip_obsidian_comments_unclosed() {
1437 let content = r#"# Heading
1439
1440%% starts but never ends
1441This should be hidden
1442Until end of document"#;
1443 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1444 let lines: Vec<_> = ctx.filtered_lines().skip_obsidian_comments().into_iter().collect();
1445
1446 assert!(
1448 lines.iter().any(|l| l.content.contains("# Heading")),
1449 "Should include heading before unclosed comment"
1450 );
1451
1452 assert!(
1454 !lines.iter().any(|l| l.content.contains("This should be hidden")),
1455 "Should exclude content in unclosed comment"
1456 );
1457 assert!(
1458 !lines.iter().any(|l| l.content.contains("Until end of document")),
1459 "Should exclude content until end of document"
1460 );
1461 }
1462
1463 #[test]
1464 fn test_skip_obsidian_comments_multiple_on_same_line() {
1465 let content = r#"# Heading
1467
1468First %%hidden1%% middle %%hidden2%% last
1469
1470Content."#;
1471 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1472 let lines: Vec<_> = ctx.filtered_lines().skip_obsidian_comments().into_iter().collect();
1473
1474 assert!(
1476 lines.iter().any(|l| l.content.contains("First")),
1477 "Should include line with multiple inline comments"
1478 );
1479 assert!(
1480 lines.iter().any(|l| l.content.contains("middle")),
1481 "Should include visible text between comments"
1482 );
1483 }
1484
1485 #[test]
1486 fn test_skip_obsidian_comments_at_start_of_line() {
1487 let content = r#"# Heading
1489
1490%%comment at start%%
1491
1492Content."#;
1493 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1494 let lines: Vec<_> = ctx.filtered_lines().skip_obsidian_comments().into_iter().collect();
1495
1496 assert!(
1497 lines.iter().any(|l| l.content.contains("# Heading")),
1498 "Should include heading"
1499 );
1500 assert!(
1501 lines.iter().any(|l| l.content.contains("Content")),
1502 "Should include content"
1503 );
1504 }
1505
1506 #[test]
1507 fn test_skip_obsidian_comments_at_end_of_line() {
1508 let content = r#"# Heading
1510
1511Some text %%comment at end%%
1512
1513Content."#;
1514 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1515 let lines: Vec<_> = ctx.filtered_lines().skip_obsidian_comments().into_iter().collect();
1516
1517 assert!(
1518 lines.iter().any(|l| l.content.contains("Some text")),
1519 "Should include text before comment"
1520 );
1521 }
1522
1523 #[test]
1524 fn test_skip_obsidian_comments_with_markdown_inside() {
1525 let content = r#"# Heading
1527
1528%%
1529# hidden heading
1530[hidden link](url)
1531**hidden bold**
1532%%
1533
1534Content."#;
1535 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1536 let lines: Vec<_> = ctx.filtered_lines().skip_obsidian_comments().into_iter().collect();
1537
1538 assert!(
1539 !lines.iter().any(|l| l.content.contains("# hidden heading")),
1540 "Should exclude heading inside comment"
1541 );
1542 assert!(
1543 !lines.iter().any(|l| l.content.contains("[hidden link]")),
1544 "Should exclude link inside comment"
1545 );
1546 assert!(
1547 !lines.iter().any(|l| l.content.contains("**hidden bold**")),
1548 "Should exclude bold inside comment"
1549 );
1550 }
1551
1552 #[test]
1553 fn test_skip_obsidian_comments_with_unicode() {
1554 let content = r#"# Heading
1556
1557%%日本語コメント%%
1558
1559%%Комментарий%%
1560
1561Content."#;
1562 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1563 let lines: Vec<_> = ctx.filtered_lines().skip_obsidian_comments().into_iter().collect();
1564
1565 assert!(
1567 lines.iter().any(|l| l.content.contains("# Heading")),
1568 "Should include heading"
1569 );
1570 assert!(
1571 lines.iter().any(|l| l.content.contains("Content")),
1572 "Should include content"
1573 );
1574 }
1575
1576 #[test]
1577 fn test_skip_obsidian_comments_triple_percent() {
1578 let content = r#"# Heading
1580
1581%%% odd percent
1582
1583Content."#;
1584 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1585 let lines: Vec<_> = ctx.filtered_lines().skip_obsidian_comments().into_iter().collect();
1586
1587 assert!(
1589 lines.iter().any(|l| l.content.contains("# Heading")),
1590 "Should include heading"
1591 );
1592 }
1593
1594 #[test]
1595 fn test_skip_obsidian_comments_not_in_standard_flavor() {
1596 let content = r#"# Heading
1598
1599%%this is not hidden in standard%%
1600
1601Content."#;
1602 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1603 let lines: Vec<_> = ctx.filtered_lines().skip_obsidian_comments().into_iter().collect();
1604
1605 assert!(
1607 lines.iter().any(|l| l.content.contains("%%this is not hidden")),
1608 "Should NOT hide %% content in Standard flavor"
1609 );
1610 }
1611
1612 #[test]
1613 fn test_skip_obsidian_comments_integration_with_other_filters() {
1614 let content = r#"---
1616title: Test
1617---
1618
1619# Heading
1620
1621```
1622code
1623```
1624
1625%%hidden comment%%
1626
1627Content."#;
1628 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1629 let lines: Vec<_> = ctx
1630 .filtered_lines()
1631 .skip_front_matter()
1632 .skip_code_blocks()
1633 .skip_obsidian_comments()
1634 .into_iter()
1635 .collect();
1636
1637 assert!(
1639 !lines.iter().any(|l| l.content.contains("title: Test")),
1640 "Should skip frontmatter"
1641 );
1642 assert!(
1643 !lines.iter().any(|l| l.content == "code"),
1644 "Should skip code block content"
1645 );
1646 assert!(
1647 lines.iter().any(|l| l.content.contains("# Heading")),
1648 "Should include heading"
1649 );
1650 assert!(
1651 lines.iter().any(|l| l.content.contains("Content")),
1652 "Should include content"
1653 );
1654 }
1655
1656 #[test]
1657 fn test_skip_obsidian_comments_whole_line_only() {
1658 let content = "start %%\nfully hidden\n%% end";
1660 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1661 let lines: Vec<_> = ctx.filtered_lines().skip_obsidian_comments().into_iter().collect();
1662
1663 assert!(
1665 lines.iter().any(|l| l.content.contains("start")),
1666 "First line should be included (starts outside comment)"
1667 );
1668 assert!(
1670 !lines.iter().any(|l| l.content == "fully hidden"),
1671 "Middle line should be excluded (entirely within comment)"
1672 );
1673 assert!(
1675 lines.iter().any(|l| l.content.contains("end")),
1676 "Last line should be included (ends outside comment)"
1677 );
1678 }
1679
1680 #[test]
1681 fn test_skip_obsidian_comments_in_inline_code() {
1682 let content = r#"# Heading
1684
1685The syntax is `%%comment%%` in Obsidian.
1686
1687Content."#;
1688 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1689 let lines: Vec<_> = ctx.filtered_lines().skip_obsidian_comments().into_iter().collect();
1690
1691 assert!(
1693 lines.iter().any(|l| l.content.contains("The syntax is")),
1694 "Should include line with %% in code span"
1695 );
1696 assert!(
1697 lines.iter().any(|l| l.content.contains("in Obsidian")),
1698 "Should include text after code span"
1699 );
1700 }
1701
1702 #[test]
1703 fn test_skip_obsidian_comments_in_inline_code_multi_backtick() {
1704 let content = r#"# Heading
1706
1707The syntax is ``%%comment%%`` in Obsidian.
1708
1709Content."#;
1710 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1711 let lines: Vec<_> = ctx.filtered_lines().skip_obsidian_comments().into_iter().collect();
1712
1713 assert!(
1714 lines.iter().any(|l| l.content.contains("The syntax is")),
1715 "Should include line with %% in multi-backtick code span"
1716 );
1717 assert!(
1718 lines.iter().any(|l| l.content.contains("Content")),
1719 "Should include content after code span"
1720 );
1721 }
1722
1723 #[test]
1724 fn test_skip_obsidian_comments_consecutive_blocks() {
1725 let content = r#"# Heading
1727
1728%%comment 1%%
1729
1730%%comment 2%%
1731
1732Content."#;
1733 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1734 let lines: Vec<_> = ctx.filtered_lines().skip_obsidian_comments().into_iter().collect();
1735
1736 assert!(
1737 lines.iter().any(|l| l.content.contains("# Heading")),
1738 "Should include heading"
1739 );
1740 assert!(
1741 lines.iter().any(|l| l.content.contains("Content")),
1742 "Should include content after comments"
1743 );
1744 }
1745
1746 #[test]
1747 fn test_skip_obsidian_comments_spanning_many_lines() {
1748 let content = r#"# Title
1750
1751%%
1752Line 1 of comment
1753Line 2 of comment
1754Line 3 of comment
1755Line 4 of comment
1756Line 5 of comment
1757%%
1758
1759After comment."#;
1760 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1761 let lines: Vec<_> = ctx.filtered_lines().skip_obsidian_comments().into_iter().collect();
1762
1763 for i in 1..=5 {
1765 assert!(
1766 !lines
1767 .iter()
1768 .any(|l| l.content.contains(&format!("Line {i} of comment"))),
1769 "Should exclude line {i} of comment"
1770 );
1771 }
1772
1773 assert!(
1774 lines.iter().any(|l| l.content.contains("# Title")),
1775 "Should include title"
1776 );
1777 assert!(
1778 lines.iter().any(|l| l.content.contains("After comment")),
1779 "Should include content after comment"
1780 );
1781 }
1782
1783 #[test]
1784 fn test_obsidian_comment_line_info_field() {
1785 let content = "visible\n%%\nhidden\n%%\nvisible";
1787 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1788
1789 assert!(
1791 !ctx.lines[0].in_obsidian_comment,
1792 "Line 0 should not be marked as in_obsidian_comment"
1793 );
1794
1795 assert!(
1797 ctx.lines[2].in_obsidian_comment,
1798 "Line 2 (hidden) should be marked as in_obsidian_comment"
1799 );
1800
1801 assert!(
1803 !ctx.lines[4].in_obsidian_comment,
1804 "Line 4 should not be marked as in_obsidian_comment"
1805 );
1806 }
1807
1808 #[test]
1811 fn test_skip_pymdown_blocks_basic() {
1812 let content = r#"# Heading
1814
1815/// caption
1816Table caption here.
1817///
1818
1819Content after."#;
1820 let ctx = LintContext::new(content, MarkdownFlavor::MkDocs, None);
1821 let lines: Vec<_> = ctx.filtered_lines().skip_pymdown_blocks().into_iter().collect();
1822
1823 assert!(
1825 lines.iter().any(|l| l.content.contains("# Heading")),
1826 "Should include heading"
1827 );
1828 assert!(
1829 lines.iter().any(|l| l.content.contains("Content after")),
1830 "Should include content after block"
1831 );
1832
1833 assert!(
1835 !lines.iter().any(|l| l.content.contains("Table caption")),
1836 "Should exclude content inside block"
1837 );
1838 }
1839
1840 #[test]
1841 fn test_skip_pymdown_blocks_details() {
1842 let content = r#"# Heading
1844
1845/// details | Click to expand
1846 open: True
1847Hidden content here.
1848More hidden content.
1849///
1850
1851Visible content."#;
1852 let ctx = LintContext::new(content, MarkdownFlavor::MkDocs, None);
1853 let lines: Vec<_> = ctx.filtered_lines().skip_pymdown_blocks().into_iter().collect();
1854
1855 assert!(
1856 !lines.iter().any(|l| l.content.contains("Hidden content")),
1857 "Should exclude hidden content"
1858 );
1859 assert!(
1860 !lines.iter().any(|l| l.content.contains("open: True")),
1861 "Should exclude YAML options"
1862 );
1863 assert!(
1864 lines.iter().any(|l| l.content.contains("Visible content")),
1865 "Should include visible content"
1866 );
1867 }
1868
1869 #[test]
1870 fn test_skip_pymdown_blocks_nested() {
1871 let content = r#"# Title
1873
1874/// details | Outer
1875Outer content.
1876
1877 /// caption
1878 Inner caption.
1879 ///
1880
1881More outer content.
1882///
1883
1884After all blocks."#;
1885 let ctx = LintContext::new(content, MarkdownFlavor::MkDocs, None);
1886 let lines: Vec<_> = ctx.filtered_lines().skip_pymdown_blocks().into_iter().collect();
1887
1888 assert!(
1889 !lines.iter().any(|l| l.content.contains("Outer content")),
1890 "Should exclude outer block content"
1891 );
1892 assert!(
1893 !lines.iter().any(|l| l.content.contains("Inner caption")),
1894 "Should exclude inner block content"
1895 );
1896 assert!(
1897 lines.iter().any(|l| l.content.contains("After all blocks")),
1898 "Should include content after all blocks"
1899 );
1900 }
1901
1902 #[test]
1903 fn test_pymdown_block_line_info_field() {
1904 let content = "visible\n/// caption\nhidden\n///\nvisible";
1906 let ctx = LintContext::new(content, MarkdownFlavor::MkDocs, None);
1907
1908 assert!(
1910 !ctx.lines[0].in_pymdown_block,
1911 "Line 0 should not be marked as in_pymdown_block"
1912 );
1913
1914 assert!(
1916 ctx.lines[1].in_pymdown_block,
1917 "Line 1 (/// caption) should be marked as in_pymdown_block"
1918 );
1919
1920 assert!(
1922 ctx.lines[2].in_pymdown_block,
1923 "Line 2 (hidden) should be marked as in_pymdown_block"
1924 );
1925
1926 assert!(
1928 ctx.lines[3].in_pymdown_block,
1929 "Line 3 (closing ///) should be marked as in_pymdown_block"
1930 );
1931
1932 assert!(
1934 !ctx.lines[4].in_pymdown_block,
1935 "Line 4 should not be marked as in_pymdown_block"
1936 );
1937 }
1938
1939 #[test]
1940 fn test_pymdown_blocks_only_for_mkdocs_flavor() {
1941 let content = "/// caption\nCaption text\n///";
1943
1944 let ctx_mkdocs = LintContext::new(content, MarkdownFlavor::MkDocs, None);
1946 assert!(
1947 ctx_mkdocs.lines[1].in_pymdown_block,
1948 "MkDocs flavor should detect pymdown blocks"
1949 );
1950
1951 let ctx_standard = LintContext::new(content, MarkdownFlavor::Standard, None);
1953 assert!(
1954 !ctx_standard.lines[1].in_pymdown_block,
1955 "Standard flavor should NOT detect pymdown blocks"
1956 );
1957 }
1958}