File size: 5,473 Bytes
e5034c3 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 | use std::sync::OnceLock;
use derive_setters::Setters;
use regex::Regex;
use termimad::crossterm::style::{Attribute, Color};
use termimad::{CompoundStyle, LineStyle, MadSkin};
use crate::code::{CodeBlockParser, SyntaxHighlighter};
/// MarkdownFormat provides functionality for formatting markdown text for
/// terminal display.
#[derive(Clone, Setters)]
#[setters(into, strip_option)]
pub struct MarkdownFormat {
skin: MadSkin,
max_consecutive_newlines: usize,
#[setters(skip)]
highlighter: OnceLock<SyntaxHighlighter>,
}
impl Default for MarkdownFormat {
fn default() -> Self {
Self::new()
}
}
impl MarkdownFormat {
/// Create a new MarkdownFormat with the default skin
pub fn new() -> Self {
let mut skin = MadSkin::default();
let compound_style = CompoundStyle::new(Some(Color::Cyan), None, Default::default());
skin.inline_code = compound_style.clone();
let codeblock_style = CompoundStyle::new(None, None, Default::default());
skin.code_block = LineStyle::new(codeblock_style, Default::default());
let mut strikethrough_style = CompoundStyle::with_attr(Attribute::CrossedOut);
strikethrough_style.add_attr(Attribute::Dim);
skin.strikeout = strikethrough_style;
Self {
skin,
max_consecutive_newlines: 2,
highlighter: OnceLock::new(),
}
}
/// Render the markdown content to a string formatted for terminal display.
pub fn render(&self, content: impl Into<String>) -> String {
let content = self.strip_excessive_newlines(content.into().trim());
if content.is_empty() {
return String::new();
}
// Extract code blocks
let processed = CodeBlockParser::new(&content);
// Render with termimad, then restore highlighted code
let rendered = self.skin.term_text(processed.markdown()).to_string();
let highlighter = self.highlighter.get_or_init(SyntaxHighlighter::default);
processed.restore(highlighter, rendered).trim().to_string()
}
fn strip_excessive_newlines(&self, content: &str) -> String {
if content.is_empty() {
return String::new();
}
Regex::new(&format!(r"\n{{{},}}", self.max_consecutive_newlines + 1))
.unwrap()
.replace_all(content, "\n".repeat(self.max_consecutive_newlines))
.into()
}
}
#[cfg(test)]
mod tests {
use pretty_assertions::assert_eq;
use super::*;
#[test]
fn test_render_simple_markdown() {
let fixture = "# Test Heading\nThis is a test.";
let markdown = MarkdownFormat::new();
let actual = markdown.render(fixture);
// Basic verification that output is non-empty
assert!(!actual.is_empty());
}
#[test]
fn test_render_empty_markdown() {
let fixture = "";
let markdown = MarkdownFormat::new();
let actual = markdown.render(fixture);
// Verify empty input produces empty output
assert!(actual.is_empty());
}
#[test]
fn test_strip_excessive_newlines_default() {
let fixture = "Line 1\n\n\n\nLine 2";
let formatter = MarkdownFormat::new();
let actual = formatter.strip_excessive_newlines(fixture);
let expected = "Line 1\n\nLine 2";
assert_eq!(actual, expected);
}
#[test]
fn test_strip_excessive_newlines_custom() {
let fixture = "Line 1\n\n\n\nLine 2";
let formatter = MarkdownFormat::new().max_consecutive_newlines(3_usize);
let actual = formatter.strip_excessive_newlines(fixture);
let expected = "Line 1\n\n\nLine 2";
assert_eq!(actual, expected);
}
#[test]
fn test_render_with_excessive_newlines() {
let fixture = "# Heading\n\n\n\nParagraph";
let markdown = MarkdownFormat::new();
// Use the default max_consecutive_newlines (2)
let actual = markdown.render(fixture);
// Compare with expected content containing only 2 newlines
let expected = markdown.render("# Heading\n\nParagraph");
// Strip any ANSI codes and whitespace for comparison
let actual_clean = strip_ansi_escapes::strip_str(&actual).trim().to_string();
let expected_clean = strip_ansi_escapes::strip_str(&expected).trim().to_string();
assert_eq!(actual_clean, expected_clean);
}
#[test]
fn test_render_with_custom_max_newlines() {
let fixture = "# Heading\n\n\n\nParagraph";
let markdown = MarkdownFormat::new().max_consecutive_newlines(1_usize);
// Use a custom max_consecutive_newlines (1)
let actual = markdown.render(fixture);
// Compare with expected content containing only 1 newline
let expected = markdown.render("# Heading\nParagraph");
// Strip any ANSI codes and whitespace for comparison
let actual_clean = strip_ansi_escapes::strip_str(&actual).trim().to_string();
let expected_clean = strip_ansi_escapes::strip_str(&expected).trim().to_string();
assert_eq!(actual_clean, expected_clean);
}
#[test]
fn test_highlight_code_block() {
let md = MarkdownFormat::new();
let actual = md.render("```rust\nfn main() {}\n```");
assert!(actual.contains("\x1b[")); // Contains ANSI escape codes
assert!(strip_ansi_escapes::strip_str(&actual).contains("fn main()"));
}
}
|