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