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