diff --git a/internal/engine/engine.go b/internal/engine/engine.go index 728c76a8..7c7560bf 100644 --- a/internal/engine/engine.go +++ b/internal/engine/engine.go @@ -4,13 +4,11 @@ import ( _ "embed" "io" "reflect" - "strconv" "strings" "github.com/dop251/goja" "github.com/antonmedv/fx/internal/jsonx" - "github.com/antonmedv/fx/internal/pretty" ) //go:embed stdlib.js @@ -29,46 +27,37 @@ type Parser interface { Recover() *jsonx.Node } -type Options struct { - Slurp bool - WithInline bool - WriteOut func(string) - WriteErr func(string) +type Error struct { + error string } -func Start(parser Parser, args []string, opts Options) int { - if opts.Slurp { - var ok bool - parser, ok = Slurp(parser, opts.WriteErr) - if !ok { - return 1 - } - } +func (e *Error) Error() string { + return e.error +} +func Start(parser Parser, args []string, out chan *jsonx.Node, errCh chan error, cancel <-chan struct{}) int { isPrettyPrintArg := len(args) == 1 && (args[0] == "." || args[0] == "this" || args[0] == "x") // Fast path. if isPrettyPrintArg { for { + select { + case <-cancel: + return 0 + default: + } + node, err := parser.Parse() if err != nil { if err == io.EOF { break } - opts.WriteErr(err.Error()) + errCh <- err return 1 } - if node.Kind == jsonx.String { - unquoted, err := strconv.Unquote(node.Value) - if err != nil { - panic(err) - } - opts.WriteOut(unquoted) - } else { - opts.WriteOut(pretty.Print(node, opts.WithInline)) - } + out <- node } return 0 @@ -78,8 +67,8 @@ func Start(parser Parser, args []string, opts Options) int { if err := validateSyntax(args, i); err != nil { jsCode := transpile(args[i]) snippet := formatErr(args, i, jsCode) - message := errorToString(err) - opts.WriteErr(snippet + message) + message := gojaErrorToString(err) + errCh <- &Error{snippet + message} return 1 } } @@ -88,9 +77,11 @@ func Start(parser Parser, args []string, opts Options) int { code.WriteString(Stdlib) code.WriteString(JS(args)) - vm := NewVM(opts.WriteOut) + vm := NewVM(func(s string) { + out <- &jsonx.Node{Kind: jsonx.Err, Value: s} + }) if _, err := vm.RunString(code.String()); err != nil { - opts.WriteErr(errorToString(err)) + errCh <- &Error{gojaErrorToString(err)} return 1 } @@ -101,26 +92,32 @@ func Start(parser Parser, args []string, opts Options) int { echo := func(output goja.Value) { rtype := output.ExportType() if output.StrictEquals(undefined) { - opts.WriteErr("undefined") + errCh <- &Error{"undefined"} } else if rtype != nil && rtype.Kind() == reflect.String { - opts.WriteOut(output.String()) + out <- &jsonx.Node{Kind: jsonx.String, Value: output.String()} } else { jsonOut := Stringify(output, vm, 0) nodeOut, err := jsonx.Parse([]byte(jsonOut)) if err != nil { panic(err) } - opts.WriteOut(pretty.Print(nodeOut, opts.WithInline)) + out <- nodeOut } } for { + select { + case <-cancel: + return 0 + default: + } + node, err := parser.Parse() if err != nil { if err == io.EOF { break } - opts.WriteErr(err.Error()) + errCh <- err return 1 } @@ -130,7 +127,7 @@ func Start(parser Parser, args []string, opts Options) int { return exitCode } if err != nil { - opts.WriteErr(errorToString(err)) + errCh <- &Error{gojaErrorToString(err)} return 1 } diff --git a/internal/engine/engine_test.go b/internal/engine/engine_test.go index 5f90d1de..f4f29c8a 100644 --- a/internal/engine/engine_test.go +++ b/internal/engine/engine_test.go @@ -2,14 +2,52 @@ package engine_test import ( "strings" + "sync" "testing" "github.com/stretchr/testify/assert" "github.com/antonmedv/fx/internal/engine" "github.com/antonmedv/fx/internal/jsonx" + "github.com/antonmedv/fx/internal/pretty" ) +// runEngine runs the engine with the given parser and args, collecting outputs and errors. +// It returns the exit code, collected outputs, and collected errors. +func runEngine(parser engine.Parser, args []string) (exitCode int, outs []string, errs []string) { + out := make(chan *jsonx.Node) + errCh := make(chan error) + cancel := make(chan struct{}) + + var wg sync.WaitGroup + wg.Add(2) + + go func() { + defer wg.Done() + for node := range out { + if node.Kind == jsonx.String { + outs = append(outs, node.Value) + } else { + outs = append(outs, pretty.Print(node, false)) + } + } + }() + + go func() { + defer wg.Done() + for err := range errCh { + errs = append(errs, err.Error()) + } + }() + + exitCode = engine.Start(parser, args, out, errCh, cancel) + close(out) + close(errCh) + wg.Wait() + + return exitCode, outs, errs +} + func TestEngine(t *testing.T) { tests := []struct { name string @@ -22,7 +60,7 @@ func TestEngine(t *testing.T) { name: "fast path: string as raw", input: `"Hello, world!"`, args: []string{"."}, - expects: []string{"Hello, world!"}, + expects: []string{"\"Hello, world!\""}, errCount: 0, }, { @@ -45,17 +83,7 @@ func TestEngine(t *testing.T) { t.Run(tc.name, func(t *testing.T) { parser := jsonx.NewJsonParser(strings.NewReader(tc.input), false) - var outs, errs []string - writeOut := func(s string) { outs = append(outs, s) } - writeErr := func(s string) { errs = append(errs, s) } - - opts := engine.Options{ - Slurp: false, - WithInline: false, - WriteOut: writeOut, - WriteErr: writeErr, - } - exitCode := engine.Start(parser, tc.args, opts) + exitCode, outs, errs := runEngine(parser, tc.args) assert.Equal(t, 0, exitCode) assert.Len(t, errs, tc.errCount, "%s: unexpected error count", tc.name) @@ -68,17 +96,7 @@ func TestStart_InvalidJSON(t *testing.T) { input := `{"unclosed": 1` parser := jsonx.NewJsonParser(strings.NewReader(input), false) - var outs, errs []string - writeOut := func(s string) { outs = append(outs, s) } - writeErr := func(s string) { errs = append(errs, s) } - - opts := engine.Options{ - Slurp: false, - WithInline: false, - WriteOut: writeOut, - WriteErr: writeErr, - } - exitCode := engine.Start(parser, []string{".unclosed + '!'"}, opts) + exitCode, _, errs := runEngine(parser, []string{".unclosed + '!'"}) assert.Equal(t, 1, exitCode) assert.Len(t, errs, 1, "Expected one error message") @@ -88,17 +106,7 @@ func TestStart_FastPath_InvalidJSON(t *testing.T) { input := `{"unclosed": 1` parser := jsonx.NewJsonParser(strings.NewReader(input), false) - var outs, errs []string - writeOut := func(s string) { outs = append(outs, s) } - writeErr := func(s string) { errs = append(errs, s) } - - opts := engine.Options{ - Slurp: false, - WithInline: false, - WriteOut: writeOut, - WriteErr: writeErr, - } - exitCode := engine.Start(parser, []string{"."}, opts) + exitCode, _, errs := runEngine(parser, []string{"."}) assert.Equal(t, 1, exitCode) assert.Len(t, errs, 1, "Expected one error message") @@ -108,17 +116,7 @@ func TestStart_EscapeSequences(t *testing.T) { input := `{"emoji": "\ud83d\ude80"}` parser := jsonx.NewJsonParser(strings.NewReader(input), false) - var outs, errs []string - writeOut := func(s string) { outs = append(outs, s) } - writeErr := func(s string) { errs = append(errs, s) } - - opts := engine.Options{ - Slurp: false, - WithInline: false, - WriteOut: writeOut, - WriteErr: writeErr, - } - exitCode := engine.Start(parser, []string{".emoji"}, opts) + exitCode, outs, errs := runEngine(parser, []string{".emoji"}) assert.Equal(t, 0, exitCode) assert.Len(t, errs, 0, "Expected no error messages") @@ -129,18 +127,28 @@ func TestStart_EscapeSequences_in_key(t *testing.T) { input := `{"\ud83d\ude80": "\ud83d\ude80"}` parser := jsonx.NewJsonParser(strings.NewReader(input), false) - var outs, errs []string - writeOut := func(s string) { outs = append(outs, s) } - writeErr := func(s string) { errs = append(errs, s) } - - opts := engine.Options{ - Slurp: false, - WithInline: false, - WriteOut: writeOut, - WriteErr: writeErr, - } - exitCode := engine.Start(parser, []string{"x => x"}, opts) + exitCode, _, errs := runEngine(parser, []string{"x => x"}) assert.Equal(t, 0, exitCode) assert.Len(t, errs, 0, "Expected no error messages") } + +func TestStart_Cancel(t *testing.T) { + // Create a parser that would produce multiple values + input := "1 2 3 4 5" + parser := jsonx.NewJsonParser(strings.NewReader(input), false) + + out := make(chan *jsonx.Node, 10) + errCh := make(chan error, 10) + cancel := make(chan struct{}) + + // Close cancel immediately to test cancellation + close(cancel) + + exitCode := engine.Start(parser, []string{"."}, out, errCh, cancel) + close(out) + close(errCh) + + // Should return 0 on cancellation + assert.Equal(t, 0, exitCode) +} diff --git a/internal/engine/slurp.go b/internal/engine/slurp.go index 301569bb..b6c5dbcc 100644 --- a/internal/engine/slurp.go +++ b/internal/engine/slurp.go @@ -6,7 +6,7 @@ import ( "github.com/antonmedv/fx/internal/jsonx" ) -func Slurp(parser Parser, writeErr func(string)) (Parser, bool) { +func Slurp(parser Parser) (Parser, error) { arr := &jsonx.Node{ Kind: jsonx.Array, Value: "[", @@ -20,8 +20,7 @@ func Slurp(parser Parser, writeErr func(string)) (Parser, bool) { if err == io.EOF { break } - writeErr(err.Error()) - return nil, false + return nil, err } node.Parent = arr @@ -46,7 +45,7 @@ func Slurp(parser Parser, writeErr func(string)) (Parser, bool) { } arr.End = end.Next - return &slurpParser{node: arr}, true + return &slurpParser{node: arr}, nil } type slurpParser struct { diff --git a/internal/engine/utils.go b/internal/engine/utils.go index f2333c55..f62b57bb 100644 --- a/internal/engine/utils.go +++ b/internal/engine/utils.go @@ -25,7 +25,7 @@ func extractErrorMessage(s string) string { return s } -func errorToString(err error) string { +func gojaErrorToString(err error) string { if exception, ok := err.(*goja.Exception); ok { message := exception.Value().String() message = extractErrorMessage(message) diff --git a/keymap.go b/keymap.go index a5d1125f..9436616f 100644 --- a/keymap.go +++ b/keymap.go @@ -34,6 +34,7 @@ type KeyMap struct { ShowSelector key.Binding `category:"View"` GoBack key.Binding `category:"Navigation"` GoForward key.Binding `category:"Navigation"` + Query key.Binding `category:"Other"` Help key.Binding `category:"Other"` CommandLine key.Binding `category:"Other"` Quit key.Binding `category:"Other"` @@ -92,6 +93,10 @@ func init() { key.WithKeys("up", "k"), key.WithHelp("", "up"), ), + Query: key.NewBinding( + key.WithKeys("."), + key.WithHelp("", "open query input"), + ), Help: key.NewBinding( key.WithKeys("?"), key.WithHelp("", "show help"), @@ -193,8 +198,7 @@ var ( yankKey = key.NewBinding(key.WithKeys("k")) yankPath = key.NewBinding(key.WithKeys("p")) yankKeyValue = key.NewBinding(key.WithKeys("b")) - arrowUp = key.NewBinding(key.WithKeys("up")) - arrowDown = key.NewBinding(key.WithKeys("down")) showSizes = key.NewBinding(key.WithKeys("s")) showLineNumbers = key.NewBinding(key.WithKeys("l")) + ctrlC = key.NewBinding(key.WithKeys("ctrl+c")) ) diff --git a/main.go b/main.go index 2e5390cc..92399499 100644 --- a/main.go +++ b/main.go @@ -14,6 +14,7 @@ import ( "runtime/pprof" "strconv" "strings" + "sync" "github.com/antonmedv/clipboard" "github.com/charmbracelet/bubbles/key" @@ -29,6 +30,7 @@ import ( "github.com/antonmedv/fx/internal/fuzzy" "github.com/antonmedv/fx/internal/jsonpath" . "github.com/antonmedv/fx/internal/jsonx" + "github.com/antonmedv/fx/internal/pretty" "github.com/antonmedv/fx/internal/theme" "github.com/antonmedv/fx/internal/toml" "github.com/antonmedv/fx/internal/utils" @@ -211,19 +213,55 @@ func main() { } if len(args) > 0 || flagSlurp { - opts := engine.Options{ - Slurp: flagSlurp, - WithInline: !flagNoInline, - WriteOut: func(s string) { fmt.Println(s) }, - WriteErr: func(s string) { fmt.Fprintln(os.Stderr, s) }, + var err error + + if flagSlurp { + parser, err = engine.Slurp(parser) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } } - exitCode := engine.Start(parser, args, opts) + + out := make(chan *Node) + errCh := make(chan error) + cancel := make(chan struct{}) + + var wg sync.WaitGroup + wg.Add(2) + + go func() { + defer wg.Done() + for node := range out { + if node.Kind == String { + fmt.Println(node.Value) + } else { + fmt.Println(pretty.Print(node, !flagNoInline)) + } + } + }() + + go func() { + defer wg.Done() + for err := range errCh { + fmt.Fprintln(os.Stderr, err) + } + }() + + exitCode := engine.Start(parser, args, out, errCh, cancel) + close(out) + close(errCh) + wg.Wait() + if exitCode != 0 { os.Exit(exitCode) } return } + queryInput := textinput.New() + queryInput.Prompt = "" + commandInput := textinput.New() commandInput.Prompt = ":" @@ -264,6 +302,7 @@ func main() { showSizes: showSizes, showLineNumbers: showLineNumbers, fileName: fileName, + queryInput: queryInput, gotoSymbolInput: gotoSymbolInput, commandInput: commandInput, searchInput: searchInput, @@ -340,6 +379,7 @@ type model struct { showLineNumbers bool totalLines int fileName string + queryInput textinput.Model gotoSymbolInput textinput.Model commandInput textinput.Model searchInput textinput.Model @@ -530,6 +570,14 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } case tea.KeyMsg: + // Quit on Ctrl-C, no matter what. + if key.Matches(msg, ctrlC) { + return m, tea.Quit + } + + if m.queryInput.Focused() { + return m.handleQueryKey(msg) + } if m.commandInput.Focused() { return m.handleGotoLineKey(msg) } @@ -550,6 +598,24 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } +func (m *model) handleQueryKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + var cmd tea.Cmd + switch { + case msg.Type == tea.KeyEscape: + m.showCursor = true + m.queryInput.Blur() + + case msg.Type == tea.KeyEnter: + m.showCursor = true + m.queryInput.Blur() + m.doQuery(m.queryInput.Value()) + + default: + m.queryInput, cmd = m.queryInput.Update(msg) + } + return m, cmd +} + func (m *model) handleHelpKey(msg tea.Msg) (tea.Model, tea.Cmd) { var cmd tea.Cmd if msg, ok := msg.(tea.KeyMsg); ok { @@ -976,6 +1042,15 @@ func (m *model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case key.Matches(msg, keyMap.Delete): m.deletePending = true + + case key.Matches(msg, keyMap.Query): + m.showCursor = false + m.queryInput.CursorEnd() + m.queryInput.Width = m.termWidth - 1 // -1 for the cursor + if m.queryInput.Value() == "" { + m.queryInput.SetValue(".") + } + m.queryInput.Focus() } return m, nil } diff --git a/query.go b/query.go new file mode 100644 index 00000000..81f612e8 --- /dev/null +++ b/query.go @@ -0,0 +1,6 @@ +package main + +func (m *model) doQuery(query string) { + //args := []string{query} + //exitCode := engine.Start(parser, args, opts) +} diff --git a/view.go b/view.go index 0832f037..57293d63 100644 --- a/view.go +++ b/view.go @@ -147,38 +147,42 @@ func (m *model) View() string { screen = append(screen, '\n') } - if m.gotoSymbolInput.Focused() && m.fuzzyMatch != nil { - var matchedStr []byte - str := m.fuzzyMatch.Str - for i := 0; i < len(str); i++ { - if utils.Contains(i, m.fuzzyMatch.Pos) { - matchedStr = append(matchedStr, theme.CurrentTheme.Search(string(str[i]))...) - } else { - matchedStr = append(matchedStr, theme.CurrentTheme.StatusBar(string(str[i]))...) - } - } - repeatCount := m.termWidth - len(str) - if repeatCount > 0 { - matchedStr = append(matchedStr, theme.CurrentTheme.StatusBar(strings.Repeat(" ", repeatCount))...) - } - screen = append(screen, matchedStr...) + if m.queryInput.Focused() || m.queryInput.Value() != "" { + screen = append(screen, m.queryInput.View()...) } else { - statusBarWidth := m.termWidth - var indicator string - if m.eof { - percent := int(float64(cursorLineNumber) / float64(m.totalLines) * 100) - if cursorLineNumber == 1 { - percent = min(1, percent) + if m.gotoSymbolInput.Focused() && m.fuzzyMatch != nil { + var matchedStr []byte + str := m.fuzzyMatch.Str + for i := 0; i < len(str); i++ { + if utils.Contains(i, m.fuzzyMatch.Pos) { + matchedStr = append(matchedStr, theme.CurrentTheme.Search(string(str[i]))...) + } else { + matchedStr = append(matchedStr, theme.CurrentTheme.StatusBar(string(str[i]))...) + } } - indicator = fmt.Sprintf("%d%%", percent) + repeatCount := m.termWidth - len(str) + if repeatCount > 0 { + matchedStr = append(matchedStr, theme.CurrentTheme.StatusBar(strings.Repeat(" ", repeatCount))...) + } + screen = append(screen, matchedStr...) } else { - indicator = fmt.Sprintf(" %s", m.spinner.View()) - statusBarWidth += 2 // adjust for spinner - } + statusBarWidth := m.termWidth + var indicator string + if m.eof { + percent := int(float64(cursorLineNumber) / float64(m.totalLines) * 100) + if cursorLineNumber == 1 { + percent = min(1, percent) + } + indicator = fmt.Sprintf("%d%%", percent) + } else { + indicator = fmt.Sprintf(" %s", m.spinner.View()) + statusBarWidth += 2 // adjust for spinner + } - info := fmt.Sprintf("%s %s", indicator, m.fileName) - statusBar := flex(statusBarWidth, m.cursorPath(), info) - screen = append(screen, theme.CurrentTheme.StatusBar(statusBar)...) + info := fmt.Sprintf("%s %s", indicator, m.fileName) + statusBar := flex(statusBarWidth, m.cursorPath(), info) + screen = append(screen, theme.CurrentTheme.StatusBar(statusBar)...) + } } if m.yank {