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.contains("cd ")
62 || cmd.contains("mkdir ")
63 || cmd.contains("touch ")
64 || cmd.contains("rm ")
65 || cmd.contains("mv ")
66 || cmd.contains("cp ")
67 || cmd.contains("export ")
68 || cmd.contains("set ")
69 || cmd.contains("alias ")
70 || cmd.contains("unset ")
71 || cmd.contains("source ")
72 || cmd.contains(". ")
73
74 || cmd.contains("git add ")
85 || cmd.contains("git commit ")
86 || cmd.contains("git push ")
87 || cmd.contains("git checkout ")
88 || cmd.contains("git branch -d")
89 || cmd.contains("git stash")
90
91 || cmd == "make clean"
93 || cmd.contains("make install")
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 mut has_command = false;
102 let mut has_output = false;
103 let mut last_command = String::new();
104
105 for line in block {
106 let trimmed = line.trim();
107 if self.is_command_line(line) {
108 has_command = true;
109 last_command = trimmed[1..].trim().to_string();
110 } else if self.is_output_line(line) {
111 has_output = true;
112 }
113 }
114
115 has_command && !has_output && !self.is_no_output_command(&last_command)
116 }
117
118 fn get_command_from_block(&self, block: &[&str]) -> String {
119 for line in block {
120 let trimmed = line.trim();
121 if self.is_command_line(line) {
122 return trimmed[1..].trim().to_string();
123 }
124 }
125 String::new()
126 }
127
128 fn fix_command_block(&self, block: &[&str]) -> String {
129 block
130 .iter()
131 .map(|line| {
132 let trimmed = line.trim_start();
133 if self.is_command_line(line) {
134 let spaces = line.len() - line.trim_start().len();
135 let cmd = trimmed.chars().skip(1).collect::<String>().trim_start().to_string();
136 format!("{}{}", " ".repeat(spaces), cmd)
137 } else {
138 line.to_string()
139 }
140 })
141 .collect::<Vec<_>>()
142 .join("\n")
143 }
144
145 fn get_code_block_language(block_start: &str) -> String {
146 block_start
147 .trim_start()
148 .trim_start_matches("```")
149 .split_whitespace()
150 .next()
151 .unwrap_or("")
152 .to_string()
153 }
154
155 fn find_first_command_line<'a>(&self, block: &[&'a str]) -> Option<(usize, &'a str)> {
156 for (i, line) in block.iter().enumerate() {
157 if self.is_command_line(line) {
158 return Some((i, line));
159 }
160 }
161 None
162 }
163}
164
165impl Rule for MD014CommandsShowOutput {
166 fn name(&self) -> &'static str {
167 "MD014"
168 }
169
170 fn description(&self) -> &'static str {
171 "Commands in code blocks should show output"
172 }
173
174 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
175 let content = ctx.content;
176 let _line_index = &ctx.line_index;
177
178 let mut warnings = Vec::new();
179
180 let mut current_block = Vec::new();
181
182 let mut in_code_block = false;
183
184 let mut block_start_line = 0;
185
186 let mut current_lang = String::new();
187
188 for (line_num, line) in content.lines().enumerate() {
189 if line.trim_start().starts_with("```") {
190 if in_code_block {
191 if self.is_command_without_output(¤t_block, ¤t_lang) {
193 if let Some((cmd_line_idx, cmd_line)) = self.find_first_command_line(¤t_block) {
195 let cmd_line_num = block_start_line + 1 + cmd_line_idx + 1; if let Ok(re) = get_cached_regex(DOLLAR_PROMPT_PATTERN)
199 && let Some(cap) = re.captures(cmd_line)
200 {
201 let match_obj = cap.get(1).unwrap(); let (start_line, start_col, end_line, end_col) =
203 calculate_match_range(cmd_line_num, cmd_line, match_obj.start(), match_obj.len());
204
205 let command = self.get_command_from_block(¤t_block);
207 let message = if command.is_empty() {
208 "Command should show output (add example output or remove $ prompt)".to_string()
209 } else {
210 format!(
211 "Command '{command}' should show output (add example output or remove $ prompt)"
212 )
213 };
214
215 warnings.push(LintWarning {
216 rule_name: Some(self.name().to_string()),
217 line: start_line,
218 column: start_col,
219 end_line,
220 end_column: end_col,
221 message,
222 severity: Severity::Warning,
223 fix: Some(Fix {
224 range: {
225 let content_start_line = block_start_line + 1; let content_end_line = line_num - 1; let start_byte =
231 _line_index.get_line_start_byte(content_start_line + 1).unwrap_or(0); let end_byte = _line_index
233 .get_line_start_byte(content_end_line + 2)
234 .unwrap_or(start_byte); start_byte..end_byte
236 },
237 replacement: format!("{}\n", self.fix_command_block(¤t_block)),
238 }),
239 });
240 }
241 }
242 }
243 current_block.clear();
244 } else {
245 block_start_line = line_num;
247 current_lang = Self::get_code_block_language(line);
248 }
249 in_code_block = !in_code_block;
250 } else if in_code_block {
251 current_block.push(line);
252 }
253 }
254
255 Ok(warnings)
256 }
257
258 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
259 let content = ctx.content;
260 let _line_index = &ctx.line_index;
261
262 let mut result = String::new();
263
264 let mut current_block = Vec::new();
265
266 let mut in_code_block = false;
267
268 let mut current_lang = String::new();
269
270 for line in content.lines() {
271 if line.trim_start().starts_with("```") {
272 if in_code_block {
273 if self.is_command_without_output(¤t_block, ¤t_lang) {
275 result.push_str(&self.fix_command_block(¤t_block));
276 result.push('\n');
277 } else {
278 for block_line in ¤t_block {
279 result.push_str(block_line);
280 result.push('\n');
281 }
282 }
283 current_block.clear();
284 } else {
285 current_lang = Self::get_code_block_language(line);
286 }
287 in_code_block = !in_code_block;
288 result.push_str(line);
289 result.push('\n');
290 } else if in_code_block {
291 current_block.push(line);
292 } else {
293 result.push_str(line);
294 result.push('\n');
295 }
296 }
297
298 if !content.ends_with('\n') && result.ends_with('\n') {
300 result.pop();
301 }
302
303 Ok(result)
304 }
305
306 fn as_any(&self) -> &dyn std::any::Any {
307 self
308 }
309
310 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
311 ctx.content.is_empty() || !ctx.likely_has_code()
313 }
314
315 fn default_config_section(&self) -> Option<(String, toml::Value)> {
316 let default_config = MD014Config::default();
317 let json_value = serde_json::to_value(&default_config).ok()?;
318 let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
319
320 if let toml::Value::Table(table) = toml_value {
321 if !table.is_empty() {
322 Some((MD014Config::RULE_NAME.to_string(), toml::Value::Table(table)))
323 } else {
324 None
325 }
326 } else {
327 None
328 }
329 }
330
331 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
332 where
333 Self: Sized,
334 {
335 let rule_config = crate::rule_config_serde::load_rule_config::<MD014Config>(config);
336 Box::new(Self::from_config_struct(rule_config))
337 }
338}
339
340#[cfg(test)]
341mod tests {
342 use super::*;
343 use crate::lint_context::LintContext;
344
345 #[test]
346 fn test_is_command_line() {
347 let rule = MD014CommandsShowOutput::new();
348 assert!(rule.is_command_line("$ echo test"));
349 assert!(rule.is_command_line(" $ ls -la"));
350 assert!(rule.is_command_line("> pwd"));
351 assert!(rule.is_command_line(" > cd /home"));
352 assert!(!rule.is_command_line("echo test"));
353 assert!(!rule.is_command_line("# comment"));
354 assert!(!rule.is_command_line("output line"));
355 }
356
357 #[test]
358 fn test_is_shell_language() {
359 let rule = MD014CommandsShowOutput::new();
360 assert!(rule.is_shell_language("bash"));
361 assert!(rule.is_shell_language("BASH"));
362 assert!(rule.is_shell_language("sh"));
363 assert!(rule.is_shell_language("shell"));
364 assert!(rule.is_shell_language("Shell"));
365 assert!(rule.is_shell_language("console"));
366 assert!(rule.is_shell_language("CONSOLE"));
367 assert!(rule.is_shell_language("terminal"));
368 assert!(rule.is_shell_language("Terminal"));
369 assert!(!rule.is_shell_language("python"));
370 assert!(!rule.is_shell_language("javascript"));
371 assert!(!rule.is_shell_language(""));
372 }
373
374 #[test]
375 fn test_is_output_line() {
376 let rule = MD014CommandsShowOutput::new();
377 assert!(rule.is_output_line("output text"));
378 assert!(rule.is_output_line(" some output"));
379 assert!(rule.is_output_line("file1 file2"));
380 assert!(!rule.is_output_line(""));
381 assert!(!rule.is_output_line(" "));
382 assert!(!rule.is_output_line("$ command"));
383 assert!(!rule.is_output_line("> prompt"));
384 assert!(!rule.is_output_line("# comment"));
385 }
386
387 #[test]
388 fn test_is_no_output_command() {
389 let rule = MD014CommandsShowOutput::new();
390 assert!(rule.is_no_output_command("cd /home"));
391 assert!(rule.is_no_output_command("mkdir test"));
392 assert!(rule.is_no_output_command("touch file.txt"));
393 assert!(rule.is_no_output_command("rm -rf dir"));
394 assert!(rule.is_no_output_command("mv old new"));
395 assert!(rule.is_no_output_command("cp src dst"));
396 assert!(rule.is_no_output_command("export VAR=value"));
397 assert!(rule.is_no_output_command("set -e"));
398 assert!(rule.is_no_output_command("CD /HOME"));
399 assert!(rule.is_no_output_command("MKDIR TEST"));
400 assert!(!rule.is_no_output_command("ls -la"));
401 assert!(!rule.is_no_output_command("echo test"));
402 assert!(!rule.is_no_output_command("pwd"));
403 }
404
405 #[test]
406 fn test_get_command_from_block() {
407 let rule = MD014CommandsShowOutput::new();
408 let block = vec!["$ echo test", "output"];
409 assert_eq!(rule.get_command_from_block(&block), "echo test");
410
411 let block2 = vec![" $ ls -la", "file1 file2"];
412 assert_eq!(rule.get_command_from_block(&block2), "ls -la");
413
414 let block3 = vec!["> pwd", "/home"];
415 assert_eq!(rule.get_command_from_block(&block3), "pwd");
416
417 let empty_block: Vec<&str> = vec![];
418 assert_eq!(rule.get_command_from_block(&empty_block), "");
419 }
420
421 #[test]
422 fn test_fix_command_block() {
423 let rule = MD014CommandsShowOutput::new();
424 let block = vec!["$ echo test", "$ ls -la"];
425 assert_eq!(rule.fix_command_block(&block), "echo test\nls -la");
426
427 let indented = vec![" $ echo test", " $ pwd"];
428 assert_eq!(rule.fix_command_block(&indented), " echo test\n pwd");
429
430 let mixed = vec!["> cd /home", "$ mkdir test"];
431 assert_eq!(rule.fix_command_block(&mixed), "cd /home\nmkdir test");
432 }
433
434 #[test]
435 fn test_get_code_block_language() {
436 assert_eq!(MD014CommandsShowOutput::get_code_block_language("```bash"), "bash");
437 assert_eq!(MD014CommandsShowOutput::get_code_block_language("```shell"), "shell");
438 assert_eq!(
439 MD014CommandsShowOutput::get_code_block_language(" ```console"),
440 "console"
441 );
442 assert_eq!(
443 MD014CommandsShowOutput::get_code_block_language("```bash {.line-numbers}"),
444 "bash"
445 );
446 assert_eq!(MD014CommandsShowOutput::get_code_block_language("```"), "");
447 }
448
449 #[test]
450 fn test_find_first_command_line() {
451 let rule = MD014CommandsShowOutput::new();
452 let block = vec!["# comment", "$ echo test", "output"];
453 let result = rule.find_first_command_line(&block);
454 assert_eq!(result, Some((1, "$ echo test")));
455
456 let no_commands = vec!["output1", "output2"];
457 assert_eq!(rule.find_first_command_line(&no_commands), None);
458 }
459
460 #[test]
461 fn test_is_command_without_output() {
462 let rule = MD014CommandsShowOutput::with_show_output(true);
463
464 let block1 = vec!["$ echo test"];
466 assert!(rule.is_command_without_output(&block1, "bash"));
467
468 let block2 = vec!["$ echo test", "test"];
470 assert!(!rule.is_command_without_output(&block2, "bash"));
471
472 let block3 = vec!["$ cd /home"];
474 assert!(!rule.is_command_without_output(&block3, "bash"));
475
476 let rule_disabled = MD014CommandsShowOutput::with_show_output(false);
478 assert!(!rule_disabled.is_command_without_output(&block1, "bash"));
479
480 assert!(!rule.is_command_without_output(&block1, "python"));
482 }
483
484 #[test]
485 fn test_edge_cases() {
486 let rule = MD014CommandsShowOutput::new();
487 let content = "```bash\n$ \n```";
489 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
490 let result = rule.check(&ctx).unwrap();
491 assert!(
492 result.is_empty(),
493 "Bare $ with only space doesn't match command pattern"
494 );
495
496 let empty_content = "```bash\n```";
498 let ctx2 = LintContext::new(empty_content, crate::config::MarkdownFlavor::Standard);
499 let result2 = rule.check(&ctx2).unwrap();
500 assert!(result2.is_empty(), "Empty code block should not be flagged");
501
502 let minimal = "```bash\n$ a\n```";
504 let ctx3 = LintContext::new(minimal, crate::config::MarkdownFlavor::Standard);
505 let result3 = rule.check(&ctx3).unwrap();
506 assert_eq!(result3.len(), 1, "Minimal command should be flagged");
507 }
508
509 #[test]
510 fn test_default_config_section() {
511 let rule = MD014CommandsShowOutput::new();
512 let config_section = rule.default_config_section();
513 assert!(config_section.is_some());
514 let (name, _value) = config_section.unwrap();
515 assert_eq!(name, "MD014");
516 }
517}