mdlint/lint/rules/
md014.rs1use crate::lint::rule::Rule;
2use crate::markdown::MarkdownParser;
3use crate::types::{Fix, Violation};
4use pulldown_cmark::{CodeBlockKind, Event, Tag, TagEnd};
5use serde_json::Value;
6
7pub struct MD014;
8
9impl Rule for MD014 {
10 fn name(&self) -> &'static str {
11 "MD014"
12 }
13
14 fn description(&self) -> &'static str {
15 "Dollar signs used before commands without showing output"
16 }
17
18 fn tags(&self) -> &[&str] {
19 &["code"]
20 }
21
22 fn check(&self, parser: &MarkdownParser, _config: Option<&Value>) -> Vec<Violation> {
23 let mut violations = Vec::new();
24 let mut in_shell_code_block = false;
25 let mut code_block_start_line = 0;
26 let mut code_block_lines: Vec<String> = Vec::new();
27
28 for (event, range) in parser.parse_with_offsets() {
29 match event {
30 Event::Start(Tag::CodeBlock(CodeBlockKind::Fenced(lang))) => {
31 let lang_str = lang.to_string().to_lowercase();
32 in_shell_code_block = lang_str == "bash"
33 || lang_str == "sh"
34 || lang_str == "shell"
35 || lang_str == "console";
36 if in_shell_code_block {
37 code_block_start_line = parser.offset_to_line(range.start);
38 code_block_lines.clear();
39 }
40 }
41 Event::Text(text) if in_shell_code_block => {
42 code_block_lines.push(text.to_string());
43 }
44 Event::End(TagEnd::CodeBlock) if in_shell_code_block => {
45 let code_text = code_block_lines.join("");
47 let lines: Vec<&str> = code_text.lines().collect();
48 let non_empty_lines: Vec<&str> = lines
49 .iter()
50 .filter(|l| !l.trim().is_empty())
51 .copied()
52 .collect();
53
54 if !non_empty_lines.is_empty() {
55 let all_start_with_dollar = non_empty_lines
56 .iter()
57 .all(|line| line.trim_start().starts_with('$'));
58
59 if all_start_with_dollar {
60 for (current_line, line) in
62 (code_block_start_line + 1..).zip(lines.iter())
63 {
64 if !line.trim().is_empty() && line.trim_start().starts_with('$') {
65 let trimmed = line.trim_start();
67 let after_dollar = trimmed
68 .strip_prefix('$')
69 .expect("starts_with('$') checked above");
70 let after_dollar_trimmed = after_dollar.trim_start();
71 let leading_spaces = line.len() - trimmed.len();
73 let replacement = format!(
74 "{}{}",
75 " ".repeat(leading_spaces),
76 after_dollar_trimmed
77 );
78
79 violations.push(Violation {
80 line: current_line,
81 column: Some(1),
82 rule: self.name().to_owned(),
83 message:
84 "Dollar signs should not be used before commands without showing output".to_owned(),
85 fix: Some(Fix {
86 line_start: current_line,
87 line_end: current_line,
88 column_start: None,
89 column_end: None,
90 replacement,
91 description: "Remove dollar sign".to_owned(),
92 }),
93 });
94 }
95 }
96 }
97 }
98
99 in_shell_code_block = false;
100 code_block_lines.clear();
101 }
102 _ => {}
103 }
104 }
105
106 violations
107 }
108
109 fn fixable(&self) -> bool {
110 true
111 }
112}
113
114#[cfg(test)]
115mod tests {
116 use super::*;
117 use crate::fix::Fixer;
118
119 fn apply_fixes(content: &str, violations: &[Violation]) -> String {
120 let fixes: Vec<_> = violations.iter().filter_map(|v| v.fix.clone()).collect();
121 Fixer::new()
122 .apply_fixes_to_content(content, &fixes)
123 .unwrap()
124 }
125
126 #[test]
127 fn test_no_dollar_signs() {
128 let content = "```bash\nls -la\necho hello\n```";
129 let parser = MarkdownParser::new(content);
130 let rule = MD014;
131 let violations = rule.check(&parser, None);
132
133 assert_eq!(violations.len(), 0);
134 }
135
136 #[test]
137 fn test_all_dollar_signs() {
138 let content = "```bash\n$ ls -la\n$ echo hello\n```";
139 let parser = MarkdownParser::new(content);
140 let rule = MD014;
141 let violations = rule.check(&parser, None);
142
143 assert_eq!(violations.len(), 2); assert_eq!(violations[0].line, 2); assert_eq!(violations[1].line, 3); }
147
148 #[test]
149 fn test_dollar_with_output() {
150 let content = "```bash\n$ ls -la\ntotal 64\n$ echo hello\nhello\n```";
151 let parser = MarkdownParser::new(content);
152 let rule = MD014;
153 let violations = rule.check(&parser, None);
154
155 assert_eq!(violations.len(), 0); }
157
158 #[test]
159 fn test_non_shell_language() {
160 let content = "```python\n$ this is not a shell\n```";
161 let parser = MarkdownParser::new(content);
162 let rule = MD014;
163 let violations = rule.check(&parser, None);
164
165 assert_eq!(violations.len(), 0); }
167
168 #[test]
169 fn test_fix_removes_dollar_signs() {
170 let content = "```bash\n$ ls -la\n$ echo hello\n```\n";
171 let parser = MarkdownParser::new(content);
172 let rule = MD014;
173 let violations = rule.check(&parser, None);
174 assert_eq!(violations.len(), 2);
175 let fixed = apply_fixes(content, &violations);
176 assert_eq!(fixed, "```bash\nls -la\necho hello\n```\n");
177 }
178}