Skip to main content

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, RuleCategory, Severity};
7use crate::utils::range_utils::calculate_match_range;
8use regex::Regex;
9use std::sync::LazyLock;
10use toml;
11
12mod md014_config;
13use md014_config::MD014Config;
14
15// Command detection patterns
16static COMMAND_PATTERN: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^\s*[$>]\s+\S+").unwrap());
17static SHELL_LANG_PATTERN: LazyLock<Regex> =
18    LazyLock::new(|| Regex::new(r"^(?i)(bash|sh|shell|console|terminal)").unwrap());
19static DOLLAR_PROMPT_PATTERN: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^\s*([$>])").unwrap());
20
21#[derive(Clone, Default)]
22pub struct MD014CommandsShowOutput {
23    config: MD014Config,
24}
25
26impl MD014CommandsShowOutput {
27    pub fn new() -> Self {
28        Self::default()
29    }
30
31    pub fn with_show_output(show_output: bool) -> Self {
32        Self {
33            config: MD014Config { show_output },
34        }
35    }
36
37    pub fn from_config_struct(config: MD014Config) -> Self {
38        Self { config }
39    }
40
41    fn is_command_line(&self, line: &str) -> bool {
42        COMMAND_PATTERN.is_match(line)
43    }
44
45    fn is_shell_language(&self, lang: &str) -> bool {
46        SHELL_LANG_PATTERN.is_match(lang)
47    }
48
49    fn is_output_line(&self, line: &str) -> bool {
50        let trimmed = line.trim();
51        !trimmed.is_empty() && !trimmed.starts_with('$') && !trimmed.starts_with('>') && !trimmed.starts_with('#')
52    }
53
54    fn is_no_output_command(&self, cmd: &str) -> bool {
55        let cmd = cmd.trim().to_lowercase();
56
57        // Only skip commands that produce NO output by design.
58        // Commands that produce output (even if verbose) should NOT be skipped -
59        // the rule's intent is to encourage showing output when using $ prompts.
60
61        // Shell built-ins and commands that produce no terminal output
62        cmd.starts_with("cd ")
63            || cmd == "cd"
64            || cmd.starts_with("mkdir ")
65            || cmd.starts_with("touch ")
66            || cmd.starts_with("rm ")
67            || cmd.starts_with("mv ")
68            || cmd.starts_with("cp ")
69            || cmd.starts_with("export ")
70            || cmd.starts_with("set ")
71            || cmd.starts_with("alias ")
72            || cmd.starts_with("unset ")
73            || cmd.starts_with("source ")
74            || cmd.starts_with(". ")
75            || cmd == "true"
76            || cmd == "false"
77            || cmd.starts_with("sleep ")
78            || cmd.starts_with("wait ")
79            || cmd.starts_with("pushd ")
80            || cmd.starts_with("popd")
81
82            // Shell redirects (output goes to file, not terminal)
83            || cmd.contains(" > ")
84            || cmd.contains(" >> ")
85
86            // Git commands that produce no output on success
87            || cmd.starts_with("git add ")
88            || cmd.starts_with("git checkout ")
89            || cmd.starts_with("git stash")
90            || cmd.starts_with("git reset ")
91    }
92
93    fn fix_command_block(&self, block: &[&str]) -> String {
94        block
95            .iter()
96            .map(|line| {
97                let trimmed = line.trim_start();
98                if self.is_command_line(line) {
99                    let spaces = line.len() - trimmed.len();
100                    let cmd = trimmed[1..].trim_start();
101                    format!("{}{}", " ".repeat(spaces), cmd)
102                } else {
103                    line.to_string()
104                }
105            })
106            .collect::<Vec<_>>()
107            .join("\n")
108    }
109
110    fn get_code_block_language(block_start: &str) -> &str {
111        block_start
112            .trim_start()
113            .trim_start_matches("```")
114            .split_whitespace()
115            .next()
116            .unwrap_or("")
117    }
118
119    /// Find all command lines in the block that should produce output.
120    /// Skips no-output commands (cd, mkdir, etc.).
121    fn command_lines_without_output<'a>(&self, block: &[&'a str], lang: &str) -> Vec<(usize, &'a str)> {
122        if !self.config.show_output
123            || !self.is_shell_language(lang)
124            || block.iter().any(|line| self.is_output_line(line))
125        {
126            return Vec::new();
127        }
128
129        let mut results = Vec::new();
130        for (i, line) in block.iter().enumerate() {
131            if self.is_command_line(line) {
132                let cmd = line.trim()[1..].trim();
133                if !self.is_no_output_command(cmd) {
134                    results.push((i, *line));
135                }
136            }
137        }
138        results
139    }
140}
141
142impl Rule for MD014CommandsShowOutput {
143    fn name(&self) -> &'static str {
144        "MD014"
145    }
146
147    fn description(&self) -> &'static str {
148        "Commands in code blocks should show output"
149    }
150
151    fn category(&self) -> RuleCategory {
152        RuleCategory::CodeBlock
153    }
154
155    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
156        let content = ctx.content;
157
158        let mut warnings = Vec::new();
159
160        let mut current_block = Vec::new();
161
162        let mut in_code_block = false;
163
164        let mut block_start_line = 0;
165
166        let mut current_lang = "";
167
168        for (line_num, line) in content.lines().enumerate() {
169            if line.trim_start().starts_with("```") {
170                if in_code_block {
171                    // End of code block
172                    let command_lines = self.command_lines_without_output(&current_block, current_lang);
173                    if !command_lines.is_empty() {
174                        let fix = Fix::new(
175                            {
176                                // Replace the content line(s) between the fences
177                                let content_start_line = block_start_line + 1; // Line after opening fence (0-indexed)
178                                let content_end_line = line_num - 1; // Line before closing fence (0-indexed)
179
180                                // Calculate byte range for the content lines including their newlines
181                                let start_byte = ctx.line_start_byte(content_start_line + 1).unwrap_or(0); // +1 for 1-indexed
182                                let end_byte = ctx.line_start_byte(content_end_line + 2).unwrap_or(start_byte); // +2 to include newline after last content line
183                                start_byte..end_byte
184                            },
185                            format!("{}\n", self.fix_command_block(&current_block)),
186                        );
187
188                        for (cmd_line_idx, cmd_line) in &command_lines {
189                            let cmd_line_num = block_start_line + 1 + cmd_line_idx + 1; // +1 for fence, +1 for 1-indexed
190
191                            // Find and highlight the dollar sign or prompt
192                            if let Some(cap) = DOLLAR_PROMPT_PATTERN.captures(cmd_line) {
193                                let match_obj = cap.get(1).unwrap(); // The $ or > character
194                                let (start_line, start_col, end_line, end_col) =
195                                    calculate_match_range(cmd_line_num, cmd_line, match_obj.start(), match_obj.len());
196
197                                // Extract command text from this specific line
198                                let cmd_text = cmd_line.trim()[1..].trim();
199                                let message = if cmd_text.is_empty() {
200                                    "Command should show output (add example output or remove $ prompt)".to_string()
201                                } else {
202                                    format!(
203                                        "Command '{cmd_text}' should show output (add example output or remove $ prompt)"
204                                    )
205                                };
206
207                                warnings.push(LintWarning {
208                                    rule_name: Some(self.name().to_string()),
209                                    line: start_line,
210                                    column: start_col,
211                                    end_line,
212                                    end_column: end_col,
213                                    message,
214                                    severity: Severity::Warning,
215                                    fix: Some(fix.clone()),
216                                });
217                            }
218                        }
219                    }
220                    current_block.clear();
221                } else {
222                    // Start of code block
223                    block_start_line = line_num;
224                    current_lang = Self::get_code_block_language(line);
225                }
226                in_code_block = !in_code_block;
227            } else if in_code_block {
228                current_block.push(line);
229            }
230        }
231
232        Ok(warnings)
233    }
234
235    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
236        if self.should_skip(ctx) {
237            return Ok(ctx.content.to_string());
238        }
239        let warnings = self.check(ctx)?;
240        if warnings.is_empty() {
241            return Ok(ctx.content.to_string());
242        }
243        let warnings =
244            crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
245        crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings)
246            .map_err(crate::rule::LintError::InvalidInput)
247    }
248
249    fn as_any(&self) -> &dyn std::any::Any {
250        self
251    }
252
253    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
254        // Skip if content is empty or has no code blocks
255        ctx.content.is_empty() || !ctx.likely_has_code()
256    }
257
258    crate::impl_rule_config_methods!(MD014Config);
259}
260
261#[cfg(test)]
262mod tests {
263    use super::*;
264    use crate::lint_context::LintContext;
265
266    #[test]
267    fn test_is_command_line() {
268        let rule = MD014CommandsShowOutput::new();
269        assert!(rule.is_command_line("$ echo test"));
270        assert!(rule.is_command_line("  $ ls -la"));
271        assert!(rule.is_command_line("> pwd"));
272        assert!(rule.is_command_line("   > cd /home"));
273        assert!(!rule.is_command_line("echo test"));
274        assert!(!rule.is_command_line("# comment"));
275        assert!(!rule.is_command_line("output line"));
276    }
277
278    #[test]
279    fn test_is_shell_language() {
280        let rule = MD014CommandsShowOutput::new();
281        assert!(rule.is_shell_language("bash"));
282        assert!(rule.is_shell_language("BASH"));
283        assert!(rule.is_shell_language("sh"));
284        assert!(rule.is_shell_language("shell"));
285        assert!(rule.is_shell_language("Shell"));
286        assert!(rule.is_shell_language("console"));
287        assert!(rule.is_shell_language("CONSOLE"));
288        assert!(rule.is_shell_language("terminal"));
289        assert!(rule.is_shell_language("Terminal"));
290        assert!(!rule.is_shell_language("python"));
291        assert!(!rule.is_shell_language("javascript"));
292        assert!(!rule.is_shell_language(""));
293    }
294
295    #[test]
296    fn test_is_output_line() {
297        let rule = MD014CommandsShowOutput::new();
298        assert!(rule.is_output_line("output text"));
299        assert!(rule.is_output_line("   some output"));
300        assert!(rule.is_output_line("file1 file2"));
301        assert!(!rule.is_output_line(""));
302        assert!(!rule.is_output_line("   "));
303        assert!(!rule.is_output_line("$ command"));
304        assert!(!rule.is_output_line("> prompt"));
305        assert!(!rule.is_output_line("# comment"));
306    }
307
308    #[test]
309    fn test_is_no_output_command() {
310        let rule = MD014CommandsShowOutput::new();
311
312        // Shell built-ins that produce no output
313        assert!(rule.is_no_output_command("cd /home"));
314        assert!(rule.is_no_output_command("cd"));
315        assert!(rule.is_no_output_command("mkdir test"));
316        assert!(rule.is_no_output_command("touch file.txt"));
317        assert!(rule.is_no_output_command("rm -rf dir"));
318        assert!(rule.is_no_output_command("mv old new"));
319        assert!(rule.is_no_output_command("cp src dst"));
320        assert!(rule.is_no_output_command("export VAR=value"));
321        assert!(rule.is_no_output_command("set -e"));
322        assert!(rule.is_no_output_command("source ~/.bashrc"));
323        assert!(rule.is_no_output_command(". ~/.profile"));
324        assert!(rule.is_no_output_command("alias ll='ls -la'"));
325        assert!(rule.is_no_output_command("unset VAR"));
326        assert!(rule.is_no_output_command("true"));
327        assert!(rule.is_no_output_command("false"));
328        assert!(rule.is_no_output_command("sleep 5"));
329        assert!(rule.is_no_output_command("pushd /tmp"));
330        assert!(rule.is_no_output_command("popd"));
331
332        // Case insensitive (lowercased internally)
333        assert!(rule.is_no_output_command("CD /HOME"));
334        assert!(rule.is_no_output_command("MKDIR TEST"));
335
336        // Shell redirects (output goes to file)
337        assert!(rule.is_no_output_command("echo 'test' > file.txt"));
338        assert!(rule.is_no_output_command("cat input.txt > output.txt"));
339        assert!(rule.is_no_output_command("echo 'append' >> log.txt"));
340
341        // Git commands that produce no output on success
342        assert!(rule.is_no_output_command("git add ."));
343        assert!(rule.is_no_output_command("git checkout main"));
344        assert!(rule.is_no_output_command("git stash"));
345        assert!(rule.is_no_output_command("git reset HEAD~1"));
346
347        // Commands that PRODUCE output (should NOT be skipped)
348        assert!(!rule.is_no_output_command("ls -la"));
349        assert!(!rule.is_no_output_command("echo test")); // echo without redirect
350        assert!(!rule.is_no_output_command("pwd"));
351        assert!(!rule.is_no_output_command("cat file.txt")); // cat without redirect
352        assert!(!rule.is_no_output_command("grep pattern file"));
353
354        // Installation commands PRODUCE output (should NOT be skipped)
355        assert!(!rule.is_no_output_command("pip install requests"));
356        assert!(!rule.is_no_output_command("npm install express"));
357        assert!(!rule.is_no_output_command("cargo install ripgrep"));
358        assert!(!rule.is_no_output_command("brew install git"));
359
360        // Build commands PRODUCE output (should NOT be skipped)
361        assert!(!rule.is_no_output_command("cargo build"));
362        assert!(!rule.is_no_output_command("npm run build"));
363        assert!(!rule.is_no_output_command("make"));
364
365        // Docker commands PRODUCE output (should NOT be skipped)
366        assert!(!rule.is_no_output_command("docker ps"));
367        assert!(!rule.is_no_output_command("docker compose up"));
368        assert!(!rule.is_no_output_command("docker run myimage"));
369
370        // Git commands that PRODUCE output (should NOT be skipped)
371        assert!(!rule.is_no_output_command("git status"));
372        assert!(!rule.is_no_output_command("git log"));
373        assert!(!rule.is_no_output_command("git diff"));
374    }
375
376    #[test]
377    fn test_fix_command_block() {
378        let rule = MD014CommandsShowOutput::new();
379        let block = vec!["$ echo test", "$ ls -la"];
380        assert_eq!(rule.fix_command_block(&block), "echo test\nls -la");
381
382        let indented = vec!["    $ echo test", "  $ pwd"];
383        assert_eq!(rule.fix_command_block(&indented), "    echo test\n  pwd");
384
385        let mixed = vec!["> cd /home", "$ mkdir test"];
386        assert_eq!(rule.fix_command_block(&mixed), "cd /home\nmkdir test");
387    }
388
389    #[test]
390    fn test_get_code_block_language() {
391        assert_eq!(MD014CommandsShowOutput::get_code_block_language("```bash"), "bash");
392        assert_eq!(MD014CommandsShowOutput::get_code_block_language("```shell"), "shell");
393        assert_eq!(
394            MD014CommandsShowOutput::get_code_block_language("   ```console"),
395            "console"
396        );
397        assert_eq!(
398            MD014CommandsShowOutput::get_code_block_language("```bash {.line-numbers}"),
399            "bash"
400        );
401        assert_eq!(MD014CommandsShowOutput::get_code_block_language("```"), "");
402    }
403
404    #[test]
405    fn test_command_lines_without_output() {
406        let rule = MD014CommandsShowOutput::with_show_output(true);
407
408        let multiple = ["# comment", "$ echo one", "$ cd /tmp", "$ echo two"];
409        assert_eq!(
410            rule.command_lines_without_output(&multiple, "bash"),
411            vec![(1, "$ echo one"), (3, "$ echo two")]
412        );
413
414        // Commands without output should be flagged
415        let block1 = vec!["$ echo test"];
416        assert_eq!(
417            rule.command_lines_without_output(&block1, "bash"),
418            vec![(0, "$ echo test")]
419        );
420
421        // Commands with output should not be flagged
422        let block2 = vec!["$ echo test", "test"];
423        assert!(rule.command_lines_without_output(&block2, "bash").is_empty());
424
425        // No-output commands should not be flagged
426        let block3 = vec!["$ cd /home"];
427        assert!(rule.command_lines_without_output(&block3, "bash").is_empty());
428
429        // Disabled rule should not flag
430        let rule_disabled = MD014CommandsShowOutput::with_show_output(false);
431        assert!(rule_disabled.command_lines_without_output(&block1, "bash").is_empty());
432
433        // Non-shell language should not be flagged
434        assert!(rule.command_lines_without_output(&block1, "python").is_empty());
435    }
436
437    #[test]
438    fn test_edge_cases() {
439        let rule = MD014CommandsShowOutput::new();
440        // Bare $ doesn't match command pattern (needs a command after $)
441        let content = "```bash\n$ \n```";
442        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
443        let result = rule.check(&ctx).unwrap();
444        assert!(
445            result.is_empty(),
446            "Bare $ with only space doesn't match command pattern"
447        );
448
449        // Test empty code block
450        let empty_content = "```bash\n```";
451        let ctx2 = LintContext::new(empty_content, crate::config::MarkdownFlavor::Standard, None);
452        let result2 = rule.check(&ctx2).unwrap();
453        assert!(result2.is_empty(), "Empty code block should not be flagged");
454
455        // Test minimal command
456        let minimal = "```bash\n$ a\n```";
457        let ctx3 = LintContext::new(minimal, crate::config::MarkdownFlavor::Standard, None);
458        let result3 = rule.check(&ctx3).unwrap();
459        assert_eq!(result3.len(), 1, "Minimal command should be flagged");
460    }
461
462    #[test]
463    fn test_mixed_silent_and_output_commands() {
464        let rule = MD014CommandsShowOutput::new();
465
466        // Block with only silent commands should NOT be flagged
467        let silent_only = "```bash\n$ cd /home\n$ mkdir test\n```";
468        let ctx1 = LintContext::new(silent_only, crate::config::MarkdownFlavor::Standard, None);
469        let result1 = rule.check(&ctx1).unwrap();
470        assert!(
471            result1.is_empty(),
472            "Block with only silent commands should not be flagged"
473        );
474
475        // Block with silent commands followed by output-producing command
476        // should flag the output-producing command only
477        let mixed_silent_first = "```bash\n$ cd /home\n$ ls -la\n```";
478        let ctx2 = LintContext::new(mixed_silent_first, crate::config::MarkdownFlavor::Standard, None);
479        let result2 = rule.check(&ctx2).unwrap();
480        assert_eq!(result2.len(), 1, "Only output-producing commands should be flagged");
481        assert!(
482            result2[0].message.contains("ls -la"),
483            "Message should mention 'ls -la', not 'cd /home'. Got: {}",
484            result2[0].message
485        );
486
487        // Block with mkdir followed by cat (which produces output)
488        let mixed_mkdir_cat = "```bash\n$ mkdir test\n$ cat file.txt\n```";
489        let ctx3 = LintContext::new(mixed_mkdir_cat, crate::config::MarkdownFlavor::Standard, None);
490        let result3 = rule.check(&ctx3).unwrap();
491        assert_eq!(result3.len(), 1, "Only output-producing commands should be flagged");
492        assert!(
493            result3[0].message.contains("cat file.txt"),
494            "Message should mention 'cat file.txt', not 'mkdir'. Got: {}",
495            result3[0].message
496        );
497
498        // Block with silent command followed by pip install (which produces output)
499        let mkdir_pip = "```bash\n$ mkdir test\n$ pip install something\n```";
500        let ctx3b = LintContext::new(mkdir_pip, crate::config::MarkdownFlavor::Standard, None);
501        let result3b = rule.check(&ctx3b).unwrap();
502        assert_eq!(result3b.len(), 1, "Block with pip install should be flagged");
503        assert!(
504            result3b[0].message.contains("pip install"),
505            "Message should mention 'pip install'. Got: {}",
506            result3b[0].message
507        );
508
509        // Block with output-producing command followed by silent command
510        let mixed_output_first = "```bash\n$ echo hello\n$ cd /home\n```";
511        let ctx4 = LintContext::new(mixed_output_first, crate::config::MarkdownFlavor::Standard, None);
512        let result4 = rule.check(&ctx4).unwrap();
513        assert_eq!(result4.len(), 1, "Only output-producing commands should be flagged");
514        assert!(
515            result4[0].message.contains("echo hello"),
516            "Message should mention 'echo hello'. Got: {}",
517            result4[0].message
518        );
519    }
520
521    #[test]
522    fn test_multiple_commands_without_output_all_flagged() {
523        let rule = MD014CommandsShowOutput::new();
524
525        // Two identical commands without output should produce two warnings
526        let content = "```shell\n# First invocation\n$ my_command\n\n# Second invocation\n$ my_command\n```";
527        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
528        let result = rule.check(&ctx).unwrap();
529        assert_eq!(result.len(), 2, "Both commands should be flagged. Got: {result:?}");
530        assert!(result[0].message.contains("my_command"));
531        assert!(result[1].message.contains("my_command"));
532        // Verify they point to different lines
533        assert_ne!(result[0].line, result[1].line, "Warnings should be on different lines");
534
535        // Three different commands without output
536        let content2 = "```bash\n$ echo hello\n$ ls -la\n$ pwd\n```";
537        let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
538        let result2 = rule.check(&ctx2).unwrap();
539        assert_eq!(
540            result2.len(),
541            3,
542            "All three commands should be flagged. Got: {result2:?}"
543        );
544        assert!(result2[0].message.contains("echo hello"));
545        assert!(result2[1].message.contains("ls -la"));
546        assert!(result2[2].message.contains("pwd"));
547
548        // Two output-producing commands mixed with one silent command
549        let content3 = "```bash\n$ echo hello\n$ cd /tmp\n$ ls -la\n```";
550        let ctx3 = LintContext::new(content3, crate::config::MarkdownFlavor::Standard, None);
551        let result3 = rule.check(&ctx3).unwrap();
552        assert_eq!(
553            result3.len(),
554            2,
555            "Only output-producing commands should be flagged. Got: {result3:?}"
556        );
557        assert!(result3[0].message.contains("echo hello"));
558        assert!(result3[1].message.contains("ls -la"));
559    }
560
561    #[test]
562    fn test_issue_516_exact_case() {
563        let rule = MD014CommandsShowOutput::new();
564
565        // Exact test case from GitHub issue #516
566        let content = "---\ntitle: Heading\n---\n\nHere is a fenced code block:\n\n```shell\n# First invocation of my_command\n$ my_command\n\n# Second invocation of my_command\n$ my_command\n```\n";
567        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
568        let result = rule.check(&ctx).unwrap();
569        assert_eq!(
570            result.len(),
571            2,
572            "Both $ my_command lines should be flagged. Got: {result:?}"
573        );
574        assert_eq!(result[0].line, 9, "First warning should be on line 9");
575        assert_eq!(result[1].line, 12, "Second warning should be on line 12");
576    }
577
578    #[test]
579    fn test_default_config_section() {
580        let rule = MD014CommandsShowOutput::new();
581        let config_section = rule.default_config_section();
582        assert!(config_section.is_some());
583        let (name, _value) = config_section.unwrap();
584        assert_eq!(name, "MD014");
585    }
586}