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