rumdl_lib/
filtered_lines.rs

1//! Filtered line iteration for markdown linting
2//!
3//! This module provides a zero-cost abstraction for iterating over markdown lines
4//! while automatically filtering out non-content regions like front matter, code blocks,
5//! and HTML blocks. This ensures rules only process actual markdown content.
6//!
7//! # Architecture
8//!
9//! The filtered iterator approach centralizes the logic of what content should be
10//! processed by rules, eliminating error-prone manual checks in each rule implementation.
11//!
12//! # Examples
13//!
14//! ```rust
15//! use rumdl_lib::lint_context::LintContext;
16//! use rumdl_lib::filtered_lines::FilteredLinesExt;
17//!
18//! let content = "---\nurl: http://example.com\n---\n\n# Title\n\nContent";
19//! let ctx = LintContext::new(content, rumdl_lib::config::MarkdownFlavor::Standard, None);
20//!
21//! // Simple: get all content lines (skips front matter by default)
22//! for line in ctx.content_lines() {
23//!     println!("Line {}: {}", line.line_num, line.content);
24//! }
25//!
26//! // Advanced: custom filter configuration
27//! for line in ctx.filtered_lines()
28//!     .skip_code_blocks()
29//!     .skip_front_matter()
30//!     .skip_html_blocks() {
31//!     println!("Line {}: {}", line.line_num, line.content);
32//! }
33//! ```
34
35use crate::lint_context::{LineInfo, LintContext};
36
37/// A single line from a filtered iteration, with guaranteed 1-indexed line numbers
38#[derive(Debug, Clone)]
39pub struct FilteredLine<'a> {
40    /// The 1-indexed line number in the original document
41    pub line_num: usize,
42    /// Reference to the line's metadata
43    pub line_info: &'a LineInfo,
44    /// The actual line content
45    pub content: &'a str,
46}
47
48/// Configuration for filtering lines during iteration
49///
50/// Use the builder pattern to configure which types of content should be skipped:
51///
52/// ```rust
53/// use rumdl_lib::filtered_lines::LineFilterConfig;
54///
55/// let config = LineFilterConfig::new()
56///     .skip_front_matter()
57///     .skip_code_blocks()
58///     .skip_html_blocks()
59///     .skip_html_comments()
60///     .skip_mkdocstrings()
61///     .skip_esm_blocks();
62/// ```
63#[derive(Debug, Clone, Default)]
64pub struct LineFilterConfig {
65    /// Skip lines inside front matter (YAML/TOML/JSON metadata)
66    pub skip_front_matter: bool,
67    /// Skip lines inside fenced code blocks
68    pub skip_code_blocks: bool,
69    /// Skip lines inside HTML blocks
70    pub skip_html_blocks: bool,
71    /// Skip lines inside HTML comments
72    pub skip_html_comments: bool,
73    /// Skip lines inside mkdocstrings blocks
74    pub skip_mkdocstrings: bool,
75    /// Skip lines inside ESM (ECMAScript Module) blocks
76    pub skip_esm_blocks: bool,
77    /// Skip lines inside math blocks ($$ ... $$)
78    pub skip_math_blocks: bool,
79}
80
81impl LineFilterConfig {
82    /// Create a new filter configuration with all filters disabled
83    #[must_use]
84    pub fn new() -> Self {
85        Self::default()
86    }
87
88    /// Skip lines that are part of front matter (YAML/TOML/JSON)
89    ///
90    /// Front matter is metadata at the start of a markdown file and should
91    /// not be processed by markdown linting rules.
92    #[must_use]
93    pub fn skip_front_matter(mut self) -> Self {
94        self.skip_front_matter = true;
95        self
96    }
97
98    /// Skip lines inside fenced code blocks
99    ///
100    /// Code blocks contain source code, not markdown, and most rules should
101    /// not process them.
102    #[must_use]
103    pub fn skip_code_blocks(mut self) -> Self {
104        self.skip_code_blocks = true;
105        self
106    }
107
108    /// Skip lines inside HTML blocks
109    ///
110    /// HTML blocks contain raw HTML and most markdown rules should not
111    /// process them.
112    #[must_use]
113    pub fn skip_html_blocks(mut self) -> Self {
114        self.skip_html_blocks = true;
115        self
116    }
117
118    /// Skip lines inside HTML comments
119    ///
120    /// HTML comments (<!-- ... -->) are metadata and should not be processed
121    /// by most markdown linting rules.
122    #[must_use]
123    pub fn skip_html_comments(mut self) -> Self {
124        self.skip_html_comments = true;
125        self
126    }
127
128    /// Skip lines inside mkdocstrings blocks
129    ///
130    /// Mkdocstrings blocks contain auto-generated documentation and most
131    /// markdown rules should not process them.
132    #[must_use]
133    pub fn skip_mkdocstrings(mut self) -> Self {
134        self.skip_mkdocstrings = true;
135        self
136    }
137
138    /// Skip lines inside ESM (ECMAScript Module) blocks
139    ///
140    /// ESM blocks contain JavaScript/TypeScript module code and most
141    /// markdown rules should not process them.
142    #[must_use]
143    pub fn skip_esm_blocks(mut self) -> Self {
144        self.skip_esm_blocks = true;
145        self
146    }
147
148    /// Skip lines inside math blocks ($$ ... $$)
149    ///
150    /// Math blocks contain LaTeX/mathematical notation and markdown rules
151    /// should not process them as regular markdown content.
152    #[must_use]
153    pub fn skip_math_blocks(mut self) -> Self {
154        self.skip_math_blocks = true;
155        self
156    }
157
158    /// Check if a line should be filtered out based on this configuration
159    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
170/// Iterator that yields filtered lines based on configuration
171pub 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    /// Create a new filtered lines iterator
180    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            // Check if this line should be filtered
201            if self.config.should_filter(&lines[idx]) {
202                continue;
203            }
204
205            // Get the actual line content from the document
206            let line_content = self.content_lines.get(idx).copied().unwrap_or("");
207
208            // Return the filtered line with 1-indexed line number
209            return Some(FilteredLine {
210                line_num: idx + 1, // Convert 0-indexed to 1-indexed
211                line_info: &lines[idx],
212                content: line_content,
213            });
214        }
215
216        None
217    }
218}
219
220/// Extension trait that adds filtered iteration methods to `LintContext`
221///
222/// This trait provides convenient methods for iterating over lines while
223/// automatically filtering out non-content regions.
224pub trait FilteredLinesExt {
225    /// Start building a filtered lines iterator
226    ///
227    /// Returns a `LineFilterConfig` builder that can be used to configure
228    /// which types of content should be filtered out.
229    ///
230    /// # Examples
231    ///
232    /// ```rust
233    /// use rumdl_lib::lint_context::LintContext;
234    /// use rumdl_lib::filtered_lines::FilteredLinesExt;
235    ///
236    /// let content = "# Title\n\n```rust\ncode\n```\n\nContent";
237    /// let ctx = LintContext::new(content, rumdl_lib::config::MarkdownFlavor::Standard, None);
238    ///
239    /// for line in ctx.filtered_lines().skip_code_blocks() {
240    ///     println!("Line {}: {}", line.line_num, line.content);
241    /// }
242    /// ```
243    fn filtered_lines(&self) -> FilteredLinesBuilder<'_>;
244
245    /// Get an iterator over content lines only
246    ///
247    /// This is a convenience method that returns an iterator with front matter
248    /// filtered out by default. This is the most common use case for rules that
249    /// should only process markdown content.
250    ///
251    /// Equivalent to: `ctx.filtered_lines().skip_front_matter()`
252    ///
253    /// # Examples
254    ///
255    /// ```rust
256    /// use rumdl_lib::lint_context::LintContext;
257    /// use rumdl_lib::filtered_lines::FilteredLinesExt;
258    ///
259    /// let content = "---\ntitle: Test\n---\n\n# Content";
260    /// let ctx = LintContext::new(content, rumdl_lib::config::MarkdownFlavor::Standard, None);
261    ///
262    /// for line in ctx.content_lines() {
263    ///     // Front matter is automatically skipped
264    ///     println!("Line {}: {}", line.line_num, line.content);
265    /// }
266    /// ```
267    fn content_lines(&self) -> FilteredLinesIter<'_>;
268}
269
270/// Builder type that allows chaining filter configuration and converting to an iterator
271pub 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    /// Skip lines that are part of front matter (YAML/TOML/JSON)
285    #[must_use]
286    pub fn skip_front_matter(mut self) -> Self {
287        self.config = self.config.skip_front_matter();
288        self
289    }
290
291    /// Skip lines inside fenced code blocks
292    #[must_use]
293    pub fn skip_code_blocks(mut self) -> Self {
294        self.config = self.config.skip_code_blocks();
295        self
296    }
297
298    /// Skip lines inside HTML blocks
299    #[must_use]
300    pub fn skip_html_blocks(mut self) -> Self {
301        self.config = self.config.skip_html_blocks();
302        self
303    }
304
305    /// Skip lines inside HTML comments
306    #[must_use]
307    pub fn skip_html_comments(mut self) -> Self {
308        self.config = self.config.skip_html_comments();
309        self
310    }
311
312    /// Skip lines inside mkdocstrings blocks
313    #[must_use]
314    pub fn skip_mkdocstrings(mut self) -> Self {
315        self.config = self.config.skip_mkdocstrings();
316        self
317    }
318
319    /// Skip lines inside ESM (ECMAScript Module) blocks
320    #[must_use]
321    pub fn skip_esm_blocks(mut self) -> Self {
322        self.config = self.config.skip_esm_blocks();
323        self
324    }
325
326    /// Skip lines inside math blocks ($$ ... $$)
327    #[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        // After front matter (lines 1-4), we have: empty line, "# Content", empty line, "More content"
376        assert_eq!(lines.len(), 4);
377        assert_eq!(lines[0].line_num, 5); // First line after front matter
378        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); // Empty line + "# Content"
394        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); // Empty line + "# Content"
406        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        // Should have: "# Title", empty line, "```rust" fence, "```" fence, empty line, "Content"
419        // Wait, actually code blocks include the fences. Let me check the line_info
420        // Looking at the implementation, in_code_block is true for lines INSIDE code blocks
421        // The fences themselves are not marked as in_code_block
422        assert!(lines.iter().any(|l| l.content == "# Title"));
423        assert!(lines.iter().any(|l| l.content == "Content"));
424        // The actual code lines should be filtered out
425        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        // With no filters, all lines should be included
435        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        // Should skip front matter (lines 1-3) and code block content (line 8)
452        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        // content_lines() should automatically skip front matter
478        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        // Test that builder pattern works smoothly
511        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        // If this compiles and runs, the builder pattern is working
520    }
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            // Should be able to access line_info fields
529            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        // Verify lines OUTSIDE mkdocstrings blocks are INCLUDED
554        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        // Verify lines INSIDE mkdocstrings blocks are EXCLUDED
568        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        // Verify line numbers are preserved (1-indexed)
582        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        // Verify lines OUTSIDE ESM blocks are INCLUDED
602        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        // Verify lines INSIDE ESM blocks (at top of file) are EXCLUDED
616        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        // ESM blocks end once markdown starts, so import after markdown is NOT in ESM block
629        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        // Verify line numbers are preserved
635        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        // Verify markdown content is INCLUDED
678        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        // Verify all filtered content is EXCLUDED
688        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        // Verify lines OUTSIDE math blocks are INCLUDED
734        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        // Verify lines INSIDE math blocks are EXCLUDED
748        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        // Check that the $$ inside code block doesn't start a math block
783        let lines: Vec<_> = ctx.filtered_lines().skip_math_blocks().into_iter().collect();
784
785        // The $$ inside the code block should NOT trigger math block detection
786        // So when we skip math blocks, the code block content is still there (until we also skip code blocks)
787        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        // But the real math block content should be excluded
793        assert!(
794            !lines.iter().any(|l| l.content == "y = 2"),
795            "Actual math block content should be excluded"
796        );
797    }
798}