From e6042cb69893e0513f11b58e812651100b3d489a Mon Sep 17 00:00:00 2001 From: Anton Medvedev Date: Fri, 11 Mar 2022 16:15:24 +0100 Subject: [PATCH] Reimplement in go --- dict.go | 34 ++++ dict_test.go | 77 ++++++++++ go.mod | 23 +++ go.sum | 50 ++++++ help.go | 40 +++++ keymap.go | 101 ++++++++++++ main.go | 409 +++++++++++++++++++++++++++++++++++++++++++++++++ parse.go | 87 +++++++++++ parse_test.go | 50 ++++++ print.go | 263 +++++++++++++++++++++++++++++++ search.go | 119 ++++++++++++++ search_test.go | 42 +++++ traverse.go | 38 +++++ util.go | 44 ++++++ util_test.go | 26 ++++ viewport.go | 110 +++++++++++++ 16 files changed, 1513 insertions(+) create mode 100644 dict.go create mode 100644 dict_test.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 help.go create mode 100644 keymap.go create mode 100644 main.go create mode 100644 parse.go create mode 100644 parse_test.go create mode 100644 print.go create mode 100644 search.go create mode 100644 search_test.go create mode 100644 traverse.go create mode 100644 util.go create mode 100644 util_test.go create mode 100644 viewport.go diff --git a/dict.go b/dict.go new file mode 100644 index 0000000..04a4d37 --- /dev/null +++ b/dict.go @@ -0,0 +1,34 @@ +package main + +type dict struct { + keys []string + indexes map[string]int + values map[string]interface{} +} + +func newDict() *dict { + d := dict{} + d.keys = []string{} + d.indexes = map[string]int{} + d.values = map[string]interface{}{} + return &d +} + +func (d *dict) get(key string) (interface{}, bool) { + val, exists := d.values[key] + return val, exists +} + +func (d *dict) index(key string) (int, bool) { + index, exists := d.indexes[key] + return index, exists +} + +func (d *dict) set(key string, value interface{}) { + _, exists := d.values[key] + if !exists { + d.indexes[key] = len(d.keys) + d.keys = append(d.keys, key) + } + d.values[key] = value +} diff --git a/dict_test.go b/dict_test.go new file mode 100644 index 0000000..14875fd --- /dev/null +++ b/dict_test.go @@ -0,0 +1,77 @@ +package main + +import "testing" + +func Test_dict(t *testing.T) { + d := newDict() + d.set("number", 3) + v, _ := d.get("number") + if v.(int) != 3 { + t.Error("Set number") + } + i, _ := d.index("number") + if i != 0 { + t.Error("Index error") + } + // string + d.set("string", "x") + v, _ = d.get("string") + if v.(string) != "x" { + t.Error("Set string") + } + i, _ = d.index("string") + if i != 1 { + t.Error("Index error") + } + // string slice + d.set("strings", []string{ + "t", + "u", + }) + v, _ = d.get("strings") + if v.([]string)[0] != "t" { + t.Error("Set strings first index") + } + if v.([]string)[1] != "u" { + t.Error("Set strings second index") + } + i, _ = d.index("strings") + if i != 2 { + t.Error("Index error") + } + // mixed slice + d.set("mixed", []interface{}{ + 1, + "1", + }) + v, _ = d.get("mixed") + if v.([]interface{})[0].(int) != 1 { + t.Error("Set mixed int") + } + if v.([]interface{})[1].(string) != "1" { + t.Error("Set mixed string") + } + // overriding existing key + d.set("number", 4) + v, _ = d.get("number") + if v.(int) != 4 { + t.Error("Override existing key") + } + // keys + expectedKeys := []string{ + "number", + "string", + "strings", + "mixed", + } + for i, key := range d.keys { + if key != expectedKeys[i] { + t.Error("Keys method", key, "!=", expectedKeys[i]) + } + } + for i, key := range expectedKeys { + if key != expectedKeys[i] { + t.Error("Keys method", key, "!=", expectedKeys[i]) + } + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..211651b --- /dev/null +++ b/go.mod @@ -0,0 +1,23 @@ +module github.com/antonmedv/fx + +go 1.17 + +require ( + github.com/charmbracelet/bubbles v0.10.3 + github.com/charmbracelet/bubbletea v0.20.0 + github.com/charmbracelet/lipgloss v0.5.0 + golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 +) + +require ( + github.com/atotto/clipboard v0.1.4 // indirect + github.com/containerd/console v1.0.3 // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-isatty v0.0.14 // indirect + github.com/mattn/go-runewidth v0.0.13 // indirect + github.com/muesli/ansi v0.0.0-20211031195517-c9f0611b6c70 // indirect + github.com/muesli/reflow v0.3.0 // indirect + github.com/muesli/termenv v0.11.1-0.20220212125758-44cd13922739 // indirect + github.com/rivo/uniseg v0.2.0 // indirect + golang.org/x/sys v0.0.0-20220319134239-a9b59b0215f8 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..053bcd1 --- /dev/null +++ b/go.sum @@ -0,0 +1,50 @@ +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= +github.com/charmbracelet/bubbles v0.10.3 h1:fKarbRaObLn/DCsZO4Y3vKCwRUzynQD9L+gGev1E/ho= +github.com/charmbracelet/bubbles v0.10.3/go.mod h1:jOA+DUF1rjZm7gZHcNyIVW+YrBPALKfpGVdJu8UiJsA= +github.com/charmbracelet/bubbletea v0.19.3/go.mod h1:VuXF2pToRxDUHcBUcPmCRUHRvFATM4Ckb/ql1rBl3KA= +github.com/charmbracelet/bubbletea v0.20.0 h1:/b8LEPgCbNr7WWZ2LuE/BV1/r4t5PyYJtDb+J3vpwxc= +github.com/charmbracelet/bubbletea v0.20.0/go.mod h1:zpkze1Rioo4rJELjRyGlm9T2YNou1Fm4LIJQSa5QMEM= +github.com/charmbracelet/harmonica v0.1.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao= +github.com/charmbracelet/lipgloss v0.4.0/go.mod h1:vmdkHvce7UzX6xkyf4cca8WlwdQ5RQr8fzta+xl7BOM= +github.com/charmbracelet/lipgloss v0.5.0 h1:lulQHuVeodSgDez+3rGiuxlPVXSnhth442DATR2/8t8= +github.com/charmbracelet/lipgloss v0.5.0/go.mod h1:EZLha/HbzEt7cYqdFPovlqy5FZPj0xFhg5SaqxScmgs= +github.com/containerd/console v1.0.2/go.mod h1:ytZPjGgY2oeTkAONYafi2kSj0aYggsf8acV1PGKCbzQ= +github.com/containerd/console v1.0.3 h1:lIr7SlA5PxZyMV30bDW0MGbiOPXwc63yRuCP0ARubLw= +github.com/containerd/console v1.0.3/go.mod h1:7LqA/THxQ86k76b8c/EMSiaJ3h1eZkMkXar0TQ1gf3U= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.13/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= +github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y= +github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= +github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= +github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= +github.com/mattn/go-runewidth v0.0.13 h1:lTGmDsbAYt5DmK6OnoV7EuIF1wEIFAcxld6ypU4OSgU= +github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b/go.mod h1:fQuZ0gauxyBcmsdE3ZT4NasjaRdxmbCS0jRHsrWu3Ho= +github.com/muesli/ansi v0.0.0-20211031195517-c9f0611b6c70 h1:kMlmsLSbjkikxQJ1IPwaM+7LJ9ltFu/fi8CRzvSnQmA= +github.com/muesli/ansi v0.0.0-20211031195517-c9f0611b6c70/go.mod h1:fQuZ0gauxyBcmsdE3ZT4NasjaRdxmbCS0jRHsrWu3Ho= +github.com/muesli/reflow v0.2.1-0.20210115123740-9e1d0d53df68/go.mod h1:Xk+z4oIWdQqJzsxyjgl3P22oYZnHdZ8FFTHAQQt5BMQ= +github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= +github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= +github.com/muesli/termenv v0.9.0/go.mod h1:R/LzAKf+suGs4IsO95y7+7DpFHO0KABgnZqtlyx2mBw= +github.com/muesli/termenv v0.11.1-0.20220204035834-5ac8409525e0/go.mod h1:Bd5NYQ7pd+SrtBSrSNoBBmXlcY8+Xj4BMJgh8qcZrvs= +github.com/muesli/termenv v0.11.1-0.20220212125758-44cd13922739 h1:QANkGiGr39l1EESqrE0gZw0/AJNYzIvoGLhIoVYtluI= +github.com/muesli/termenv v0.11.1-0.20220212125758-44cd13922739/go.mod h1:Bd5NYQ7pd+SrtBSrSNoBBmXlcY8+Xj4BMJgh8qcZrvs= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/sahilm/fuzzy v0.1.0/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= +golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220319134239-a9b59b0215f8 h1:OH54vjqzRWmbJ62fjuhxy7AxFFgoHN0/DPc/UrL8cAs= +golang.org/x/sys v0.0.0-20220319134239-a9b59b0215f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.0.0-20210422114643-f5beecf764ed/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 h1:JGgROgKl9N8DuW20oFS5gxc+lE67/N3FcwmBPMe7ArY= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= diff --git a/help.go b/help.go new file mode 100644 index 0000000..a8eedd6 --- /dev/null +++ b/help.go @@ -0,0 +1,40 @@ +package main + +import ( + "fmt" + "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/lipgloss" + "reflect" + "strings" +) + +var helpStyle = lipgloss.NewStyle().PaddingLeft(4).PaddingTop(2).PaddingBottom(2) + +func (m *model) helpView() []string { + v := reflect.ValueOf(m.keyMap) + fields := reflect.VisibleFields(v.Type()) + + keys := make([]string, 0) + for i := range fields { + k := v.Field(i).Interface().(key.Binding) + str := k.Help().Key + if width(str) == 0 { + str = strings.Join(k.Keys(), ", ") + } + keys = append(keys, fmt.Sprintf("%v ", str)) + } + + desc := make([]string, 0) + for i := range fields { + k := v.Field(i).Interface().(key.Binding) + desc = append(desc, fmt.Sprintf("%v", k.Help().Desc)) + } + + content := lipgloss.JoinHorizontal( + lipgloss.Top, + strings.Join(keys, "\n"), + strings.Join(desc, "\n"), + ) + + return strings.Split(helpStyle.Render(content), "\n") +} diff --git a/keymap.go b/keymap.go new file mode 100644 index 0000000..effca80 --- /dev/null +++ b/keymap.go @@ -0,0 +1,101 @@ +package main + +import "github.com/charmbracelet/bubbles/key" + +type KeyMap struct { + Quit key.Binding + Help key.Binding + PageDown key.Binding + PageUp key.Binding + HalfPageUp key.Binding + HalfPageDown key.Binding + Down key.Binding + Up key.Binding + Expand key.Binding + Collapse key.Binding + GotoTop key.Binding + GotoBottom key.Binding + ToggleWrap key.Binding + ExpandAll key.Binding + CollapseAll key.Binding + Search key.Binding + Next key.Binding + Prev key.Binding +} + +func DefaultKeyMap() KeyMap { + return KeyMap{ + Quit: key.NewBinding( + key.WithKeys("q", "ctrl+c", "esc"), + key.WithHelp("", "exit program"), + ), + Help: key.NewBinding( + key.WithKeys("?"), + key.WithHelp("", "show help"), + ), + PageDown: key.NewBinding( + key.WithKeys("pgdown", " ", "f"), + key.WithHelp("pgdown, space, f", "page down"), + ), + PageUp: key.NewBinding( + key.WithKeys("pgup", "b"), + key.WithHelp("pgup, b", "page up"), + ), + HalfPageUp: key.NewBinding( + key.WithKeys("u", "ctrl+u"), + key.WithHelp("", "half page up"), + ), + HalfPageDown: key.NewBinding( + key.WithKeys("d", "ctrl+d"), + key.WithHelp("", "half page down"), + ), + Down: key.NewBinding( + key.WithKeys("down", "j"), + key.WithHelp("", "down"), + ), + Up: key.NewBinding( + key.WithKeys("up", "k"), + key.WithHelp("", "up"), + ), + Expand: key.NewBinding( + key.WithKeys("right", "l"), + key.WithHelp("", "expand"), + ), + Collapse: key.NewBinding( + key.WithKeys("left", "h"), + key.WithHelp("", "collapse"), + ), + GotoTop: key.NewBinding( + key.WithKeys("g"), + key.WithHelp("", "goto top"), + ), + GotoBottom: key.NewBinding( + key.WithKeys("G"), + key.WithHelp("", "goto bottom"), + ), + ToggleWrap: key.NewBinding( + key.WithKeys("z"), + key.WithHelp("", "toggle strings wrap"), + ), + ExpandAll: key.NewBinding( + key.WithKeys("e"), + key.WithHelp("", "expand all"), + ), + CollapseAll: key.NewBinding( + key.WithKeys("E"), + key.WithHelp("", "collapse all"), + ), + Search: key.NewBinding( + key.WithKeys("/"), + key.WithHelp("", "search regexp"), + ), + Next: key.NewBinding( + key.WithKeys("n"), + key.WithHelp("", "next search result"), + ), + Prev: key.NewBinding( + key.WithKeys("N"), + key.WithHelp("", "prev search result"), + ), + } +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..78589e7 --- /dev/null +++ b/main.go @@ -0,0 +1,409 @@ +package main + +import ( + "encoding/json" + "fmt" + "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "golang.org/x/term" + "os" + "path" + "strings" +) + +var colors = struct { + cursor lipgloss.Style + bracket lipgloss.Style + key lipgloss.Style + null lipgloss.Style + boolean lipgloss.Style + number lipgloss.Style + string lipgloss.Style + preview lipgloss.Style + statusBar lipgloss.Style + search lipgloss.Style +}{ + cursor: lipgloss.NewStyle().Reverse(true), + bracket: lipgloss.NewStyle(), + key: lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("4")), + null: lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("8")), + boolean: lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("3")), + number: lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("6")), + string: lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("2")), + preview: lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("8")), + statusBar: lipgloss.NewStyle().Background(lipgloss.Color("7")).Foreground(lipgloss.Color("0")), + search: lipgloss.NewStyle().Background(lipgloss.Color("11")).Foreground(lipgloss.Color("16")), +} + +func main() { + filePath := "" + var dec *json.Decoder + if term.IsTerminal(int(os.Stdin.Fd())) { + if len(os.Args) >= 2 { + filePath = os.Args[1] + f, err := os.Open(os.Args[1]) + if err != nil { + panic(err) + } + dec = json.NewDecoder(f) + } + } else { + dec = json.NewDecoder(os.Stdin) + } + dec.UseNumber() + jsonObject, err := parse(dec) + if err != nil { + panic(err) + } + + expand := map[string]bool{ + "": true, + } + + if array, ok := jsonObject.(array); ok { + for i := range array { + expand[accessor("", i)] = true + } + } + + parents := map[string]string{} + dfs(jsonObject, func(it iterator) { + parents[it.path] = it.parent + }) + + input := textinput.New() + input.Prompt = "" + + m := &model{ + fileName: path.Base(filePath), + json: jsonObject, + width: 80, + height: 60, + mouseWheelDelta: 3, + keyMap: DefaultKeyMap(), + expandedPaths: expand, + parents: parents, + wrap: true, + searchInput: input, + } + + p := tea.NewProgram(m, tea.WithAltScreen(), tea.WithMouseCellMotion()) + if err := p.Start(); err != nil { + panic(err) + } + os.Exit(m.exitCode) +} + +type model struct { + exitCode int + width, height int + windowHeight int + footerHeight int + + fileName string + json interface{} + lines []string + + mouseWheelDelta int // number of lines the mouse wheel will scroll + offset int // offset is the vertical scroll position + + keyMap KeyMap + showHelp bool + + expandedPaths map[string]bool // a set with expanded paths + canBeExpanded map[string]bool // a set for path => can be expanded (i.e. dict or array) + pathToLineNumber *dict // dict with path => line number + lineNumberToPath map[int]string // map of line number => path + parents map[string]string // map of subpath => parent path + cursor int // cursor in range of m.pathToLineNumber.keys slice + showCursor bool + + wrap bool + searchInput textinput.Model + searchRegexCompileError string + searchResults *dict // path => searchResult + showSearchResults bool + resultsCursor int // [0, searchResults length) +} + +func (m *model) Init() tea.Cmd { + return nil +} + +func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width = msg.Width + m.windowHeight = msg.Height + m.searchInput.Width = msg.Width - 2 // minus prompt + m.render() + + case tea.MouseMsg: + switch msg.Type { + case tea.MouseWheelUp: + m.LineUp(m.mouseWheelDelta) + case tea.MouseWheelDown: + m.LineDown(m.mouseWheelDelta) + } + } + + if m.searchInput.Focused() { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.Type { + case tea.KeyEsc: + m.searchInput.Blur() + m.searchResults = newDict() + m.render() + + case tea.KeyEnter: + m.doSearch(m.searchInput.Value()) + } + } + var cmd tea.Cmd + m.searchInput, cmd = m.searchInput.Update(msg) + return m, cmd + } + + switch msg := msg.(type) { + case tea.KeyMsg: + switch { + case key.Matches(msg, m.keyMap.PageDown): + m.ViewDown() + case key.Matches(msg, m.keyMap.PageUp): + m.ViewUp() + case key.Matches(msg, m.keyMap.HalfPageDown): + m.HalfViewDown() + case key.Matches(msg, m.keyMap.HalfPageUp): + m.HalfViewUp() + case key.Matches(msg, m.keyMap.GotoTop): + m.GotoTop() + case key.Matches(msg, m.keyMap.GotoBottom): + m.GotoBottom() + } + } + + if m.showHelp { + switch msg := msg.(type) { + case tea.KeyMsg: + switch { + case key.Matches(msg, m.keyMap.Quit): + m.showHelp = false + m.render() + case key.Matches(msg, m.keyMap.Down): + m.LineDown(1) + case key.Matches(msg, m.keyMap.Up): + m.LineUp(1) + } + } + return m, nil + } + + switch msg := msg.(type) { + case tea.KeyMsg: + switch { + case key.Matches(msg, m.keyMap.Quit): + m.exitCode = 0 + return m, tea.Quit + + case key.Matches(msg, m.keyMap.Help): + m.GotoTop() + m.showHelp = !m.showHelp + m.render() + + case key.Matches(msg, m.keyMap.Down): + m.showCursor = true + if m.cursor < len(m.pathToLineNumber.keys)-1 { // scroll till last element in m.pathToLineNumber + m.cursor++ + } else { + // at the bottom of viewport maybe some hidden brackets, lets scroll to see them + if !m.AtBottom() { + m.LineDown(1) + } + } + if m.cursor >= len(m.pathToLineNumber.keys) { + m.cursor = len(m.pathToLineNumber.keys) - 1 + } + m.render() + at := m.cursorLineNumber() + if m.offset <= at { // cursor is lower + m.LineDown(max(0, at-(m.offset+m.height-1))) // minus one is due to cursorLineNumber() starts from 0 + } else { + m.SetOffset(at) + } + + case key.Matches(msg, m.keyMap.Up): + m.showCursor = true + if m.cursor > 0 { + m.cursor-- + } + if m.cursor >= len(m.pathToLineNumber.keys) { + m.cursor = len(m.pathToLineNumber.keys) - 1 + } + m.render() + at := m.cursorLineNumber() + if at < m.offset+m.height { // cursor is above + m.LineUp(max(0, m.offset-at)) + } else { + m.SetOffset(at) + } + + case key.Matches(msg, m.keyMap.Expand): + m.showCursor = true + m.expandedPaths[m.cursorPath()] = true + m.render() + + case key.Matches(msg, m.keyMap.Collapse): + m.showCursor = true + if m.expandedPaths[m.cursorPath()] { + m.expandedPaths[m.cursorPath()] = false + } else { + parentPath, ok := m.parents[m.cursorPath()] + if ok { + m.expandedPaths[parentPath] = false + index, _ := m.pathToLineNumber.index(parentPath) + m.cursor = index + } + } + m.render() + at := m.cursorLineNumber() + if at < m.offset+m.height { // cursor is above + m.LineUp(max(0, m.offset-at)) + } else { + m.SetOffset(at) + } + + case key.Matches(msg, m.keyMap.ToggleWrap): + m.wrap = !m.wrap + m.render() + + case key.Matches(msg, m.keyMap.ExpandAll): + dfs(m.json, func(it iterator) { + switch it.object.(type) { + case *dict, array: + m.expandedPaths[it.path] = true + } + }) + m.render() + + case key.Matches(msg, m.keyMap.CollapseAll): + m.expandedPaths = map[string]bool{ + "": true, + } + m.render() + + case key.Matches(msg, m.keyMap.Search): + m.showSearchResults = false + m.searchInput.Focus() + m.render() + return m, textinput.Blink + + case key.Matches(msg, m.keyMap.Next): + if m.showSearchResults { + m.nextSearchResult() + } + + case key.Matches(msg, m.keyMap.Prev): + if m.showSearchResults { + m.prevSearchResult() + } + } + + case tea.MouseMsg: + switch msg.Type { + case tea.MouseLeft: + m.showCursor = true + clickedPath, ok := m.lineNumberToPath[m.offset+msg.Y] + if ok { + if m.canBeExpanded[clickedPath] { + m.expandedPaths[clickedPath] = !m.expandedPaths[clickedPath] + } + index, _ := m.pathToLineNumber.index(clickedPath) + m.cursor = index + m.render() + } + } + } + return m, nil +} + +func (m *model) View() string { + lines := m.visibleLines() + extraLines := "" + if len(lines) < m.height { + extraLines = strings.Repeat("\n", max(0, m.height-len(lines))) + } + statusBar := m.cursorPath() + " " + if m.showHelp { + statusBar = "Press Esc or q to close help." + } + statusBar += strings.Repeat(" ", max(0, m.width-width(statusBar)-width(m.fileName))) + statusBar += m.fileName + statusBar = colors.statusBar.Render(statusBar) + output := strings.Join(lines, "\n") + extraLines + "\n" + statusBar + if m.searchInput.Focused() { + output += "\n/" + m.searchInput.View() + } + if len(m.searchRegexCompileError) > 0 { + output += fmt.Sprintf("\n/%v/i %v", m.searchInput.Value(), m.searchRegexCompileError) + } + if m.showSearchResults { + if len(m.searchResults.keys) == 0 { + output += fmt.Sprintf("\n/%v/i not found", m.searchInput.Value()) + } else { + output += fmt.Sprintf("\n/%v/i found: [%v/%v]", m.searchInput.Value(), m.resultsCursor+1, len(m.searchResults.keys)) + } + } + return output +} + +func (m *model) recalculateViewportHeight() { + m.height = m.windowHeight + m.height-- // status bar + if m.searchInput.Focused() { + m.height-- + } + if m.showSearchResults { + m.height-- + } + if len(m.searchRegexCompileError) > 0 { + m.height-- + } +} + +func (m *model) render() { + m.recalculateViewportHeight() + + if m.showHelp { + m.lines = m.helpView() + return + } + + m.pathToLineNumber = newDict() + m.canBeExpanded = map[string]bool{} + m.lineNumberToPath = map[int]string{} + m.lines = m.print(m.json, 1, 0, 0, "", false) + + if m.offset > len(m.lines)-1 { + m.GotoBottom() + } +} + +func (m *model) cursorPath() string { + if m.cursor == 0 { + return "" + } + if 0 <= m.cursor && m.cursor < len(m.pathToLineNumber.keys) { + return m.pathToLineNumber.keys[m.cursor] + } + return "?" +} + +func (m *model) cursorLineNumber() int { + if 0 <= m.cursor && m.cursor < len(m.pathToLineNumber.keys) { + return m.pathToLineNumber.values[m.pathToLineNumber.keys[m.cursor]].(int) + } + return -1 +} diff --git a/parse.go b/parse.go new file mode 100644 index 0000000..e4613db --- /dev/null +++ b/parse.go @@ -0,0 +1,87 @@ +package main + +import ( + "encoding/json" +) + +func parse(dec *json.Decoder) (interface{}, error) { + token, err := dec.Token() + if err != nil { + return nil, err + } + if delim, ok := token.(json.Delim); ok { + switch delim { + case '{': + return decodeDict(dec) + case '[': + return decodeArray(dec) + } + } + return token, nil +} + +func decodeDict(dec *json.Decoder) (*dict, error) { + d := newDict() + for { + token, err := dec.Token() + if err != nil { + return nil, err + } + if delim, ok := token.(json.Delim); ok && delim == '}' { + return d, nil + } + key := token.(string) + token, err = dec.Token() + if err != nil { + return nil, err + } + var value interface{} = token + if delim, ok := token.(json.Delim); ok { + switch delim { + case '{': + value, err = decodeDict(dec) + if err != nil { + return nil, err + } + case '[': + value, err = decodeArray(dec) + if err != nil { + return nil, err + } + } + } + d.set(key, value) + } +} + +type array = []interface{} + +func decodeArray(dec *json.Decoder) ([]interface{}, error) { + slice := make(array, 0) + for index := 0; ; index++ { + token, err := dec.Token() + if err != nil { + return nil, err + } + if delim, ok := token.(json.Delim); ok { + switch delim { + case '{': + value, err := decodeDict(dec) + if err != nil { + return nil, err + } + slice = append(slice, value) + case '[': + value, err := decodeArray(dec) + if err != nil { + return nil, err + } + slice = append(slice, value) + case ']': + return slice, nil + } + continue + } + slice = append(slice, token) + } +} diff --git a/parse_test.go b/parse_test.go new file mode 100644 index 0000000..07b28c1 --- /dev/null +++ b/parse_test.go @@ -0,0 +1,50 @@ +package main + +import ( + "encoding/json" + "strings" + "testing" +) + +func Test_parse(t *testing.T) { + input := `{ + "a": 1, + "b": 2, + "a": 3, + "slice": [{"z": "z", "1": "1"}] +}` + + p, err := parse(json.NewDecoder(strings.NewReader(input))) + if err != nil { + t.Error("JSON parse error", err) + } + o := p.(*dict) + + expectedKeys := []string{ + "a", + "b", + "slice", + } + for i := range o.keys { + if o.keys[i] != expectedKeys[i] { + t.Error("Wrong key order ", i, o.keys[i], "!=", expectedKeys[i]) + } + } + + s, ok := o.get("slice") + if !ok { + t.Error("slice missing") + } + a := s.(array) + z := a[0].(*dict) + + expectedKeys = []string{ + "z", + "1", + } + for i := range z.keys { + if z.keys[i] != expectedKeys[i] { + t.Error("Wrong key order for nested map ", i, z.keys[i], "!=", expectedKeys[i]) + } + } +} diff --git a/print.go b/print.go new file mode 100644 index 0000000..75dd0e5 --- /dev/null +++ b/print.go @@ -0,0 +1,263 @@ +package main + +import ( + "encoding/json" + "fmt" + "github.com/charmbracelet/lipgloss" + "strings" +) + +func (m *model) print(v interface{}, level, lineNumber, keyEndPos int, path string, dontHighlightCursor bool) []string { + ident := strings.Repeat(" ", level) + subident := strings.Repeat(" ", level-1) + + cursorOr := func(style lipgloss.Style) lipgloss.Style { + if m.cursorPath() == path && !dontHighlightCursor && m.showCursor { + return colors.cursor + } + return style + } + searchStyle := colors.search.Render + if m.resultsCursorPath() == path { + searchStyle = colors.cursor.Render + } + highlight := func(line string, style func(s string) string) string { + chunks := m.split(line, path) + return mergeChunks(chunks, style, searchStyle) + } + + switch v.(type) { + case nil: + line := highlight("null", cursorOr(colors.null).Render) + return []string{line} + + case bool: + line := highlight(stringify(v), cursorOr(colors.boolean).Render) + return []string{line} + + case json.Number: + line := highlight(v.(json.Number).String(), cursorOr(colors.number).Render) + return []string{line} + + case string: + stringStyle := cursorOr(colors.string).Render + line := fmt.Sprintf("%q", v) + chunks := m.split(line, path) + if m.wrap && keyEndPos+width(line) > m.width { + return wrapLines(chunks, keyEndPos, m.width, subident, stringStyle, searchStyle) + } + return []string{mergeChunks(chunks, stringStyle, searchStyle)} + + case *dict: + m.pathToLineNumber.set(path, lineNumber) + m.canBeExpanded[path] = true + m.lineNumberToPath[lineNumber] = path + bracketStyle := cursorOr(colors.bracket).Render + if len(v.(*dict).keys) == 0 { + return []string{bracketStyle("{}")} + } + if !m.expandedPaths[path] { + return []string{m.preview(v, path, dontHighlightCursor)} + } + output := []string{bracketStyle("{")} + lineNumber++ // bracket is on separate line + keys := v.(*dict).keys + for i, k := range keys { + subpath := path + "." + k + m.pathToLineNumber.set(subpath, lineNumber) + m.lineNumberToPath[lineNumber] = subpath + keyStyle := colors.key.Render + if m.cursorPath() == subpath && m.showCursor { + keyStyle = colors.cursor.Render + } + key := fmt.Sprintf("%q", k) + { + var indexes [][]int + if m.searchResults != nil { + sr, ok := m.searchResults.get(subpath) + if ok { + indexes = sr.(searchResult).key + } + } + chunks := explode(key, indexes) + searchStyle := colors.search.Render + if m.resultsCursorPath() == subpath && !m.showCursor { + searchStyle = colors.cursor.Render + } + key = mergeChunks(chunks, keyStyle, searchStyle) + } + value, _ := v.(*dict).get(k) + delim := ": " + keyEndPos := width(ident) + width(key) + width(delim) + lines := m.print(value, level+1, lineNumber, keyEndPos, subpath, true) + lines[0] = ident + key + delim + lines[0] + if i < len(keys)-1 { + lines[len(lines)-1] += "," + } + output = append(output, lines...) + lineNumber += len(lines) + } + output = append(output, subident+colors.bracket.Render("}")) + return output + + case array: + m.pathToLineNumber.set(path, lineNumber) + m.canBeExpanded[path] = true + m.lineNumberToPath[lineNumber] = path + bracketStyle := cursorOr(colors.bracket).Render + if len(v.(array)) == 0 { + return []string{bracketStyle("[]")} + } + if !m.expandedPaths[path] { + return []string{bracketStyle(m.preview(v, path, dontHighlightCursor))} + } + output := []string{bracketStyle("[")} + lineNumber++ // bracket is on separate line + slice := v.(array) + for i, value := range slice { + subpath := fmt.Sprintf("%v[%v]", path, i) + m.pathToLineNumber.set(subpath, lineNumber) + m.lineNumberToPath[lineNumber] = subpath + lines := m.print(value, level+1, lineNumber, width(ident), subpath, false) + lines[0] = ident + lines[0] + if i < len(slice)-1 { + lines[len(lines)-1] += "," + } + lineNumber += len(lines) + output = append(output, lines...) + } + output = append(output, subident+colors.bracket.Render("]")) + return output + + default: + return []string{"unknown type"} + } +} + +func (m *model) preview(v interface{}, path string, dontHighlightCursor bool) string { + cursorOr := func(style lipgloss.Style) lipgloss.Style { + if m.cursorPath() == path && !dontHighlightCursor { + return colors.cursor + } + return style + } + + bracketStyle := cursorOr(colors.bracket) + previewStyle := cursorOr(colors.preview) + + printValue := func(value interface{}) string { + switch value.(type) { + case nil, bool, json.Number: + return previewStyle.Render(fmt.Sprintf("%v", value)) + case string: + return previewStyle.Render(fmt.Sprintf("%q", value)) + case *dict: + return previewStyle.Render("{\u2026}") + case array: + return previewStyle.Render("[\u2026]") + } + return "..." + } + + switch v.(type) { + case *dict: + output := bracketStyle.Render("{") + keys := v.(*dict).keys + for _, k := range keys { + key := fmt.Sprintf("%q", k) + output += previewStyle.Render(key + ": ") + value, _ := v.(*dict).get(k) + output += printValue(value) + break + } + if len(keys) == 1 { + output += bracketStyle.Render("}") + } else { + output += bracketStyle.Render(", \u2026}") + } + return output + + case array: + output := bracketStyle.Render("[") + slice := v.(array) + for _, value := range slice { + output += printValue(value) + break + } + if len(slice) == 1 { + output += bracketStyle.Render("]") + } else { + output += bracketStyle.Render(", \u2026]") + } + return output + } + return "?" +} + +func wrapLines(chunks []string, keyEndPos, mWidth int, subident string, stringStyle, searchStyle func(s string) string) []string { + wrappedLines := make([]string, 0) + currentLine := "" + ident := "" // First line stays on the same line with a "key", + pos := keyEndPos // so no ident is needed. Start counting from the "key" offset. + style := stringStyle + for i, chunk := range chunks { + if i%2 == 0 { + style = stringStyle + } else { + style = searchStyle + } + buffer := "" + for _, ch := range chunk { + buffer += string(ch) + if pos == mWidth-1 { + wrappedLines = append(wrappedLines, ident+currentLine+style(buffer)) + currentLine = "" + buffer = "" + pos = width(subident) // Start counting from ident. + ident = subident // After first line, add ident to all. + } else { + pos++ + } + } + currentLine += style(buffer) + } + if width(currentLine) > 0 { + wrappedLines = append(wrappedLines, subident+currentLine) + } + return wrappedLines +} + +func (m *model) split(line, path string) []string { + var indexes [][]int + if m.searchResults != nil { + sr, ok := m.searchResults.get(path) + if ok { + indexes = sr.(searchResult).value + } + } + return explode(line, indexes) +} + +func explode(s string, indexes [][]int) []string { + out := make([]string, 0) + pos := 0 + for _, l := range indexes { + out = append(out, s[pos:l[0]]) + out = append(out, s[l[0]:l[1]]) + pos = l[1] + } + out = append(out, s[pos:]) + return out +} + +func mergeChunks(chunks []string, stringStyle, searchStyle func(s string) string) string { + currentLine := "" + for i, chunk := range chunks { + if i%2 == 0 { + currentLine += stringStyle(chunk) + } else { + currentLine += searchStyle(chunk) + } + } + return currentLine +} diff --git a/search.go b/search.go new file mode 100644 index 0000000..0c63252 --- /dev/null +++ b/search.go @@ -0,0 +1,119 @@ +package main + +import ( + "encoding/json" + "fmt" + "regexp" +) + +type searchResult struct { + key, value [][]int +} + +func (m *model) doSearch(s string) { + re, err := regexp.Compile("(?i)" + s) + if err != nil { + m.searchRegexCompileError = err.Error() + m.searchInput.Blur() + return + } + m.searchRegexCompileError = "" + results := newDict() + addSearchResult := func(path string, indexes [][]int) { + if indexes != nil { + sr := searchResult{} + prev, ok := results.get(path) + if ok { + sr = prev.(searchResult) + } + sr.value = indexes + results.set(path, sr) + } + } + + dfs(m.json, func(it iterator) { + switch it.object.(type) { + case nil: + line := "null" + found := re.FindAllStringIndex(line, -1) + addSearchResult(it.path, found) + case bool: + line := stringify(it.object) + found := re.FindAllStringIndex(line, -1) + addSearchResult(it.path, found) + case json.Number: + line := it.object.(json.Number).String() + found := re.FindAllStringIndex(line, -1) + addSearchResult(it.path, found) + case string: + line := fmt.Sprintf("%q", it.object) + found := re.FindAllStringIndex(line, -1) + addSearchResult(it.path, found) + case *dict: + keys := it.object.(*dict).keys + for _, key := range keys { + line := fmt.Sprintf("%q", key) + subpath := it.path + "." + key + indexes := re.FindAllStringIndex(line, -1) + if indexes != nil { + sr := searchResult{} + prev, ok := results.get(subpath) + if ok { + sr = prev.(searchResult) + } + sr.key = indexes + results.set(subpath, sr) + } + } + } + }) + m.searchResults = results + m.searchInput.Blur() + m.showSearchResults = true + m.jumpToSearchResult(0) +} + +func (m *model) jumpToSearchResult(at int) { + if m.searchResults == nil || len(m.searchResults.keys) == 0 { + return + } + m.showCursor = false + m.resultsCursor = at % len(m.searchResults.keys) + desiredPath := m.searchResults.keys[m.resultsCursor] + lineNumber, ok := m.pathToLineNumber.get(desiredPath) + if ok { + m.cursor = m.pathToLineNumber.indexes[desiredPath] + m.SetOffset(lineNumber.(int)) + m.render() + } else { + m.recursiveExpand(desiredPath) + m.render() + m.jumpToSearchResult(at) + } +} + +func (m *model) recursiveExpand(path string) { + m.expandedPaths[path] = true + if path != "" { + m.recursiveExpand(m.parents[path]) + } +} + +func (m *model) nextSearchResult() { + m.jumpToSearchResult((m.resultsCursor + 1) % len(m.searchResults.keys)) +} + +func (m *model) prevSearchResult() { + i := m.resultsCursor - 1 + if i < 0 { + i = len(m.searchResults.keys) - 1 + } + m.jumpToSearchResult(i) +} + +func (m *model) resultsCursorPath() string { + if m.searchResults == nil || len(m.searchResults.keys) == 0 { + return "?" + } + return m.searchResults.keys[m.resultsCursor] +} diff --git a/search_test.go b/search_test.go new file mode 100644 index 0000000..3cf32b7 --- /dev/null +++ b/search_test.go @@ -0,0 +1,42 @@ +package main + +import ( + "fmt" + "reflect" + "regexp" + "testing" +) + +func Test_splitByFoundIndexes(t *testing.T) { + s := fmt.Sprintf("%q", "0 aaa 123 \"bbb 44\" ccc 5") + re := regexp.MustCompile("\"?\\d+\"?") + indexes := re.FindAllStringIndex(s, -1) + chunks := explode(s, indexes) + expected := []string{"", "\"0", " aaa ", "123", " \\\"bbb ", "44", "\\\" ccc ", "5\"", ""} + ok := reflect.DeepEqual(chunks, expected) + if !ok { + t.Errorf( + "split error:\n"+ + " got %v,\n"+ + " expected %v", + stringify(chunks), + stringify(expected), + ) + } +} + +func Test_splitByFoundIndexes_empty(t *testing.T) { + s := fmt.Sprintf("%q", "foo") + chunks := explode(s, nil) + expected := []string{"\"foo\""} + ok := reflect.DeepEqual(chunks, expected) + if !ok { + t.Errorf( + "split error:\n"+ + " got %v,\n"+ + " expected %v", + stringify(chunks), + stringify(expected), + ) + } +} diff --git a/traverse.go b/traverse.go new file mode 100644 index 0000000..65ca634 --- /dev/null +++ b/traverse.go @@ -0,0 +1,38 @@ +package main + +type iterator struct { + object interface{} + path, parent string +} + +func dfs(object interface{}, f func(it iterator)) { + sub(iterator{object: object}, f) +} + +func sub(it iterator, f func(it iterator)) { + f(it) + switch it.object.(type) { + case *dict: + keys := it.object.(*dict).keys + for _, k := range keys { + subpath := it.path + "." + k + value, _ := it.object.(*dict).get(k) + sub(iterator{ + object: value, + path: subpath, + parent: it.path, + }, f) + } + + case array: + slice := it.object.(array) + for i, value := range slice { + subpath := accessor(it.path, i) + sub(iterator{ + object: value, + path: subpath, + parent: it.path, + }, f) + } + } +} diff --git a/util.go b/util.go new file mode 100644 index 0000000..2df2cce --- /dev/null +++ b/util.go @@ -0,0 +1,44 @@ +package main + +import ( + "encoding/json" + "fmt" + "github.com/charmbracelet/lipgloss" +) + +func clamp(v, low, high int) int { + if high < low { + low, high = high, low + } + return min(high, max(low, v)) +} + +func min(a, b int) int { + if a < b { + return a + } + return b +} + +func max(a, b int) int { + if a > b { + return a + } + return b +} + +func stringify(x interface{}) string { + str, err := json.Marshal(x) + if err != nil { + panic(err) + } + return string(str) +} + +func width(s string) int { + return lipgloss.Width(s) +} + +func accessor(path string, to interface{}) string { + return fmt.Sprintf("%v[%v]", path, to) +} diff --git a/util_test.go b/util_test.go new file mode 100644 index 0000000..2af9b06 --- /dev/null +++ b/util_test.go @@ -0,0 +1,26 @@ +package main + +import "testing" + +func Test_clamp(t *testing.T) { + got := clamp(1, 2, 3) + if got != 2 { + t.Errorf("clamp() = %v, want 2", got) + } + +} + +func Test_max(t *testing.T) { + got := max(1, 2) + if got != 2 { + t.Errorf("max() = %v, want 2", got) + } +} + +func Test_min(t *testing.T) { + got := min(1, 2) + if got != 1 { + t.Errorf("min() = %v, want 1", got) + } + +} diff --git a/viewport.go b/viewport.go new file mode 100644 index 0000000..4f58789 --- /dev/null +++ b/viewport.go @@ -0,0 +1,110 @@ +package main + +import ( + "math" +) + +func (m *model) AtTop() bool { + return m.offset <= 0 +} + +func (m *model) AtBottom() bool { + return m.offset >= m.maxYOffset() +} + +func (m *model) PastBottom() bool { + return m.offset > m.maxYOffset() +} + +func (m *model) ScrollPercent() float64 { + if m.height >= len(m.lines) { + return 1.0 + } + y := float64(m.offset) + h := float64(m.height) + t := float64(len(m.lines) - 1) + v := y / (t - h) + return math.Max(0.0, math.Min(1.0, v)) +} + +func (m *model) maxYOffset() int { + return max(0, len(m.lines)-m.height) +} + +func (m *model) visibleLines() (lines []string) { + if len(m.lines) > 0 { + top := max(0, m.offset) + bottom := clamp(m.offset+m.height, top, len(m.lines)) + lines = m.lines[top:bottom] + } + return lines +} + +func (m *model) SetOffset(n int) { + m.offset = clamp(n, 0, m.maxYOffset()) +} + +func (m *model) ViewDown() { + if m.AtBottom() { + return + } + + m.SetOffset(m.offset + m.height) +} + +func (m *model) ViewUp() { + if m.AtTop() { + return + } + + m.SetOffset(m.offset - m.height) +} + +func (m *model) HalfViewDown() { + if m.AtBottom() { + return + } + + m.SetOffset(m.offset + m.height/2) +} + +func (m *model) HalfViewUp() { + if m.AtTop() { + return + } + + m.SetOffset(m.offset - m.height/2) +} + +func (m *model) LineDown(n int) { + if m.AtBottom() || n == 0 { + return + } + + // Make sure the number of lines by which we're going to scroll isn't + // greater than the number of lines we actually have left before we reach + // the bottom. + m.SetOffset(m.offset + n) +} + +func (m *model) LineUp(n int) { + if m.AtTop() || n == 0 { + return + } + + // Make sure the number of lines by which we're going to scroll isn't + // greater than the number of lines we are from the top. + m.SetOffset(m.offset - n) +} + +func (m *model) GotoTop() { + if m.AtTop() { + return + } + + m.SetOffset(0) +} + +func (m *model) GotoBottom() { + m.SetOffset(m.maxYOffset()) +}