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