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