Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions internal/view/helper.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (

"github.com/atotto/clipboard"
"github.com/charmbracelet/glamour"
"github.com/charmbracelet/glamour/styles"
"github.com/fatih/color"
"github.com/mgutz/ansi"
"github.com/rivo/tview"
Expand Down Expand Up @@ -98,6 +99,22 @@ func MDRenderer() (*glamour.TermRenderer, error) {
)
}

// plainMDRenderer constructs a markdown renderer for plain output.
//
// Glamour's "ascii" and "notty" styles are not ASCII: both decorate list items
// with a bullet and images with an arrow. Plain output must survive a non-UTF-8
// locale, so we swap those two for ASCII equivalents.
func plainMDRenderer() (*glamour.TermRenderer, error) {
style := styles.ASCIIStyleConfig
style.Item.BlockPrefix = "* "
style.ImageText.Format = "Image: {{.text}}"

return glamour.NewTermRenderer(
glamour.WithStyles(style),
glamour.WithWordWrap(wordWrap),
)
}

func formatDateTime(dt, format, tz string) string {
t, err := time.Parse(format, dt)
if err != nil {
Expand Down
20 changes: 14 additions & 6 deletions internal/view/issue.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,9 @@ type Issue struct {
// Render renders the view.
func (i Issue) Render() error {
if i.Display.Plain || tui.IsDumbTerminal() || tui.IsNotTTY() {
// A dumb terminal or a pipe needs the same ASCII-only treatment as an
// explicit --plain; everything below renderPlain branches on this flag.
i.Display.Plain = true
return i.renderPlain(os.Stdout)
}
r, err := MDRenderer()
Expand Down Expand Up @@ -179,6 +182,14 @@ func (i Issue) fragments() []fragment {
return append(scraps, newBlankFragment(1), fragment{Body: i.footer()}, newBlankFragment(2))
}

// markdownTranslator translates ADF content, e.g. a description or a comment.
func (i Issue) markdownTranslator() *adf.MarkdownTranslator {
if i.Display.Plain {
return adf.NewMarkdownTranslator(adf.WithMarkdownASCII())
}
return adf.NewMarkdownTranslator()
}

// fieldSeparator divides inline fields, e.g. the priority and status of a subtask.
func (i Issue) fieldSeparator() string {
if i.Display.Plain {
Expand Down Expand Up @@ -287,7 +298,7 @@ func (i Issue) description() string {
var desc string

if adfNode, ok := i.Data.Fields.Description.(*adf.ADF); ok {
desc = adf.NewTranslator(adfNode, adf.NewMarkdownTranslator()).Translate()
desc = adf.NewTranslator(adfNode, i.markdownTranslator()).Translate()
} else {
desc = i.Data.Fields.Description.(string)
desc = md.FromJiraMD(desc)
Expand Down Expand Up @@ -438,7 +449,7 @@ func (i Issue) comments() []issueComment {
c := i.Data.Fields.Comment.Comments[idx]
var body string
if adfNode, ok := c.Body.(*adf.ADF); ok {
body = adf.NewTranslator(adfNode, adf.NewMarkdownTranslator()).Translate()
body = adf.NewTranslator(adfNode, i.markdownTranslator()).Translate()
} else {
body = c.Body.(string)
body = md.FromJiraMD(body)
Expand Down Expand Up @@ -488,10 +499,7 @@ func (i Issue) footer() string {

// renderPlain renders the issue in plain view.
func (i Issue) renderPlain(w io.Writer) error {
r, err := glamour.NewTermRenderer(
glamour.WithStandardStyle("notty"),
glamour.WithWordWrap(wordWrap),
)
r, err := plainMDRenderer()
if err != nil {
return err
}
Expand Down
143 changes: 139 additions & 4 deletions internal/view/issue_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package view

import (
"bytes"
"strings"
"testing"
"unicode"

Expand Down Expand Up @@ -350,19 +351,153 @@ func decoratedIssue() *jira.Issue {
}
}

// Plain output must survive a non-UTF-8 locale like LC_ALL=C, so none of the
// decorations the view adds around issue data may be non-ASCII.
// adfBody is a body Jira cloud would return: a list, an inline card, a code
// block and angle brackets, each of which the markdown pipeline decorates.
func adfBody(text string) *adf.ADF {
return &adf.ADF{
Version: 1,
DocType: "doc",
Content: []*adf.Node{
{
NodeType: "paragraph",
Content: []*adf.Node{
{NodeType: "text", NodeValue: adf.NodeValue{Text: text + " for <Person A> & co"}},
{NodeType: "inlineCard", Attributes: map[string]any{"url": "https://test.local/browse/TEST-9"}},
},
},
{
NodeType: "bulletList",
Content: []*adf.Node{
{
NodeType: "listItem",
Content: []*adf.Node{
{
NodeType: "paragraph",
Content: []*adf.Node{
{NodeType: "text", NodeValue: adf.NodeValue{Text: "list item"}},
},
},
},
},
},
},
{
NodeType: "codeBlock",
Content: []*adf.Node{
{NodeType: "text", NodeValue: adf.NodeValue{Text: "if (a<b && b>c) {}"}},
},
},
},
}
}

// Plain output must survive a non-UTF-8 locale like LC_ALL=C, so nothing the
// view and its markdown renderer add around issue data may be non-ASCII.
func TestPlainIssueViewIsASCIIOnly(t *testing.T) {
t.Parallel()

for _, tc := range []struct {
name string
body any
}{
{name: "jira markdown body", body: "Test description\n\n* list item\n* another item"},
{name: "adf body", body: adfBody("Test description")},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()

data := decoratedIssue()
data.Fields.Description = tc.body
data.Fields.Comment.Comments[0].Body = tc.body

issue := Issue{
Server: "https://test.local",
Data: data,
Display: DisplayFormat{Plain: true},
Options: IssueOption{NumComments: 2},
}

var b bytes.Buffer
assert.NoError(t, issue.renderPlain(&b))
assert.Empty(t, nonASCII(b.String()))
})
}
}

// The ASCII escaping must not cost us any content: what the lookalike glyphs
// used to stand in for has to come out of the renderer as the real character.
func TestPlainIssueViewKeepsAngleBrackets(t *testing.T) {
t.Parallel()

data := decoratedIssue()
data.Fields.Description = adfBody("Test description")

issue := Issue{
Server: "https://test.local",
Data: decoratedIssue(),
Data: data,
Display: DisplayFormat{Plain: true},
Options: IssueOption{NumComments: 2},
}

assert.Empty(t, nonASCII(issue.String()))
var b bytes.Buffer
assert.NoError(t, issue.renderPlain(&b))

out := b.String()
assert.Contains(t, out, "<Person A>")
assert.Contains(t, out, "if (a<b && b>c) {}")
}

// Known issue: a link inside a table whose URL is longer than the terminal
// width still leaks one non-ASCII rune into plain output. Glamour truncates
// the URL with a hardcoded "…" (ansi/table_links.go), which the style config
// cannot override, so plainMDRenderer can't get rid of it. Fixable, but only
// by post-processing the rendered output or patching glamour — not worth it
// so far. This test pins the leak so we notice when a glamour upgrade or a
// workaround changes the behavior; if it starts failing with no non-ASCII
// left, delete it and celebrate.
func TestPlainIssueViewLongTableLinkLeaksEllipsis(t *testing.T) {
t.Parallel()

cell := func(kind string, text string, marks []adf.MarkNode) *adf.Node {
return &adf.Node{
NodeType: adf.NodeType(kind),
Content: []*adf.Node{
{
NodeType: "paragraph",
Content: []*adf.Node{{NodeType: "text", NodeValue: adf.NodeValue{Text: text, Marks: marks}}},
},
},
}
}
link := []adf.MarkNode{{
MarkType: "link",
Attributes: map[string]any{"href": "https://test.local/" + strings.Repeat("a", 200)},
}}

data := decoratedIssue()
data.Fields.Description = &adf.ADF{
Version: 1,
DocType: "doc",
Content: []*adf.Node{
{
NodeType: "table",
Content: []*adf.Node{
{NodeType: "tableRow", Content: []*adf.Node{cell("tableHeader", "col a", nil), cell("tableHeader", "col b", nil)}},
{NodeType: "tableRow", Content: []*adf.Node{cell("tableCell", "a link", link), cell("tableCell", "plain", nil)}},
},
},
},
}

issue := Issue{
Server: "https://test.local",
Data: data,
Display: DisplayFormat{Plain: true},
}

var b bytes.Buffer
assert.NoError(t, issue.renderPlain(&b))
assert.Equal(t, []string{"…"}, nonASCII(b.String()))
}

func TestIssueViewKeepsDecorationsWhenNotPlain(t *testing.T) {
Expand Down
15 changes: 14 additions & 1 deletion pkg/adf/adf.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,12 @@ type TagCloser interface {
Close(Connector) string
}

// TextSanitizer escapes the text of a node for the target format. A
// TagOpenerCloser that doesn't implement it gets the default escaping.
type TextSanitizer interface {
Sanitize(string) string
}

// TagOpenerCloser wraps tag opener and closer.
type TagOpenerCloser interface {
TagOpener
Expand Down Expand Up @@ -219,7 +225,7 @@ func (a *Translator) visit(n *Node, depth int) {
}
}

tag.WriteString(sanitize(n.Text))
tag.WriteString(a.sanitize(n.Text))

// Close tags in reverse order.
for _, m := range slices.Backward(opened) {
Expand All @@ -232,6 +238,13 @@ func (a *Translator) visit(n *Node, depth int) {
a.buf.WriteString(a.tsl.Close(n))
}

func (a *Translator) sanitize(s string) string {
if ts, ok := a.tsl.(TextSanitizer); ok {
return ts.Sanitize(s)
}
return sanitize(s)
}

func sanitize(s string) string {
s = strings.TrimSpace(s)
s = strings.TrimRight(s, "\n")
Expand Down
51 changes: 51 additions & 0 deletions pkg/adf/adf_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"encoding/json"
"os"
"testing"
"unicode"

"github.com/stretchr/testify/assert"
)
Expand All @@ -22,6 +23,56 @@ func TestADF(t *testing.T) {
assert.Equal(t, expected, tr.Translate())
}

func TestADFMarkdownASCII(t *testing.T) {
data, err := os.ReadFile("./testdata/md.json")
assert.NoError(t, err)

var adf ADF
err = json.Unmarshal(data, &adf)
assert.NoError(t, err)

out := NewTranslator(&adf, NewMarkdownTranslator(WithMarkdownASCII())).Translate()

for _, r := range out {
assert.LessOrEqual(t, r, rune(unicode.MaxASCII), "translated markdown must stay ASCII")
}
assert.Contains(t, out, "Inline Node https://antiklabs.atlassian.net")
}

func TestADFMarkdownASCIIEscapesAngleBrackets(t *testing.T) {
t.Parallel()

doc := ADF{
Version: 1,
DocType: "doc",
Content: []*Node{
{
NodeType: NodeParagraph,
Content: []*Node{
{NodeType: ChildNodeText, NodeValue: NodeValue{Text: "a <b> c"}},
{
NodeType: ChildNodeText,
NodeValue: NodeValue{Text: "d <e> f", Marks: []MarkNode{{MarkType: MarkCode}}},
},
},
},
{
NodeType: NodeCodeBlock,
Content: []*Node{{NodeType: ChildNodeText, NodeValue: NodeValue{Text: "g <h> i"}}},
},
},
}

// Escaping is what keeps the markdown renderer from eating the angle
// brackets as an HTML tag, so it is only correct outside of code, which
// the renderer prints verbatim.
out := NewTranslator(&doc, NewMarkdownTranslator(WithMarkdownASCII())).Translate()

assert.Contains(t, out, `a \<b\> c`)
assert.Contains(t, out, "`d <e> f`")
assert.Contains(t, out, "g <h> i")
}

func TestADFReplaceAll(t *testing.T) {
data, err := os.ReadFile("./testdata/md.json")
assert.NoError(t, err)
Expand Down
Loading