]> git.giacomolli.uk - onechat/blobdiff - telegram_markdown.go
Name and order the Telegram bot code
[onechat] / telegram_markdown.go
similarity index 52%
rename from markdown.go
rename to telegram_markdown.go
index 1a2f4feb1343723a704a874c2c586e78a0fee8d6..54bfbcb36031565e89fa67621ceaf2f78e480d92 100644 (file)
@@ -7,29 +7,12 @@ import (
        "unicode/utf8"
 )
 
        "unicode/utf8"
 )
 
-// markdownPunctuation holds the ASCII characters that a backslash makes plain.
-const markdownPunctuation = "!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~"
+// telegramMarkdownPunctuation holds the ASCII characters that a backslash makes
+// plain.
+const telegramMarkdownPunctuation = "!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~"
 
 
-// markdownText is the plain text of a Markdown reply and its Telegram entities,
-// while the conversion builds it. Units counts the UTF-16 code units of text,
-// the unit of the entity offsets and lengths.
-type markdownText struct {
-       text     strings.Builder
-       units    int
-       entities []botEntity
-}
-
-// markdownSpan is an inline mark of a line: the entity type, the content inside
-// the marks, the index after the closing mark, and the URL of a link.
-type markdownSpan struct {
-       kind    string
-       content string
-       end     int
-       url     string
-}
-
-// markdownEntities converts the Markdown of a reply to plain text and Telegram
-// message entities. It knows bold (** and __), italic (* and _),
+// telegramMarkdownEntities converts the Markdown of a reply to plain text and
+// Telegram message entities. It knows bold (** and __), italic (* and _),
 // strikethrough (~~), inline code, fenced code blocks with an optional
 // language, links [text](url), and ATX headings, which become bold. Lists,
 // quotes, and tables stay as their text. A mark that does not close stays as
 // strikethrough (~~), inline code, fenced code blocks with an optional
 // language, links [text](url), and ATX headings, which become bold. Lists,
 // quotes, and tables stay as their text. A mark that does not close stays as
