1537 lines
53 KiB
Rust
1537 lines
53 KiB
Rust
use crate::utils::decode_bin;
|
|
|
|
use ansi_colours::AsRGB;
|
|
use anyhow::{Context, Result, anyhow};
|
|
use comfy_table::CellAlignment;
|
|
use crossterm::style::{Color, Stylize};
|
|
use crossterm::terminal;
|
|
use fancy_regex::Regex;
|
|
use std::collections::HashMap;
|
|
use std::sync::LazyLock;
|
|
use syntect::highlighting::{Color as SyntectColor, FontStyle, Style, Theme, ThemeItem};
|
|
use syntect::parsing::SyntaxSet;
|
|
use syntect::{easy::HighlightLines, parsing::SyntaxReference};
|
|
|
|
/// Comes from <https://github.com/sharkdp/bat/raw/5e77ca37e89c873e4490b42ff556370dc5c6ba4f/assets/syntaxes.bin>
|
|
const SYNTAXES: &[u8] = include_bytes!("../../assets/syntaxes.bin");
|
|
|
|
static LANG_MAPS: LazyLock<HashMap<String, String>> = LazyLock::new(|| {
|
|
let mut m = HashMap::new();
|
|
m.insert("csharp".into(), "C#".into());
|
|
m.insert("php".into(), "PHP Source".into());
|
|
m
|
|
});
|
|
|
|
static HEADING_RE: LazyLock<Regex> =
|
|
LazyLock::new(|| Regex::new(r"^\s*(#{1,6}) +.+").unwrap());
|
|
static BLOCKQUOTE_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^\s*>").unwrap());
|
|
static TASK_ITEM_RE: LazyLock<Regex> =
|
|
LazyLock::new(|| Regex::new(r"^\s*[-*+] \[([ xX])\] +.+").unwrap());
|
|
static BULLET_ITEM_RE: LazyLock<Regex> =
|
|
LazyLock::new(|| Regex::new(r"^\s*[-*+] +.+").unwrap());
|
|
static NUMBERED_ITEM_RE: LazyLock<Regex> =
|
|
LazyLock::new(|| Regex::new(r"^\s*\d+\. +.+").unwrap());
|
|
static HRULE_RE: LazyLock<Regex> =
|
|
LazyLock::new(|| Regex::new(r"^\s*(-{3,}|_{3,}|\*{3,})\s*$").unwrap());
|
|
static TABLE_SEPARATOR_RE: LazyLock<Regex> =
|
|
LazyLock::new(|| Regex::new(r"^\s*\|(\s*:?-+:?\s*\|)+\s*$").unwrap());
|
|
static TABLE_ROW_RE: LazyLock<Regex> =
|
|
LazyLock::new(|| Regex::new(r"^\s*\|.*\|\s*$").unwrap());
|
|
|
|
static INLINE_CODE_RE: LazyLock<Regex> =
|
|
LazyLock::new(|| Regex::new(r"`([^`\n]+)`").unwrap());
|
|
static IMAGE_RE: LazyLock<Regex> =
|
|
LazyLock::new(|| Regex::new(r"!\[([^\]]*)\]\(([^)]+)\)").unwrap());
|
|
static LINK_RE: LazyLock<Regex> =
|
|
LazyLock::new(|| Regex::new(r"\[([^\]]+)\]\(([^)]+)\)").unwrap());
|
|
static BOLD_AST_RE: LazyLock<Regex> =
|
|
LazyLock::new(|| Regex::new(r"\*\*([^*\n]+)\*\*").unwrap());
|
|
static BOLD_US_RE: LazyLock<Regex> =
|
|
LazyLock::new(|| Regex::new(r"__([^_\n]+)__").unwrap());
|
|
static ITALIC_AST_RE: LazyLock<Regex> = LazyLock::new(|| {
|
|
Regex::new(r"(?<![*\w])\*(?!\s)([^*\n]+?)(?<!\s)\*(?!\*)").unwrap()
|
|
});
|
|
static ITALIC_US_RE: LazyLock<Regex> = LazyLock::new(|| {
|
|
Regex::new(r"(?<![_\w])_(?!\s)([^_\n]+?)(?<!\s)_(?!_)").unwrap()
|
|
});
|
|
static STRIKETHROUGH_RE: LazyLock<Regex> =
|
|
LazyLock::new(|| Regex::new(r"~~([^~\n]+)~~").unwrap());
|
|
static CODE_PLACEHOLDER_RE: LazyLock<Regex> =
|
|
LazyLock::new(|| Regex::new(r"\x00C(\d+)\x00").unwrap());
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum LineKind {
|
|
Heading(u8),
|
|
Blockquote,
|
|
TaskItem(bool),
|
|
BulletItem,
|
|
NumberedItem,
|
|
HorizontalRule,
|
|
TableRow,
|
|
TableSeparator,
|
|
Paragraph,
|
|
}
|
|
|
|
fn detect_line_kind(line: &str) -> LineKind {
|
|
if HRULE_RE.is_match(line).unwrap_or(false) {
|
|
return LineKind::HorizontalRule;
|
|
}
|
|
if let Ok(Some(caps)) = HEADING_RE.captures(line) {
|
|
let level = caps
|
|
.get(1)
|
|
.map(|m| m.as_str().len() as u8)
|
|
.unwrap_or(1)
|
|
.min(6);
|
|
return LineKind::Heading(level);
|
|
}
|
|
if BLOCKQUOTE_RE.is_match(line).unwrap_or(false) {
|
|
return LineKind::Blockquote;
|
|
}
|
|
if let Ok(Some(caps)) = TASK_ITEM_RE.captures(line) {
|
|
let checked = caps.get(1).map(|m| m.as_str() != " ").unwrap_or(false);
|
|
return LineKind::TaskItem(checked);
|
|
}
|
|
if BULLET_ITEM_RE.is_match(line).unwrap_or(false) {
|
|
return LineKind::BulletItem;
|
|
}
|
|
if NUMBERED_ITEM_RE.is_match(line).unwrap_or(false) {
|
|
return LineKind::NumberedItem;
|
|
}
|
|
if TABLE_SEPARATOR_RE.is_match(line).unwrap_or(false) {
|
|
return LineKind::TableSeparator;
|
|
}
|
|
if TABLE_ROW_RE.is_match(line).unwrap_or(false) {
|
|
return LineKind::TableRow;
|
|
}
|
|
LineKind::Paragraph
|
|
}
|
|
|
|
#[allow(dead_code)]
|
|
fn parse_table_row(line: &str) -> Vec<String> {
|
|
let inner = line
|
|
.trim()
|
|
.trim_start_matches('|')
|
|
.trim_end_matches('|');
|
|
inner.split('|').map(|c| c.trim().to_string()).collect()
|
|
}
|
|
|
|
#[allow(dead_code)]
|
|
fn parse_alignments(separator_row: &str) -> Vec<CellAlignment> {
|
|
parse_table_row(separator_row)
|
|
.iter()
|
|
.map(|c| {
|
|
let trimmed = c.trim();
|
|
let starts = trimmed.starts_with(':');
|
|
let ends = trimmed.ends_with(':');
|
|
match (starts, ends) {
|
|
(true, true) => CellAlignment::Center,
|
|
(false, true) => CellAlignment::Right,
|
|
_ => CellAlignment::Left,
|
|
}
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn regex_replace<F>(text: &str, re: &Regex, mut f: F) -> String
|
|
where
|
|
F: FnMut(&fancy_regex::Captures) -> String,
|
|
{
|
|
let mut out = String::new();
|
|
let mut last_end = 0;
|
|
for caps_result in re.captures_iter(text) {
|
|
let Ok(caps) = caps_result else { continue };
|
|
let Some(whole) = caps.get(0) else { continue };
|
|
out.push_str(&text[last_end..whole.start()]);
|
|
out.push_str(&f(&caps));
|
|
last_end = whole.end();
|
|
}
|
|
out.push_str(&text[last_end..]);
|
|
out
|
|
}
|
|
|
|
fn wrap_osc8(url: &str, visible: &str) -> String {
|
|
format!("\x1b]8;;{url}\x1b\\{visible}\x1b]8;;\x1b\\")
|
|
}
|
|
|
|
fn style_inline_code(content: &str, styles: &MarkdownStyles) -> String {
|
|
let styled = content.with(styles.inline_code_fg);
|
|
match styles.inline_code_bg {
|
|
Some(bg) => styled.on(bg).to_string(),
|
|
None => styled.to_string(),
|
|
}
|
|
}
|
|
|
|
fn render_markdown_line(line: &str, kind: LineKind, styles: &MarkdownStyles) -> String {
|
|
match kind {
|
|
LineKind::Heading(level) => render_heading(line, level, styles),
|
|
LineKind::Blockquote => render_blockquote(line, styles),
|
|
LineKind::BulletItem => render_bullet(line, styles),
|
|
LineKind::NumberedItem => render_numbered(line, styles),
|
|
LineKind::TaskItem(checked) => render_task(line, checked, styles),
|
|
LineKind::HorizontalRule => render_hrule(styles),
|
|
LineKind::TableRow | LineKind::TableSeparator => apply_inline(line, styles),
|
|
LineKind::Paragraph => apply_inline(line, styles),
|
|
}
|
|
}
|
|
|
|
fn split_indent(line: &str) -> (&str, &str) {
|
|
let indent_len = line
|
|
.char_indices()
|
|
.find(|(_, c)| !c.is_whitespace())
|
|
.map(|(i, _)| i)
|
|
.unwrap_or(line.len());
|
|
line.split_at(indent_len)
|
|
}
|
|
|
|
fn render_heading(line: &str, level: u8, styles: &MarkdownStyles) -> String {
|
|
let (indent, rest) = split_indent(line);
|
|
let content = rest.trim_start_matches('#').trim_start();
|
|
let inline = apply_inline(content, styles);
|
|
let (color, _force_bold) = styles.heading;
|
|
let body = if level == 1 {
|
|
format!(" {inline} ")
|
|
} else {
|
|
format!("{} {inline}", "#".repeat(level as usize))
|
|
};
|
|
format!("{indent}{}", body.with(color).bold())
|
|
}
|
|
|
|
fn render_blockquote(line: &str, styles: &MarkdownStyles) -> String {
|
|
let (indent, rest) = split_indent(line);
|
|
let content = rest.trim_start_matches('>').trim_start();
|
|
let prefix = "│ ".with(styles.blockquote).to_string();
|
|
let styled_content = apply_inline(content, styles)
|
|
.with(styles.blockquote)
|
|
.to_string();
|
|
format!("{indent}{prefix}{styled_content}")
|
|
}
|
|
|
|
fn render_bullet(line: &str, styles: &MarkdownStyles) -> String {
|
|
let (indent, rest) = split_indent(line);
|
|
let content = rest.get(2..).unwrap_or("");
|
|
let bullet = "•".with(styles.list_bullet).to_string();
|
|
let styled_content = apply_inline(content, styles);
|
|
format!("{indent}{bullet} {styled_content}")
|
|
}
|
|
|
|
fn render_numbered(line: &str, styles: &MarkdownStyles) -> String {
|
|
let (indent, rest) = split_indent(line);
|
|
match rest.find('.') {
|
|
Some(dot_pos) => {
|
|
let number = &rest[..dot_pos];
|
|
let after = rest[dot_pos + 1..].trim_start();
|
|
let styled_dot = ".".with(styles.list_bullet).to_string();
|
|
let content = apply_inline(after, styles);
|
|
format!("{indent}{number}{styled_dot} {content}")
|
|
}
|
|
None => format!("{indent}{}", apply_inline(rest, styles)),
|
|
}
|
|
}
|
|
|
|
fn render_task(line: &str, checked: bool, styles: &MarkdownStyles) -> String {
|
|
let (indent, rest) = split_indent(line);
|
|
let after_bullet = rest.get(2..).unwrap_or("");
|
|
let after_brackets = after_bullet.get(3..).map(str::trim_start).unwrap_or("");
|
|
let glyph = if checked { "[✓]" } else { "[ ]" };
|
|
let styled_brackets = glyph.with(styles.list_bullet).to_string();
|
|
let styled_content = apply_inline(after_brackets, styles);
|
|
format!("{indent}{styled_brackets} {styled_content}")
|
|
}
|
|
|
|
fn render_hrule(styles: &MarkdownStyles) -> String {
|
|
"────────".with(styles.hrule).to_string()
|
|
}
|
|
|
|
fn apply_inline(text: &str, styles: &MarkdownStyles) -> String {
|
|
let mut code_bank: Vec<String> = Vec::new();
|
|
let masked = regex_replace(text, &INLINE_CODE_RE, |caps| {
|
|
let content = caps.get(1).map(|m| m.as_str()).unwrap_or("");
|
|
let idx = code_bank.len();
|
|
code_bank.push(style_inline_code(content, styles));
|
|
format!("\x00C{idx}\x00")
|
|
});
|
|
|
|
let with_images = regex_replace(&masked, &IMAGE_RE, |caps| {
|
|
let alt = caps.get(1).map(|m| m.as_str()).unwrap_or("");
|
|
let url = caps.get(2).map(|m| m.as_str()).unwrap_or("");
|
|
let visible = format!("Image: {alt} → {url}")
|
|
.with(styles.link_url)
|
|
.to_string();
|
|
wrap_osc8(url, &visible)
|
|
});
|
|
|
|
let with_links = regex_replace(&with_images, &LINK_RE, |caps| {
|
|
let label = caps.get(1).map(|m| m.as_str()).unwrap_or("");
|
|
let url = caps.get(2).map(|m| m.as_str()).unwrap_or("");
|
|
let styled_label = label.with(styles.link_text).to_string();
|
|
let styled_url = url.with(styles.link_url).to_string();
|
|
wrap_osc8(url, &format!("{styled_label} {styled_url}"))
|
|
});
|
|
|
|
let with_bold = regex_replace(&with_links, &BOLD_AST_RE, |caps| {
|
|
let content = caps.get(1).map(|m| m.as_str()).unwrap_or("");
|
|
content.with(styles.bold).bold().to_string()
|
|
});
|
|
let with_bold = regex_replace(&with_bold, &BOLD_US_RE, |caps| {
|
|
let content = caps.get(1).map(|m| m.as_str()).unwrap_or("");
|
|
content.with(styles.bold).bold().to_string()
|
|
});
|
|
|
|
let with_italic = regex_replace(&with_bold, &ITALIC_AST_RE, |caps| {
|
|
let content = caps.get(1).map(|m| m.as_str()).unwrap_or("");
|
|
content.with(styles.italic).italic().to_string()
|
|
});
|
|
let with_italic = regex_replace(&with_italic, &ITALIC_US_RE, |caps| {
|
|
let content = caps.get(1).map(|m| m.as_str()).unwrap_or("");
|
|
content.with(styles.italic).italic().to_string()
|
|
});
|
|
|
|
let with_strike = regex_replace(&with_italic, &STRIKETHROUGH_RE, |caps| {
|
|
let content = caps.get(1).map(|m| m.as_str()).unwrap_or("");
|
|
content.with(styles.strikethrough).crossed_out().to_string()
|
|
});
|
|
|
|
regex_replace(&with_strike, &CODE_PLACEHOLDER_RE, |caps| {
|
|
let idx: usize = caps
|
|
.get(1)
|
|
.and_then(|m| m.as_str().parse().ok())
|
|
.unwrap_or(0);
|
|
code_bank
|
|
.get(idx)
|
|
.cloned()
|
|
.unwrap_or_else(|| caps.get(0).unwrap().as_str().to_string())
|
|
})
|
|
}
|
|
|
|
pub struct MarkdownRender {
|
|
options: RenderOptions,
|
|
syntax_set: SyntaxSet,
|
|
code_color: Option<Color>,
|
|
md_syntax: SyntaxReference,
|
|
code_syntax: Option<SyntaxReference>,
|
|
prev_line_type: LineType,
|
|
wrap_width: Option<u16>,
|
|
styles: MarkdownStyles,
|
|
}
|
|
|
|
impl MarkdownRender {
|
|
pub fn init(options: RenderOptions) -> Result<Self> {
|
|
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::<u16>()
|
|
.map_err(|_| anyhow!("Invalid wrap value"))?;
|
|
Some(columns.min(value))
|
|
}
|
|
}
|
|
Err(_) => None,
|
|
},
|
|
};
|
|
let styles = MarkdownStyles::from_theme(options.theme.as_ref(), options.truecolor);
|
|
Ok(Self {
|
|
syntax_set,
|
|
code_color,
|
|
md_syntax,
|
|
code_syntax: None,
|
|
prev_line_type: line_type,
|
|
wrap_width,
|
|
styles,
|
|
options,
|
|
})
|
|
}
|
|
|
|
pub fn render(&mut self, text: &str) -> String {
|
|
text.split('\n')
|
|
.map(|line| self.render_line_mut(line))
|
|
.collect::<Vec<String>>()
|
|
.join("\n")
|
|
}
|
|
|
|
pub fn render_line(&self, line: &str) -> String {
|
|
let (_, line_kind, code_syntax, is_code) = self.check_line(line);
|
|
if is_code {
|
|
self.highlight_code_line(line, &code_syntax)
|
|
} else if self.options.raw_markdown {
|
|
self.highlight_line(line, &self.md_syntax, false)
|
|
} else {
|
|
self.render_rich_markdown_line(line, line_kind)
|
|
}
|
|
}
|
|
|
|
fn render_line_mut(&mut self, line: &str) -> String {
|
|
let (line_type, line_kind, code_syntax, is_code) = self.check_line(line);
|
|
let output = if is_code {
|
|
self.highlight_code_line(line, &code_syntax)
|
|
} else if self.options.raw_markdown {
|
|
self.highlight_line(line, &self.md_syntax, false)
|
|
} else {
|
|
self.render_rich_markdown_line(line, line_kind)
|
|
};
|
|
self.prev_line_type = line_type;
|
|
self.code_syntax = code_syntax;
|
|
output
|
|
}
|
|
|
|
fn render_rich_markdown_line(&self, line: &str, kind: LineKind) -> String {
|
|
let styled = render_markdown_line(line, kind, &self.styles);
|
|
self.wrap_line(styled, false)
|
|
}
|
|
|
|
fn check_line(
|
|
&self,
|
|
line: &str,
|
|
) -> (LineType, LineKind, Option<SyntaxReference>, 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;
|
|
}
|
|
}
|
|
}
|
|
let line_kind = if is_code {
|
|
LineKind::Paragraph
|
|
} else {
|
|
detect_line_kind(line)
|
|
};
|
|
(line_type, line_kind, 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<SyntaxReference>) -> 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<Theme>,
|
|
pub wrap: Option<String>,
|
|
pub wrap_code: bool,
|
|
pub raw_markdown: bool,
|
|
pub truecolor: bool,
|
|
}
|
|
|
|
impl RenderOptions {
|
|
pub(crate) fn new(
|
|
theme: Option<Theme>,
|
|
wrap: Option<String>,
|
|
wrap_code: bool,
|
|
raw_markdown: bool,
|
|
truecolor: bool,
|
|
) -> Self {
|
|
Self {
|
|
theme,
|
|
wrap,
|
|
wrap_code,
|
|
raw_markdown,
|
|
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<String> {
|
|
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))
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, Default)]
|
|
struct ResolvedStyle {
|
|
fg: Option<Color>,
|
|
bg: Option<Color>,
|
|
font_style: FontStyle,
|
|
}
|
|
|
|
fn find_theme_scope<'a>(theme: &'a Theme, name: &str) -> Option<&'a ThemeItem> {
|
|
theme.scopes.iter().find(|item| {
|
|
item.scope
|
|
.selectors
|
|
.iter()
|
|
.any(|sel| sel.path.scopes.iter().any(|s| s.to_string() == name))
|
|
})
|
|
}
|
|
|
|
fn resolve_scope_style(
|
|
theme: &Theme,
|
|
primary: &str,
|
|
fallbacks: &[&str],
|
|
truecolor: bool,
|
|
) -> ResolvedStyle {
|
|
for scope_name in std::iter::once(primary).chain(fallbacks.iter().copied()) {
|
|
let Some(item) = find_theme_scope(theme, scope_name) else {
|
|
continue;
|
|
};
|
|
let resolved = ResolvedStyle {
|
|
fg: item.style.foreground.map(|c| convert_color(c, truecolor)),
|
|
bg: item.style.background.map(|c| convert_color(c, truecolor)),
|
|
font_style: item.style.font_style.unwrap_or_default(),
|
|
};
|
|
if resolved.fg.is_some() || resolved.bg.is_some() || !resolved.font_style.is_empty() {
|
|
return resolved;
|
|
}
|
|
}
|
|
ResolvedStyle::default()
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct MarkdownStyles {
|
|
heading: (Color, bool),
|
|
bold: Color,
|
|
italic: Color,
|
|
inline_code_fg: Color,
|
|
inline_code_bg: Option<Color>,
|
|
blockquote: Color,
|
|
list_bullet: Color,
|
|
link_text: Color,
|
|
link_url: Color,
|
|
strikethrough: Color,
|
|
hrule: Color,
|
|
#[allow(dead_code)]
|
|
table_border: Color,
|
|
}
|
|
|
|
impl MarkdownStyles {
|
|
fn from_theme(theme: Option<&Theme>, truecolor: bool) -> Self {
|
|
let Some(theme) = theme else {
|
|
return Self::none();
|
|
};
|
|
|
|
let heading = resolve_scope_style(
|
|
theme,
|
|
"markup.heading",
|
|
&["markup.bold", "entity.name.section"],
|
|
truecolor,
|
|
);
|
|
let bold = resolve_scope_style(
|
|
theme,
|
|
"markup.bold",
|
|
&["entity.other.attribute-name"],
|
|
truecolor,
|
|
);
|
|
let italic = resolve_scope_style(theme, "markup.italic", &["comment"], truecolor);
|
|
let inline_code = resolve_scope_style(
|
|
theme,
|
|
"markup.raw.inline",
|
|
&["markup.raw", "string"],
|
|
truecolor,
|
|
);
|
|
let blockquote =
|
|
resolve_scope_style(theme, "markup.quote", &["comment", "string"], truecolor);
|
|
let list_bullet = resolve_scope_style(
|
|
theme,
|
|
"markup.list",
|
|
&["punctuation.definition.list", "keyword"],
|
|
truecolor,
|
|
);
|
|
let link_text = resolve_scope_style(
|
|
theme,
|
|
"markup.link",
|
|
&["string.other.link", "entity.name.tag"],
|
|
truecolor,
|
|
);
|
|
let link_url = resolve_scope_style(
|
|
theme,
|
|
"markup.underline.link",
|
|
&["string.other.link", "constant"],
|
|
truecolor,
|
|
);
|
|
let strikethrough =
|
|
resolve_scope_style(theme, "markup.deleted", &["invalid"], truecolor);
|
|
let hrule = resolve_scope_style(theme, "comment", &["punctuation"], truecolor);
|
|
let table_border = resolve_scope_style(
|
|
theme,
|
|
"punctuation.definition.table.markdown",
|
|
&["punctuation", "meta.separator"],
|
|
truecolor,
|
|
);
|
|
|
|
Self {
|
|
heading: (
|
|
heading.fg.unwrap_or(Color::Yellow),
|
|
heading.font_style.contains(FontStyle::BOLD),
|
|
),
|
|
bold: bold.fg.unwrap_or(Color::Reset),
|
|
italic: italic.fg.unwrap_or(Color::Reset),
|
|
inline_code_fg: inline_code.fg.unwrap_or(Color::Yellow),
|
|
inline_code_bg: inline_code.bg,
|
|
blockquote: blockquote.fg.unwrap_or(Color::DarkGrey),
|
|
list_bullet: list_bullet.fg.unwrap_or(Color::Reset),
|
|
link_text: link_text.fg.unwrap_or(Color::Cyan),
|
|
link_url: link_url.fg.unwrap_or(Color::Blue),
|
|
strikethrough: strikethrough.fg.unwrap_or(Color::DarkGrey),
|
|
hrule: hrule.fg.unwrap_or(Color::DarkGrey),
|
|
table_border: table_border.fg.unwrap_or(Color::DarkGrey),
|
|
}
|
|
}
|
|
|
|
fn none() -> Self {
|
|
Self {
|
|
heading: (Color::Reset, false),
|
|
bold: Color::Reset,
|
|
italic: Color::Reset,
|
|
inline_code_fg: Color::Reset,
|
|
inline_code_bg: None,
|
|
blockquote: Color::Reset,
|
|
list_bullet: Color::Reset,
|
|
link_text: Color::Reset,
|
|
link_url: Color::Reset,
|
|
strikethrough: Color::Reset,
|
|
hrule: Color::Reset,
|
|
table_border: Color::Reset,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[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<dyn std::error::Error>> {
|
|
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<dyn std::error::Error>> {
|
|
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<dyn
|
|
std::error::Error>> {
|
|
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 {
|
|
raw_markdown: true,
|
|
..Default::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 {
|
|
raw_markdown: true,
|
|
..Default::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,
|
|
raw_markdown: 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);
|
|
}
|
|
|
|
use std::str::FromStr;
|
|
use syntect::highlighting::{ScopeSelectors, StyleModifier, ThemeItem};
|
|
|
|
const DARK_THEME_BYTES: &[u8] = include_bytes!("../../assets/monokai-extended.theme.bin");
|
|
|
|
fn load_dark_theme() -> Theme {
|
|
decode_bin(DARK_THEME_BYTES).expect("built-in dark theme should decode")
|
|
}
|
|
|
|
fn syntect_rgb(r: u8, g: u8, b: u8) -> SyntectColor {
|
|
SyntectColor { r, g, b, a: 255 }
|
|
}
|
|
|
|
fn theme_item(scope: &str, fg: SyntectColor, font_style: Option<FontStyle>) -> ThemeItem {
|
|
ThemeItem {
|
|
scope: ScopeSelectors::from_str(scope).expect("valid scope selector"),
|
|
style: StyleModifier {
|
|
foreground: Some(fg),
|
|
background: None,
|
|
font_style,
|
|
},
|
|
}
|
|
}
|
|
|
|
fn minimal_root_scope_theme() -> Theme {
|
|
let mut theme = Theme::default();
|
|
theme.scopes.push(theme_item(
|
|
"string",
|
|
syntect_rgb(0xff, 0xdd, 0x00),
|
|
None,
|
|
));
|
|
theme.scopes.push(theme_item(
|
|
"comment",
|
|
syntect_rgb(0x88, 0x88, 0x88),
|
|
None,
|
|
));
|
|
theme.scopes.push(theme_item(
|
|
"keyword",
|
|
syntect_rgb(0xaa, 0x00, 0xff),
|
|
None,
|
|
));
|
|
theme.scopes.push(theme_item(
|
|
"constant",
|
|
syntect_rgb(0x00, 0xcc, 0xff),
|
|
None,
|
|
));
|
|
theme.scopes.push(theme_item(
|
|
"entity.name.tag",
|
|
syntect_rgb(0x11, 0x22, 0x33),
|
|
None,
|
|
));
|
|
theme.scopes.push(theme_item(
|
|
"entity.other.attribute-name",
|
|
syntect_rgb(0x44, 0x55, 0x66),
|
|
Some(FontStyle::BOLD),
|
|
));
|
|
theme.scopes.push(theme_item(
|
|
"entity.name.section",
|
|
syntect_rgb(0xde, 0xad, 0xbe),
|
|
Some(FontStyle::BOLD),
|
|
));
|
|
theme.scopes.push(theme_item(
|
|
"invalid",
|
|
syntect_rgb(0xff, 0x00, 0x00),
|
|
None,
|
|
));
|
|
theme.scopes.push(theme_item(
|
|
"punctuation",
|
|
syntect_rgb(0x77, 0x77, 0x77),
|
|
None,
|
|
));
|
|
theme
|
|
}
|
|
|
|
fn rgb(r: u8, g: u8, b: u8) -> Color {
|
|
Color::Rgb { r, g, b }
|
|
}
|
|
|
|
#[test]
|
|
fn resolve_scope_style_uses_primary() {
|
|
let mut theme = Theme::default();
|
|
theme.scopes.push(theme_item(
|
|
"markup.bold",
|
|
syntect_rgb(0xaa, 0xbb, 0xcc),
|
|
Some(FontStyle::BOLD),
|
|
));
|
|
let resolved = resolve_scope_style(&theme, "markup.bold", &["fallback"], true);
|
|
assert_eq!(resolved.fg, Some(rgb(0xaa, 0xbb, 0xcc)));
|
|
assert!(resolved.font_style.contains(FontStyle::BOLD));
|
|
}
|
|
|
|
#[test]
|
|
fn resolve_scope_style_falls_back_when_primary_missing() {
|
|
let mut theme = Theme::default();
|
|
theme.scopes.push(theme_item(
|
|
"comment",
|
|
syntect_rgb(0x33, 0x44, 0x55),
|
|
None,
|
|
));
|
|
let resolved =
|
|
resolve_scope_style(&theme, "markup.italic", &["nope", "comment"], true);
|
|
assert_eq!(resolved.fg, Some(rgb(0x33, 0x44, 0x55)));
|
|
}
|
|
|
|
#[test]
|
|
fn resolve_scope_style_returns_default_when_nothing_matches() {
|
|
let theme = Theme::default();
|
|
let resolved = resolve_scope_style(&theme, "markup.italic", &["comment"], true);
|
|
assert!(resolved.fg.is_none());
|
|
assert!(resolved.bg.is_none());
|
|
assert!(resolved.font_style.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn markdown_styles_none_when_theme_absent() {
|
|
let styles = MarkdownStyles::from_theme(None, true);
|
|
assert_eq!(styles.heading, (Color::Reset, false));
|
|
assert_eq!(styles.bold, Color::Reset);
|
|
assert_eq!(styles.italic, Color::Reset);
|
|
assert_eq!(styles.inline_code_fg, Color::Reset);
|
|
assert_eq!(styles.inline_code_bg, None);
|
|
assert_eq!(styles.blockquote, Color::Reset);
|
|
assert_eq!(styles.list_bullet, Color::Reset);
|
|
assert_eq!(styles.link_text, Color::Reset);
|
|
assert_eq!(styles.link_url, Color::Reset);
|
|
assert_eq!(styles.strikethrough, Color::Reset);
|
|
assert_eq!(styles.hrule, Color::Reset);
|
|
assert_eq!(styles.table_border, Color::Reset);
|
|
}
|
|
|
|
#[test]
|
|
fn markdown_styles_resolve_with_builtin_dark_theme() {
|
|
let theme = load_dark_theme();
|
|
let styles = MarkdownStyles::from_theme(Some(&theme), true);
|
|
|
|
assert_ne!(styles.heading.0, Color::Reset);
|
|
assert_ne!(styles.bold, Color::Reset);
|
|
assert_ne!(styles.italic, Color::Reset);
|
|
assert_ne!(styles.inline_code_fg, Color::Reset);
|
|
assert_ne!(styles.blockquote, Color::Reset);
|
|
assert_ne!(styles.list_bullet, Color::Reset);
|
|
assert_ne!(styles.link_text, Color::Reset);
|
|
assert_ne!(styles.link_url, Color::Reset);
|
|
assert_ne!(styles.strikethrough, Color::Reset);
|
|
assert_ne!(styles.hrule, Color::Reset);
|
|
assert_ne!(styles.table_border, Color::Reset);
|
|
}
|
|
|
|
#[test]
|
|
fn detect_line_kind_heading_levels() {
|
|
assert_eq!(detect_line_kind("# H1"), LineKind::Heading(1));
|
|
assert_eq!(detect_line_kind("## H2"), LineKind::Heading(2));
|
|
assert_eq!(detect_line_kind("### H3"), LineKind::Heading(3));
|
|
assert_eq!(detect_line_kind("#### H4"), LineKind::Heading(4));
|
|
assert_eq!(detect_line_kind("##### H5"), LineKind::Heading(5));
|
|
assert_eq!(detect_line_kind("###### H6"), LineKind::Heading(6));
|
|
assert_eq!(detect_line_kind(" ## Indented"), LineKind::Heading(2));
|
|
}
|
|
|
|
#[test]
|
|
fn detect_line_kind_heading_requires_space_after_hashes() {
|
|
assert_eq!(detect_line_kind("##notheading"), LineKind::Paragraph);
|
|
assert_eq!(detect_line_kind("#"), LineKind::Paragraph);
|
|
}
|
|
|
|
#[test]
|
|
fn detect_line_kind_blockquote() {
|
|
assert_eq!(detect_line_kind("> quoted"), LineKind::Blockquote);
|
|
assert_eq!(detect_line_kind(">no space"), LineKind::Blockquote);
|
|
assert_eq!(detect_line_kind(" > indented"), LineKind::Blockquote);
|
|
assert_eq!(detect_line_kind(">"), LineKind::Blockquote);
|
|
}
|
|
|
|
#[test]
|
|
fn detect_line_kind_task_item() {
|
|
assert_eq!(detect_line_kind("- [ ] todo"), LineKind::TaskItem(false));
|
|
assert_eq!(detect_line_kind("- [x] done"), LineKind::TaskItem(true));
|
|
assert_eq!(detect_line_kind("- [X] done"), LineKind::TaskItem(true));
|
|
assert_eq!(detect_line_kind("* [ ] todo"), LineKind::TaskItem(false));
|
|
assert_eq!(
|
|
detect_line_kind(" - [x] indented"),
|
|
LineKind::TaskItem(true)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn detect_line_kind_bullet_item() {
|
|
assert_eq!(detect_line_kind("- item"), LineKind::BulletItem);
|
|
assert_eq!(detect_line_kind("* item"), LineKind::BulletItem);
|
|
assert_eq!(detect_line_kind("+ item"), LineKind::BulletItem);
|
|
assert_eq!(detect_line_kind(" - nested"), LineKind::BulletItem);
|
|
assert_eq!(detect_line_kind("-nospace"), LineKind::Paragraph);
|
|
}
|
|
|
|
#[test]
|
|
fn detect_line_kind_numbered_item() {
|
|
assert_eq!(detect_line_kind("1. first"), LineKind::NumberedItem);
|
|
assert_eq!(detect_line_kind("42. answer"), LineKind::NumberedItem);
|
|
assert_eq!(detect_line_kind(" 3. nested"), LineKind::NumberedItem);
|
|
assert_eq!(detect_line_kind("1.nospace"), LineKind::Paragraph);
|
|
}
|
|
|
|
#[test]
|
|
fn detect_line_kind_horizontal_rule() {
|
|
assert_eq!(detect_line_kind("---"), LineKind::HorizontalRule);
|
|
assert_eq!(detect_line_kind("___"), LineKind::HorizontalRule);
|
|
assert_eq!(detect_line_kind("***"), LineKind::HorizontalRule);
|
|
assert_eq!(detect_line_kind("--------"), LineKind::HorizontalRule);
|
|
assert_eq!(detect_line_kind(" ---"), LineKind::HorizontalRule);
|
|
assert_eq!(detect_line_kind("--- "), LineKind::HorizontalRule);
|
|
assert_eq!(detect_line_kind("--"), LineKind::Paragraph);
|
|
}
|
|
|
|
#[test]
|
|
fn detect_line_kind_paragraph_default() {
|
|
assert_eq!(detect_line_kind("just text"), LineKind::Paragraph);
|
|
assert_eq!(detect_line_kind(""), LineKind::Paragraph);
|
|
assert_eq!(detect_line_kind(" "), LineKind::Paragraph);
|
|
}
|
|
|
|
#[test]
|
|
fn detect_line_kind_table_row() {
|
|
assert_eq!(detect_line_kind("| a | b |"), LineKind::TableRow);
|
|
assert_eq!(detect_line_kind("|a|b|c|"), LineKind::TableRow);
|
|
assert_eq!(detect_line_kind(" | a | b | "), LineKind::TableRow);
|
|
assert_eq!(detect_line_kind("| | |"), LineKind::TableRow);
|
|
}
|
|
|
|
#[test]
|
|
fn detect_line_kind_table_separator() {
|
|
assert_eq!(detect_line_kind("|---|---|"), LineKind::TableSeparator);
|
|
assert_eq!(detect_line_kind("| --- | --- |"), LineKind::TableSeparator);
|
|
assert_eq!(
|
|
detect_line_kind("|:---|---:|:---:|---|"),
|
|
LineKind::TableSeparator,
|
|
);
|
|
assert_eq!(detect_line_kind("|:--|--:|:-:|"), LineKind::TableSeparator);
|
|
assert_eq!(detect_line_kind(" |---|---| "), LineKind::TableSeparator);
|
|
}
|
|
|
|
#[test]
|
|
fn detect_line_kind_non_table_pipe_line_stays_paragraph() {
|
|
assert_eq!(
|
|
detect_line_kind("use `a | b` for or"),
|
|
LineKind::Paragraph,
|
|
);
|
|
assert_eq!(detect_line_kind("| trailing"), LineKind::Paragraph);
|
|
assert_eq!(detect_line_kind("no closer |"), LineKind::Paragraph);
|
|
}
|
|
|
|
#[test]
|
|
fn detect_line_kind_prefers_separator_over_row() {
|
|
assert_eq!(detect_line_kind("|---|---|"), LineKind::TableSeparator);
|
|
assert_ne!(detect_line_kind("|---|---|"), LineKind::TableRow);
|
|
}
|
|
|
|
#[test]
|
|
fn parse_table_row_splits_cells() {
|
|
assert_eq!(parse_table_row("| a | b | c |"), vec!["a", "b", "c"]);
|
|
assert_eq!(parse_table_row("|a|b|c|"), vec!["a", "b", "c"]);
|
|
}
|
|
|
|
#[test]
|
|
fn parse_table_row_handles_empty_cells() {
|
|
assert_eq!(parse_table_row("| a | | c |"), vec!["a", "", "c"]);
|
|
assert_eq!(parse_table_row("| | | |"), vec!["", "", ""]);
|
|
}
|
|
|
|
#[test]
|
|
fn parse_table_row_trims_whitespace() {
|
|
assert_eq!(
|
|
parse_table_row(" | foo | bar | "),
|
|
vec!["foo", "bar"],
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn parse_alignments_reads_colons() {
|
|
assert_eq!(
|
|
parse_alignments("|:---|---:|:---:|---|"),
|
|
vec![
|
|
CellAlignment::Left,
|
|
CellAlignment::Right,
|
|
CellAlignment::Center,
|
|
CellAlignment::Left,
|
|
],
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn parse_alignments_short_dashes() {
|
|
assert_eq!(
|
|
parse_alignments("|:--|--:|:-:|"),
|
|
vec![
|
|
CellAlignment::Left,
|
|
CellAlignment::Right,
|
|
CellAlignment::Center,
|
|
],
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn parse_alignments_defaults_to_left() {
|
|
assert_eq!(
|
|
parse_alignments("|---|---|---|"),
|
|
vec![
|
|
CellAlignment::Left,
|
|
CellAlignment::Left,
|
|
CellAlignment::Left,
|
|
],
|
|
);
|
|
}
|
|
|
|
fn test_styles() -> MarkdownStyles {
|
|
MarkdownStyles {
|
|
heading: (Color::Yellow, true),
|
|
bold: Color::Red,
|
|
italic: Color::Green,
|
|
inline_code_fg: Color::Cyan,
|
|
inline_code_bg: None,
|
|
blockquote: Color::DarkGrey,
|
|
list_bullet: Color::Reset,
|
|
link_text: Color::Blue,
|
|
link_url: Color::Magenta,
|
|
strikethrough: Color::DarkGrey,
|
|
hrule: Color::DarkGrey,
|
|
table_border: Color::DarkGrey,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn inline_code_strips_backticks() {
|
|
let styles = test_styles();
|
|
let result = apply_inline("hello `world` foo", &styles);
|
|
assert!(!result.contains('`'), "backticks stripped: {result:?}");
|
|
assert!(result.contains("world"));
|
|
assert!(result.contains("hello "));
|
|
assert!(result.contains(" foo"));
|
|
}
|
|
|
|
#[test]
|
|
fn bold_asterisk_applied() {
|
|
let styles = test_styles();
|
|
let result = apply_inline("**loud**", &styles);
|
|
assert!(!result.contains("**"), "markers stripped: {result:?}");
|
|
assert!(result.contains("loud"));
|
|
assert!(result.contains("\x1b[1m"), "bold SGR present: {result:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn bold_underscore_applied() {
|
|
let styles = test_styles();
|
|
let result = apply_inline("__loud__", &styles);
|
|
assert!(!result.contains("__"), "markers stripped: {result:?}");
|
|
assert!(result.contains("loud"));
|
|
assert!(result.contains("\x1b[1m"));
|
|
}
|
|
|
|
#[test]
|
|
fn italic_asterisk_applied() {
|
|
let styles = test_styles();
|
|
let result = apply_inline("*soft*", &styles);
|
|
assert!(result.contains("soft"));
|
|
assert!(result.contains("\x1b[3m"), "italic SGR present: {result:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn italic_underscore_applied() {
|
|
let styles = test_styles();
|
|
let result = apply_inline("_soft_", &styles);
|
|
assert!(result.contains("soft"));
|
|
assert!(result.contains("\x1b[3m"));
|
|
}
|
|
|
|
#[test]
|
|
fn strikethrough_applied() {
|
|
let styles = test_styles();
|
|
let result = apply_inline("~~gone~~", &styles);
|
|
assert!(!result.contains("~~"), "markers stripped");
|
|
assert!(result.contains("gone"));
|
|
assert!(result.contains("\x1b[9m"), "strikethrough SGR: {result:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn bold_wraps_inline_code() {
|
|
let styles = test_styles();
|
|
let result = apply_inline("**foo `bar` baz**", &styles);
|
|
assert!(!result.contains('`'));
|
|
assert!(!result.contains("**"));
|
|
assert!(result.contains("foo"));
|
|
assert!(result.contains("bar"));
|
|
assert!(result.contains("baz"));
|
|
assert!(result.contains("\x1b[1m"), "bold applied around code: {result:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn partial_bold_stays_raw() {
|
|
let styles = test_styles();
|
|
let result = apply_inline("**unclosed", &styles);
|
|
assert_eq!(result, "**unclosed");
|
|
}
|
|
|
|
#[test]
|
|
fn partial_italic_stays_raw() {
|
|
let styles = test_styles();
|
|
assert_eq!(apply_inline("*unclosed", &styles), "*unclosed");
|
|
assert_eq!(apply_inline("_unclosed", &styles), "_unclosed");
|
|
}
|
|
|
|
#[test]
|
|
fn italic_ignores_word_internal_underscores() {
|
|
let styles = test_styles();
|
|
assert_eq!(apply_inline("some_var_name", &styles), "some_var_name");
|
|
}
|
|
|
|
#[test]
|
|
fn italic_ignores_math_like_spaces() {
|
|
let styles = test_styles();
|
|
assert_eq!(apply_inline("a * b * c", &styles), "a * b * c");
|
|
}
|
|
|
|
#[test]
|
|
fn links_emit_osc8_and_visible_parts() {
|
|
let styles = test_styles();
|
|
let result = apply_inline("[label](https://example.com)", &styles);
|
|
assert!(
|
|
result.contains("\x1b]8;;https://example.com\x1b\\"),
|
|
"OSC 8 open present: {result:?}"
|
|
);
|
|
assert!(result.contains("\x1b]8;;\x1b\\"), "OSC 8 close present");
|
|
assert!(result.contains("label"));
|
|
assert!(result.contains("https://example.com"));
|
|
}
|
|
|
|
#[test]
|
|
fn images_emit_labeled_link() {
|
|
let styles = test_styles();
|
|
let result = apply_inline("", &styles);
|
|
assert!(!result.contains("!["), "raw image marker removed: {result:?}");
|
|
assert!(result.contains("Image: alt text"));
|
|
assert!(result.contains("https://img.example/x.png"));
|
|
assert!(result.contains("\x1b]8;;https://img.example/x.png\x1b\\"));
|
|
}
|
|
|
|
#[test]
|
|
fn image_processed_before_link() {
|
|
let styles = test_styles();
|
|
let result = apply_inline("", &styles);
|
|
assert!(!result.starts_with('!'), "no stray ! left behind: {result:?}");
|
|
assert!(result.contains("Image:"));
|
|
}
|
|
|
|
#[test]
|
|
fn plain_text_unchanged() {
|
|
let styles = test_styles();
|
|
assert_eq!(apply_inline("just plain text", &styles), "just plain text");
|
|
}
|
|
|
|
#[test]
|
|
fn render_heading_level_1_pads_content() {
|
|
let styles = test_styles();
|
|
let result = render_markdown_line("# Big", LineKind::Heading(1), &styles);
|
|
assert!(result.contains(" Big "), "H1 padded content: {result:?}");
|
|
assert!(!result.contains('#'), "H1 hashes removed: {result:?}");
|
|
assert!(result.contains("\x1b[1m"), "bold applied: {result:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn render_heading_level_2_through_6_keeps_hash_prefix() {
|
|
let styles = test_styles();
|
|
for level in 2u8..=6 {
|
|
let hashes = "#".repeat(level as usize);
|
|
let line = format!("{hashes} Title");
|
|
let result = render_markdown_line(&line, LineKind::Heading(level), &styles);
|
|
assert!(result.contains(&hashes), "H{level} keeps hashes: {result:?}");
|
|
assert!(result.contains("Title"));
|
|
assert!(result.contains("\x1b[1m"), "H{level} bold: {result:?}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn render_heading_preserves_leading_indent() {
|
|
let styles = test_styles();
|
|
let result = render_markdown_line(" ## Nested", LineKind::Heading(2), &styles);
|
|
assert!(result.starts_with(" "), "indent preserved: {result:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn render_blockquote_uses_pipe_prefix() {
|
|
let styles = test_styles();
|
|
let result = render_markdown_line("> quoted", LineKind::Blockquote, &styles);
|
|
assert!(result.contains("│ "), "pipe prefix: {result:?}");
|
|
assert!(!result.contains('>'), "gt removed: {result:?}");
|
|
assert!(result.contains("quoted"));
|
|
}
|
|
|
|
#[test]
|
|
fn render_blockquote_preserves_indent() {
|
|
let styles = test_styles();
|
|
let result = render_markdown_line(" > deep", LineKind::Blockquote, &styles);
|
|
assert!(result.starts_with(" "));
|
|
assert!(result.contains("│ "));
|
|
}
|
|
|
|
#[test]
|
|
fn render_bullet_uses_bullet_char() {
|
|
let styles = test_styles();
|
|
let result = render_markdown_line("- item", LineKind::BulletItem, &styles);
|
|
assert!(result.contains("•"), "bullet glyph: {result:?}");
|
|
assert!(!result.contains("- "), "dash removed: {result:?}");
|
|
assert!(result.contains("item"));
|
|
}
|
|
|
|
#[test]
|
|
fn render_bullet_supports_star_and_plus() {
|
|
let styles = test_styles();
|
|
let star = render_markdown_line("* one", LineKind::BulletItem, &styles);
|
|
let plus = render_markdown_line("+ two", LineKind::BulletItem, &styles);
|
|
assert!(star.contains("•") && star.contains("one"));
|
|
assert!(plus.contains("•") && plus.contains("two"));
|
|
}
|
|
|
|
#[test]
|
|
fn render_bullet_preserves_nested_indent() {
|
|
let styles = test_styles();
|
|
let result = render_markdown_line(" - nested", LineKind::BulletItem, &styles);
|
|
assert!(result.starts_with(" "), "indent kept: {result:?}");
|
|
assert!(result.contains("•"));
|
|
}
|
|
|
|
#[test]
|
|
fn render_numbered_preserves_number_and_styles_dot() {
|
|
let styles = test_styles();
|
|
let result = render_markdown_line("42. answer", LineKind::NumberedItem, &styles);
|
|
assert!(result.contains("42"), "number kept: {result:?}");
|
|
assert!(result.contains("answer"));
|
|
assert!(result.contains('.'), "dot present");
|
|
}
|
|
|
|
#[test]
|
|
fn render_task_unchecked() {
|
|
let styles = test_styles();
|
|
let result = render_markdown_line("- [ ] todo", LineKind::TaskItem(false), &styles);
|
|
assert!(result.contains("[ ]"), "unchecked glyph: {result:?}");
|
|
assert!(!result.contains("- "), "no dash prefix: {result:?}");
|
|
assert!(result.contains("todo"));
|
|
}
|
|
|
|
#[test]
|
|
fn render_task_checked_uses_check_glyph() {
|
|
let styles = test_styles();
|
|
let result = render_markdown_line("- [x] done", LineKind::TaskItem(true), &styles);
|
|
assert!(result.contains("[✓]"), "checked glyph: {result:?}");
|
|
assert!(!result.contains("[x]"), "raw x removed: {result:?}");
|
|
assert!(result.contains("done"));
|
|
}
|
|
|
|
#[test]
|
|
fn render_hrule_emits_box_drawing() {
|
|
let styles = test_styles();
|
|
let result = render_markdown_line("---", LineKind::HorizontalRule, &styles);
|
|
assert!(result.contains("────"), "box drawing chars: {result:?}");
|
|
assert!(!result.contains("---"), "raw dashes removed: {result:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn render_paragraph_delegates_to_inline() {
|
|
let styles = test_styles();
|
|
let result =
|
|
render_markdown_line("hello **world**", LineKind::Paragraph, &styles);
|
|
assert!(!result.contains("**"), "bold markers stripped: {result:?}");
|
|
assert!(result.contains("world"));
|
|
assert!(result.contains("\x1b[1m"), "bold applied via inline: {result:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn render_bullet_runs_inline_on_content() {
|
|
let styles = test_styles();
|
|
let result =
|
|
render_markdown_line("- see `code`", LineKind::BulletItem, &styles);
|
|
assert!(result.contains("•"));
|
|
assert!(!result.contains('`'), "backticks stripped: {result:?}");
|
|
assert!(result.contains("see "));
|
|
assert!(result.contains("code"));
|
|
}
|
|
|
|
#[test]
|
|
fn render_blockquote_runs_inline_on_content() {
|
|
let styles = test_styles();
|
|
let result =
|
|
render_markdown_line("> visit [here](https://example.com)", LineKind::Blockquote, &styles);
|
|
assert!(result.contains("│ "));
|
|
assert!(result.contains("here"));
|
|
assert!(result.contains("https://example.com"));
|
|
assert!(result.contains("\x1b]8;;https://example.com\x1b\\"));
|
|
}
|
|
|
|
#[test]
|
|
fn heading_can_contain_bold_inline() {
|
|
let styles = test_styles();
|
|
let result =
|
|
render_markdown_line("## Announce **now**", LineKind::Heading(2), &styles);
|
|
assert!(!result.contains("**"), "bold markers stripped: {result:?}");
|
|
assert!(result.contains("now"));
|
|
assert!(result.contains("Announce"));
|
|
assert!(result.contains("\x1b[1m"), "bold present: {result:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn streaming_partial_bold_stays_raw() {
|
|
let options = RenderOptions::default();
|
|
let render = MarkdownRender::init(options).unwrap();
|
|
let partial = render.render_line("**bo");
|
|
assert!(partial.contains("**bo"), "unclosed bold preserved: {partial:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn streaming_partial_link_stays_raw() {
|
|
let options = RenderOptions::default();
|
|
let render = MarkdownRender::init(options).unwrap();
|
|
let partial = render.render_line("[label](https://exa");
|
|
assert!(partial.contains("[label]"), "unclosed link preserved: {partial:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn rich_render_strips_syntax_without_theme() {
|
|
let options = RenderOptions::default();
|
|
let mut render = MarkdownRender::init(options).unwrap();
|
|
let output = render.render("# Heading\n\n> quoted\n\n- item\n");
|
|
assert!(!output.contains("# Heading"), "hash removed: {output:?}");
|
|
assert!(output.contains("Heading"));
|
|
assert!(!output.contains("> quoted"), "gt removed");
|
|
assert!(output.contains("│ "), "pipe prefix present");
|
|
assert!(!output.contains("- item"), "dash removed");
|
|
assert!(output.contains("•"), "bullet glyph present");
|
|
assert!(output.contains("item"));
|
|
}
|
|
|
|
#[test]
|
|
fn rich_and_raw_paths_diverge() {
|
|
let raw_opts = RenderOptions {
|
|
raw_markdown: true,
|
|
..Default::default()
|
|
};
|
|
let rich_opts = RenderOptions::default();
|
|
let mut raw_render = MarkdownRender::init(raw_opts).unwrap();
|
|
let mut rich_render = MarkdownRender::init(rich_opts).unwrap();
|
|
let text = "# Heading\n\n**bold** text\n";
|
|
let raw = raw_render.render(text);
|
|
let rich = rich_render.render(text);
|
|
assert_eq!(raw, text, "raw path preserves input");
|
|
assert_ne!(rich, text, "rich path transforms input");
|
|
}
|
|
|
|
#[test]
|
|
fn code_block_content_still_routes_to_syntect() {
|
|
let options = RenderOptions::default();
|
|
let mut render = MarkdownRender::init(options).unwrap();
|
|
let text = "```rust\nfn main() {}\n```\n";
|
|
let output = render.render(text);
|
|
assert!(output.contains("fn main()"), "code content preserved: {output:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn markdown_styles_fall_back_to_root_scopes() {
|
|
let theme = minimal_root_scope_theme();
|
|
let styles = MarkdownStyles::from_theme(Some(&theme), true);
|
|
|
|
assert_eq!(styles.heading.0, rgb(0xde, 0xad, 0xbe));
|
|
assert!(styles.heading.1);
|
|
assert_eq!(styles.bold, rgb(0x44, 0x55, 0x66));
|
|
assert_eq!(styles.italic, rgb(0x88, 0x88, 0x88));
|
|
assert_eq!(styles.inline_code_fg, rgb(0xff, 0xdd, 0x00));
|
|
assert_eq!(styles.inline_code_bg, None);
|
|
assert_eq!(styles.blockquote, rgb(0x88, 0x88, 0x88));
|
|
assert_eq!(styles.list_bullet, rgb(0xaa, 0x00, 0xff));
|
|
assert_eq!(styles.link_text, rgb(0x11, 0x22, 0x33));
|
|
assert_eq!(styles.link_url, rgb(0x00, 0xcc, 0xff));
|
|
assert_eq!(styles.strikethrough, rgb(0xff, 0x00, 0x00));
|
|
assert_eq!(styles.hrule, rgb(0x88, 0x88, 0x88));
|
|
assert_eq!(styles.table_border, rgb(0x77, 0x77, 0x77));
|
|
}
|
|
}
|