rumdl_lib/rules/
md014_commands_show_output.rs

1//!
2//! Rule MD014: Commands should show output
3//!
4//! See [docs/md014.md](../../docs/md014.md) for full documentation, configuration, and examples.
5
6use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, Severity};
7use crate::rule_config_serde::RuleConfig;
8use crate::utils::range_utils::{LineIndex, calculate_match_range};
9use lazy_static::lazy_static;
10use regex::Regex;
11use toml;
12
13mod md014_config;
14use md014_config::MD014Config;
15
16lazy_static! {
17    static ref COMMAND_PATTERN: Regex = Regex::new(r"^\s*[$>]\s+\S+").unwrap();
18    static ref SHELL_LANG_PATTERN: Regex = Regex::new(r"^(?i)(bash|sh|shell|console|terminal)").unwrap();
19    static ref DOLLAR_PROMPT_PATTERN: Regex = Regex::new(r"^\s*([$>])").unwrap();
20}
21
22#[derive(Clone, Default)]
23pub struct MD014CommandsShowOutput {
24    config: MD014Config,
25}
26
27impl MD014CommandsShowOutput {
28    pub fn new() -> Self {
29        Self::default()
30    }
31
32    pub fn with_show_output(show_output: bool) -> Self {
33        Self {
34            config: MD014Config { show_output },
35        }
36    }
37
38    pub fn from_config_struct(config: MD014Config) -> Self {
39        Self { config }
40    }
41
42    fn is_command_line(&self, line: &str) -> bool {
43        COMMAND_PATTERN.is_match(line)
44    }
45
46    fn is_shell_language(&self, lang: &str) -> bool {
47        SHELL_LANG_PATTERN.is_match(lang)
48    }
49
50    fn is_output_line(&self, line: &str) -> bool {
51        let trimmed = line.trim();
52        !trimmed.is_empty() && !trimmed.starts_with('$') && !trimmed.starts_with('>') && !trimmed.starts_with('#')
53    }
54
55    fn is_no_output_command(&self, cmd: &str) -> bool {
56        let cmd = cmd.trim().to_lowercase();
57        cmd.contains("cd ")
58            || cmd.contains("mkdir ")
59            || cmd.contains("touch ")
60            || cmd.contains("rm ")
61            || cmd.contains("mv ")
62            || cmd.contains("cp ")
63            || cmd.contains("export ")
64            || cmd.contains("set ")
65    }
66
67    fn is_command_without_output(&self, block: &[&str], lang: &str) -> bool {
68        if !self.config.show_output || !self.is_shell_language(lang) {
69            return false;
70        }
71
72        let mut has_command = false;
73        let mut has_output = false;
74        let mut last_command = String::new();
75
76        for line in block {
77            let trimmed = line.trim();
78            if self.is_command_line(line) {
79                has_command = true;
80                last_command = trimmed[1..].trim().to_string();
81            } else if self.is_output_line(line) {
82                has_output = true;
83            }
84        }
85
86        has_command && !has_output && !self.is_no_output_command(&last_command)
87    }
88
89    fn get_command_from_block(&self, block: &[&str]) -> String {
90        for line in block {
91            let trimmed = line.trim();
92            if self.is_command_line(line) {
93                return trimmed[1..].trim().to_string();
94            }
95        }
96        String::new()
97    }
98
99    fn fix_command_block(&self, block: &[&str]) -> String {
100        block
101            .iter()
102            .map(|line| {
103                let trimmed = line.trim_start();
104                if self.is_command_line(line) {
105                    let spaces = line.len() - line.trim_start().len();
106                    let cmd = trimmed.chars().skip(1).collect::<String>().trim_start().to_string();
107                    format!("{}{}", " ".repeat(spaces), cmd)
108                } else {
109                    line.to_string()
110                }
111            })
112            .collect::<Vec<_>>()
113            .join("\n")
114    }
115
116    fn get_code_block_language(block_start: &str) -> String {
117        block_start
118            .trim_start()
119            .trim_start_matches("```")
120            .split_whitespace()
121            .next()
122            .unwrap_or("")
123            .to_string()
124    }
125
126    fn find_first_command_line<'a>(&self, block: &[&'a str]) -> Option<(usize, &'a str)> {
127        for (i, line) in block.iter().enumerate() {
128            if self.is_command_line(line) {
129                return Some((i, line));
130            }
131        }
132        None
133    }
134}
135
136impl Rule for MD014CommandsShowOutput {
137    fn name(&self) -> &'static str {
138        "MD014"
139    }
140
141    fn description(&self) -> &'static str {
142        "Commands in code blocks should show output"
143    }
144
145    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
146        let content = ctx.content;
147        let _line_index = LineIndex::new(content.to_string());
148
149        let mut warnings = Vec::new();
150
151        let mut current_block = Vec::new();
152
153        let mut in_code_block = false;
154
155        let mut block_start_line = 0;
156
157        let mut current_lang = String::new();
158
159        for (line_num, line) in content.lines().enumerate() {
160            if line.trim_start().starts_with("```") {
161                if in_code_block {
162                    // End of code block
163                    if self.is_command_without_output(&current_block, &current_lang) {
164                        // Find the first command line to highlight the dollar sign
165                        if let Some((cmd_line_idx, cmd_line)) = self.find_first_command_line(&current_block) {
166                            let cmd_line_num = block_start_line + 1 + cmd_line_idx + 1; // +1 for fence, +1 for 1-indexed
167
168                            // Find and highlight the dollar sign or prompt
169                            if let Some(cap) = DOLLAR_PROMPT_PATTERN.captures(cmd_line) {
170                                let match_obj = cap.get(1).unwrap(); // The $ or > character
171                                let (start_line, start_col, end_line, end_col) =
172                                    calculate_match_range(cmd_line_num, cmd_line, match_obj.start(), match_obj.len());
173
174                                // Get the command for a more helpful message
175                                let command = self.get_command_from_block(&current_block);
176                                let message = if command.is_empty() {
177                                    "Command should show output (add example output or remove $ prompt)".to_string()
178                                } else {
179                                    format!(
180                                        "Command '{command}' should show output (add example output or remove $ prompt)"
181                                    )
182                                };
183
184                                warnings.push(LintWarning {
185                                    rule_name: Some(self.name()),
186                                    line: start_line,
187                                    column: start_col,
188                                    end_line,
189                                    end_column: end_col,
190                                    message,
191                                    severity: Severity::Warning,
192                                    fix: Some(Fix {
193                                        range: {
194                                            // Replace the content line(s) between the fences
195                                            let content_start_line = block_start_line + 1; // Line after opening fence (0-indexed)
196                                            let content_end_line = line_num - 1; // Line before closing fence (0-indexed)
197
198                                            // Calculate byte range for the content lines including their newlines
199                                            let start_byte =
200                                                _line_index.get_line_start_byte(content_start_line + 1).unwrap_or(0); // +1 for 1-indexed
201                                            let end_byte = _line_index
202                                                .get_line_start_byte(content_end_line + 2)
203                                                .unwrap_or(start_byte); // +2 to include newline after last content line
204                                            start_byte..end_byte
205                                        },
206                                        replacement: format!("{}\n", self.fix_command_block(&current_block)),
207                                    }),
208                                });
209                            }
210                        }
211                    }
212                    current_block.clear();
213                } else {
214                    // Start of code block
215                    block_start_line = line_num;
216                    current_lang = Self::get_code_block_language(line);
217                }
218                in_code_block = !in_code_block;
219            } else if in_code_block {
220                current_block.push(line);
221            }
222        }
223
224        Ok(warnings)
225    }
226
227    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
228        let content = ctx.content;
229        let _line_index = LineIndex::new(content.to_string());
230
231        let mut result = String::new();
232
233        let mut current_block = Vec::new();
234
235        let mut in_code_block = false;
236
237        let mut current_lang = String::new();
238
239        for line in content.lines() {
240            if line.trim_start().starts_with("```") {
241                if in_code_block {
242                    // End of code block
243                    if self.is_command_without_output(&current_block, &current_lang) {
244                        result.push_str(&self.fix_command_block(&current_block));
245                        result.push('\n');
246                    } else {
247                        for block_line in &current_block {
248                            result.push_str(block_line);
249                            result.push('\n');
250                        }
251                    }
252                    current_block.clear();
253                } else {
254                    current_lang = Self::get_code_block_language(line);
255                }
256                in_code_block = !in_code_block;
257                result.push_str(line);
258                result.push('\n');
259            } else if in_code_block {
260                current_block.push(line);
261            } else {
262                result.push_str(line);
263                result.push('\n');
264            }
265        }
266
267        // Remove trailing newline if original didn't have one
268        if !content.ends_with('\n') && result.ends_with('\n') {
269            result.pop();
270        }
271
272        Ok(result)
273    }
274
275    fn as_any(&self) -> &dyn std::any::Any {
276        self
277    }
278
279    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
280        // Skip if content is empty or has no code blocks
281        ctx.content.is_empty() || !ctx.content.contains("```")
282    }
283
284    fn default_config_section(&self) -> Option<(String, toml::Value)> {
285        let default_config = MD014Config::default();
286        let json_value = serde_json::to_value(&default_config).ok()?;
287        let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
288
289        if let toml::Value::Table(table) = toml_value {
290            if !table.is_empty() {
291                Some((MD014Config::RULE_NAME.to_string(), toml::Value::Table(table)))
292            } else {
293                None
294            }
295        } else {
296            None
297        }
298    }
299
300    fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
301    where
302        Self: Sized,
303    {
304        let rule_config = crate::rule_config_serde::load_rule_config::<MD014Config>(config);
305        Box::new(Self::from_config_struct(rule_config))
306    }
307}
308
309#[cfg(test)]
310mod tests {
311    use super::*;
312    use crate::lint_context::LintContext;
313
314    #[test]
315    fn test_is_command_line() {
316        let rule = MD014CommandsShowOutput::new();
317        assert!(rule.is_command_line("$ echo test"));
318        assert!(rule.is_command_line("  $ ls -la"));
319        assert!(rule.is_command_line("> pwd"));
320        assert!(rule.is_command_line("   > cd /home"));
321        assert!(!rule.is_command_line("echo test"));
322        assert!(!rule.is_command_line("# comment"));
323        assert!(!rule.is_command_line("output line"));
324    }
325
326    #[test]
327    fn test_is_shell_language() {
328        let rule = MD014CommandsShowOutput::new();
329        assert!(rule.is_shell_language("bash"));
330        assert!(rule.is_shell_language("BASH"));
331        assert!(rule.is_shell_language("sh"));
332        assert!(rule.is_shell_language("shell"));
333        assert!(rule.is_shell_language("Shell"));
334        assert!(rule.is_shell_language("console"));
335        assert!(rule.is_shell_language("CONSOLE"));
336        assert!(rule.is_shell_language("terminal"));
337        assert!(rule.is_shell_language("Terminal"));
338        assert!(!rule.is_shell_language("python"));
339        assert!(!rule.is_shell_language("javascript"));
340        assert!(!rule.is_shell_language(""));
341    }
342
343    #[test]
344    fn test_is_output_line() {
345        let rule = MD014CommandsShowOutput::new();
346        assert!(rule.is_output_line("output text"));
347        assert!(rule.is_output_line("   some output"));
348        assert!(rule.is_output_line("file1 file2"));
349        assert!(!rule.is_output_line(""));
350        assert!(!rule.is_output_line("   "));
351        assert!(!rule.is_output_line("$ command"));
352        assert!(!rule.is_output_line("> prompt"));
353        assert!(!rule.is_output_line("# comment"));
354    }
355
356    #[test]
357    fn test_is_no_output_command() {
358        let rule = MD014CommandsShowOutput::new();
359        assert!(rule.is_no_output_command("cd /home"));
360        assert!(rule.is_no_output_command("mkdir test"));
361        assert!(rule.is_no_output_command("touch file.txt"));
362        assert!(rule.is_no_output_command("rm -rf dir"));
363        assert!(rule.is_no_output_command("mv old new"));
364        assert!(rule.is_no_output_command("cp src dst"));
365        assert!(rule.is_no_output_command("export VAR=value"));
366        assert!(rule.is_no_output_command("set -e"));
367        assert!(rule.is_no_output_command("CD /HOME"));
368        assert!(rule.is_no_output_command("MKDIR TEST"));
369        assert!(!rule.is_no_output_command("ls -la"));
370        assert!(!rule.is_no_output_command("echo test"));
371        assert!(!rule.is_no_output_command("pwd"));
372    }
373
374    #[test]
375    fn test_get_command_from_block() {
376        let rule = MD014CommandsShowOutput::new();
377        let block = vec!["$ echo test", "output"];
378        assert_eq!(rule.get_command_from_block(&block), "echo test");
379
380        let block2 = vec!["  $ ls -la", "file1 file2"];
381        assert_eq!(rule.get_command_from_block(&block2), "ls -la");
382
383        let block3 = vec!["> pwd", "/home"];
384        assert_eq!(rule.get_command_from_block(&block3), "pwd");
385
386        let empty_block: Vec<&str> = vec![];
387        assert_eq!(rule.get_command_from_block(&empty_block), "");
388    }
389
390    #[test]
391    fn test_fix_command_block() {
392        let rule = MD014CommandsShowOutput::new();
393        let block = vec!["$ echo test", "$ ls -la"];
394        assert_eq!(rule.fix_command_block(&block), "echo test\nls -la");
395
396        let indented = vec!["    $ echo test", "  $ pwd"];
397        assert_eq!(rule.fix_command_block(&indented), "    echo test\n  pwd");
398
399        let mixed = vec!["> cd /home", "$ mkdir test"];
400        assert_eq!(rule.fix_command_block(&mixed), "cd /home\nmkdir test");
401    }
402
403    #[test]
404    fn test_get_code_block_language() {
405        assert_eq!(MD014CommandsShowOutput::get_code_block_language("```bash"), "bash");
406        assert_eq!(MD014CommandsShowOutput::get_code_block_language("```shell"), "shell");
407        assert_eq!(
408            MD014CommandsShowOutput::get_code_block_language("   ```console"),
409            "console"
410        );
411        assert_eq!(
412            MD014CommandsShowOutput::get_code_block_language("```bash {.line-numbers}"),
413            "bash"
414        );
415        assert_eq!(MD014CommandsShowOutput::get_code_block_language("```"), "");
416    }
417
418    #[test]
419    fn test_find_first_command_line() {
420        let rule = MD014CommandsShowOutput::new();
421        let block = vec!["# comment", "$ echo test", "output"];
422        let result = rule.find_first_command_line(&block);
423        assert_eq!(result, Some((1, "$ echo test")));
424
425        let no_commands = vec!["output1", "output2"];
426        assert_eq!(rule.find_first_command_line(&no_commands), None);
427    }
428
429    #[test]
430    fn test_is_command_without_output() {
431        let rule = MD014CommandsShowOutput::with_show_output(true);
432
433        // Commands without output should be flagged
434        let block1 = vec!["$ echo test"];
435        assert!(rule.is_command_without_output(&block1, "bash"));
436
437        // Commands with output should not be flagged
438        let block2 = vec!["$ echo test", "test"];
439        assert!(!rule.is_command_without_output(&block2, "bash"));
440
441        // No-output commands should not be flagged
442        let block3 = vec!["$ cd /home"];
443        assert!(!rule.is_command_without_output(&block3, "bash"));
444
445        // Disabled rule should not flag
446        let rule_disabled = MD014CommandsShowOutput::with_show_output(false);
447        assert!(!rule_disabled.is_command_without_output(&block1, "bash"));
448
449        // Non-shell language should not be flagged
450        assert!(!rule.is_command_without_output(&block1, "python"));
451    }
452
453    #[test]
454    fn test_edge_cases() {
455        let rule = MD014CommandsShowOutput::new();
456        // Bare $ doesn't match command pattern (needs a command after $)
457        let content = "```bash\n$ \n```";
458        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
459        let result = rule.check(&ctx).unwrap();
460        assert!(
461            result.is_empty(),
462            "Bare $ with only space doesn't match command pattern"
463        );
464
465        // Test empty code block
466        let empty_content = "```bash\n```";
467        let ctx2 = LintContext::new(empty_content, crate::config::MarkdownFlavor::Standard);
468        let result2 = rule.check(&ctx2).unwrap();
469        assert!(result2.is_empty(), "Empty code block should not be flagged");
470
471        // Test minimal command
472        let minimal = "```bash\n$ a\n```";
473        let ctx3 = LintContext::new(minimal, crate::config::MarkdownFlavor::Standard);
474        let result3 = rule.check(&ctx3).unwrap();
475        assert_eq!(result3.len(), 1, "Minimal command should be flagged");
476    }
477
478    #[test]
479    fn test_default_config_section() {
480        let rule = MD014CommandsShowOutput::new();
481        let config_section = rule.default_config_section();
482        assert!(config_section.is_some());
483        let (name, _value) = config_section.unwrap();
484        assert_eq!(name, "MD014");
485    }
486}