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