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 cmd.contains("cd ")
58 || cmd.contains("mkdir ")
59 || cmd.contains("touch ")
60 || cmd.contains("rm ")
61 || cmd.contains("mv ")
62 || cmd.contains("cp ")
63 || cmd.contains("export ")
64 || cmd.contains("set ")
65 }
66
67 fn is_command_without_output(&self, block: &[&str], lang: &str) -> bool {
68 if !self.config.show_output || !self.is_shell_language(lang) {
69 return false;
70 }
71
72 let mut has_command = false;
73 let mut has_output = false;
74 let mut last_command = String::new();
75
76 for line in block {
77 let trimmed = line.trim();
78 if self.is_command_line(line) {
79 has_command = true;
80 last_command = trimmed[1..].trim().to_string();
81 } else if self.is_output_line(line) {
82 has_output = true;
83 }
84 }
85
86 has_command && !has_output && !self.is_no_output_command(&last_command)
87 }
88
89 fn get_command_from_block(&self, block: &[&str]) -> String {
90 for line in block {
91 let trimmed = line.trim();
92 if self.is_command_line(line) {
93 return trimmed[1..].trim().to_string();
94 }
95 }
96 String::new()
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() - line.trim_start().len();
106 let cmd = trimmed.chars().skip(1).collect::<String>().trim_start().to_string();
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) -> String {
117 block_start
118 .trim_start()
119 .trim_start_matches("```")
120 .split_whitespace()
121 .next()
122 .unwrap_or("")
123 .to_string()
124 }
125
126 fn find_first_command_line<'a>(&self, block: &[&'a str]) -> Option<(usize, &'a str)> {
127 for (i, line) in block.iter().enumerate() {
128 if self.is_command_line(line) {
129 return Some((i, line));
130 }
131 }
132 None
133 }
134}
135
136impl Rule for MD014CommandsShowOutput {
137 fn name(&self) -> &'static str {
138 "MD014"
139 }
140
141 fn description(&self) -> &'static str {
142 "Commands in code blocks should show output"
143 }
144
145 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
146 let content = ctx.content;
147 let _line_index = LineIndex::new(content.to_string());
148
149 let mut warnings = Vec::new();
150
151 let mut current_block = Vec::new();
152
153 let mut in_code_block = false;
154
155 let mut block_start_line = 0;
156
157 let mut current_lang = String::new();
158
159 for (line_num, line) in content.lines().enumerate() {
160 if line.trim_start().starts_with("```") {
161 if in_code_block {
162 if self.is_command_without_output(¤t_block, ¤t_lang) {
164 if let Some((cmd_line_idx, cmd_line)) = self.find_first_command_line(¤t_block) {
166 let cmd_line_num = block_start_line + 1 + cmd_line_idx + 1; if let Some(cap) = DOLLAR_PROMPT_PATTERN.captures(cmd_line) {
170 let match_obj = cap.get(1).unwrap(); let (start_line, start_col, end_line, end_col) =
172 calculate_match_range(cmd_line_num, cmd_line, match_obj.start(), match_obj.len());
173
174 let command = self.get_command_from_block(¤t_block);
176 let message = if command.is_empty() {
177 "Command should show output (add example output or remove $ prompt)".to_string()
178 } else {
179 format!(
180 "Command '{command}' should show output (add example output or remove $ prompt)"
181 )
182 };
183
184 warnings.push(LintWarning {
185 rule_name: Some(self.name()),
186 line: start_line,
187 column: start_col,
188 end_line,
189 end_column: end_col,
190 message,
191 severity: Severity::Warning,
192 fix: Some(Fix {
193 range: {
194 let content_start_line = block_start_line + 1; let content_end_line = line_num - 1; let start_byte =
200 _line_index.get_line_start_byte(content_start_line + 1).unwrap_or(0); let end_byte = _line_index
202 .get_line_start_byte(content_end_line + 2)
203 .unwrap_or(start_byte); start_byte..end_byte
205 },
206 replacement: format!("{}\n", self.fix_command_block(¤t_block)),
207 }),
208 });
209 }
210 }
211 }
212 current_block.clear();
213 } else {
214 block_start_line = line_num;
216 current_lang = Self::get_code_block_language(line);
217 }
218 in_code_block = !in_code_block;
219 } else if in_code_block {
220 current_block.push(line);
221 }
222 }
223
224 Ok(warnings)
225 }
226
227 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
228 let content = ctx.content;
229 let _line_index = LineIndex::new(content.to_string());
230
231 let mut result = String::new();
232
233 let mut current_block = Vec::new();
234
235 let mut in_code_block = false;
236
237 let mut current_lang = String::new();
238
239 for line in content.lines() {
240 if line.trim_start().starts_with("```") {
241 if in_code_block {
242 if self.is_command_without_output(¤t_block, ¤t_lang) {
244 result.push_str(&self.fix_command_block(¤t_block));
245 result.push('\n');
246 } else {
247 for block_line in ¤t_block {
248 result.push_str(block_line);
249 result.push('\n');
250 }
251 }
252 current_block.clear();
253 } else {
254 current_lang = Self::get_code_block_language(line);
255 }
256 in_code_block = !in_code_block;
257 result.push_str(line);
258 result.push('\n');
259 } else if in_code_block {
260 current_block.push(line);
261 } else {
262 result.push_str(line);
263 result.push('\n');
264 }
265 }
266
267 if !content.ends_with('\n') && result.ends_with('\n') {
269 result.pop();
270 }
271
272 Ok(result)
273 }
274
275 fn as_any(&self) -> &dyn std::any::Any {
276 self
277 }
278
279 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
280 ctx.content.is_empty() || !ctx.content.contains("```")
282 }
283
284 fn default_config_section(&self) -> Option<(String, toml::Value)> {
285 let default_config = MD014Config::default();
286 let json_value = serde_json::to_value(&default_config).ok()?;
287 let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
288
289 if let toml::Value::Table(table) = toml_value {
290 if !table.is_empty() {
291 Some((MD014Config::RULE_NAME.to_string(), toml::Value::Table(table)))
292 } else {
293 None
294 }
295 } else {
296 None
297 }
298 }
299
300 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
301 where
302 Self: Sized,
303 {
304 let rule_config = crate::rule_config_serde::load_rule_config::<MD014Config>(config);
305 Box::new(Self::from_config_struct(rule_config))
306 }
307}
308
309#[cfg(test)]
310mod tests {
311 use super::*;
312 use crate::lint_context::LintContext;
313
314 #[test]
315 fn test_is_command_line() {
316 let rule = MD014CommandsShowOutput::new();
317 assert!(rule.is_command_line("$ echo test"));
318 assert!(rule.is_command_line(" $ ls -la"));
319 assert!(rule.is_command_line("> pwd"));
320 assert!(rule.is_command_line(" > cd /home"));
321 assert!(!rule.is_command_line("echo test"));
322 assert!(!rule.is_command_line("# comment"));
323 assert!(!rule.is_command_line("output line"));
324 }
325
326 #[test]
327 fn test_is_shell_language() {
328 let rule = MD014CommandsShowOutput::new();
329 assert!(rule.is_shell_language("bash"));
330 assert!(rule.is_shell_language("BASH"));
331 assert!(rule.is_shell_language("sh"));
332 assert!(rule.is_shell_language("shell"));
333 assert!(rule.is_shell_language("Shell"));
334 assert!(rule.is_shell_language("console"));
335 assert!(rule.is_shell_language("CONSOLE"));
336 assert!(rule.is_shell_language("terminal"));
337 assert!(rule.is_shell_language("Terminal"));
338 assert!(!rule.is_shell_language("python"));
339 assert!(!rule.is_shell_language("javascript"));
340 assert!(!rule.is_shell_language(""));
341 }
342
343 #[test]
344 fn test_is_output_line() {
345 let rule = MD014CommandsShowOutput::new();
346 assert!(rule.is_output_line("output text"));
347 assert!(rule.is_output_line(" some output"));
348 assert!(rule.is_output_line("file1 file2"));
349 assert!(!rule.is_output_line(""));
350 assert!(!rule.is_output_line(" "));
351 assert!(!rule.is_output_line("$ command"));
352 assert!(!rule.is_output_line("> prompt"));
353 assert!(!rule.is_output_line("# comment"));
354 }
355
356 #[test]
357 fn test_is_no_output_command() {
358 let rule = MD014CommandsShowOutput::new();
359 assert!(rule.is_no_output_command("cd /home"));
360 assert!(rule.is_no_output_command("mkdir test"));
361 assert!(rule.is_no_output_command("touch file.txt"));
362 assert!(rule.is_no_output_command("rm -rf dir"));
363 assert!(rule.is_no_output_command("mv old new"));
364 assert!(rule.is_no_output_command("cp src dst"));
365 assert!(rule.is_no_output_command("export VAR=value"));
366 assert!(rule.is_no_output_command("set -e"));
367 assert!(rule.is_no_output_command("CD /HOME"));
368 assert!(rule.is_no_output_command("MKDIR TEST"));
369 assert!(!rule.is_no_output_command("ls -la"));
370 assert!(!rule.is_no_output_command("echo test"));
371 assert!(!rule.is_no_output_command("pwd"));
372 }
373
374 #[test]
375 fn test_get_command_from_block() {
376 let rule = MD014CommandsShowOutput::new();
377 let block = vec!["$ echo test", "output"];
378 assert_eq!(rule.get_command_from_block(&block), "echo test");
379
380 let block2 = vec![" $ ls -la", "file1 file2"];
381 assert_eq!(rule.get_command_from_block(&block2), "ls -la");
382
383 let block3 = vec!["> pwd", "/home"];
384 assert_eq!(rule.get_command_from_block(&block3), "pwd");
385
386 let empty_block: Vec<&str> = vec![];
387 assert_eq!(rule.get_command_from_block(&empty_block), "");
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_find_first_command_line() {
420 let rule = MD014CommandsShowOutput::new();
421 let block = vec!["# comment", "$ echo test", "output"];
422 let result = rule.find_first_command_line(&block);
423 assert_eq!(result, Some((1, "$ echo test")));
424
425 let no_commands = vec!["output1", "output2"];
426 assert_eq!(rule.find_first_command_line(&no_commands), None);
427 }
428
429 #[test]
430 fn test_is_command_without_output() {
431 let rule = MD014CommandsShowOutput::with_show_output(true);
432
433 let block1 = vec!["$ echo test"];
435 assert!(rule.is_command_without_output(&block1, "bash"));
436
437 let block2 = vec!["$ echo test", "test"];
439 assert!(!rule.is_command_without_output(&block2, "bash"));
440
441 let block3 = vec!["$ cd /home"];
443 assert!(!rule.is_command_without_output(&block3, "bash"));
444
445 let rule_disabled = MD014CommandsShowOutput::with_show_output(false);
447 assert!(!rule_disabled.is_command_without_output(&block1, "bash"));
448
449 assert!(!rule.is_command_without_output(&block1, "python"));
451 }
452
453 #[test]
454 fn test_edge_cases() {
455 let rule = MD014CommandsShowOutput::new();
456 let content = "```bash\n$ \n```";
458 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
459 let result = rule.check(&ctx).unwrap();
460 assert!(
461 result.is_empty(),
462 "Bare $ with only space doesn't match command pattern"
463 );
464
465 let empty_content = "```bash\n```";
467 let ctx2 = LintContext::new(empty_content, crate::config::MarkdownFlavor::Standard);
468 let result2 = rule.check(&ctx2).unwrap();
469 assert!(result2.is_empty(), "Empty code block should not be flagged");
470
471 let minimal = "```bash\n$ a\n```";
473 let ctx3 = LintContext::new(minimal, crate::config::MarkdownFlavor::Standard);
474 let result3 = rule.check(&ctx3).unwrap();
475 assert_eq!(result3.len(), 1, "Minimal command should be flagged");
476 }
477
478 #[test]
479 fn test_default_config_section() {
480 let rule = MD014CommandsShowOutput::new();
481 let config_section = rule.default_config_section();
482 assert!(config_section.is_some());
483 let (name, _value) = config_section.unwrap();
484 assert_eq!(name, "MD014");
485 }
486}