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