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