]> git.giacomolli.uk - onechat/blob - telegram_markdown.go
Rename the test files of shared helpers
[onechat] / telegram_markdown.go
1 package main
2
3 import (
4         "slices"
5         "strings"
6         "unicode"
7         "unicode/utf8"
8 )
9
10 // telegramMarkdownPunctuation holds the ASCII characters that a backslash makes
11 // plain.
12 const telegramMarkdownPunctuation = "!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~"
13
14 // telegramMarkdownEntities converts the Markdown of a reply to plain text and
15 // Telegram message entities. It knows bold (** and __), italic (* and _),
16 // strikethrough (~~), inline code, fenced code blocks with an optional
17 // language, links [text](url), and ATX headings, which become bold. Lists,
18 // quotes, and tables stay as their text. A mark that does not close stays as
19 // text with no entity, and code holds no entity.
20 //
21 // Inline marks do not cross a line break, so a mark that does not close cannot
22 // change the rest of the reply.
23 func telegramMarkdownEntities(markdown string) (string, []TelegramEntity) {
24         var out TelegramMarkdownText
25         lines := strings.Split(markdown, "\n")
26         for index := 0; index < len(lines); index++ {
27                 if index > 0 {
28                         out.add("\n")
29                 }
30
31                 line := lines[index]
32                 language, fence := telegramCodeFence(line)
33                 end := telegramClosingFence(lines, index, fence)
34                 heading := telegramHeadingText(line)
35                 switch {
36                 case end > index:
37                         start := out.units
38                         out.add(strings.Join(lines[index+1:end], "\n"))
39                         out.entity(TelegramEntity{
40                                 Type:     "pre",
41                                 Offset:   start,
42                                 Length:   out.units - start,
43                                 Language: language,
44                         })
45
46                         index = end
47                 case heading != "":
48                         start := out.units
49                         out.inline(heading)
50                         out.entity(TelegramEntity{
51                                 Type:   "bold",
52                                 Offset: start,
53                                 Length: out.units - start,
54                         })
55                 default:
56                         out.inline(line)
57                 }
58         }
59
60         slices.SortStableFunc(out.entities, func(a, b TelegramEntity) int {
61                 if a.Offset != b.Offset {
62                         return a.Offset - b.Offset
63                 }
64
65                 return b.Length - a.Length
66         })
67
68         return out.text.String(), out.entities
69 }
70
71 // telegramEscapeTags puts a backslash before each < of markdown outside inline
72 // code and fenced code blocks, so that rich Markdown shows a tag name as text
73 // and reads no raw HTML. A < that a backslash already makes plain stays as it
74 // is. A code block that does not close runs to the end, as in Markdown.
75 func telegramEscapeTags(markdown string) string {
76         var out strings.Builder
77         lines := strings.Split(markdown, "\n")
78         for index := 0; index < len(lines); index++ {
79                 if index > 0 {
80                         out.WriteString("\n")
81                 }
82
83                 line := lines[index]
84                 _, fence := telegramCodeFence(line)
85                 if fence == "" {
86                         telegramEscapeLineTags(&out, line)
87                         continue
88                 }
89
90                 end := telegramClosingFence(lines, index, fence)
91                 if end < 0 {
92                         end = len(lines) - 1
93                 }
94
95                 out.WriteString(strings.Join(lines[index:end+1], "\n"))
96                 index = end
97         }
98
99         return out.String()
100 }
101
102 // telegramCodeFence returns the language of a line that opens a fenced code
103 // block, and the fence. The fence is "" when line does not open a block.
104 func telegramCodeFence(line string) (string, string) {
105         trimmed := strings.TrimLeft(line, " ")
106         if len(line)-len(trimmed) > 3 || !strings.HasPrefix(trimmed, "```") {
107                 return "", ""
108         }
109
110         run := len(trimmed) - len(strings.TrimLeft(trimmed, "`"))
111         info := strings.TrimSpace(trimmed[run:])
112         if strings.Contains(info, "`") {
113                 return "", ""
114         }
115
116         language, _, _ := strings.Cut(info, " ")
117
118         return language, trimmed[:run]
119 }
120
121 // telegramClosesFence tells if line closes the code block of fence.
122 func telegramClosesFence(line, fence string) bool {
123         trimmed := strings.TrimSpace(line)
124         return strings.HasPrefix(trimmed, fence) && strings.Trim(trimmed, "`") == ""
125 }
126
127 // telegramUTF16Units returns the number of UTF-16 code units of text.
128 func telegramUTF16Units(text string) int {
129         units := 0
130         for _, char := range text {
131                 units++
132                 if char > 0xFFFF {
133                         units++
134                 }
135         }
136
137         return units
138 }
139
140 // TelegramMarkdownText is the plain text of a Markdown reply and its Telegram
141 // entities, while the conversion builds it. Units counts the UTF-16 code units
142 // of text, the unit of the entity offsets and lengths.
143 type TelegramMarkdownText struct {
144         text     strings.Builder
145         units    int
146         entities []TelegramEntity
147 }
148
149 // add appends text.
150 func (m *TelegramMarkdownText) add(text string) {
151         m.text.WriteString(text)
152         m.units += telegramUTF16Units(text)
153 }
154
155 // entity keeps entity, unless it is empty, because Telegram refuses an entity
156 // of length 0.
157 func (m *TelegramMarkdownText) entity(entity TelegramEntity) {
158         if entity.Length > 0 {
159                 m.entities = append(m.entities, entity)
160         }
161 }
162
163 // inline appends one line of Markdown with its inline marks as entities. A
164 // backslash before an ASCII punctuation character makes the character plain
165 // text.
166 func (m *TelegramMarkdownText) inline(line string) {
167         plain := 0
168         index := 0
169         for index < len(line) {
170                 if line[index] == '\\' && index+1 < len(line) && strings.IndexByte(telegramMarkdownPunctuation, line[index+1]) >= 0 {
171                         m.add(line[plain:index])
172                         plain = index + 1
173                         index += 2
174
175                         continue
176                 }
177
178                 span, found := telegramMarkSpan(line, index)
179                 if !found {
180                         index++
181                         continue
182                 }
183
184                 m.add(line[plain:index])
185                 start := m.units
186                 if span.kind == "code" {
187                         m.add(span.content)
188                 } else {
189                         m.inline(span.content)
190                 }
191
192                 m.entity(TelegramEntity{
193                         Type:   span.kind,
194                         Offset: start,
195                         Length: m.units - start,
196                         URL:    span.url,
197                 })
198
199                 index = span.end
200                 plain = span.end
201         }
202
203         m.add(line[plain:])
204 }
205
206 // TelegramMarkdownSpan is an inline mark of a line: the entity type, the
207 // content inside the marks, the index after the closing mark, and the URL of a
208 // link.
209 type TelegramMarkdownSpan struct {
210         kind    string
211         content string
212         end     int
213         url     string
214 }
215
216 // telegramClosingFence returns the index of the line after index that closes
217 // the code block of fence, or -1 when fence is "" or no line closes it.
218 func telegramClosingFence(lines []string, index int, fence string) int {
219         if fence == "" {
220                 return -1
221         }
222
223         for end := index + 1; end < len(lines); end++ {
224                 if telegramClosesFence(lines[end], fence) {
225                         return end
226                 }
227         }
228
229         return -1
230 }
231
232 // telegramHeadingText returns the text of an ATX heading line without its
233 // marks, or "" when line is not a heading or has no text.
234 func telegramHeadingText(line string) string {
235         trimmed := strings.TrimLeft(line, " ")
236         if len(line)-len(trimmed) > 3 {
237                 return ""
238         }
239
240         level := len(trimmed) - len(strings.TrimLeft(trimmed, "#"))
241         if level == 0 || level > 6 {
242                 return ""
243         }
244
245         rest := trimmed[level:]
246         if rest != "" && rest[0] != ' ' && rest[0] != '\t' {
247                 return ""
248         }
249
250         rest = strings.TrimSpace(rest)
251         closed := strings.TrimRight(rest, "#")
252         if closed == "" || strings.HasSuffix(closed, " ") {
253                 rest = strings.TrimSpace(closed)
254         }
255
256         return rest
257 }
258
259 // telegramEscapeLineTags writes line to out with a backslash before each < that
260 // is outside inline code and not already plain. A run of backticks that opens
261 // no inline code stays as text whole, as in Markdown.
262 func telegramEscapeLineTags(out *strings.Builder, line string) {
263         for index := 0; index < len(line); {
264                 switch line[index] {
265                 case '\\':
266                         if index+1 < len(line) && strings.IndexByte(telegramMarkdownPunctuation, line[index+1]) >= 0 {
267                                 out.WriteString(line[index : index+2])
268                                 index += 2
269
270                                 continue
271                         }
272                 case '`':
273                         end := index + telegramBackticks(line[index:])
274                         span, found := telegramCodeSpan(line, index)
275                         if found {
276                                 end = span.end
277                         }
278
279                         out.WriteString(line[index:end])
280                         index = end
281
282                         continue
283                 case '<':
284                         out.WriteByte('\\')
285                 }
286
287                 out.WriteByte(line[index])
288                 index++
289         }
290 }
291
292 // telegramMarkSpan returns the inline mark that starts at index of line and
293 // closes in line. It returns false when no mark starts there or the mark does
294 // not close.
295 func telegramMarkSpan(line string, index int) (TelegramMarkdownSpan, bool) {
296         switch line[index] {
297         case '`':
298                 return telegramCodeSpan(line, index)
299         case '[':
300                 return telegramLinkSpan(line, index)
301         case '~':
302                 return telegramDelimited(line, index, "~~", "strikethrough")
303         case '*', '_':
304                 double := line[index : index+1]
305                 double += double
306                 span, found := telegramDelimited(line, index, double, "bold")
307                 if found {
308                         return span, true
309                 }
310
311                 return telegramDelimited(line, index, line[index:index+1], "italic")
312         default:
313                 return TelegramMarkdownSpan{}, false
314         }
315 }
316
317 // telegramCodeSpan returns the inline code that starts at index: a run of
318 // backticks, the code, and a run of the same length. One space on each side of
319 // the code is left out, as in Markdown.
320 func telegramCodeSpan(line string, index int) (TelegramMarkdownSpan, bool) {
321         run := telegramBackticks(line[index:])
322         for search := index + run; search < len(line); {
323                 next := strings.IndexByte(line[search:], '`')
324                 if next < 0 {
325                         break
326                 }
327
328                 next += search
329                 length := telegramBackticks(line[next:])
330                 if length != run {
331                         search = next + length
332                         continue
333                 }
334
335                 content := line[index+run : next]
336                 if strings.TrimSpace(content) == "" {
337                         break
338                 }
339
340                 if len(content) > 1 && content[0] == ' ' && content[len(content)-1] == ' ' {
341                         content = content[1 : len(content)-1]
342                 }
343
344                 return TelegramMarkdownSpan{
345                         kind:    "code",
346                         content: content,
347                         end:     next + run,
348                 }, true
349         }
350
351         return TelegramMarkdownSpan{}, false
352 }
353
354 // telegramLinkSpan returns the link [text](url) that starts at index. The text
355 // can hold brackets in pairs, and the URL holds no space.
356 func telegramLinkSpan(line string, index int) (TelegramMarkdownSpan, bool) {
357         depth := 0
358         for closing := index; closing < len(line); closing++ {
359                 switch line[closing] {
360                 case '\\':
361                         closing++
362                 case '[':
363                         depth++
364                 case ']':
365                         depth--
366                 }
367
368                 if depth > 0 {
369                         continue
370                 }
371
372                 rest := line[closing+1:]
373                 if closing == index+1 || !strings.HasPrefix(rest, "(") {
374                         return TelegramMarkdownSpan{}, false
375                 }
376
377                 url, _, found := strings.Cut(rest[1:], ")")
378                 if !found || url == "" || strings.ContainsAny(url, " \t") {
379                         return TelegramMarkdownSpan{}, false
380                 }
381
382                 return TelegramMarkdownSpan{
383                         kind:    "text_link",
384                         content: line[index+1 : closing],
385                         end:     closing + 2 + len(url) + 1,
386                         url:     url,
387                 }, true
388         }
389
390         return TelegramMarkdownSpan{}, false
391 }
392
393 // telegramDelimited returns the span of kind between the mark at index and the
394 // same mark later in line. An opening mark is followed by a character that is
395 // not a space, and a closing mark follows one. A single mark is not a part of a
396 // double one. A mark of underscores does not open or close inside a word, so
397 // snake_case stays as text. A code span inside the content is skipped whole.
398 func telegramDelimited(line string, index int, mark, kind string) (TelegramMarkdownSpan, bool) {
399         char := mark[0]
400         after := index + len(mark)
401         if !strings.HasPrefix(line[index:], mark) || after >= len(line) || telegramIsSpace(line[after]) {
402                 return TelegramMarkdownSpan{}, false
403         }
404
405         if len(mark) == 1 && (line[after] == char || index > 0 && line[index-1] == char) {
406                 return TelegramMarkdownSpan{}, false
407         }
408
409         if char == '_' && index > 0 && telegramWordCharBefore(line[:index]) {
410                 return TelegramMarkdownSpan{}, false
411         }
412
413         for closing := after + 1; closing < len(line); closing++ {
414                 switch {
415                 case line[closing] == '\\':
416                         closing++
417                         continue
418                 case line[closing] == '`':
419                         span, found := telegramCodeSpan(line, closing)
420                         if found {
421                                 closing = span.end - 1
422                         }
423
424                         continue
425                 case !strings.HasPrefix(line[closing:], mark) || telegramIsSpace(line[closing-1]):
426                         continue
427                 }
428
429                 end := closing + len(mark)
430                 if len(mark) == 1 && (line[closing-1] == char || end < len(line) && line[end] == char) {
431                         continue
432                 }
433
434                 if char == '_' && telegramWordChar(line[end:]) {
435                         continue
436                 }
437
438                 return TelegramMarkdownSpan{
439                         kind:    kind,
440                         content: line[after:closing],
441                         end:     end,
442                 }, true
443         }
444
445         return TelegramMarkdownSpan{}, false
446 }
447
448 // telegramBackticks returns the length of the run of backticks at the start of
449 // text.
450 func telegramBackticks(text string) int {
451         return len(text) - len(strings.TrimLeft(text, "`"))
452 }
453
454 // telegramIsSpace tells if char is a space, a tab, or a line break.
455 func telegramIsSpace(char byte) bool {
456         return char == ' ' || char == '\t' || char == '\n' || char == '\r'
457 }
458
459 // telegramWordCharBefore tells if the character that ends text is a letter or a
460 // digit.
461 func telegramWordCharBefore(text string) bool {
462         char, _ := utf8.DecodeLastRuneInString(text)
463         return text != "" && (unicode.IsLetter(char) || unicode.IsDigit(char))
464 }
465
466 // telegramWordChar tells if the character that starts text is a letter or a
467 // digit. An underscore next to one is a part of a word, such as in snake_case.
468 func telegramWordChar(text string) bool {
469         char, _ := utf8.DecodeRuneInString(text)
470         return text != "" && (unicode.IsLetter(char) || unicode.IsDigit(char))
471 }