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