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