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