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 get_command_from_block(&self, block: &[&str]) -> String {
126 if let Some((_, cmd)) = self.get_first_output_command(block) {
128 return cmd;
129 }
130 for line in block {
132 let trimmed = line.trim();
133 if self.is_command_line(line) {
134 return trimmed[1..].trim().to_string();
135 }
136 }
137 String::new()
138 }
139
140 fn fix_command_block(&self, block: &[&str]) -> String {
141 block
142 .iter()
143 .map(|line| {
144 let trimmed = line.trim_start();
145 if self.is_command_line(line) {
146 let spaces = line.len() - line.trim_start().len();
147 let cmd = trimmed.chars().skip(1).collect::<String>().trim_start().to_string();
148 format!("{}{}", " ".repeat(spaces), cmd)
149 } else {
150 line.to_string()
151 }
152 })
153 .collect::<Vec<_>>()
154 .join("\n")
155 }
156
157 fn get_code_block_language(block_start: &str) -> String {
158 block_start
159 .trim_start()
160 .trim_start_matches("```")
161 .split_whitespace()
162 .next()
163 .unwrap_or("")
164 .to_string()
165 }
166
167 fn find_first_command_line<'a>(&self, block: &[&'a str]) -> Option<(usize, &'a str)> {
170 for (i, line) in block.iter().enumerate() {
171 if self.is_command_line(line) {
172 let cmd = line.trim()[1..].trim();
173 if !self.is_no_output_command(cmd) {
174 return Some((i, line));
175 }
176 }
177 }
178 None
179 }
180}
181
182impl Rule for MD014CommandsShowOutput {
183 fn name(&self) -> &'static str {
184 "MD014"
185 }
186
187 fn description(&self) -> &'static str {
188 "Commands in code blocks should show output"
189 }
190
191 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
192 let content = ctx.content;
193 let _line_index = &ctx.line_index;
194
195 let mut warnings = Vec::new();
196
197 let mut current_block = Vec::new();
198
199 let mut in_code_block = false;
200
201 let mut block_start_line = 0;
202
203 let mut current_lang = String::new();
204
205 for (line_num, line) in content.lines().enumerate() {
206 if line.trim_start().starts_with("```") {
207 if in_code_block {
208 if self.is_command_without_output(¤t_block, ¤t_lang) {
210 if let Some((cmd_line_idx, cmd_line)) = self.find_first_command_line(¤t_block) {
212 let cmd_line_num = block_start_line + 1 + cmd_line_idx + 1; if let Ok(re) = get_cached_regex(DOLLAR_PROMPT_PATTERN)
216 && let Some(cap) = re.captures(cmd_line)
217 {
218 let match_obj = cap.get(1).unwrap(); let (start_line, start_col, end_line, end_col) =
220 calculate_match_range(cmd_line_num, cmd_line, match_obj.start(), match_obj.len());
221
222 let command = self.get_command_from_block(¤t_block);
224 let message = if command.is_empty() {
225 "Command should show output (add example output or remove $ prompt)".to_string()
226 } else {
227 format!(
228 "Command '{command}' should show output (add example output or remove $ prompt)"
229 )
230 };
231
232 warnings.push(LintWarning {
233 rule_name: Some(self.name().to_string()),
234 line: start_line,
235 column: start_col,
236 end_line,
237 end_column: end_col,
238 message,
239 severity: Severity::Warning,
240 fix: Some(Fix {
241 range: {
242 let content_start_line = block_start_line + 1; let content_end_line = line_num - 1; let start_byte =
248 _line_index.get_line_start_byte(content_start_line + 1).unwrap_or(0); let end_byte = _line_index
250 .get_line_start_byte(content_end_line + 2)
251 .unwrap_or(start_byte); start_byte..end_byte
253 },
254 replacement: format!("{}\n", self.fix_command_block(¤t_block)),
255 }),
256 });
257 }
258 }
259 }
260 current_block.clear();
261 } else {
262 block_start_line = line_num;
264 current_lang = Self::get_code_block_language(line);
265 }
266 in_code_block = !in_code_block;
267 } else if in_code_block {
268 current_block.push(line);
269 }
270 }
271
272 Ok(warnings)
273 }
274
275 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
276 let content = ctx.content;
277 let _line_index = &ctx.line_index;
278
279 let mut result = String::new();
280
281 let mut current_block = Vec::new();
282
283 let mut in_code_block = false;
284
285 let mut current_lang = String::new();
286
287 let mut block_start_line_num = 0usize;
288
289 for (line_num_0, line) in content.lines().enumerate() {
290 let line_num = line_num_0 + 1;
291 if line.trim_start().starts_with("```") {
292 if in_code_block {
293 let block_disabled = (0..current_block.len()).any(|j| {
296 let block_line_num = block_start_line_num + 1 + j;
297 ctx.inline_config().is_rule_disabled(self.name(), block_line_num)
298 });
299 if !block_disabled && self.is_command_without_output(¤t_block, ¤t_lang) {
300 result.push_str(&self.fix_command_block(¤t_block));
301 result.push('\n');
302 } else {
303 for block_line in ¤t_block {
304 result.push_str(block_line);
305 result.push('\n');
306 }
307 }
308 current_block.clear();
309 } else {
310 current_lang = Self::get_code_block_language(line);
311 block_start_line_num = line_num;
312 }
313 in_code_block = !in_code_block;
314 result.push_str(line);
315 result.push('\n');
316 } else if in_code_block {
317 current_block.push(line);
318 } else {
319 result.push_str(line);
320 result.push('\n');
321 }
322 }
323
324 if !content.ends_with('\n') && result.ends_with('\n') {
326 result.pop();
327 }
328
329 Ok(result)
330 }
331
332 fn as_any(&self) -> &dyn std::any::Any {
333 self
334 }
335
336 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
337 ctx.content.is_empty() || !ctx.likely_has_code()
339 }
340
341 fn default_config_section(&self) -> Option<(String, toml::Value)> {
342 let default_config = MD014Config::default();
343 let json_value = serde_json::to_value(&default_config).ok()?;
344 let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
345
346 if let toml::Value::Table(table) = toml_value {
347 if !table.is_empty() {
348 Some((MD014Config::RULE_NAME.to_string(), toml::Value::Table(table)))
349 } else {
350 None
351 }
352 } else {
353 None
354 }
355 }
356
357 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
358 where
359 Self: Sized,
360 {
361 let rule_config = crate::rule_config_serde::load_rule_config::<MD014Config>(config);
362 Box::new(Self::from_config_struct(rule_config))
363 }
364}
365
366#[cfg(test)]
367mod tests {
368 use super::*;
369 use crate::lint_context::LintContext;
370
371 #[test]
372 fn test_is_command_line() {
373 let rule = MD014CommandsShowOutput::new();
374 assert!(rule.is_command_line("$ echo test"));
375 assert!(rule.is_command_line(" $ ls -la"));
376 assert!(rule.is_command_line("> pwd"));
377 assert!(rule.is_command_line(" > cd /home"));
378 assert!(!rule.is_command_line("echo test"));
379 assert!(!rule.is_command_line("# comment"));
380 assert!(!rule.is_command_line("output line"));
381 }
382
383 #[test]
384 fn test_is_shell_language() {
385 let rule = MD014CommandsShowOutput::new();
386 assert!(rule.is_shell_language("bash"));
387 assert!(rule.is_shell_language("BASH"));
388 assert!(rule.is_shell_language("sh"));
389 assert!(rule.is_shell_language("shell"));
390 assert!(rule.is_shell_language("Shell"));
391 assert!(rule.is_shell_language("console"));
392 assert!(rule.is_shell_language("CONSOLE"));
393 assert!(rule.is_shell_language("terminal"));
394 assert!(rule.is_shell_language("Terminal"));
395 assert!(!rule.is_shell_language("python"));
396 assert!(!rule.is_shell_language("javascript"));
397 assert!(!rule.is_shell_language(""));
398 }
399
400 #[test]
401 fn test_is_output_line() {
402 let rule = MD014CommandsShowOutput::new();
403 assert!(rule.is_output_line("output text"));
404 assert!(rule.is_output_line(" some output"));
405 assert!(rule.is_output_line("file1 file2"));
406 assert!(!rule.is_output_line(""));
407 assert!(!rule.is_output_line(" "));
408 assert!(!rule.is_output_line("$ command"));
409 assert!(!rule.is_output_line("> prompt"));
410 assert!(!rule.is_output_line("# comment"));
411 }
412
413 #[test]
414 fn test_is_no_output_command() {
415 let rule = MD014CommandsShowOutput::new();
416
417 assert!(rule.is_no_output_command("cd /home"));
419 assert!(rule.is_no_output_command("cd"));
420 assert!(rule.is_no_output_command("mkdir test"));
421 assert!(rule.is_no_output_command("touch file.txt"));
422 assert!(rule.is_no_output_command("rm -rf dir"));
423 assert!(rule.is_no_output_command("mv old new"));
424 assert!(rule.is_no_output_command("cp src dst"));
425 assert!(rule.is_no_output_command("export VAR=value"));
426 assert!(rule.is_no_output_command("set -e"));
427 assert!(rule.is_no_output_command("source ~/.bashrc"));
428 assert!(rule.is_no_output_command(". ~/.profile"));
429 assert!(rule.is_no_output_command("alias ll='ls -la'"));
430 assert!(rule.is_no_output_command("unset VAR"));
431 assert!(rule.is_no_output_command("true"));
432 assert!(rule.is_no_output_command("false"));
433 assert!(rule.is_no_output_command("sleep 5"));
434 assert!(rule.is_no_output_command("pushd /tmp"));
435 assert!(rule.is_no_output_command("popd"));
436
437 assert!(rule.is_no_output_command("CD /HOME"));
439 assert!(rule.is_no_output_command("MKDIR TEST"));
440
441 assert!(rule.is_no_output_command("echo 'test' > file.txt"));
443 assert!(rule.is_no_output_command("cat input.txt > output.txt"));
444 assert!(rule.is_no_output_command("echo 'append' >> log.txt"));
445
446 assert!(rule.is_no_output_command("git add ."));
448 assert!(rule.is_no_output_command("git checkout main"));
449 assert!(rule.is_no_output_command("git stash"));
450 assert!(rule.is_no_output_command("git reset HEAD~1"));
451
452 assert!(!rule.is_no_output_command("ls -la"));
454 assert!(!rule.is_no_output_command("echo test")); assert!(!rule.is_no_output_command("pwd"));
456 assert!(!rule.is_no_output_command("cat file.txt")); assert!(!rule.is_no_output_command("grep pattern file"));
458
459 assert!(!rule.is_no_output_command("pip install requests"));
461 assert!(!rule.is_no_output_command("npm install express"));
462 assert!(!rule.is_no_output_command("cargo install ripgrep"));
463 assert!(!rule.is_no_output_command("brew install git"));
464
465 assert!(!rule.is_no_output_command("cargo build"));
467 assert!(!rule.is_no_output_command("npm run build"));
468 assert!(!rule.is_no_output_command("make"));
469
470 assert!(!rule.is_no_output_command("docker ps"));
472 assert!(!rule.is_no_output_command("docker compose up"));
473 assert!(!rule.is_no_output_command("docker run myimage"));
474
475 assert!(!rule.is_no_output_command("git status"));
477 assert!(!rule.is_no_output_command("git log"));
478 assert!(!rule.is_no_output_command("git diff"));
479 }
480
481 #[test]
482 fn test_get_command_from_block() {
483 let rule = MD014CommandsShowOutput::new();
484 let block = vec!["$ echo test", "output"];
485 assert_eq!(rule.get_command_from_block(&block), "echo test");
486
487 let block2 = vec![" $ ls -la", "file1 file2"];
488 assert_eq!(rule.get_command_from_block(&block2), "ls -la");
489
490 let block3 = vec!["> pwd", "/home"];
491 assert_eq!(rule.get_command_from_block(&block3), "pwd");
492
493 let empty_block: Vec<&str> = vec![];
494 assert_eq!(rule.get_command_from_block(&empty_block), "");
495 }
496
497 #[test]
498 fn test_fix_command_block() {
499 let rule = MD014CommandsShowOutput::new();
500 let block = vec!["$ echo test", "$ ls -la"];
501 assert_eq!(rule.fix_command_block(&block), "echo test\nls -la");
502
503 let indented = vec![" $ echo test", " $ pwd"];
504 assert_eq!(rule.fix_command_block(&indented), " echo test\n pwd");
505
506 let mixed = vec!["> cd /home", "$ mkdir test"];
507 assert_eq!(rule.fix_command_block(&mixed), "cd /home\nmkdir test");
508 }
509
510 #[test]
511 fn test_get_code_block_language() {
512 assert_eq!(MD014CommandsShowOutput::get_code_block_language("```bash"), "bash");
513 assert_eq!(MD014CommandsShowOutput::get_code_block_language("```shell"), "shell");
514 assert_eq!(
515 MD014CommandsShowOutput::get_code_block_language(" ```console"),
516 "console"
517 );
518 assert_eq!(
519 MD014CommandsShowOutput::get_code_block_language("```bash {.line-numbers}"),
520 "bash"
521 );
522 assert_eq!(MD014CommandsShowOutput::get_code_block_language("```"), "");
523 }
524
525 #[test]
526 fn test_find_first_command_line() {
527 let rule = MD014CommandsShowOutput::new();
528 let block = vec!["# comment", "$ echo test", "output"];
529 let result = rule.find_first_command_line(&block);
530 assert_eq!(result, Some((1, "$ echo test")));
531
532 let no_commands = vec!["output1", "output2"];
533 assert_eq!(rule.find_first_command_line(&no_commands), None);
534 }
535
536 #[test]
537 fn test_is_command_without_output() {
538 let rule = MD014CommandsShowOutput::with_show_output(true);
539
540 let block1 = vec!["$ echo test"];
542 assert!(rule.is_command_without_output(&block1, "bash"));
543
544 let block2 = vec!["$ echo test", "test"];
546 assert!(!rule.is_command_without_output(&block2, "bash"));
547
548 let block3 = vec!["$ cd /home"];
550 assert!(!rule.is_command_without_output(&block3, "bash"));
551
552 let rule_disabled = MD014CommandsShowOutput::with_show_output(false);
554 assert!(!rule_disabled.is_command_without_output(&block1, "bash"));
555
556 assert!(!rule.is_command_without_output(&block1, "python"));
558 }
559
560 #[test]
561 fn test_edge_cases() {
562 let rule = MD014CommandsShowOutput::new();
563 let content = "```bash\n$ \n```";
565 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
566 let result = rule.check(&ctx).unwrap();
567 assert!(
568 result.is_empty(),
569 "Bare $ with only space doesn't match command pattern"
570 );
571
572 let empty_content = "```bash\n```";
574 let ctx2 = LintContext::new(empty_content, crate::config::MarkdownFlavor::Standard, None);
575 let result2 = rule.check(&ctx2).unwrap();
576 assert!(result2.is_empty(), "Empty code block should not be flagged");
577
578 let minimal = "```bash\n$ a\n```";
580 let ctx3 = LintContext::new(minimal, crate::config::MarkdownFlavor::Standard, None);
581 let result3 = rule.check(&ctx3).unwrap();
582 assert_eq!(result3.len(), 1, "Minimal command should be flagged");
583 }
584
585 #[test]
586 fn test_mixed_silent_and_output_commands() {
587 let rule = MD014CommandsShowOutput::new();
588
589 let silent_only = "```bash\n$ cd /home\n$ mkdir test\n```";
591 let ctx1 = LintContext::new(silent_only, crate::config::MarkdownFlavor::Standard, None);
592 let result1 = rule.check(&ctx1).unwrap();
593 assert!(
594 result1.is_empty(),
595 "Block with only silent commands should not be flagged"
596 );
597
598 let mixed_silent_first = "```bash\n$ cd /home\n$ ls -la\n```";
601 let ctx2 = LintContext::new(mixed_silent_first, crate::config::MarkdownFlavor::Standard, None);
602 let result2 = rule.check(&ctx2).unwrap();
603 assert_eq!(result2.len(), 1, "Mixed block should be flagged once");
604 assert!(
605 result2[0].message.contains("ls -la"),
606 "Message should mention 'ls -la', not 'cd /home'. Got: {}",
607 result2[0].message
608 );
609
610 let mixed_mkdir_cat = "```bash\n$ mkdir test\n$ cat file.txt\n```";
612 let ctx3 = LintContext::new(mixed_mkdir_cat, crate::config::MarkdownFlavor::Standard, None);
613 let result3 = rule.check(&ctx3).unwrap();
614 assert_eq!(result3.len(), 1, "Mixed block should be flagged once");
615 assert!(
616 result3[0].message.contains("cat file.txt"),
617 "Message should mention 'cat file.txt', not 'mkdir'. Got: {}",
618 result3[0].message
619 );
620
621 let mkdir_pip = "```bash\n$ mkdir test\n$ pip install something\n```";
624 let ctx3b = LintContext::new(mkdir_pip, crate::config::MarkdownFlavor::Standard, None);
625 let result3b = rule.check(&ctx3b).unwrap();
626 assert_eq!(result3b.len(), 1, "Block with pip install should be flagged");
627 assert!(
628 result3b[0].message.contains("pip install"),
629 "Message should mention 'pip install'. Got: {}",
630 result3b[0].message
631 );
632
633 let mixed_output_first = "```bash\n$ echo hello\n$ cd /home\n```";
636 let ctx4 = LintContext::new(mixed_output_first, crate::config::MarkdownFlavor::Standard, None);
637 let result4 = rule.check(&ctx4).unwrap();
638 assert_eq!(result4.len(), 1, "Mixed block should be flagged once");
639 assert!(
640 result4[0].message.contains("echo hello"),
641 "Message should mention 'echo hello'. Got: {}",
642 result4[0].message
643 );
644 }
645
646 #[test]
647 fn test_default_config_section() {
648 let rule = MD014CommandsShowOutput::new();
649 let config_section = rule.default_config_section();
650 assert!(config_section.is_some());
651 let (name, _value) = config_section.unwrap();
652 assert_eq!(name, "MD014");
653 }
654}