use crate::lint::rule::Rule;
use crate::markdown::MarkdownParser;
use crate::types::{Fix, Violation};
use pulldown_cmark::{CodeBlockKind, Event, Tag, TagEnd};
use serde_json::Value;
pub struct MD014;
impl Rule for MD014 {
fn name(&self) -> &str {
"MD014"
}
fn description(&self) -> &str {
"Dollar signs used before commands without showing output"
}
fn tags(&self) -> &[&str] {
&["code"]
}
fn check(&self, parser: &MarkdownParser, _config: Option<&Value>) -> Vec<Violation> {
let mut violations = Vec::new();
let mut in_shell_code_block = false;
let mut code_block_start_line = 0;
let mut code_block_lines: Vec<String> = Vec::new();
for (event, range) in parser.parse_with_offsets() {
match event {
Event::Start(Tag::CodeBlock(CodeBlockKind::Fenced(lang))) => {
let lang_str = lang.to_string().to_lowercase();
in_shell_code_block = lang_str == "bash"
|| lang_str == "sh"
|| lang_str == "shell"
|| lang_str == "console";
if in_shell_code_block {
code_block_start_line = parser.offset_to_line(range.start);
code_block_lines.clear();
}
}
Event::Text(text) if in_shell_code_block => {
code_block_lines.push(text.to_string());
}
Event::End(TagEnd::CodeBlock) if in_shell_code_block => {
let code_text = code_block_lines.join("");
let lines: Vec<&str> = code_text.lines().collect();
let non_empty_lines: Vec<&str> = lines
.iter()
.filter(|l| !l.trim().is_empty())
.copied()
.collect();
if !non_empty_lines.is_empty() {
let all_start_with_dollar = non_empty_lines
.iter()
.all(|line| line.trim_start().starts_with('$'));
if all_start_with_dollar {
let mut current_line = code_block_start_line + 1;
for line in &lines {
if !line.trim().is_empty() && line.trim_start().starts_with('$') {
let trimmed = line.trim_start();
let after_dollar = trimmed.strip_prefix('$').unwrap();
let after_dollar_trimmed = after_dollar.trim_start();
let leading_spaces = line.len() - trimmed.len();
let replacement = format!(
"{}{}",
" ".repeat(leading_spaces),
after_dollar_trimmed
);
violations.push(Violation {
line: current_line,
column: Some(1),
rule: self.name().to_string(),
message:
"Dollar signs should not be used before commands without showing output"
.to_string(),
fix: Some(Fix {
line_start: current_line,
line_end: current_line,
column_start: None,
column_end: None,
replacement,
description: "Remove dollar sign".to_string(),
}),
});
}
current_line += 1;
}
}
}
in_shell_code_block = false;
code_block_lines.clear();
}
_ => {}
}
}
violations
}
fn fixable(&self) -> bool {
true
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_no_dollar_signs() {
let content = "```bash\nls -la\necho hello\n```";
let parser = MarkdownParser::new(content);
let rule = MD014;
let violations = rule.check(&parser, None);
assert_eq!(violations.len(), 0);
}
#[test]
fn test_all_dollar_signs() {
let content = "```bash\n$ ls -la\n$ echo hello\n```";
let parser = MarkdownParser::new(content);
let rule = MD014;
let violations = rule.check(&parser, None);
assert_eq!(violations.len(), 2); assert_eq!(violations[0].line, 2); assert_eq!(violations[1].line, 3); }
#[test]
fn test_dollar_with_output() {
let content = "```bash\n$ ls -la\ntotal 64\n$ echo hello\nhello\n```";
let parser = MarkdownParser::new(content);
let rule = MD014;
let violations = rule.check(&parser, None);
assert_eq!(violations.len(), 0); }
#[test]
fn test_non_shell_language() {
let content = "```python\n$ this is not a shell\n```";
let parser = MarkdownParser::new(content);
let rule = MD014;
let violations = rule.check(&parser, None);
assert_eq!(violations.len(), 0); }
}