use crate::utils::decode_bin; use ansi_colours::AsRGB; use anyhow::{Context, Result, anyhow}; use crossterm::style::{Color, Stylize}; use crossterm::terminal; use std::collections::HashMap; use std::sync::LazyLock; use syntect::highlighting::{Color as SyntectColor, FontStyle, Style, Theme}; use syntect::parsing::SyntaxSet; use syntect::{easy::HighlightLines, parsing::SyntaxReference}; /// Comes from const SYNTAXES: &[u8] = include_bytes!("../../assets/syntaxes.bin"); static LANG_MAPS: LazyLock> = LazyLock::new(|| { let mut m = HashMap::new(); m.insert("csharp".into(), "C#".into()); m.insert("php".into(), "PHP Source".into()); m }); pub struct MarkdownRender { options: RenderOptions, syntax_set: SyntaxSet, code_color: Option, md_syntax: SyntaxReference, code_syntax: Option, prev_line_type: LineType, wrap_width: Option, } impl MarkdownRender { pub fn init(options: RenderOptions) -> Result { let syntax_set: SyntaxSet = decode_bin(SYNTAXES).with_context(|| "MarkdownRender: invalid syntaxes binary")?; let code_color = options .theme .as_ref() .map(|theme| get_code_color(theme, options.truecolor)); let md_syntax = syntax_set.find_syntax_by_extension("md").unwrap().clone(); let line_type = LineType::Normal; let wrap_width = match options.wrap.as_deref() { None => None, Some(value) => match terminal::size() { Ok((columns, _)) => { if value == "auto" { Some(columns) } else { let value = value .parse::() .map_err(|_| anyhow!("Invalid wrap value"))?; Some(columns.min(value)) } } Err(_) => None, }, }; Ok(Self { syntax_set, code_color, md_syntax, code_syntax: None, prev_line_type: line_type, wrap_width, options, }) } pub fn render(&mut self, text: &str) -> String { text.split('\n') .map(|line| self.render_line_mut(line)) .collect::>() .join("\n") } pub fn render_line(&self, line: &str) -> String { let (_, code_syntax, is_code) = self.check_line(line); if is_code { self.highlight_code_line(line, &code_syntax) } else { self.highlight_line(line, &self.md_syntax, false) } } fn render_line_mut(&mut self, line: &str) -> String { let (line_type, code_syntax, is_code) = self.check_line(line); let output = if is_code { self.highlight_code_line(line, &code_syntax) } else { self.highlight_line(line, &self.md_syntax, false) }; self.prev_line_type = line_type; self.code_syntax = code_syntax; output } fn check_line(&self, line: &str) -> (LineType, Option, bool) { let mut line_type = self.prev_line_type; let mut code_syntax = self.code_syntax.clone(); let mut is_code = false; if let Some(lang) = detect_code_block(line) { match line_type { LineType::Normal | LineType::CodeEnd => { line_type = LineType::CodeBegin; code_syntax = if lang.is_empty() { None } else { self.find_syntax(&lang).cloned() }; } LineType::CodeBegin | LineType::CodeInner => { line_type = LineType::CodeEnd; code_syntax = None; } } } else { match line_type { LineType::Normal => {} LineType::CodeEnd => { line_type = LineType::Normal; } LineType::CodeBegin => { if code_syntax.is_none() && let Some(syntax) = self.syntax_set.find_syntax_by_first_line(line) { code_syntax = Some(syntax.clone()); } line_type = LineType::CodeInner; is_code = true; } LineType::CodeInner => { is_code = true; } } } (line_type, code_syntax, is_code) } fn highlight_line(&self, line: &str, syntax: &SyntaxReference, is_code: bool) -> String { let ws: String = line.chars().take_while(|c| c.is_whitespace()).collect(); let trimmed_line: &str = &line[ws.len()..]; let mut line_highlighted = None; if let Some(theme) = &self.options.theme { let mut highlighter = HighlightLines::new(syntax, theme); if let Ok(ranges) = highlighter.highlight_line(trimmed_line, &self.syntax_set) { line_highlighted = Some(format!( "{ws}{}", as_terminal_escaped(&ranges, self.options.truecolor) )) } } let line = line_highlighted.unwrap_or_else(|| line.into()); self.wrap_line(line, is_code) } fn highlight_code_line(&self, line: &str, code_syntax: &Option) -> String { if let Some(syntax) = code_syntax { self.highlight_line(line, syntax, true) } else { let line = match self.code_color { Some(color) => line.with(color).to_string(), None => line.to_string(), }; self.wrap_line(line, true) } } fn wrap_line(&self, line: String, is_code: bool) -> String { if let Some(width) = self.wrap_width { if is_code && !self.options.wrap_code { return line; } wrap(&line, width as usize) } else { line } } fn find_syntax(&self, lang: &str) -> Option<&SyntaxReference> { if let Some(new_lang) = LANG_MAPS.get(&lang.to_ascii_lowercase()) { self.syntax_set.find_syntax_by_name(new_lang) } else { self.syntax_set .find_syntax_by_token(lang) .or_else(|| self.syntax_set.find_syntax_by_extension(lang)) } } } fn wrap(text: &str, width: usize) -> String { let indent: usize = text.chars().take_while(|c| *c == ' ').count(); let wrap_options = textwrap::Options::new(width) .wrap_algorithm(textwrap::WrapAlgorithm::FirstFit) .initial_indent(&text[0..indent]); textwrap::wrap(&text[indent..], wrap_options).join("\n") } #[derive(Debug, Clone, Default)] pub struct RenderOptions { pub theme: Option, pub wrap: Option, pub wrap_code: bool, pub truecolor: bool, } impl RenderOptions { pub(crate) fn new( theme: Option, wrap: Option, wrap_code: bool, truecolor: bool, ) -> Self { Self { theme, wrap, wrap_code, truecolor, } } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum LineType { Normal, CodeBegin, CodeInner, CodeEnd, } fn as_terminal_escaped(ranges: &[(Style, &str)], truecolor: bool) -> String { let mut output = String::new(); for (style, text) in ranges { let fg = blend_fg_color(style.foreground, style.background); let mut text = text.with(convert_color(fg, truecolor)); if style.font_style.contains(FontStyle::BOLD) { text = text.bold(); } if style.font_style.contains(FontStyle::UNDERLINE) { text = text.underlined(); } output.push_str(&text.to_string()); } output } fn convert_color(c: SyntectColor, truecolor: bool) -> Color { if truecolor { Color::Rgb { r: c.r, g: c.g, b: c.b, } } else { let value = (c.r, c.g, c.b).to_ansi256(); // lower contrast let value = match value { 7 | 15 | 231 | 252..=255 => 252, _ => value, }; Color::AnsiValue(value) } } fn blend_fg_color(fg: SyntectColor, bg: SyntectColor) -> SyntectColor { if fg.a == 0xff { return fg; } let ratio = u32::from(fg.a); let r = (u32::from(fg.r) * ratio + u32::from(bg.r) * (255 - ratio)) / 255; let g = (u32::from(fg.g) * ratio + u32::from(bg.g) * (255 - ratio)) / 255; let b = (u32::from(fg.b) * ratio + u32::from(bg.b) * (255 - ratio)) / 255; SyntectColor { r: u8::try_from(r).unwrap_or(u8::MAX), g: u8::try_from(g).unwrap_or(u8::MAX), b: u8::try_from(b).unwrap_or(u8::MAX), a: 255, } } fn detect_code_block(line: &str) -> Option { let line = line.trim_start(); if !line.starts_with("```") { return None; } let lang = line .chars() .skip(3) .take_while(|v| !v.is_whitespace()) .collect(); Some(lang) } fn get_code_color(theme: &Theme, truecolor: bool) -> Color { let scope = theme.scopes.iter().find(|v| { v.scope .selectors .iter() .any(|v| v.path.scopes.iter().any(|v| v.to_string() == "string")) }); scope .and_then(|v| v.style.foreground) .map_or_else(|| Color::Yellow, |c| convert_color(c, truecolor)) } #[cfg(test)] mod tests { use super::*; const TEXT: &str = r#" To unzip a file in Rust, you can use the `zip` crate. Here's an example code that shows how to unzip a file: ```rust use std::fs::File; fn unzip_file(path: &str, output_dir: &str) -> Result<(), Box> { todo!() } ``` "#; const TEXT_NO_WRAP_CODE: &str = r#" To unzip a file in Rust, you can use the `zip` crate. Here's an example code that shows how to unzip a file: ```rust use std::fs::File; fn unzip_file(path: &str, output_dir: &str) -> Result<(), Box> { todo!() } ``` "#; const TEXT_WRAP_ALL: &str = r#" To unzip a file in Rust, you can use the `zip` crate. Here's an example code that shows how to unzip a file: ```rust use std::fs::File; fn unzip_file(path: &str, output_dir: &str) -> Result<(), Box> { todo!() } ``` "#; #[test] fn test_render() { let options = RenderOptions::default(); let render = MarkdownRender::init(options).unwrap(); assert!(render.find_syntax("csharp").is_some()); } #[test] fn no_theme() { let options = RenderOptions::default(); let mut render = MarkdownRender::init(options).unwrap(); let output = render.render(TEXT); assert_eq!(TEXT, output); } #[test] fn no_wrap_code() { let options = RenderOptions::default(); let mut render = MarkdownRender::init(options).unwrap(); render.wrap_width = Some(80); let output = render.render(TEXT); assert_eq!(TEXT_NO_WRAP_CODE, output); } #[test] fn wrap_all() { let options = RenderOptions { wrap_code: true, ..Default::default() }; let mut render = MarkdownRender::init(options).unwrap(); render.wrap_width = Some(80); let output = render.render(TEXT); assert_eq!(TEXT_WRAP_ALL, output); } #[test] fn test_detect_code_block() { assert_eq!(detect_code_block("```rust"), Some("rust".into())); assert_eq!(detect_code_block("```c++"), Some("c++".into())); assert_eq!(detect_code_block(" ```rust"), Some("rust".into())); assert_eq!(detect_code_block("```"), Some("".into())); assert_eq!(detect_code_block("``rust"), None); } }