]> git.giacomolli.uk - onechat/blob - markdown.go
Define child agent areas and reporting rules
[onechat] / markdown.go
1 package main
2
3 import (
4         "strings"
5         "unicode"
6         "unicode/utf8"
7 )
8
9 // markdownPunctuation holds the ASCII characters that a backslash makes plain.
10 const markdownPunctuation = "!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~"
11
12 // MarkdownSpan is an inline mark of a line: the mark kind, the content inside
13 // the marks, the index after the closing mark, and the URL of a link.
14 type MarkdownSpan struct {
15         kind    string
16         content string
17         end     int
18         url     string
19 }
20
21 // markdownCodeFence returns the language of a line that opens a fenced code
22 // block, and the fence. The fence is "" when line does not open a block.
23 func markdownCodeFence(line string) (string, string) {
24         trimmed := strings.TrimLeft(line, " ")
25         if len(line)-len(trimmed) > 3 || !strings.HasPrefix(trimmed, "```") {
26                 return "", ""
27         }
28
29         run := len(trimmed) - len(strings.TrimLeft(trimmed, "`"))
30         info := strings.TrimSpace(trimmed[run:])
31         if strings.Contains(info, "`") {
32                 return "", ""
33         }
34
35         language, _, _ := strings.Cut(info, " ")
36
37         return language, trimmed[:run]
38 }
39
40 // markdownClosingFence returns the index of the line after index that closes
41 // the code block of fence, or -1 when fence is "" or no line closes it.
42 func markdownClosingFence(lines []string, index int, fence string) int {
43         if fence == "" {
44                 return -1
45         }
46
47         for end := index + 1; end < len(lines); end++ {
48                 if markdownClosesFence(lines[end], fence) {
49                         return end
50                 }
51         }
52
53         return -1
54 }
55
56 // markdownClosesFence tells if line closes the code block of fence.
57 func markdownClosesFence(line, fence string) bool {
58         trimmed := strings.TrimSpace(line)
59         return strings.HasPrefix(trimmed, fence) && strings.Trim(trimmed, "`") == ""
60 }
61
62 // markdownHeadingText returns the text of an ATX heading line without its
63 // marks, or "" when line is not a heading or has no text.
64 func markdownHeadingText(line string) string {
65         trimmed := strings.TrimLeft(line, " ")
66         if len(line)-len(trimmed) > 3 {
67                 return ""
68         }
69
70         level := len(trimmed) - len(strings.TrimLeft(trimmed, "#"))
71         if level == 0 || level > 6 {
72                 return ""
73         }
74
75         rest := trimmed[level:]
76         if rest != "" && rest[0] != ' ' && rest[0] != '\t' {
77                 return ""
78         }
79
80         rest = strings.TrimSpace(rest)
81         closed := strings.TrimRight(rest, "#")
82         if closed == "" || strings.HasSuffix(closed, " ") {
83                 rest = strings.TrimSpace(closed)
84         }
85
86         return rest
87 }
88
89 // markdownMarkSpan returns the inline mark that starts at index of line and
90 // closes in line. It returns false when no mark starts there or the mark does
91 // not close.
92 func markdownMarkSpan(line string, index int) (MarkdownSpan, bool) {
93         switch line[index] {
94         case '`':
95                 return markdownCodeSpan(line, index)
96         case '[':
97                 return markdownLinkSpan(line, index)
98         case '~':
99                 return markdownDelimited(line, index, "~~", "strikethrough")
100         case '*', '_':
101                 double := line[index : index+1]
102                 double += double
103                 span, found := markdownDelimited(line, index, double, "bold")
104                 if found {
105                         return span, true
106                 }
107
108                 return markdownDelimited(line, index, line[index:index+1], "italic")
109         default:
110                 return MarkdownSpan{}, false
111         }
112 }
113
114 // markdownCodeSpan returns the inline code that starts at index: a run of
115 // backticks, the code, and a run of the same length. One space on each side of
116 // the code is left out, as in Markdown.
117 func markdownCodeSpan(line string, index int) (MarkdownSpan, bool) {
118         run := markdownBackticks(line[index:])
119         for search := index + run; search < len(line); {
120                 next := strings.IndexByte(line[search:], '`')
121                 if next < 0 {
122                         break
123                 }
124
125                 next += search
126                 length := markdownBackticks(line[next:])
127                 if length != run {
128                         search = next + length
129                         continue
130                 }
131
132                 content := line[index+run : next]
133                 if strings.TrimSpace(content) == "" {
134                         break
135                 }
136
137                 if len(content) > 1 && content[0] == ' ' && content[len(content)-1] == ' ' {
138                         content = content[1 : len(content)-1]
139                 }
140
141                 return MarkdownSpan{
142                         kind:    "code",
143                         content: content,
144                         end:     next + run,
145                 }, true
146         }
147
148         return MarkdownSpan{}, false
149 }
150
151 // markdownLinkSpan returns the link [text](url) that starts at index. The text
152 // can hold brackets in pairs, and the URL holds no space.
153 func markdownLinkSpan(line string, index int) (MarkdownSpan, bool) {
154         depth := 0
155         for closing := index; closing < len(line); closing++ {
156                 switch line[closing] {
157                 case '\\':
158                         closing++
159                 case '[':
160                         depth++
161                 case ']':
162                         depth--
163                 }
164
165                 if depth > 0 {
166                         continue
167                 }
168
169                 rest := line[closing+1:]
170                 if closing == index+1 || !strings.HasPrefix(rest, "(") {
171                         return MarkdownSpan{}, false
172                 }
173
174                 url, _, found := strings.Cut(rest[1:], ")")
175                 if !found || url == "" || strings.ContainsAny(url, " \t") {
176                         return MarkdownSpan{}, false
177                 }
178
179                 return MarkdownSpan{
180                         kind:    "text_link",
181                         content: line[index+1 : closing],
182                         end:     closing + 2 + len(url) + 1,
183                         url:     url,
184                 }, true
185         }
186
187         return MarkdownSpan{}, false
188 }
189
190 // markdownDelimited returns the span of kind between the mark at index and the
191 // same mark later in line. An opening mark is followed by a character that is
192 // not a space, and a closing mark follows one. A single mark is not a part of a
193 // double one. A mark of underscores does not open or close inside a word, so
194 // snake_case stays as text. A code span inside the content is skipped whole.
195 func markdownDelimited(line string, index int, mark, kind string) (MarkdownSpan, bool) {
196         char := mark[0]
197         after := index + len(mark)
198         if !strings.HasPrefix(line[index:], mark) || after >= len(line) || markdownIsSpace(line[after]) {
199                 return MarkdownSpan{}, false
200         }
201
202         if len(mark) == 1 && (line[after] == char || index > 0 && line[index-1] == char) {
203                 return MarkdownSpan{}, false
204         }
205
206         if char == '_' && index > 0 && markdownWordCharBefore(line[:index]) {
207                 return MarkdownSpan{}, false
208         }
209
210         for closing := after + 1; closing < len(line); closing++ {
211                 switch {
212                 case line[closing] == '\\':
213                         closing++
214                         continue
215                 case line[closing] == '`':
216                         span, found := markdownCodeSpan(line, closing)
217                         if found {
218                                 closing = span.end - 1
219                         }
220
221                         continue
222                 case !strings.HasPrefix(line[closing:], mark) || markdownIsSpace(line[closing-1]):
223                         continue
224                 }
225
226                 end := closing + len(mark)
227                 if len(mark) == 1 && (line[closing-1] == char || end < len(line) && line[end] == char) {
228                         continue
229                 }
230
231                 if char == '_' && markdownWordChar(line[end:]) {
232                         continue
233                 }
234
235                 return MarkdownSpan{
236                         kind:    kind,
237                         content: line[after:closing],
238                         end:     end,
239                 }, true
240         }
241
242         return MarkdownSpan{}, false
243 }
244
245 // markdownBackticks returns the length of the run of backticks at the start of
246 // text.
247 func markdownBackticks(text string) int {
248         return len(text) - len(strings.TrimLeft(text, "`"))
249 }
250
251 // markdownIsSpace tells if char is a space, a tab, or a line break.
252 func markdownIsSpace(char byte) bool {
253         return char == ' ' || char == '\t' || char == '\n' || char == '\r'
254 }
255
256 // markdownWordCharBefore tells if the character that ends text is a letter or a
257 // digit.
258 func markdownWordCharBefore(text string) bool {
259         char, _ := utf8.DecodeLastRuneInString(text)
260         return text != "" && (unicode.IsLetter(char) || unicode.IsDigit(char))
261 }
262
263 // markdownWordChar tells if the character that starts text is a letter or a
264 // digit. An underscore next to one is a part of a word, such as in snake_case.
265 func markdownWordChar(text string) bool {
266         char, _ := utf8.DecodeRuneInString(text)
267         return text != "" && (unicode.IsLetter(char) || unicode.IsDigit(char))
268 }