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