Skip to main content

ronami/utils/
markdown.rs

1//! Utils for working with the [Markdown V2 message style][spec].
2//!
3//! [spec]: https://core.telegram.org/bots/api#markdownv2-style
4
5use ronami_core::types::{User, UserId};
6
7pub(super) const ESCAPE_CHARS: [char; 19] = [
8    '\\', '_', '*', '[', ']', '(', ')', '~', '`', '>', '#', '+', '-', '=', '|', '{', '}', '.', '!',
9];
10
11/// Applies the bold font style to the string.
12///
13/// Passed string will not be automatically escaped because it can contain
14/// nested markup.
15#[must_use = "This function returns a new string, rather than mutating the argument, so calling it \
16              without using its output does nothing useful"]
17pub fn bold(s: &str) -> String {
18    format!("*{s}*")
19}
20
21/// Applies the block quotation style to the string.
22///
23/// Passed string will not be automatically escaped because it can contain
24/// nested markup.
25#[must_use = "This function returns a new string, rather than mutating the argument, so calling it \
26              without using its output does nothing useful"]
27pub fn blockquote(s: &str) -> String {
28    format!("**>{}", s.replace('\n', "\n>"))
29}
30
31/// Applies the expandable block quotation style to the string.
32///
33/// Passed string will not be automatically escaped because it can contain
34/// nested markup.
35#[must_use = "This function returns a new string, rather than mutating the argument, so calling it \
36              without using its output does nothing useful"]
37pub fn expandable_blockquote(s: &str) -> String {
38    format!("**>{}||", s.replace('\n', "\n>"))
39}
40
41/// Applies the italic font style to the string.
42///
43/// Can be safely used with `utils::markdown::underline()`.
44/// Passed string will not be automatically escaped because it can contain
45/// nested markup.
46#[must_use = "This function returns a new string, rather than mutating the argument, so calling it \
47              without using its output does nothing useful"]
48pub fn italic(s: &str) -> String {
49    if s.starts_with("__") && s.ends_with("__") {
50        format!(r"_{}\r__", &s[..s.len() - 1])
51    } else {
52        format!("_{s}_")
53    }
54}
55
56/// Applies the underline font style to the string.
57///
58/// Can be safely used with `utils::markdown::italic()`.
59/// Passed string will not be automatically escaped because it can contain
60/// nested markup.
61#[must_use = "This function returns a new string, rather than mutating the argument, so calling it \
62              without using its output does nothing useful"]
63pub fn underline(s: &str) -> String {
64    // In case of ambiguity between italic and underline entities
65    // ‘__’ is always greedily treated from left to right as beginning or end of
66    // underline entity, so instead of ___italic underline___ we should use
67    // ___italic underline_\r__, where \r is a character with code 13, which
68    // will be ignored.
69    if s.starts_with('_') && s.ends_with('_') { format!(r"__{s}\r__") } else { format!("__{s}__") }
70}
71
72/// Applies the strikethrough font style to the string.
73///
74/// Passed string will not be automatically escaped because it can contain
75/// nested markup.
76#[must_use = "This function returns a new string, rather than mutating the argument, so calling it \
77              without using its output does nothing useful"]
78pub fn strike(s: &str) -> String {
79    format!("~{s}~")
80}
81
82/// Builds an inline link with an anchor.
83///
84/// Escapes `)` and ``` characters inside the link url.
85#[must_use = "This function returns a new string, rather than mutating the argument, so calling it \
86              without using its output does nothing useful"]
87pub fn link(url: &str, text: &str) -> String {
88    format!("[{}]({})", text, escape_link_url(url))
89}
90
91/// Builds an inline user mention link with an anchor.
92#[must_use = "This function returns a new string, rather than mutating the argument, so calling it \
93              without using its output does nothing useful"]
94pub fn user_mention(user_id: UserId, text: &str) -> String {
95    link(format!("tg://user?id={user_id}").as_str(), text)
96}
97
98/// Formats the code block.
99///
100/// Escapes ``` and `\` characters inside the block.
101#[must_use = "This function returns a new string, rather than mutating the argument, so calling it \
102              without using its output does nothing useful"]
103pub fn code_block(code: &str) -> String {
104    format!("```\n{}\n```", escape_code(code))
105}
106
107/// Formats the code block with a specific language syntax.
108///
109/// Escapes ``` and `\` characters inside the block.
110#[must_use = "This function returns a new string, rather than mutating the argument, so calling it \
111              without using its output does nothing useful"]
112pub fn code_block_with_lang(code: &str, lang: &str) -> String {
113    format!("```{}\n{}\n```", escape(lang), escape_code(code))
114}
115
116/// Formats the string as an inline code.
117///
118/// Escapes ``` and `\` characters inside the block.
119#[must_use = "This function returns a new string, rather than mutating the argument, so calling it \
120              without using its output does nothing useful"]
121pub fn code_inline(s: &str) -> String {
122    format!("`{}`", escape_code(s))
123}
124
125/// Escapes the string to be shown "as is" within the Telegram [Markdown
126/// v2][spec] message style.
127///
128/// [spec]: https://core.telegram.org/bots/api#markdownv2-style
129#[must_use = "This function returns a new string, rather than mutating the argument, so calling it \
130              without using its output does nothing useful"]
131pub fn escape(s: &str) -> String {
132    s.chars().fold(String::with_capacity(s.len()), |mut s, c| {
133        if ESCAPE_CHARS.contains(&c) {
134            s.push('\\');
135        }
136        s.push(c);
137        s
138    })
139}
140
141/// Escapes all markdown special characters specific for the inline link URL
142/// (``` and `)`).
143#[must_use = "This function returns a new string, rather than mutating the argument, so calling it \
144              without using its output does nothing useful"]
145pub fn escape_link_url(s: &str) -> String {
146    s.chars().fold(String::with_capacity(s.len()), |mut s, c| {
147        if ['`', ')'].contains(&c) {
148            s.push('\\');
149        }
150        s.push(c);
151        s
152    })
153}
154
155/// Escapes all markdown special characters specific for the code block (``` and
156/// `\`).
157#[must_use = "This function returns a new string, rather than mutating the argument, so calling it \
158              without using its output does nothing useful"]
159pub fn escape_code(s: &str) -> String {
160    s.chars().fold(String::with_capacity(s.len()), |mut s, c| {
161        if ['`', '\\'].contains(&c) {
162            s.push('\\');
163        }
164        s.push(c);
165        s
166    })
167}
168
169#[must_use = "This function returns a new string, rather than mutating the argument, so calling it \
170              without using its output does nothing useful"]
171pub fn user_mention_or_link(user: &User) -> String {
172    match user.mention() {
173        Some(mention) => escape(&mention),
174        None => link(user.url().as_str(), &escape(&user.full_name())),
175    }
176}
177
178/// Formats text as a date-time entity for MarkdownV2.
179#[must_use = "This function returns a new string, rather than mutating the argument, so calling it \
180              without using its output does nothing useful"]
181pub fn date_time(text: &str, unix_time: i64, format: Option<&str>) -> String {
182    let escaped_text = escape(text);
183    match format {
184        Some(fmt) => format!("![{escaped_text}](tg://time?unix={unix_time}&format={fmt})"),
185        None => format!("![{escaped_text}](tg://time?unix={unix_time})"),
186    }
187}
188
189#[cfg(test)]
190mod tests {
191    use super::*;
192
193    #[test]
194    fn test_bold() {
195        assert_eq!(bold(" foobar "), "* foobar *");
196        assert_eq!(bold(" _foobar_ "), "* _foobar_ *");
197        assert_eq!(bold("~(`foobar`)~"), "*~(`foobar`)~*");
198    }
199
200    #[test]
201    fn test_italic() {
202        assert_eq!(italic(" foobar "), "_ foobar _");
203        assert_eq!(italic("*foobar*"), "_*foobar*_");
204        assert_eq!(italic("~(foobar)~"), "_~(foobar)~_");
205    }
206
207    #[test]
208    fn test_underline() {
209        assert_eq!(underline(" foobar "), "__ foobar __");
210        assert_eq!(underline("*foobar*"), "__*foobar*__");
211        assert_eq!(underline("~(foobar)~"), "__~(foobar)~__");
212    }
213
214    #[test]
215    fn test_strike() {
216        assert_eq!(strike(" foobar "), "~ foobar ~");
217        assert_eq!(strike("*foobar*"), "~*foobar*~");
218        assert_eq!(strike("*(foobar)*"), "~*(foobar)*~");
219    }
220
221    #[test]
222    fn test_italic_with_underline() {
223        assert_eq!(underline(italic("foobar").as_str()), r"___foobar_\r__");
224        assert_eq!(italic(underline("foobar").as_str()), r"___foobar_\r__");
225    }
226
227    #[test]
228    fn test_link() {
229        assert_eq!(
230            link("https://www.google.com/(`foobar`)", "google"),
231            r"[google](https://www.google.com/(\`foobar\`\))",
232        );
233    }
234
235    #[test]
236    fn test_user_mention() {
237        assert_eq!(
238            user_mention(UserId(123_456_789), "pwner666"),
239            "[pwner666](tg://user?id=123456789)"
240        );
241    }
242
243    #[test]
244    fn test_blockquote() {
245        assert_eq!(blockquote("foobar\n\nfoo\nbar"), "**>foobar\n>\n>foo\n>bar");
246    }
247
248    #[test]
249    fn test_expandable_blockquote() {
250        assert_eq!(expandable_blockquote("foobar\n\nfoo\nbar"), "**>foobar\n>\n>foo\n>bar||");
251    }
252
253    #[test]
254    fn test_code_block() {
255        assert_eq!(
256            code_block("pre-'formatted'\nfixed-width \\code `block`"),
257            "```\npre-'formatted'\nfixed-width \\\\code \\`block\\`\n```"
258        );
259    }
260
261    #[test]
262    fn test_code_block_with_lang() {
263        assert_eq!(
264            code_block_with_lang("pre-'formatted'\nfixed-width \\code `block`", "[python]"),
265            "```\\[python\\]\npre-'formatted'\nfixed-width \\\\code \\`block\\`\n```"
266        );
267    }
268
269    #[test]
270    fn test_code_inline() {
271        assert_eq!(code_inline(" let x = (1, 2, 3); "), "` let x = (1, 2, 3); `");
272        assert_eq!(code_inline("<html>foo</html>"), "`<html>foo</html>`");
273        assert_eq!(code_inline(r" `(code inside code \ )` "), r"` \`(code inside code \\ )\` `");
274    }
275
276    #[test]
277    fn test_escape() {
278        assert_eq!(escape("\\!"), r"\\\!");
279        assert_eq!(escape("* foobar *"), r"\* foobar \*");
280        assert_eq!(
281            escape(r"_ * [ ] ( ) ~ \ ` > # + - = | { } . !"),
282            r"\_ \* \[ \] \( \) \~ \\ \` \> \# \+ \- \= \| \{ \} \. \!",
283        );
284    }
285
286    #[test]
287    fn test_escape_link_url() {
288        assert_eq!(
289            escape_link_url(r"https://en.wikipedia.org/wiki/Development+(Software)"),
290            r"https://en.wikipedia.org/wiki/Development+(Software\)"
291        );
292        assert_eq!(
293            escape_link_url(r"https://en.wikipedia.org/wiki/`"),
294            r"https://en.wikipedia.org/wiki/\`"
295        );
296        assert_eq!(escape_link_url(r"_*[]()~`#+-=|{}.!\"), r"_*[](\)~\`#+-=|{}.!\");
297    }
298
299    #[test]
300    fn test_escape_code() {
301        assert_eq!(escape_code(r"` \code inside the code\ `"), r"\` \\code inside the code\\ \`");
302        assert_eq!(escape_code(r"_*[]()~`#+-=|{}.!\"), r"_*[]()~\`#+-=|{}.!\\");
303    }
304
305    #[test]
306    fn user_mention_link() {
307        let user_with_username = User {
308            id: UserId(0),
309            is_bot: false,
310            first_name: "".to_string(),
311            last_name: None,
312            username: Some("abcd".to_string()),
313            language_code: None,
314            is_premium: false,
315            added_to_attachment_menu: false,
316            can_manage_bots: false,
317            supports_guest_queries: false,
318            supports_join_request_queries: false,
319        };
320        assert_eq!(user_mention_or_link(&user_with_username), "@abcd");
321        // Telegram allows underscores in usernames
322        // https://telegram.org/faq?setln=en#q-what-can-i-use-as-my-username
323        let user_with_username_underscore =
324            User { username: Some("Abcd_Efg".to_string()), ..user_with_username };
325        assert_eq!(user_mention_or_link(&user_with_username_underscore), "@Abcd\\_Efg");
326        let user_without_username = User {
327            id: UserId(123_456_789),
328            is_bot: false,
329            first_name: "Name".to_string(),
330            last_name: None,
331            username: None,
332            language_code: None,
333            is_premium: false,
334            added_to_attachment_menu: false,
335            can_manage_bots: false,
336            supports_guest_queries: false,
337            supports_join_request_queries: false,
338        };
339        assert_eq!(user_mention_or_link(&user_without_username), "[Name](tg://user/?id=123456789)")
340    }
341}