use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
#[derive(Clone, Default)]
pub struct MD070NestedCodeFence;
impl MD070NestedCodeFence {
pub fn new() -> Self {
Self
}
fn should_check_language(lang: &str) -> bool {
let base = lang.split_whitespace().next().unwrap_or("");
matches!(
base.to_ascii_lowercase().as_str(),
""
| "markdown"
| "md"
| "mdx"
| "text"
| "txt"
| "plain"
| "python"
| "py"
| "ruby"
| "rb"
| "perl"
| "pl"
| "php"
| "lua"
| "r"
| "rmd"
| "rmarkdown"
| "javascript"
| "js"
| "jsx"
| "mjs"
| "cjs"
| "typescript"
| "ts"
| "tsx"
| "mts"
| "rust"
| "rs"
| "go"
| "golang"
| "swift"
| "kotlin"
| "kt"
| "kts"
| "java"
| "csharp"
| "cs"
| "c#"
| "scala"
| "shell"
| "sh"
| "bash"
| "zsh"
| "fish"
| "powershell"
| "ps1"
| "pwsh"
| "yaml"
| "yml"
| "toml"
| "json"
| "jsonc"
| "json5"
| "jinja"
| "jinja2"
| "handlebars"
| "hbs"
| "liquid"
| "nunjucks"
| "njk"
| "ejs"
| "console"
| "terminal"
)
}
fn find_fence_collision(content: &str, fence_char: char, outer_fence_length: usize) -> Option<(usize, usize)> {
for (line_idx, line) in content.lines().enumerate() {
let trimmed = line.trim_start();
if trimmed.starts_with(fence_char) {
let count = trimmed.chars().take_while(|&c| c == fence_char).count();
if count >= outer_fence_length {
let after_fence = &trimmed[count..];
if after_fence.is_empty()
|| after_fence.trim().is_empty()
|| after_fence
.chars()
.next()
.is_some_and(|c| c.is_alphabetic() || c == '{')
{
return Some((line_idx, count));
}
}
}
}
None
}
fn find_safe_fence_length(content: &str, fence_char: char) -> usize {
let mut max_fence = 0;
for line in content.lines() {
let trimmed = line.trim_start();
if trimmed.starts_with(fence_char) {
let count = trimmed.chars().take_while(|&c| c == fence_char).count();
if count >= 3 {
let after_fence = &trimmed[count..];
if after_fence.is_empty()
|| after_fence.trim().is_empty()
|| after_fence
.chars()
.next()
.is_some_and(|c| c.is_alphabetic() || c == '{')
{
max_fence = max_fence.max(count);
}
}
}
}
max_fence
}
fn find_intended_close(
lines: &[&str],
first_close: usize,
fence_char: char,
fence_length: usize,
opening_indent: usize,
) -> usize {
let mut intended_close = first_close;
for (j, line_j) in lines.iter().enumerate().skip(first_close + 1) {
if Self::is_closing_fence(line_j, fence_char, fence_length) {
intended_close = j;
} else if Self::parse_fence_line(line_j)
.is_some_and(|(ind, ch, _, info)| ind <= opening_indent && ch == fence_char && !info.is_empty())
{
break;
}
}
intended_close
}
fn parse_fence_line(line: &str) -> Option<(usize, char, usize, &str)> {
let indent = line.len() - line.trim_start().len();
if indent > 3 {
return None;
}
let trimmed = line.trim_start();
if trimmed.starts_with("```") {
let count = trimmed.chars().take_while(|&c| c == '`').count();
if count >= 3 {
let info = trimmed[count..].trim();
return Some((indent, '`', count, info));
}
} else if trimmed.starts_with("~~~") {
let count = trimmed.chars().take_while(|&c| c == '~').count();
if count >= 3 {
let info = trimmed[count..].trim();
return Some((indent, '~', count, info));
}
}
None
}
fn is_closing_fence(line: &str, fence_char: char, min_length: usize) -> bool {
let indent = line.len() - line.trim_start().len();
if indent > 3 {
return false;
}
let trimmed = line.trim_start();
if !trimmed.starts_with(fence_char) {
return false;
}
let count = trimmed.chars().take_while(|&c| c == fence_char).count();
if count < min_length {
return false;
}
trimmed[count..].trim().is_empty()
}
}
impl Rule for MD070NestedCodeFence {
fn name(&self) -> &'static str {
"MD070"
}
fn description(&self) -> &'static str {
"Nested code fence collision - use longer fence to avoid premature closure"
}
fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
let mut warnings = Vec::new();
let lines = ctx.raw_lines();
let mut i = 0;
while i < lines.len() {
if let Some(line_info) = ctx.lines.get(i)
&& (line_info.in_front_matter || line_info.in_html_comment || line_info.in_html_block)
{
i += 1;
continue;
}
if i > 0
&& let Some(prev_line_info) = ctx.lines.get(i - 1)
&& prev_line_info.in_code_block
{
i += 1;
continue;
}
let line = lines[i];
if let Some((_indent, fence_char, fence_length, info_string)) = Self::parse_fence_line(line) {
let block_start = i;
let language = info_string.split_whitespace().next().unwrap_or("");
let mut block_end = None;
for (j, line_j) in lines.iter().enumerate().skip(i + 1) {
if Self::is_closing_fence(line_j, fence_char, fence_length) {
block_end = Some(j);
break;
}
}
if let Some(end_line) = block_end {
if Self::should_check_language(language) {
let block_content: String = if block_start + 1 < end_line {
lines[(block_start + 1)..end_line].join("\n")
} else {
String::new()
};
if let Some((collision_line_offset, _collision_length)) =
Self::find_fence_collision(&block_content, fence_char, fence_length)
{
let collision_line_num = block_start + 1 + collision_line_offset + 1;
let indent = line.len() - line.trim_start().len();
let intended_close =
Self::find_intended_close(lines, end_line, fence_char, fence_length, indent);
let full_content: String = if block_start + 1 < intended_close {
lines[(block_start + 1)..intended_close].join("\n")
} else {
block_content.clone()
};
let safe_length = Self::find_safe_fence_length(&full_content, fence_char) + 1;
let suggested_fence: String = std::iter::repeat_n(fence_char, safe_length).collect();
let open_byte_start = ctx.line_index.get_line_start_byte(block_start + 1).unwrap_or(0);
let close_byte_end = ctx
.line_index
.get_line_start_byte(intended_close + 2)
.unwrap_or(ctx.content.len());
let indent_str = &line[..indent];
let closing_line = lines[intended_close];
let closing_indent = &closing_line[..closing_line.len() - closing_line.trim_start().len()];
let mut replacement = format!("{indent_str}{suggested_fence}");
if !info_string.is_empty() {
replacement.push_str(info_string);
}
replacement.push('\n');
for content_line in &lines[(block_start + 1)..intended_close] {
replacement.push_str(content_line);
replacement.push('\n');
}
replacement.push_str(closing_indent);
replacement.push_str(&suggested_fence);
replacement.push('\n');
warnings.push(LintWarning {
rule_name: Some(self.name().to_string()),
message: format!(
"Code block contains fence markers at line {collision_line_num} that interfere with block parsing — use {suggested_fence} for outer fence"
),
line: block_start + 1,
column: 1,
end_line: intended_close + 1,
end_column: lines[intended_close].len() + 1,
severity: Severity::Warning,
fix: Some(Fix {
range: (open_byte_start..close_byte_end),
replacement,
}),
});
}
}
i = end_line + 1;
continue;
}
}
i += 1;
}
Ok(warnings)
}
fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
let content = ctx.content;
let mut result = String::new();
let lines = ctx.raw_lines();
let mut i = 0;
while i < lines.len() {
if ctx.is_rule_disabled(self.name(), i + 1) {
result.push_str(lines[i]);
result.push('\n');
i += 1;
continue;
}
if let Some(line_info) = ctx.lines.get(i)
&& (line_info.in_front_matter || line_info.in_html_comment || line_info.in_html_block)
{
result.push_str(lines[i]);
result.push('\n');
i += 1;
continue;
}
if i > 0
&& let Some(prev_line_info) = ctx.lines.get(i - 1)
&& prev_line_info.in_code_block
{
result.push_str(lines[i]);
result.push('\n');
i += 1;
continue;
}
let line = lines[i];
if let Some((indent, fence_char, fence_length, info_string)) = Self::parse_fence_line(line) {
let block_start = i;
let language = info_string.split_whitespace().next().unwrap_or("");
let mut first_close = None;
for (j, line_j) in lines.iter().enumerate().skip(i + 1) {
if Self::is_closing_fence(line_j, fence_char, fence_length) {
first_close = Some(j);
break;
}
}
if let Some(end_line) = first_close {
if Self::should_check_language(language) {
let block_content: String = if block_start + 1 < end_line {
lines[(block_start + 1)..end_line].join("\n")
} else {
String::new()
};
if Self::find_fence_collision(&block_content, fence_char, fence_length).is_some() {
let intended_close =
Self::find_intended_close(lines, end_line, fence_char, fence_length, indent);
let full_block_content: String = if block_start + 1 < intended_close {
lines[(block_start + 1)..intended_close].join("\n")
} else {
String::new()
};
let safe_length = Self::find_safe_fence_length(&full_block_content, fence_char) + 1;
let suggested_fence: String = std::iter::repeat_n(fence_char, safe_length).collect();
let opening_indent = " ".repeat(indent);
result.push_str(&format!("{opening_indent}{suggested_fence}{info_string}\n"));
for line_content in &lines[(block_start + 1)..intended_close] {
result.push_str(line_content);
result.push('\n');
}
let closing_line = lines[intended_close];
let closing_indent = closing_line.len() - closing_line.trim_start().len();
let closing_indent_str = " ".repeat(closing_indent);
result.push_str(&format!("{closing_indent_str}{suggested_fence}\n"));
i = intended_close + 1;
continue;
}
}
for line_content in &lines[block_start..=end_line] {
result.push_str(line_content);
result.push('\n');
}
i = end_line + 1;
continue;
}
}
result.push_str(line);
result.push('\n');
i += 1;
}
if !content.ends_with('\n') && result.ends_with('\n') {
result.pop();
}
Ok(result)
}
fn category(&self) -> RuleCategory {
RuleCategory::CodeBlock
}
fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
ctx.content.is_empty() || (!ctx.likely_has_code() && !ctx.has_char('~'))
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
where
Self: Sized,
{
Box::new(MD070NestedCodeFence::new())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::lint_context::LintContext;
fn run_check(content: &str) -> LintResult {
let rule = MD070NestedCodeFence::new();
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
rule.check(&ctx)
}
fn run_fix(content: &str) -> Result<String, LintError> {
let rule = MD070NestedCodeFence::new();
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
rule.fix(&ctx)
}
#[test]
fn test_no_collision_simple() {
let content = "```python\nprint('hello')\n```\n";
let result = run_check(content).unwrap();
assert!(result.is_empty(), "Simple code block should not trigger warning");
}
#[test]
fn test_no_collision_unchecked_language() {
let content = "```c\n```bash\necho hello\n```\n```\n";
let result = run_check(content).unwrap();
assert!(result.is_empty(), "Unchecked language should not trigger");
}
#[test]
fn test_collision_python_language() {
let content = "```python\n```json\n{}\n```\n```\n";
let result = run_check(content).unwrap();
assert_eq!(result.len(), 1, "Python should be checked for nested fences");
assert!(result[0].message.contains("````"));
}
#[test]
fn test_collision_javascript_language() {
let content = "```javascript\n```html\n<div></div>\n```\n```\n";
let result = run_check(content).unwrap();
assert_eq!(result.len(), 1, "JavaScript should be checked for nested fences");
}
#[test]
fn test_collision_shell_language() {
let content = "```bash\n```yaml\nkey: val\n```\n```\n";
let result = run_check(content).unwrap();
assert_eq!(result.len(), 1, "Shell should be checked for nested fences");
}
#[test]
fn test_collision_rust_language() {
let content = "```rust\n```toml\n[dep]\n```\n```\n";
let result = run_check(content).unwrap();
assert_eq!(result.len(), 1, "Rust should be checked for nested fences");
}
#[test]
fn test_no_collision_assembly_language() {
for lang in ["asm", "c", "cpp", "sql", "css", "fortran"] {
let content = format!("```{lang}\n```inner\ncontent\n```\n```\n");
let result = run_check(&content).unwrap();
assert!(result.is_empty(), "{lang} should not be checked for nested fences");
}
}
#[test]
fn test_collision_markdown_language() {
let content = "```markdown\n```python\ncode()\n```\n```\n";
let result = run_check(content).unwrap();
assert_eq!(result.len(), 1, "Should emit single warning for collision");
assert!(result[0].message.contains("fence markers at line"));
assert!(result[0].message.contains("interfere with block parsing"));
assert!(result[0].message.contains("use ````"));
}
#[test]
fn test_collision_empty_language() {
let content = "```\n```python\ncode()\n```\n```\n";
let result = run_check(content).unwrap();
assert_eq!(result.len(), 1, "Empty language should be checked");
}
#[test]
fn test_no_collision_longer_outer_fence() {
let content = "````markdown\n```python\ncode()\n```\n````\n";
let result = run_check(content).unwrap();
assert!(result.is_empty(), "Longer outer fence should not trigger warning");
}
#[test]
fn test_tilde_fence_ignores_backticks() {
let content = "~~~markdown\n```python\ncode()\n```\n~~~\n";
let result = run_check(content).unwrap();
assert!(result.is_empty(), "Different fence types should not collide");
}
#[test]
fn test_tilde_collision() {
let content = "~~~markdown\n~~~python\ncode()\n~~~\n~~~\n";
let result = run_check(content).unwrap();
assert_eq!(result.len(), 1, "Same fence type should collide");
assert!(result[0].message.contains("~~~~"));
}
#[test]
fn test_fix_increases_fence_length() {
let content = "```markdown\n```python\ncode()\n```\n```\n";
let fixed = run_fix(content).unwrap();
assert!(fixed.starts_with("````markdown"), "Should increase to 4 backticks");
assert!(
fixed.contains("````\n") || fixed.ends_with("````"),
"Closing should also be 4 backticks"
);
}
#[test]
fn test_fix_handles_longer_inner_fence() {
let content = "```markdown\n`````python\ncode()\n`````\n```\n";
let fixed = run_fix(content).unwrap();
assert!(fixed.starts_with("``````markdown"), "Should increase to 6 backticks");
}
#[test]
fn test_backticks_in_code_not_fence() {
let content = "```markdown\nconst x = `template`;\n```\n";
let result = run_check(content).unwrap();
assert!(result.is_empty(), "Inline backticks should not be detected as fences");
}
#[test]
fn test_preserves_info_string() {
let content = "```markdown {.highlight}\n```python\ncode()\n```\n```\n";
let fixed = run_fix(content).unwrap();
assert!(
fixed.contains("````markdown {.highlight}"),
"Should preserve info string attributes"
);
}
#[test]
fn test_md_language_alias() {
let content = "```md\n```python\ncode()\n```\n```\n";
let result = run_check(content).unwrap();
assert_eq!(result.len(), 1, "md should be recognized as markdown");
}
#[test]
fn test_real_world_docs_case() {
let content = r#"```markdown
1. First item
```python
code_in_list()
```
1. Second item
```
"#;
let result = run_check(content).unwrap();
assert_eq!(result.len(), 1, "Should emit single warning for nested fence issue");
assert!(result[0].message.contains("line 4"));
let fixed = run_fix(content).unwrap();
assert!(fixed.starts_with("````markdown"), "Should fix with longer fence");
}
#[test]
fn test_empty_code_block() {
let content = "```markdown\n```\n";
let result = run_check(content).unwrap();
assert!(result.is_empty(), "Empty code block should not trigger");
}
#[test]
fn test_multiple_code_blocks() {
let content = r#"```python
safe code
```
```markdown
```python
collision
```
```
```javascript
also safe
```
"#;
let result = run_check(content).unwrap();
assert_eq!(result.len(), 1, "Should emit single warning for collision");
assert!(result[0].message.contains("line 6")); }
#[test]
fn test_single_collision_properly_closed() {
let content = r#"```python
safe code
```
````markdown
```python
collision
```
````
```javascript
also safe
```
"#;
let result = run_check(content).unwrap();
assert!(result.is_empty(), "Properly fenced blocks should not trigger");
}
#[test]
fn test_indented_code_block_in_list() {
let content = r#"- List item
```markdown
```python
nested
```
```
"#;
let result = run_check(content).unwrap();
assert_eq!(result.len(), 1, "Should detect collision in indented block");
assert!(result[0].message.contains("````"));
}
#[test]
fn test_no_false_positive_list_indented_block() {
let content = r#"1. List item with code:
```json
{"key": "value"}
```
2. Another item
```python
code()
```
"#;
let result = run_check(content).unwrap();
assert!(
result.is_empty(),
"List-indented code blocks should not trigger false positives"
);
}
#[test]
fn test_case_insensitive_language() {
for lang in ["MARKDOWN", "Markdown", "MD", "Md", "mD"] {
let content = format!("```{lang}\n```python\ncode()\n```\n```\n");
let result = run_check(&content).unwrap();
assert_eq!(result.len(), 1, "{lang} should be recognized as markdown");
}
}
#[test]
fn test_unclosed_outer_fence() {
let content = "```markdown\n```python\ncode()\n```\n";
let result = run_check(content).unwrap();
assert!(result.len() <= 1, "Unclosed fence should not cause issues");
}
#[test]
fn test_deeply_nested_fences() {
let content = r#"```markdown
````markdown
```python
code()
```
````
```
"#;
let result = run_check(content).unwrap();
assert_eq!(result.len(), 1, "Deep nesting should trigger warning");
assert!(result[0].message.contains("`````")); }
#[test]
fn test_very_long_fences() {
let content = "``````````markdown\n```python\ncode()\n```\n``````````\n";
let result = run_check(content).unwrap();
assert!(result.is_empty(), "Very long outer fence should not trigger warning");
}
#[test]
fn test_blockquote_with_fence() {
let content = "> ```markdown\n> ```python\n> code()\n> ```\n> ```\n";
let result = run_check(content).unwrap();
assert!(result.is_empty() || result.len() == 1);
}
#[test]
fn test_fence_with_attributes() {
let content = "```markdown {.highlight #example}\n```python\ncode()\n```\n```\n";
let result = run_check(content).unwrap();
assert_eq!(
result.len(),
1,
"Attributes in info string should not prevent detection"
);
let fixed = run_fix(content).unwrap();
assert!(
fixed.contains("````markdown {.highlight #example}"),
"Attributes should be preserved in fix"
);
}
#[test]
fn test_trailing_whitespace_in_info_string() {
let content = "```markdown \n```python\ncode()\n```\n```\n";
let result = run_check(content).unwrap();
assert_eq!(result.len(), 1, "Trailing whitespace should not affect detection");
}
#[test]
fn test_only_closing_fence_pattern() {
let content = "```markdown\nsome text\n```\nmore text\n```\n";
let result = run_check(content).unwrap();
assert!(result.is_empty(), "Properly closed block should not trigger");
}
#[test]
fn test_fence_at_end_of_file_no_newline() {
let content = "```markdown\n```python\ncode()\n```\n```";
let result = run_check(content).unwrap();
assert_eq!(result.len(), 1, "Should detect collision even without trailing newline");
let fixed = run_fix(content).unwrap();
assert!(!fixed.ends_with('\n'), "Should preserve lack of trailing newline");
}
#[test]
fn test_empty_lines_between_fences() {
let content = "```markdown\n\n\n```python\n\ncode()\n\n```\n\n```\n";
let result = run_check(content).unwrap();
assert_eq!(result.len(), 1, "Empty lines should not affect collision detection");
}
#[test]
fn test_tab_indented_opening_fence() {
let content = "\t```markdown\n```python\ncode()\n```\n```\n";
let result = run_check(content).unwrap();
assert_eq!(result.len(), 1, "Tab-indented fence is parsed (tab = 1 char)");
}
#[test]
fn test_mixed_fence_types_no_collision() {
let content = "```markdown\n~~~python\ncode()\n~~~\n```\n";
let result = run_check(content).unwrap();
assert!(result.is_empty(), "Different fence chars should not collide");
let content2 = "~~~markdown\n```python\ncode()\n```\n~~~\n";
let result2 = run_check(content2).unwrap();
assert!(result2.is_empty(), "Different fence chars should not collide");
}
#[test]
fn test_frontmatter_not_confused_with_fence() {
let content = "---\ntitle: Test\n---\n\n```markdown\n```python\ncode()\n```\n```\n";
let result = run_check(content).unwrap();
assert_eq!(result.len(), 1, "Should detect collision after frontmatter");
}
#[test]
fn test_html_comment_with_fence_inside() {
let content = "<!-- ```markdown\n```python\ncode()\n``` -->\n\n```markdown\nreal content\n```\n";
let result = run_check(content).unwrap();
assert!(result.is_empty(), "Fences in HTML comments should be ignored");
}
#[test]
fn test_consecutive_code_blocks() {
let content = r#"```markdown
```python
a()
```
```
```markdown
```ruby
b()
```
```
"#;
let result = run_check(content).unwrap();
assert!(!result.is_empty(), "Should detect collision in first block");
}
#[test]
fn test_numeric_info_string() {
let content = "```123\n```456\ncode()\n```\n```\n";
let result = run_check(content).unwrap();
assert!(result.is_empty(), "Numeric info string is not markdown");
}
#[test]
fn test_collision_at_exact_length() {
let content = "```markdown\n```python\ncode()\n```\n```\n";
let result = run_check(content).unwrap();
assert_eq!(
result.len(),
1,
"Same-length fence with language should trigger collision"
);
let content2 = "````markdown\n```python\ncode()\n```\n````\n";
let result2 = run_check(content2).unwrap();
assert!(result2.is_empty(), "Shorter inner fence should not collide");
let content3 = "```markdown\n```\n";
let result3 = run_check(content3).unwrap();
assert!(result3.is_empty(), "Empty closing fence is not a collision");
}
#[test]
fn test_fix_preserves_content_exactly() {
let content = "```markdown\n```python\n indented\n\ttabbed\nspecial: !@#$%\n```\n```\n";
let fixed = run_fix(content).unwrap();
assert!(fixed.contains(" indented"), "Indentation should be preserved");
assert!(fixed.contains("\ttabbed"), "Tabs should be preserved");
assert!(fixed.contains("special: !@#$%"), "Special chars should be preserved");
}
#[test]
fn test_warning_line_numbers_accurate() {
let content = "# Title\n\nParagraph\n\n```markdown\n```python\ncode()\n```\n```\n";
let result = run_check(content).unwrap();
assert_eq!(result.len(), 1);
assert_eq!(result[0].line, 5, "Warning should be on opening fence line");
assert!(result[0].message.contains("line 6"), "Collision line should be line 6");
}
#[test]
fn test_should_skip_optimization() {
let rule = MD070NestedCodeFence::new();
let ctx1 = LintContext::new("Just plain text", crate::config::MarkdownFlavor::Standard, None);
assert!(
rule.should_skip(&ctx1),
"Should skip content without backticks or tildes"
);
let ctx2 = LintContext::new("Has `code`", crate::config::MarkdownFlavor::Standard, None);
assert!(!rule.should_skip(&ctx2), "Should not skip content with backticks");
let ctx3 = LintContext::new("Has ~~~", crate::config::MarkdownFlavor::Standard, None);
assert!(!rule.should_skip(&ctx3), "Should not skip content with tildes");
let ctx4 = LintContext::new("", crate::config::MarkdownFlavor::Standard, None);
assert!(rule.should_skip(&ctx4), "Should skip empty content");
}
#[test]
fn test_python_triplestring_fence_collision_fix() {
let content = "# Test\n\n```python\ndef f():\n text = \"\"\"\n```json\n{}\n```\n\"\"\"\n```\n";
let result = run_check(content).unwrap();
assert_eq!(result.len(), 1, "Should detect collision in python block");
assert!(result[0].fix.is_some(), "Warning should be marked as fixable");
let fixed = run_fix(content).unwrap();
assert!(
fixed.contains("````python"),
"Should upgrade opening fence to 4 backticks"
);
assert!(
fixed.contains("````\n") || fixed.ends_with("````"),
"Should upgrade closing fence to 4 backticks"
);
assert!(fixed.contains("```json"), "Inner fences should be preserved as content");
}
#[test]
fn test_warning_is_fixable() {
let content = "```markdown\n```python\ncode()\n```\n```\n";
let result = run_check(content).unwrap();
assert_eq!(result.len(), 1);
assert!(
result[0].fix.is_some(),
"MD070 warnings must be marked fixable for the fix coordinator"
);
}
#[test]
fn test_fix_via_warning_struct_is_safe() {
let content = "```markdown\n```python\ncode()\n```\n```\n";
let result = run_check(content).unwrap();
assert_eq!(result.len(), 1);
let fix = result[0].fix.as_ref().unwrap();
let mut fixed = String::new();
fixed.push_str(&content[..fix.range.start]);
fixed.push_str(&fix.replacement);
fixed.push_str(&content[fix.range.end..]);
assert!(
fixed.contains("````markdown"),
"Direct Fix application should upgrade opening fence, got: {fixed}"
);
assert!(
fixed.contains("````\n") || fixed.ends_with("````"),
"Direct Fix application should upgrade closing fence, got: {fixed}"
);
assert!(
fixed.contains("```python"),
"Inner content should be preserved, got: {fixed}"
);
}
#[test]
fn test_fix_via_warning_struct_python_block() {
let content = "```python\ndef f():\n text = \"\"\"\n```json\n{}\n```\n\"\"\"\n print(text)\nf()\n```\n";
let result = run_check(content).unwrap();
assert_eq!(result.len(), 1);
let fix = result[0].fix.as_ref().unwrap();
let mut fixed = String::new();
fixed.push_str(&content[..fix.range.start]);
fixed.push_str(&fix.replacement);
fixed.push_str(&content[fix.range.end..]);
assert!(
fixed.starts_with("````python\n"),
"Should upgrade opening fence, got:\n{fixed}"
);
assert!(
fixed.contains("````\n") || fixed.trim_end().ends_with("````"),
"Should upgrade closing fence, got:\n{fixed}"
);
let fence_start = fixed.find("````python\n").unwrap();
let after_open = fence_start + "````python\n".len();
let close_pos = fixed[after_open..]
.find("\n````\n")
.or_else(|| fixed[after_open..].find("\n````"));
assert!(
close_pos.is_some(),
"Should have closing fence after content, got:\n{fixed}"
);
let block_content = &fixed[after_open..after_open + close_pos.unwrap()];
assert!(
block_content.contains("print(text)"),
"print(text) must be inside the code block, got block:\n{block_content}"
);
assert!(
block_content.contains("f()"),
"f() must be inside the code block, got block:\n{block_content}"
);
assert!(
block_content.contains("```json"),
"Inner fences must be preserved as content, got block:\n{block_content}"
);
}
#[test]
fn test_fix_via_apply_warning_fixes() {
let content = "```markdown\n```python\ncode()\n```\n```\n";
let result = run_check(content).unwrap();
assert_eq!(result.len(), 1);
let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &result).unwrap();
assert!(
fixed.contains("````markdown"),
"apply_warning_fixes should upgrade opening fence"
);
assert!(
fixed.contains("````\n") || fixed.ends_with("````"),
"apply_warning_fixes should upgrade closing fence"
);
let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
let rule = MD070NestedCodeFence::new();
let result2 = rule.check(&ctx2).unwrap();
assert!(
result2.is_empty(),
"Re-check after LSP fix should find no issues, got: {:?}",
result2.iter().map(|w| &w.message).collect::<Vec<_>>()
);
}
}