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
16 changes: 8 additions & 8 deletions internal/jsonview/explorer.go
Original file line number Diff line number Diff line change
Expand Up @@ -280,7 +280,7 @@ func (tv *TextView) Resize(width, height int) {
h := height - heightOffset
if !tv.ready {
tv.viewport = viewport.New(width, h)
tv.viewport.SetContent(wordwrap.String(sanitizeTerminalString(tv.data.Str), width))
tv.viewport.SetContent(wordwrap.String(SanitizeTerminalString(tv.data.Str), width))
tv.ready = true
return
}
Expand Down Expand Up @@ -428,7 +428,7 @@ func (v *JSONViewer) getSelectedContent() string {

selected := tableView.rowData[tableView.table.Cursor()]
if selected.Type == gjson.String {
return sanitizeTerminalString(selected.Str)
return SanitizeTerminalString(selected.Str)
}
return selected.Raw
}
Expand Down Expand Up @@ -472,7 +472,7 @@ func quoteString(s string) string {
// Replace backslashes and quotes with escaped versions
s = strings.ReplaceAll(s, "\\", "\\\\")
s = strings.ReplaceAll(s, "\"", "\\\"")
s = sanitizeTerminalString(s)
s = SanitizeTerminalString(s)
return stringLiteralStyle.Render("\"" + s + "\"")
}

Expand Down Expand Up @@ -612,7 +612,7 @@ func newArrayOfObjectsTableView(path string, data gjson.Result, array []gjson.Re
for _, key := range item.Get("@keys").Array() {
if _, exists := keySet[key.Str]; !exists {
keySet[key.Str] = struct{}{}
title := sanitizeTerminalString(key.Str)
title := SanitizeTerminalString(key.Str)
columns = append(columns, table.Column{Title: title, Width: defaultColumnWidth})
columnKeys = append(columnKeys, key.Str)
}
Expand Down Expand Up @@ -651,7 +651,7 @@ func newObjectTableView(path string, data gjson.Result, raw bool) *TableView {

for _, key := range keys {
value := data.Get(key.Str)
title := sanitizeTerminalString(key.Str)
title := SanitizeTerminalString(key.Str)
rows = append(rows, table.Row{title, formatValue(value, raw)})
rowData = append(rowData, value)
}
Expand Down Expand Up @@ -709,7 +709,7 @@ func formatValue(value gjson.Result, raw bool) string {
case value.IsArray():
return formatArray(value)
case value.Type == gjson.String:
return sanitizeTerminalString(value.Str)
return SanitizeTerminalString(value.Str)
default:
return value.Raw
}
Expand All @@ -721,7 +721,7 @@ func formatObject(value gjson.Result) string {

for i, key := range keys {
val := value.Get(key.Str)
keyStrs[i] = formatObjectKey(sanitizeTerminalString(key.Str), val)
keyStrs[i] = formatObjectKey(SanitizeTerminalString(key.Str), val)
}

return "{" + strings.Join(keyStrs, ", ") + "}"
Expand All @@ -734,7 +734,7 @@ func formatObjectKey(key string, val gjson.Result) string {
case val.IsArray():
return key + ":[…]"
case val.Type == gjson.String:
str := sanitizeTerminalString(val.Str)
str := SanitizeTerminalString(val.Str)
if lipgloss.Width(str) <= maxPreviewLength {
return fmt.Sprintf(`%s:"%s"`, key, str)
}
Expand Down
36 changes: 35 additions & 1 deletion internal/jsonview/explorer_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,12 +57,46 @@ func TestSanitizeTerminalStringEscapesControlSequences(t *testing.T) {
t.Parallel()

sample := "before\x1b]52;c;ZGF0YQ==\a\u009b31m\nafter\t"
got := sanitizeTerminalString(sample)
got := SanitizeTerminalString(sample)

require.Equal(t, `before\u001b]52;c;ZGF0YQ==\u0007\u009b31m\nafter\t`, got)
requireNoRawTerminalControls(t, got)
}

func TestSanitizeTerminalStringDoesNotAllocateForCleanInput(t *testing.T) {
const input = "ordinary terminal output with unicode: café ☕"

allocations := testing.AllocsPerRun(100, func() {
_ = SanitizeTerminalString(input)
})

require.Zero(t, allocations)
require.Equal(t, input, SanitizeTerminalString(input))
}

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

tests := []struct {
name string
input string
want string
}{
{name: "invalid leading byte", input: string([]byte{0xff, 'a'}), want: "\ufffda"},
{name: "invalid middle byte", input: string([]byte{'a', 0xff, 'b'}), want: "a\ufffdb"},
{name: "invalid trailing byte", input: string([]byte{'a', 0xff}), want: "a\ufffd"},
{name: "valid replacement rune", input: "a\ufffdb", want: "a\ufffdb"},
{name: "invalid byte with escape", input: string([]byte{'a', 0xff, '\x1b'}), want: "a\ufffd\\u001b"},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
require.Equal(t, tt.want, SanitizeTerminalString(tt.input))
})
}
}

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

Expand Down
4 changes: 2 additions & 2 deletions internal/jsonview/staticdisplay.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ func formatJSON(json gjson.Result, width int) string {
func formatResult(result gjson.Result, indent, width int) string {
switch result.Type {
case gjson.String:
str := sanitizeTerminalString(result.Str)
str := SanitizeTerminalString(result.Str)
if str == "" {
return nullValueStyle.Render("(empty)")
}
Expand Down Expand Up @@ -104,7 +104,7 @@ func formatJSONObject(result gjson.Result, indent, width int) string {
var items []string
for _, key := range keys {
value := result.Get(key.Str)
keyStr := getIndent(indent) + keyStyle.Render(sanitizeTerminalString(key.Str)+":")
keyStr := getIndent(indent) + keyStyle.Render(SanitizeTerminalString(key.Str)+":")
// If item will be a one-liner, put it inline after the key, otherwise
// it starts with a newline and goes below the key.
itemWidth := width
Expand Down
26 changes: 21 additions & 5 deletions internal/jsonview/terminal.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,35 @@ package jsonview
import (
"fmt"
"strings"
"unicode/utf8"
)

func sanitizeTerminalString(s string) string {
// SanitizeTerminalString escapes control characters that terminals can interpret.
func SanitizeTerminalString(s string) string {
Comment thread
jbeckwith-oai marked this conversation as resolved.
var b strings.Builder
for _, r := range s {
for i, r := range s {
escaped := terminalControlEscape(r)
if b.Cap() == 0 && escaped == "" {
if r != utf8.RuneError {
continue
}
// Preserve range's existing normalization of malformed UTF-8.
_, width := utf8.DecodeRuneInString(s[i:])
if width != 1 {
continue
}
}
if b.Cap() == 0 {
b.Grow(len(s))
b.WriteString(s[:i])
}
if escaped == "" {
b.WriteRune(r)
continue
} else {
b.WriteString(escaped)
}
b.WriteString(escaped)
}
if b.Len() == len(s) {
if b.Cap() == 0 {
return s
}
return b.String()
Expand Down
24 changes: 19 additions & 5 deletions pkg/cmd/cmdutil.go
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,12 @@ func shouldUseColors(w io.Writer) bool {
}

func formatJSON(res gjson.Result, opts ShowJSONOpts) ([]byte, error) {
return formatJSONForOutput(res, opts, opts.Stdout)
}

// formatJSONForOutput keeps the final destination available when a pager sits between
// formatted output and the terminal.
func formatJSONForOutput(res gjson.Result, opts ShowJSONOpts, destination io.Writer) ([]byte, error) {
if opts.Transform != "" {
transformed := res.Get(opts.Transform)
if transformed.Exists() {
Expand All @@ -313,27 +319,31 @@ func formatJSON(res gjson.Result, opts ShowJSONOpts) ([]byte, error) {
// Modeled after `jq -r` (`--raw-output`): if the result is a string, print it without JSON quotes so that
// it's easier to pipe into other programs.
if opts.RawOutput && res.Type == gjson.String {
return []byte(res.Str + "\n"), nil
value := res.Str
if isTerminal(destination) {
value = jsonview.SanitizeTerminalString(value)
}
return []byte(value + "\n"), nil
}
switch strings.ToLower(opts.Format) {
case "auto":
autoOpts := opts
autoOpts.Format = "json"
autoOpts.Transform = ""
return formatJSON(res, autoOpts)
return formatJSONForOutput(res, autoOpts, destination)
Comment thread
jbeckwith-oai marked this conversation as resolved.
case "pretty":
return []byte(jsonview.RenderJSON(opts.Title, res) + "\n"), nil
case "json":
prettyJSON := pretty.Pretty([]byte(res.Raw))
if shouldUseColors(opts.Stdout) {
if shouldUseColors(destination) {
return pretty.Color(prettyJSON, pretty.TerminalStyle), nil
} else {
return prettyJSON, nil
}
case "jsonl":
// @ugly is gjson syntax for "no whitespace", so it fits on one line
oneLineJSON := res.Get("@ugly").Raw
if shouldUseColors(opts.Stdout) {
if shouldUseColors(destination) {
bytes := append(pretty.Color([]byte(oneLineJSON), pretty.TerminalStyle), '\n')
return bytes, nil
} else {
Expand Down Expand Up @@ -507,7 +517,11 @@ func ShowJSONIterator[T any](iter jsonview.Iterator[T], itemsToDisplay int64, op
}
obj = gjson.ParseBytes(jsonData)
}
if err := ShowJSON(obj, pagerOpts); err != nil {
formatted, err := formatJSONForOutput(obj, pagerOpts, opts.Stdout)
if err != nil {
return err
}
if _, err := pager.Write(formatted); err != nil {
return err
}
itemsToDisplay -= 1
Expand Down
Loading