use crate::lint_context::{LineInfo, LintContext};
#[derive(Debug, Clone)]
pub struct FilteredLine<'a> {
pub line_num: usize,
pub line_info: &'a LineInfo,
pub content: &'a str,
}
#[derive(Debug, Clone, Default)]
pub struct LineFilterConfig {
pub skip_front_matter: bool,
pub skip_code_blocks: bool,
pub skip_html_blocks: bool,
pub skip_html_comments: bool,
pub skip_mkdocstrings: bool,
pub skip_esm_blocks: bool,
pub skip_math_blocks: bool,
pub skip_quarto_divs: bool,
pub skip_jsx_expressions: bool,
pub skip_mdx_comments: bool,
pub skip_admonitions: bool,
pub skip_content_tabs: bool,
pub skip_mkdocs_html_markdown: bool,
pub skip_definition_lists: bool,
pub skip_obsidian_comments: bool,
pub skip_pymdown_blocks: bool,
pub skip_kramdown_extension_blocks: bool,
pub skip_div_markers: bool,
pub skip_jsx_blocks: bool,
}
impl LineFilterConfig {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn skip_front_matter(mut self) -> Self {
self.skip_front_matter = true;
self
}
#[must_use]
pub fn skip_code_blocks(mut self) -> Self {
self.skip_code_blocks = true;
self
}
#[must_use]
pub fn skip_html_blocks(mut self) -> Self {
self.skip_html_blocks = true;
self
}
#[must_use]
pub fn skip_html_comments(mut self) -> Self {
self.skip_html_comments = true;
self
}
#[must_use]
pub fn skip_mkdocstrings(mut self) -> Self {
self.skip_mkdocstrings = true;
self
}
#[must_use]
pub fn skip_esm_blocks(mut self) -> Self {
self.skip_esm_blocks = true;
self
}
#[must_use]
pub fn skip_math_blocks(mut self) -> Self {
self.skip_math_blocks = true;
self
}
#[must_use]
pub fn skip_quarto_divs(mut self) -> Self {
self.skip_quarto_divs = true;
self
}
#[must_use]
pub fn skip_jsx_expressions(mut self) -> Self {
self.skip_jsx_expressions = true;
self
}
#[must_use]
pub fn skip_mdx_comments(mut self) -> Self {
self.skip_mdx_comments = true;
self
}
#[must_use]
pub fn skip_admonitions(mut self) -> Self {
self.skip_admonitions = true;
self
}
#[must_use]
pub fn skip_content_tabs(mut self) -> Self {
self.skip_content_tabs = true;
self
}
#[must_use]
pub fn skip_mkdocs_html_markdown(mut self) -> Self {
self.skip_mkdocs_html_markdown = true;
self
}
#[must_use]
pub fn skip_mkdocs_containers(mut self) -> Self {
self.skip_admonitions = true;
self.skip_content_tabs = true;
self.skip_mkdocs_html_markdown = true;
self
}
#[must_use]
pub fn skip_definition_lists(mut self) -> Self {
self.skip_definition_lists = true;
self
}
#[must_use]
pub fn skip_obsidian_comments(mut self) -> Self {
self.skip_obsidian_comments = true;
self
}
#[must_use]
pub fn skip_pymdown_blocks(mut self) -> Self {
self.skip_pymdown_blocks = true;
self
}
#[must_use]
pub fn skip_kramdown_extension_blocks(mut self) -> Self {
self.skip_kramdown_extension_blocks = true;
self
}
#[must_use]
pub fn skip_div_markers(mut self) -> Self {
self.skip_div_markers = true;
self
}
#[must_use]
pub fn skip_jsx_blocks(mut self) -> Self {
self.skip_jsx_blocks = true;
self
}
fn should_filter(&self, line_info: &LineInfo) -> bool {
line_info.in_kramdown_extension_block
|| (self.skip_front_matter && line_info.in_front_matter)
|| (self.skip_code_blocks && line_info.in_code_block)
|| (self.skip_html_blocks && line_info.in_html_block)
|| (self.skip_html_comments && line_info.in_html_comment)
|| (self.skip_mkdocstrings && line_info.in_mkdocstrings)
|| (self.skip_esm_blocks && line_info.in_esm_block)
|| (self.skip_math_blocks && line_info.in_math_block)
|| (self.skip_quarto_divs && line_info.in_quarto_div)
|| (self.skip_jsx_expressions && line_info.in_jsx_expression)
|| (self.skip_mdx_comments && line_info.in_mdx_comment)
|| (self.skip_admonitions && line_info.in_admonition)
|| (self.skip_content_tabs && line_info.in_content_tab)
|| (self.skip_mkdocs_html_markdown && line_info.in_mkdocs_html_markdown)
|| (self.skip_definition_lists && line_info.in_definition_list)
|| (self.skip_obsidian_comments && line_info.in_obsidian_comment)
|| (self.skip_pymdown_blocks && line_info.in_pymdown_block)
|| (self.skip_div_markers && line_info.is_div_marker)
|| (self.skip_jsx_blocks && line_info.in_jsx_block)
}
}
pub struct FilteredLinesIter<'a> {
ctx: &'a LintContext<'a>,
config: LineFilterConfig,
current_index: usize,
}
impl<'a> FilteredLinesIter<'a> {
fn new(ctx: &'a LintContext<'a>, config: LineFilterConfig) -> Self {
Self {
ctx,
config,
current_index: 0,
}
}
}
impl<'a> Iterator for FilteredLinesIter<'a> {
type Item = FilteredLine<'a>;
fn next(&mut self) -> Option<Self::Item> {
let lines = &self.ctx.lines;
let raw_lines = self.ctx.raw_lines();
while self.current_index < lines.len() {
let idx = self.current_index;
self.current_index += 1;
if self.config.should_filter(&lines[idx]) {
continue;
}
let line_content = raw_lines.get(idx).copied().unwrap_or("");
return Some(FilteredLine {
line_num: idx + 1, line_info: &lines[idx],
content: line_content,
});
}
None
}
}
pub trait FilteredLinesExt {
fn filtered_lines(&self) -> FilteredLinesBuilder<'_>;
fn content_lines(&self) -> FilteredLinesIter<'_>;
}
pub struct FilteredLinesBuilder<'a> {
ctx: &'a LintContext<'a>,
config: LineFilterConfig,
}
impl<'a> FilteredLinesBuilder<'a> {
fn new(ctx: &'a LintContext<'a>) -> Self {
Self {
ctx,
config: LineFilterConfig::new(),
}
}
#[must_use]
pub fn skip_front_matter(mut self) -> Self {
self.config = self.config.skip_front_matter();
self
}
#[must_use]
pub fn skip_code_blocks(mut self) -> Self {
self.config = self.config.skip_code_blocks();
self
}
#[must_use]
pub fn skip_html_blocks(mut self) -> Self {
self.config = self.config.skip_html_blocks();
self
}
#[must_use]
pub fn skip_html_comments(mut self) -> Self {
self.config = self.config.skip_html_comments();
self
}
#[must_use]
pub fn skip_mkdocstrings(mut self) -> Self {
self.config = self.config.skip_mkdocstrings();
self
}
#[must_use]
pub fn skip_esm_blocks(mut self) -> Self {
self.config = self.config.skip_esm_blocks();
self
}
#[must_use]
pub fn skip_math_blocks(mut self) -> Self {
self.config = self.config.skip_math_blocks();
self
}
#[must_use]
pub fn skip_quarto_divs(mut self) -> Self {
self.config = self.config.skip_quarto_divs();
self
}
#[must_use]
pub fn skip_jsx_expressions(mut self) -> Self {
self.config = self.config.skip_jsx_expressions();
self
}
#[must_use]
pub fn skip_mdx_comments(mut self) -> Self {
self.config = self.config.skip_mdx_comments();
self
}
#[must_use]
pub fn skip_admonitions(mut self) -> Self {
self.config = self.config.skip_admonitions();
self
}
#[must_use]
pub fn skip_content_tabs(mut self) -> Self {
self.config = self.config.skip_content_tabs();
self
}
#[must_use]
pub fn skip_mkdocs_html_markdown(mut self) -> Self {
self.config = self.config.skip_mkdocs_html_markdown();
self
}
#[must_use]
pub fn skip_mkdocs_containers(mut self) -> Self {
self.config = self.config.skip_mkdocs_containers();
self
}
#[must_use]
pub fn skip_definition_lists(mut self) -> Self {
self.config = self.config.skip_definition_lists();
self
}
#[must_use]
pub fn skip_obsidian_comments(mut self) -> Self {
self.config = self.config.skip_obsidian_comments();
self
}
#[must_use]
pub fn skip_pymdown_blocks(mut self) -> Self {
self.config = self.config.skip_pymdown_blocks();
self
}
#[must_use]
pub fn skip_kramdown_extension_blocks(mut self) -> Self {
self.config = self.config.skip_kramdown_extension_blocks();
self
}
#[must_use]
pub fn skip_div_markers(mut self) -> Self {
self.config = self.config.skip_div_markers();
self
}
}
impl<'a> IntoIterator for FilteredLinesBuilder<'a> {
type Item = FilteredLine<'a>;
type IntoIter = FilteredLinesIter<'a>;
fn into_iter(self) -> Self::IntoIter {
FilteredLinesIter::new(self.ctx, self.config)
}
}
impl<'a> FilteredLinesExt for LintContext<'a> {
fn filtered_lines(&self) -> FilteredLinesBuilder<'_> {
FilteredLinesBuilder::new(self)
}
fn content_lines(&self) -> FilteredLinesIter<'_> {
FilteredLinesIter::new(self, LineFilterConfig::new().skip_front_matter())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::MarkdownFlavor;
#[test]
fn test_filtered_line_structure() {
let content = "# Title\n\nContent";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
let line = ctx.content_lines().next().unwrap();
assert_eq!(line.line_num, 1);
assert_eq!(line.content, "# Title");
assert!(!line.line_info.in_front_matter);
}
#[test]
fn test_skip_front_matter_yaml() {
let content = "---\ntitle: Test\nurl: http://example.com\n---\n\n# Content\n\nMore content";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
let lines: Vec<_> = ctx.content_lines().collect();
assert_eq!(lines.len(), 4);
assert_eq!(lines[0].line_num, 5); assert_eq!(lines[0].content, "");
assert_eq!(lines[1].line_num, 6);
assert_eq!(lines[1].content, "# Content");
assert_eq!(lines[2].line_num, 7);
assert_eq!(lines[2].content, "");
assert_eq!(lines[3].line_num, 8);
assert_eq!(lines[3].content, "More content");
}
#[test]
fn test_skip_front_matter_toml() {
let content = "+++\ntitle = \"Test\"\nurl = \"http://example.com\"\n+++\n\n# Content";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
let lines: Vec<_> = ctx.content_lines().collect();
assert_eq!(lines.len(), 2); assert_eq!(lines[0].line_num, 5);
assert_eq!(lines[1].line_num, 6);
assert_eq!(lines[1].content, "# Content");
}
#[test]
fn test_skip_front_matter_json() {
let content = "{\n\"title\": \"Test\",\n\"url\": \"http://example.com\"\n}\n\n# Content";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
let lines: Vec<_> = ctx.content_lines().collect();
assert_eq!(lines.len(), 2); assert_eq!(lines[0].line_num, 5);
assert_eq!(lines[1].line_num, 6);
assert_eq!(lines[1].content, "# Content");
}
#[test]
fn test_skip_code_blocks() {
let content = "# Title\n\n```rust\nlet x = 1;\nlet y = 2;\n```\n\nContent";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
let lines: Vec<_> = ctx.filtered_lines().skip_code_blocks().into_iter().collect();
assert!(lines.iter().any(|l| l.content == "# Title"));
assert!(lines.iter().any(|l| l.content == "Content"));
assert!(!lines.iter().any(|l| l.content == "let x = 1;"));
assert!(!lines.iter().any(|l| l.content == "let y = 2;"));
}
#[test]
fn test_no_filters() {
let content = "---\ntitle: Test\n---\n\n# Content";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
let lines: Vec<_> = ctx.filtered_lines().into_iter().collect();
assert_eq!(lines.len(), ctx.lines.len());
}
#[test]
fn test_multiple_filters() {
let content = "---\ntitle: Test\n---\n\n# Title\n\n```rust\ncode\n```\n\nContent";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
let lines: Vec<_> = ctx
.filtered_lines()
.skip_front_matter()
.skip_code_blocks()
.into_iter()
.collect();
assert!(lines.iter().any(|l| l.content == "# Title"));
assert!(lines.iter().any(|l| l.content == "Content"));
assert!(!lines.iter().any(|l| l.content == "title: Test"));
assert!(!lines.iter().any(|l| l.content == "code"));
}
#[test]
fn test_line_numbering_is_1_indexed() {
let content = "First\nSecond\nThird";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
let lines: Vec<_> = ctx.content_lines().collect();
assert_eq!(lines[0].line_num, 1);
assert_eq!(lines[0].content, "First");
assert_eq!(lines[1].line_num, 2);
assert_eq!(lines[1].content, "Second");
assert_eq!(lines[2].line_num, 3);
assert_eq!(lines[2].content, "Third");
}
#[test]
fn test_content_lines_convenience_method() {
let content = "---\nfoo: bar\n---\n\nContent";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
let lines: Vec<_> = ctx.content_lines().collect();
assert!(!lines.iter().any(|l| l.content.contains("foo")));
assert!(lines.iter().any(|l| l.content == "Content"));
}
#[test]
fn test_empty_document() {
let content = "";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
let lines: Vec<_> = ctx.content_lines().collect();
assert_eq!(lines.len(), 0);
}
#[test]
fn test_only_front_matter() {
let content = "---\ntitle: Test\n---";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
let lines: Vec<_> = ctx.content_lines().collect();
assert_eq!(
lines.len(),
0,
"Document with only front matter should have no content lines"
);
}
#[test]
fn test_builder_pattern_ergonomics() {
let content = "# Title\n\n```\ncode\n```\n\nContent";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
let _lines: Vec<_> = ctx
.filtered_lines()
.skip_front_matter()
.skip_code_blocks()
.skip_html_blocks()
.into_iter()
.collect();
}
#[test]
fn test_filtered_line_access_to_line_info() {
let content = "# Title\n\nContent";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
for line in ctx.content_lines() {
assert!(!line.line_info.in_front_matter);
assert!(!line.line_info.in_code_block);
}
}
#[test]
fn test_skip_mkdocstrings() {
let content = r#"# API Documentation
::: mymodule.MyClass
options:
show_root_heading: true
show_source: false
Some regular content here.
::: mymodule.function
options:
show_signature: true
More content."#;
let ctx = LintContext::new(content, MarkdownFlavor::MkDocs, None);
let lines: Vec<_> = ctx.filtered_lines().skip_mkdocstrings().into_iter().collect();
assert!(
lines.iter().any(|l| l.content.contains("# API Documentation")),
"Should include lines outside mkdocstrings blocks"
);
assert!(
lines.iter().any(|l| l.content.contains("Some regular content")),
"Should include content between mkdocstrings blocks"
);
assert!(
lines.iter().any(|l| l.content.contains("More content")),
"Should include content after mkdocstrings blocks"
);
assert!(
!lines.iter().any(|l| l.content.contains("::: mymodule")),
"Should exclude mkdocstrings marker lines"
);
assert!(
!lines.iter().any(|l| l.content.contains("show_root_heading")),
"Should exclude mkdocstrings option lines"
);
assert!(
!lines.iter().any(|l| l.content.contains("show_signature")),
"Should exclude all mkdocstrings option lines"
);
assert_eq!(lines[0].line_num, 1, "First line should be line 1");
}
#[test]
fn test_skip_esm_blocks() {
let content = r#"import {Chart} from './components.js'
import {Table} from './table.js'
export const year = 2023
# Last year's snowfall
Content about snowfall data.
import {Footer} from './footer.js'
More content."#;
let ctx = LintContext::new(content, MarkdownFlavor::MDX, None);
let lines: Vec<_> = ctx.filtered_lines().skip_esm_blocks().into_iter().collect();
assert!(
lines.iter().any(|l| l.content.contains("# Last year's snowfall")),
"Should include markdown headings"
);
assert!(
lines.iter().any(|l| l.content.contains("Content about snowfall")),
"Should include markdown content"
);
assert!(
lines.iter().any(|l| l.content.contains("More content")),
"Should include content after ESM blocks"
);
assert!(
!lines.iter().any(|l| l.content.contains("import {Chart}")),
"Should exclude import statements at top of file"
);
assert!(
!lines.iter().any(|l| l.content.contains("import {Table}")),
"Should exclude all import statements at top of file"
);
assert!(
!lines.iter().any(|l| l.content.contains("export const year")),
"Should exclude export statements at top of file"
);
assert!(
!lines.iter().any(|l| l.content.contains("import {Footer}")),
"Should exclude import statements even after markdown content (MDX 2.0+ ESM anywhere)"
);
let heading_line = lines
.iter()
.find(|l| l.content.contains("# Last year's snowfall"))
.unwrap();
assert_eq!(heading_line.line_num, 5, "Heading should be on line 5");
}
#[test]
fn test_all_filters_combined() {
let content = r#"---
title: Test
---
# Title
```
code
```
<!-- HTML comment here -->
::: mymodule.Class
options:
show_root_heading: true
<div>
HTML block
</div>
Content"#;
let ctx = LintContext::new(content, MarkdownFlavor::MkDocs, None);
let lines: Vec<_> = ctx
.filtered_lines()
.skip_front_matter()
.skip_code_blocks()
.skip_html_blocks()
.skip_html_comments()
.skip_mkdocstrings()
.into_iter()
.collect();
assert!(
lines.iter().any(|l| l.content == "# Title"),
"Should include markdown headings"
);
assert!(
lines.iter().any(|l| l.content == "Content"),
"Should include markdown content"
);
assert!(
!lines.iter().any(|l| l.content == "title: Test"),
"Should exclude front matter"
);
assert!(
!lines.iter().any(|l| l.content == "code"),
"Should exclude code block content"
);
assert!(
!lines.iter().any(|l| l.content.contains("HTML comment")),
"Should exclude HTML comments"
);
assert!(
!lines.iter().any(|l| l.content.contains("::: mymodule")),
"Should exclude mkdocstrings blocks"
);
assert!(
!lines.iter().any(|l| l.content.contains("show_root_heading")),
"Should exclude mkdocstrings options"
);
assert!(
!lines.iter().any(|l| l.content.contains("HTML block")),
"Should exclude HTML blocks"
);
}
#[test]
fn test_skip_math_blocks() {
let content = r#"# Heading
Some regular text.
$$
A = \left[
\begin{array}{c}
1 \\
-D
\end{array}
\right]
$$
More content after math."#;
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
let lines: Vec<_> = ctx.filtered_lines().skip_math_blocks().into_iter().collect();
assert!(
lines.iter().any(|l| l.content.contains("# Heading")),
"Should include markdown headings"
);
assert!(
lines.iter().any(|l| l.content.contains("Some regular text")),
"Should include regular text before math block"
);
assert!(
lines.iter().any(|l| l.content.contains("More content after math")),
"Should include content after math block"
);
assert!(
!lines.iter().any(|l| l.content == "$$"),
"Should exclude math block delimiters"
);
assert!(
!lines.iter().any(|l| l.content.contains("\\left[")),
"Should exclude LaTeX content inside math block"
);
assert!(
!lines.iter().any(|l| l.content.contains("-D")),
"Should exclude content that looks like list items inside math block"
);
assert!(
!lines.iter().any(|l| l.content.contains("\\begin{array}")),
"Should exclude LaTeX array content"
);
}
#[test]
fn test_math_blocks_not_confused_with_code_blocks() {
let content = r#"# Title
```python
# This $$ is inside a code block
x = 1
```
$$
y = 2
$$
Regular text."#;
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
let lines: Vec<_> = ctx.filtered_lines().skip_math_blocks().into_iter().collect();
assert!(
lines.iter().any(|l| l.content.contains("# This $$")),
"Code block content with $$ should not be detected as math block"
);
assert!(
!lines.iter().any(|l| l.content == "y = 2"),
"Actual math block content should be excluded"
);
}
#[test]
fn test_skip_quarto_divs() {
let content = r#"# Heading
::: {.callout-note}
This is a callout note.
With multiple lines.
:::
Regular text outside.
::: {.bordered}
Content inside bordered div.
:::
More content."#;
let ctx = LintContext::new(content, MarkdownFlavor::Quarto, None);
let lines: Vec<_> = ctx.filtered_lines().skip_quarto_divs().into_iter().collect();
assert!(
lines.iter().any(|l| l.content.contains("# Heading")),
"Should include markdown headings"
);
assert!(
lines.iter().any(|l| l.content.contains("Regular text outside")),
"Should include content between divs"
);
assert!(
lines.iter().any(|l| l.content.contains("More content")),
"Should include content after divs"
);
assert!(
!lines.iter().any(|l| l.content.contains("::: {.callout-note}")),
"Should exclude callout div markers"
);
assert!(
!lines.iter().any(|l| l.content.contains("This is a callout note")),
"Should exclude callout content"
);
assert!(
!lines.iter().any(|l| l.content.contains("Content inside bordered")),
"Should exclude bordered div content"
);
}
#[test]
fn test_skip_jsx_expressions() {
let content = r#"# MDX Document
Here is some content with {myVariable} inline.
{items.map(item => (
<Item key={item.id} />
))}
Regular paragraph after expression.
{/* This should NOT be skipped by jsx_expressions filter */}
{/* MDX comments have their own filter */}
More content."#;
let ctx = LintContext::new(content, MarkdownFlavor::MDX, None);
let lines: Vec<_> = ctx.filtered_lines().skip_jsx_expressions().into_iter().collect();
assert!(
lines.iter().any(|l| l.content.contains("# MDX Document")),
"Should include markdown headings"
);
assert!(
lines.iter().any(|l| l.content.contains("Regular paragraph")),
"Should include regular paragraphs"
);
assert!(
lines.iter().any(|l| l.content.contains("More content")),
"Should include content after expressions"
);
assert!(
!lines.iter().any(|l| l.content.contains("{myVariable}")),
"Should exclude lines with inline JSX expressions"
);
assert!(
!lines.iter().any(|l| l.content.contains("items.map")),
"Should exclude multi-line JSX expression content"
);
assert!(
!lines.iter().any(|l| l.content.contains("<Item key")),
"Should exclude JSX inside expressions"
);
}
#[test]
fn test_skip_quarto_divs_nested() {
let content = r#"# Title
::: {.outer}
Outer content.
::: {.inner}
Inner content.
:::
Back to outer.
:::
Outside text."#;
let ctx = LintContext::new(content, MarkdownFlavor::Quarto, None);
let lines: Vec<_> = ctx.filtered_lines().skip_quarto_divs().into_iter().collect();
assert!(
lines.iter().any(|l| l.content.contains("# Title")),
"Should include heading"
);
assert!(
lines.iter().any(|l| l.content.contains("Outside text")),
"Should include text after divs"
);
assert!(
!lines.iter().any(|l| l.content.contains("Outer content")),
"Should exclude outer div content"
);
assert!(
!lines.iter().any(|l| l.content.contains("Inner content")),
"Should exclude inner div content"
);
}
#[test]
fn test_skip_quarto_divs_not_in_standard_flavor() {
let content = r#"::: {.callout-note}
This should NOT be skipped in standard flavor.
:::"#;
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
let lines: Vec<_> = ctx.filtered_lines().skip_quarto_divs().into_iter().collect();
assert!(
lines.iter().any(|l| l.content.contains("This should NOT be skipped")),
"Standard flavor should not detect Quarto divs"
);
}
#[test]
fn test_skip_mdx_comments() {
let content = r#"# MDX Document
{/* This is an MDX comment */}
Regular content here.
{/*
Multi-line
MDX comment
*/}
More content after comment."#;
let ctx = LintContext::new(content, MarkdownFlavor::MDX, None);
let lines: Vec<_> = ctx.filtered_lines().skip_mdx_comments().into_iter().collect();
assert!(
lines.iter().any(|l| l.content.contains("# MDX Document")),
"Should include markdown headings"
);
assert!(
lines.iter().any(|l| l.content.contains("Regular content")),
"Should include regular content"
);
assert!(
lines.iter().any(|l| l.content.contains("More content")),
"Should include content after comments"
);
assert!(
!lines.iter().any(|l| l.content.contains("{/* This is")),
"Should exclude single-line MDX comments"
);
assert!(
!lines.iter().any(|l| l.content.contains("Multi-line")),
"Should exclude multi-line MDX comment content"
);
}
#[test]
fn test_jsx_expressions_with_nested_braces() {
let content = r#"# Document
{props.style || {color: "red", background: "blue"}}
Regular content."#;
let ctx = LintContext::new(content, MarkdownFlavor::MDX, None);
let lines: Vec<_> = ctx.filtered_lines().skip_jsx_expressions().into_iter().collect();
assert!(
!lines.iter().any(|l| l.content.contains("props.style")),
"Should exclude JSX expression with nested braces"
);
assert!(
lines.iter().any(|l| l.content.contains("Regular content")),
"Should include content after nested expression"
);
}
#[test]
fn test_jsx_and_mdx_comments_combined() {
let content = r#"# Title
{variable}
{/* comment */}
Content."#;
let ctx = LintContext::new(content, MarkdownFlavor::MDX, None);
let lines: Vec<_> = ctx
.filtered_lines()
.skip_jsx_expressions()
.skip_mdx_comments()
.into_iter()
.collect();
assert!(
lines.iter().any(|l| l.content.contains("# Title")),
"Should include heading"
);
assert!(
lines.iter().any(|l| l.content.contains("Content")),
"Should include regular content"
);
assert!(
!lines.iter().any(|l| l.content.contains("{variable}")),
"Should exclude JSX expression"
);
assert!(
!lines.iter().any(|l| l.content.contains("{/* comment */")),
"Should exclude MDX comment"
);
}
#[test]
fn test_jsx_expressions_not_detected_in_standard_flavor() {
let content = r#"# Document
{this is not JSX in standard markdown}
Content."#;
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
let lines: Vec<_> = ctx.filtered_lines().skip_jsx_expressions().into_iter().collect();
assert!(
lines.iter().any(|l| l.content.contains("{this is not JSX")),
"Should NOT exclude brace content in standard markdown"
);
}
#[test]
fn test_skip_obsidian_comments_simple_inline() {
let content = r#"# Heading
This is visible %%this is hidden%% and visible again.
More content."#;
let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
let lines: Vec<_> = ctx.filtered_lines().skip_obsidian_comments().into_iter().collect();
assert!(
lines.iter().any(|l| l.content.contains("# Heading")),
"Should include heading"
);
assert!(
lines.iter().any(|l| l.content.contains("This is visible")),
"Should include line with inline comment"
);
assert!(
lines.iter().any(|l| l.content.contains("More content")),
"Should include content after comment"
);
}
#[test]
fn test_skip_obsidian_comments_multiline_block() {
let content = r#"# Heading
%%
This is a multi-line
comment block
%%
Content after."#;
let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
let lines: Vec<_> = ctx.filtered_lines().skip_obsidian_comments().into_iter().collect();
assert!(
lines.iter().any(|l| l.content.contains("# Heading")),
"Should include heading"
);
assert!(
lines.iter().any(|l| l.content.contains("Content after")),
"Should include content after comment block"
);
assert!(
!lines.iter().any(|l| l.content.contains("This is a multi-line")),
"Should exclude multi-line comment content"
);
assert!(
!lines.iter().any(|l| l.content.contains("comment block")),
"Should exclude multi-line comment content"
);
}
#[test]
fn test_skip_obsidian_comments_in_code_block() {
let content = r#"# Heading
```
%% This is NOT a comment
It's inside a code block
%%
```
Content."#;
let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
let lines: Vec<_> = ctx
.filtered_lines()
.skip_obsidian_comments()
.skip_code_blocks()
.into_iter()
.collect();
assert!(
lines.iter().any(|l| l.content.contains("# Heading")),
"Should include heading"
);
assert!(
lines.iter().any(|l| l.content.contains("Content")),
"Should include content after code block"
);
}
#[test]
fn test_skip_obsidian_comments_in_html_comment() {
let content = r#"# Heading
<!-- %% This is inside HTML comment %% -->
Content."#;
let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
let lines: Vec<_> = ctx
.filtered_lines()
.skip_obsidian_comments()
.skip_html_comments()
.into_iter()
.collect();
assert!(
lines.iter().any(|l| l.content.contains("# Heading")),
"Should include heading"
);
assert!(
lines.iter().any(|l| l.content.contains("Content")),
"Should include content"
);
}
#[test]
fn test_skip_obsidian_comments_empty() {
let content = r#"# Heading
%%%% empty comment
Content."#;
let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
let lines: Vec<_> = ctx.filtered_lines().skip_obsidian_comments().into_iter().collect();
assert!(
lines.iter().any(|l| l.content.contains("# Heading")),
"Should include heading"
);
}
#[test]
fn test_skip_obsidian_comments_unclosed() {
let content = r#"# Heading
%% starts but never ends
This should be hidden
Until end of document"#;
let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
let lines: Vec<_> = ctx.filtered_lines().skip_obsidian_comments().into_iter().collect();
assert!(
lines.iter().any(|l| l.content.contains("# Heading")),
"Should include heading before unclosed comment"
);
assert!(
!lines.iter().any(|l| l.content.contains("This should be hidden")),
"Should exclude content in unclosed comment"
);
assert!(
!lines.iter().any(|l| l.content.contains("Until end of document")),
"Should exclude content until end of document"
);
}
#[test]
fn test_skip_obsidian_comments_multiple_on_same_line() {
let content = r#"# Heading
First %%hidden1%% middle %%hidden2%% last
Content."#;
let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
let lines: Vec<_> = ctx.filtered_lines().skip_obsidian_comments().into_iter().collect();
assert!(
lines.iter().any(|l| l.content.contains("First")),
"Should include line with multiple inline comments"
);
assert!(
lines.iter().any(|l| l.content.contains("middle")),
"Should include visible text between comments"
);
}
#[test]
fn test_skip_obsidian_comments_at_start_of_line() {
let content = r#"# Heading
%%comment at start%%
Content."#;
let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
let lines: Vec<_> = ctx.filtered_lines().skip_obsidian_comments().into_iter().collect();
assert!(
lines.iter().any(|l| l.content.contains("# Heading")),
"Should include heading"
);
assert!(
lines.iter().any(|l| l.content.contains("Content")),
"Should include content"
);
}
#[test]
fn test_skip_obsidian_comments_at_end_of_line() {
let content = r#"# Heading
Some text %%comment at end%%
Content."#;
let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
let lines: Vec<_> = ctx.filtered_lines().skip_obsidian_comments().into_iter().collect();
assert!(
lines.iter().any(|l| l.content.contains("Some text")),
"Should include text before comment"
);
}
#[test]
fn test_skip_obsidian_comments_with_markdown_inside() {
let content = r#"# Heading
%%
# hidden heading
[hidden link](url)
**hidden bold**
%%
Content."#;
let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
let lines: Vec<_> = ctx.filtered_lines().skip_obsidian_comments().into_iter().collect();
assert!(
!lines.iter().any(|l| l.content.contains("# hidden heading")),
"Should exclude heading inside comment"
);
assert!(
!lines.iter().any(|l| l.content.contains("[hidden link]")),
"Should exclude link inside comment"
);
assert!(
!lines.iter().any(|l| l.content.contains("**hidden bold**")),
"Should exclude bold inside comment"
);
}
#[test]
fn test_skip_obsidian_comments_with_unicode() {
let content = r#"# Heading
%%日本語コメント%%
%%Комментарий%%
Content."#;
let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
let lines: Vec<_> = ctx.filtered_lines().skip_obsidian_comments().into_iter().collect();
assert!(
lines.iter().any(|l| l.content.contains("# Heading")),
"Should include heading"
);
assert!(
lines.iter().any(|l| l.content.contains("Content")),
"Should include content"
);
}
#[test]
fn test_skip_obsidian_comments_triple_percent() {
let content = r#"# Heading
%%% odd percent
Content."#;
let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
let lines: Vec<_> = ctx.filtered_lines().skip_obsidian_comments().into_iter().collect();
assert!(
lines.iter().any(|l| l.content.contains("# Heading")),
"Should include heading"
);
}
#[test]
fn test_skip_obsidian_comments_not_in_standard_flavor() {
let content = r#"# Heading
%%this is not hidden in standard%%
Content."#;
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
let lines: Vec<_> = ctx.filtered_lines().skip_obsidian_comments().into_iter().collect();
assert!(
lines.iter().any(|l| l.content.contains("%%this is not hidden")),
"Should NOT hide %% content in Standard flavor"
);
}
#[test]
fn test_skip_obsidian_comments_integration_with_other_filters() {
let content = r#"---
title: Test
---
# Heading
```
code
```
%%hidden comment%%
Content."#;
let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
let lines: Vec<_> = ctx
.filtered_lines()
.skip_front_matter()
.skip_code_blocks()
.skip_obsidian_comments()
.into_iter()
.collect();
assert!(
!lines.iter().any(|l| l.content.contains("title: Test")),
"Should skip frontmatter"
);
assert!(
!lines.iter().any(|l| l.content == "code"),
"Should skip code block content"
);
assert!(
lines.iter().any(|l| l.content.contains("# Heading")),
"Should include heading"
);
assert!(
lines.iter().any(|l| l.content.contains("Content")),
"Should include content"
);
}
#[test]
fn test_skip_obsidian_comments_whole_line_only() {
let content = "start %%\nfully hidden\n%% end";
let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
let lines: Vec<_> = ctx.filtered_lines().skip_obsidian_comments().into_iter().collect();
assert!(
lines.iter().any(|l| l.content.contains("start")),
"First line should be included (starts outside comment)"
);
assert!(
!lines.iter().any(|l| l.content == "fully hidden"),
"Middle line should be excluded (entirely within comment)"
);
assert!(
lines.iter().any(|l| l.content.contains("end")),
"Last line should be included (ends outside comment)"
);
}
#[test]
fn test_skip_obsidian_comments_in_inline_code() {
let content = r#"# Heading
The syntax is `%%comment%%` in Obsidian.
Content."#;
let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
let lines: Vec<_> = ctx.filtered_lines().skip_obsidian_comments().into_iter().collect();
assert!(
lines.iter().any(|l| l.content.contains("The syntax is")),
"Should include line with %% in code span"
);
assert!(
lines.iter().any(|l| l.content.contains("in Obsidian")),
"Should include text after code span"
);
}
#[test]
fn test_skip_obsidian_comments_in_inline_code_multi_backtick() {
let content = r#"# Heading
The syntax is ``%%comment%%`` in Obsidian.
Content."#;
let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
let lines: Vec<_> = ctx.filtered_lines().skip_obsidian_comments().into_iter().collect();
assert!(
lines.iter().any(|l| l.content.contains("The syntax is")),
"Should include line with %% in multi-backtick code span"
);
assert!(
lines.iter().any(|l| l.content.contains("Content")),
"Should include content after code span"
);
}
#[test]
fn test_skip_obsidian_comments_consecutive_blocks() {
let content = r#"# Heading
%%comment 1%%
%%comment 2%%
Content."#;
let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
let lines: Vec<_> = ctx.filtered_lines().skip_obsidian_comments().into_iter().collect();
assert!(
lines.iter().any(|l| l.content.contains("# Heading")),
"Should include heading"
);
assert!(
lines.iter().any(|l| l.content.contains("Content")),
"Should include content after comments"
);
}
#[test]
fn test_skip_obsidian_comments_spanning_many_lines() {
let content = r#"# Title
%%
Line 1 of comment
Line 2 of comment
Line 3 of comment
Line 4 of comment
Line 5 of comment
%%
After comment."#;
let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
let lines: Vec<_> = ctx.filtered_lines().skip_obsidian_comments().into_iter().collect();
for i in 1..=5 {
assert!(
!lines
.iter()
.any(|l| l.content.contains(&format!("Line {i} of comment"))),
"Should exclude line {i} of comment"
);
}
assert!(
lines.iter().any(|l| l.content.contains("# Title")),
"Should include title"
);
assert!(
lines.iter().any(|l| l.content.contains("After comment")),
"Should include content after comment"
);
}
#[test]
fn test_obsidian_comment_line_info_field() {
let content = "visible\n%%\nhidden\n%%\nvisible";
let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
assert!(
!ctx.lines[0].in_obsidian_comment,
"Line 0 should not be marked as in_obsidian_comment"
);
assert!(
ctx.lines[2].in_obsidian_comment,
"Line 2 (hidden) should be marked as in_obsidian_comment"
);
assert!(
!ctx.lines[4].in_obsidian_comment,
"Line 4 should not be marked as in_obsidian_comment"
);
}
#[test]
fn test_skip_pymdown_blocks_basic() {
let content = r#"# Heading
/// caption
Table caption here.
///
Content after."#;
let ctx = LintContext::new(content, MarkdownFlavor::MkDocs, None);
let lines: Vec<_> = ctx.filtered_lines().skip_pymdown_blocks().into_iter().collect();
assert!(
lines.iter().any(|l| l.content.contains("# Heading")),
"Should include heading"
);
assert!(
lines.iter().any(|l| l.content.contains("Content after")),
"Should include content after block"
);
assert!(
!lines.iter().any(|l| l.content.contains("Table caption")),
"Should exclude content inside block"
);
}
#[test]
fn test_skip_pymdown_blocks_details() {
let content = r#"# Heading
/// details | Click to expand
open: True
Hidden content here.
More hidden content.
///
Visible content."#;
let ctx = LintContext::new(content, MarkdownFlavor::MkDocs, None);
let lines: Vec<_> = ctx.filtered_lines().skip_pymdown_blocks().into_iter().collect();
assert!(
!lines.iter().any(|l| l.content.contains("Hidden content")),
"Should exclude hidden content"
);
assert!(
!lines.iter().any(|l| l.content.contains("open: True")),
"Should exclude YAML options"
);
assert!(
lines.iter().any(|l| l.content.contains("Visible content")),
"Should include visible content"
);
}
#[test]
fn test_skip_pymdown_blocks_nested() {
let content = r#"# Title
/// details | Outer
Outer content.
/// caption
Inner caption.
///
More outer content.
///
After all blocks."#;
let ctx = LintContext::new(content, MarkdownFlavor::MkDocs, None);
let lines: Vec<_> = ctx.filtered_lines().skip_pymdown_blocks().into_iter().collect();
assert!(
!lines.iter().any(|l| l.content.contains("Outer content")),
"Should exclude outer block content"
);
assert!(
!lines.iter().any(|l| l.content.contains("Inner caption")),
"Should exclude inner block content"
);
assert!(
lines.iter().any(|l| l.content.contains("After all blocks")),
"Should include content after all blocks"
);
}
#[test]
fn test_pymdown_block_line_info_field() {
let content = "visible\n/// caption\nhidden\n///\nvisible";
let ctx = LintContext::new(content, MarkdownFlavor::MkDocs, None);
assert!(
!ctx.lines[0].in_pymdown_block,
"Line 0 should not be marked as in_pymdown_block"
);
assert!(
ctx.lines[1].in_pymdown_block,
"Line 1 (/// caption) should be marked as in_pymdown_block"
);
assert!(
ctx.lines[2].in_pymdown_block,
"Line 2 (hidden) should be marked as in_pymdown_block"
);
assert!(
ctx.lines[3].in_pymdown_block,
"Line 3 (closing ///) should be marked as in_pymdown_block"
);
assert!(
!ctx.lines[4].in_pymdown_block,
"Line 4 should not be marked as in_pymdown_block"
);
}
#[test]
fn test_pymdown_blocks_only_for_mkdocs_flavor() {
let content = "/// caption\nCaption text\n///";
let ctx_mkdocs = LintContext::new(content, MarkdownFlavor::MkDocs, None);
assert!(
ctx_mkdocs.lines[1].in_pymdown_block,
"MkDocs flavor should detect pymdown blocks"
);
let ctx_standard = LintContext::new(content, MarkdownFlavor::Standard, None);
assert!(
!ctx_standard.lines[1].in_pymdown_block,
"Standard flavor should NOT detect pymdown blocks"
);
}
}