]> git.giacomolli.uk - onechat/blob - markdown.go
Check the width of comment lines
[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 }