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