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) -> &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                    // 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.strip_prefix('$').unwrap();
68                                    let after_dollar_trimmed = after_dollar.trim_start();
69                                    // Preserve leading whitespace before $
70                                    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); // One for each line with $
143        assert_eq!(violations[0].line, 2); // First line of code content
144        assert_eq!(violations[1].line, 3); // Second line of code content
145    }
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); // Mixed lines, showing output
155    }
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); // Not a shell language
165    }
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}