internal/termtext/org.go
210 lines · 5643 bytes
1package termtext
2
3import (
4 "bytes"
5 "errors"
6 "io"
7 "log"
8 "strings"
9
10 "github.com/niklasfasching/go-org/org"
11)
12
13func Org(src string, o Options) string {
14 return renderOrg(src, &out{o: o})
15}
16
17// renderOrg parses with the same restrictions as the web: no file is
18// ever read (#+INCLUDE, #+SETUPFILE), and parse warnings go nowhere.
19func renderOrg(src string, w *out) string {
20 c := org.New()
21 c.ReadFile = func(string) ([]byte, error) { return nil, errors.New("org: includes are disabled") }
22 c.Log = log.New(io.Discard, "", 0)
23 doc := c.Parse(bytes.NewReader([]byte(src)), "")
24 r := orgRenderer{w: w}
25 r.nodes(doc.Nodes, "", "")
26 return w.String()
27}
28
29type orgRenderer struct{ w *out }
30
31func (r orgRenderer) nodes(ns []org.Node, first, rest string) {
32 p := first
33 wrote := false
34 for _, n := range ns {
35 if skipOrg(n) {
36 continue
37 }
38 if wrote {
39 r.w.blank()
40 }
41 r.block(n, p, rest)
42 p, wrote = rest, true
43 }
44}
45
46// skipOrg drops nodes that carry nothing to render: keywords
47// (#+INCLUDE included — ReadFile already refuses the read, but the
48// keyword itself is still a line of source, not content), property
49// drawers, comments, and the empty paragraph go-org emits for a blank
50// line that separated two blocks or ended a list item.
51func skipOrg(n org.Node) bool {
52 switch n := n.(type) {
53 case org.Keyword, org.Include, *org.PropertyDrawer, org.Comment:
54 return true
55 case org.Paragraph:
56 return len(n.Children) == 0
57 }
58 return false
59}
60
61func (r orgRenderer) block(n org.Node, first, rest string) {
62 switch n := n.(type) {
63 case org.Headline:
64 r.w.para(r.w.paint(sgrBold, r.inline(n.Title)), first, rest)
65 if len(n.Children) > 0 {
66 r.w.blank()
67 r.nodes(n.Children, rest, rest)
68 }
69 case org.Paragraph:
70 r.w.para(r.inline(n.Children), first, rest)
71 case org.List:
72 loose := looseOrgList(n)
73 p := first
74 for i, item := range n.Items {
75 if i > 0 && loose {
76 r.w.blank()
77 }
78 switch item := item.(type) {
79 case org.ListItem:
80 marker := "• "
81 if n.Kind == "ordered" {
82 marker = item.Bullet + " "
83 }
84 hang := rest + strings.Repeat(" ", cells(marker))
85 r.itemChildren(item.Children, p+marker, hang, loose)
86 case org.DescriptiveListItem:
87 term := r.w.paint(sgrBold, r.inline(item.Term))
88 r.w.para(term, p, rest)
89 r.itemChildren(item.Details, rest+" ", rest+" ", loose)
90 default:
91 r.w.code(org.String(item), "", rest)
92 }
93 p = rest
94 }
95 case org.Block:
96 switch strings.ToUpper(n.Name) {
97 case "SRC":
98 lang := ""
99 if len(n.Parameters) > 0 {
100 lang = n.Parameters[0]
101 }
102 r.w.code(org.String(n.Children...), lang, rest)
103 case "QUOTE":
104 bar := r.w.paint(sgrDim, "│ ")
105 r.nodes(n.Children, first+bar, rest+bar)
106 default:
107 r.w.code(org.String(n.Children...), "", rest)
108 }
109 case org.Example:
110 r.w.code(org.String(n.Children...), "", rest)
111 case org.HorizontalRule:
112 r.w.rule(first)
113 default:
114 r.w.code(org.String(n), "", rest)
115 }
116}
117
118// looseOrgList reports whether n's source had a blank line between
119// any two of its items. go-org marks the item before such a blank
120// with a trailing empty org.Paragraph — the same artifact skipOrg
121// drops elsewhere, checked here first since skipOrg would erase it.
122// The list's own last item gets that trailing empty paragraph too
123// whenever a blank line follows the whole list (ending it before the
124// next block), which says nothing about spacing inside the list, so
125// only non-last items are checked. A single-item list is always
126// tight by this measure: there is no gap between items to have or
127// lack a blank line.
128func looseOrgList(n org.List) bool {
129 for i := 0; i < len(n.Items)-1; i++ {
130 var children []org.Node
131 switch item := n.Items[i].(type) {
132 case org.ListItem:
133 children = item.Children
134 case org.DescriptiveListItem:
135 children = item.Details
136 default:
137 continue
138 }
139 if len(children) == 0 {
140 continue
141 }
142 if p, ok := children[len(children)-1].(org.Paragraph); ok && len(p.Children) == 0 {
143 return true
144 }
145 }
146 return false
147}
148
149// itemChildren renders a list item's children. A loose list keeps
150// nodes' blank line between them; a tight list runs them straight
151// together, so a nested list sits directly under its parent item's
152// line rather than a line below it.
153func (r orgRenderer) itemChildren(ns []org.Node, first, rest string, loose bool) {
154 if loose {
155 r.nodes(ns, first, rest)
156 return
157 }
158 p := first
159 for _, n := range ns {
160 if skipOrg(n) {
161 continue
162 }
163 r.block(n, p, rest)
164 p = rest
165 }
166}
167
168// trimOrgLineBreaks drops leading and trailing org.LineBreak nodes: a
169// paragraph that follows a keyword across a blank line, or a blank
170// line gap already handled elsewhere, otherwise renders that break as
171// the leading or trailing space inline() gives it.
172func trimOrgLineBreaks(ns []org.Node) []org.Node {
173 isBreak := func(n org.Node) bool { _, ok := n.(org.LineBreak); return ok }
174 for len(ns) > 0 && isBreak(ns[0]) {
175 ns = ns[1:]
176 }
177 for len(ns) > 0 && isBreak(ns[len(ns)-1]) {
178 ns = ns[:len(ns)-1]
179 }
180 return ns
181}
182
183func (r orgRenderer) inline(ns []org.Node) string {
184 ns = trimOrgLineBreaks(ns)
185 var b strings.Builder
186 for _, n := range ns {
187 switch n := n.(type) {
188 case org.Text:
189 b.WriteString(n.Content)
190 case org.LineBreak:
191 b.WriteString(" ")
192 case org.ExplicitLineBreak:
193 b.WriteString("\n")
194 case org.Emphasis:
195 s := r.inline(n.Content)
196 switch n.Kind {
197 case "*":
198 s = r.w.paint(sgrBold, s)
199 case "/", "_":
200 s = r.w.paint(sgrUnderline, s)
201 }
202 b.WriteString(s)
203 case org.RegularLink:
204 b.WriteString(r.w.link(r.inline(n.Description), n.URL))
205 default:
206 b.WriteString(org.String(n))
207 }
208 }
209 return b.String()
210}