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