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