Skip to main content

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///     .skip_quarto_divs();
63/// ```
64#[derive(Debug, Clone, Default)]
65pub struct LineFilterConfig {
66    /// Skip lines inside front matter (YAML/TOML/JSON metadata)
67    pub skip_front_matter: bool,
68    /// Skip lines inside fenced code blocks
69    pub skip_code_blocks: bool,
70    /// Skip lines inside HTML blocks
71    pub skip_html_blocks: bool,
72    /// Skip lines inside HTML comments
73    pub skip_html_comments: bool,
74    /// Skip lines inside mkdocstrings blocks
75    pub skip_mkdocstrings: bool,
76    /// Skip lines inside ESM (ECMAScript Module) blocks
77    pub skip_esm_blocks: bool,
78    /// Skip lines inside math blocks ($$ ... $$)
79    pub skip_math_blocks: bool,
80    /// Skip lines inside Quarto div blocks (::: ... :::)
81    pub skip_quarto_divs: bool,
82    /// Skip lines containing or inside JSX expressions (MDX: {expression})
83    pub skip_jsx_expressions: bool,
84    /// Skip lines inside MDX comments ({/* ... */})
85    pub skip_mdx_comments: bool,
86    /// Skip lines inside MkDocs admonitions (!!! or ???)
87    pub skip_admonitions: bool,
88    /// Skip lines inside MkDocs content tabs (=== "Tab")
89    pub skip_content_tabs: bool,
90    /// Skip lines inside HTML blocks with markdown attribute (MkDocs grid cards, etc.)
91    pub skip_mkdocs_html_markdown: bool,
92    /// Skip lines inside definition lists (:  definition)
93    pub skip_definition_lists: bool,
94    /// Skip lines inside Obsidian comments (%%...%%)
95    pub skip_obsidian_comments: bool,
96    /// Skip lines inside PyMdown Blocks (/// ... ///, MkDocs flavor only)
97    pub skip_pymdown_blocks: bool,
98    /// Skip lines that are div markers (::: opening or closing)
99    /// Unlike `skip_quarto_divs` which skips ALL content inside divs,
100    /// this only skips the marker lines themselves (structural delimiters)
101    pub skip_div_markers: bool,
102}
103
104impl LineFilterConfig {
105    /// Create a new filter configuration with all filters disabled
106    #[must_use]
107    pub fn new() -> Self {
108        Self::default()
109    }
110
111    /// Skip lines that are part of front matter (YAML/TOML/JSON)
112    ///
113    /// Front matter is metadata at the start of a markdown file and should
114    /// not be processed by markdown linting rules.
115    #[must_use]
116    pub fn skip_front_matter(mut self) -> Self {
117        self.skip_front_matter = true;
118        self
119    }
120
121    /// Skip lines inside fenced code blocks
122    ///
123    /// Code blocks contain source code, not markdown, and most rules should
124    /// not process them.
125    #[must_use]
126    pub fn skip_code_blocks(mut self) -> Self {
127        self.skip_code_blocks = true;
128        self
129    }
130
131    /// Skip lines inside HTML blocks
132    ///
133    /// HTML blocks contain raw HTML and most markdown rules should not
134    /// process them.
135    #[must_use]
136    pub fn skip_html_blocks(mut self) -> Self {
137        self.skip_html_blocks = true;
138        self
139    }
140
141    /// Skip lines inside HTML comments
142    ///
143    /// HTML comments (<!-- ... -->) are metadata and should not be processed
144    /// by most markdown linting rules.
145    #[must_use]
146    pub fn skip_html_comments(mut self) -> Self {
147        self.skip_html_comments = true;
148        self
149    }
150
151    /// Skip lines inside mkdocstrings blocks
152    ///
153    /// Mkdocstrings blocks contain auto-generated documentation and most
154    /// markdown rules should not process them.
155    #[must_use]
156    pub fn skip_mkdocstrings(mut self) -> Self {
157        self.skip_mkdocstrings = true;
158        self
159    }
160
161    /// Skip lines inside ESM (ECMAScript Module) blocks
162    ///
163    /// ESM blocks contain JavaScript/TypeScript module code and most
164    /// markdown rules should not process them.
165    #[must_use]
166    pub fn skip_esm_blocks(mut self) -> Self {
167        self.skip_esm_blocks = true;
168        self
169    }
170
171    /// Skip lines inside math blocks ($$ ... $$)
172    ///
173    /// Math blocks contain LaTeX/mathematical notation and markdown rules
174    /// should not process them as regular markdown content.
175    #[must_use]
176    pub fn skip_math_blocks(mut self) -> Self {
177        self.skip_math_blocks = true;
178        self
179    }
180
181    /// Skip lines inside Quarto div blocks (::: ... :::)
182    ///
183    /// Quarto divs are fenced containers for callouts, panels, and other
184    /// structured content. Rules may need to skip them for accurate processing.
185    #[must_use]
186    pub fn skip_quarto_divs(mut self) -> Self {
187        self.skip_quarto_divs = true;
188        self
189    }
190
191    /// Skip lines containing or inside JSX expressions (MDX: {expression})
192    ///
193    /// JSX expressions contain JavaScript code and most markdown rules
194    /// should not process them as regular markdown content.
195    #[must_use]
196    pub fn skip_jsx_expressions(mut self) -> Self {
197        self.skip_jsx_expressions = true;
198        self
199    }
200
201    /// Skip lines inside MDX comments ({/* ... */})
202    ///
203    /// MDX comments are metadata and should not be processed by most
204    /// markdown linting rules.
205    #[must_use]
206    pub fn skip_mdx_comments(mut self) -> Self {
207        self.skip_mdx_comments = true;
208        self
209    }
210
211    /// Skip lines inside MkDocs admonitions (!!! or ???)
212    ///
213    /// Admonitions are callout blocks and may have special formatting
214    /// that rules should not process as regular content.
215    #[must_use]
216    pub fn skip_admonitions(mut self) -> Self {
217        self.skip_admonitions = true;
218        self
219    }
220
221    /// Skip lines inside MkDocs content tabs (=== "Tab")
222    ///
223    /// Content tabs contain tabbed content that may need special handling.
224    #[must_use]
225    pub fn skip_content_tabs(mut self) -> Self {
226        self.skip_content_tabs = true;
227        self
228    }
229
230    /// Skip lines inside HTML blocks with markdown attribute (MkDocs grid cards, etc.)
231    ///
232    /// These blocks contain markdown-enabled HTML which may have custom styling rules.
233    #[must_use]
234    pub fn skip_mkdocs_html_markdown(mut self) -> Self {
235        self.skip_mkdocs_html_markdown = true;
236        self
237    }
238
239    /// Skip lines inside any MkDocs container (admonitions, content tabs, or markdown HTML divs)
240    ///
241    /// This is a convenience method that enables `skip_admonitions`,
242    /// `skip_content_tabs`, and `skip_mkdocs_html_markdown`. MkDocs containers use
243    /// 4-space indented content which may need special handling to preserve structure.
244    #[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    /// Skip lines inside definition lists (:  definition)
253    ///
254    /// Definition lists have special formatting that rules should
255    /// not process as regular content.
256    #[must_use]
257    pub fn skip_definition_lists(mut self) -> Self {
258        self.skip_definition_lists = true;
259        self
260    }
261
262    /// Skip lines inside Obsidian comments (%%...%%)
263    ///
264    /// Obsidian comments are content hidden from rendering and most
265    /// markdown rules should not process them.
266    #[must_use]
267    pub fn skip_obsidian_comments(mut self) -> Self {
268        self.skip_obsidian_comments = true;
269        self
270    }
271
272    /// Skip lines inside PyMdown Blocks (/// ... ///)
273    ///
274    /// PyMdown Blocks are structured content blocks used by the PyMdown Extensions
275    /// library for captions, collapsible details, admonitions, and other features.
276    /// Rules may need to skip them for accurate processing.
277    #[must_use]
278    pub fn skip_pymdown_blocks(mut self) -> Self {
279        self.skip_pymdown_blocks = true;
280        self
281    }
282
283    /// Skip lines that are div markers (::: opening or closing)
284    ///
285    /// Unlike `skip_quarto_divs` which skips ALL lines inside a div block,
286    /// this only skips the `:::` marker lines themselves. Use this when you
287    /// want to process content inside divs but treat markers as block boundaries.
288    #[must_use]
289    pub fn skip_div_markers(mut self) -> Self {
290        self.skip_div_markers = true;
291        self
292    }
293
294    /// Check if a line should be filtered out based on this configuration
295    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
316/// Iterator that yields filtered lines based on configuration
317pub struct FilteredLinesIter<'a> {
318    ctx: &'a LintContext<'a>,
319    config: LineFilterConfig,
320    current_index: usize,
321}
322
323impl<'a> FilteredLinesIter<'a> {
324    /// Create a new filtered lines iterator
325    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            // Check if this line should be filtered
346            if self.config.should_filter(&lines[idx]) {
347                continue;
348            }
349
350            // Get the actual line content from pre-split lines
351            let line_content = raw_lines.get(idx).copied().unwrap_or("");
352
353            // Return the filtered line with 1-indexed line number
354            return Some(FilteredLine {
355                line_num: idx + 1, // Convert 0-indexed to 1-indexed
356                line_info: &lines[idx],
357                content: line_content,
358            });
359        }
360
361        None
362    }
363}
364
365/// Extension trait that adds filtered iteration methods to `LintContext`
366///
367/// This trait provides convenient methods for iterating over lines while
368/// automatically filtering out non-content regions.
369pub trait FilteredLinesExt {
370    /// Start building a filtered lines iterator
371    ///
372    /// Returns a `LineFilterConfig` builder that can be used to configure
373    /// which types of content should be filtered out.
374    ///
375    /// # Examples
376    ///
377    /// ```rust
378    /// use rumdl_lib::lint_context::LintContext;
379    /// use rumdl_lib::filtered_lines::FilteredLinesExt;
380    ///
381    /// let content = "# Title\n\n```rust\ncode\n```\n\nContent";
382    /// let ctx = LintContext::new(content, rumdl_lib::config::MarkdownFlavor::Standard, None);
383    ///
384    /// for line in ctx.filtered_lines().skip_code_blocks() {
385    ///     println!("Line {}: {}", line.line_num, line.content);
386    /// }
387    /// ```
388    fn filtered_lines(&self) -> FilteredLinesBuilder<'_>;
389
390    /// Get an iterator over content lines only
391    ///
392    /// This is a convenience method that returns an iterator with front matter
393    /// filtered out by default. This is the most common use case for rules that
394    /// should only process markdown content.
395    ///
396    /// Equivalent to: `ctx.filtered_lines().skip_front_matter()`
397    ///
398    /// # Examples
399    ///
400    /// ```rust
401    /// use rumdl_lib::lint_context::LintContext;
402    /// use rumdl_lib::filtered_lines::FilteredLinesExt;
403    ///
404    /// let content = "---\ntitle: Test\n---\n\n# Content";
405    /// let ctx = LintContext::new(content, rumdl_lib::config::MarkdownFlavor::Standard, None);
406    ///
407    /// for line in ctx.content_lines() {
408    ///     // Front matter is automatically skipped
409    ///     println!("Line {}: {}", line.line_num, line.content);
410    /// }
411    /// ```
412    fn content_lines(&self) -> FilteredLinesIter<'_>;
413}
414
415/// Builder type that allows chaining filter configuration and converting to an iterator
416pub 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    /// Skip lines that are part of front matter (YAML/TOML/JSON)
430    #[must_use]
431    pub fn skip_front_matter(mut self) -> Self {
432        self.config = self.config.skip_front_matter();
433        self
434    }
435
436    /// Skip lines inside fenced code blocks
437    #[must_use]
438    pub fn skip_code_blocks(mut self) -> Self {
439        self.config = self.config.skip_code_blocks();
440        self
441    }
442
443    /// Skip lines inside HTML blocks
444    #[must_use]
445    pub fn skip_html_blocks(mut self) -> Self {
446        self.config = self.config.skip_html_blocks();
447        self
448    }
449
450    /// Skip lines inside HTML comments
451    #[must_use]
452    pub fn skip_html_comments(mut self) -> Self {
453        self.config = self.config.skip_html_comments();
454        self
455    }
456
457    /// Skip lines inside mkdocstrings blocks
458    #[must_use]
459    pub fn skip_mkdocstrings(mut self) -> Self {
460        self.config = self.config.skip_mkdocstrings();
461        self
462    }
463
464    /// Skip lines inside ESM (ECMAScript Module) blocks
465    #[must_use]
466    pub fn skip_esm_blocks(mut self) -> Self {
467        self.config = self.config.skip_esm_blocks();
468        self
469    }
470
471    /// Skip lines inside math blocks ($$ ... $$)
472    #[must_use]
473    pub fn skip_math_blocks(mut self) -> Self {
474        self.config = self.config.skip_math_blocks();
475        self
476    }
477
478    /// Skip lines inside Quarto div blocks (::: ... :::)
479    #[must_use]
480    pub fn skip_quarto_divs(mut self) -> Self {
481        self.config = self.config.skip_quarto_divs();
482        self
483    }
484
485    /// Skip lines containing or inside JSX expressions (MDX: {expression})
486    #[must_use]
487    pub fn skip_jsx_expressions(mut self) -> Self {
488        self.config = self.config.skip_jsx_expressions();
489        self
490    }
491
492    /// Skip lines inside MDX comments ({/* ... */})
493    #[must_use]
494    pub fn skip_mdx_comments(mut self) -> Self {
495        self.config = self.config.skip_mdx_comments();
496        self
497    }
498
499    /// Skip lines inside MkDocs admonitions (!!! or ???)
500    #[must_use]
501    pub fn skip_admonitions(mut self) -> Self {
502        self.config = self.config.skip_admonitions();
503        self
504    }
505
506    /// Skip lines inside MkDocs content tabs (=== "Tab")
507    #[must_use]
508    pub fn skip_content_tabs(mut self) -> Self {
509        self.config = self.config.skip_content_tabs();
510        self
511    }
512
513    /// Skip lines inside HTML blocks with markdown attribute (MkDocs grid cards, etc.)
514    #[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    /// Skip lines inside any MkDocs container (admonitions, content tabs, or markdown HTML divs)
521    ///
522    /// This is a convenience method that enables `skip_admonitions`,
523    /// `skip_content_tabs`, and `skip_mkdocs_html_markdown`. MkDocs containers use
524    /// 4-space indented content which may need special handling to preserve structure.
525    #[must_use]
526    pub fn skip_mkdocs_containers(mut self) -> Self {
527        self.config = self.config.skip_mkdocs_containers();
528        self
529    }
530
531    /// Skip lines inside definition lists (:  definition)
532    #[must_use]
533    pub fn skip_definition_lists(mut self) -> Self {
534        self.config = self.config.skip_definition_lists();
535        self
536    }
537
538    /// Skip lines inside Obsidian comments (%%...%%)
539    #[must_use]
540    pub fn skip_obsidian_comments(mut self) -> Self {
541        self.config = self.config.skip_obsidian_comments();
542        self
543    }
544
545    /// Skip lines inside PyMdown Blocks (/// ... ///)
546    #[must_use]
547    pub fn skip_pymdown_blocks(mut self) -> Self {
548        self.config = self.config.skip_pymdown_blocks();
549        self
550    }
551
552    /// Skip lines that are div markers (::: opening or closing)
553    ///
554    /// Unlike `skip_quarto_divs` which skips ALL lines inside a div block,
555    /// this only skips the `:::` marker lines themselves.
556    #[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        // After front matter (lines 1-4), we have: empty line, "# Content", empty line, "More content"
605        assert_eq!(lines.len(), 4);
606        assert_eq!(lines[0].line_num, 5); // First line after front matter
607        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); // Empty line + "# Content"
623        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); // Empty line + "# Content"
635        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        // Should have: "# Title", empty line, "```rust" fence, "```" fence, empty line, "Content"
648        // Wait, actually code blocks include the fences. Let me check the line_info
649        // Looking at the implementation, in_code_block is true for lines INSIDE code blocks
650        // The fences themselves are not marked as in_code_block
651        assert!(lines.iter().any(|l| l.content == "# Title"));
652        assert!(lines.iter().any(|l| l.content == "Content"));
653        // The actual code lines should be filtered out
654        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        // With no filters, all lines should be included
664        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        // Should skip front matter (lines 1-3) and code block content (line 8)
681        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        // content_lines() should automatically skip front matter
707        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        // Test that builder pattern works smoothly
740        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        // If this compiles and runs, the builder pattern is working
749    }
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            // Should be able to access line_info fields
758            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        // Verify lines OUTSIDE mkdocstrings blocks are INCLUDED
783        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        // Verify lines INSIDE mkdocstrings blocks are EXCLUDED
797        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        // Verify line numbers are preserved (1-indexed)
811        assert_eq!(lines[0].line_num, 1, "First line should be line 1");
812    }
813
814    #[test]
815    fn test_skip_esm_blocks() {
816        // MDX 2.0+ allows ESM imports/exports anywhere in the document
817        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        // Verify lines OUTSIDE ESM blocks are INCLUDED
832        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        // Verify ALL ESM blocks are EXCLUDED (MDX 2.0+ allows imports anywhere)
846        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        // MDX 2.0+ allows imports anywhere - they should ALL be excluded
859        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        // Verify line numbers are preserved
865        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        // Verify markdown content is INCLUDED
908        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        // Verify all filtered content is EXCLUDED
918        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        // Verify lines OUTSIDE math blocks are INCLUDED
964        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        // Verify lines INSIDE math blocks are EXCLUDED
978        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        // Check that the $$ inside code block doesn't start a math block
1013        let lines: Vec<_> = ctx.filtered_lines().skip_math_blocks().into_iter().collect();
1014
1015        // The $$ inside the code block should NOT trigger math block detection
1016        // So when we skip math blocks, the code block content is still there (until we also skip code blocks)
1017        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        // But the real math block content should be excluded
1023        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        // Verify lines OUTSIDE Quarto divs are INCLUDED
1049        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        // Verify lines INSIDE Quarto divs are EXCLUDED
1063        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        // Verify lines OUTSIDE JSX expressions are INCLUDED
1097        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        // Verify lines with JSX expressions are EXCLUDED
1111        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        // Should include content outside all divs
1144        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        // Should exclude all div content
1154        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        // In standard flavor, Quarto divs are not detected, so nothing is skipped
1173        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        // Verify lines OUTSIDE MDX comments are INCLUDED
1197        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        // Verify lines with MDX comments are EXCLUDED
1211        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        // Test that nested braces are handled correctly
1224        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        // Verify nested braces don't break detection
1233        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        // Test both filters together
1246        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        // JSX expressions should only be detected in MDX flavor
1282        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        // In standard markdown, braces are just text - nothing should be filtered
1291        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    // ==================== Obsidian Comment Tests ====================
1298
1299    #[test]
1300    fn test_skip_obsidian_comments_simple_inline() {
1301        // Simple inline comment: text %%hidden%% text
1302        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        // All lines should be included - inline comments don't hide entire lines
1311        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        // Multi-line comment block
1328        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        // Should include content outside the comment block
1340        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        // Lines inside the comment block should be excluded
1350        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        // %% inside code blocks should NOT be treated as comments
1363        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        // The code block content should be excluded by skip_code_blocks, not by obsidian comments
1381        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        // %% inside HTML comments should NOT be treated as Obsidian comments
1394        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        // Empty comment: %%%%
1420        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        // Empty comments should be handled gracefully
1429        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        // Unclosed comment extends to end of document
1438        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        // Should include content before the unclosed comment
1447        assert!(
1448            lines.iter().any(|l| l.content.contains("# Heading")),
1449            "Should include heading before unclosed comment"
1450        );
1451
1452        // Content after the %% should be excluded
1453        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        // Multiple comments on same line
1466        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        // Line should still be included (inline comments)
1475        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        // Comment at start of line
1488        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        // Comment at end of line
1509        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        // Comments containing special markdown
1526        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        // Unicode content inside comments
1555        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        // Lines with only comments should be handled properly
1566        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        // Odd number of percent signs: %%%
1579        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        // Should handle gracefully - the %%% starts a comment, single % is content
1588        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        // Obsidian comments should NOT be detected in Standard flavor
1597        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        // In Standard flavor, %% is just text - nothing should be filtered
1606        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        // Test combining with frontmatter and code block filters
1615        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        // Should skip frontmatter, code blocks, and Obsidian comments
1638        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        // Multi-line comment should only mark lines entirely within the comment
1659        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        // First line starts before comment, should be included
1664        assert!(
1665            lines.iter().any(|l| l.content.contains("start")),
1666            "First line should be included (starts outside comment)"
1667        );
1668        // Middle line is entirely within comment, should be excluded
1669        assert!(
1670            !lines.iter().any(|l| l.content == "fully hidden"),
1671            "Middle line should be excluded (entirely within comment)"
1672        );
1673        // Last line ends after comment, should be included
1674        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        // %% inside inline code spans should NOT be treated as comments
1683        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        // The line with code span should be included
1692        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        // %% inside inline code spans with multiple backticks should NOT be treated as comments
1705        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        // Multiple consecutive comment blocks
1726        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        // Comment block spanning many lines
1749        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        // All lines inside the comment should be excluded
1764        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        // Verify the in_obsidian_comment field is set correctly
1786        let content = "visible\n%%\nhidden\n%%\nvisible";
1787        let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1788
1789        // Line 0: visible - should NOT be in comment
1790        assert!(
1791            !ctx.lines[0].in_obsidian_comment,
1792            "Line 0 should not be marked as in_obsidian_comment"
1793        );
1794
1795        // Line 2: hidden - should be in comment
1796        assert!(
1797            ctx.lines[2].in_obsidian_comment,
1798            "Line 2 (hidden) should be marked as in_obsidian_comment"
1799        );
1800
1801        // Line 4: visible - should NOT be in comment
1802        assert!(
1803            !ctx.lines[4].in_obsidian_comment,
1804            "Line 4 should not be marked as in_obsidian_comment"
1805        );
1806    }
1807
1808    // ==================== PyMdown Blocks Filter Tests ====================
1809
1810    #[test]
1811    fn test_skip_pymdown_blocks_basic() {
1812        // Basic PyMdown block (caption)
1813        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        // Should include heading and content after
1824        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        // Should NOT include content inside the block
1834        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        // Details block with summary
1843        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        // Nested blocks
1872        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        // Verify the in_pymdown_block field is set correctly
1905        let content = "visible\n/// caption\nhidden\n///\nvisible";
1906        let ctx = LintContext::new(content, MarkdownFlavor::MkDocs, None);
1907
1908        // Line 0: visible - should NOT be in block
1909        assert!(
1910            !ctx.lines[0].in_pymdown_block,
1911            "Line 0 should not be marked as in_pymdown_block"
1912        );
1913
1914        // Line 1: /// caption - should be in block
1915        assert!(
1916            ctx.lines[1].in_pymdown_block,
1917            "Line 1 (/// caption) should be marked as in_pymdown_block"
1918        );
1919
1920        // Line 2: hidden - should be in block
1921        assert!(
1922            ctx.lines[2].in_pymdown_block,
1923            "Line 2 (hidden) should be marked as in_pymdown_block"
1924        );
1925
1926        // Line 3: /// - closing should still be in block range
1927        assert!(
1928            ctx.lines[3].in_pymdown_block,
1929            "Line 3 (closing ///) should be marked as in_pymdown_block"
1930        );
1931
1932        // Line 4: visible - should NOT be in block
1933        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        // PyMdown blocks should only be detected for MkDocs flavor
1942        let content = "/// caption\nCaption text\n///";
1943
1944        // Test with MkDocs flavor - should detect block
1945        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        // Test with Standard flavor - should NOT detect block
1952        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}