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}
97
98impl LineFilterConfig {
99 #[must_use]
101 pub fn new() -> Self {
102 Self::default()
103 }
104
105 #[must_use]
110 pub fn skip_front_matter(mut self) -> Self {
111 self.skip_front_matter = true;
112 self
113 }
114
115 #[must_use]
120 pub fn skip_code_blocks(mut self) -> Self {
121 self.skip_code_blocks = true;
122 self
123 }
124
125 #[must_use]
130 pub fn skip_html_blocks(mut self) -> Self {
131 self.skip_html_blocks = true;
132 self
133 }
134
135 #[must_use]
140 pub fn skip_html_comments(mut self) -> Self {
141 self.skip_html_comments = true;
142 self
143 }
144
145 #[must_use]
150 pub fn skip_mkdocstrings(mut self) -> Self {
151 self.skip_mkdocstrings = true;
152 self
153 }
154
155 #[must_use]
160 pub fn skip_esm_blocks(mut self) -> Self {
161 self.skip_esm_blocks = true;
162 self
163 }
164
165 #[must_use]
170 pub fn skip_math_blocks(mut self) -> Self {
171 self.skip_math_blocks = true;
172 self
173 }
174
175 #[must_use]
180 pub fn skip_quarto_divs(mut self) -> Self {
181 self.skip_quarto_divs = true;
182 self
183 }
184
185 #[must_use]
190 pub fn skip_jsx_expressions(mut self) -> Self {
191 self.skip_jsx_expressions = true;
192 self
193 }
194
195 #[must_use]
200 pub fn skip_mdx_comments(mut self) -> Self {
201 self.skip_mdx_comments = true;
202 self
203 }
204
205 #[must_use]
210 pub fn skip_admonitions(mut self) -> Self {
211 self.skip_admonitions = true;
212 self
213 }
214
215 #[must_use]
219 pub fn skip_content_tabs(mut self) -> Self {
220 self.skip_content_tabs = true;
221 self
222 }
223
224 #[must_use]
228 pub fn skip_mkdocs_html_markdown(mut self) -> Self {
229 self.skip_mkdocs_html_markdown = true;
230 self
231 }
232
233 #[must_use]
239 pub fn skip_mkdocs_containers(mut self) -> Self {
240 self.skip_admonitions = true;
241 self.skip_content_tabs = true;
242 self.skip_mkdocs_html_markdown = true;
243 self
244 }
245
246 #[must_use]
251 pub fn skip_definition_lists(mut self) -> Self {
252 self.skip_definition_lists = true;
253 self
254 }
255
256 #[must_use]
261 pub fn skip_obsidian_comments(mut self) -> Self {
262 self.skip_obsidian_comments = true;
263 self
264 }
265
266 fn should_filter(&self, line_info: &LineInfo) -> bool {
268 (self.skip_front_matter && line_info.in_front_matter)
269 || (self.skip_code_blocks && line_info.in_code_block)
270 || (self.skip_html_blocks && line_info.in_html_block)
271 || (self.skip_html_comments && line_info.in_html_comment)
272 || (self.skip_mkdocstrings && line_info.in_mkdocstrings)
273 || (self.skip_esm_blocks && line_info.in_esm_block)
274 || (self.skip_math_blocks && line_info.in_math_block)
275 || (self.skip_quarto_divs && line_info.in_quarto_div)
276 || (self.skip_jsx_expressions && line_info.in_jsx_expression)
277 || (self.skip_mdx_comments && line_info.in_mdx_comment)
278 || (self.skip_admonitions && line_info.in_admonition)
279 || (self.skip_content_tabs && line_info.in_content_tab)
280 || (self.skip_mkdocs_html_markdown && line_info.in_mkdocs_html_markdown)
281 || (self.skip_definition_lists && line_info.in_definition_list)
282 || (self.skip_obsidian_comments && line_info.in_obsidian_comment)
283 }
284}
285
286pub struct FilteredLinesIter<'a> {
288 ctx: &'a LintContext<'a>,
289 config: LineFilterConfig,
290 current_index: usize,
291 content_lines: Vec<&'a str>,
292}
293
294impl<'a> FilteredLinesIter<'a> {
295 fn new(ctx: &'a LintContext<'a>, config: LineFilterConfig) -> Self {
297 Self {
298 ctx,
299 config,
300 current_index: 0,
301 content_lines: ctx.content.lines().collect(),
302 }
303 }
304}
305
306impl<'a> Iterator for FilteredLinesIter<'a> {
307 type Item = FilteredLine<'a>;
308
309 fn next(&mut self) -> Option<Self::Item> {
310 let lines = &self.ctx.lines;
311
312 while self.current_index < lines.len() {
313 let idx = self.current_index;
314 self.current_index += 1;
315
316 if self.config.should_filter(&lines[idx]) {
318 continue;
319 }
320
321 let line_content = self.content_lines.get(idx).copied().unwrap_or("");
323
324 return Some(FilteredLine {
326 line_num: idx + 1, line_info: &lines[idx],
328 content: line_content,
329 });
330 }
331
332 None
333 }
334}
335
336pub trait FilteredLinesExt {
341 fn filtered_lines(&self) -> FilteredLinesBuilder<'_>;
360
361 fn content_lines(&self) -> FilteredLinesIter<'_>;
384}
385
386pub struct FilteredLinesBuilder<'a> {
388 ctx: &'a LintContext<'a>,
389 config: LineFilterConfig,
390}
391
392impl<'a> FilteredLinesBuilder<'a> {
393 fn new(ctx: &'a LintContext<'a>) -> Self {
394 Self {
395 ctx,
396 config: LineFilterConfig::new(),
397 }
398 }
399
400 #[must_use]
402 pub fn skip_front_matter(mut self) -> Self {
403 self.config = self.config.skip_front_matter();
404 self
405 }
406
407 #[must_use]
409 pub fn skip_code_blocks(mut self) -> Self {
410 self.config = self.config.skip_code_blocks();
411 self
412 }
413
414 #[must_use]
416 pub fn skip_html_blocks(mut self) -> Self {
417 self.config = self.config.skip_html_blocks();
418 self
419 }
420
421 #[must_use]
423 pub fn skip_html_comments(mut self) -> Self {
424 self.config = self.config.skip_html_comments();
425 self
426 }
427
428 #[must_use]
430 pub fn skip_mkdocstrings(mut self) -> Self {
431 self.config = self.config.skip_mkdocstrings();
432 self
433 }
434
435 #[must_use]
437 pub fn skip_esm_blocks(mut self) -> Self {
438 self.config = self.config.skip_esm_blocks();
439 self
440 }
441
442 #[must_use]
444 pub fn skip_math_blocks(mut self) -> Self {
445 self.config = self.config.skip_math_blocks();
446 self
447 }
448
449 #[must_use]
451 pub fn skip_quarto_divs(mut self) -> Self {
452 self.config = self.config.skip_quarto_divs();
453 self
454 }
455
456 #[must_use]
458 pub fn skip_jsx_expressions(mut self) -> Self {
459 self.config = self.config.skip_jsx_expressions();
460 self
461 }
462
463 #[must_use]
465 pub fn skip_mdx_comments(mut self) -> Self {
466 self.config = self.config.skip_mdx_comments();
467 self
468 }
469
470 #[must_use]
472 pub fn skip_admonitions(mut self) -> Self {
473 self.config = self.config.skip_admonitions();
474 self
475 }
476
477 #[must_use]
479 pub fn skip_content_tabs(mut self) -> Self {
480 self.config = self.config.skip_content_tabs();
481 self
482 }
483
484 #[must_use]
486 pub fn skip_mkdocs_html_markdown(mut self) -> Self {
487 self.config = self.config.skip_mkdocs_html_markdown();
488 self
489 }
490
491 #[must_use]
497 pub fn skip_mkdocs_containers(mut self) -> Self {
498 self.config = self.config.skip_mkdocs_containers();
499 self
500 }
501
502 #[must_use]
504 pub fn skip_definition_lists(mut self) -> Self {
505 self.config = self.config.skip_definition_lists();
506 self
507 }
508
509 #[must_use]
511 pub fn skip_obsidian_comments(mut self) -> Self {
512 self.config = self.config.skip_obsidian_comments();
513 self
514 }
515}
516
517impl<'a> IntoIterator for FilteredLinesBuilder<'a> {
518 type Item = FilteredLine<'a>;
519 type IntoIter = FilteredLinesIter<'a>;
520
521 fn into_iter(self) -> Self::IntoIter {
522 FilteredLinesIter::new(self.ctx, self.config)
523 }
524}
525
526impl<'a> FilteredLinesExt for LintContext<'a> {
527 fn filtered_lines(&self) -> FilteredLinesBuilder<'_> {
528 FilteredLinesBuilder::new(self)
529 }
530
531 fn content_lines(&self) -> FilteredLinesIter<'_> {
532 FilteredLinesIter::new(self, LineFilterConfig::new().skip_front_matter())
533 }
534}
535
536#[cfg(test)]
537mod tests {
538 use super::*;
539 use crate::config::MarkdownFlavor;
540
541 #[test]
542 fn test_filtered_line_structure() {
543 let content = "# Title\n\nContent";
544 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
545
546 let line = ctx.content_lines().next().unwrap();
547 assert_eq!(line.line_num, 1);
548 assert_eq!(line.content, "# Title");
549 assert!(!line.line_info.in_front_matter);
550 }
551
552 #[test]
553 fn test_skip_front_matter_yaml() {
554 let content = "---\ntitle: Test\nurl: http://example.com\n---\n\n# Content\n\nMore content";
555 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
556
557 let lines: Vec<_> = ctx.content_lines().collect();
558 assert_eq!(lines.len(), 4);
560 assert_eq!(lines[0].line_num, 5); assert_eq!(lines[0].content, "");
562 assert_eq!(lines[1].line_num, 6);
563 assert_eq!(lines[1].content, "# Content");
564 assert_eq!(lines[2].line_num, 7);
565 assert_eq!(lines[2].content, "");
566 assert_eq!(lines[3].line_num, 8);
567 assert_eq!(lines[3].content, "More content");
568 }
569
570 #[test]
571 fn test_skip_front_matter_toml() {
572 let content = "+++\ntitle = \"Test\"\nurl = \"http://example.com\"\n+++\n\n# Content";
573 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
574
575 let lines: Vec<_> = ctx.content_lines().collect();
576 assert_eq!(lines.len(), 2); assert_eq!(lines[0].line_num, 5);
578 assert_eq!(lines[1].line_num, 6);
579 assert_eq!(lines[1].content, "# Content");
580 }
581
582 #[test]
583 fn test_skip_front_matter_json() {
584 let content = "{\n\"title\": \"Test\",\n\"url\": \"http://example.com\"\n}\n\n# Content";
585 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
586
587 let lines: Vec<_> = ctx.content_lines().collect();
588 assert_eq!(lines.len(), 2); assert_eq!(lines[0].line_num, 5);
590 assert_eq!(lines[1].line_num, 6);
591 assert_eq!(lines[1].content, "# Content");
592 }
593
594 #[test]
595 fn test_skip_code_blocks() {
596 let content = "# Title\n\n```rust\nlet x = 1;\nlet y = 2;\n```\n\nContent";
597 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
598
599 let lines: Vec<_> = ctx.filtered_lines().skip_code_blocks().into_iter().collect();
600
601 assert!(lines.iter().any(|l| l.content == "# Title"));
606 assert!(lines.iter().any(|l| l.content == "Content"));
607 assert!(!lines.iter().any(|l| l.content == "let x = 1;"));
609 assert!(!lines.iter().any(|l| l.content == "let y = 2;"));
610 }
611
612 #[test]
613 fn test_no_filters() {
614 let content = "---\ntitle: Test\n---\n\n# Content";
615 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
616
617 let lines: Vec<_> = ctx.filtered_lines().into_iter().collect();
619 assert_eq!(lines.len(), ctx.lines.len());
620 }
621
622 #[test]
623 fn test_multiple_filters() {
624 let content = "---\ntitle: Test\n---\n\n# Title\n\n```rust\ncode\n```\n\nContent";
625 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
626
627 let lines: Vec<_> = ctx
628 .filtered_lines()
629 .skip_front_matter()
630 .skip_code_blocks()
631 .into_iter()
632 .collect();
633
634 assert!(lines.iter().any(|l| l.content == "# Title"));
636 assert!(lines.iter().any(|l| l.content == "Content"));
637 assert!(!lines.iter().any(|l| l.content == "title: Test"));
638 assert!(!lines.iter().any(|l| l.content == "code"));
639 }
640
641 #[test]
642 fn test_line_numbering_is_1_indexed() {
643 let content = "First\nSecond\nThird";
644 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
645
646 let lines: Vec<_> = ctx.content_lines().collect();
647 assert_eq!(lines[0].line_num, 1);
648 assert_eq!(lines[0].content, "First");
649 assert_eq!(lines[1].line_num, 2);
650 assert_eq!(lines[1].content, "Second");
651 assert_eq!(lines[2].line_num, 3);
652 assert_eq!(lines[2].content, "Third");
653 }
654
655 #[test]
656 fn test_content_lines_convenience_method() {
657 let content = "---\nfoo: bar\n---\n\nContent";
658 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
659
660 let lines: Vec<_> = ctx.content_lines().collect();
662 assert!(!lines.iter().any(|l| l.content.contains("foo")));
663 assert!(lines.iter().any(|l| l.content == "Content"));
664 }
665
666 #[test]
667 fn test_empty_document() {
668 let content = "";
669 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
670
671 let lines: Vec<_> = ctx.content_lines().collect();
672 assert_eq!(lines.len(), 0);
673 }
674
675 #[test]
676 fn test_only_front_matter() {
677 let content = "---\ntitle: Test\n---";
678 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
679
680 let lines: Vec<_> = ctx.content_lines().collect();
681 assert_eq!(
682 lines.len(),
683 0,
684 "Document with only front matter should have no content lines"
685 );
686 }
687
688 #[test]
689 fn test_builder_pattern_ergonomics() {
690 let content = "# Title\n\n```\ncode\n```\n\nContent";
691 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
692
693 let _lines: Vec<_> = ctx
695 .filtered_lines()
696 .skip_front_matter()
697 .skip_code_blocks()
698 .skip_html_blocks()
699 .into_iter()
700 .collect();
701
702 }
704
705 #[test]
706 fn test_filtered_line_access_to_line_info() {
707 let content = "# Title\n\nContent";
708 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
709
710 for line in ctx.content_lines() {
711 assert!(!line.line_info.in_front_matter);
713 assert!(!line.line_info.in_code_block);
714 }
715 }
716
717 #[test]
718 fn test_skip_mkdocstrings() {
719 let content = r#"# API Documentation
720
721::: mymodule.MyClass
722 options:
723 show_root_heading: true
724 show_source: false
725
726Some regular content here.
727
728::: mymodule.function
729 options:
730 show_signature: true
731
732More content."#;
733 let ctx = LintContext::new(content, MarkdownFlavor::MkDocs, None);
734 let lines: Vec<_> = ctx.filtered_lines().skip_mkdocstrings().into_iter().collect();
735
736 assert!(
738 lines.iter().any(|l| l.content.contains("# API Documentation")),
739 "Should include lines outside mkdocstrings blocks"
740 );
741 assert!(
742 lines.iter().any(|l| l.content.contains("Some regular content")),
743 "Should include content between mkdocstrings blocks"
744 );
745 assert!(
746 lines.iter().any(|l| l.content.contains("More content")),
747 "Should include content after mkdocstrings blocks"
748 );
749
750 assert!(
752 !lines.iter().any(|l| l.content.contains("::: mymodule")),
753 "Should exclude mkdocstrings marker lines"
754 );
755 assert!(
756 !lines.iter().any(|l| l.content.contains("show_root_heading")),
757 "Should exclude mkdocstrings option lines"
758 );
759 assert!(
760 !lines.iter().any(|l| l.content.contains("show_signature")),
761 "Should exclude all mkdocstrings option lines"
762 );
763
764 assert_eq!(lines[0].line_num, 1, "First line should be line 1");
766 }
767
768 #[test]
769 fn test_skip_esm_blocks() {
770 let content = r#"import {Chart} from './components.js'
772import {Table} from './table.js'
773export const year = 2023
774
775# Last year's snowfall
776
777Content about snowfall data.
778
779import {Footer} from './footer.js'
780
781More content."#;
782 let ctx = LintContext::new(content, MarkdownFlavor::MDX, None);
783 let lines: Vec<_> = ctx.filtered_lines().skip_esm_blocks().into_iter().collect();
784
785 assert!(
787 lines.iter().any(|l| l.content.contains("# Last year's snowfall")),
788 "Should include markdown headings"
789 );
790 assert!(
791 lines.iter().any(|l| l.content.contains("Content about snowfall")),
792 "Should include markdown content"
793 );
794 assert!(
795 lines.iter().any(|l| l.content.contains("More content")),
796 "Should include content after ESM blocks"
797 );
798
799 assert!(
801 !lines.iter().any(|l| l.content.contains("import {Chart}")),
802 "Should exclude import statements at top of file"
803 );
804 assert!(
805 !lines.iter().any(|l| l.content.contains("import {Table}")),
806 "Should exclude all import statements at top of file"
807 );
808 assert!(
809 !lines.iter().any(|l| l.content.contains("export const year")),
810 "Should exclude export statements at top of file"
811 );
812 assert!(
814 !lines.iter().any(|l| l.content.contains("import {Footer}")),
815 "Should exclude import statements even after markdown content (MDX 2.0+ ESM anywhere)"
816 );
817
818 let heading_line = lines
820 .iter()
821 .find(|l| l.content.contains("# Last year's snowfall"))
822 .unwrap();
823 assert_eq!(heading_line.line_num, 5, "Heading should be on line 5");
824 }
825
826 #[test]
827 fn test_all_filters_combined() {
828 let content = r#"---
829title: Test
830---
831
832# Title
833
834```
835code
836```
837
838<!-- HTML comment here -->
839
840::: mymodule.Class
841 options:
842 show_root_heading: true
843
844<div>
845HTML block
846</div>
847
848Content"#;
849 let ctx = LintContext::new(content, MarkdownFlavor::MkDocs, None);
850
851 let lines: Vec<_> = ctx
852 .filtered_lines()
853 .skip_front_matter()
854 .skip_code_blocks()
855 .skip_html_blocks()
856 .skip_html_comments()
857 .skip_mkdocstrings()
858 .into_iter()
859 .collect();
860
861 assert!(
863 lines.iter().any(|l| l.content == "# Title"),
864 "Should include markdown headings"
865 );
866 assert!(
867 lines.iter().any(|l| l.content == "Content"),
868 "Should include markdown content"
869 );
870
871 assert!(
873 !lines.iter().any(|l| l.content == "title: Test"),
874 "Should exclude front matter"
875 );
876 assert!(
877 !lines.iter().any(|l| l.content == "code"),
878 "Should exclude code block content"
879 );
880 assert!(
881 !lines.iter().any(|l| l.content.contains("HTML comment")),
882 "Should exclude HTML comments"
883 );
884 assert!(
885 !lines.iter().any(|l| l.content.contains("::: mymodule")),
886 "Should exclude mkdocstrings blocks"
887 );
888 assert!(
889 !lines.iter().any(|l| l.content.contains("show_root_heading")),
890 "Should exclude mkdocstrings options"
891 );
892 assert!(
893 !lines.iter().any(|l| l.content.contains("HTML block")),
894 "Should exclude HTML blocks"
895 );
896 }
897
898 #[test]
899 fn test_skip_math_blocks() {
900 let content = r#"# Heading
901
902Some regular text.
903
904$$
905A = \left[
906\begin{array}{c}
9071 \\
908-D
909\end{array}
910\right]
911$$
912
913More content after math."#;
914 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
915 let lines: Vec<_> = ctx.filtered_lines().skip_math_blocks().into_iter().collect();
916
917 assert!(
919 lines.iter().any(|l| l.content.contains("# Heading")),
920 "Should include markdown headings"
921 );
922 assert!(
923 lines.iter().any(|l| l.content.contains("Some regular text")),
924 "Should include regular text before math block"
925 );
926 assert!(
927 lines.iter().any(|l| l.content.contains("More content after math")),
928 "Should include content after math block"
929 );
930
931 assert!(
933 !lines.iter().any(|l| l.content == "$$"),
934 "Should exclude math block delimiters"
935 );
936 assert!(
937 !lines.iter().any(|l| l.content.contains("\\left[")),
938 "Should exclude LaTeX content inside math block"
939 );
940 assert!(
941 !lines.iter().any(|l| l.content.contains("-D")),
942 "Should exclude content that looks like list items inside math block"
943 );
944 assert!(
945 !lines.iter().any(|l| l.content.contains("\\begin{array}")),
946 "Should exclude LaTeX array content"
947 );
948 }
949
950 #[test]
951 fn test_math_blocks_not_confused_with_code_blocks() {
952 let content = r#"# Title
953
954```python
955# This $$ is inside a code block
956x = 1
957```
958
959$$
960y = 2
961$$
962
963Regular text."#;
964 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
965
966 let lines: Vec<_> = ctx.filtered_lines().skip_math_blocks().into_iter().collect();
968
969 assert!(
972 lines.iter().any(|l| l.content.contains("# This $$")),
973 "Code block content with $$ should not be detected as math block"
974 );
975
976 assert!(
978 !lines.iter().any(|l| l.content == "y = 2"),
979 "Actual math block content should be excluded"
980 );
981 }
982
983 #[test]
984 fn test_skip_quarto_divs() {
985 let content = r#"# Heading
986
987::: {.callout-note}
988This is a callout note.
989With multiple lines.
990:::
991
992Regular text outside.
993
994::: {.bordered}
995Content inside bordered div.
996:::
997
998More content."#;
999 let ctx = LintContext::new(content, MarkdownFlavor::Quarto, None);
1000 let lines: Vec<_> = ctx.filtered_lines().skip_quarto_divs().into_iter().collect();
1001
1002 assert!(
1004 lines.iter().any(|l| l.content.contains("# Heading")),
1005 "Should include markdown headings"
1006 );
1007 assert!(
1008 lines.iter().any(|l| l.content.contains("Regular text outside")),
1009 "Should include content between divs"
1010 );
1011 assert!(
1012 lines.iter().any(|l| l.content.contains("More content")),
1013 "Should include content after divs"
1014 );
1015
1016 assert!(
1018 !lines.iter().any(|l| l.content.contains("::: {.callout-note}")),
1019 "Should exclude callout div markers"
1020 );
1021 assert!(
1022 !lines.iter().any(|l| l.content.contains("This is a callout note")),
1023 "Should exclude callout content"
1024 );
1025 assert!(
1026 !lines.iter().any(|l| l.content.contains("Content inside bordered")),
1027 "Should exclude bordered div content"
1028 );
1029 }
1030
1031 #[test]
1032 fn test_skip_jsx_expressions() {
1033 let content = r#"# MDX Document
1034
1035Here is some content with {myVariable} inline.
1036
1037{items.map(item => (
1038 <Item key={item.id} />
1039))}
1040
1041Regular paragraph after expression.
1042
1043{/* This should NOT be skipped by jsx_expressions filter */}
1044{/* MDX comments have their own filter */}
1045
1046More content."#;
1047 let ctx = LintContext::new(content, MarkdownFlavor::MDX, None);
1048 let lines: Vec<_> = ctx.filtered_lines().skip_jsx_expressions().into_iter().collect();
1049
1050 assert!(
1052 lines.iter().any(|l| l.content.contains("# MDX Document")),
1053 "Should include markdown headings"
1054 );
1055 assert!(
1056 lines.iter().any(|l| l.content.contains("Regular paragraph")),
1057 "Should include regular paragraphs"
1058 );
1059 assert!(
1060 lines.iter().any(|l| l.content.contains("More content")),
1061 "Should include content after expressions"
1062 );
1063
1064 assert!(
1066 !lines.iter().any(|l| l.content.contains("{myVariable}")),
1067 "Should exclude lines with inline JSX expressions"
1068 );
1069 assert!(
1070 !lines.iter().any(|l| l.content.contains("items.map")),
1071 "Should exclude multi-line JSX expression content"
1072 );
1073 assert!(
1074 !lines.iter().any(|l| l.content.contains("<Item key")),
1075 "Should exclude JSX inside expressions"
1076 );
1077 }
1078
1079 #[test]
1080 fn test_skip_quarto_divs_nested() {
1081 let content = r#"# Title
1082
1083::: {.outer}
1084Outer content.
1085
1086::: {.inner}
1087Inner content.
1088:::
1089
1090Back to outer.
1091:::
1092
1093Outside text."#;
1094 let ctx = LintContext::new(content, MarkdownFlavor::Quarto, None);
1095 let lines: Vec<_> = ctx.filtered_lines().skip_quarto_divs().into_iter().collect();
1096
1097 assert!(
1099 lines.iter().any(|l| l.content.contains("# Title")),
1100 "Should include heading"
1101 );
1102 assert!(
1103 lines.iter().any(|l| l.content.contains("Outside text")),
1104 "Should include text after divs"
1105 );
1106
1107 assert!(
1109 !lines.iter().any(|l| l.content.contains("Outer content")),
1110 "Should exclude outer div content"
1111 );
1112 assert!(
1113 !lines.iter().any(|l| l.content.contains("Inner content")),
1114 "Should exclude inner div content"
1115 );
1116 }
1117
1118 #[test]
1119 fn test_skip_quarto_divs_not_in_standard_flavor() {
1120 let content = r#"::: {.callout-note}
1121This should NOT be skipped in standard flavor.
1122:::"#;
1123 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1124 let lines: Vec<_> = ctx.filtered_lines().skip_quarto_divs().into_iter().collect();
1125
1126 assert!(
1128 lines.iter().any(|l| l.content.contains("This should NOT be skipped")),
1129 "Standard flavor should not detect Quarto divs"
1130 );
1131 }
1132
1133 #[test]
1134 fn test_skip_mdx_comments() {
1135 let content = r#"# MDX Document
1136
1137{/* This is an MDX comment */}
1138
1139Regular content here.
1140
1141{/*
1142 Multi-line
1143 MDX comment
1144*/}
1145
1146More content after comment."#;
1147 let ctx = LintContext::new(content, MarkdownFlavor::MDX, None);
1148 let lines: Vec<_> = ctx.filtered_lines().skip_mdx_comments().into_iter().collect();
1149
1150 assert!(
1152 lines.iter().any(|l| l.content.contains("# MDX Document")),
1153 "Should include markdown headings"
1154 );
1155 assert!(
1156 lines.iter().any(|l| l.content.contains("Regular content")),
1157 "Should include regular content"
1158 );
1159 assert!(
1160 lines.iter().any(|l| l.content.contains("More content")),
1161 "Should include content after comments"
1162 );
1163
1164 assert!(
1166 !lines.iter().any(|l| l.content.contains("{/* This is")),
1167 "Should exclude single-line MDX comments"
1168 );
1169 assert!(
1170 !lines.iter().any(|l| l.content.contains("Multi-line")),
1171 "Should exclude multi-line MDX comment content"
1172 );
1173 }
1174
1175 #[test]
1176 fn test_jsx_expressions_with_nested_braces() {
1177 let content = r#"# Document
1179
1180{props.style || {color: "red", background: "blue"}}
1181
1182Regular content."#;
1183 let ctx = LintContext::new(content, MarkdownFlavor::MDX, None);
1184 let lines: Vec<_> = ctx.filtered_lines().skip_jsx_expressions().into_iter().collect();
1185
1186 assert!(
1188 !lines.iter().any(|l| l.content.contains("props.style")),
1189 "Should exclude JSX expression with nested braces"
1190 );
1191 assert!(
1192 lines.iter().any(|l| l.content.contains("Regular content")),
1193 "Should include content after nested expression"
1194 );
1195 }
1196
1197 #[test]
1198 fn test_jsx_and_mdx_comments_combined() {
1199 let content = r#"# Title
1201
1202{variable}
1203
1204{/* comment */}
1205
1206Content."#;
1207 let ctx = LintContext::new(content, MarkdownFlavor::MDX, None);
1208 let lines: Vec<_> = ctx
1209 .filtered_lines()
1210 .skip_jsx_expressions()
1211 .skip_mdx_comments()
1212 .into_iter()
1213 .collect();
1214
1215 assert!(
1216 lines.iter().any(|l| l.content.contains("# Title")),
1217 "Should include heading"
1218 );
1219 assert!(
1220 lines.iter().any(|l| l.content.contains("Content")),
1221 "Should include regular content"
1222 );
1223 assert!(
1224 !lines.iter().any(|l| l.content.contains("{variable}")),
1225 "Should exclude JSX expression"
1226 );
1227 assert!(
1228 !lines.iter().any(|l| l.content.contains("{/* comment */")),
1229 "Should exclude MDX comment"
1230 );
1231 }
1232
1233 #[test]
1234 fn test_jsx_expressions_not_detected_in_standard_flavor() {
1235 let content = r#"# Document
1237
1238{this is not JSX in standard markdown}
1239
1240Content."#;
1241 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1242 let lines: Vec<_> = ctx.filtered_lines().skip_jsx_expressions().into_iter().collect();
1243
1244 assert!(
1246 lines.iter().any(|l| l.content.contains("{this is not JSX")),
1247 "Should NOT exclude brace content in standard markdown"
1248 );
1249 }
1250
1251 #[test]
1254 fn test_skip_obsidian_comments_simple_inline() {
1255 let content = r#"# Heading
1257
1258This is visible %%this is hidden%% and visible again.
1259
1260More content."#;
1261 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1262 let lines: Vec<_> = ctx.filtered_lines().skip_obsidian_comments().into_iter().collect();
1263
1264 assert!(
1266 lines.iter().any(|l| l.content.contains("# Heading")),
1267 "Should include heading"
1268 );
1269 assert!(
1270 lines.iter().any(|l| l.content.contains("This is visible")),
1271 "Should include line with inline comment"
1272 );
1273 assert!(
1274 lines.iter().any(|l| l.content.contains("More content")),
1275 "Should include content after comment"
1276 );
1277 }
1278
1279 #[test]
1280 fn test_skip_obsidian_comments_multiline_block() {
1281 let content = r#"# Heading
1283
1284%%
1285This is a multi-line
1286comment block
1287%%
1288
1289Content after."#;
1290 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1291 let lines: Vec<_> = ctx.filtered_lines().skip_obsidian_comments().into_iter().collect();
1292
1293 assert!(
1295 lines.iter().any(|l| l.content.contains("# Heading")),
1296 "Should include heading"
1297 );
1298 assert!(
1299 lines.iter().any(|l| l.content.contains("Content after")),
1300 "Should include content after comment block"
1301 );
1302
1303 assert!(
1305 !lines.iter().any(|l| l.content.contains("This is a multi-line")),
1306 "Should exclude multi-line comment content"
1307 );
1308 assert!(
1309 !lines.iter().any(|l| l.content.contains("comment block")),
1310 "Should exclude multi-line comment content"
1311 );
1312 }
1313
1314 #[test]
1315 fn test_skip_obsidian_comments_in_code_block() {
1316 let content = r#"# Heading
1318
1319```
1320%% This is NOT a comment
1321It's inside a code block
1322%%
1323```
1324
1325Content."#;
1326 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1327 let lines: Vec<_> = ctx
1328 .filtered_lines()
1329 .skip_obsidian_comments()
1330 .skip_code_blocks()
1331 .into_iter()
1332 .collect();
1333
1334 assert!(
1336 lines.iter().any(|l| l.content.contains("# Heading")),
1337 "Should include heading"
1338 );
1339 assert!(
1340 lines.iter().any(|l| l.content.contains("Content")),
1341 "Should include content after code block"
1342 );
1343 }
1344
1345 #[test]
1346 fn test_skip_obsidian_comments_in_html_comment() {
1347 let content = r#"# Heading
1349
1350<!-- %% This is inside HTML comment %% -->
1351
1352Content."#;
1353 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1354 let lines: Vec<_> = ctx
1355 .filtered_lines()
1356 .skip_obsidian_comments()
1357 .skip_html_comments()
1358 .into_iter()
1359 .collect();
1360
1361 assert!(
1362 lines.iter().any(|l| l.content.contains("# Heading")),
1363 "Should include heading"
1364 );
1365 assert!(
1366 lines.iter().any(|l| l.content.contains("Content")),
1367 "Should include content"
1368 );
1369 }
1370
1371 #[test]
1372 fn test_skip_obsidian_comments_empty() {
1373 let content = r#"# Heading
1375
1376%%%% empty comment
1377
1378Content."#;
1379 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1380 let lines: Vec<_> = ctx.filtered_lines().skip_obsidian_comments().into_iter().collect();
1381
1382 assert!(
1384 lines.iter().any(|l| l.content.contains("# Heading")),
1385 "Should include heading"
1386 );
1387 }
1388
1389 #[test]
1390 fn test_skip_obsidian_comments_unclosed() {
1391 let content = r#"# Heading
1393
1394%% starts but never ends
1395This should be hidden
1396Until end of document"#;
1397 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1398 let lines: Vec<_> = ctx.filtered_lines().skip_obsidian_comments().into_iter().collect();
1399
1400 assert!(
1402 lines.iter().any(|l| l.content.contains("# Heading")),
1403 "Should include heading before unclosed comment"
1404 );
1405
1406 assert!(
1408 !lines.iter().any(|l| l.content.contains("This should be hidden")),
1409 "Should exclude content in unclosed comment"
1410 );
1411 assert!(
1412 !lines.iter().any(|l| l.content.contains("Until end of document")),
1413 "Should exclude content until end of document"
1414 );
1415 }
1416
1417 #[test]
1418 fn test_skip_obsidian_comments_multiple_on_same_line() {
1419 let content = r#"# Heading
1421
1422First %%hidden1%% middle %%hidden2%% last
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("First")),
1431 "Should include line with multiple inline comments"
1432 );
1433 assert!(
1434 lines.iter().any(|l| l.content.contains("middle")),
1435 "Should include visible text between comments"
1436 );
1437 }
1438
1439 #[test]
1440 fn test_skip_obsidian_comments_at_start_of_line() {
1441 let content = r#"# Heading
1443
1444%%comment at start%%
1445
1446Content."#;
1447 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1448 let lines: Vec<_> = ctx.filtered_lines().skip_obsidian_comments().into_iter().collect();
1449
1450 assert!(
1451 lines.iter().any(|l| l.content.contains("# Heading")),
1452 "Should include heading"
1453 );
1454 assert!(
1455 lines.iter().any(|l| l.content.contains("Content")),
1456 "Should include content"
1457 );
1458 }
1459
1460 #[test]
1461 fn test_skip_obsidian_comments_at_end_of_line() {
1462 let content = r#"# Heading
1464
1465Some text %%comment at end%%
1466
1467Content."#;
1468 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1469 let lines: Vec<_> = ctx.filtered_lines().skip_obsidian_comments().into_iter().collect();
1470
1471 assert!(
1472 lines.iter().any(|l| l.content.contains("Some text")),
1473 "Should include text before comment"
1474 );
1475 }
1476
1477 #[test]
1478 fn test_skip_obsidian_comments_with_markdown_inside() {
1479 let content = r#"# Heading
1481
1482%%
1483# hidden heading
1484[hidden link](url)
1485**hidden bold**
1486%%
1487
1488Content."#;
1489 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1490 let lines: Vec<_> = ctx.filtered_lines().skip_obsidian_comments().into_iter().collect();
1491
1492 assert!(
1493 !lines.iter().any(|l| l.content.contains("# hidden heading")),
1494 "Should exclude heading inside comment"
1495 );
1496 assert!(
1497 !lines.iter().any(|l| l.content.contains("[hidden link]")),
1498 "Should exclude link inside comment"
1499 );
1500 assert!(
1501 !lines.iter().any(|l| l.content.contains("**hidden bold**")),
1502 "Should exclude bold inside comment"
1503 );
1504 }
1505
1506 #[test]
1507 fn test_skip_obsidian_comments_with_unicode() {
1508 let content = r#"# Heading
1510
1511%%日本語コメント%%
1512
1513%%Комментарий%%
1514
1515Content."#;
1516 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1517 let lines: Vec<_> = ctx.filtered_lines().skip_obsidian_comments().into_iter().collect();
1518
1519 assert!(
1521 lines.iter().any(|l| l.content.contains("# Heading")),
1522 "Should include heading"
1523 );
1524 assert!(
1525 lines.iter().any(|l| l.content.contains("Content")),
1526 "Should include content"
1527 );
1528 }
1529
1530 #[test]
1531 fn test_skip_obsidian_comments_triple_percent() {
1532 let content = r#"# Heading
1534
1535%%% odd percent
1536
1537Content."#;
1538 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1539 let lines: Vec<_> = ctx.filtered_lines().skip_obsidian_comments().into_iter().collect();
1540
1541 assert!(
1543 lines.iter().any(|l| l.content.contains("# Heading")),
1544 "Should include heading"
1545 );
1546 }
1547
1548 #[test]
1549 fn test_skip_obsidian_comments_not_in_standard_flavor() {
1550 let content = r#"# Heading
1552
1553%%this is not hidden in standard%%
1554
1555Content."#;
1556 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1557 let lines: Vec<_> = ctx.filtered_lines().skip_obsidian_comments().into_iter().collect();
1558
1559 assert!(
1561 lines.iter().any(|l| l.content.contains("%%this is not hidden")),
1562 "Should NOT hide %% content in Standard flavor"
1563 );
1564 }
1565
1566 #[test]
1567 fn test_skip_obsidian_comments_integration_with_other_filters() {
1568 let content = r#"---
1570title: Test
1571---
1572
1573# Heading
1574
1575```
1576code
1577```
1578
1579%%hidden comment%%
1580
1581Content."#;
1582 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1583 let lines: Vec<_> = ctx
1584 .filtered_lines()
1585 .skip_front_matter()
1586 .skip_code_blocks()
1587 .skip_obsidian_comments()
1588 .into_iter()
1589 .collect();
1590
1591 assert!(
1593 !lines.iter().any(|l| l.content.contains("title: Test")),
1594 "Should skip frontmatter"
1595 );
1596 assert!(
1597 !lines.iter().any(|l| l.content == "code"),
1598 "Should skip code block content"
1599 );
1600 assert!(
1601 lines.iter().any(|l| l.content.contains("# Heading")),
1602 "Should include heading"
1603 );
1604 assert!(
1605 lines.iter().any(|l| l.content.contains("Content")),
1606 "Should include content"
1607 );
1608 }
1609
1610 #[test]
1611 fn test_skip_obsidian_comments_whole_line_only() {
1612 let content = "start %%\nfully hidden\n%% end";
1614 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1615 let lines: Vec<_> = ctx.filtered_lines().skip_obsidian_comments().into_iter().collect();
1616
1617 assert!(
1619 lines.iter().any(|l| l.content.contains("start")),
1620 "First line should be included (starts outside comment)"
1621 );
1622 assert!(
1624 !lines.iter().any(|l| l.content == "fully hidden"),
1625 "Middle line should be excluded (entirely within comment)"
1626 );
1627 assert!(
1629 lines.iter().any(|l| l.content.contains("end")),
1630 "Last line should be included (ends outside comment)"
1631 );
1632 }
1633
1634 #[test]
1635 fn test_skip_obsidian_comments_in_inline_code() {
1636 let content = r#"# Heading
1638
1639The syntax is `%%comment%%` in Obsidian.
1640
1641Content."#;
1642 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1643 let lines: Vec<_> = ctx.filtered_lines().skip_obsidian_comments().into_iter().collect();
1644
1645 assert!(
1647 lines.iter().any(|l| l.content.contains("The syntax is")),
1648 "Should include line with %% in code span"
1649 );
1650 assert!(
1651 lines.iter().any(|l| l.content.contains("in Obsidian")),
1652 "Should include text after code span"
1653 );
1654 }
1655
1656 #[test]
1657 fn test_skip_obsidian_comments_in_inline_code_multi_backtick() {
1658 let content = r#"# Heading
1660
1661The syntax is ``%%comment%%`` in Obsidian.
1662
1663Content."#;
1664 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1665 let lines: Vec<_> = ctx.filtered_lines().skip_obsidian_comments().into_iter().collect();
1666
1667 assert!(
1668 lines.iter().any(|l| l.content.contains("The syntax is")),
1669 "Should include line with %% in multi-backtick code span"
1670 );
1671 assert!(
1672 lines.iter().any(|l| l.content.contains("Content")),
1673 "Should include content after code span"
1674 );
1675 }
1676
1677 #[test]
1678 fn test_skip_obsidian_comments_consecutive_blocks() {
1679 let content = r#"# Heading
1681
1682%%comment 1%%
1683
1684%%comment 2%%
1685
1686Content."#;
1687 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1688 let lines: Vec<_> = ctx.filtered_lines().skip_obsidian_comments().into_iter().collect();
1689
1690 assert!(
1691 lines.iter().any(|l| l.content.contains("# Heading")),
1692 "Should include heading"
1693 );
1694 assert!(
1695 lines.iter().any(|l| l.content.contains("Content")),
1696 "Should include content after comments"
1697 );
1698 }
1699
1700 #[test]
1701 fn test_skip_obsidian_comments_spanning_many_lines() {
1702 let content = r#"# Title
1704
1705%%
1706Line 1 of comment
1707Line 2 of comment
1708Line 3 of comment
1709Line 4 of comment
1710Line 5 of comment
1711%%
1712
1713After comment."#;
1714 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1715 let lines: Vec<_> = ctx.filtered_lines().skip_obsidian_comments().into_iter().collect();
1716
1717 for i in 1..=5 {
1719 assert!(
1720 !lines
1721 .iter()
1722 .any(|l| l.content.contains(&format!("Line {i} of comment"))),
1723 "Should exclude line {i} of comment"
1724 );
1725 }
1726
1727 assert!(
1728 lines.iter().any(|l| l.content.contains("# Title")),
1729 "Should include title"
1730 );
1731 assert!(
1732 lines.iter().any(|l| l.content.contains("After comment")),
1733 "Should include content after comment"
1734 );
1735 }
1736
1737 #[test]
1738 fn test_obsidian_comment_line_info_field() {
1739 let content = "visible\n%%\nhidden\n%%\nvisible";
1741 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1742
1743 assert!(
1745 !ctx.lines[0].in_obsidian_comment,
1746 "Line 0 should not be marked as in_obsidian_comment"
1747 );
1748
1749 assert!(
1751 ctx.lines[2].in_obsidian_comment,
1752 "Line 2 (hidden) should be marked as in_obsidian_comment"
1753 );
1754
1755 assert!(
1757 !ctx.lines[4].in_obsidian_comment,
1758 "Line 4 should not be marked as in_obsidian_comment"
1759 );
1760 }
1761}