1use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
7use crate::utils::range_utils::calculate_match_range;
8use crate::utils::regex_cache::get_cached_regex;
9use toml;
10
11mod md014_config;
12use md014_config::MD014Config;
13
14const COMMAND_PATTERN: &str = r"^\s*[$>]\s+\S+";
16const SHELL_LANG_PATTERN: &str = r"^(?i)(bash|sh|shell|console|terminal)";
17const DOLLAR_PROMPT_PATTERN: &str = r"^\s*([$>])";
18
19#[derive(Clone, Default)]
20pub struct MD014CommandsShowOutput {
21 config: MD014Config,
22}
23
24impl MD014CommandsShowOutput {
25 pub fn new() -> Self {
26 Self::default()
27 }
28
29 pub fn with_show_output(show_output: bool) -> Self {
30 Self {
31 config: MD014Config { show_output },
32 }
33 }
34
35 pub fn from_config_struct(config: MD014Config) -> Self {
36 Self { config }
37 }
38
39 fn is_command_line(&self, line: &str) -> bool {
40 get_cached_regex(COMMAND_PATTERN).is_ok_and(|re| re.is_match(line))
41 }
42
43 fn is_shell_language(&self, lang: &str) -> bool {
44 get_cached_regex(SHELL_LANG_PATTERN).is_ok_and(|re| re.is_match(lang))
45 }
46
47 fn is_output_line(&self, line: &str) -> bool {
48 let trimmed = line.trim();
49 !trimmed.is_empty() && !trimmed.starts_with('$') && !trimmed.starts_with('>') && !trimmed.starts_with('#')
50 }
51
52 fn is_no_output_command(&self, cmd: &str) -> bool {
53 let cmd = cmd.trim().to_lowercase();
54
55 cmd.starts_with("cd ")
61 || cmd == "cd"
62 || cmd.starts_with("mkdir ")
63 || cmd.starts_with("touch ")
64 || cmd.starts_with("rm ")
65 || cmd.starts_with("mv ")
66 || cmd.starts_with("cp ")
67 || cmd.starts_with("export ")
68 || cmd.starts_with("set ")
69 || cmd.starts_with("alias ")
70 || cmd.starts_with("unset ")
71 || cmd.starts_with("source ")
72 || cmd.starts_with(". ")
73 || cmd == "true"
74 || cmd == "false"
75 || cmd.starts_with("sleep ")
76 || cmd.starts_with("wait ")
77 || cmd.starts_with("pushd ")
78 || cmd.starts_with("popd")
79
80 || cmd.contains(" > ")
82 || cmd.contains(" >> ")
83
84 || cmd.starts_with("git add ")
86 || cmd.starts_with("git checkout ")
87 || cmd.starts_with("git stash")
88 || cmd.starts_with("git reset ")
89 }
90
91 fn is_command_without_output(&self, block: &[&str], lang: &str) -> bool {
92 if !self.config.show_output || !self.is_shell_language(lang) {
93 return false;
94 }
95
96 let has_output = block.iter().any(|line| self.is_output_line(line));
98 if has_output {
99 return false; }
101
102 self.get_first_output_command(block).is_some()
104 }
105
106 fn get_first_output_command(&self, block: &[&str]) -> Option<(usize, String)> {
109 for (i, line) in block.iter().enumerate() {
110 if self.is_command_line(line) {
111 let cmd = line.trim()[1..].trim().to_string();
112 if !self.is_no_output_command(&cmd) {
113 return Some((i, cmd));
114 }
115 }
116 }
117 None }
119
120 fn fix_command_block(&self, block: &[&str]) -> String {
121 block
122 .iter()
123 .map(|line| {
124 let trimmed = line.trim_start();
125 if self.is_command_line(line) {
126 let spaces = line.len() - line.trim_start().len();
127 let cmd = trimmed.chars().skip(1).collect::<String>().trim_start().to_string();
128 format!("{}{}", " ".repeat(spaces), cmd)
129 } else {
130 line.to_string()
131 }
132 })
133 .collect::<Vec<_>>()
134 .join("\n")
135 }
136
137 fn get_code_block_language(block_start: &str) -> String {
138 block_start
139 .trim_start()
140 .trim_start_matches("```")
141 .split_whitespace()
142 .next()
143 .unwrap_or("")
144 .to_string()
145 }
146
147 fn find_all_command_lines<'a>(&self, block: &[&'a str]) -> Vec<(usize, &'a str)> {
150 let mut results = Vec::new();
151 for (i, line) in block.iter().enumerate() {
152 if self.is_command_line(line) {
153 let cmd = line.trim()[1..].trim();
154 if !self.is_no_output_command(cmd) {
155 results.push((i, *line));
156 }
157 }
158 }
159 results
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 category(&self) -> RuleCategory {
173 RuleCategory::CodeBlock
174 }
175
176 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
177 let content = ctx.content;
178 let line_index = &ctx.line_index;
179
180 let mut warnings = Vec::new();
181
182 let mut current_block = Vec::new();
183
184 let mut in_code_block = false;
185
186 let mut block_start_line = 0;
187
188 let mut current_lang = String::new();
189
190 for (line_num, line) in content.lines().enumerate() {
191 if line.trim_start().starts_with("```") {
192 if in_code_block {
193 if self.is_command_without_output(¤t_block, ¤t_lang) {
195 let command_lines = self.find_all_command_lines(¤t_block);
197 let fix = Fix::new(
198 {
199 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
206 .get_line_start_byte(content_end_line + 2)
207 .unwrap_or(start_byte); start_byte..end_byte
209 },
210 format!("{}\n", self.fix_command_block(¤t_block)),
211 );
212
213 for (cmd_line_idx, cmd_line) in &command_lines {
214 let cmd_line_num = block_start_line + 1 + cmd_line_idx + 1; if let Ok(re) = get_cached_regex(DOLLAR_PROMPT_PATTERN)
218 && let Some(cap) = re.captures(cmd_line)
219 {
220 let match_obj = cap.get(1).unwrap(); let (start_line, start_col, end_line, end_col) =
222 calculate_match_range(cmd_line_num, cmd_line, match_obj.start(), match_obj.len());
223
224 let cmd_text = cmd_line.trim()[1..].trim().to_string();
226 let message = if cmd_text.is_empty() {
227 "Command should show output (add example output or remove $ prompt)".to_string()
228 } else {
229 format!(
230 "Command '{cmd_text}' should show output (add example output or remove $ prompt)"
231 )
232 };
233
234 warnings.push(LintWarning {
235 rule_name: Some(self.name().to_string()),
236 line: start_line,
237 column: start_col,
238 end_line,
239 end_column: end_col,
240 message,
241 severity: Severity::Warning,
242 fix: Some(fix.clone()),
243 });
244 }
245 }
246 }
247 current_block.clear();
248 } else {
249 block_start_line = line_num;
251 current_lang = Self::get_code_block_language(line);
252 }
253 in_code_block = !in_code_block;
254 } else if in_code_block {
255 current_block.push(line);
256 }
257 }
258
259 Ok(warnings)
260 }
261
262 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
263 if self.should_skip(ctx) {
264 return Ok(ctx.content.to_string());
265 }
266 let warnings = self.check(ctx)?;
267 if warnings.is_empty() {
268 return Ok(ctx.content.to_string());
269 }
270 let warnings =
271 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
272 crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings)
273 .map_err(crate::rule::LintError::InvalidInput)
274 }
275
276 fn as_any(&self) -> &dyn std::any::Any {
277 self
278 }
279
280 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
281 ctx.content.is_empty() || !ctx.likely_has_code()
283 }
284
285 crate::impl_rule_config_methods!(MD014Config);
286}
287
288#[cfg(test)]
289mod tests {
290 use super::*;
291 use crate::lint_context::LintContext;
292
293 #[test]
294 fn test_is_command_line() {
295 let rule = MD014CommandsShowOutput::new();
296 assert!(rule.is_command_line("$ echo test"));
297 assert!(rule.is_command_line(" $ ls -la"));
298 assert!(rule.is_command_line("> pwd"));
299 assert!(rule.is_command_line(" > cd /home"));
300 assert!(!rule.is_command_line("echo test"));
301 assert!(!rule.is_command_line("# comment"));
302 assert!(!rule.is_command_line("output line"));
303 }
304
305 #[test]
306 fn test_is_shell_language() {
307 let rule = MD014CommandsShowOutput::new();
308 assert!(rule.is_shell_language("bash"));
309 assert!(rule.is_shell_language("BASH"));
310 assert!(rule.is_shell_language("sh"));
311 assert!(rule.is_shell_language("shell"));
312 assert!(rule.is_shell_language("Shell"));
313 assert!(rule.is_shell_language("console"));
314 assert!(rule.is_shell_language("CONSOLE"));
315 assert!(rule.is_shell_language("terminal"));
316 assert!(rule.is_shell_language("Terminal"));
317 assert!(!rule.is_shell_language("python"));
318 assert!(!rule.is_shell_language("javascript"));
319 assert!(!rule.is_shell_language(""));
320 }
321
322 #[test]
323 fn test_is_output_line() {
324 let rule = MD014CommandsShowOutput::new();
325 assert!(rule.is_output_line("output text"));
326 assert!(rule.is_output_line(" some output"));
327 assert!(rule.is_output_line("file1 file2"));
328 assert!(!rule.is_output_line(""));
329 assert!(!rule.is_output_line(" "));
330 assert!(!rule.is_output_line("$ command"));
331 assert!(!rule.is_output_line("> prompt"));
332 assert!(!rule.is_output_line("# comment"));
333 }
334
335 #[test]
336 fn test_is_no_output_command() {
337 let rule = MD014CommandsShowOutput::new();
338
339 assert!(rule.is_no_output_command("cd /home"));
341 assert!(rule.is_no_output_command("cd"));
342 assert!(rule.is_no_output_command("mkdir test"));
343 assert!(rule.is_no_output_command("touch file.txt"));
344 assert!(rule.is_no_output_command("rm -rf dir"));
345 assert!(rule.is_no_output_command("mv old new"));
346 assert!(rule.is_no_output_command("cp src dst"));
347 assert!(rule.is_no_output_command("export VAR=value"));
348 assert!(rule.is_no_output_command("set -e"));
349 assert!(rule.is_no_output_command("source ~/.bashrc"));
350 assert!(rule.is_no_output_command(". ~/.profile"));
351 assert!(rule.is_no_output_command("alias ll='ls -la'"));
352 assert!(rule.is_no_output_command("unset VAR"));
353 assert!(rule.is_no_output_command("true"));
354 assert!(rule.is_no_output_command("false"));
355 assert!(rule.is_no_output_command("sleep 5"));
356 assert!(rule.is_no_output_command("pushd /tmp"));
357 assert!(rule.is_no_output_command("popd"));
358
359 assert!(rule.is_no_output_command("CD /HOME"));
361 assert!(rule.is_no_output_command("MKDIR TEST"));
362
363 assert!(rule.is_no_output_command("echo 'test' > file.txt"));
365 assert!(rule.is_no_output_command("cat input.txt > output.txt"));
366 assert!(rule.is_no_output_command("echo 'append' >> log.txt"));
367
368 assert!(rule.is_no_output_command("git add ."));
370 assert!(rule.is_no_output_command("git checkout main"));
371 assert!(rule.is_no_output_command("git stash"));
372 assert!(rule.is_no_output_command("git reset HEAD~1"));
373
374 assert!(!rule.is_no_output_command("ls -la"));
376 assert!(!rule.is_no_output_command("echo test")); assert!(!rule.is_no_output_command("pwd"));
378 assert!(!rule.is_no_output_command("cat file.txt")); assert!(!rule.is_no_output_command("grep pattern file"));
380
381 assert!(!rule.is_no_output_command("pip install requests"));
383 assert!(!rule.is_no_output_command("npm install express"));
384 assert!(!rule.is_no_output_command("cargo install ripgrep"));
385 assert!(!rule.is_no_output_command("brew install git"));
386
387 assert!(!rule.is_no_output_command("cargo build"));
389 assert!(!rule.is_no_output_command("npm run build"));
390 assert!(!rule.is_no_output_command("make"));
391
392 assert!(!rule.is_no_output_command("docker ps"));
394 assert!(!rule.is_no_output_command("docker compose up"));
395 assert!(!rule.is_no_output_command("docker run myimage"));
396
397 assert!(!rule.is_no_output_command("git status"));
399 assert!(!rule.is_no_output_command("git log"));
400 assert!(!rule.is_no_output_command("git diff"));
401 }
402
403 #[test]
404 fn test_fix_command_block() {
405 let rule = MD014CommandsShowOutput::new();
406 let block = vec!["$ echo test", "$ ls -la"];
407 assert_eq!(rule.fix_command_block(&block), "echo test\nls -la");
408
409 let indented = vec![" $ echo test", " $ pwd"];
410 assert_eq!(rule.fix_command_block(&indented), " echo test\n pwd");
411
412 let mixed = vec!["> cd /home", "$ mkdir test"];
413 assert_eq!(rule.fix_command_block(&mixed), "cd /home\nmkdir test");
414 }
415
416 #[test]
417 fn test_get_code_block_language() {
418 assert_eq!(MD014CommandsShowOutput::get_code_block_language("```bash"), "bash");
419 assert_eq!(MD014CommandsShowOutput::get_code_block_language("```shell"), "shell");
420 assert_eq!(
421 MD014CommandsShowOutput::get_code_block_language(" ```console"),
422 "console"
423 );
424 assert_eq!(
425 MD014CommandsShowOutput::get_code_block_language("```bash {.line-numbers}"),
426 "bash"
427 );
428 assert_eq!(MD014CommandsShowOutput::get_code_block_language("```"), "");
429 }
430
431 #[test]
432 fn test_find_all_command_lines() {
433 let rule = MD014CommandsShowOutput::new();
434 let block = vec!["# comment", "$ echo test", "output"];
435 let result = rule.find_all_command_lines(&block);
436 assert_eq!(result, vec![(1, "$ echo test")]);
437
438 let no_commands = vec!["output1", "output2"];
439 assert!(rule.find_all_command_lines(&no_commands).is_empty());
440
441 let multiple = vec!["$ echo one", "$ echo two", "$ cd /tmp"];
442 let result = rule.find_all_command_lines(&multiple);
443 assert_eq!(result, vec![(0, "$ echo one"), (1, "$ echo two")]);
445 }
446
447 #[test]
448 fn test_is_command_without_output() {
449 let rule = MD014CommandsShowOutput::with_show_output(true);
450
451 let block1 = vec!["$ echo test"];
453 assert!(rule.is_command_without_output(&block1, "bash"));
454
455 let block2 = vec!["$ echo test", "test"];
457 assert!(!rule.is_command_without_output(&block2, "bash"));
458
459 let block3 = vec!["$ cd /home"];
461 assert!(!rule.is_command_without_output(&block3, "bash"));
462
463 let rule_disabled = MD014CommandsShowOutput::with_show_output(false);
465 assert!(!rule_disabled.is_command_without_output(&block1, "bash"));
466
467 assert!(!rule.is_command_without_output(&block1, "python"));
469 }
470
471 #[test]
472 fn test_edge_cases() {
473 let rule = MD014CommandsShowOutput::new();
474 let content = "```bash\n$ \n```";
476 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
477 let result = rule.check(&ctx).unwrap();
478 assert!(
479 result.is_empty(),
480 "Bare $ with only space doesn't match command pattern"
481 );
482
483 let empty_content = "```bash\n```";
485 let ctx2 = LintContext::new(empty_content, crate::config::MarkdownFlavor::Standard, None);
486 let result2 = rule.check(&ctx2).unwrap();
487 assert!(result2.is_empty(), "Empty code block should not be flagged");
488
489 let minimal = "```bash\n$ a\n```";
491 let ctx3 = LintContext::new(minimal, crate::config::MarkdownFlavor::Standard, None);
492 let result3 = rule.check(&ctx3).unwrap();
493 assert_eq!(result3.len(), 1, "Minimal command should be flagged");
494 }
495
496 #[test]
497 fn test_mixed_silent_and_output_commands() {
498 let rule = MD014CommandsShowOutput::new();
499
500 let silent_only = "```bash\n$ cd /home\n$ mkdir test\n```";
502 let ctx1 = LintContext::new(silent_only, crate::config::MarkdownFlavor::Standard, None);
503 let result1 = rule.check(&ctx1).unwrap();
504 assert!(
505 result1.is_empty(),
506 "Block with only silent commands should not be flagged"
507 );
508
509 let mixed_silent_first = "```bash\n$ cd /home\n$ ls -la\n```";
512 let ctx2 = LintContext::new(mixed_silent_first, crate::config::MarkdownFlavor::Standard, None);
513 let result2 = rule.check(&ctx2).unwrap();
514 assert_eq!(result2.len(), 1, "Only output-producing commands should be flagged");
515 assert!(
516 result2[0].message.contains("ls -la"),
517 "Message should mention 'ls -la', not 'cd /home'. Got: {}",
518 result2[0].message
519 );
520
521 let mixed_mkdir_cat = "```bash\n$ mkdir test\n$ cat file.txt\n```";
523 let ctx3 = LintContext::new(mixed_mkdir_cat, crate::config::MarkdownFlavor::Standard, None);
524 let result3 = rule.check(&ctx3).unwrap();
525 assert_eq!(result3.len(), 1, "Only output-producing commands should be flagged");
526 assert!(
527 result3[0].message.contains("cat file.txt"),
528 "Message should mention 'cat file.txt', not 'mkdir'. Got: {}",
529 result3[0].message
530 );
531
532 let mkdir_pip = "```bash\n$ mkdir test\n$ pip install something\n```";
534 let ctx3b = LintContext::new(mkdir_pip, crate::config::MarkdownFlavor::Standard, None);
535 let result3b = rule.check(&ctx3b).unwrap();
536 assert_eq!(result3b.len(), 1, "Block with pip install should be flagged");
537 assert!(
538 result3b[0].message.contains("pip install"),
539 "Message should mention 'pip install'. Got: {}",
540 result3b[0].message
541 );
542
543 let mixed_output_first = "```bash\n$ echo hello\n$ cd /home\n```";
545 let ctx4 = LintContext::new(mixed_output_first, crate::config::MarkdownFlavor::Standard, None);
546 let result4 = rule.check(&ctx4).unwrap();
547 assert_eq!(result4.len(), 1, "Only output-producing commands should be flagged");
548 assert!(
549 result4[0].message.contains("echo hello"),
550 "Message should mention 'echo hello'. Got: {}",
551 result4[0].message
552 );
553 }
554
555 #[test]
556 fn test_multiple_commands_without_output_all_flagged() {
557 let rule = MD014CommandsShowOutput::new();
558
559 let content = "```shell\n# First invocation\n$ my_command\n\n# Second invocation\n$ my_command\n```";
561 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
562 let result = rule.check(&ctx).unwrap();
563 assert_eq!(result.len(), 2, "Both commands should be flagged. Got: {result:?}");
564 assert!(result[0].message.contains("my_command"));
565 assert!(result[1].message.contains("my_command"));
566 assert_ne!(result[0].line, result[1].line, "Warnings should be on different lines");
568
569 let content2 = "```bash\n$ echo hello\n$ ls -la\n$ pwd\n```";
571 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
572 let result2 = rule.check(&ctx2).unwrap();
573 assert_eq!(
574 result2.len(),
575 3,
576 "All three commands should be flagged. Got: {result2:?}"
577 );
578 assert!(result2[0].message.contains("echo hello"));
579 assert!(result2[1].message.contains("ls -la"));
580 assert!(result2[2].message.contains("pwd"));
581
582 let content3 = "```bash\n$ echo hello\n$ cd /tmp\n$ ls -la\n```";
584 let ctx3 = LintContext::new(content3, crate::config::MarkdownFlavor::Standard, None);
585 let result3 = rule.check(&ctx3).unwrap();
586 assert_eq!(
587 result3.len(),
588 2,
589 "Only output-producing commands should be flagged. Got: {result3:?}"
590 );
591 assert!(result3[0].message.contains("echo hello"));
592 assert!(result3[1].message.contains("ls -la"));
593 }
594
595 #[test]
596 fn test_issue_516_exact_case() {
597 let rule = MD014CommandsShowOutput::new();
598
599 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";
601 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
602 let result = rule.check(&ctx).unwrap();
603 assert_eq!(
604 result.len(),
605 2,
606 "Both $ my_command lines should be flagged. Got: {result:?}"
607 );
608 assert_eq!(result[0].line, 9, "First warning should be on line 9");
609 assert_eq!(result[1].line, 12, "Second warning should be on line 12");
610 }
611
612 #[test]
613 fn test_default_config_section() {
614 let rule = MD014CommandsShowOutput::new();
615 let config_section = rule.default_config_section();
616 assert!(config_section.is_some());
617 let (name, _value) = config_section.unwrap();
618 assert_eq!(name, "MD014");
619 }
620}