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) -> &str {
11 "MD014"
12 }
13
14 fn description(&self) -> &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.strip_prefix('$').unwrap();
68 let after_dollar_trimmed = after_dollar.trim_start();
69 let leading_spaces = line.len() - trimmed.len();
71 let replacement = format!(
72 "{}{}",
73 " ".repeat(leading_spaces),
74 after_dollar_trimmed
75 );
76
77 violations.push(Violation {
78 line: current_line,
79 column: Some(1),
80 rule: self.name().to_string(),
81 message:
82 "Dollar signs should not be used before commands without showing output"
83 .to_string(),
84 fix: Some(Fix {
85 line_start: current_line,
86 line_end: current_line,
87 column_start: None,
88 column_end: None,
89 replacement,
90 description: "Remove dollar sign".to_string(),
91 }),
92 });
93 }
94 }
95 }
96 }
97
98 in_shell_code_block = false;
99 code_block_lines.clear();
100 }
101 _ => {}
102 }
103 }
104
105 violations
106 }
107
108 fn fixable(&self) -> bool {
109 true
110 }
111}
112
113#[cfg(test)]
114mod tests {
115 use super::*;
116 use crate::fix::Fixer;
117
118 fn apply_fixes(content: &str, violations: &[Violation]) -> String {
119 let fixes: Vec<_> = violations.iter().filter_map(|v| v.fix.clone()).collect();
120 Fixer::new()
121 .apply_fixes_to_content(content, &fixes)
122 .unwrap()
123 }
124
125 #[test]
126 fn test_no_dollar_signs() {
127 let content = "```bash\nls -la\necho hello\n```";
128 let parser = MarkdownParser::new(content);
129 let rule = MD014;
130 let violations = rule.check(&parser, None);
131
132 assert_eq!(violations.len(), 0);
133 }
134
135 #[test]
136 fn test_all_dollar_signs() {
137 let content = "```bash\n$ ls -la\n$ echo hello\n```";
138 let parser = MarkdownParser::new(content);
139 let rule = MD014;
140 let violations = rule.check(&parser, None);
141
142 assert_eq!(violations.len(), 2); assert_eq!(violations[0].line, 2); assert_eq!(violations[1].line, 3); }
146
147 #[test]
148 fn test_dollar_with_output() {
149 let content = "```bash\n$ ls -la\ntotal 64\n$ echo hello\nhello\n```";
150 let parser = MarkdownParser::new(content);
151 let rule = MD014;
152 let violations = rule.check(&parser, None);
153
154 assert_eq!(violations.len(), 0); }
156
157 #[test]
158 fn test_non_shell_language() {
159 let content = "```python\n$ this is not a shell\n```";
160 let parser = MarkdownParser::new(content);
161 let rule = MD014;
162 let violations = rule.check(&parser, None);
163
164 assert_eq!(violations.len(), 0); }
166
167 #[test]
168 fn test_fix_removes_dollar_signs() {
169 let content = "```bash\n$ ls -la\n$ echo hello\n```\n";
170 let parser = MarkdownParser::new(content);
171 let rule = MD014;
172 let violations = rule.check(&parser, None);
173 assert_eq!(violations.len(), 2);
174 let fixed = apply_fixes(content, &violations);
175 assert_eq!(fixed, "```bash\nls -la\necho hello\n```\n");
176 }
177}