1use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, Severity};
7use crate::rule_config_serde::RuleConfig;
8use crate::utils::range_utils::calculate_match_range;
9use crate::utils::regex_cache::get_cached_regex;
10use toml;
11
12mod md014_config;
13use md014_config::MD014Config;
14
15const 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 cmd.starts_with("cd ")
66 || cmd == "cd"
67 || cmd.starts_with("mkdir ")
68 || cmd.starts_with("touch ")
69 || cmd.starts_with("rm ")
70 || cmd.starts_with("mv ")
71 || cmd.starts_with("cp ")
72 || cmd.starts_with("export ")
73 || cmd.starts_with("set ")
74 || cmd.starts_with("alias ")
75 || cmd.starts_with("unset ")
76 || cmd.starts_with("source ")
77 || cmd.starts_with(". ")
78 || cmd == "true"
79 || cmd == "false"
80 || cmd.starts_with("sleep ")
81 || cmd.starts_with("wait ")
82 || cmd.starts_with("pushd ")
83 || cmd.starts_with("popd")
84
85 || cmd.contains(" > ")
87 || cmd.contains(" >> ")
88
89 || cmd.starts_with("git add ")
91 || cmd.starts_with("git checkout ")
92 || cmd.starts_with("git stash")
93 || cmd.starts_with("git reset ")
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 has_output = block.iter().any(|line| self.is_output_line(line));
103 if has_output {
104 return false; }
106
107 self.get_first_output_command(block).is_some()
109 }
110
111 fn get_first_output_command(&self, block: &[&str]) -> Option<(usize, String)> {
114 for (i, line) in block.iter().enumerate() {
115 if self.is_command_line(line) {
116 let cmd = line.trim()[1..].trim().to_string();
117 if !self.is_no_output_command(&cmd) {
118 return Some((i, cmd));
119 }
120 }
121 }
122 None }
124
125 fn fix_command_block(&self, block: &[&str]) -> String {
126 block
127 .iter()
128 .map(|line| {
129 let trimmed = line.trim_start();
130 if self.is_command_line(line) {
131 let spaces = line.len() - line.trim_start().len();
132 let cmd = trimmed.chars().skip(1).collect::<String>().trim_start().to_string();
133 format!("{}{}", " ".repeat(spaces), cmd)
134 } else {
135 line.to_string()
136 }
137 })
138 .collect::<Vec<_>>()
139 .join("\n")
140 }
141
142 fn get_code_block_language(block_start: &str) -> String {
143 block_start
144 .trim_start()
145 .trim_start_matches("```")
146 .split_whitespace()
147 .next()
148 .unwrap_or("")
149 .to_string()
150 }
151
152 fn find_all_command_lines<'a>(&self, block: &[&'a str]) -> Vec<(usize, &'a str)> {
155 let mut results = Vec::new();
156 for (i, line) in block.iter().enumerate() {
157 if self.is_command_line(line) {
158 let cmd = line.trim()[1..].trim();
159 if !self.is_no_output_command(cmd) {
160 results.push((i, *line));
161 }
162 }
163 }
164 results
165 }
166}
167
168impl Rule for MD014CommandsShowOutput {
169 fn name(&self) -> &'static str {
170 "MD014"
171 }
172
173 fn description(&self) -> &'static str {
174 "Commands in code blocks should show output"
175 }
176
177 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
178 let content = ctx.content;
179 let _line_index = &ctx.line_index;
180
181 let mut warnings = Vec::new();
182
183 let mut current_block = Vec::new();
184
185 let mut in_code_block = false;
186
187 let mut block_start_line = 0;
188
189 let mut current_lang = String::new();
190
191 for (line_num, line) in content.lines().enumerate() {
192 if line.trim_start().starts_with("```") {
193 if in_code_block {
194 if self.is_command_without_output(¤t_block, ¤t_lang) {
196 let command_lines = self.find_all_command_lines(¤t_block);
198 let fix = Fix {
199 range: {
200 let content_start_line = block_start_line + 1; let content_end_line = line_num - 1; let start_byte = _line_index.get_line_start_byte(content_start_line + 1).unwrap_or(0); let end_byte = _line_index
207 .get_line_start_byte(content_end_line + 2)
208 .unwrap_or(start_byte); start_byte..end_byte
210 },
211 replacement: format!("{}\n", self.fix_command_block(¤t_block)),
212 };
213
214 for (cmd_line_idx, cmd_line) in &command_lines {
215 let cmd_line_num = block_start_line + 1 + cmd_line_idx + 1; if let Ok(re) = get_cached_regex(DOLLAR_PROMPT_PATTERN)
219 && let Some(cap) = re.captures(cmd_line)
220 {
221 let match_obj = cap.get(1).unwrap(); let (start_line, start_col, end_line, end_col) =
223 calculate_match_range(cmd_line_num, cmd_line, match_obj.start(), match_obj.len());
224
225 let cmd_text = cmd_line.trim()[1..].trim().to_string();
227 let message = if cmd_text.is_empty() {
228 "Command should show output (add example output or remove $ prompt)".to_string()
229 } else {
230 format!(
231 "Command '{cmd_text}' should show output (add example output or remove $ prompt)"
232 )
233 };
234
235 warnings.push(LintWarning {
236 rule_name: Some(self.name().to_string()),
237 line: start_line,
238 column: start_col,
239 end_line,
240 end_column: end_col,
241 message,
242 severity: Severity::Warning,
243 fix: Some(fix.clone()),
244 });
245 }
246 }
247 }
248 current_block.clear();
249 } else {
250 block_start_line = line_num;
252 current_lang = Self::get_code_block_language(line);
253 }
254 in_code_block = !in_code_block;
255 } else if in_code_block {
256 current_block.push(line);
257 }
258 }
259
260 Ok(warnings)
261 }
262
263 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
264 let content = ctx.content;
265 let _line_index = &ctx.line_index;
266
267 let mut result = String::new();
268
269 let mut current_block = Vec::new();
270
271 let mut in_code_block = false;
272
273 let mut current_lang = String::new();
274
275 let mut block_start_line_num = 0usize;
276
277 for (line_num_0, line) in content.lines().enumerate() {
278 let line_num = line_num_0 + 1;
279 if line.trim_start().starts_with("```") {
280 if in_code_block {
281 let block_disabled = (0..current_block.len()).any(|j| {
284 let block_line_num = block_start_line_num + 1 + j;
285 ctx.inline_config().is_rule_disabled(self.name(), block_line_num)
286 });
287 if !block_disabled && self.is_command_without_output(¤t_block, ¤t_lang) {
288 result.push_str(&self.fix_command_block(¤t_block));
289 result.push('\n');
290 } else {
291 for block_line in ¤t_block {
292 result.push_str(block_line);
293 result.push('\n');
294 }
295 }
296 current_block.clear();
297 } else {
298 current_lang = Self::get_code_block_language(line);
299 block_start_line_num = line_num;
300 }
301 in_code_block = !in_code_block;
302 result.push_str(line);
303 result.push('\n');
304 } else if in_code_block {
305 current_block.push(line);
306 } else {
307 result.push_str(line);
308 result.push('\n');
309 }
310 }
311
312 if !content.ends_with('\n') && result.ends_with('\n') {
314 result.pop();
315 }
316
317 Ok(result)
318 }
319
320 fn as_any(&self) -> &dyn std::any::Any {
321 self
322 }
323
324 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
325 ctx.content.is_empty() || !ctx.likely_has_code()
327 }
328
329 fn default_config_section(&self) -> Option<(String, toml::Value)> {
330 let default_config = MD014Config::default();
331 let json_value = serde_json::to_value(&default_config).ok()?;
332 let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
333
334 if let toml::Value::Table(table) = toml_value {
335 if !table.is_empty() {
336 Some((MD014Config::RULE_NAME.to_string(), toml::Value::Table(table)))
337 } else {
338 None
339 }
340 } else {
341 None
342 }
343 }
344
345 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
346 where
347 Self: Sized,
348 {
349 let rule_config = crate::rule_config_serde::load_rule_config::<MD014Config>(config);
350 Box::new(Self::from_config_struct(rule_config))
351 }
352}
353
354#[cfg(test)]
355mod tests {
356 use super::*;
357 use crate::lint_context::LintContext;
358
359 #[test]
360 fn test_is_command_line() {
361 let rule = MD014CommandsShowOutput::new();
362 assert!(rule.is_command_line("$ echo test"));
363 assert!(rule.is_command_line(" $ ls -la"));
364 assert!(rule.is_command_line("> pwd"));
365 assert!(rule.is_command_line(" > cd /home"));
366 assert!(!rule.is_command_line("echo test"));
367 assert!(!rule.is_command_line("# comment"));
368 assert!(!rule.is_command_line("output line"));
369 }
370
371 #[test]
372 fn test_is_shell_language() {
373 let rule = MD014CommandsShowOutput::new();
374 assert!(rule.is_shell_language("bash"));
375 assert!(rule.is_shell_language("BASH"));
376 assert!(rule.is_shell_language("sh"));
377 assert!(rule.is_shell_language("shell"));
378 assert!(rule.is_shell_language("Shell"));
379 assert!(rule.is_shell_language("console"));
380 assert!(rule.is_shell_language("CONSOLE"));
381 assert!(rule.is_shell_language("terminal"));
382 assert!(rule.is_shell_language("Terminal"));
383 assert!(!rule.is_shell_language("python"));
384 assert!(!rule.is_shell_language("javascript"));
385 assert!(!rule.is_shell_language(""));
386 }
387
388 #[test]
389 fn test_is_output_line() {
390 let rule = MD014CommandsShowOutput::new();
391 assert!(rule.is_output_line("output text"));
392 assert!(rule.is_output_line(" some output"));
393 assert!(rule.is_output_line("file1 file2"));
394 assert!(!rule.is_output_line(""));
395 assert!(!rule.is_output_line(" "));
396 assert!(!rule.is_output_line("$ command"));
397 assert!(!rule.is_output_line("> prompt"));
398 assert!(!rule.is_output_line("# comment"));
399 }
400
401 #[test]
402 fn test_is_no_output_command() {
403 let rule = MD014CommandsShowOutput::new();
404
405 assert!(rule.is_no_output_command("cd /home"));
407 assert!(rule.is_no_output_command("cd"));
408 assert!(rule.is_no_output_command("mkdir test"));
409 assert!(rule.is_no_output_command("touch file.txt"));
410 assert!(rule.is_no_output_command("rm -rf dir"));
411 assert!(rule.is_no_output_command("mv old new"));
412 assert!(rule.is_no_output_command("cp src dst"));
413 assert!(rule.is_no_output_command("export VAR=value"));
414 assert!(rule.is_no_output_command("set -e"));
415 assert!(rule.is_no_output_command("source ~/.bashrc"));
416 assert!(rule.is_no_output_command(". ~/.profile"));
417 assert!(rule.is_no_output_command("alias ll='ls -la'"));
418 assert!(rule.is_no_output_command("unset VAR"));
419 assert!(rule.is_no_output_command("true"));
420 assert!(rule.is_no_output_command("false"));
421 assert!(rule.is_no_output_command("sleep 5"));
422 assert!(rule.is_no_output_command("pushd /tmp"));
423 assert!(rule.is_no_output_command("popd"));
424
425 assert!(rule.is_no_output_command("CD /HOME"));
427 assert!(rule.is_no_output_command("MKDIR TEST"));
428
429 assert!(rule.is_no_output_command("echo 'test' > file.txt"));
431 assert!(rule.is_no_output_command("cat input.txt > output.txt"));
432 assert!(rule.is_no_output_command("echo 'append' >> log.txt"));
433
434 assert!(rule.is_no_output_command("git add ."));
436 assert!(rule.is_no_output_command("git checkout main"));
437 assert!(rule.is_no_output_command("git stash"));
438 assert!(rule.is_no_output_command("git reset HEAD~1"));
439
440 assert!(!rule.is_no_output_command("ls -la"));
442 assert!(!rule.is_no_output_command("echo test")); assert!(!rule.is_no_output_command("pwd"));
444 assert!(!rule.is_no_output_command("cat file.txt")); assert!(!rule.is_no_output_command("grep pattern file"));
446
447 assert!(!rule.is_no_output_command("pip install requests"));
449 assert!(!rule.is_no_output_command("npm install express"));
450 assert!(!rule.is_no_output_command("cargo install ripgrep"));
451 assert!(!rule.is_no_output_command("brew install git"));
452
453 assert!(!rule.is_no_output_command("cargo build"));
455 assert!(!rule.is_no_output_command("npm run build"));
456 assert!(!rule.is_no_output_command("make"));
457
458 assert!(!rule.is_no_output_command("docker ps"));
460 assert!(!rule.is_no_output_command("docker compose up"));
461 assert!(!rule.is_no_output_command("docker run myimage"));
462
463 assert!(!rule.is_no_output_command("git status"));
465 assert!(!rule.is_no_output_command("git log"));
466 assert!(!rule.is_no_output_command("git diff"));
467 }
468
469 #[test]
470 fn test_fix_command_block() {
471 let rule = MD014CommandsShowOutput::new();
472 let block = vec!["$ echo test", "$ ls -la"];
473 assert_eq!(rule.fix_command_block(&block), "echo test\nls -la");
474
475 let indented = vec![" $ echo test", " $ pwd"];
476 assert_eq!(rule.fix_command_block(&indented), " echo test\n pwd");
477
478 let mixed = vec!["> cd /home", "$ mkdir test"];
479 assert_eq!(rule.fix_command_block(&mixed), "cd /home\nmkdir test");
480 }
481
482 #[test]
483 fn test_get_code_block_language() {
484 assert_eq!(MD014CommandsShowOutput::get_code_block_language("```bash"), "bash");
485 assert_eq!(MD014CommandsShowOutput::get_code_block_language("```shell"), "shell");
486 assert_eq!(
487 MD014CommandsShowOutput::get_code_block_language(" ```console"),
488 "console"
489 );
490 assert_eq!(
491 MD014CommandsShowOutput::get_code_block_language("```bash {.line-numbers}"),
492 "bash"
493 );
494 assert_eq!(MD014CommandsShowOutput::get_code_block_language("```"), "");
495 }
496
497 #[test]
498 fn test_find_all_command_lines() {
499 let rule = MD014CommandsShowOutput::new();
500 let block = vec!["# comment", "$ echo test", "output"];
501 let result = rule.find_all_command_lines(&block);
502 assert_eq!(result, vec![(1, "$ echo test")]);
503
504 let no_commands = vec!["output1", "output2"];
505 assert!(rule.find_all_command_lines(&no_commands).is_empty());
506
507 let multiple = vec!["$ echo one", "$ echo two", "$ cd /tmp"];
508 let result = rule.find_all_command_lines(&multiple);
509 assert_eq!(result, vec![(0, "$ echo one"), (1, "$ echo two")]);
511 }
512
513 #[test]
514 fn test_is_command_without_output() {
515 let rule = MD014CommandsShowOutput::with_show_output(true);
516
517 let block1 = vec!["$ echo test"];
519 assert!(rule.is_command_without_output(&block1, "bash"));
520
521 let block2 = vec!["$ echo test", "test"];
523 assert!(!rule.is_command_without_output(&block2, "bash"));
524
525 let block3 = vec!["$ cd /home"];
527 assert!(!rule.is_command_without_output(&block3, "bash"));
528
529 let rule_disabled = MD014CommandsShowOutput::with_show_output(false);
531 assert!(!rule_disabled.is_command_without_output(&block1, "bash"));
532
533 assert!(!rule.is_command_without_output(&block1, "python"));
535 }
536
537 #[test]
538 fn test_edge_cases() {
539 let rule = MD014CommandsShowOutput::new();
540 let content = "```bash\n$ \n```";
542 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
543 let result = rule.check(&ctx).unwrap();
544 assert!(
545 result.is_empty(),
546 "Bare $ with only space doesn't match command pattern"
547 );
548
549 let empty_content = "```bash\n```";
551 let ctx2 = LintContext::new(empty_content, crate::config::MarkdownFlavor::Standard, None);
552 let result2 = rule.check(&ctx2).unwrap();
553 assert!(result2.is_empty(), "Empty code block should not be flagged");
554
555 let minimal = "```bash\n$ a\n```";
557 let ctx3 = LintContext::new(minimal, crate::config::MarkdownFlavor::Standard, None);
558 let result3 = rule.check(&ctx3).unwrap();
559 assert_eq!(result3.len(), 1, "Minimal command should be flagged");
560 }
561
562 #[test]
563 fn test_mixed_silent_and_output_commands() {
564 let rule = MD014CommandsShowOutput::new();
565
566 let silent_only = "```bash\n$ cd /home\n$ mkdir test\n```";
568 let ctx1 = LintContext::new(silent_only, crate::config::MarkdownFlavor::Standard, None);
569 let result1 = rule.check(&ctx1).unwrap();
570 assert!(
571 result1.is_empty(),
572 "Block with only silent commands should not be flagged"
573 );
574
575 let mixed_silent_first = "```bash\n$ cd /home\n$ ls -la\n```";
578 let ctx2 = LintContext::new(mixed_silent_first, crate::config::MarkdownFlavor::Standard, None);
579 let result2 = rule.check(&ctx2).unwrap();
580 assert_eq!(result2.len(), 1, "Only output-producing commands should be flagged");
581 assert!(
582 result2[0].message.contains("ls -la"),
583 "Message should mention 'ls -la', not 'cd /home'. Got: {}",
584 result2[0].message
585 );
586
587 let mixed_mkdir_cat = "```bash\n$ mkdir test\n$ cat file.txt\n```";
589 let ctx3 = LintContext::new(mixed_mkdir_cat, crate::config::MarkdownFlavor::Standard, None);
590 let result3 = rule.check(&ctx3).unwrap();
591 assert_eq!(result3.len(), 1, "Only output-producing commands should be flagged");
592 assert!(
593 result3[0].message.contains("cat file.txt"),
594 "Message should mention 'cat file.txt', not 'mkdir'. Got: {}",
595 result3[0].message
596 );
597
598 let mkdir_pip = "```bash\n$ mkdir test\n$ pip install something\n```";
600 let ctx3b = LintContext::new(mkdir_pip, crate::config::MarkdownFlavor::Standard, None);
601 let result3b = rule.check(&ctx3b).unwrap();
602 assert_eq!(result3b.len(), 1, "Block with pip install should be flagged");
603 assert!(
604 result3b[0].message.contains("pip install"),
605 "Message should mention 'pip install'. Got: {}",
606 result3b[0].message
607 );
608
609 let mixed_output_first = "```bash\n$ echo hello\n$ cd /home\n```";
611 let ctx4 = LintContext::new(mixed_output_first, crate::config::MarkdownFlavor::Standard, None);
612 let result4 = rule.check(&ctx4).unwrap();
613 assert_eq!(result4.len(), 1, "Only output-producing commands should be flagged");
614 assert!(
615 result4[0].message.contains("echo hello"),
616 "Message should mention 'echo hello'. Got: {}",
617 result4[0].message
618 );
619 }
620
621 #[test]
622 fn test_multiple_commands_without_output_all_flagged() {
623 let rule = MD014CommandsShowOutput::new();
624
625 let content = "```shell\n# First invocation\n$ my_command\n\n# Second invocation\n$ my_command\n```";
627 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
628 let result = rule.check(&ctx).unwrap();
629 assert_eq!(result.len(), 2, "Both commands should be flagged. Got: {result:?}");
630 assert!(result[0].message.contains("my_command"));
631 assert!(result[1].message.contains("my_command"));
632 assert_ne!(result[0].line, result[1].line, "Warnings should be on different lines");
634
635 let content2 = "```bash\n$ echo hello\n$ ls -la\n$ pwd\n```";
637 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
638 let result2 = rule.check(&ctx2).unwrap();
639 assert_eq!(
640 result2.len(),
641 3,
642 "All three commands should be flagged. Got: {result2:?}"
643 );
644 assert!(result2[0].message.contains("echo hello"));
645 assert!(result2[1].message.contains("ls -la"));
646 assert!(result2[2].message.contains("pwd"));
647
648 let content3 = "```bash\n$ echo hello\n$ cd /tmp\n$ ls -la\n```";
650 let ctx3 = LintContext::new(content3, crate::config::MarkdownFlavor::Standard, None);
651 let result3 = rule.check(&ctx3).unwrap();
652 assert_eq!(
653 result3.len(),
654 2,
655 "Only output-producing commands should be flagged. Got: {result3:?}"
656 );
657 assert!(result3[0].message.contains("echo hello"));
658 assert!(result3[1].message.contains("ls -la"));
659 }
660
661 #[test]
662 fn test_issue_516_exact_case() {
663 let rule = MD014CommandsShowOutput::new();
664
665 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";
667 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
668 let result = rule.check(&ctx).unwrap();
669 assert_eq!(
670 result.len(),
671 2,
672 "Both $ my_command lines should be flagged. Got: {result:?}"
673 );
674 assert_eq!(result[0].line, 9, "First warning should be on line 9");
675 assert_eq!(result[1].line, 12, "Second warning should be on line 12");
676 }
677
678 #[test]
679 fn test_default_config_section() {
680 let rule = MD014CommandsShowOutput::new();
681 let config_section = rule.default_config_section();
682 assert!(config_section.is_some());
683 let (name, _value) = config_section.unwrap();
684 assert_eq!(name, "MD014");
685 }
686}