use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
use crate::rule_config_serde::RuleConfig;
use crate::utils::range_utils::calculate_match_range;
use crate::utils::regex_cache::get_cached_regex;
use toml;
mod md014_config;
use md014_config::MD014Config;
const COMMAND_PATTERN: &str = r"^\s*[$>]\s+\S+";
const SHELL_LANG_PATTERN: &str = r"^(?i)(bash|sh|shell|console|terminal)";
const DOLLAR_PROMPT_PATTERN: &str = r"^\s*([$>])";
#[derive(Clone, Default)]
pub struct MD014CommandsShowOutput {
config: MD014Config,
}
impl MD014CommandsShowOutput {
pub fn new() -> Self {
Self::default()
}
pub fn with_show_output(show_output: bool) -> Self {
Self {
config: MD014Config { show_output },
}
}
pub fn from_config_struct(config: MD014Config) -> Self {
Self { config }
}
fn is_command_line(&self, line: &str) -> bool {
get_cached_regex(COMMAND_PATTERN)
.map(|re| re.is_match(line))
.unwrap_or(false)
}
fn is_shell_language(&self, lang: &str) -> bool {
get_cached_regex(SHELL_LANG_PATTERN)
.map(|re| re.is_match(lang))
.unwrap_or(false)
}
fn is_output_line(&self, line: &str) -> bool {
let trimmed = line.trim();
!trimmed.is_empty() && !trimmed.starts_with('$') && !trimmed.starts_with('>') && !trimmed.starts_with('#')
}
fn is_no_output_command(&self, cmd: &str) -> bool {
let cmd = cmd.trim().to_lowercase();
cmd.starts_with("cd ")
|| cmd == "cd"
|| cmd.starts_with("mkdir ")
|| cmd.starts_with("touch ")
|| cmd.starts_with("rm ")
|| cmd.starts_with("mv ")
|| cmd.starts_with("cp ")
|| cmd.starts_with("export ")
|| cmd.starts_with("set ")
|| cmd.starts_with("alias ")
|| cmd.starts_with("unset ")
|| cmd.starts_with("source ")
|| cmd.starts_with(". ")
|| cmd == "true"
|| cmd == "false"
|| cmd.starts_with("sleep ")
|| cmd.starts_with("wait ")
|| cmd.starts_with("pushd ")
|| cmd.starts_with("popd")
|| cmd.contains(" > ")
|| cmd.contains(" >> ")
|| cmd.starts_with("git add ")
|| cmd.starts_with("git checkout ")
|| cmd.starts_with("git stash")
|| cmd.starts_with("git reset ")
}
fn is_command_without_output(&self, block: &[&str], lang: &str) -> bool {
if !self.config.show_output || !self.is_shell_language(lang) {
return false;
}
let has_output = block.iter().any(|line| self.is_output_line(line));
if has_output {
return false; }
self.get_first_output_command(block).is_some()
}
fn get_first_output_command(&self, block: &[&str]) -> Option<(usize, String)> {
for (i, line) in block.iter().enumerate() {
if self.is_command_line(line) {
let cmd = line.trim()[1..].trim().to_string();
if !self.is_no_output_command(&cmd) {
return Some((i, cmd));
}
}
}
None }
fn fix_command_block(&self, block: &[&str]) -> String {
block
.iter()
.map(|line| {
let trimmed = line.trim_start();
if self.is_command_line(line) {
let spaces = line.len() - line.trim_start().len();
let cmd = trimmed.chars().skip(1).collect::<String>().trim_start().to_string();
format!("{}{}", " ".repeat(spaces), cmd)
} else {
line.to_string()
}
})
.collect::<Vec<_>>()
.join("\n")
}
fn get_code_block_language(block_start: &str) -> String {
block_start
.trim_start()
.trim_start_matches("```")
.split_whitespace()
.next()
.unwrap_or("")
.to_string()
}
fn find_all_command_lines<'a>(&self, block: &[&'a str]) -> Vec<(usize, &'a str)> {
let mut results = Vec::new();
for (i, line) in block.iter().enumerate() {
if self.is_command_line(line) {
let cmd = line.trim()[1..].trim();
if !self.is_no_output_command(cmd) {
results.push((i, *line));
}
}
}
results
}
}
impl Rule for MD014CommandsShowOutput {
fn name(&self) -> &'static str {
"MD014"
}
fn description(&self) -> &'static str {
"Commands in code blocks should show output"
}
fn category(&self) -> RuleCategory {
RuleCategory::CodeBlock
}
fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
let content = ctx.content;
let line_index = &ctx.line_index;
let mut warnings = Vec::new();
let mut current_block = Vec::new();
let mut in_code_block = false;
let mut block_start_line = 0;
let mut current_lang = String::new();
for (line_num, line) in content.lines().enumerate() {
if line.trim_start().starts_with("```") {
if in_code_block {
if self.is_command_without_output(¤t_block, ¤t_lang) {
let command_lines = self.find_all_command_lines(¤t_block);
let fix = Fix::new(
{
let content_start_line = block_start_line + 1; let content_end_line = line_num - 1;
let start_byte = line_index.get_line_start_byte(content_start_line + 1).unwrap_or(0); let end_byte = line_index
.get_line_start_byte(content_end_line + 2)
.unwrap_or(start_byte); start_byte..end_byte
},
format!("{}\n", self.fix_command_block(¤t_block)),
);
for (cmd_line_idx, cmd_line) in &command_lines {
let cmd_line_num = block_start_line + 1 + cmd_line_idx + 1;
if let Ok(re) = get_cached_regex(DOLLAR_PROMPT_PATTERN)
&& let Some(cap) = re.captures(cmd_line)
{
let match_obj = cap.get(1).unwrap(); let (start_line, start_col, end_line, end_col) =
calculate_match_range(cmd_line_num, cmd_line, match_obj.start(), match_obj.len());
let cmd_text = cmd_line.trim()[1..].trim().to_string();
let message = if cmd_text.is_empty() {
"Command should show output (add example output or remove $ prompt)".to_string()
} else {
format!(
"Command '{cmd_text}' should show output (add example output or remove $ prompt)"
)
};
warnings.push(LintWarning {
rule_name: Some(self.name().to_string()),
line: start_line,
column: start_col,
end_line,
end_column: end_col,
message,
severity: Severity::Warning,
fix: Some(fix.clone()),
});
}
}
}
current_block.clear();
} else {
block_start_line = line_num;
current_lang = Self::get_code_block_language(line);
}
in_code_block = !in_code_block;
} else if in_code_block {
current_block.push(line);
}
}
Ok(warnings)
}
fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
if self.should_skip(ctx) {
return Ok(ctx.content.to_string());
}
let warnings = self.check(ctx)?;
if warnings.is_empty() {
return Ok(ctx.content.to_string());
}
let warnings =
crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings)
.map_err(crate::rule::LintError::InvalidInput)
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
ctx.content.is_empty() || !ctx.likely_has_code()
}
fn default_config_section(&self) -> Option<(String, toml::Value)> {
let default_config = MD014Config::default();
let json_value = serde_json::to_value(&default_config).ok()?;
let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
if let toml::Value::Table(table) = toml_value {
if !table.is_empty() {
Some((MD014Config::RULE_NAME.to_string(), toml::Value::Table(table)))
} else {
None
}
} else {
None
}
}
fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
where
Self: Sized,
{
let rule_config = crate::rule_config_serde::load_rule_config::<MD014Config>(config);
Box::new(Self::from_config_struct(rule_config))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::lint_context::LintContext;
#[test]
fn test_is_command_line() {
let rule = MD014CommandsShowOutput::new();
assert!(rule.is_command_line("$ echo test"));
assert!(rule.is_command_line(" $ ls -la"));
assert!(rule.is_command_line("> pwd"));
assert!(rule.is_command_line(" > cd /home"));
assert!(!rule.is_command_line("echo test"));
assert!(!rule.is_command_line("# comment"));
assert!(!rule.is_command_line("output line"));
}
#[test]
fn test_is_shell_language() {
let rule = MD014CommandsShowOutput::new();
assert!(rule.is_shell_language("bash"));
assert!(rule.is_shell_language("BASH"));
assert!(rule.is_shell_language("sh"));
assert!(rule.is_shell_language("shell"));
assert!(rule.is_shell_language("Shell"));
assert!(rule.is_shell_language("console"));
assert!(rule.is_shell_language("CONSOLE"));
assert!(rule.is_shell_language("terminal"));
assert!(rule.is_shell_language("Terminal"));
assert!(!rule.is_shell_language("python"));
assert!(!rule.is_shell_language("javascript"));
assert!(!rule.is_shell_language(""));
}
#[test]
fn test_is_output_line() {
let rule = MD014CommandsShowOutput::new();
assert!(rule.is_output_line("output text"));
assert!(rule.is_output_line(" some output"));
assert!(rule.is_output_line("file1 file2"));
assert!(!rule.is_output_line(""));
assert!(!rule.is_output_line(" "));
assert!(!rule.is_output_line("$ command"));
assert!(!rule.is_output_line("> prompt"));
assert!(!rule.is_output_line("# comment"));
}
#[test]
fn test_is_no_output_command() {
let rule = MD014CommandsShowOutput::new();
assert!(rule.is_no_output_command("cd /home"));
assert!(rule.is_no_output_command("cd"));
assert!(rule.is_no_output_command("mkdir test"));
assert!(rule.is_no_output_command("touch file.txt"));
assert!(rule.is_no_output_command("rm -rf dir"));
assert!(rule.is_no_output_command("mv old new"));
assert!(rule.is_no_output_command("cp src dst"));
assert!(rule.is_no_output_command("export VAR=value"));
assert!(rule.is_no_output_command("set -e"));
assert!(rule.is_no_output_command("source ~/.bashrc"));
assert!(rule.is_no_output_command(". ~/.profile"));
assert!(rule.is_no_output_command("alias ll='ls -la'"));
assert!(rule.is_no_output_command("unset VAR"));
assert!(rule.is_no_output_command("true"));
assert!(rule.is_no_output_command("false"));
assert!(rule.is_no_output_command("sleep 5"));
assert!(rule.is_no_output_command("pushd /tmp"));
assert!(rule.is_no_output_command("popd"));
assert!(rule.is_no_output_command("CD /HOME"));
assert!(rule.is_no_output_command("MKDIR TEST"));
assert!(rule.is_no_output_command("echo 'test' > file.txt"));
assert!(rule.is_no_output_command("cat input.txt > output.txt"));
assert!(rule.is_no_output_command("echo 'append' >> log.txt"));
assert!(rule.is_no_output_command("git add ."));
assert!(rule.is_no_output_command("git checkout main"));
assert!(rule.is_no_output_command("git stash"));
assert!(rule.is_no_output_command("git reset HEAD~1"));
assert!(!rule.is_no_output_command("ls -la"));
assert!(!rule.is_no_output_command("echo test")); assert!(!rule.is_no_output_command("pwd"));
assert!(!rule.is_no_output_command("cat file.txt")); assert!(!rule.is_no_output_command("grep pattern file"));
assert!(!rule.is_no_output_command("pip install requests"));
assert!(!rule.is_no_output_command("npm install express"));
assert!(!rule.is_no_output_command("cargo install ripgrep"));
assert!(!rule.is_no_output_command("brew install git"));
assert!(!rule.is_no_output_command("cargo build"));
assert!(!rule.is_no_output_command("npm run build"));
assert!(!rule.is_no_output_command("make"));
assert!(!rule.is_no_output_command("docker ps"));
assert!(!rule.is_no_output_command("docker compose up"));
assert!(!rule.is_no_output_command("docker run myimage"));
assert!(!rule.is_no_output_command("git status"));
assert!(!rule.is_no_output_command("git log"));
assert!(!rule.is_no_output_command("git diff"));
}
#[test]
fn test_fix_command_block() {
let rule = MD014CommandsShowOutput::new();
let block = vec!["$ echo test", "$ ls -la"];
assert_eq!(rule.fix_command_block(&block), "echo test\nls -la");
let indented = vec![" $ echo test", " $ pwd"];
assert_eq!(rule.fix_command_block(&indented), " echo test\n pwd");
let mixed = vec!["> cd /home", "$ mkdir test"];
assert_eq!(rule.fix_command_block(&mixed), "cd /home\nmkdir test");
}
#[test]
fn test_get_code_block_language() {
assert_eq!(MD014CommandsShowOutput::get_code_block_language("```bash"), "bash");
assert_eq!(MD014CommandsShowOutput::get_code_block_language("```shell"), "shell");
assert_eq!(
MD014CommandsShowOutput::get_code_block_language(" ```console"),
"console"
);
assert_eq!(
MD014CommandsShowOutput::get_code_block_language("```bash {.line-numbers}"),
"bash"
);
assert_eq!(MD014CommandsShowOutput::get_code_block_language("```"), "");
}
#[test]
fn test_find_all_command_lines() {
let rule = MD014CommandsShowOutput::new();
let block = vec!["# comment", "$ echo test", "output"];
let result = rule.find_all_command_lines(&block);
assert_eq!(result, vec![(1, "$ echo test")]);
let no_commands = vec!["output1", "output2"];
assert!(rule.find_all_command_lines(&no_commands).is_empty());
let multiple = vec!["$ echo one", "$ echo two", "$ cd /tmp"];
let result = rule.find_all_command_lines(&multiple);
assert_eq!(result, vec![(0, "$ echo one"), (1, "$ echo two")]);
}
#[test]
fn test_is_command_without_output() {
let rule = MD014CommandsShowOutput::with_show_output(true);
let block1 = vec!["$ echo test"];
assert!(rule.is_command_without_output(&block1, "bash"));
let block2 = vec!["$ echo test", "test"];
assert!(!rule.is_command_without_output(&block2, "bash"));
let block3 = vec!["$ cd /home"];
assert!(!rule.is_command_without_output(&block3, "bash"));
let rule_disabled = MD014CommandsShowOutput::with_show_output(false);
assert!(!rule_disabled.is_command_without_output(&block1, "bash"));
assert!(!rule.is_command_without_output(&block1, "python"));
}
#[test]
fn test_edge_cases() {
let rule = MD014CommandsShowOutput::new();
let content = "```bash\n$ \n```";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Bare $ with only space doesn't match command pattern"
);
let empty_content = "```bash\n```";
let ctx2 = LintContext::new(empty_content, crate::config::MarkdownFlavor::Standard, None);
let result2 = rule.check(&ctx2).unwrap();
assert!(result2.is_empty(), "Empty code block should not be flagged");
let minimal = "```bash\n$ a\n```";
let ctx3 = LintContext::new(minimal, crate::config::MarkdownFlavor::Standard, None);
let result3 = rule.check(&ctx3).unwrap();
assert_eq!(result3.len(), 1, "Minimal command should be flagged");
}
#[test]
fn test_mixed_silent_and_output_commands() {
let rule = MD014CommandsShowOutput::new();
let silent_only = "```bash\n$ cd /home\n$ mkdir test\n```";
let ctx1 = LintContext::new(silent_only, crate::config::MarkdownFlavor::Standard, None);
let result1 = rule.check(&ctx1).unwrap();
assert!(
result1.is_empty(),
"Block with only silent commands should not be flagged"
);
let mixed_silent_first = "```bash\n$ cd /home\n$ ls -la\n```";
let ctx2 = LintContext::new(mixed_silent_first, crate::config::MarkdownFlavor::Standard, None);
let result2 = rule.check(&ctx2).unwrap();
assert_eq!(result2.len(), 1, "Only output-producing commands should be flagged");
assert!(
result2[0].message.contains("ls -la"),
"Message should mention 'ls -la', not 'cd /home'. Got: {}",
result2[0].message
);
let mixed_mkdir_cat = "```bash\n$ mkdir test\n$ cat file.txt\n```";
let ctx3 = LintContext::new(mixed_mkdir_cat, crate::config::MarkdownFlavor::Standard, None);
let result3 = rule.check(&ctx3).unwrap();
assert_eq!(result3.len(), 1, "Only output-producing commands should be flagged");
assert!(
result3[0].message.contains("cat file.txt"),
"Message should mention 'cat file.txt', not 'mkdir'. Got: {}",
result3[0].message
);
let mkdir_pip = "```bash\n$ mkdir test\n$ pip install something\n```";
let ctx3b = LintContext::new(mkdir_pip, crate::config::MarkdownFlavor::Standard, None);
let result3b = rule.check(&ctx3b).unwrap();
assert_eq!(result3b.len(), 1, "Block with pip install should be flagged");
assert!(
result3b[0].message.contains("pip install"),
"Message should mention 'pip install'. Got: {}",
result3b[0].message
);
let mixed_output_first = "```bash\n$ echo hello\n$ cd /home\n```";
let ctx4 = LintContext::new(mixed_output_first, crate::config::MarkdownFlavor::Standard, None);
let result4 = rule.check(&ctx4).unwrap();
assert_eq!(result4.len(), 1, "Only output-producing commands should be flagged");
assert!(
result4[0].message.contains("echo hello"),
"Message should mention 'echo hello'. Got: {}",
result4[0].message
);
}
#[test]
fn test_multiple_commands_without_output_all_flagged() {
let rule = MD014CommandsShowOutput::new();
let content = "```shell\n# First invocation\n$ my_command\n\n# Second invocation\n$ my_command\n```";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 2, "Both commands should be flagged. Got: {result:?}");
assert!(result[0].message.contains("my_command"));
assert!(result[1].message.contains("my_command"));
assert_ne!(result[0].line, result[1].line, "Warnings should be on different lines");
let content2 = "```bash\n$ echo hello\n$ ls -la\n$ pwd\n```";
let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
let result2 = rule.check(&ctx2).unwrap();
assert_eq!(
result2.len(),
3,
"All three commands should be flagged. Got: {result2:?}"
);
assert!(result2[0].message.contains("echo hello"));
assert!(result2[1].message.contains("ls -la"));
assert!(result2[2].message.contains("pwd"));
let content3 = "```bash\n$ echo hello\n$ cd /tmp\n$ ls -la\n```";
let ctx3 = LintContext::new(content3, crate::config::MarkdownFlavor::Standard, None);
let result3 = rule.check(&ctx3).unwrap();
assert_eq!(
result3.len(),
2,
"Only output-producing commands should be flagged. Got: {result3:?}"
);
assert!(result3[0].message.contains("echo hello"));
assert!(result3[1].message.contains("ls -la"));
}
#[test]
fn test_issue_516_exact_case() {
let rule = MD014CommandsShowOutput::new();
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";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(
result.len(),
2,
"Both $ my_command lines should be flagged. Got: {result:?}"
);
assert_eq!(result[0].line, 9, "First warning should be on line 9");
assert_eq!(result[1].line, 12, "Second warning should be on line 12");
}
#[test]
fn test_default_config_section() {
let rule = MD014CommandsShowOutput::new();
let config_section = rule.default_config_section();
assert!(config_section.is_some());
let (name, _value) = config_section.unwrap();
assert_eq!(name, "MD014");
}
}