diff --git a/README.md b/README.md index 9f24e93..f6f832b 100644 --- a/README.md +++ b/README.md @@ -124,6 +124,15 @@ theme: name: "nord" # controls the color scheme chromaStyleOverrides: # override parts of the chroma style kc: "#009900 underline" # keys use the chroma short names +indent: 2 # number of spaces used to indent JSON (defaults to 4) +``` + +## Indentation + +By default, `jqp` indents the input data and query results with 4 spaces. You can change the number of spaces used per indentation level with the `indent` option in your [configuration file](#configuration). This can make deeply nested JSON easier to read by fitting more data on screen. + +```yaml +indent: 2 ``` ## Themes diff --git a/cmd/root.go b/cmd/root.go index b1b2197..aa98b49 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -63,12 +63,16 @@ You can provide the input JSON or NDJSON either through a file or via standard i } } + // indent controls how many spaces are used per level when prettifying + // JSON output. An unset or non-positive value falls back to the default. + indent := viper.GetInt(configKeysName.indent) + if isInputFromPipe() { stdin, err := streamToBytes(os.Stdin) if err != nil { return err } - bubble, err := jqplayground.New(stdin, "STDIN", query, jqtheme) + bubble, err := jqplayground.New(stdin, "STDIN", query, jqtheme, indent) if err != nil { return err } @@ -102,7 +106,7 @@ You can provide the input JSON or NDJSON either through a file or via standard i return err } - bubble, err := jqplayground.New(data, fi.Name(), query, jqtheme) + bubble, err := jqplayground.New(data, fi.Name(), query, jqtheme, indent) if err != nil { return err } @@ -169,10 +173,12 @@ var configKeysName = struct { themeName string themeOverrides string styleOverrides string + indent string }{ themeName: "theme.name", themeOverrides: "theme.chromaStyleOverrides", styleOverrides: "theme.styleOverrides", + indent: "indent", } var cfgFile string diff --git a/schema.json b/schema.json index ab33beb..38937e3 100644 --- a/schema.json +++ b/schema.json @@ -545,6 +545,12 @@ }, "minProperties": 1, "additionalProperties": false + }, + "indent": { + "title": "indent", + "description": "Number of spaces used to indent prettified JSON output (defaults to 4)\nhttps://github.com/noahgorstein/jqp?tab=readme-ov-file#indentation", + "type": "integer", + "minimum": 1 } }, "minProperties": 1, diff --git a/tui/bubbles/inputdata/inputdata.go b/tui/bubbles/inputdata/inputdata.go index c374375..6f496f1 100644 --- a/tui/bubbles/inputdata/inputdata.go +++ b/tui/bubbles/inputdata/inputdata.go @@ -22,10 +22,11 @@ type Bubble struct { highlightedJSON *bytes.Buffer filename string theme theme.Theme + indent int setInitialContentSub chan setPrettifiedContentMsg } -func New(inputJSON []byte, filename string, jqtheme theme.Theme) (Bubble, error) { +func New(inputJSON []byte, filename string, jqtheme theme.Theme, indent int) (Bubble, error) { styles := DefaultStyles() styles.containerStyle = styles.containerStyle.BorderForeground(jqtheme.Inactive) styles.infoStyle = styles.infoStyle.BorderForeground(jqtheme.Inactive) @@ -39,6 +40,7 @@ func New(inputJSON []byte, filename string, jqtheme theme.Theme) (Bubble, error) inputJSON: inputJSON, filename: filename, theme: jqtheme, + indent: indent, setInitialContentSub: make(chan setPrettifiedContentMsg), } return b, nil @@ -101,7 +103,7 @@ type setPrettifiedContentMsg struct { // sent through the channel to ensure the prettified data is available without blocking other operations. func (b Bubble) prettifyContentCmd(sub chan setPrettifiedContentMsg, isJSONLines bool) tea.Cmd { return func() tea.Msg { - prettifiedData, _ := utils.Prettify(b.inputJSON, b.theme.ChromaStyle, isJSONLines) + prettifiedData, _ := utils.Prettify(b.inputJSON, b.theme.ChromaStyle, isJSONLines, b.indent) sub <- setPrettifiedContentMsg{Content: prettifiedData} return nil } diff --git a/tui/bubbles/jqplayground/commands.go b/tui/bubbles/jqplayground/commands.go index 52e1049..5eaccf5 100644 --- a/tui/bubbles/jqplayground/commands.go +++ b/tui/bubbles/jqplayground/commands.go @@ -100,7 +100,7 @@ func (b *Bubble) executeQueryCommand(ctx context.Context) tea.Cmd { if err != nil { return errorMsg{error: err} } - highlightedOutput, err := utils.Prettify([]byte(results), b.theme.ChromaStyle, true) + highlightedOutput, err := utils.Prettify([]byte(results), b.theme.ChromaStyle, true, b.indent) if err != nil { return errorMsg{error: err} } diff --git a/tui/bubbles/jqplayground/model.go b/tui/bubbles/jqplayground/model.go index 04bc7d5..9e56155 100644 --- a/tui/bubbles/jqplayground/model.go +++ b/tui/bubbles/jqplayground/model.go @@ -32,12 +32,13 @@ type Bubble struct { results string cancel func() theme theme.Theme + indent int ExitMessage string isJSONLines bool showInputPanel bool } -func New(inputJSON []byte, filename string, query string, jqtheme theme.Theme) (Bubble, error) { +func New(inputJSON []byte, filename string, query string, jqtheme theme.Theme, indent int) (Bubble, error) { workingDirectory, err := os.Getwd() if err != nil { return Bubble{}, err @@ -49,7 +50,7 @@ func New(inputJSON []byte, filename string, query string, jqtheme theme.Theme) ( fs.SetInput(workingDirectory) - inputData, err := inputdata.New(inputJSON, filename, jqtheme) + inputData, err := inputdata.New(inputJSON, filename, jqtheme, indent) if err != nil { return Bubble{}, err } @@ -68,6 +69,7 @@ func New(inputJSON []byte, filename string, query string, jqtheme theme.Theme) ( statusbar: sb, fileselector: fs, theme: jqtheme, + indent: indent, showInputPanel: true, } return b, nil diff --git a/tui/utils/json.go b/tui/utils/json.go index d11add4..3e1b466 100644 --- a/tui/utils/json.go +++ b/tui/utils/json.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "io" + "strings" "github.com/alecthomas/chroma/v2" "github.com/alecthomas/chroma/v2/formatters" @@ -15,6 +16,20 @@ import ( const FourSpaces = " " +// DefaultIndent is the number of spaces used to indent prettified JSON when the +// configured indent is unset or invalid. +const DefaultIndent = 4 + +// indentUnit returns the string used to indent one level of prettified JSON for +// the given number of spaces, falling back to FourSpaces when spaces is not a +// positive number. +func indentUnit(spaces int) string { + if spaces < 1 { + return FourSpaces + } + return strings.Repeat(" ", spaces) +} + // IsValidInput checks the validity of input data as JSON or JSON lines. // It takes a byte slice 'data' and returns two boolean values indicating // whether the data is valid JSON and valid JSON lines, along with an error @@ -73,21 +88,21 @@ func IsValidJSONLines(input []byte) error { return nil } -func indentJSON(input *[]byte, output *bytes.Buffer) error { +func indentJSON(input *[]byte, output *bytes.Buffer, indent string) error { err := IsValidJSON(*input) if err != nil { return nil } - err = json.Indent(output, []byte(*input), "", FourSpaces) + err = json.Indent(output, []byte(*input), "", indent) if err != nil { return err } return nil } -func prettifyJSON(input []byte, chromaStyle *chroma.Style) (*bytes.Buffer, error) { +func prettifyJSON(input []byte, chromaStyle *chroma.Style, indent string) (*bytes.Buffer, error) { var indentedBuf bytes.Buffer - err := indentJSON(&input, &indentedBuf) + err := indentJSON(&input, &indentedBuf, indent) if err != nil { return nil, err } @@ -106,14 +121,18 @@ func prettifyJSON(input []byte, chromaStyle *chroma.Style) (*bytes.Buffer, error return &highlightedBuf, nil } -func Prettify(inputJSON []byte, chromaStyle *chroma.Style, isJSONLines bool) (*bytes.Buffer, error) { +// Prettify indents and syntax-highlights the input JSON (or NDJSON when +// isJSONLines is true). The indent argument is the number of spaces used for +// each indentation level; values below 1 fall back to DefaultIndent. +func Prettify(inputJSON []byte, chromaStyle *chroma.Style, isJSONLines bool, indent int) (*bytes.Buffer, error) { + unit := indentUnit(indent) if !isJSONLines { - return prettifyJSON(inputJSON, chromaStyle) + return prettifyJSON(inputJSON, chromaStyle, unit) } var buf bytes.Buffer processLine := func(line []byte) error { - hightlighedLine, err := prettifyJSON(line, chromaStyle) + hightlighedLine, err := prettifyJSON(line, chromaStyle, unit) if err != nil { return err } diff --git a/tui/utils/json_test.go b/tui/utils/json_test.go new file mode 100644 index 0000000..6c013b1 --- /dev/null +++ b/tui/utils/json_test.go @@ -0,0 +1,81 @@ +package utils + +import ( + "bytes" + "regexp" + "testing" +) + +var ansiEscape = regexp.MustCompile(`\x1b\[[0-9;]*m`) + +func TestIndentUnit(t *testing.T) { + tests := []struct { + name string + spaces int + want string + }{ + {name: "two spaces", spaces: 2, want: " "}, + {name: "four spaces", spaces: 4, want: FourSpaces}, + {name: "one space", spaces: 1, want: " "}, + {name: "zero falls back to default", spaces: 0, want: FourSpaces}, + {name: "negative falls back to default", spaces: -3, want: FourSpaces}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := indentUnit(tt.spaces); got != tt.want { + t.Errorf("indentUnit(%d) = %q, want %q", tt.spaces, got, tt.want) + } + }) + } +} + +func assertIndentJSON(t *testing.T, indent, want string) { + t.Helper() + + data := []byte(`{"a":{"b":1}}`) + var out bytes.Buffer + if err := indentJSON(&data, &out, indent); err != nil { + t.Fatalf("indentJSON returned error: %v", err) + } + if out.String() != want { + t.Errorf("indentJSON with %q =\n%q\nwant\n%q", indent, out.String(), want) + } +} + +func TestIndentJSON(t *testing.T) { + t.Run("two spaces", func(t *testing.T) { + assertIndentJSON(t, " ", "{\n \"a\": {\n \"b\": 1\n }\n}") + }) + t.Run("four spaces", func(t *testing.T) { + assertIndentJSON(t, FourSpaces, "{\n \"a\": {\n \"b\": 1\n }\n}") + }) +} + +func TestIndentJSONInvalidInputIsNoop(t *testing.T) { + var out bytes.Buffer + data := []byte("not json") + if err := indentJSON(&data, &out, " "); err != nil { + t.Fatalf("indentJSON returned error for invalid input: %v", err) + } + if out.Len() != 0 { + t.Errorf("expected no output for invalid input, got %q", out.String()) + } +} + +func TestPrettifyRespectsIndent(t *testing.T) { + input := []byte(`{"a":{"b":1}}`) + + buf, err := Prettify(input, nil, false, 2) + if err != nil { + t.Fatalf("Prettify returned error: %v", err) + } + + // Highlighting wraps tokens in ANSI escapes, so strip them before comparing + // against the expected 2-space indented output. + got := ansiEscape.ReplaceAllString(buf.String(), "") + want := "{\n \"a\": {\n \"b\": 1\n }\n}" + if got != want { + t.Errorf("Prettify with indent 2 =\n%q\nwant\n%q", got, want) + } +}