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