You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
fx/main.go

781 lines
15 KiB
Go

package main
import (
9 months ago
"errors"
"fmt"
"io"
9 months ago
"io/fs"
"os"
9 months ago
"path"
9 months ago
"regexp"
"runtime/pprof"
9 months ago
"strconv"
9 months ago
"github.com/charmbracelet/bubbles/key"
9 months ago
"github.com/charmbracelet/bubbles/textinput"
9 months ago
tea "github.com/charmbracelet/bubbletea"
9 months ago
"github.com/charmbracelet/lipgloss"
9 months ago
"github.com/mattn/go-isatty"
9 months ago
9 months ago
jsonpath "github.com/antonmedv/fx/path"
)
9 months ago
var (
flagHelp bool
flagVersion bool
)
func main() {
9 months ago
if _, ok := os.LookupEnv("FX_PPROF"); ok {
f, err := os.Create("cpu.prof")
if err != nil {
panic(err)
}
err = pprof.StartCPUProfile(f)
if err != nil {
panic(err)
}
defer pprof.StopCPUProfile()
memProf, err := os.Create("mem.prof")
if err != nil {
panic(err)
}
defer pprof.WriteHeapProfile(memProf)
9 months ago
}
9 months ago
var args []string
for _, arg := range os.Args[1:] {
switch arg {
case "-h", "--help":
flagHelp = true
case "-v", "-V", "--version":
flagVersion = true
9 months ago
case "--themes":
themeTester()
return
9 months ago
default:
args = append(args, arg)
}
}
9 months ago
9 months ago
if flagHelp {
fmt.Println(usage(keyMap))
return
}
9 months ago
9 months ago
if flagVersion {
fmt.Println(version)
return
}
9 months ago
stdinIsTty := isatty.IsTerminal(os.Stdin.Fd())
var fileName string
var src io.Reader
if stdinIsTty && len(args) == 0 {
fmt.Println(usage(keyMap))
return
} else if stdinIsTty && len(args) == 1 {
9 months ago
filePath := args[0]
f, err := os.Open(filePath)
if err != nil {
9 months ago
var pathError *fs.PathError
if errors.As(err, &pathError) {
9 months ago
fmt.Println(err)
os.Exit(1)
9 months ago
} else {
9 months ago
panic(err)
}
}
fileName = path.Base(filePath)
src = f
9 months ago
} else if !stdinIsTty && len(args) == 0 {
9 months ago
src = os.Stdin
9 months ago
} else {
reduce(args)
return
9 months ago
}
data, err := io.ReadAll(src)
if err != nil {
panic(err)
}
head, err := parse(data)
if err != nil {
fmt.Print(err.Error())
os.Exit(1)
return
}
9 months ago
digInput := textinput.New()
digInput.Prompt = ""
digInput.TextStyle = lipgloss.NewStyle().
Background(lipgloss.Color("7")).
Foreground(lipgloss.Color("0"))
digInput.Cursor.Style = lipgloss.NewStyle().
Background(lipgloss.Color("15")).
Foreground(lipgloss.Color("0"))
9 months ago
searchInput := textinput.New()
searchInput.Prompt = "/"
m := &model{
9 months ago
head: head,
top: head,
wrap: true,
fileName: fileName,
digInput: digInput,
searchInput: searchInput,
searchResultsIndexes: make(map[*node][][]int),
}
p := tea.NewProgram(m, tea.WithAltScreen(), tea.WithMouseCellMotion())
_, err = p.Run()
if err != nil {
panic(err)
}
}
type model struct {
9 months ago
termWidth, termHeight int
9 months ago
head, top *node
9 months ago
cursor int // cursor position [0, termHeight)
9 months ago
wrap bool
9 months ago
margin int
9 months ago
fileName string
9 months ago
digInput textinput.Model
9 months ago
searchInput textinput.Model
searchError error
9 months ago
searchResults []*node
searchResultsCursor int
searchResultsIndexes map[*node][][]int
}
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:
9 months ago
m.termWidth = msg.Width
m.termHeight = msg.Height
9 months ago
wrapAll(m.top, m.termWidth)
case tea.MouseMsg:
switch msg.Type {
case tea.MouseWheelUp:
9 months ago
m.up()
case tea.MouseWheelDown:
9 months ago
m.down()
9 months ago
case tea.MouseLeft:
9 months ago
m.digInput.Blur()
9 months ago
if msg.Y < m.viewHeight() {
9 months ago
if m.cursor == msg.Y {
to := m.cursorPointsTo()
if to != nil {
if to.isCollapsed() {
to.expand()
} else {
to.collapse()
}
}
9 months ago
} else {
9 months ago
to := m.at(msg.Y)
if to != nil {
m.cursor = msg.Y
if to.isCollapsed() {
to.expand()
}
}
9 months ago
}
}
}
case tea.KeyMsg:
9 months ago
if m.digInput.Focused() {
return m.handleDigKey(msg)
}
9 months ago
if m.searchInput.Focused() {
return m.handleSearchKey(msg)
}
return m.handleKey(msg)
}
return m, nil
}
9 months ago
func (m *model) handleDigKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
switch {
case msg.Type == tea.KeyEscape, msg.Type == tea.KeyEnter:
m.digInput.Blur()
default:
m.digInput, cmd = m.digInput.Update(msg)
n := m.dig(m.digInput.Value())
if n != nil {
m.selectNode(n)
}
}
return m, cmd
}
9 months ago
func (m *model) handleSearchKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
switch {
case msg.Type == tea.KeyEscape:
m.searchInput.Blur()
9 months ago
m.searchInput.SetValue("")
m.searchError = nil
m.searchResults = nil
m.searchResultsCursor = 0
9 months ago
case msg.Type == tea.KeyEnter:
m.searchInput.Blur()
if m.searchInput.Value() != "" {
m.search(m.searchInput.Value())
}
default:
m.searchInput, cmd = m.searchInput.Update(msg)
}
return m, cmd
}
func (m *model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch {
case key.Matches(msg, keyMap.Quit):
return m, tea.Quit
case key.Matches(msg, keyMap.Up):
9 months ago
m.up()
case key.Matches(msg, keyMap.Down):
9 months ago
m.down()
case key.Matches(msg, keyMap.PageUp):
m.cursor = 0
for i := 0; i < m.viewHeight(); i++ {
m.up()
}
case key.Matches(msg, keyMap.PageDown):
m.cursor = m.viewHeight() - 1
for i := 0; i < m.viewHeight(); i++ {
m.down()
}
case key.Matches(msg, keyMap.HalfPageUp):
m.cursor = 0
for i := 0; i < m.viewHeight()/2; i++ {
m.up()
}
case key.Matches(msg, keyMap.HalfPageDown):
m.cursor = m.viewHeight() - 1
for i := 0; i < m.viewHeight()/2; i++ {
m.down()
9 months ago
}
9 months ago
case key.Matches(msg, keyMap.GotoTop):
m.head = m.top
9 months ago
m.cursor = 0
9 months ago
case key.Matches(msg, keyMap.GotoBottom):
m.head = m.findBottom()
9 months ago
m.cursor = 0
9 months ago
m.scrollIntoView()
9 months ago
case key.Matches(msg, keyMap.NextSibling):
pointsTo := m.cursorPointsTo()
var nextSibling *node
if pointsTo.end != nil && pointsTo.end.next != nil {
nextSibling = pointsTo.end.next
} else {
nextSibling = pointsTo.next
}
if nextSibling != nil {
9 months ago
m.selectNode(nextSibling)
9 months ago
}
case key.Matches(msg, keyMap.PrevSibling):
9 months ago
pointsTo := m.cursorPointsTo()
var prevSibling *node
if pointsTo.parent() != nil && pointsTo.parent().end == pointsTo {
prevSibling = pointsTo.parent()
} else if pointsTo.prev != nil {
prevSibling = pointsTo.prev
parent := prevSibling.parent()
if parent != nil && parent.end == prevSibling {
prevSibling = parent
}
}
if prevSibling != nil {
9 months ago
m.selectNode(prevSibling)
9 months ago
}
9 months ago
9 months ago
case key.Matches(msg, keyMap.Collapse):
9 months ago
n := m.cursorPointsTo()
if n.hasChildren() && !n.isCollapsed() {
n.collapse()
9 months ago
} else {
9 months ago
if n.parent() != nil {
n = n.parent()
}
9 months ago
}
9 months ago
m.selectNode(n)
9 months ago
case key.Matches(msg, keyMap.Expand):
m.cursorPointsTo().expand()
9 months ago
9 months ago
case key.Matches(msg, keyMap.CollapseRecursively):
n := m.cursorPointsTo()
if n.hasChildren() {
n.collapseRecursively()
}
case key.Matches(msg, keyMap.ExpandRecursively):
n := m.cursorPointsTo()
if n.hasChildren() {
n.expandRecursively()
}
case key.Matches(msg, keyMap.CollapseAll):
m.top.collapseRecursively()
m.cursor = 0
m.head = m.top
case key.Matches(msg, keyMap.ExpandAll):
at := m.cursorPointsTo()
m.top.expandRecursively()
m.selectNode(at)
9 months ago
case key.Matches(msg, keyMap.ToggleWrap):
at := m.cursorPointsTo()
m.wrap = !m.wrap
if m.wrap {
9 months ago
wrapAll(m.top, m.termWidth)
9 months ago
} else {
dropWrapAll(m.top)
}
if at.chunk != nil && at.value == nil {
at = at.parent()
}
m.selectNode(at)
9 months ago
case key.Matches(msg, keyMap.Dig):
m.digInput.SetValue(m.cursorPath())
m.digInput.CursorEnd()
m.digInput.Width = m.termWidth - 1
m.digInput.Focus()
9 months ago
case key.Matches(msg, keyMap.Search):
m.searchInput.CursorEnd()
m.searchInput.Width = m.termWidth - 2 // -1 for the prompt, -1 for the cursor
m.searchInput.Focus()
case key.Matches(msg, keyMap.SearchNext):
9 months ago
m.selectSearchResult(m.searchResultsCursor + 1)
9 months ago
case key.Matches(msg, keyMap.SearchPrev):
9 months ago
m.selectSearchResult(m.searchResultsCursor - 1)
9 months ago
}
return m, nil
}
9 months ago
func (m *model) up() {
m.cursor--
if m.cursor < 0 {
m.cursor = 0
if m.head.prev != nil {
m.head = m.head.prev
}
}
}
func (m *model) down() {
m.cursor++
n := m.cursorPointsTo()
if n == nil {
m.cursor--
return
}
if m.cursor >= m.viewHeight() {
m.cursor = m.viewHeight() - 1
9 months ago
if m.head.next != nil {
9 months ago
m.head = m.head.next
}
}
}
func (m *model) visibleLines() int {
visibleLines := 0
n := m.head
for n != nil && visibleLines < m.viewHeight() {
visibleLines++
n = n.next
}
return visibleLines
}
9 months ago
func (m *model) scrollIntoView() {
9 months ago
visibleLines := m.visibleLines()
9 months ago
if m.cursor >= visibleLines {
m.cursor = visibleLines - 1
}
9 months ago
for visibleLines < m.viewHeight() && m.head.prev != nil {
visibleLines++
m.cursor++
m.head = m.head.prev
}
}
func (m *model) View() string {
var screen []byte
9 months ago
n := m.head
9 months ago
9 months ago
printedLines := 0
9 months ago
for lineNumber := 0; lineNumber < m.viewHeight(); lineNumber++ {
9 months ago
if n == nil {
break
}
9 months ago
for ident := 0; ident < int(n.depth); ident++ {
screen = append(screen, ' ', ' ')
}
9 months ago
9 months ago
selected := m.cursor == lineNumber
9 months ago
9 months ago
if n.key != nil {
9 months ago
keyColor := currentTheme.Key
9 months ago
if m.cursor == lineNumber {
9 months ago
keyColor = currentTheme.Cursor
}
9 months ago
screen = append(screen, keyColor(n.key)...)
9 months ago
screen = append(screen, colon...)
9 months ago
selected = false // don't highlight the key's value
}
9 months ago
9 months ago
screen = append(screen, m.prettyPrint(n, selected)...)
9 months ago
if n.isCollapsed() {
9 months ago
if n.value[0] == '{' {
9 months ago
if n.collapsed.key != nil {
screen = append(screen, currentTheme.Preview(n.collapsed.key)...)
screen = append(screen, colonPreview...)
}
9 months ago
screen = append(screen, dot3...)
screen = append(screen, closeCurlyBracket...)
9 months ago
} else if n.value[0] == '[' {
9 months ago
screen = append(screen, dot3...)
screen = append(screen, closeSquareBracket...)
9 months ago
}
}
9 months ago
if n.comma {
9 months ago
screen = append(screen, comma...)
}
9 months ago
screen = append(screen, '\n')
9 months ago
printedLines++
n = n.next
}
9 months ago
9 months ago
for i := printedLines; i < m.viewHeight(); i++ {
screen = append(screen, empty...)
screen = append(screen, '\n')
}
9 months ago
9 months ago
if m.digInput.Focused() {
screen = append(screen, m.digInput.View()...)
} else {
9 months ago
statusBar := flex(m.termWidth, m.cursorPath(), m.fileName)
9 months ago
screen = append(screen, currentTheme.StatusBar([]byte(statusBar))...)
}
9 months ago
9 months ago
if m.searchInput.Focused() {
screen = append(screen, '\n')
screen = append(screen, m.searchInput.View()...)
} else if m.searchInput.Value() != "" {
screen = append(screen, '\n')
re, ci := regexCase(m.searchInput.Value())
9 months ago
re = "/" + re + "/"
9 months ago
if ci {
9 months ago
re += "i"
9 months ago
}
if m.searchError != nil {
screen = append(screen, flex(m.termWidth, re, m.searchError.Error())...)
} else if len(m.searchResults) == 0 {
screen = append(screen, flex(m.termWidth, re, "not found")...)
} else {
9 months ago
cursor := fmt.Sprintf("found: [%v/%v]", m.searchResultsCursor+1, len(m.searchResults))
9 months ago
screen = append(screen, flex(m.termWidth, re, cursor)...)
}
}
return string(screen)
}
9 months ago
9 months ago
func (m *model) prettyPrint(node *node, selected bool) []byte {
var b []byte
if node.chunk != nil {
b = node.chunk
} else {
b = node.value
}
if len(b) == 0 {
return b
}
var style color
if selected {
style = currentTheme.Cursor
} else if node.chunk != nil {
style = currentTheme.String
} else {
if node.chunk != nil {
style = currentTheme.String
}
switch b[0] {
case '"':
style = currentTheme.String
case 't', 'f':
style = currentTheme.Boolean
case 'n':
style = currentTheme.Null
case '{', '[', '}', ']':
style = currentTheme.Syntax
default:
if isDigit(b[0]) || b[0] == '-' {
style = currentTheme.Number
}
style = noColor
}
}
if indexes, ok := m.searchResultsIndexes[node]; ok {
bb := splitBytesByIndexes(b, indexes)
var out []byte
for i, b := range bb {
if i%2 == 0 {
out = append(out, style(b)...)
} else {
out = append(out, currentTheme.Search(b)...)
}
}
return out
} else {
return style(b)
}
}
9 months ago
func (m *model) viewHeight() int {
9 months ago
if m.searchInput.Focused() || m.searchInput.Value() != "" {
return m.termHeight - 2
}
9 months ago
return m.termHeight - 1
9 months ago
}
func (m *model) cursorPointsTo() *node {
9 months ago
return m.at(m.cursor)
}
func (m *model) at(pos int) *node {
9 months ago
head := m.head
9 months ago
for i := 0; i < pos; i++ {
9 months ago
if head == nil {
9 months ago
break
9 months ago
}
head = head.next
}
return head
}
9 months ago
func (m *model) findBottom() *node {
n := m.head
for n.next != nil {
if n.end != nil {
n = n.end
} else {
n = n.next
}
}
return n
9 months ago
}
9 months ago
func (m *model) nodeInsideView(n *node) bool {
if n == nil {
return false
}
head := m.head
for i := 0; i < m.viewHeight(); i++ {
if head == nil {
break
}
if head == n {
return true
}
head = head.next
}
return false
}
func (m *model) selectNodeInView(n *node) {
head := m.head
for i := 0; i < m.viewHeight(); i++ {
if head == nil {
break
}
if head == n {
m.cursor = i
return
}
head = head.next
}
}
9 months ago
func (m *model) selectNode(n *node) {
if m.nodeInsideView(n) {
m.selectNodeInView(n)
m.scrollIntoView()
} else {
m.cursor = 0
m.head = n
m.scrollIntoView()
}
9 months ago
parent := n.parent()
for parent != nil {
parent.expand()
parent = parent.parent()
}
9 months ago
}
9 months ago
func (m *model) cursorPath() string {
path := ""
at := m.cursorPointsTo()
for at != nil {
if at.prev != nil {
9 months ago
if at.chunk != nil && at.value == nil {
9 months ago
at = at.parent()
}
if at.key != nil {
quoted := string(at.key)
unquoted, err := strconv.Unquote(quoted)
if err != nil {
panic(err)
}
if identifier.MatchString(unquoted) {
path = "." + unquoted + path
} else {
path = "[" + quoted + "]" + path
}
} else if at.index >= 0 {
path = "[" + strconv.Itoa(at.index) + "]" + path
}
}
at = at.parent()
}
return path
}
9 months ago
func (m *model) dig(value string) *node {
9 months ago
p, ok := jsonpath.Split(value)
9 months ago
if !ok {
return nil
}
n := m.top
for _, part := range p {
if n == nil {
return nil
}
switch part := part.(type) {
case string:
n = n.findChildByKey(part)
case int:
n = n.findChildByIndex(part)
}
}
return n
}
9 months ago
func (m *model) search(s string) {
m.searchError = nil
m.searchResults = nil
9 months ago
m.searchResultsCursor = 0
9 months ago
code, ci := regexCase(s)
if ci {
code = "(?i)" + code
}
re, err := regexp.Compile(code)
if err != nil {
m.searchError = err
return
}
9 months ago
n := m.top
for n != nil {
indexes := re.FindAllIndex(n.value, -1)
if len(indexes) > 0 {
for range indexes {
m.searchResults = append(m.searchResults, n)
}
if n.chunk != nil && n.hasChildren() {
// String can be split into chunks, so we need to map the indexes to the chunks.
chunks := [][]byte{n.chunk}
chunkNodes := []*node{n}
var it *node
if n.isCollapsed() {
it = n.collapsed
} else {
it = n.next
}
for it != nil {
chunkNodes = append(chunkNodes, it)
chunks = append(chunks, it.chunk)
if it == n.end {
break
}
it = it.next
}
chunkMatches := splitIndexesToChunks(chunks, indexes)
for i, matches := range chunkMatches {
m.searchResultsIndexes[chunkNodes[i]] = matches
}
} else {
m.searchResultsIndexes[n] = indexes
}
}
if n.isCollapsed() {
n = n.collapsed
} else {
n = n.next
9 months ago
}
}
m.selectSearchResult(0)
}
func (m *model) selectSearchResult(i int) {
if len(m.searchResults) == 0 {
return
}
if i < 0 {
i = len(m.searchResults) - 1
}
if i >= len(m.searchResults) {
i = 0
}
9 months ago
m.searchResultsCursor = i
9 months ago
result := m.searchResults[i]
9 months ago
m.selectNode(result)
9 months ago
}