7 "golang.org/x/text/width"
11 terminalMarkdownBold = 1 << iota
13 terminalMarkdownItalic
14 terminalMarkdownReverse
17 // TerminalMarkdownGlyph holds a character, its display width, and its style.
18 type TerminalMarkdownGlyph struct {
24 // terminalMarkdown renders Markdown as lines with terminal styles. Width is in
25 // display columns, with a default of 80. Styles end at each line boundary.
26 func terminalMarkdown(text string, columns int) []string {
31 lines := strings.Split(text, "\n")
35 for index := 0; index < len(lines); index++ {
37 _, fence := markdownCodeFence(line)
38 end := markdownClosingFence(lines, index, fence)
39 heading := markdownHeadingText(line)
42 for _, code := range lines[index+1 : end] {
43 out = append(out, terminalMarkdownLines(terminalMarkdownPlain(code, terminalMarkdownDim), columns, " ", true)...)
47 case strings.TrimSpace(line) == "---":
48 out = append(out, "\x1b[2m"+strings.Repeat("-", columns)+"\x1b[0m")
49 case index+1 < len(lines) && terminalMarkdownSeparator(lines[index+1]):
50 rows := [][]string{terminalMarkdownCells(line)}
52 for index < len(lines) && strings.Contains(lines[index], "|") && strings.TrimSpace(lines[index]) != "" {
53 rows = append(rows, terminalMarkdownCells(lines[index]))
58 out = append(out, terminalMarkdownTable(rows, columns)...)
60 out = append(out, terminalMarkdownLines(terminalMarkdownInline(heading, terminalMarkdownBold), columns, "", false)...)
62 prefix, content := terminalMarkdownPrefix(line)
63 out = append(out, terminalMarkdownLines(terminalMarkdownInline(content, 0), columns, prefix, false)...)
70 // terminalMarkdownLines wraps glyphs with a hanging prefix. Literal lines keep
71 // their spaces. When a prefix leaves no text column, a list mark gets its own
72 // line and a quote uses a bar without a space or indent. A quote bar and one
73 // glyph, or a complete list mark, can exceed a narrower width rather than lose
74 // text. A wide character stays whole.
75 func terminalMarkdownLines(glyphs []TerminalMarkdownGlyph, columns int, prefix string, literal bool) []string {
78 if len(prefix) >= columns {
82 case strings.Contains(prefix, "|"):
85 out = append(out, strings.TrimSpace(prefix))
90 available := max(1, columns-len(prefix))
92 parts := terminalMarkdownWrap(glyphs, available, literal)
94 for index, part := range parts {
96 if index > 0 && !literal && !strings.Contains(prefix, "|") {
97 mark = strings.Repeat(" ", len(prefix))
100 out = append(out, mark+terminalMarkdownSGR(part))
106 // terminalMarkdownPlain makes display glyphs without reading inline marks.
107 // Control characters cannot insert terminal commands. Combining marks take no
108 // column and East Asian wide and fullwidth characters take two.
109 func terminalMarkdownPlain(text string, style int) []TerminalMarkdownGlyph {
110 var glyphs []TerminalMarkdownGlyph
112 for _, char := range strings.ReplaceAll(text, "\t", " ") {
113 if unicode.IsControl(char) {
119 case unicode.Is(unicode.Mn, char) || unicode.Is(unicode.Me, char) || char == '\u200d':
121 case width.LookupRune(char).Kind() == width.EastAsianWide || width.LookupRune(char).Kind() == width.EastAsianFullwidth:
125 glyphs = append(glyphs, TerminalMarkdownGlyph{
135 // terminalMarkdownSeparator tells if a line is a Markdown table separator.
136 func terminalMarkdownSeparator(line string) bool {
137 cells := terminalMarkdownCells(line)
138 if !strings.Contains(line, "|") {
142 for _, cell := range cells {
143 cell = strings.Trim(cell, ":")
144 if len(cell) < 3 || strings.Trim(cell, "-") != "" {
152 // terminalMarkdownCells splits a row outside code spans and escaped pipes.
153 func terminalMarkdownCells(line string) []string {
154 line = strings.TrimSpace(line)
155 line = strings.TrimPrefix(line, "|")
156 if strings.HasSuffix(line, "|") && !strings.HasSuffix(line, "\\|") {
157 line = strings.TrimSuffix(line, "|")
163 for index := 0; index < len(line); index++ {
168 span, found := markdownCodeSpan(line, index)
173 cells = append(cells, strings.TrimSpace(line[start:index]))
178 return append(cells, strings.TrimSpace(line[start:]))
181 // terminalMarkdownTable fits columns to the available width and wraps cells.
182 // When the separators and one glyph per column cannot fit, cells stack in row
184 func terminalMarkdownTable(rows [][]string, columns int) []string {
185 count := len(rows[0])
187 sizes := make([]int, count)
189 minimum := make([]int, count)
190 cells := make([][][]TerminalMarkdownGlyph, len(rows))
191 for row, values := range rows {
192 cells[row] = make([][]TerminalMarkdownGlyph, count)
193 for column := range count {
195 if column < len(values) {
196 value = values[column]
201 style = terminalMarkdownBold
204 cells[row][column] = terminalMarkdownInline(value, style)
206 sizes[column] = max(sizes[column], terminalMarkdownColumns(cells[row][column]), 1)
207 minimum[column] = max(minimum[column], 1)
208 for _, glyph := range cells[row][column] {
209 minimum[column] = max(minimum[column], glyph.columns)
214 required := (count - 1) * 3
215 for _, size := range minimum {
219 if required > columns {
222 for row, values := range cells {
223 for _, cell := range values {
224 out = append(out, terminalMarkdownLines(cell, columns, "", false)...)
228 out = append(out, "\x1b[2m"+strings.Repeat("-", columns)+"\x1b[0m")
235 budget := columns - (count-1)*3
237 total, largest := 0, 0
238 for column, size := range sizes {
240 if size > minimum[column] && (sizes[largest] == minimum[largest] || size > sizes[largest]) {
254 for row, values := range cells {
255 wrapped := make([][][]TerminalMarkdownGlyph, count)
257 for column, value := range values {
258 wrapped[column] = terminalMarkdownWrap(value, sizes[column], false)
259 height = max(height, len(wrapped[column]))
262 for line := range height {
263 parts := make([]string, count)
264 for column := range count {
265 var glyphs []TerminalMarkdownGlyph
267 if line < len(wrapped[column]) {
268 glyphs = wrapped[column][line]
271 parts[column] = terminalMarkdownSGR(glyphs) + strings.Repeat(" ", sizes[column]-terminalMarkdownColumns(glyphs))
274 out = append(out, strings.Join(parts, " | "))
278 rules := make([]string, count)
279 for column, size := range sizes {
280 rules[column] = strings.Repeat("-", size)
283 out = append(out, "\x1b[2m"+strings.Join(rules, "-+-")+"\x1b[0m")
290 // terminalMarkdownInline uses the shared marks, so unclosed marks stay as text.
291 // Strikethrough stays as text because the terminal styles exclude it.
292 func terminalMarkdownInline(line string, style int) []TerminalMarkdownGlyph {
293 var out []TerminalMarkdownGlyph
295 for index := 0; index < len(line); {
296 if line[index] == '\\' && index+1 < len(line) && strings.IndexByte(markdownPunctuation, line[index+1]) >= 0 {
297 out = append(out, terminalMarkdownPlain(line[index+1:index+2], style)...)
303 span, found := markdownMarkSpan(line, index)
304 if !found || span.kind == "strikethrough" {
306 for end < len(line) && !strings.ContainsRune("\\`[*_~", rune(line[end])) {
310 out = append(out, terminalMarkdownPlain(line[index:end], style)...)
319 mark = terminalMarkdownBold
321 mark = terminalMarkdownItalic
323 mark = terminalMarkdownReverse
326 var content []TerminalMarkdownGlyph
328 if span.kind == "code" {
329 content = terminalMarkdownPlain(span.content, style|mark)
331 content = terminalMarkdownInline(span.content, style|mark)
334 out = append(out, content...)
335 if span.kind == "text_link" && terminalMarkdownGlyphText(content) != span.url {
336 out = append(out, terminalMarkdownPlain(" ("+span.url+")", style)...)
345 // terminalMarkdownPrefix returns the hanging mark of a quote or a list item.
346 func terminalMarkdownPrefix(line string) (string, string) {
347 trimmed := strings.TrimLeft(line, " ")
348 indent := line[:len(line)-len(trimmed)]
349 if strings.HasPrefix(trimmed, ">") {
350 return indent + "| ", strings.TrimPrefix(strings.TrimPrefix(trimmed, ">"), " ")
353 if len(trimmed) > 1 && strings.ContainsRune("-*+", rune(trimmed[0])) && trimmed[1] == ' ' {
354 return indent + "- ", trimmed[2:]
358 for digits < len(trimmed) && trimmed[digits] >= '0' && trimmed[digits] <= '9' {
362 if digits > 0 && digits+1 < len(trimmed) && strings.ContainsRune(".)", rune(trimmed[digits])) && trimmed[digits+1] == ' ' {
363 return indent + trimmed[:digits+2], trimmed[digits+2:]
369 // terminalMarkdownWrap splits at spaces when possible and otherwise at glyph
370 // boundaries. It always makes progress, even for a single wide glyph.
371 func terminalMarkdownWrap(glyphs []TerminalMarkdownGlyph, columns int, literal bool) [][]TerminalMarkdownGlyph {
372 var out [][]TerminalMarkdownGlyph
374 for len(glyphs) > 0 {
375 end, used, space := 0, 0, -1
376 for end < len(glyphs) && (used+glyphs[end].columns <= columns || end == 0) {
377 if glyphs[end].char == ' ' {
381 used += glyphs[end].columns
386 if !literal && end < len(glyphs) && glyphs[end].char != ' ' && space > 0 {
391 out = append(out, glyphs[:end])
392 glyphs = glyphs[next:]
394 for len(glyphs) > 0 && glyphs[0].char == ' ' {
401 out = append(out, nil)
407 // terminalMarkdownSGR writes only the selected SGR styles and a final reset.
408 func terminalMarkdownSGR(glyphs []TerminalMarkdownGlyph) string {
409 var out strings.Builder
412 for _, glyph := range glyphs {
413 if glyph.style != style {
415 out.WriteString("\x1b[0m")
418 for bit, code := range []string{"1", "2", "3", "7"} {
419 if glyph.style&(1<<bit) != 0 {
420 out.WriteString("\x1b[" + code + "m")
427 out.WriteRune(glyph.char)
431 out.WriteString("\x1b[0m")
437 // terminalMarkdownColumns returns the display width without SGR commands.
438 func terminalMarkdownColumns(glyphs []TerminalMarkdownGlyph) int {
440 for _, glyph := range glyphs {
441 columns += glyph.columns
447 // terminalMarkdownGlyphText returns the text without its styles.
448 func terminalMarkdownGlyphText(glyphs []TerminalMarkdownGlyph) string {
449 var out strings.Builder
451 for _, glyph := range glyphs {
452 out.WriteRune(glyph.char)