@@ -37,8 +20,8 @@ type markdownSpan struct {
 //
 // Inline marks do not cross a line break, so a mark that does not close cannot
 // change the rest of the reply.
 //
 // Inline marks do not cross a line break, so a mark that does not close cannot
 // change the rest of the reply.
-func markdownEntities(markdown string) (string, []botEntity) {
-       var out markdownText
+func telegramMarkdownEntities(markdown string) (string, []TelegramEntity) {
+       var out TelegramMarkdownText
        lines := strings.Split(markdown, "\n")
        for index := 0; index < len(lines); index++ {
                if index > 0 {
        lines := strings.Split(markdown, "\n")
        for index := 0; index < len(lines); index++ {
                if index > 0 {
@@ -46,14 +29,14 @@ func markdownEntities(markdown string) (string, []botEntity) {
                }
 
                line := lines[index]
                }
 
                line := lines[index]
-               language, fence := codeFence(line)
-               end := closingFence(lines, index, fence)
-               heading := headingText(line)
+               language, fence := telegramCodeFence(line)
+               end := telegramClosingFence(lines, index, fence)
+               heading := telegramHeadingText(line)
                switch {
                case end > index:
                        start := out.units
                        out.add(strings.Join(lines[index+1:end], "\n"))
                switch {
                case end > index:
                        start := out.units
                        out.add(strings.Join(lines[index+1:end], "\n"))
-                       out.entity(botEntity{
+                       out.entity(TelegramEntity{
                                Type:     "pre",
                                Offset:   start,
                                Length:   out.units - start,
                                Type:     "pre",
                                Offset:   start,
                                Length:   out.units - start,
@@ -64,7 +47,7 @@ func markdownEntities(markdown string) (string, []botEntity) {
                case heading != "":
                        start := out.units
                        out.inline(heading)
                case heading != "":
                        start := out.units
                        out.inline(heading)
-                       out.entity(botEntity{
+                       out.entity(TelegramEntity{
                                Type:   "bold",
                                Offset: start,
                                Length: out.units - start,
                                Type:   "bold",
                                Offset: start,
                                Length: out.units - start,
@@ -74,7 +57,7 @@ func markdownEntities(markdown string) (string, []botEntity) {
                }
        }
 
                }
        }
 
-       slices.SortStableFunc(out.entities, func(a, b botEntity) int {
+       slices.SortStableFunc(out.entities, func(a, b TelegramEntity) int {
                if a.Offset != b.Offset {
                        return a.Offset - b.Offset
                }
                if a.Offset != b.Offset {
                        return a.Offset - b.Offset
                }
@@ -85,15 +68,62 @@ func markdownEntities(markdown string) (string, []botEntity) {
        return out.text.String(), out.entities
 }
 
        return out.text.String(), out.entities
 }
 
+// telegramCodeFence returns the language of a line that opens a fenced code
+// block, and the fence. The fence is "" when line does not open a block.
+func telegramCodeFence(line string) (string, string) {
+       trimmed := strings.TrimLeft(line, " ")
+       if len(line)-len(trimmed) > 3 || !strings.HasPrefix(trimmed, "```") {
+               return "", ""
+       }
+
+       run := len(trimmed) - len(strings.TrimLeft(trimmed, "`"))
+       info := strings.TrimSpace(trimmed[run:])
+       if strings.Contains(info, "`") {
+               return "", ""
+       }
+
+       language, _, _ := strings.Cut(info, " ")
+
+       return language, trimmed[:run]
+}
+
+// telegramClosesFence tells if line closes the code block of fence.
+func telegramClosesFence(line, fence string) bool {
+       trimmed := strings.TrimSpace(line)
+       return strings.HasPrefix(trimmed, fence) && strings.Trim(trimmed, "`") == ""
+}
+
+// telegramUTF16Units returns the number of UTF-16 code units of text.
+func telegramUTF16Units(text string) int {
+       units := 0
+       for _, char := range text {
+               units++
+               if char > 0xFFFF {
+                       units++
+               }
+       }
+
+       return units
+}
+
+// TelegramMarkdownText is the plain text of a Markdown reply and its Telegram
+// entities, while the conversion builds it. Units counts the UTF-16 code units
+// of text, the unit of the entity offsets and lengths.
+type TelegramMarkdownText struct {
+       text     strings.Builder
+       units    int
+       entities []TelegramEntity
+}
+
 // add appends text.
 // add appends text.
-func (m *markdownText) add(text string) {
+func (m *TelegramMarkdownText) add(text string) {
        m.text.WriteString(text)
        m.text.WriteString(text)
-       m.units += utf16Units(text)
+       m.units += telegramUTF16Units(text)
 }
 
 // entity keeps entity, unless it is empty, because Telegram refuses an entity
 // of length 0.
 }
 
 // entity keeps entity, unless it is empty, because Telegram refuses an entity
 // of length 0.
-func (m *markdownText) entity(entity botEntity) {
+func (m *TelegramMarkdownText) entity(entity TelegramEntity) {
        if entity.Length > 0 {
                m.entities = append(m.entities, entity)
        }
        if entity.Length > 0 {
                m.entities = append(m.entities, entity)
        }
@@ -102,11 +132,11 @@ func (m *markdownText) entity(entity botEntity) {
 // inline appends one line of Markdown with its inline marks as entities. A
 // backslash before an ASCII punctuation character makes the character plain
 // text.
 // inline appends one line of Markdown with its inline marks as entities. A
 // backslash before an ASCII punctuation character makes the character plain
 // text.
-func (m *markdownText) inline(line string) {
+func (m *TelegramMarkdownText) inline(line string) {
        plain := 0
        index := 0
        for index < len(line) {
        plain := 0
        index := 0
        for index < len(line) {
-               if line[index] == '\\' && index+1 < len(line) && strings.IndexByte(markdownPunctuation, line[index+1]) >= 0 {
+               if line[index] == '\\' && index+1 < len(line) && strings.IndexByte(telegramMarkdownPunctuation, line[index+1]) >= 0 {
                        m.add(line[plain:index])
                        plain = index + 1
                        index += 2
                        m.add(line[plain:index])
                        plain = index + 1
                        index += 2
@@ -114,7 +144,7 @@ func (m *markdownText) inline(line string) {
                        continue
                }
 
                        continue
                }
 
-               span, found := markSpan(line, index)
+               span, found := telegramMarkSpan(line, index)
                if !found {
                        index++
                        continue
                if !found {
                        index++
                        continue
@@ -128,7 +158,7 @@ func (m *markdownText) inline(line string) {
                        m.inline(span.content)
                }
 
                        m.inline(span.content)
                }
 
-               m.entity(botEntity{
+               m.entity(TelegramEntity{
                        Type:   span.kind,
                        Offset: start,
                        Length: m.units - start,
                        Type:   span.kind,
                        Offset: start,
                        Length: m.units - start,
@@ -142,34 +172,25 @@ func (m *markdownText) inline(line string) {
        m.add(line[plain:])
 }
 
        m.add(line[plain:])
 }
 
-// codeFence returns the language of a line that opens a fenced code block, and
-// the fence. The fence is "" when line does not open a block.
-func codeFence(line string) (string, string) {
-       trimmed := strings.TrimLeft(line, " ")
-       if len(line)-len(trimmed) > 3 || !strings.HasPrefix(trimmed, "```") {
-               return "", ""
-       }
-
-       run := len(trimmed) - len(strings.TrimLeft(trimmed, "`"))
-       info := strings.TrimSpace(trimmed[run:])
-       if strings.Contains(info, "`") {
-               return "", ""
-       }
-
-       language, _, _ := strings.Cut(info, " ")
-
-       return language, trimmed[:run]
+// TelegramMarkdownSpan is an inline mark of a line: the entity type, the
+// content inside the marks, the index after the closing mark, and the URL of a
+// link.
+type TelegramMarkdownSpan struct {
+       kind    string
+       content string
+       end     int
+       url     string
 }
 
 }
 
-// closingFence returns the index of the line after index that closes the code
-// block of fence, or -1 when fence is "" or no line closes it.
-func closingFence(lines []string, index int, fence string) int {
+// telegramClosingFence returns the index of the line after index that closes
+// the code block of fence, or -1 when fence is "" or no line closes it.
+func telegramClosingFence(lines []string, index int, fence string) int {
        if fence == "" {
                return -1
        }
 
        for end := index + 1; end < len(lines); end++ {
        if fence == "" {
                return -1
        }
 
        for end := index + 1; end < len(lines); end++ {
-               if closesFence(lines[end], fence) {
+               if telegramClosesFence(lines[end], fence) {
                        return end
                }
        }
                        return end
                }
        }
@@ -177,15 +198,9 @@ func closingFence(lines []string, index int, fence string) int {
        return -1
 }
 
        return -1
 }
 
-// closesFence tells if line closes the code block of fence.
-func closesFence(line, fence string) bool {
-       trimmed := strings.TrimSpace(line)
-       return strings.HasPrefix(trimmed, fence) && strings.Trim(trimmed, "`") == ""
-}
-
-// headingText returns the text of an ATX heading line without its marks, or ""
-// when line is not a heading or has no text.
-func headingText(line string) string {
+// telegramHeadingText returns the text of an ATX heading line without its
+// marks, or "" when line is not a heading or has no text.
+func telegramHeadingText(line string) string {
        trimmed := strings.TrimLeft(line, " ")
        if len(line)-len(trimmed) > 3 {
                return ""
        trimmed := strings.TrimLeft(line, " ")
        if len(line)-len(trimmed) > 3 {
                return ""
@@ -210,48 +225,36 @@ func headingText(line string) string {
        return rest
 }
 
        return rest
 }
 
-// utf16Units returns the number of UTF-16 code units of text.
-func utf16Units(text string) int {
-       units := 0
-       for _, char := range text {
-               units++
-               if char > 0xFFFF {
-                       units++
-               }
-       }
-
-       return units
-}
-
-// markSpan returns the inline mark that starts at index of line and closes in
-// line. It returns false when no mark starts there or the mark does not close.
-func markSpan(line string, index int) (markdownSpan, bool) {
+// telegramMarkSpan returns the inline mark that starts at index of line and
+// closes in line. It returns false when no mark starts there or the mark does
+// not close.
+func telegramMarkSpan(line string, index int) (TelegramMarkdownSpan, bool) {
        switch line[index] {
        case '`':
        switch line[index] {
        case '`':
-               return codeSpan(line, index)
+               return telegramCodeSpan(line, index)
        case '[':
        case '[':
-               return linkSpan(line, index)
+               return telegramLinkSpan(line, index)
        case '~':
        case '~':
-               return delimited(line, index, "~~", "strikethrough")
+               return telegramDelimited(line, index, "~~", "strikethrough")
        case '*', '_':
                double := line[index : index+1]
                double += double
        case '*', '_':
                double := line[index : index+1]
                double += double
-               span, found := delimited(line, index, double, "bold")
+               span, found := telegramDelimited(line, index, double, "bold")
                if found {
                        return span, true
                }
 
                if found {
                        return span, true
                }
 
-               return delimited(line, index, line[index:index+1], "italic")
+               return telegramDelimited(line, index, line[index:index+1], "italic")
        default:
        default:
-               return markdownSpan{}, false
+               return TelegramMarkdownSpan{}, false
        }
 }
 
        }
 }
 
-// codeSpan returns the inline code that starts at index: a run of backticks,
-// the code, and a run of the same length. One space on each side of the code
-// is left out, as in Markdown.
-func codeSpan(line string, index int) (markdownSpan, bool) {
-       run := backticks(line[index:])
+// telegramCodeSpan returns the inline code that starts at index: a run of
+// backticks, the code, and a run of the same length. One space on each side of
+// the code is left out, as in Markdown.
+func telegramCodeSpan(line string, index int) (TelegramMarkdownSpan, bool) {
+       run := telegramBackticks(line[index:])
        for search := index + run; search < len(line); {
                next := strings.IndexByte(line[search:], '`')
                if next < 0 {
        for search := index + run; search < len(line); {
                next := strings.IndexByte(line[search:], '`')
                if next < 0 {
@@ -259,7 +262,7 @@ func codeSpan(line string, index int) (markdownSpan, bool) {
                }
 
                next += search
                }
 
                next += search
-               length := backticks(line[next:])
+               length := telegramBackticks(line[next:])
                if length != run {
                        search = next + length
                        continue
                if length != run {
                        search = next + length
                        continue
@@ -274,19 +277,19 @@ func codeSpan(line string, index int) (markdownSpan, bool) {
                        content = content[1 : len(content)-1]
                }
 
                        content = content[1 : len(content)-1]
                }
 
-               return markdownSpan{
+               return TelegramMarkdownSpan{
                        kind:    "code",
                        content: content,
                        end:     next + run,
                }, true
        }
 
                        kind:    "code",
                        content: content,
                        end:     next + run,
                }, true
        }
 
-       return markdownSpan{}, false
+       return TelegramMarkdownSpan{}, false
 }
 
 }
 
-// linkSpan returns the link [text](url) that starts at index. The text can
-// hold brackets in pairs, and the URL holds no space.
-func linkSpan(line string, index int) (markdownSpan, bool) {
+// telegramLinkSpan returns the link [text](url) that starts at index. The text
+// can hold brackets in pairs, and the URL holds no space.
+func telegramLinkSpan(line string, index int) (TelegramMarkdownSpan, bool) {
        depth := 0
        for closing := index; closing < len(line); closing++ {
                switch line[closing] {
        depth := 0
        for closing := index; closing < len(line); closing++ {
                switch line[closing] {
@@ -304,15 +307,15 @@ func linkSpan(line string, index int) (markdownSpan, bool) {
 
                rest := line[closing+1:]
                if closing == index+1 || !strings.HasPrefix(rest, "(") {
 
                rest := line[closing+1:]
                if closing == index+1 || !strings.HasPrefix(rest, "(") {
-                       return markdownSpan{}, false
+                       return TelegramMarkdownSpan{}, false
                }
 
                url, _, found := strings.Cut(rest[1:], ")")
                if !found || url == "" || strings.ContainsAny(url, " \t") {
                }
 
                url, _, found := strings.Cut(rest[1:], ")")
                if !found || url == "" || strings.ContainsAny(url, " \t") {
-                       return markdownSpan{}, false
+                       return TelegramMarkdownSpan{}, false
                }
 
                }
 
-               return markdownSpan{
+               return TelegramMarkdownSpan{
                        kind:    "text_link",
                        content: line[index+1 : closing],
                        end:     closing + 2 + len(url) + 1,
                        kind:    "text_link",
                        content: line[index+1 : closing],
                        end:     closing + 2 + len(url) + 1,
@@ -320,27 +323,27 @@ func linkSpan(line string, index int) (markdownSpan, bool) {
                }, true
        }
 
                }, true
        }
 
-       return markdownSpan{}, false
+       return TelegramMarkdownSpan{}, false
 }
 
 }
 
-// delimited returns the span of kind between the mark at index and the same
-// mark later in line. An opening mark is followed by a character that is not a
-// space, and a closing mark follows one. A single mark is not a part of a
+// telegramDelimited returns the span of kind between the mark at index and the
+// same mark later in line. An opening mark is followed by a character that is
+// not a space, and a closing mark follows one. A single mark is not a part of a
 // double one. A mark of underscores does not open or close inside a word, so
 // snake_case stays as text. A code span inside the content is skipped whole.
 // double one. A mark of underscores does not open or close inside a word, so
 // snake_case stays as text. A code span inside the content is skipped whole.
-func delimited(line string, index int, mark, kind string) (markdownSpan, bool) {
+func telegramDelimited(line string, index int, mark, kind string) (TelegramMarkdownSpan, bool) {
        char := mark[0]
        after := index + len(mark)
        char := mark[0]
        after := index + len(mark)
-       if !strings.HasPrefix(line[index:], mark) || after >= len(line) || isSpace(line[after]) {
-               return markdownSpan{}, false
+       if !strings.HasPrefix(line[index:], mark) || after >= len(line) || telegramIsSpace(line[after]) {
+               return TelegramMarkdownSpan{}, false
        }
 
        if len(mark) == 1 && (line[after] == char || index > 0 && line[index-1] == char) {
        }
 
        if len(mark) == 1 && (line[after] == char || index > 0 && line[index-1] == char) {
-               return markdownSpan{}, false
+               return TelegramMarkdownSpan{}, false
        }
 
        }
 
-       if char == '_' && index > 0 && wordCharBefore(line[:index]) {
-               return markdownSpan{}, false
+       if char == '_' && index > 0 && telegramWordCharBefore(line[:index]) {
+               return TelegramMarkdownSpan{}, false
        }
 
        for closing := after + 1; closing < len(line); closing++ {
        }
 
        for closing := after + 1; closing < len(line); closing++ {
@@ -349,13 +352,13 @@ func delimited(line string, index int, mark, kind string) (markdownSpan, bool) {
                        closing++
                        continue
                case line[closing] == '`':
                        closing++
                        continue
                case line[closing] == '`':
-                       span, found := codeSpan(line, closing)
+                       span, found := telegramCodeSpan(line, closing)
                        if found {
                                closing = span.end - 1
                        }
 
                        continue
                        if found {
                                closing = span.end - 1
                        }
 
                        continue
-               case !strings.HasPrefix(line[closing:], mark) || isSpace(line[closing-1]):
+               case !strings.HasPrefix(line[closing:], mark) || telegramIsSpace(line[closing-1]):
                        continue
                }
 
                        continue
                }
 
@@ -364,39 +367,41 @@ func delimited(line string, index int, mark, kind string) (markdownSpan, bool) {
                        continue
                }
 
                        continue
                }
 
-               if char == '_' && wordChar(line[end:]) {
+               if char == '_' && telegramWordChar(line[end:]) {
                        continue
                }
 
                        continue
                }
 
-               return markdownSpan{
+               return TelegramMarkdownSpan{
                        kind:    kind,
                        content: line[after:closing],
                        end:     end,
                }, true
        }
 
                        kind:    kind,
                        content: line[after:closing],
                        end:     end,
                }, true
        }
 
-       return markdownSpan{}, false
+       return TelegramMarkdownSpan{}, false
 }
 
 }
 
-// backticks returns the length of the run of backticks at the start of text.
-func backticks(text string) int {
+// telegramBackticks returns the length of the run of backticks at the start of
+// text.
+func telegramBackticks(text string) int {
        return len(text) - len(strings.TrimLeft(text, "`"))
 }
 
        return len(text) - len(strings.TrimLeft(text, "`"))
 }
 
-// isSpace tells if char is a space, a tab, or a line break.
-func isSpace(char byte) bool {
+// telegramIsSpace tells if char is a space, a tab, or a line break.
+func telegramIsSpace(char byte) bool {
        return char == ' ' || char == '\t' || char == '\n' || char == '\r'
 }
 
        return char == ' ' || char == '\t' || char == '\n' || char == '\r'
 }
 
-// wordCharBefore tells if the character that ends text is a letter or a digit.
-func wordCharBefore(text string) bool {
+// telegramWordCharBefore tells if the character that ends text is a letter or a
+// digit.
+func telegramWordCharBefore(text string) bool {
        char, _ := utf8.DecodeLastRuneInString(text)
        return text != "" && (unicode.IsLetter(char) || unicode.IsDigit(char))
 }
 
        char, _ := utf8.DecodeLastRuneInString(text)
        return text != "" && (unicode.IsLetter(char) || unicode.IsDigit(char))
 }
 
-// wordChar tells if the character that starts text is a letter or a digit. An
-// underscore next to one is a part of a word, such as in snake_case.
-func wordChar(text string) bool {
+// telegramWordChar tells if the character that starts text is a letter or a
+// digit. An underscore next to one is a part of a word, such as in snake_case.
+func telegramWordChar(text string) bool {
        char, _ := utf8.DecodeRuneInString(text)
        return text != "" && (unicode.IsLetter(char) || unicode.IsDigit(char))
 }
        char, _ := utf8.DecodeRuneInString(text)
        return text != "" && (unicode.IsLetter(char) || unicode.IsDigit(char))
 }