Skip to main content

mdlint/lint/rules/
md014.rs

1use 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                    // Check if all non-empty lines start with $
46                    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                            // Report a violation for each line that starts with $
61                            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                                    // Remove leading $ and any spaces after it
66                                    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                                    // Preserve leading whitespace before $
72                                    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); // One for each line with $
144        assert_eq!(violations[0].line, 2); // First line of code content
145        assert_eq!(violations[1].line, 3); // Second line of code content
146    }
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); // Mixed lines, showing output
156    }
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); // Not a shell language
166    }
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}