gitstack

grindlemire/go-tui code browser

5.4 KB Go 196 lines 2026-05-29 · 248109d raw
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
// Package markdown parses a small, well-scoped subset of markdown into a block
// tree for terminal rendering. It has no dependency on the tui package.
package markdown

import "strings"

type BlockKind int

const (
	KindParagraph BlockKind = iota
	KindHeading
	KindCodeFence
	KindTable
	KindList
	KindListItem
	KindBlockquote
)

// Inline is a styled run of text. Code spans set Code; links set Link (with Text
// as the label). Bold/Italic may combine.
type Inline struct {
	Text   string
	Bold   bool
	Italic bool
	Code   bool
	Link   string // non-empty => hyperlink target
}

// TableCell holds one cell's inline content.
type TableCell struct {
	Inline []Inline
}

// Block is one node in the document tree.
type Block struct {
	Kind     BlockKind
	Level    int           // heading level (1-6); unused otherwise
	Ordered  bool          // ordered list
	Lang     string        // code-fence info string
	Inline   []Inline      // leaf inline content (heading, paragraph, list item)
	Lines    []string      // raw code-fence lines
	Rows     [][]TableCell // table rows; row 0 is the header
	Children []Block       // nested blocks (list items, blockquote/list contents)
}

// Parse parses markdown source into a block tree.
func Parse(src string) []Block {
	lines := strings.Split(strings.ReplaceAll(src, "\r\n", "\n"), "\n")
	return parseBlocks(lines)
}

func parseBlocks(lines []string) []Block {
	var blocks []Block
	for i := 0; i < len(lines); {
		line := lines[i]
		if strings.TrimSpace(line) == "" {
			i++
			continue
		}
		switch {
		case isFence(line):
			b, next := parseFence(lines, i)
			blocks, i = append(blocks, b), next
		case isATXHeading(line):
			blocks, i = append(blocks, parseATX(line)), i+1
		case isBlockquote(line):
			b, next := parseBlockquote(lines, i)
			blocks, i = append(blocks, b), next
		case isListLine(line):
			b, next := parseList(lines, i, listIndent(line))
			blocks, i = append(blocks, b), next
		case isTableStart(lines, i):
			b, next := parseTable(lines, i)
			blocks, i = append(blocks, b), next
		default:
			b, next := parseParagraphOrSetext(lines, i)
			blocks, i = append(blocks, b), next
		}
	}
	return blocks
}

func isFence(line string) bool {
	return strings.HasPrefix(strings.TrimSpace(line), "```")
}

func parseFence(lines []string, i int) (Block, int) {
	info := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(lines[i]), "```"))
	var body []string
	j := i + 1
	for j < len(lines) && !strings.HasPrefix(strings.TrimSpace(lines[j]), "```") {
		body = append(body, lines[j])
		j++
	}
	if j < len(lines) {
		j++ // consume closing fence
	}
	return Block{Kind: KindCodeFence, Lang: info, Lines: body}, j
}

func isATXHeading(line string) bool {
	t := strings.TrimLeft(line, " ")
	n := 0
	for n < len(t) && t[n] == '#' {
		n++
	}
	return n >= 1 && n <= 6 && n < len(t) && t[n] == ' '
}

func parseATX(line string) Block {
	t := strings.TrimLeft(line, " ")
	n := 0
	for n < len(t) && t[n] == '#' {
		n++
	}
	text := strings.TrimSpace(strings.TrimRight(strings.TrimSpace(t[n:]), "#"))
	return Block{Kind: KindHeading, Level: n, Inline: parseInline(text)}
}

func isSetextUnderline(s string, ch byte) bool {
	s = strings.TrimSpace(s)
	if s == "" {
		return false
	}
	for i := 0; i < len(s); i++ {
		if s[i] != ch {
			return false
		}
	}
	return true
}

func parseParagraphOrSetext(lines []string, i int) (Block, int) {
	// Single-line setext: a text line followed by an all-'=' or all-'-' underline.
	// NOTE: only single-line setext headings are recognized. CommonMark allows the
	// heading text to span multiple lines before the underline; this parser does
	// not, so a wrapped title followed by "===" renders as a paragraph that
	// includes the literal underline. This is an accepted subset limitation.
	if i+1 < len(lines) {
		if isSetextUnderline(lines[i+1], '=') {
			return Block{Kind: KindHeading, Level: 1, Inline: parseInline(strings.TrimSpace(lines[i]))}, i + 2
		}
		if isSetextUnderline(lines[i+1], '-') {
			return Block{Kind: KindHeading, Level: 2, Inline: parseInline(strings.TrimSpace(lines[i]))}, i + 2
		}
	}
	var parts []string
	j := i
	for j < len(lines) {
		l := lines[j]
		if strings.TrimSpace(l) == "" || isFence(l) || isATXHeading(l) || isBlockquote(l) || isListLine(l) || isTableStart(lines, j) {
			break
		}
		parts = append(parts, strings.TrimSpace(l))
		j++
	}
	return Block{Kind: KindParagraph, Inline: parseInline(strings.Join(parts, " "))}, j
}

func isTableStart(lines []string, i int) bool {
	return i+1 < len(lines) && strings.Contains(lines[i], "|") && isTableSeparator(lines[i+1])
}

func isTableSeparator(line string) bool {
	s := strings.TrimSpace(line)
	if s == "" || !strings.Contains(s, "-") {
		return false
	}
	for _, r := range s {
		if r != '-' && r != '|' && r != ':' && r != ' ' {
			return false
		}
	}
	return true
}

func parseTable(lines []string, i int) (Block, int) {
	rows := [][]TableCell{splitRow(lines[i])}
	j := i + 2 // skip header + separator
	for j < len(lines) && strings.TrimSpace(lines[j]) != "" && strings.Contains(lines[j], "|") {
		rows = append(rows, splitRow(lines[j]))
		j++
	}
	return Block{Kind: KindTable, Rows: rows}, j
}

func splitRow(line string) []TableCell {
	s := strings.TrimSpace(line)
	s = strings.TrimSuffix(strings.TrimPrefix(s, "|"), "|")
	parts := strings.Split(s, "|")
	cells := make([]TableCell, 0, len(parts))
	for _, p := range parts {
		cells = append(cells, TableCell{Inline: parseInline(strings.TrimSpace(p))})
	}
	return cells
}