use crate::format::context::{OutputContext, OutputMode};
use rich_rust::color::ColorSystem;
use rich_rust::renderables::markdown::Markdown;
#[must_use]
pub fn render_markdown(content: &str, ctx: &OutputContext) -> String {
match ctx.mode() {
OutputMode::Quiet => String::new(),
OutputMode::Json => content.to_string(),
OutputMode::Plain => strip_markdown(content),
OutputMode::Rich => render_rich_markdown(content, ctx.width()),
}
}
fn strip_markdown(content: &str) -> String {
let mut result = String::new();
let mut in_code_block = false;
for line in content.lines() {
let trimmed = line.trim();
if trimmed.starts_with("```") {
in_code_block = !in_code_block;
continue;
}
if in_code_block {
result.push_str(" ");
result.push_str(line);
result.push('\n');
continue;
}
if is_horizontal_rule(trimmed) {
result.push_str("---\n");
continue;
}
let processed = strip_line_markdown(line);
result.push_str(&processed);
result.push('\n');
}
result.trim_end().to_string()
}
fn is_horizontal_rule(trimmed: &str) -> bool {
let hr_stripped: String = trimmed.chars().filter(|c| !c.is_whitespace()).collect();
(hr_stripped.chars().all(|c| c == '-') || hr_stripped.chars().all(|c| c == '*'))
&& hr_stripped.len() >= 3
}
fn strip_line_markdown(line: &str) -> String {
let mut processed = String::new();
let chars: Vec<char> = line.chars().collect();
let mut i = 0;
let mut in_inline_code = false;
while i < chars.len() {
let c = chars[i];
if c == '`' {
in_inline_code = !in_inline_code;
i += 1;
continue;
}
if !in_inline_code && let Some(skip) = try_skip_formatting(&chars, i, &mut processed) {
i = skip;
continue;
}
processed.push(c);
i += 1;
}
processed
}
fn try_skip_formatting(chars: &[char], i: usize, processed: &mut String) -> Option<usize> {
let c = chars[i];
if c == '*' || c == '_' {
let is_double = i + 1 < chars.len() && chars[i + 1] == c;
if !is_double {
if c == '_' && i > 0 && i + 1 < chars.len() {
let prev = chars[i - 1];
let next = chars[i + 1];
if prev.is_alphanumeric() && next.is_alphanumeric() {
return None;
}
}
if c == '*' {
let prev_space = i == 0 || chars[i - 1].is_whitespace();
let next_space = i + 1 >= chars.len() || chars[i + 1].is_whitespace();
if prev_space && next_space {
return None;
}
if prev_space && !next_space {
let has_closing = chars[i + 1..].iter().enumerate().any(|(k, &ch)| {
ch == '*' && k > 0 && !chars[i + 1 + k - 1].is_whitespace()
});
if !has_closing {
return None;
}
}
}
}
let mut j = i;
while j < chars.len() && chars[j] == c {
j += 1;
}
return Some(j);
}
if c == '~' && i + 1 < chars.len() && chars[i + 1] == '~' {
return Some(i + 2);
}
if processed.is_empty() && c == '#' {
let mut j = i;
while j < chars.len() && chars[j] == '#' {
j += 1;
}
if j < chars.len() && chars[j] == ' ' {
j += 1;
}
return Some(j);
}
if c == '['
&& let Some(new_i) = try_extract_link(chars, i, processed)
{
return Some(new_i);
}
if c == '!'
&& i + 1 < chars.len()
&& chars[i + 1] == '['
&& let Some(new_i) = try_extract_image(chars, i, processed)
{
return Some(new_i);
}
if processed.is_empty() && c == '>' {
let mut j = i + 1;
if j < chars.len() && chars[j] == ' ' {
j += 1;
}
processed.push_str(" "); return Some(j);
}
None
}
fn try_extract_link(chars: &[char], i: usize, processed: &mut String) -> Option<usize> {
let start = i + 1;
let bracket_end = find_matching_bracket(chars, start)?;
let text: String = chars[start..bracket_end].iter().collect();
processed.push_str(&text);
let mut j = bracket_end + 1;
if j < chars.len() && chars[j] == '(' {
j = skip_parentheses(chars, j);
}
Some(j)
}
fn try_extract_image(chars: &[char], i: usize, processed: &mut String) -> Option<usize> {
let start = i + 2; let bracket_end = find_closing_bracket(chars, start)?;
let alt: String = chars[start..bracket_end].iter().collect();
if !alt.is_empty() {
processed.push_str("[Image: ");
processed.push_str(&alt);
processed.push(']');
}
let mut j = bracket_end + 1;
if j < chars.len() && chars[j] == '(' {
j = skip_parentheses(chars, j);
}
Some(j)
}
fn find_matching_bracket(chars: &[char], start: usize) -> Option<usize> {
let mut depth = 1;
for (offset, &ch) in chars[start..].iter().enumerate() {
match ch {
'[' => depth += 1,
']' => {
depth -= 1;
if depth == 0 {
return Some(start + offset);
}
}
_ => {}
}
}
None
}
fn find_closing_bracket(chars: &[char], start: usize) -> Option<usize> {
for (offset, &ch) in chars[start..].iter().enumerate() {
if ch == ']' {
return Some(start + offset);
}
}
None
}
fn skip_parentheses(chars: &[char], start: usize) -> usize {
let mut j = start + 1;
let mut paren_depth = 1;
while j < chars.len() && paren_depth > 0 {
if chars[j] == '(' {
paren_depth += 1;
} else if chars[j] == ')' {
paren_depth -= 1;
}
j += 1;
}
j
}
fn render_rich_markdown(content: &str, width: usize) -> String {
let md = Markdown::new(content).hyperlinks(true);
let segments = md.render(width);
let mut result = String::new();
for segment in segments {
if let Some(style) = &segment.style {
result.push_str(&style.render(&segment.text, ColorSystem::TrueColor));
} else {
result.push_str(&segment.text);
}
}
result
}
#[must_use]
pub fn contains_markdown(content: &str) -> bool {
let patterns = [
"**", "__", "*", "_", "~~", "`", "```", "[", "](", "# ", "##", "> ", "- ", "* ", "1.", "---", "***", ];
patterns.iter().any(|pattern| content.contains(pattern))
}
#[must_use]
pub fn escape_markdown(content: &str) -> String {
let mut result = String::with_capacity(content.len() * 2);
for c in content.chars() {
match c {
'\\' | '`' | '*' | '_' | '{' | '}' | '[' | ']' | '(' | ')' | '#' | '+' | '-' | '.'
| '!' | '|' | '~' | '>' => {
result.push('\\');
result.push(c);
}
_ => result.push(c),
}
}
result
}
#[cfg(test)]
mod tests {
use super::*;
fn plain_ctx() -> OutputContext {
OutputContext::with_mode(OutputMode::Plain)
}
fn json_ctx() -> OutputContext {
OutputContext::with_mode(OutputMode::Json)
}
fn quiet_ctx() -> OutputContext {
OutputContext::with_mode(OutputMode::Quiet)
}
fn rich_ctx() -> OutputContext {
OutputContext::with_mode(OutputMode::Rich)
}
#[test]
fn test_render_markdown_plain_strips_formatting() {
let content = "# Heading\n\nThis is **bold** and *italic*.";
let result = render_markdown(content, &plain_ctx());
assert!(result.contains("Heading"));
assert!(result.contains("bold"));
assert!(result.contains("italic"));
assert!(!result.contains("**"));
assert!(!result.contains('#'));
}
#[test]
fn test_render_markdown_json_unchanged() {
let content = "# Heading\n\n**bold** text";
let result = render_markdown(content, &json_ctx());
assert_eq!(result, content);
}
#[test]
fn test_render_markdown_quiet_empty() {
let content = "# Heading\n\nSome content";
let result = render_markdown(content, &quiet_ctx());
assert!(result.is_empty());
}
#[test]
fn test_render_markdown_rich_contains_content() {
let content = "# Heading\n\nThis is **bold** text.";
let result = render_markdown(content, &rich_ctx());
assert!(result.contains("Heading"));
assert!(result.contains("bold"));
assert!(result.contains("text"));
}
#[test]
fn test_strip_markdown_headers() {
assert!(strip_markdown("# H1").contains("H1"));
assert!(strip_markdown("## H2").contains("H2"));
assert!(strip_markdown("### H3").contains("H3"));
assert!(!strip_markdown("# H1").contains('#'));
}
#[test]
fn test_strip_markdown_emphasis() {
assert_eq!(strip_markdown("**bold**"), "bold");
assert_eq!(strip_markdown("*italic*"), "italic");
assert_eq!(strip_markdown("__bold__"), "bold");
assert_eq!(strip_markdown("_italic_"), "italic");
assert_eq!(strip_markdown("~~strikethrough~~"), "strikethrough");
}
#[test]
fn test_strip_markdown_preserves_snake_case() {
assert_eq!(strip_markdown("my_variable_name"), "my_variable_name");
assert_eq!(
strip_markdown("some_function(with_args)"),
"some_function(with_args)"
);
}
#[test]
fn test_strip_markdown_preserves_math_asterisks() {
assert_eq!(strip_markdown("2 * 3 = 6"), "2 * 3 = 6");
assert_eq!(strip_markdown("pointer *p"), "pointer *p");
}
#[test]
fn test_strip_markdown_links() {
assert_eq!(strip_markdown("[text](https://example.com)"), "text");
assert!(strip_markdown("[link text](url)").contains("link text"));
assert!(!strip_markdown("[link](url)").contains("url"));
}
#[test]
fn test_strip_markdown_code() {
let result = strip_markdown("`inline code`");
assert!(result.contains("inline code"));
assert!(!result.contains('`'));
}
#[test]
fn test_strip_markdown_code_blocks() {
let content = "```rust\nfn main() {}\n```";
let result = strip_markdown(content);
assert!(result.contains("fn main()"));
assert!(!result.contains("```"));
}
#[test]
fn test_strip_markdown_blockquotes() {
let result = strip_markdown("> quoted text");
assert!(result.contains("quoted text"));
assert!(result.starts_with(" "));
}
#[test]
fn test_strip_markdown_horizontal_rule() {
assert!(strip_markdown("---").contains("---"));
assert!(strip_markdown("***").contains("---"));
}
#[test]
fn test_contains_markdown_detection() {
assert!(contains_markdown("**bold**"));
assert!(contains_markdown("*italic*"));
assert!(contains_markdown("[link](url)"));
assert!(contains_markdown("# Header"));
assert!(contains_markdown("```code```"));
assert!(!contains_markdown("plain text without formatting"));
}
#[test]
fn test_escape_markdown() {
assert_eq!(escape_markdown("**bold**"), "\\*\\*bold\\*\\*");
assert_eq!(escape_markdown("[link]"), "\\[link\\]");
assert_eq!(escape_markdown("# header"), "\\# header");
assert_eq!(escape_markdown("plain text"), "plain text");
}
#[test]
fn test_strip_markdown_images() {
let result = strip_markdown("");
assert!(result.contains("[Image: alt text]"));
assert!(!result.contains("image.png"));
}
#[test]
fn test_strip_markdown_nested_formatting() {
let content = "**bold with *italic* inside**";
let result = strip_markdown(content);
assert!(result.contains("bold with"));
assert!(result.contains("italic"));
assert!(result.contains("inside"));
}
#[test]
fn test_strip_markdown_empty() {
assert!(strip_markdown("").is_empty());
}
#[test]
fn test_render_markdown_multiline() {
let content = "# Title\n\nParagraph one.\n\nParagraph two.";
let result = render_markdown(content, &plain_ctx());
assert!(result.contains("Title"));
assert!(result.contains("Paragraph one"));
assert!(result.contains("Paragraph two"));
}
}