use pulldown_cmark::{Event, Options, Parser, Tag, TagEnd};
const SPECIAL_CHARS: &[char] = &[
'_', '*', '[', ']', '(', ')', '~', '`', '>', '#', '+', '-', '=', '|', '{', '}', '.', '!', '\\',
];
pub(crate) const DEFAULT_EXPANDABLE_BLOCKQUOTE_MIN_LINES: u32 = 10;
#[must_use]
pub fn markdown_to_telegram(input: &str) -> String {
markdown_to_telegram_with_config(input, DEFAULT_EXPANDABLE_BLOCKQUOTE_MIN_LINES)
}
#[must_use]
pub fn markdown_to_telegram_with_config(
input: &str,
expandable_blockquote_min_lines: u32,
) -> String {
let options = Options::ENABLE_STRIKETHROUGH;
let parser = Parser::new_ext(input, options);
let mut renderer = TelegramRenderer::new(input.len(), expandable_blockquote_min_lines);
for event in parser {
renderer.push_event(event);
}
renderer.finish()
}
#[must_use]
pub fn utf8_chunks(text: &str, max_bytes: usize) -> Vec<&str> {
if text.len() <= max_bytes {
return vec![text];
}
let mut chunks = Vec::new();
let mut offset = 0;
while offset < text.len() {
let remaining = text.len() - offset;
if remaining <= max_bytes {
chunks.push(&text[offset..]);
break;
}
let mut split_at = text.floor_char_boundary(offset + max_bytes);
if split_at >= text.len() {
chunks.push(&text[offset..]);
break;
}
let search_start = split_at.saturating_sub(256).max(offset);
if let Some(newline_pos) = text[search_start..split_at].rfind('\n') {
let potential_split = search_start + newline_pos + 1;
if potential_split > offset {
split_at = potential_split;
}
}
chunks.push(&text[offset..split_at]);
offset = split_at;
}
chunks
}
const MAX_BLOCKQUOTE_NESTING_DEPTH: usize = 512;
struct TelegramRenderer {
output: String,
in_code_block: bool,
link_url: Option<String>,
expandable_blockquote_min_lines: u32,
blockquote_marks: Vec<usize>,
blockquote_depth: usize,
}
impl TelegramRenderer {
fn new(capacity: usize, expandable_blockquote_min_lines: u32) -> Self {
Self {
output: String::with_capacity(capacity),
in_code_block: false,
link_url: None,
expandable_blockquote_min_lines,
blockquote_marks: Vec::new(),
blockquote_depth: 0,
}
}
fn push_event(&mut self, event: Event<'_>) {
match event {
Event::End(TagEnd::Heading { .. }) => {
self.output.push_str("*\n");
}
Event::Start(Tag::Heading { .. } | Tag::Strong) | Event::End(TagEnd::Strong) => {
self.output.push('*');
}
Event::Start(Tag::Emphasis) | Event::End(TagEnd::Emphasis) => {
self.output.push('_');
}
Event::Start(Tag::Strikethrough) | Event::End(TagEnd::Strikethrough) => {
self.output.push('~');
}
Event::Start(Tag::CodeBlock(_)) => {
self.output.push_str("```\n");
self.in_code_block = true;
}
Event::End(TagEnd::CodeBlock) => {
self.output.push_str("```");
self.in_code_block = false;
}
Event::Code(text) => {
self.output.push('`');
self.output.push_str(&Self::escape_code_text(&text));
self.output.push('`');
}
Event::Text(text) => {
let escaped = if self.in_code_block {
Self::escape_code_text(&text)
} else {
Self::escape_text(&text)
};
self.output.push_str(&escaped);
}
Event::Start(Tag::Link { dest_url, .. }) => {
self.output.push('[');
self.link_url = Some(dest_url.to_string());
}
Event::End(TagEnd::Link) => {
if let Some(url) = self.link_url.take() {
self.output.push_str("](");
self.output.push_str(&Self::escape_url(&url));
self.output.push(')');
}
}
Event::Start(Tag::Item) => {
self.output.push_str("• ");
}
Event::Start(Tag::BlockQuote(_)) => {
self.blockquote_depth += 1;
if self.blockquote_marks.len() < MAX_BLOCKQUOTE_NESTING_DEPTH {
self.blockquote_marks.push(self.output.len());
}
}
Event::End(TagEnd::BlockQuote(_)) => {
self.end_blockquote();
}
Event::End(TagEnd::Paragraph | TagEnd::Item) | Event::SoftBreak | Event::HardBreak => {
self.output.push('\n');
}
_ => {}
}
}
fn end_blockquote(&mut self) {
let Some(closing_level) = self.blockquote_depth.checked_sub(1) else {
return;
};
self.blockquote_depth = closing_level;
if closing_level >= MAX_BLOCKQUOTE_NESTING_DEPTH {
return;
}
let Some(mark) = self.blockquote_marks.pop() else {
return;
};
if !self.blockquote_marks.is_empty() {
return;
}
let content = self.output.split_off(mark);
let trimmed = content.trim_end_matches('\n');
let lines: Vec<&str> = if trimmed.is_empty() {
vec![""]
} else {
trimmed.split('\n').collect()
};
let line_count = lines.len();
let line_count_u32 = u32::try_from(line_count).unwrap_or(u32::MAX);
let expandable = self.expandable_blockquote_min_lines > 0
&& line_count_u32 >= self.expandable_blockquote_min_lines;
for (i, line) in lines.iter().enumerate() {
if i > 0 {
self.output.push('\n');
}
if expandable && i == 0 {
self.output.push_str("**>");
} else {
self.output.push('>');
}
self.output.push_str(line);
if expandable && i == line_count - 1 {
self.output.push_str("||");
}
}
self.output.push_str("\n\n");
}
fn escape_text(text: &str) -> String {
let mut result = String::with_capacity(text.len() * 2);
for c in text.chars() {
if SPECIAL_CHARS.contains(&c) {
result.push('\\');
}
result.push(c);
}
result
}
fn escape_code_text(text: &str) -> String {
let mut result = String::with_capacity(text.len() * 2);
for c in text.chars() {
match c {
'`' | '\\' => {
result.push('\\');
result.push(c);
}
_ => result.push(c),
}
}
result
}
fn escape_url(text: &str) -> String {
let mut result = String::with_capacity(text.len());
for c in text.chars() {
if c == ')' || c == '\\' {
result.push('\\');
}
result.push(c);
}
result
}
fn finish(mut self) -> String {
if self.output.ends_with('\n') {
self.output.pop();
}
self.output
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_bold_conversion() {
let input = "**bold**";
let output = markdown_to_telegram(input);
assert_eq!(output, "*bold*");
}
#[test]
fn test_italic_conversion() {
let input = "*italic*";
let output = markdown_to_telegram(input);
assert_eq!(output, "_italic_");
}
#[test]
fn test_strikethrough_conversion() {
let input = "~~strikethrough~~";
let output = markdown_to_telegram(input);
assert_eq!(output, "~strikethrough~");
}
#[test]
fn test_header_to_bold() {
let input = "# Header 1\n## Header 2";
let output = markdown_to_telegram(input);
assert!(output.contains("*Header 1*"));
assert!(output.contains("*Header 2*"));
}
#[test]
fn test_nested_formatting() {
let input = "**bold _italic_**";
let output = markdown_to_telegram(input);
assert_eq!(output, "*bold _italic_*");
}
#[test]
fn test_inline_code() {
let input = "text `code` text";
let output = markdown_to_telegram(input);
assert!(output.contains("`code`"));
}
#[test]
fn test_code_block() {
let input = "```\ncode block\n```";
let output = markdown_to_telegram(input);
assert!(output.starts_with("```\n"));
assert!(output.contains("code block"));
assert!(output.ends_with("```"));
}
#[test]
fn test_links() {
let input = "[text](https://example.com)";
let output = markdown_to_telegram(input);
assert_eq!(output, "[text](https://example.com)");
}
#[test]
fn test_blockquote() {
let input = "> quote";
let output = markdown_to_telegram(input);
assert!(output.starts_with('>'));
}
#[test]
fn test_multiline_blockquote_prefixes_every_line() {
let input = "> line 1\n> line 2\n> line 3";
let output = markdown_to_telegram(input);
for line in ["line 1", "line 2", "line 3"] {
assert!(
output.contains(&format!(">{line}")),
"expected '>{line}' in output: {output:?}"
);
}
assert!(
!output.contains("**>"),
"must not be expandable: {output:?}"
);
assert!(!output.contains("||"), "must not be expandable: {output:?}");
}
#[test]
fn test_blockquote_line_counts_two_to_nine_below_default_threshold() {
for n in 2..=9 {
let lines: Vec<String> = (1..=n).map(|i| format!("> line {i}")).collect();
let input = lines.join("\n");
let output = markdown_to_telegram(&input);
for i in 1..=n {
assert!(
output.contains(&format!(">line {i}")),
"n={n}: expected '>line {i}' in output: {output:?}"
);
}
assert!(!output.contains("**>"), "n={n}: must not be expandable");
}
}
#[test]
fn test_blockquote_expandable_at_threshold_boundary() {
let lines: Vec<String> = (1..=5).map(|i| format!("> line {i}")).collect();
let input = lines.join("\n");
let expandable = markdown_to_telegram_with_config(&input, 5);
assert!(
expandable.starts_with("**>line 1"),
"output: {expandable:?}"
);
assert!(
expandable.trim_end().ends_with("||"),
"output: {expandable:?}"
);
let regular = markdown_to_telegram_with_config(&input, 6);
assert!(!regular.contains("**>"), "output: {regular:?}");
assert!(!regular.ends_with("||"), "output: {regular:?}");
assert!(regular.starts_with(">line 1"), "output: {regular:?}");
}
#[test]
fn test_expandable_blockquote_min_lines_zero_disables_expandable_unconditionally() {
let lines: Vec<String> = (1..=50).map(|i| format!("> line {i}")).collect();
let input = lines.join("\n");
let output = markdown_to_telegram_with_config(&input, 0);
assert!(!output.contains("**>"), "output: {output:?}");
assert!(!output.ends_with("||"), "output: {output:?}");
for i in 1..=50 {
assert!(output.contains(&format!(">line {i}")));
}
}
#[test]
fn test_nested_blockquote_flattens_to_single_level() {
let input = "> outer\n> > inner";
let output = markdown_to_telegram(input);
assert_eq!(output, ">outer\n>inner\n");
assert!(
!output.contains(">>"),
"nested blockquote must never emit a doubled '>' prefix: {output:?}"
);
}
#[test]
fn test_blockquote_nesting_beyond_depth_cap_stays_bounded() {
let depth = 600; let input = format!("{}deep", "> ".repeat(depth));
let output = markdown_to_telegram(&input);
assert_eq!(output, ">deep\n");
assert!(!output.contains(">>"), "output: {output:?}");
}
#[test]
fn test_blockquote_nesting_within_depth_cap_still_flattens() {
let depth = 20; let input = format!("{}shallow", "> ".repeat(depth));
let output = markdown_to_telegram(&input);
assert_eq!(output, ">shallow\n");
assert!(!output.contains(">>"), "output: {output:?}");
}
#[test]
fn test_short_blockquote_unaffected_by_expandable_config() {
let input = "> quote";
let default_output = markdown_to_telegram(input);
let custom_output = markdown_to_telegram_with_config(input, 1);
assert_eq!(default_output, ">quote\n");
assert!(custom_output.starts_with("**>quote"));
assert!(custom_output.trim_end().ends_with("||"));
}
#[test]
fn test_blockquote_line_with_special_chars_escaped_exactly_as_outside_blockquote() {
let input = "> Special: . ! - + = | { }";
let output = markdown_to_telegram(input);
assert_eq!(output, ">Special: \\. \\! \\- \\+ \\= \\| \\{ \\}\n");
}
#[test]
fn test_fenced_code_block_inside_blockquote_pins_current_behavior() {
let input = "> ```\n> code line\n> ```";
let output = markdown_to_telegram(input);
assert_eq!(output, ">```\n>code line\n>```\n");
}
#[test]
fn test_lists() {
let input = "- item 1\n- item 2";
let output = markdown_to_telegram(input);
assert!(output.contains("• item 1"));
assert!(output.contains("• item 2"));
}
#[test]
fn test_escape_special_chars() {
let input = "Special: . ! - + = | { }";
let output = markdown_to_telegram(input);
assert_eq!(output, "Special: \\. \\! \\- \\+ \\= \\| \\{ \\}");
}
#[test]
fn test_code_block_minimal_escape() {
let input = "```\nbackslash \\ and backtick `\n```";
let output = markdown_to_telegram(input);
assert!(output.contains("backslash \\\\"));
assert!(output.contains("backtick \\`"));
}
#[test]
fn test_no_double_escape() {
let input = "already escaped: \\*";
let output = markdown_to_telegram(input);
assert_eq!(output, "already escaped: \\*");
}
#[test]
fn test_mixed_code_and_text() {
let input = "text with `code` and **bold**";
let output = markdown_to_telegram(input);
assert!(output.contains("`code`"));
assert!(output.contains("*bold*"));
}
#[test]
fn test_empty_input() {
let input = "";
let output = markdown_to_telegram(input);
assert_eq!(output, "");
}
#[test]
fn test_plain_text() {
let input = "Plain text with special chars: -";
let output = markdown_to_telegram(input);
assert!(output.contains("\\-"));
}
#[test]
fn test_unclosed_bold() {
let input = "**unclosed bold";
let output = markdown_to_telegram(input);
assert!(!output.is_empty());
}
#[test]
fn test_unclosed_code_block() {
let input = "```\nunclosed";
let output = markdown_to_telegram(input);
assert!(!output.is_empty());
}
#[test]
fn test_horizontal_rule() {
let input = "Text\n---\nMore";
let output = markdown_to_telegram(input);
assert!(output.contains("Text"));
assert!(output.contains("More"));
}
#[test]
fn test_unicode_text() {
let input = "emoji 🎉 and CJK 中文";
let output = markdown_to_telegram(input);
assert!(output.contains("🎉"));
assert!(output.contains("中文"));
}
#[test]
fn test_multiline() {
let input = "# Title\n\nParagraph 1.\n\nParagraph 2 with **bold**.";
let output = markdown_to_telegram(input);
assert!(output.contains("*Title*"));
assert!(output.contains("Paragraph 1"));
assert!(output.contains("*bold*"));
}
#[test]
fn test_no_split_needed() {
let text = "short text";
let chunks = utf8_chunks(text, 100);
assert_eq!(chunks.len(), 1);
assert_eq!(chunks[0], text);
}
#[test]
fn test_split_at_newline() {
let text = "line 1\nline 2\nline 3";
let chunks = utf8_chunks(text, 10);
assert!(chunks.len() > 1);
for chunk in &chunks {
assert!(chunk.len() <= 10);
}
}
#[test]
fn test_split_respects_utf8() {
let text = "日本語";
let chunks = utf8_chunks(text, 5);
for chunk in &chunks {
assert!(std::str::from_utf8(chunk.as_bytes()).is_ok());
}
}
#[test]
fn test_split_emoji() {
let text = "🎉🎊🎈🎁";
let chunks = utf8_chunks(text, 8);
for chunk in &chunks {
assert!(std::str::from_utf8(chunk.as_bytes()).is_ok());
assert!(chunk.len() <= 8);
}
}
#[test]
fn test_chunks_concatenate() {
let text = "The quick brown fox jumps over the lazy dog";
let chunks = utf8_chunks(text, 10);
let rejoined = chunks.join("");
assert_eq!(rejoined, text);
}
#[test]
fn test_each_chunk_within_limit() {
let text = "a".repeat(1000);
let max_bytes = 100;
let chunks = utf8_chunks(&text, max_bytes);
for chunk in &chunks {
assert!(chunk.len() <= max_bytes);
}
}
#[test]
fn test_code_block_with_special_chars() {
let input = "```bash\nfind . -name \"*.txt\"\n```";
let output = markdown_to_telegram(input);
assert!(output.contains("find . -name"));
}
#[test]
fn test_escaping_backslash() {
let input = "backslash \\";
let output = markdown_to_telegram(input);
assert!(output.contains("\\\\"));
}
#[test]
fn test_link_with_special_chars() {
let input = "[link](https://example.com/path?param=value)";
let output = markdown_to_telegram(input);
assert!(output.contains("[link]"));
assert!(output.contains("example.com"));
}
#[test]
fn test_utf8_chunks_no_infinite_loop() {
let text = format!("{}\n{}{}", "A".repeat(7), "X".repeat(90), "Y".repeat(50));
let chunks = utf8_chunks(&text, 50);
let rejoined: String = chunks.concat();
assert_eq!(rejoined, text);
assert!(chunks.len() >= 2, "Should produce at least 2 chunks");
for chunk in &chunks {
assert!(
chunk.len() <= 50,
"Chunk exceeds max_bytes: {}",
chunk.len()
);
assert!(
!chunk.is_empty(),
"Empty chunk detected - infinite loop bug"
);
}
}
}