internal/termtext/org.go

0338e6ace3de199d5fc383852649919b68ef3e42
gitbay/internal/termtext/org.go history · blame · raw

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}