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)]
64pub struct LineFilterConfig {
65 pub skip_front_matter: bool,
67 pub skip_code_blocks: bool,
69 pub skip_html_blocks: bool,
71 pub skip_html_comments: bool,
73 pub skip_mkdocstrings: bool,
75 pub skip_esm_blocks: bool,
77 pub skip_math_blocks: bool,
79}
80
81impl LineFilterConfig {
82 #[must_use]
84 pub fn new() -> Self {
85 Self::default()
86 }
87
88 #[must_use]
93 pub fn skip_front_matter(mut self) -> Self {
94 self.skip_front_matter = true;
95 self
96 }
97
98 #[must_use]
103 pub fn skip_code_blocks(mut self) -> Self {
104 self.skip_code_blocks = true;
105 self
106 }
107
108 #[must_use]
113 pub fn skip_html_blocks(mut self) -> Self {
114 self.skip_html_blocks = true;
115 self
116 }
117
118 #[must_use]
123 pub fn skip_html_comments(mut self) -> Self {
124 self.skip_html_comments = true;
125 self
126 }
127
128 #[must_use]
133 pub fn skip_mkdocstrings(mut self) -> Self {
134 self.skip_mkdocstrings = true;
135 self
136 }
137
138 #[must_use]
143 pub fn skip_esm_blocks(mut self) -> Self {
144 self.skip_esm_blocks = true;
145 self
146 }
147
148 #[must_use]
153 pub fn skip_math_blocks(mut self) -> Self {
154 self.skip_math_blocks = true;
155 self
156 }
157
158 fn should_filter(&self, line_info: &LineInfo) -> bool {
160 (self.skip_front_matter && line_info.in_front_matter)
161 || (self.skip_code_blocks && line_info.in_code_block)
162 || (self.skip_html_blocks && line_info.in_html_block)
163 || (self.skip_html_comments && line_info.in_html_comment)
164 || (self.skip_mkdocstrings && line_info.in_mkdocstrings)
165 || (self.skip_esm_blocks && line_info.in_esm_block)
166 || (self.skip_math_blocks && line_info.in_math_block)
167 }
168}
169
170pub struct FilteredLinesIter<'a> {
172 ctx: &'a LintContext<'a>,
173 config: LineFilterConfig,
174 current_index: usize,
175 content_lines: Vec<&'a str>,
176}
177
178impl<'a> FilteredLinesIter<'a> {
179 fn new(ctx: &'a LintContext<'a>, config: LineFilterConfig) -> Self {
181 Self {
182 ctx,
183 config,
184 current_index: 0,
185 content_lines: ctx.content.lines().collect(),
186 }
187 }
188}
189
190impl<'a> Iterator for FilteredLinesIter<'a> {
191 type Item = FilteredLine<'a>;
192
193 fn next(&mut self) -> Option<Self::Item> {
194 let lines = &self.ctx.lines;
195
196 while self.current_index < lines.len() {
197 let idx = self.current_index;
198 self.current_index += 1;
199
200 if self.config.should_filter(&lines[idx]) {
202 continue;
203 }
204
205 let line_content = self.content_lines.get(idx).copied().unwrap_or("");
207
208 return Some(FilteredLine {
210 line_num: idx + 1, line_info: &lines[idx],
212 content: line_content,
213 });
214 }
215
216 None
217 }
218}
219
220pub trait FilteredLinesExt {
225 fn filtered_lines(&self) -> FilteredLinesBuilder<'_>;
244
245 fn content_lines(&self) -> FilteredLinesIter<'_>;
268}
269
270pub struct FilteredLinesBuilder<'a> {
272 ctx: &'a LintContext<'a>,
273 config: LineFilterConfig,
274}
275
276impl<'a> FilteredLinesBuilder<'a> {
277 fn new(ctx: &'a LintContext<'a>) -> Self {
278 Self {
279 ctx,
280 config: LineFilterConfig::new(),
281 }
282 }
283
284 #[must_use]
286 pub fn skip_front_matter(mut self) -> Self {
287 self.config = self.config.skip_front_matter();
288 self
289 }
290
291 #[must_use]
293 pub fn skip_code_blocks(mut self) -> Self {
294 self.config = self.config.skip_code_blocks();
295 self
296 }
297
298 #[must_use]
300 pub fn skip_html_blocks(mut self) -> Self {
301 self.config = self.config.skip_html_blocks();
302 self
303 }
304
305 #[must_use]
307 pub fn skip_html_comments(mut self) -> Self {
308 self.config = self.config.skip_html_comments();
309 self
310 }
311
312 #[must_use]
314 pub fn skip_mkdocstrings(mut self) -> Self {
315 self.config = self.config.skip_mkdocstrings();
316 self
317 }
318
319 #[must_use]
321 pub fn skip_esm_blocks(mut self) -> Self {
322 self.config = self.config.skip_esm_blocks();
323 self
324 }
325
326 #[must_use]
328 pub fn skip_math_blocks(mut self) -> Self {
329 self.config = self.config.skip_math_blocks();
330 self
331 }
332}
333
334impl<'a> IntoIterator for FilteredLinesBuilder<'a> {
335 type Item = FilteredLine<'a>;
336 type IntoIter = FilteredLinesIter<'a>;
337
338 fn into_iter(self) -> Self::IntoIter {
339 FilteredLinesIter::new(self.ctx, self.config)
340 }
341}
342
343impl<'a> FilteredLinesExt for LintContext<'a> {
344 fn filtered_lines(&self) -> FilteredLinesBuilder<'_> {
345 FilteredLinesBuilder::new(self)
346 }
347
348 fn content_lines(&self) -> FilteredLinesIter<'_> {
349 FilteredLinesIter::new(self, LineFilterConfig::new().skip_front_matter())
350 }
351}
352
353#[cfg(test)]
354mod tests {
355 use super::*;
356 use crate::config::MarkdownFlavor;
357
358 #[test]
359 fn test_filtered_line_structure() {
360 let content = "# Title\n\nContent";
361 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
362
363 let line = ctx.content_lines().next().unwrap();
364 assert_eq!(line.line_num, 1);
365 assert_eq!(line.content, "# Title");
366 assert!(!line.line_info.in_front_matter);
367 }
368
369 #[test]
370 fn test_skip_front_matter_yaml() {
371 let content = "---\ntitle: Test\nurl: http://example.com\n---\n\n# Content\n\nMore content";
372 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
373
374 let lines: Vec<_> = ctx.content_lines().collect();
375 assert_eq!(lines.len(), 4);
377 assert_eq!(lines[0].line_num, 5); assert_eq!(lines[0].content, "");
379 assert_eq!(lines[1].line_num, 6);
380 assert_eq!(lines[1].content, "# Content");
381 assert_eq!(lines[2].line_num, 7);
382 assert_eq!(lines[2].content, "");
383 assert_eq!(lines[3].line_num, 8);
384 assert_eq!(lines[3].content, "More content");
385 }
386
387 #[test]
388 fn test_skip_front_matter_toml() {
389 let content = "+++\ntitle = \"Test\"\nurl = \"http://example.com\"\n+++\n\n# Content";
390 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
391
392 let lines: Vec<_> = ctx.content_lines().collect();
393 assert_eq!(lines.len(), 2); assert_eq!(lines[0].line_num, 5);
395 assert_eq!(lines[1].line_num, 6);
396 assert_eq!(lines[1].content, "# Content");
397 }
398
399 #[test]
400 fn test_skip_front_matter_json() {
401 let content = "{\n\"title\": \"Test\",\n\"url\": \"http://example.com\"\n}\n\n# Content";
402 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
403
404 let lines: Vec<_> = ctx.content_lines().collect();
405 assert_eq!(lines.len(), 2); assert_eq!(lines[0].line_num, 5);
407 assert_eq!(lines[1].line_num, 6);
408 assert_eq!(lines[1].content, "# Content");
409 }
410
411 #[test]
412 fn test_skip_code_blocks() {
413 let content = "# Title\n\n```rust\nlet x = 1;\nlet y = 2;\n```\n\nContent";
414 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
415
416 let lines: Vec<_> = ctx.filtered_lines().skip_code_blocks().into_iter().collect();
417
418 assert!(lines.iter().any(|l| l.content == "# Title"));
423 assert!(lines.iter().any(|l| l.content == "Content"));
424 assert!(!lines.iter().any(|l| l.content == "let x = 1;"));
426 assert!(!lines.iter().any(|l| l.content == "let y = 2;"));
427 }
428
429 #[test]
430 fn test_no_filters() {
431 let content = "---\ntitle: Test\n---\n\n# Content";
432 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
433
434 let lines: Vec<_> = ctx.filtered_lines().into_iter().collect();
436 assert_eq!(lines.len(), ctx.lines.len());
437 }
438
439 #[test]
440 fn test_multiple_filters() {
441 let content = "---\ntitle: Test\n---\n\n# Title\n\n```rust\ncode\n```\n\nContent";
442 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
443
444 let lines: Vec<_> = ctx
445 .filtered_lines()
446 .skip_front_matter()
447 .skip_code_blocks()
448 .into_iter()
449 .collect();
450
451 assert!(lines.iter().any(|l| l.content == "# Title"));
453 assert!(lines.iter().any(|l| l.content == "Content"));
454 assert!(!lines.iter().any(|l| l.content == "title: Test"));
455 assert!(!lines.iter().any(|l| l.content == "code"));
456 }
457
458 #[test]
459 fn test_line_numbering_is_1_indexed() {
460 let content = "First\nSecond\nThird";
461 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
462
463 let lines: Vec<_> = ctx.content_lines().collect();
464 assert_eq!(lines[0].line_num, 1);
465 assert_eq!(lines[0].content, "First");
466 assert_eq!(lines[1].line_num, 2);
467 assert_eq!(lines[1].content, "Second");
468 assert_eq!(lines[2].line_num, 3);
469 assert_eq!(lines[2].content, "Third");
470 }
471
472 #[test]
473 fn test_content_lines_convenience_method() {
474 let content = "---\nfoo: bar\n---\n\nContent";
475 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
476
477 let lines: Vec<_> = ctx.content_lines().collect();
479 assert!(!lines.iter().any(|l| l.content.contains("foo")));
480 assert!(lines.iter().any(|l| l.content == "Content"));
481 }
482
483 #[test]
484 fn test_empty_document() {
485 let content = "";
486 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
487
488 let lines: Vec<_> = ctx.content_lines().collect();
489 assert_eq!(lines.len(), 0);
490 }
491
492 #[test]
493 fn test_only_front_matter() {
494 let content = "---\ntitle: Test\n---";
495 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
496
497 let lines: Vec<_> = ctx.content_lines().collect();
498 assert_eq!(
499 lines.len(),
500 0,
501 "Document with only front matter should have no content lines"
502 );
503 }
504
505 #[test]
506 fn test_builder_pattern_ergonomics() {
507 let content = "# Title\n\n```\ncode\n```\n\nContent";
508 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
509
510 let _lines: Vec<_> = ctx
512 .filtered_lines()
513 .skip_front_matter()
514 .skip_code_blocks()
515 .skip_html_blocks()
516 .into_iter()
517 .collect();
518
519 }
521
522 #[test]
523 fn test_filtered_line_access_to_line_info() {
524 let content = "# Title\n\nContent";
525 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
526
527 for line in ctx.content_lines() {
528 assert!(!line.line_info.in_front_matter);
530 assert!(!line.line_info.in_code_block);
531 }
532 }
533
534 #[test]
535 fn test_skip_mkdocstrings() {
536 let content = r#"# API Documentation
537
538::: mymodule.MyClass
539 options:
540 show_root_heading: true
541 show_source: false
542
543Some regular content here.
544
545::: mymodule.function
546 options:
547 show_signature: true
548
549More content."#;
550 let ctx = LintContext::new(content, MarkdownFlavor::MkDocs, None);
551 let lines: Vec<_> = ctx.filtered_lines().skip_mkdocstrings().into_iter().collect();
552
553 assert!(
555 lines.iter().any(|l| l.content.contains("# API Documentation")),
556 "Should include lines outside mkdocstrings blocks"
557 );
558 assert!(
559 lines.iter().any(|l| l.content.contains("Some regular content")),
560 "Should include content between mkdocstrings blocks"
561 );
562 assert!(
563 lines.iter().any(|l| l.content.contains("More content")),
564 "Should include content after mkdocstrings blocks"
565 );
566
567 assert!(
569 !lines.iter().any(|l| l.content.contains("::: mymodule")),
570 "Should exclude mkdocstrings marker lines"
571 );
572 assert!(
573 !lines.iter().any(|l| l.content.contains("show_root_heading")),
574 "Should exclude mkdocstrings option lines"
575 );
576 assert!(
577 !lines.iter().any(|l| l.content.contains("show_signature")),
578 "Should exclude all mkdocstrings option lines"
579 );
580
581 assert_eq!(lines[0].line_num, 1, "First line should be line 1");
583 }
584
585 #[test]
586 fn test_skip_esm_blocks() {
587 let content = r#"import {Chart} from './components.js'
588import {Table} from './table.js'
589export const year = 2023
590
591# Last year's snowfall
592
593Content about snowfall data.
594
595import {Footer} from './footer.js'
596
597More content."#;
598 let ctx = LintContext::new(content, MarkdownFlavor::MDX, None);
599 let lines: Vec<_> = ctx.filtered_lines().skip_esm_blocks().into_iter().collect();
600
601 assert!(
603 lines.iter().any(|l| l.content.contains("# Last year's snowfall")),
604 "Should include markdown headings"
605 );
606 assert!(
607 lines.iter().any(|l| l.content.contains("Content about snowfall")),
608 "Should include markdown content"
609 );
610 assert!(
611 lines.iter().any(|l| l.content.contains("More content")),
612 "Should include content after ESM blocks"
613 );
614
615 assert!(
617 !lines.iter().any(|l| l.content.contains("import {Chart}")),
618 "Should exclude import statements at top of file"
619 );
620 assert!(
621 !lines.iter().any(|l| l.content.contains("import {Table}")),
622 "Should exclude all import statements at top of file"
623 );
624 assert!(
625 !lines.iter().any(|l| l.content.contains("export const year")),
626 "Should exclude export statements at top of file"
627 );
628 assert!(
630 lines.iter().any(|l| l.content.contains("import {Footer}")),
631 "Should include import statements after markdown content (not in ESM block)"
632 );
633
634 let heading_line = lines
636 .iter()
637 .find(|l| l.content.contains("# Last year's snowfall"))
638 .unwrap();
639 assert_eq!(heading_line.line_num, 5, "Heading should be on line 5");
640 }
641
642 #[test]
643 fn test_all_filters_combined() {
644 let content = r#"---
645title: Test
646---
647
648# Title
649
650```
651code
652```
653
654<!-- HTML comment here -->
655
656::: mymodule.Class
657 options:
658 show_root_heading: true
659
660<div>
661HTML block
662</div>
663
664Content"#;
665 let ctx = LintContext::new(content, MarkdownFlavor::MkDocs, None);
666
667 let lines: Vec<_> = ctx
668 .filtered_lines()
669 .skip_front_matter()
670 .skip_code_blocks()
671 .skip_html_blocks()
672 .skip_html_comments()
673 .skip_mkdocstrings()
674 .into_iter()
675 .collect();
676
677 assert!(
679 lines.iter().any(|l| l.content == "# Title"),
680 "Should include markdown headings"
681 );
682 assert!(
683 lines.iter().any(|l| l.content == "Content"),
684 "Should include markdown content"
685 );
686
687 assert!(
689 !lines.iter().any(|l| l.content == "title: Test"),
690 "Should exclude front matter"
691 );
692 assert!(
693 !lines.iter().any(|l| l.content == "code"),
694 "Should exclude code block content"
695 );
696 assert!(
697 !lines.iter().any(|l| l.content.contains("HTML comment")),
698 "Should exclude HTML comments"
699 );
700 assert!(
701 !lines.iter().any(|l| l.content.contains("::: mymodule")),
702 "Should exclude mkdocstrings blocks"
703 );
704 assert!(
705 !lines.iter().any(|l| l.content.contains("show_root_heading")),
706 "Should exclude mkdocstrings options"
707 );
708 assert!(
709 !lines.iter().any(|l| l.content.contains("HTML block")),
710 "Should exclude HTML blocks"
711 );
712 }
713
714 #[test]
715 fn test_skip_math_blocks() {
716 let content = r#"# Heading
717
718Some regular text.
719
720$$
721A = \left[
722\begin{array}{c}
7231 \\
724-D
725\end{array}
726\right]
727$$
728
729More content after math."#;
730 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
731 let lines: Vec<_> = ctx.filtered_lines().skip_math_blocks().into_iter().collect();
732
733 assert!(
735 lines.iter().any(|l| l.content.contains("# Heading")),
736 "Should include markdown headings"
737 );
738 assert!(
739 lines.iter().any(|l| l.content.contains("Some regular text")),
740 "Should include regular text before math block"
741 );
742 assert!(
743 lines.iter().any(|l| l.content.contains("More content after math")),
744 "Should include content after math block"
745 );
746
747 assert!(
749 !lines.iter().any(|l| l.content == "$$"),
750 "Should exclude math block delimiters"
751 );
752 assert!(
753 !lines.iter().any(|l| l.content.contains("\\left[")),
754 "Should exclude LaTeX content inside math block"
755 );
756 assert!(
757 !lines.iter().any(|l| l.content.contains("-D")),
758 "Should exclude content that looks like list items inside math block"
759 );
760 assert!(
761 !lines.iter().any(|l| l.content.contains("\\begin{array}")),
762 "Should exclude LaTeX array content"
763 );
764 }
765
766 #[test]
767 fn test_math_blocks_not_confused_with_code_blocks() {
768 let content = r#"# Title
769
770```python
771# This $$ is inside a code block
772x = 1
773```
774
775$$
776y = 2
777$$
778
779Regular text."#;
780 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
781
782 let lines: Vec<_> = ctx.filtered_lines().skip_math_blocks().into_iter().collect();
784
785 assert!(
788 lines.iter().any(|l| l.content.contains("# This $$")),
789 "Code block content with $$ should not be detected as math block"
790 );
791
792 assert!(
794 !lines.iter().any(|l| l.content == "y = 2"),
795 "Actual math block content should be excluded"
796 );
797 }
798}