From 64b2f590270a4a8f689c414c19f5db43b88787b0 Mon Sep 17 00:00:00 2001 From: Caleb Foust Date: Sat, 17 Aug 2024 17:07:27 +0800 Subject: [PATCH 01/24] feat: skeleton for exec command --- cmd/cy/connect.go | 109 ++++++++++++++++++++++++++++++++++++++++++++++ cmd/cy/exec.go | 6 +++ cmd/cy/main.go | 86 ++++++++---------------------------- 3 files changed, 134 insertions(+), 67 deletions(-) create mode 100644 cmd/cy/connect.go create mode 100644 cmd/cy/exec.go diff --git a/cmd/cy/connect.go b/cmd/cy/connect.go new file mode 100644 index 00000000..039eb61f --- /dev/null +++ b/cmd/cy/connect.go @@ -0,0 +1,109 @@ +package main + +import ( + "fmt" + "net/http" + "os" + "runtime/pprof" + "runtime/trace" + + "github.com/rs/zerolog/log" + "github.com/sevlyar/go-daemon" +) + +// connectCommand is the entrypoint for the connect command. +func connectCommand() error { + var socketPath string + + if envPath, ok := os.LookupEnv(CY_SOCKET_ENV); ok { + socketPath = envPath + } else { + label, err := getSocketPath(CLI.Socket) + if err != nil { + return fmt.Errorf( + "failed to detect socket path: %s", + err, + ) + } + socketPath = label + } + + if daemon.WasReborn() { + cntx := new(daemon.Context) + _, err := cntx.Reborn() + if err != nil { + return fmt.Errorf("failed to reincarnate") + } + + defer func() { + if err := cntx.Release(); err != nil { + log.Panic().Err(err).Msg("unable to release pid-file") + } + }() + + if len(CLI.Connect.CPU) > 0 { + f, err := os.Create(CLI.Connect.CPU) + if err != nil { + return fmt.Errorf( + "unable to create %s: %s", + CLI.Connect.CPU, + err, + ) + } + defer f.Close() + if err := pprof.StartCPUProfile(f); err != nil { + return fmt.Errorf( + "could not start CPU profile: %s", + err, + ) + } + defer pprof.StopCPUProfile() + } + + if len(CLI.Connect.Trace) > 0 { + f, err := os.Create(CLI.Connect.Trace) + if err != nil { + return fmt.Errorf( + "unable to create %s: %s", + CLI.Connect.Trace, + err, + ) + } + defer f.Close() + if err := trace.Start(f); err != nil { + return fmt.Errorf( + "could not start trace profile: %s", + err, + ) + } + defer trace.Stop() + } + + err = serve(socketPath) + if err != nil && err != http.ErrServerClosed { + return fmt.Errorf( + "failed to start cy: %s", + err, + ) + } + return nil + } + + conn, err := connect(socketPath) + if err != nil { + return fmt.Errorf( + "failed to start cy: %s", + err, + ) + } + + err = poll(conn) + if err != nil { + return fmt.Errorf( + "failed while polling: %s", + err, + ) + } + + return nil +} diff --git a/cmd/cy/exec.go b/cmd/cy/exec.go new file mode 100644 index 00000000..33f5a71f --- /dev/null +++ b/cmd/cy/exec.go @@ -0,0 +1,6 @@ +package main + +// execCommand is the entrypoint for the exec command. +func execCommand() error { + return nil +} diff --git a/cmd/cy/main.go b/cmd/cy/main.go index 8b640a05..15a44c8c 100644 --- a/cmd/cy/main.go +++ b/cmd/cy/main.go @@ -2,28 +2,32 @@ package main import ( "fmt" - "net/http" "os" - "runtime/pprof" - "runtime/trace" "github.com/cfoust/cy/pkg/version" "github.com/alecthomas/kong" "github.com/rs/zerolog/log" - "github.com/sevlyar/go-daemon" ) var CLI struct { Socket string `help:"Specify the name of the socket." name:"socket-name" optional:"" short:"L" default:"default"` - CPU string `help:"Save a CPU performance report to the given path." name:"perf-file" optional:"" default:""` - Trace string `help:"Save a trace report to the given path." name:"trace-file" optional:"" default:""` - Version bool `help:"Print version information and exit." short:"v"` + Version bool `help:"Print version information and exit." short:"v"` + + Exec struct { + Command string `help:"Provide Janet code as a string argument." name:"command" short:"c" optional:"" default:""` + File string `arg:"" optional:"" help:"Provide a file containing Janet code." type:"existingfile"` + } `cmd:"" help:"Execute Janet code on the cy server."` + + Connect struct { + CPU string `help:"Save a CPU performance report to the given path." name:"perf-file" optional:"" default:""` + Trace string `help:"Save a trace report to the given path." name:"trace-file" optional:"" default:""` + } `cmd:"" default:"1" help:"Connect to the cy server, starting one if necessary."` } func main() { - kong.Parse(&CLI, + ctx := kong.Parse(&CLI, kong.Name("cy"), kong.Description("the time traveling terminal multiplexer"), kong.UsageOnError(), @@ -45,69 +49,17 @@ func main() { os.Exit(0) } - var socketPath string - - if envPath, ok := os.LookupEnv(CY_SOCKET_ENV); ok { - socketPath = envPath - } else { - label, err := getSocketPath(CLI.Socket) + switch ctx.Command() { + case "exec": + err := execCommand() if err != nil { - log.Panic().Err(err).Msg("failed to detect socket path") + log.Fatal().Err(err).Msg("failed to execute Janet code") } - socketPath = label - } - - if daemon.WasReborn() { - cntx := new(daemon.Context) - _, err := cntx.Reborn() + case "connect": + err := connectCommand() if err != nil { - log.Panic().Err(err).Msg("failed to reincarnate") - } - - defer func() { - if err := cntx.Release(); err != nil { - log.Panic().Err(err).Msg("unable to release pid-file") - } - }() - - if len(CLI.CPU) > 0 { - f, err := os.Create(CLI.CPU) - if err != nil { - log.Panic().Err(err).Msgf("unable to create %s", CLI.CPU) - } - defer f.Close() - if err := pprof.StartCPUProfile(f); err != nil { - log.Panic().Err(err).Msgf("could not start CPU profile") - } - defer pprof.StopCPUProfile() + log.Fatal().Err(err).Msg("failed to connect") } - - if len(CLI.Trace) > 0 { - f, err := os.Create(CLI.Trace) - if err != nil { - log.Panic().Err(err).Msgf("unable to create %s", CLI.Trace) - } - defer f.Close() - if err := trace.Start(f); err != nil { - log.Panic().Err(err).Msgf("could not start trace profile") - } - defer trace.Stop() - } - - err = serve(socketPath) - if err != nil && err != http.ErrServerClosed { - log.Panic().Err(err).Msg("failed to start cy") - } - return } - conn, err := connect(socketPath) - if err != nil { - log.Panic().Err(err).Msg("failed to start cy") - } - - err = poll(conn) - if err != nil { - log.Panic().Err(err).Msg("failed while polling") - } } From 9810d04662a0b828ad6f2dd0c459200ec67c3057 Mon Sep 17 00:00:00 2001 From: Caleb Foust Date: Sat, 17 Aug 2024 18:08:23 +0800 Subject: [PATCH 02/24] feat: CY env var with socket/id --- cmd/cy/server.go | 1 + cmd/cy/socket.go | 2 -- pkg/cy/api.go | 4 ++-- pkg/cy/api/cmd.go | 13 ++++++++++- pkg/cy/api/module.go | 1 + pkg/cy/client.go | 2 +- pkg/cy/constants.go | 13 +++++++++++ pkg/cy/janet.go | 1 + pkg/cy/module.go | 42 ++++++++++++++++++------------------ pkg/mux/screen/tree/group.go | 32 +++++++++++++++++++++++++++ pkg/mux/stream/cmd.go | 9 ++++++++ 11 files changed, 93 insertions(+), 27 deletions(-) create mode 100644 pkg/cy/constants.go diff --git a/cmd/cy/server.go b/cmd/cy/server.go index 09b5f9db..b6f1d95f 100644 --- a/cmd/cy/server.go +++ b/cmd/cy/server.go @@ -144,6 +144,7 @@ func (s *Server) HandleWSClient(conn ws.Client[P.Message]) { func serve(path string) error { cy, err := cy.Start(context.Background(), cy.Options{ SocketPath: path, + SocketName: CLI.Socket, Config: cy.FindConfig(), DataDir: cy.FindDataDir(), Shell: getShell(), diff --git a/cmd/cy/socket.go b/cmd/cy/socket.go index fc73306b..b6e5e221 100644 --- a/cmd/cy/socket.go +++ b/cmd/cy/socket.go @@ -9,13 +9,11 @@ import ( ) const ( - CY_SOCKET_ENV = "CY" CY_SOCKET_TEMPLATE = "/tmp/cy-%d" ) // Much of the socket creation code is ported from tmux. (see tmux.c) // Part laziness, part I wanted cy to be as familiar as possible. - func getSocketPath(name string) (string, error) { uid := os.Getuid() directory := fmt.Sprintf(CY_SOCKET_TEMPLATE, uid) diff --git a/pkg/cy/api.go b/pkg/cy/api.go index 1a40bd23..272c34d2 100644 --- a/pkg/cy/api.go +++ b/pkg/cy/api.go @@ -49,7 +49,7 @@ func (c *CyModule) CpuProfile(user interface{}) error { return fmt.Errorf("no user") } - socketPath := c.cy.socketPath + socketPath := c.cy.options.SocketPath if len(socketPath) == 0 { return fmt.Errorf("no socket path") } @@ -91,7 +91,7 @@ func (c *CyModule) Trace(user interface{}) error { return fmt.Errorf("no user") } - socketPath := c.cy.socketPath + socketPath := c.cy.options.SocketPath if len(socketPath) == 0 { return fmt.Errorf("no socket path") } diff --git a/pkg/cy/api/cmd.go b/pkg/cy/api/cmd.go index f6215416..6ac48077 100644 --- a/pkg/cy/api/cmd.go +++ b/pkg/cy/api/cmd.go @@ -22,6 +22,7 @@ type CmdParams struct { } type CmdModule struct { + Server Server Lifetime util.Lifetime Tree *tree.Tree TimeBinds, CopyBinds *bind.BindScope @@ -48,6 +49,8 @@ func (c *CmdModule) New( Command: command, }) + id, create := group.NewPaneCreator(c.Lifetime.Ctx()) + replayable, err := cmd.New( c.Lifetime.Ctx(), stream.CmdOptions{ @@ -55,6 +58,13 @@ func (c *CmdModule) New( Args: values.Args, Directory: values.Path, Restart: values.Restart, + Env: map[string]string{ + "CY": fmt.Sprintf( + "%s:%d", + c.Server.SocketName(), + id, + ), + }, }, group.Params().DataDirectory(), c.TimeBinds, @@ -64,7 +74,8 @@ func (c *CmdModule) New( return 0, err } - pane := group.NewPane(c.Lifetime.Ctx(), replayable) + pane := create(replayable) + if values.Name != "" { pane.SetName(values.Name) } diff --git a/pkg/cy/api/module.go b/pkg/cy/api/module.go index bcf5fb89..6dda37f6 100644 --- a/pkg/cy/api/module.go +++ b/pkg/cy/api/module.go @@ -29,6 +29,7 @@ type Client interface { } type Server interface { + SocketName() string ExecuteJanet(path string) error Log(level zerolog.Level, message string) } diff --git a/pkg/cy/client.go b/pkg/cy/client.go index b432f3e9..eb1b0d22 100644 --- a/pkg/cy/client.go +++ b/pkg/cy/client.go @@ -284,7 +284,7 @@ func (c *Client) initialize(options ClientOptions) error { screen.WithOpaque, ) - if c.cy.showSplash { + if !c.cy.options.HideSplash { splashScreen := splash.New(c.Ctx(), options.Size, !isClientSSH) c.outerLayers.NewLayer( splashScreen.Ctx(), diff --git a/pkg/cy/constants.go b/pkg/cy/constants.go new file mode 100644 index 00000000..52c75a18 --- /dev/null +++ b/pkg/cy/constants.go @@ -0,0 +1,13 @@ +package cy + +import ( + "regexp" +) + +var ( + CONTEXT_ENV = "CY" + CONTEXT_REGEX = regexp.MustCompile("^(?P\\w+):(?P\\d+)$") + // Regex used for validating socket names, which must be alphanumeric and not + // contain spaces. + SOCKET_REGEX = regexp.MustCompile("^(\\w+)$") +) diff --git a/pkg/cy/janet.go b/pkg/cy/janet.go index 43055822..8e545116 100644 --- a/pkg/cy/janet.go +++ b/pkg/cy/janet.go @@ -21,6 +21,7 @@ func (c *Cy) initJanet(ctx context.Context) (*janet.VM, error) { modules := map[string]interface{}{ "cmd": &api.CmdModule{ + Server: c, Lifetime: util.NewLifetime(c.Ctx()), Tree: c.tree, TimeBinds: c.timeBinds, diff --git a/pkg/cy/module.go b/pkg/cy/module.go index f0d1c8d2..e84c5d4c 100644 --- a/pkg/cy/module.go +++ b/pkg/cy/module.go @@ -38,6 +38,8 @@ type Options struct { SkipInput bool // The path to the Unix domain socket for this server. SocketPath string + // The name of the socket (before calculating the real path.) + SocketName string } type historyEvent struct { @@ -72,8 +74,7 @@ type Cy struct { log zerolog.Logger - configPath, socketPath string - showSplash bool + options Options toast *ToastLogger queuedToasts []toasts.Toast @@ -94,7 +95,7 @@ func (c *Cy) Log(level zerolog.Level, message string) { } func (c *Cy) loadConfig() error { - err := c.ExecuteFile(c.Ctx(), c.configPath) + err := c.ExecuteFile(c.Ctx(), c.options.Config) // We want to make a lot of noise if this fails for some reason, even // if this is being called in user code @@ -102,7 +103,7 @@ func (c *Cy) loadConfig() error { c.log.Error().Err(err).Msg("failed to execute config") message := fmt.Sprintf( "an error occurred while loading %s: %s", - c.configPath, + c.options.Config, err.Error(), ) c.toast.Error(message) @@ -118,7 +119,7 @@ func (c *Cy) reloadConfig() error { } c.Lock() - c.configPath = path + c.options.Config = path c.Unlock() return c.loadConfig() @@ -234,6 +235,10 @@ func (c *Cy) pollNodeEvents(ctx context.Context, events <-chan events.Msg) { } } +func (c *Cy) SocketName() string { + return c.options.SocketName +} + func Start(ctx context.Context, options Options) (*Cy, error) { timeBinds := bind.NewBindScope(nil) copyBinds := bind.NewBindScope(nil) @@ -241,17 +246,17 @@ func Start(ctx context.Context, options Options) (*Cy, error) { defaults := params.New() t := tree.NewTree(tree.WithParams(defaults.NewChild())) cy := Cy{ - Lifetime: util.NewLifetime(ctx), - tree: t, - muxServer: server.New(), - defaults: defaults, - timeBinds: timeBinds, - copyBinds: copyBinds, - showSplash: !options.HideSplash, - lastVisit: make(map[tree.NodeID]historyEvent), - lastWrite: make(map[tree.NodeID]historyEvent), - writes: make(chan historyEvent), - visits: make(chan historyEvent), + Lifetime: util.NewLifetime(ctx), + tree: t, + muxServer: server.New(), + defaults: defaults, + timeBinds: timeBinds, + copyBinds: copyBinds, + options: options, + lastVisit: make(map[tree.NodeID]historyEvent), + lastWrite: make(map[tree.NodeID]historyEvent), + writes: make(chan historyEvent), + visits: make(chan historyEvent), } cy.toast = NewToastLogger(cy.sendToast) @@ -296,13 +301,8 @@ func Start(ctx context.Context, options Options) (*Cy, error) { cy.VM = vm if len(options.Config) != 0 { - cy.configPath = options.Config cy.loadConfig() } - if len(options.SocketPath) != 0 { - cy.socketPath = options.SocketPath - } - return &cy, nil } diff --git a/pkg/mux/screen/tree/group.go b/pkg/mux/screen/tree/group.go index cee69764..462f2f27 100644 --- a/pkg/mux/screen/tree/group.go +++ b/pkg/mux/screen/tree/group.go @@ -4,6 +4,7 @@ import ( "context" "github.com/cfoust/cy/pkg/mux" + "github.com/cfoust/cy/pkg/util" "github.com/sasha-s/go-deadlock" ) @@ -64,6 +65,37 @@ func (g *Group) Leaves() []Node { return getLeaves(g) } +// NewPaneCreator is the same as NewPane, but it gives you the NodeID before +// the Node is created and a function to call with the final Screen. +func (g *Group) NewPaneCreator(ctx context.Context) (NodeID, func(screen mux.Screen) *Pane) { + p := &Pane{Lifetime: util.NewLifetime(ctx)} + metadata := g.tree.newMetadata(p) + p.metaData = metadata + + return p.Id(), func(screen mux.Screen) *Pane { + p.screen = screen + metadata.params = g.params.NewChild() + g.addNode(p) + + go func() { + updates := screen.Subscribe(ctx) + for { + select { + case event := <-updates.Recv(): + g.tree.Publish(NodeEvent{ + Id: metadata.Id(), + Event: event, + }) + case <-ctx.Done(): + return + } + } + }() + + return p + } +} + func (g *Group) NewPane(ctx context.Context, screen mux.Screen) *Pane { pane := newPane(ctx, screen) metadata := g.tree.newMetadata(pane) diff --git a/pkg/mux/stream/cmd.go b/pkg/mux/stream/cmd.go index 2da9361a..44ff3528 100644 --- a/pkg/mux/stream/cmd.go +++ b/pkg/mux/stream/cmd.go @@ -20,6 +20,7 @@ type CmdOptions struct { Command string Args []string Restart bool + Env map[string]string } type CmdStatus int @@ -108,6 +109,14 @@ func (c *Cmd) runPty(ctx context.Context) (chan error, error) { "TERM=xterm-256color", ) + for key, value := range options.Env { + cmd.Env = append( + cmd.Env, + // TODO(cfoust): 08/17/24 escaping? + fmt.Sprintf("%s=%s", key, value), + ) + } + fd, err := pty.StartWithSize( cmd, &pty.Winsize{ From da45d7ff497dedff3bfd2ff3719974bf1083753b Mon Sep 17 00:00:00 2001 From: Caleb Foust Date: Sun, 18 Aug 2024 07:22:31 +0800 Subject: [PATCH 03/24] fix: don't inherit fiber environment this was causing the *current-file* binding to be incorrect, which broke imports. --- pkg/janet/go-boot.janet | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pkg/janet/go-boot.janet b/pkg/janet/go-boot.janet index 8deda144..06cd856c 100644 --- a/pkg/janet/go-boot.janet +++ b/pkg/janet/go-boot.janet @@ -69,12 +69,12 @@ (defn on-parse-error [parser where] (set err (go/capture-stderr bad-parse parser where)) - (set (env :exit) true)) + (put env :exit true)) (defn on-compile-error [msg fiber where line col] (set err (go/capture-stderr bad-compile msg nil where line col)) (set err-fiber fiber) - (set (env :exit) true)) + (put env :exit true)) (run-context {:env env @@ -87,7 +87,7 @@ (set err-fiber f) (put env :exit true))) :source source - :fiber-flags :dti}) + :fiber-flags :dt}) (if (nil? err) env err)) From 1897093b58fc384ce77305f726dcb285215fffe3 Mon Sep 17 00:00:00 2001 From: Caleb Foust Date: Sun, 18 Aug 2024 08:47:31 +0800 Subject: [PATCH 04/24] fix: populate root-env correctly --- pkg/cy/boot/actions.janet | 4 +++- pkg/cy/boot/binds.janet | 2 ++ pkg/cy/boot/layout.janet | 2 ++ pkg/janet/interop.go | 2 ++ 4 files changed, 9 insertions(+), 1 deletion(-) diff --git a/pkg/cy/boot/actions.janet b/pkg/cy/boot/actions.janet index a054fc16..33d3fd91 100644 --- a/pkg/cy/boot/actions.janet +++ b/pkg/cy/boot/actions.janet @@ -11,7 +11,7 @@ In a similar way to other modern applications, cy has a command palette (invoked (def func-name (string name)) ~(upscope (defn ,name ,docstring [] ,;body) - (,put actions ,func-name [,docstring ,name]))) + (,put ,actions ,func-name [,docstring ,name]))) (defmacro key/bind-many ````Bind many bindings at once in the same scope. @@ -331,3 +331,5 @@ For example: action/trace "Save a trace to cy's socket directory." (cy/trace)) + +(merge-module root-env (curenv)) diff --git a/pkg/cy/boot/binds.janet b/pkg/cy/boot/binds.janet index 98fc56a8..4ca0df5f 100644 --- a/pkg/cy/boot/binds.janet +++ b/pkg/cy/boot/binds.janet @@ -163,3 +163,5 @@ ["F" [:re "."]] replay/jump-backward ["t" [:re "."]] replay/jump-to-forward ["T" [:re "."]] replay/jump-to-backward) + +(merge-module root-env (curenv)) diff --git a/pkg/cy/boot/layout.janet b/pkg/cy/boot/layout.janet index 15687c62..3bd133ed 100644 --- a/pkg/cy/boot/layout.janet +++ b/pkg/cy/boot/layout.janet @@ -868,3 +868,5 @@ For example, when moving vertically upwards, for a vertical split node this func action/prev-tab "Switch to the previous tab." (switch-tab-delta -1)) + +(merge-module root-env (curenv)) diff --git a/pkg/janet/interop.go b/pkg/janet/interop.go index 234344ca..8a947236 100644 --- a/pkg/janet/interop.go +++ b/pkg/janet/interop.go @@ -555,6 +555,7 @@ func (v *VM) registerCallback( docstring = strings.TrimSpace(docstring) + // TODO(cfoust): 08/18/24 this should be a Go template format := "(defn %s %s %s)" // You can provide a custom method prototype by providing a docstring @@ -581,6 +582,7 @@ func (v *VM) registerCallback( docstring, prototype, ) + code += "\n(merge-module root-env (curenv))" call := CallString(code) call.Options.UpdateEnv = true err = v.ExecuteCall(context.Background(), nil, call) From 3e681d0ca08381ed1203420d001af6c9afee6e7b Mon Sep 17 00:00:00 2001 From: Caleb Foust Date: Sun, 18 Aug 2024 12:21:32 +0800 Subject: [PATCH 05/24] feat: RPC proof of concept --- cmd/cy/client.go | 10 ++- cmd/cy/connect.go | 20 +++--- cmd/cy/exec.go | 83 +++++++++++++++++++++- cmd/cy/main.go | 13 +++- cmd/cy/rpc.go | 134 ++++++++++++++++++++++++++++++++++++ cmd/cy/server.go | 20 +++--- cmd/cy/server_test.go | 23 ++++++- pkg/cy/module.go | 17 +++++ pkg/io/pipe/map.go | 61 ---------------- pkg/io/pipe/module.go | 8 ++- pkg/io/protocol/messages.go | 17 +++++ pkg/io/protocol/serde.go | 4 ++ pkg/io/ws/client.go | 66 +++++++++--------- pkg/io/ws/server.go | 10 ++- pkg/io/ws/ws_test.go | 4 +- pkg/janet/exec.go | 20 ++++++ 16 files changed, 384 insertions(+), 126 deletions(-) create mode 100644 cmd/cy/rpc.go delete mode 100644 pkg/io/pipe/map.go diff --git a/cmd/cy/client.go b/cmd/cy/client.go index 013073fa..0adf7078 100644 --- a/cmd/cy/client.go +++ b/cmd/cy/client.go @@ -137,12 +137,12 @@ func poll(conn Connection) error { } go func() { - events := conn.Receive() + events := conn.Subscribe(conn.Ctx()) for { select { case <-conn.Ctx().Done(): return - case packet := <-events: + case packet := <-events.Recv(): if packet.Error != nil { // TODO(cfoust): 12/25/23 return @@ -167,7 +167,7 @@ func poll(conn Connection) error { ) } -func connect(socketPath string) (Connection, error) { +func connect(socketPath string, shouldStart bool) (Connection, error) { // mimics client_connect() in tmux's client.c var lockFd *os.File var lockPath string @@ -180,6 +180,10 @@ func connect(socketPath string) (Connection, error) { return conn, nil } + if !shouldStart { + return nil, err + } + message := err.Error() if !strings.Contains(message, ENOENT) && !strings.Contains(message, ECONNREFUSED) { return nil, err diff --git a/cmd/cy/connect.go b/cmd/cy/connect.go index 039eb61f..b9f3fd31 100644 --- a/cmd/cy/connect.go +++ b/cmd/cy/connect.go @@ -15,18 +15,14 @@ import ( func connectCommand() error { var socketPath string - if envPath, ok := os.LookupEnv(CY_SOCKET_ENV); ok { - socketPath = envPath - } else { - label, err := getSocketPath(CLI.Socket) - if err != nil { - return fmt.Errorf( - "failed to detect socket path: %s", - err, - ) - } - socketPath = label + label, err := getSocketPath(CLI.Socket) + if err != nil { + return fmt.Errorf( + "failed to detect socket path: %s", + err, + ) } + socketPath = label if daemon.WasReborn() { cntx := new(daemon.Context) @@ -89,7 +85,7 @@ func connectCommand() error { return nil } - conn, err := connect(socketPath) + conn, err := connect(socketPath, true) if err != nil { return fmt.Errorf( "failed to start cy: %s", diff --git a/cmd/cy/exec.go b/cmd/cy/exec.go index 33f5a71f..3f3dc482 100644 --- a/cmd/cy/exec.go +++ b/cmd/cy/exec.go @@ -1,6 +1,87 @@ package main +import ( + "fmt" + "io/ioutil" + "os" + "strconv" + + "github.com/cfoust/cy/pkg/cy" +) + +func getContext() (socket string, id int, ok bool) { + context, ok := os.LookupEnv(cy.CONTEXT_ENV) + if !ok { + return "", 0, false + } + + match := cy.CONTEXT_REGEX.FindStringSubmatch(context) + if match == nil { + return "", 0, false + } + + socket = match[cy.CONTEXT_REGEX.SubexpIndex("socket")] + id, _ = strconv.Atoi(match[cy.CONTEXT_REGEX.SubexpIndex("id")]) + ok = true + return +} + // execCommand is the entrypoint for the exec command. func execCommand() error { - return nil + if CLI.Exec.Command == "" && CLI.Exec.File == "" { + return fmt.Errorf("no Janet code provided") + } + + var err error + var source, cwd string + var code []byte + + cwd, err = os.Getwd() + if err != nil { + return err + } + + if CLI.Exec.Command != "" { + source = "" + code = []byte(CLI.Exec.Command) + } else if CLI.Exec.File == "-" { + source = "" + code, err = ioutil.ReadAll(os.Stdin) + if err != nil { + return fmt.Errorf("failed to read from stdin: %s", err) + } + } else { + source = CLI.Exec.File + code, err = ioutil.ReadFile(CLI.Exec.File) + if err != nil { + return fmt.Errorf("failed to read from %s: %s", CLI.Exec.File, err) + } + } + + socket, id, ok := getContext() + if !ok { + socket = CLI.Socket + } + + socketPath, err := getSocketPath(socket) + if err != nil { + return err + } + + var conn Connection + conn, err = connect(socketPath, false) + if err != nil { + return err + } + + _, err = RPC[RPCExecArgs, RPCExecResponse]( + conn, "exec", RPCExecArgs{ + Source: source, + Code: code, + Node: id, + Dir: cwd, + }, + ) + + return err } diff --git a/cmd/cy/main.go b/cmd/cy/main.go index 15a44c8c..d243baba 100644 --- a/cmd/cy/main.go +++ b/cmd/cy/main.go @@ -3,10 +3,13 @@ package main import ( "fmt" "os" + "time" + "github.com/cfoust/cy/pkg/cy" "github.com/cfoust/cy/pkg/version" "github.com/alecthomas/kong" + "github.com/rs/zerolog" "github.com/rs/zerolog/log" ) @@ -36,6 +39,9 @@ func main() { Summary: true, })) + consoleWriter := zerolog.ConsoleWriter{Out: os.Stdout, TimeFormat: time.RFC3339} + log.Logger = log.Output(consoleWriter) + if CLI.Version { fmt.Printf( "cy %s (commit %s)\n", @@ -49,8 +55,14 @@ func main() { os.Exit(0) } + if !cy.SOCKET_REGEX.MatchString(CLI.Socket) { + log.Fatal().Msg("invalid socket name, the socket name must be alphanumeric") + } + switch ctx.Command() { case "exec": + fallthrough + case "exec ": err := execCommand() if err != nil { log.Fatal().Err(err).Msg("failed to execute Janet code") @@ -61,5 +73,4 @@ func main() { log.Fatal().Err(err).Msg("failed to connect") } } - } diff --git a/cmd/cy/rpc.go b/cmd/cy/rpc.go new file mode 100644 index 00000000..7d5f177a --- /dev/null +++ b/cmd/cy/rpc.go @@ -0,0 +1,134 @@ +package main + +import ( + "fmt" + + P "github.com/cfoust/cy/pkg/io/protocol" + "github.com/cfoust/cy/pkg/mux/screen/tree" + + "github.com/ugorji/go/codec" +) + +type RPCExecArgs struct { + Source string + Node int + Code []byte + Dir string +} + +type RPCExecResponse struct { +} + +// RPC executes an RPC call on the server. +func RPC[S any, T any]( + conn Connection, + name string, + args S, +) (T, error) { + var result T + + msgs := conn.Subscribe(conn.Ctx()) + errc := make(chan error) + response := make(chan *P.RPCResponseMessage) + + go func() { + for { + select { + case <-conn.Ctx().Done(): + return + case msg := <-msgs.Recv(): + if msg.Error != nil { + errc <- msg.Error + return + } + + if msg.Contents.Type() != P.MessageTypeRPCResponse { + continue + } + + response <- msg.Contents.(*P.RPCResponseMessage) + } + } + }() + + var payload []byte + enc := codec.NewEncoderBytes( + &payload, + new(codec.MsgpackHandle), + ) + if err := enc.Encode(args); err != nil { + return result, err + } + + conn.Send(P.RPCRequestMessage{ + Name: "exec", + Args: payload, + }) + + select { + case <-conn.Ctx().Done(): + return result, conn.Ctx().Err() + case err := <-errc: + return result, err + case msg := <-response: + if msg.Errored { + return result, fmt.Errorf(msg.Error) + } + + dec := codec.NewDecoderBytes( + msg.Response, + new(codec.MsgpackHandle), + ) + if err := dec.Decode(&result); err != nil { + return result, err + } + } + + return result, nil +} + +func (s *Server) HandleRPC(conn Connection, msg *P.RPCRequestMessage) { + handle := new(codec.MsgpackHandle) + + var responseBytes []byte + var err error + + switch msg.Name { + case "exec": + var args RPCExecArgs + if err = codec.NewDecoderBytes( + msg.Args, + handle, + ).Decode(&args); err != nil { + break + } + + _, err = s.cy.ExecuteOnBehalf( + conn.Ctx(), + tree.NodeID(args.Node), + args.Code, + args.Source, + ) + if err != nil { + break + } + + enc := codec.NewEncoderBytes(&responseBytes, handle) + if err = enc.Encode(nil); err != nil { + return + } + default: + err = fmt.Errorf("unknown RPC: %s", msg.Name) + } + + response := P.RPCResponseMessage{ + Errored: err != nil, + Response: responseBytes, + } + + if err != nil { + response.Error = err.Error() + } + + conn.Send(response) +} diff --git a/cmd/cy/server.go b/cmd/cy/server.go index b6f1d95f..f33d562b 100644 --- a/cmd/cy/server.go +++ b/cmd/cy/server.go @@ -76,7 +76,7 @@ func (c *Client) Write(data []byte) (n int, err error) { } func (s *Server) HandleWSClient(conn ws.Client[P.Message]) { - events := conn.Receive() + events := conn.Subscribe(conn.Ctx()) // First we need to wait for the client's handshake to know how to // handle its terminal @@ -88,15 +88,19 @@ func (s *Server) HandleWSClient(conn ws.Client[P.Message]) { var err error select { + case <-conn.Ctx().Done(): + return case <-handshakeCtx.Done(): wsClient.closeError(fmt.Errorf("no handshake received")) return - case message, more := <-events: - if handshake, ok := message.Contents.(*P.HandshakeMessage); ok { - client, err = s.cy.NewClient(conn.Ctx(), *handshake) - } else if !more { - err = fmt.Errorf("closed by remote") - } else { + case msg := <-events.Recv(): + switch msg := msg.Contents.(type) { + case *P.HandshakeMessage: + client, err = s.cy.NewClient(conn.Ctx(), *msg) + case *P.RPCRequestMessage: + s.HandleRPC(conn, msg) + return + default: err = fmt.Errorf("must send handshake first") } @@ -115,7 +119,7 @@ func (s *Server) HandleWSClient(conn ws.Client[P.Message]) { case <-client.Ctx().Done(): wsClient.close() return - case packet := <-events: + case packet := <-events.Recv(): if packet.Error != nil { // TODO(cfoust): 06/08/23 handle gracefully continue diff --git a/cmd/cy/server_test.go b/cmd/cy/server_test.go index 47c4ca34..b9907c6f 100644 --- a/cmd/cy/server_test.go +++ b/cmd/cy/server_test.go @@ -128,12 +128,33 @@ func TestBadHandshake(t *testing.T) { }) require.NoError(t, err) + events := conn.Subscribe(conn.Ctx()) + go func() { for { - <-conn.Receive() + <-events.Recv() } }() <-conn.Ctx().Done() require.Error(t, conn.Ctx().Err()) } + +func TestRPC(t *testing.T) { + server := setupServer(t) + defer server.Release() + + conn, err := server.Connect() + require.NoError(t, err) + + result, err := RPC[RPCExecArgs, RPCExecResponse]( + conn, + "exec", + RPCExecArgs{ + Source: "", + Code: []byte(`(pp "hello")`), + }, + ) + require.NoError(t, err) + require.NotNil(t, result) +} diff --git a/pkg/cy/module.go b/pkg/cy/module.go index e84c5d4c..f3b34367 100644 --- a/pkg/cy/module.go +++ b/pkg/cy/module.go @@ -90,6 +90,23 @@ func (c *Cy) ExecuteJanet(path string) error { return c.ExecuteFile(c.Ctx(), path) } +func (c *Cy) ExecuteOnBehalf( + ctx context.Context, + node tree.NodeID, + code []byte, + path string, +) (result *janet.Value, err error) { + return c.ExecuteCallResult( + ctx, + // todo: infer + nil, + janet.Call{ + Code: code, + SourcePath: path, + }, + ) +} + func (c *Cy) Log(level zerolog.Level, message string) { c.log.WithLevel(level).Msgf(message) } diff --git a/pkg/io/pipe/map.go b/pkg/io/pipe/map.go deleted file mode 100644 index db21706b..00000000 --- a/pkg/io/pipe/map.go +++ /dev/null @@ -1,61 +0,0 @@ -package pipe - -type mappedPipe[S any, T any] struct { - original Pipe[S] - encode func(T) (S, error) - decode func(S) (T, error) -} - -func (t *mappedPipe[S, T]) Send(data T) error { - encoded, err := t.encode(data) - if err != nil { - return err - } - - return t.original.Send(encoded) -} - -func (t *mappedPipe[S, T]) Receive() <-chan Packet[T] { - before := t.original.Receive() - after := make(chan Packet[T]) - - go func() { - for { - select { - case msg, more := <-before: - if !more { - close(after) - return - } - - if msg.Error != nil { - after <- Packet[T]{ - Error: msg.Error, - } - close(after) - return - } - - decoded, err := t.decode(msg.Contents) - after <- Packet[T]{ - Contents: decoded, - Error: err, - } - } - } - }() - - return after -} - -func Map[S any, T any]( - original Pipe[S], - encode func(T) (S, error), - decode func(S) (T, error), -) Pipe[T] { - return &mappedPipe[S, T]{ - original: original, - encode: encode, - decode: decode, - } -} diff --git a/pkg/io/pipe/module.go b/pkg/io/pipe/module.go index ff3aee6f..49094d41 100644 --- a/pkg/io/pipe/module.go +++ b/pkg/io/pipe/module.go @@ -1,5 +1,11 @@ package pipe +import ( + "context" + + "github.com/cfoust/cy/pkg/util" +) + type Packet[T any] struct { Contents T Error error @@ -7,5 +13,5 @@ type Packet[T any] struct { type Pipe[T any] interface { Send(data T) error - Receive() <-chan Packet[T] + Subscribe(ctx context.Context) *util.Subscriber[Packet[T]] } diff --git a/pkg/io/protocol/messages.go b/pkg/io/protocol/messages.go index 1a0a8e89..35b72b81 100644 --- a/pkg/io/protocol/messages.go +++ b/pkg/io/protocol/messages.go @@ -14,6 +14,8 @@ const ( MessageTypeSize MessageTypeInput MessageTypeOutput + MessageTypeRPCRequest + MessageTypeRPCResponse MessageTypeClose ) @@ -70,3 +72,18 @@ type ErrorMessage struct { } func (i ErrorMessage) Type() MessageType { return MessageTypeError } + +type RPCRequestMessage struct { + Name string + Args []byte +} + +func (r RPCRequestMessage) Type() MessageType { return MessageTypeRPCRequest } + +type RPCResponseMessage struct { + Response []byte + Errored bool + Error string +} + +func (r RPCResponseMessage) Type() MessageType { return MessageTypeRPCResponse } diff --git a/pkg/io/protocol/serde.go b/pkg/io/protocol/serde.go index ff7223f7..f36b6035 100644 --- a/pkg/io/protocol/serde.go +++ b/pkg/io/protocol/serde.go @@ -33,6 +33,10 @@ func Decode(data []byte) (Message, error) { msg = &SizeMessage{} case MessageTypeClose: msg = &CloseMessage{} + case MessageTypeRPCRequest: + msg = &RPCRequestMessage{} + case MessageTypeRPCResponse: + msg = &RPCResponseMessage{} default: return nil, fmt.Errorf("invalid type: %d", type_) } diff --git a/pkg/io/ws/client.go b/pkg/io/ws/client.go index b88f78e8..61773df9 100644 --- a/pkg/io/ws/client.go +++ b/pkg/io/ws/client.go @@ -39,6 +39,7 @@ type RawClient Client[[]byte] type WSClient[T any] struct { util.Lifetime + *util.Publisher[P.Packet[T]] Conn *websocket.Conn protocol Protocol[T] } @@ -59,39 +60,35 @@ func (c *WSClient[T]) Send(data T) error { return c.Conn.Write(ctx, websocket.MessageBinary, encoded) } -func (c *WSClient[T]) Receive() <-chan P.Packet[T] { +func (c *WSClient[T]) poll() { ctx := c.Ctx() - out := make(chan P.Packet[T]) - go func() { - for { - if ctx.Err() != nil { - return - } - - typ, message, err := c.Conn.Read(ctx) - if err != nil { - out <- P.Packet[T]{ - Error: err, - } - // TODO(cfoust): 05/27/23 error handling? - c.Cancel() - return - } - - if typ != websocket.MessageBinary { - continue - } - - decoded, err := c.protocol.Decode(message) - - out <- P.Packet[T]{ - Contents: decoded, - Error: err, - } + + for { + if ctx.Err() != nil { + return + } + + typ, message, err := c.Conn.Read(ctx) + if err != nil { + c.Publish(P.Packet[T]{ + Error: err, + }) + // TODO(cfoust): 05/27/23 error handling? + c.Cancel() + return } - }() - return out + if typ != websocket.MessageBinary { + continue + } + + decoded, err := c.protocol.Decode(message) + + c.Publish(P.Packet[T]{ + Contents: decoded, + Error: err, + }) + } } func (c *WSClient[T]) Close() error { @@ -123,11 +120,14 @@ func Connect[T any](ctx context.Context, protocol Protocol[T], socketPath string c.SetReadLimit(32768 * 256) client := WSClient[T]{ - protocol: protocol, - Lifetime: util.NewLifetime(ctx), - Conn: c, + Lifetime: util.NewLifetime(ctx), + Publisher: util.NewPublisher[P.Packet[T]](), + protocol: protocol, + Conn: c, } + go client.poll() + go func() { <-client.Ctx().Done() c.Close(websocket.StatusNormalClosure, "") diff --git a/pkg/io/ws/server.go b/pkg/io/ws/server.go index fd5b82db..7c57aa34 100644 --- a/pkg/io/ws/server.go +++ b/pkg/io/ws/server.go @@ -6,6 +6,7 @@ import ( "net/http" "os" + P "github.com/cfoust/cy/pkg/io/pipe" "github.com/cfoust/cy/pkg/util" "nhooyr.io/websocket" @@ -33,11 +34,14 @@ func (ws *WSServer[T]) ServeHTTP(w http.ResponseWriter, r *http.Request) { defer c.Close(websocket.StatusInternalError, "operational fault during relay") client := WSClient[T]{ - Lifetime: util.NewLifetime(r.Context()), - Conn: c, - protocol: ws.protocol, + Lifetime: util.NewLifetime(r.Context()), + Publisher: util.NewPublisher[P.Packet[T]](), + Conn: c, + protocol: ws.protocol, } + go client.poll() + done := make(chan bool) go func() { ws.server.HandleWSClient(&client) diff --git a/pkg/io/ws/ws_test.go b/pkg/io/ws/ws_test.go index cddfd04e..067e7c40 100644 --- a/pkg/io/ws/ws_test.go +++ b/pkg/io/ws/ws_test.go @@ -43,9 +43,9 @@ func TestServer(t *testing.T) { ok := make(chan bool, 1) c, err := Connect(ctx, RawProtocol, socketPath) assert.NoError(t, err) + reads := c.Subscribe(ctx) go func() { time.Sleep(100 * time.Millisecond) - reads := c.Receive() timeout, cancel := context.WithTimeout(ctx, 1*time.Second) defer cancel() @@ -53,7 +53,7 @@ func TestServer(t *testing.T) { select { case <-timeout.Done(): ok <- false - case msg := <-reads: + case msg := <-reads.Recv(): ok <- string(msg.Contents) == "test" } }() diff --git a/pkg/janet/exec.go b/pkg/janet/exec.go index 32f0190a..031b2d25 100644 --- a/pkg/janet/exec.go +++ b/pkg/janet/exec.go @@ -195,6 +195,26 @@ func (v *VM) ExecuteCall(ctx context.Context, user interface{}, call Call) error return req.WaitErr() } +// ExecuteCallResult executes a call and returns the Janet value the code +// returned. +func (v *VM) ExecuteCallResult( + ctx context.Context, + user interface{}, + call Call, +) (*Value, error) { + result := make(chan Result) + req := callRequest{ + Params: Params{ + Context: ctx, + User: user, + Result: result, + }, + Call: call, + } + v.requests <- req + return req.WaitResult() +} + func (v *VM) Execute(ctx context.Context, code string) error { return v.ExecuteCall(ctx, nil, CallString(code)) } From cc7b77a18fb1dae9f0265b29c1574f15a7f7ea0c Mon Sep 17 00:00:00 2001 From: Caleb Foust Date: Sun, 18 Aug 2024 14:48:24 +0800 Subject: [PATCH 06/24] feat: json library in janet --- pkg/janet/api.c | 8 + pkg/janet/api.h | 1 + pkg/janet/exec.go | 4 +- pkg/janet/json.c | 604 +++++++++++++++++++++++++++++++++++++++++++ pkg/janet/json.h | 3 + pkg/janet/module.go | 2 +- pkg/janet/vm_test.go | 6 + 7 files changed, 625 insertions(+), 3 deletions(-) create mode 100644 pkg/janet/json.c create mode 100644 pkg/janet/json.h diff --git a/pkg/janet/api.c b/pkg/janet/api.c index 0c528330..8edb98e8 100644 --- a/pkg/janet/api.c +++ b/pkg/janet/api.c @@ -1,4 +1,5 @@ #include +#include Janet wrap_result_value(Janet value) { Janet parts[2] = { @@ -43,3 +44,10 @@ int tuple_length(const Janet *t) { int get_arity(JanetFunction *callee) { return callee->def->arity; } + +JANET_API JanetTable *go_janet_core_env() { + JanetTable *env = janet_core_env(NULL); + module_json(env); + printf("registered json\n"); + return env; +} diff --git a/pkg/janet/api.h b/pkg/janet/api.h index 428b1ae0..f5a86bb6 100644 --- a/pkg/janet/api.h +++ b/pkg/janet/api.h @@ -8,3 +8,4 @@ const char *_pretty_print(Janet value); Janet wrap_keyword(const char *str); int tuple_length(const Janet *t); int get_arity(JanetFunction *callee); +JANET_API JanetTable *go_janet_core_env(); diff --git a/pkg/janet/exec.go b/pkg/janet/exec.go index 031b2d25..e79b7afe 100644 --- a/pkg/janet/exec.go +++ b/pkg/janet/exec.go @@ -52,7 +52,7 @@ type callRequest struct { // Run code without using our evaluation function. This can panic. func (v *VM) runCodeUnsafe(code []byte, source string) { - env := C.janet_core_env(nil) + env := C.go_janet_core_env() sourcePtr := C.CString(source) C.janet_dobytes( env, @@ -112,7 +112,7 @@ func (v *VM) handleCodeResult(params Params, call Call) error { func (v *VM) runCode(params Params, call Call) { sourcePtr := C.CString(call.SourcePath) - var env *C.JanetTable = C.janet_core_env(nil) + var env *C.JanetTable = C.go_janet_core_env() if v.env != nil { env = v.env.table diff --git a/pkg/janet/json.c b/pkg/janet/json.c new file mode 100644 index 00000000..9801b8e1 --- /dev/null +++ b/pkg/janet/json.c @@ -0,0 +1,604 @@ +/* +* Copyright (c) 2022 Calvin Rose +* +* Permission is hereby granted, free of charge, to any person obtaining a copy +* of this software and associated documentation files (the "Software"), to +* deal in the Software without restriction, including without limitation the +* rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +* sell copies of the Software, and to permit persons to whom the Software is +* furnished to do so, subject to the following conditions: +* +* The above copyright notice and this permission notice shall be included in +* all copies or substantial portions of the Software. +* +* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +* IN THE SOFTWARE. +*/ + +#include +#include +#include + +/*****************/ +/* JSON Decoding */ +/*****************/ + +#define JSON_KEYWORD_KEY 0x10000 +#define JSON_NULL_TO_NIL 0x20000 + +/* Check if a character is whitespace */ +static int white(uint8_t c) { + return c == '\t' || c == '\n' || c == ' ' || c == '\r'; +} + +/* Skip whitespace */ +static void skipwhite(const char **p) { + const char *cp = *p; + for (;;) { + if (white(*cp)) + cp++; + else + break; + } + *p = cp; +} + +/* Get a hex digit value */ +static int hexdig(char dig) { + if (dig >= '0' && dig <= '9') + return dig - '0'; + if (dig >= 'a' && dig <= 'f') + return 10 + dig - 'a'; + if (dig >= 'A' && dig <= 'F') + return 10 + dig - 'A'; + return -1; +} + +/* Convert integer to hex character */ +static const char hex_digits[] = "0123456789ABCDEF"; +#define tohex(x) (hex_digits[x]) + +/* Read the hex value for a unicode escape */ +static const char *decode_utf16_escape(const char *p, uint32_t *outpoint) { + if (!p[0] || !p[1] || !p[2] || !p[3]) + return "unexpected end of source"; + int d1 = hexdig(p[0]); + int d2 = hexdig(p[1]); + int d3 = hexdig(p[2]); + int d4 = hexdig(p[3]); + if (d1 < 0 || d2 < 0 || d3 < 0 || d4 < 0) + return "invalid hex digit"; + *outpoint = d4 | (d3 << 4) | (d2 << 8) | (d1 << 12); + return NULL; +} + +/* Parse a string. Also handles the conversion of utf-16 to + * utf-8. */ +static const char *decode_string(const char **p, Janet *out) { + JanetBuffer *buffer = janet_buffer(0); + const char *cp = *p; + while (*cp != '"') { + uint8_t b = (uint8_t) *cp; + if (b < 32) return "invalid character in string"; + if (b == '\\') { + cp++; + switch(*cp) { + default: + return "unknown string escape"; + case 'b': + b = '\b'; + break; + case 'f': + b = '\f'; + break; + case 'n': + b = '\n'; + break; + case 'r': + b = '\r'; + break; + case 't': + b = '\t'; + break; + case '"': + b = '"'; + break; + case '\\': + b = '\\'; + break; + case '/': + b = '/'; + break; + case 'u': + { + /* Get codepoint and check for surrogate pair */ + uint32_t codepoint; + const char *err = decode_utf16_escape(cp + 1, &codepoint); + if (err) return err; + if (codepoint >= 0xDC00 && codepoint <= 0xDFFF) { + return "unexpected utf-16 low surrogate"; + } else if (codepoint >= 0xD800 && codepoint <= 0xDBFF) { + if (cp[5] != '\\') return "expected utf-16 low surrogate pair"; + if (cp[6] != 'u') return "expected utf-16 low surrogate pair"; + uint32_t lowsur; + const char *err = decode_utf16_escape(cp + 7, &lowsur); + if (err) return err; + if (lowsur < 0xDC00 || lowsur > 0xDFFF) + return "expected utf-16 low surrogate pair"; + codepoint = ((codepoint - 0xD800) << 10) + + (lowsur - 0xDC00) + 0x10000; + cp += 11; + } else { + cp += 5; + } + /* Write codepoint */ + if (codepoint <= 0x7F) { + janet_buffer_push_u8(buffer, codepoint); + } else if (codepoint <= 0x7FF) { + janet_buffer_push_u8(buffer, ((codepoint >> 6) & 0x1F) | 0xC0); + janet_buffer_push_u8(buffer, ((codepoint >> 0) & 0x3F) | 0x80); + } else if (codepoint <= 0xFFFF) { + janet_buffer_push_u8(buffer, ((codepoint >> 12) & 0x0F) | 0xE0); + janet_buffer_push_u8(buffer, ((codepoint >> 6) & 0x3F) | 0x80); + janet_buffer_push_u8(buffer, ((codepoint >> 0) & 0x3F) | 0x80); + } else { + janet_buffer_push_u8(buffer, ((codepoint >> 18) & 0x07) | 0xF0); + janet_buffer_push_u8(buffer, ((codepoint >> 12) & 0x3F) | 0x80); + janet_buffer_push_u8(buffer, ((codepoint >> 6) & 0x3F) | 0x80); + janet_buffer_push_u8(buffer, ((codepoint >> 0) & 0x3F) | 0x80); + } + } + continue; + } + } + janet_buffer_push_u8(buffer, b); + cp++; + } + *out = janet_stringv(buffer->data, buffer->count); + *p = cp + 1; + return NULL; +} + +static const char *decode_one(const char **p, Janet *out, int depth) { + + /* Prevent stack overflow */ + if ((depth & 0xFFFF) > JANET_RECURSION_GUARD) goto recurdepth; + + /* Skip leading whitepspace */ + skipwhite(p); + + /* Main switch */ + switch (**p) { + default: + goto badchar; + case '\0': + goto eos; + /* Numbers */ + case '-': case '0': case '1' : case '2': case '3' : case '4': + case '5': case '6': case '7' : case '8': case '9': + { + errno = 0; + char *end = NULL; + double x = strtod(*p, &end); + if (end == *p) goto badnum; + *p = end; + *out = janet_wrap_number(x); + break; + } + /* false, null, true */ + case 'f': + { + const char *cp = *p; + if (cp[1] != 'a' || cp[2] != 'l' || cp[3] != 's' || cp[4] != 'e') + goto badident; + *out = janet_wrap_false(); + *p = cp + 5; + break; + } + case 'n': + { + const char *cp = *p; + + if (cp[1] != 'u' || cp[2] != 'l' || cp[3] != 'l') + goto badident; + if (depth & JSON_NULL_TO_NIL) { + *out = janet_wrap_nil(); + } else { + *out = janet_ckeywordv("null"); + } + *p = cp + 4; + break; + } + case 't': + { + const char *cp = *p; + if (cp[1] != 'r' || cp[2] != 'u' || cp[3] != 'e') + goto badident; + *out = janet_wrap_true(); + *p = cp + 4; + break; + } + /* String */ + case '"': + { + const char *cp = *p + 1; + const char *start = cp; + while ((*cp >= 32 || *cp < 0) && *cp != '"' && *cp != '\\') + cp++; + /* Only use a buffer for strings with escapes, else just copy + * memory from source */ + if (*cp == '\\') { + *p = *p + 1; + const char *err = decode_string(p, out); + if (err) return err; + break; + } + if (*cp != '"') goto badchar; + *p = cp + 1; + *out = janet_stringv((const uint8_t *)start, cp - start); + break; + } + /* Array */ + case '[': + { + *p = *p + 1; + JanetArray *array = janet_array(0); + const char *err; + Janet subval; + skipwhite(p); + while (**p != ']') { + err = decode_one(p, &subval, depth + 1); + if (err) return err; + janet_array_push(array, subval); + skipwhite(p); + if (**p == ']') break; + if (**p != ',') goto wantcomma; + *p = *p + 1; + } + *p = *p + 1; + *out = janet_wrap_array(array); + } + break; + /* Object */ + case '{': + { + *p = *p + 1; + JanetTable *table = janet_table(0); + const char *err; + Janet subkey, subval; + skipwhite(p); + while (**p != '}') { + skipwhite(p); + if (**p != '"') goto wantstring; + err = decode_one(p, &subkey, depth + 1); + if (err) return err; + skipwhite(p); + if (**p != ':') goto wantcolon; + *p = *p + 1; + err = decode_one(p, &subval, depth + 1); + if (err) return err; + if (depth & JSON_KEYWORD_KEY) { + JanetString str = janet_unwrap_string(subkey); + subkey = janet_keywordv(str, janet_string_length(str)); + } + janet_table_put(table, subkey, subval); + skipwhite(p); + if (**p == '}') break; + if (**p != ',') goto wantcomma; + *p = *p + 1; + } + *p = *p + 1; + *out = janet_wrap_table(table); + break; + } + } + + /* Good return */ + return NULL; + + /* Errors */ +recurdepth: + return "recursed too deeply"; +eos: + return "unexpected end of source"; +badident: + return "bad identifier"; +badnum: + return "bad number"; +wantcomma: + return "expected comma"; +wantcolon: + return "expected colon"; +badchar: + return "unexpected character"; +wantstring: + return "expected json string"; +} + +static Janet json_decode(int32_t argc, Janet *argv) { + janet_arity(argc, 1, 3); + Janet ret = janet_wrap_nil(); + const char *err; + const char *start; + const char *p; + if (janet_checktype(argv[0], JANET_BUFFER)) { + JanetBuffer *buffer = janet_unwrap_buffer(argv[0]); + /* Ensure 0 padded */ + janet_buffer_push_u8(buffer, 0); + buffer->count--; + start = p = (const char *)buffer->data; + } else { + JanetByteView bytes = janet_getbytes(argv, 0); + start = p = (const char *)bytes.bytes; + } + int flags = 0; + if (argc > 1 && janet_truthy(argv[1])) flags |= JSON_KEYWORD_KEY; + if (argc > 2 && janet_truthy(argv[2])) flags |= JSON_NULL_TO_NIL; + err = decode_one(&p, &ret, flags); + /* Check trailing values */ + if (!err) { + skipwhite(&p); + if (*p) err = "unexpected extra token"; + } + if (err) + janet_panicf("decode error at position %d: %s", p - start, err); + return ret; +} + +/*****************/ +/* JSON Encoding */ +/*****************/ + +typedef struct { + JanetBuffer *buffer; + int32_t indent; + const uint8_t *tab; + const uint8_t *newline; + int32_t tablen; + int32_t newlinelen; +} Encoder; + +static void encode_newline(Encoder *e) { + janet_buffer_push_bytes(e->buffer, e->newline, e->newlinelen); + /* Skip loop if no tab string */ + if (!e->tablen) return; + for (int32_t i = 0; i < e->indent; i++) + janet_buffer_push_bytes(e->buffer, e->tab, e->tablen); +} + +static const char *encode_one(Encoder *e, Janet x, int depth) { + if ((depth & 0xFFFF) > JANET_RECURSION_GUARD) goto recurdepth; + switch(janet_type(x)) { + default: + goto badtype; + case JANET_NIL: + janet_buffer_push_cstring(e->buffer, "null"); + break; + case JANET_BOOLEAN: + janet_buffer_push_cstring(e->buffer, + janet_unwrap_boolean(x) ? "true" : "false"); + break; + case JANET_NUMBER: + { + char cbuf[25]; + sprintf(cbuf, "%.17g", janet_unwrap_number(x)); + janet_buffer_push_cstring(e->buffer, cbuf); + } + break; + case JANET_STRING: + case JANET_SYMBOL: + case JANET_KEYWORD: + case JANET_BUFFER: + { + const uint8_t *bytes; + const uint8_t *c; + const uint8_t *end; + int32_t len; + if (janet_keyeq(x, "null")) { + janet_buffer_push_cstring(e->buffer, "null"); + break; + } + janet_bytes_view(x, &bytes, &len); + janet_buffer_push_u8(e->buffer, '"'); + c = bytes; + end = bytes + len; + while (c < end) { + + /* get codepoint */ + uint32_t codepoint; + if (*c < 0x80) { + /* one byte */ + codepoint = *c++; + } else if (*c < 0xE0) { + /* two bytes */ + if (c + 2 > end) goto invalidutf8; + if ((c[1] >> 6) != 2) goto invalidutf8; + codepoint = ((c[0] & 0x1F) << 6) | + (c[1] & 0x3F); + c += 2; + } else if (*c < 0xF0) { + /* three bytes */ + if (c + 3 > end) goto invalidutf8; + if ((c[1] >> 6) != 2) goto invalidutf8; + if ((c[2] >> 6) != 2) goto invalidutf8; + codepoint = ((c[0] & 0x0F) << 12) | + ((c[1] & 0x3F) << 6) | + (c[2] & 0x3F); + c += 3; + } else if (*c < 0xF8) { + /* four bytes */ + if (c + 4 > end) goto invalidutf8; + if ((c[1] >> 6) != 2) goto invalidutf8; + if ((c[2] >> 6) != 2) goto invalidutf8; + if ((c[3] >> 6) != 2) goto invalidutf8; + codepoint = ((c[0] & 0x07) << 18) | + ((c[1] & 0x3F) << 12) | + ((c[2] & 0x3F) << 6) | + (c[3] & 0x3F); + c += 4; + } else { + /* invalid */ + goto invalidutf8; + } + + /* write codepoint */ + if (codepoint > 0x1F && codepoint < 0x80) { + /* Normal, no escape */ + if (codepoint == '\\' || codepoint == '"') + janet_buffer_push_u8(e->buffer, '\\'); + janet_buffer_push_u8(e->buffer, (uint8_t) codepoint); + } else if (codepoint < 0x10000) { + /* One unicode escape */ + uint8_t buf[6]; + buf[0] = '\\'; + buf[1] = 'u'; + buf[2] = tohex((codepoint >> 12) & 0xF); + buf[3] = tohex((codepoint >> 8) & 0xF); + buf[4] = tohex((codepoint >> 4) & 0xF); + buf[5] = tohex(codepoint & 0xF); + janet_buffer_push_bytes(e->buffer, buf, sizeof(buf)); + } else { + /* Two unicode escapes (surrogate pair) */ + uint32_t hi, lo; + uint8_t buf[12]; + hi = ((codepoint - 0x10000) >> 10) + 0xD800; + lo = ((codepoint - 0x10000) & 0x3FF) + 0xDC00; + buf[0] = '\\'; + buf[1] = 'u'; + buf[2] = tohex((hi >> 12) & 0xF); + buf[3] = tohex((hi >> 8) & 0xF); + buf[4] = tohex((hi >> 4) & 0xF); + buf[5] = tohex(hi & 0xF); + buf[6] = '\\'; + buf[7] = 'u'; + buf[8] = tohex((lo >> 12) & 0xF); + buf[9] = tohex((lo >> 8) & 0xF); + buf[10] = tohex((lo >> 4) & 0xF); + buf[11] = tohex(lo & 0xF); + janet_buffer_push_bytes(e->buffer, buf, sizeof(buf)); + } + } + janet_buffer_push_u8(e->buffer, '"'); + } + break; + case JANET_TUPLE: + case JANET_ARRAY: + { + const char *err; + const Janet *items; + int32_t len; + janet_indexed_view(x, &items, &len); + janet_buffer_push_u8(e->buffer, '['); + e->indent++; + for (int32_t i = 0; i < len; i++) { + encode_newline(e); + if ((err = encode_one(e, items[i], depth + 1))) return err; + janet_buffer_push_u8(e->buffer, ','); + } + e->indent--; + if (e->buffer->data[e->buffer->count - 1] == ',') { + e->buffer->count--; + encode_newline(e); + } + janet_buffer_push_u8(e->buffer, ']'); + } + break; + case JANET_TABLE: + case JANET_STRUCT: + { + const char *err; + const JanetKV *kvs; + int32_t count, capacity; + janet_dictionary_view(x, &kvs, &count, &capacity); + janet_buffer_push_u8(e->buffer, '{'); + e->indent++; + for (int32_t i = 0; i < capacity; i++) { + if (janet_checktype(kvs[i].key, JANET_NIL)) + continue; + if (!janet_checktypes(kvs[i].key, JANET_TFLAG_BYTES)) + return "object key must be a byte sequence"; + encode_newline(e); + if ((err = encode_one(e, kvs[i].key, depth + 1))) + return err; + const char *sep = e->tablen ? ": " : ":"; + janet_buffer_push_cstring(e->buffer, sep); + if ((err = encode_one(e, kvs[i].value, depth + 1))) + return err; + janet_buffer_push_u8(e->buffer, ','); + } + e->indent--; + if (e->buffer->data[e->buffer->count - 1] == ',') { + e->buffer->count--; + encode_newline(e); + } + janet_buffer_push_u8(e->buffer, '}'); + } + break; + } + return NULL; + + /* Errors */ + +badtype: + return "type not supported"; +invalidutf8: + return "string contains invalid utf-8"; +recurdepth: + return "recursed too deeply"; +} + +static Janet json_encode(int32_t argc, Janet *argv) { + janet_arity(argc, 1, 4); + Encoder e; + e.indent = 0; + e.buffer = janet_optbuffer(argv, argc, 3, 10); + e.tab = NULL; + e.newline = NULL; + e.tablen = 0; + e.newlinelen = 0; + if (argc >= 2) { + JanetByteView tab = janet_getbytes(argv, 1); + e.tab = tab.bytes; + e.tablen = tab.len; + if (argc >= 3) { + JanetByteView newline = janet_getbytes(argv, 2); + e.newline = newline.bytes; + e.newlinelen = newline.len; + } else { + e.newline = (const uint8_t *)"\r\n"; + e.newlinelen = 2; + } + } + const char *err = encode_one(&e, argv[0], 0); + if (err) janet_panicf("encode error: %s", err); + return janet_wrap_buffer(e.buffer); +} + +/****************/ +/* Module Entry */ +/****************/ + +static const JanetReg cfuns[] = { + {"json/encode", json_encode, + "(json/encode x &opt tab newline buf)\n\n" + "Encodes a janet value in JSON (utf-8). tab and newline are optional byte sequence which are used " + "to format the output JSON. if buf is provided, the formated JSON is append to buf instead of a new buffer. " + "Returns the modifed buffer." + }, + {"json/decode", json_decode, + "(json/decode json-source &opt keywords nils)\n\n" + "Returns a janet object after parsing JSON. If keywords is truthy, string " + "keys will be converted to keywords. If nils is truthy, null will become nil instead " + "of the keyword :null." + }, + {NULL, NULL, NULL} +}; + +void module_json(JanetTable *env) { + janet_cfuns(env, "json", cfuns); +} diff --git a/pkg/janet/json.h b/pkg/janet/json.h new file mode 100644 index 00000000..3309270b --- /dev/null +++ b/pkg/janet/json.h @@ -0,0 +1,3 @@ +#include + +void module_json(JanetTable *env); diff --git a/pkg/janet/module.go b/pkg/janet/module.go index 6456320c..79f73d3d 100644 --- a/pkg/janet/module.go +++ b/pkg/janet/module.go @@ -60,7 +60,7 @@ func (v *VM) poll(ctx context.Context, ready chan bool) { defer deInitJanet() // Set up the core environment - env := C.janet_core_env(nil) + env := C.go_janet_core_env() v.runCodeUnsafe(GO_BOOT_FILE, "go-boot.janet") // Then store our evaluation function diff --git a/pkg/janet/vm_test.go b/pkg/janet/vm_test.go index 72253c6b..6023e0b2 100644 --- a/pkg/janet/vm_test.go +++ b/pkg/janet/vm_test.go @@ -360,4 +360,10 @@ func TestVM(t *testing.T) { require.Equal(t, customBefore.Number, customAfter.Number) } }) + + t.Run("json", func(t *testing.T) { + err = vm.Execute(ctx, `(json/encode [1 2 3])`) + require.NoError(t, err) + }) + } From 2a81f923c5346f0b730dd3faaac40f9f00cbd7c3 Mon Sep 17 00:00:00 2001 From: Caleb Foust Date: Mon, 19 Aug 2024 06:50:28 +0800 Subject: [PATCH 07/24] feat: support yielding in Janet code --- pkg/janet/api.c | 1 - pkg/janet/exec.go | 82 +++++++++++++++++++++-------------------- pkg/janet/fiber.go | 47 ++++++++++++++++++++--- pkg/janet/go-boot.janet | 2 +- pkg/janet/interop.go | 2 +- pkg/janet/module.go | 19 ++++++++++ pkg/janet/value.go | 26 ++++++++++++- pkg/janet/vm_test.go | 13 +++++++ 8 files changed, 144 insertions(+), 48 deletions(-) diff --git a/pkg/janet/api.c b/pkg/janet/api.c index 8edb98e8..f3105b30 100644 --- a/pkg/janet/api.c +++ b/pkg/janet/api.c @@ -48,6 +48,5 @@ int get_arity(JanetFunction *callee) { JANET_API JanetTable *go_janet_core_env() { JanetTable *env = janet_core_env(NULL); module_json(env); - printf("registered json\n"); return env; } diff --git a/pkg/janet/exec.go b/pkg/janet/exec.go index e79b7afe..2f390253 100644 --- a/pkg/janet/exec.go +++ b/pkg/janet/exec.go @@ -64,18 +64,7 @@ func (v *VM) runCodeUnsafe(code []byte, source string) { C.free(unsafe.Pointer(sourcePtr)) } -func (v *VM) handleCodeResult(params Params, call Call) error { - var out *Value - select { - case <-params.Context.Done(): - return params.Context.Err() - case result := <-params.Result: - if result.Error != nil { - return result.Error - } - out = result.Out - } - +func (v *VM) handleCodeResult(call Call, out *Value) error { result := out.janet resultType := C.janet_type(result) @@ -102,13 +91,14 @@ func (v *VM) handleCodeResult(params Params, call Call) error { Value: v.value(result), table: C.janet_unwrap_table(result), } + } else { + out.unroot() } return nil } // Run a string containing Janet code and return any error that occurs. -// TODO(cfoust): 07/20/23 send error to errc with timeout func (v *VM) runCode(params Params, call Call) { sourcePtr := C.CString(call.SourcePath) @@ -148,17 +138,42 @@ func (v *VM) runCode(params Params, call Call) { go func() { v.runFiber(subParams, fiber, nil) - err := v.handleCodeResult(subParams, call) - if err != nil { - params.Error(err) + + select { + case <-params.Context.Done(): + params.Error(params.Context.Err()) return + case result := <-params.Result: + if result.Error != nil { + params.Error(result.Error) + return + } + if result.Yield != nil { + params.Yield(result.Yield) + return + } + + err := v.handleCodeResult( + call, + result.Out, + ) + if err != nil { + params.Error(err) + return + } + + params.Ok() } - - params.Ok() }() } -func (v *VM) runFunction(params Params, fun *C.JanetFunction, args []interface{}) { +// runFunction creates a fiber for the given function and arguments and runs +// it. +func (v *VM) runFunction( + params Params, + fun *C.JanetFunction, + args []interface{}, +) { cArgs := make([]C.Janet, 0) for _, arg := range args { value, err := v.marshal(arg) @@ -181,27 +196,13 @@ func (v *VM) runFunction(params Params, fun *C.JanetFunction, args []interface{} go v.runFiber(params, fiber, nil) } -func (v *VM) ExecuteCall(ctx context.Context, user interface{}, call Call) error { - result := make(chan Result) - req := callRequest{ - Params: Params{ - Context: ctx, - User: user, - Result: result, - }, - Call: call, - } - v.requests <- req - return req.WaitErr() -} - -// ExecuteCallResult executes a call and returns the Janet value the code -// returned. -func (v *VM) ExecuteCallResult( +// ExecuteCall executes a Call, which is the lowest-level interface for running +// Janet code. +func (v *VM) ExecuteCall( ctx context.Context, user interface{}, call Call, -) (*Value, error) { +) (*Result, error) { result := make(chan Result) req := callRequest{ Params: Params{ @@ -216,9 +217,11 @@ func (v *VM) ExecuteCallResult( } func (v *VM) Execute(ctx context.Context, code string) error { - return v.ExecuteCall(ctx, nil, CallString(code)) + _, err := v.ExecuteCall(ctx, nil, CallString(code)) + return err } +// ExecuteFile executes a file containing Janet code. func (v *VM) ExecuteFile(ctx context.Context, path string) error { bytes, err := readFile(path) if err != nil { @@ -228,5 +231,6 @@ func (v *VM) ExecuteFile(ctx context.Context, path string) error { call := CallBytes(bytes) call.SourcePath = path - return v.ExecuteCall(ctx, nil, call) + _, err = v.ExecuteCall(ctx, nil, call) + return err } diff --git a/pkg/janet/fiber.go b/pkg/janet/fiber.go index 21ae6ede..9ab4a603 100644 --- a/pkg/janet/fiber.go +++ b/pkg/janet/fiber.go @@ -17,8 +17,13 @@ import ( "unsafe" ) +// Result is the value produced when a fiber finishes executing. Fibers can halt in three ways: +// - They can return a value, which is stored in Out. +// - They can yield (with (yield)), which is stored in Yield. +// - They can error, the message for which is stored in Error. type Result struct { Out *Value + Yield *Value Error error } @@ -53,27 +58,54 @@ func (p Params) Out(value *Value) { } } +func (p Params) Yield(value *Value) { + p.Result <- Result{ + Yield: value, + } +} + +// WaitErr waits for a fiber to finish executing, ignoring any values it +// returns or yields. func (p Params) WaitErr() error { select { case result := <-p.Result: if result.Out != nil { result.Out.Free() } + if result.Yield != nil { + result.Yield.Free() + } return result.Error case <-p.Context.Done(): return p.Context.Err() } } -func (p Params) WaitResult() (*Value, error) { +// WaitOut waits for the result of a fiber. If the fiber yields (instead of +// just returning a result), WaitOut will return an error. +func (p Params) WaitOut() (*Value, error) { select { case result := <-p.Result: + if result.Yield != nil { + return nil, fmt.Errorf("unexpected yield") + } + return result.Out, result.Error case <-p.Context.Done(): return nil, p.Context.Err() } } +// WaitResult waits for the fiber to produce a Result and returns it. +func (p Params) WaitResult() (*Result, error) { + select { + case result := <-p.Result: + return &result, result.Error + case <-p.Context.Done(): + return nil, p.Context.Err() + } +} + type fiberRequest struct { Params // The fiber to run @@ -119,9 +151,10 @@ func (v *VM) runFiber(params Params, fiber *Fiber, in *Value) { } } -func (v *VM) handleYield(params Params, fiber *Fiber, out C.Janet) { +// handleCallback invokes a callback defined in Go. +func (v *VM) handleCallback(params Params, fiber *Fiber, out C.Janet) { if C.janet_checktype(out, C.JANET_TUPLE) == 0 { - params.Error(fmt.Errorf("(yield) called with non-tuple")) + params.Error(fmt.Errorf("(signal) called with non-tuple")) return } @@ -182,6 +215,9 @@ func (v *VM) continueFiber(params Params, fiber *Fiber, in *Value) { case C.JANET_SIGNAL_OK: params.Out(v.value(out)) return + case C.JANET_SIGNAL_YIELD: + params.Yield(v.value(out)) + return case C.JANET_SIGNAL_ERROR: var errStr string if err := v.unmarshal(out, &errStr); err != nil { @@ -191,8 +227,9 @@ func (v *VM) continueFiber(params Params, fiber *Fiber, in *Value) { params.Error(fmt.Errorf("%s", errStr)) return - case C.JANET_SIGNAL_YIELD: - v.handleYield(params, fiber, out) + case C.JANET_SIGNAL_USER5: + v.handleCallback(params, fiber, out) + return default: params.Error(fmt.Errorf("unrecognized signal: %d", signal)) return diff --git a/pkg/janet/go-boot.janet b/pkg/janet/go-boot.janet index 06cd856c..3e86b730 100644 --- a/pkg/janet/go-boot.janet +++ b/pkg/janet/go-boot.janet @@ -95,7 +95,7 @@ go/callback "Invoke a Go callback by name and return the result, but raise errors instead of returning them." [& args] - (def [status result] (yield args)) + (def [status result] (signal 5 args)) (case status :value result diff --git a/pkg/janet/interop.go b/pkg/janet/interop.go index 8a947236..2a0a6251 100644 --- a/pkg/janet/interop.go +++ b/pkg/janet/interop.go @@ -585,7 +585,7 @@ func (v *VM) registerCallback( code += "\n(merge-module root-env (curenv))" call := CallString(code) call.Options.UpdateEnv = true - err = v.ExecuteCall(context.Background(), nil, call) + _, err = v.ExecuteCall(context.Background(), nil, call) if err != nil { return err } diff --git a/pkg/janet/module.go b/pkg/janet/module.go index 79f73d3d..dcc5ebae 100644 --- a/pkg/janet/module.go +++ b/pkg/janet/module.go @@ -28,6 +28,8 @@ type VM struct { callbacks map[string]*Callback evaluate C.Janet + jsonEncode, format *Function + requests chan Request env *Table @@ -50,6 +52,20 @@ func (v *VM) Env() *Table { return v.env } +func (v *VM) getFunction(env *C.JanetTable, name string) *Function { + var fun C.Janet + C.janet_resolve( + env, + C.janet_csymbol(C.CString(name)), + &fun, + ) + + return &Function{ + Value: v.value(fun), + function: C.janet_unwrap_function(fun), + } +} + // Wait for code calls and process them. func (v *VM) poll(ctx context.Context, ready chan bool) { // All Janet state is thread-local, so we explicitly want to execute @@ -69,6 +85,9 @@ func (v *VM) poll(ctx context.Context, ready chan bool) { C.janet_gcroot(evaluate) v.evaluate = evaluate + v.jsonEncode = v.getFunction(env, "json/encode") + v.format = v.getFunction(env, "string/format") + ready <- true for { diff --git a/pkg/janet/value.go b/pkg/janet/value.go index c35f4309..dfd72e2e 100644 --- a/pkg/janet/value.go +++ b/pkg/janet/value.go @@ -68,6 +68,30 @@ type stringRequest struct { result chan string } +func (v *Value) JSON(ctx context.Context) ([]byte, error) { + if v.IsFree() { + return nil, ERROR_FREED + } + + out, err := v.vm.jsonEncode.CallResult( + ctx, + nil, + v.janet, + ) + if err != nil { + return nil, err + } + + var result []byte + err = out.Unmarshal(&result) + if err != nil { + out.Free() + return nil, err + } + + return result, nil +} + func (v *Value) String() string { if v.isSafe { return prettyPrint(v.janet) @@ -174,7 +198,7 @@ func (f *Function) CallResult( } f.vm.requests <- req - return req.WaitResult() + return req.WaitOut() } func (f *Function) Call(ctx context.Context, params ...interface{}) error { diff --git a/pkg/janet/vm_test.go b/pkg/janet/vm_test.go index 6023e0b2..27590457 100644 --- a/pkg/janet/vm_test.go +++ b/pkg/janet/vm_test.go @@ -364,6 +364,19 @@ func TestVM(t *testing.T) { t.Run("json", func(t *testing.T) { err = vm.Execute(ctx, `(json/encode [1 2 3])`) require.NoError(t, err) + + out, err := vm.ExecuteCallResult( + ctx, + nil, + CallString(`(yield [1 2 3])`), + ) + require.NoError(t, err) + require.NotNil(t, out) + + json, err := out.JSON(ctx) + require.NoError(t, err) + + t.Logf("json: %s", json) }) } From 5c47624fcce07e36ddca2bc95c36e2660df217d8 Mon Sep 17 00:00:00 2001 From: Caleb Foust Date: Mon, 19 Aug 2024 06:50:38 +0800 Subject: [PATCH 08/24] feat: adjust for Janet package changes --- cmd/cy/rpc.go | 57 ++++++++++++++++++++++++------------- pkg/cy/client.go | 3 +- pkg/cy/janet.go | 2 +- pkg/cy/module.go | 5 ++-- pkg/io/protocol/messages.go | 1 + 5 files changed, 44 insertions(+), 24 deletions(-) diff --git a/cmd/cy/rpc.go b/cmd/cy/rpc.go index 7d5f177a..0c305fcd 100644 --- a/cmd/cy/rpc.go +++ b/cmd/cy/rpc.go @@ -14,12 +14,13 @@ type RPCExecArgs struct { Node int Code []byte Dir string + JSON bool } type RPCExecResponse struct { } -// RPC executes an RPC call on the server. +// RPC executes an RPC call on the server over the given Connection. func RPC[S any, T any]( conn Connection, name string, @@ -87,48 +88,64 @@ func RPC[S any, T any]( return result, nil } -func (s *Server) HandleRPC(conn Connection, msg *P.RPCRequestMessage) { +// callRPC executes an RPC call and returns the result. +func (s *Server) callRPC( + conn Connection, + request *P.RPCRequestMessage, +) (interface{}, error) { handle := new(codec.MsgpackHandle) - var responseBytes []byte - var err error - - switch msg.Name { + switch request.Name { case "exec": var args RPCExecArgs - if err = codec.NewDecoderBytes( - msg.Args, + if err := codec.NewDecoderBytes( + request.Args, handle, ).Decode(&args); err != nil { - break + return nil, err } - _, err = s.cy.ExecuteOnBehalf( + _, err := s.cy.ExecuteOnBehalf( conn.Ctx(), tree.NodeID(args.Node), args.Code, args.Source, ) if err != nil { - break + return nil, err } - enc := codec.NewEncoderBytes(&responseBytes, handle) - if err = enc.Encode(nil); err != nil { - return - } - default: - err = fmt.Errorf("unknown RPC: %s", msg.Name) + return nil, nil + } + + return nil, fmt.Errorf("unknown RPC: %s", request.Name) +} + +// HandleRPC handles an RPC request, calling the appropriate function and +// encoding the response. +func (s *Server) HandleRPC(conn Connection, request *P.RPCRequestMessage) { + response, err := s.callRPC(conn, request) + if err != nil { + return + } + + var responseBytes []byte + if response != nil { + enc := codec.NewEncoderBytes( + &responseBytes, + new(codec.MsgpackHandle), + ) + err = enc.Encode(response) } - response := P.RPCResponseMessage{ + msg := P.RPCResponseMessage{ Errored: err != nil, Response: responseBytes, } if err != nil { - response.Error = err.Error() + msg.Error = err.Error() } - conn.Send(response) + conn.Send(msg) } diff --git a/pkg/cy/client.go b/pkg/cy/client.go index eb1b0d22..b55227cc 100644 --- a/pkg/cy/client.go +++ b/pkg/cy/client.go @@ -529,10 +529,11 @@ func (c *Client) Detach() { // execute runs some Janet code on behalf of the client. func (c *Client) execute(code string) error { - return c.cy.ExecuteCall(c.Ctx(), c, janet.Call{ + _, err := c.cy.ExecuteCall(c.Ctx(), c, janet.Call{ Code: []byte(code), Options: janet.DEFAULT_CALL_OPTIONS, }) + return err } func (c *Client) Toast(toast toasts.Toast) { diff --git a/pkg/cy/janet.go b/pkg/cy/janet.go index 8e545116..22226742 100644 --- a/pkg/cy/janet.go +++ b/pkg/cy/janet.go @@ -76,7 +76,7 @@ func (c *Cy) initJanet(ctx context.Context) (*janet.VM, error) { return nil, err } - err = vm.ExecuteCall(ctx, nil, janet.Call{ + _, err = vm.ExecuteCall(ctx, nil, janet.Call{ Code: data, SourcePath: path, Options: janet.DEFAULT_CALL_OPTIONS, diff --git a/pkg/cy/module.go b/pkg/cy/module.go index f3b34367..80488e79 100644 --- a/pkg/cy/module.go +++ b/pkg/cy/module.go @@ -95,8 +95,8 @@ func (c *Cy) ExecuteOnBehalf( node tree.NodeID, code []byte, path string, -) (result *janet.Value, err error) { - return c.ExecuteCallResult( +) (*janet.Value, error) { + _, err := c.ExecuteCall( ctx, // todo: infer nil, @@ -105,6 +105,7 @@ func (c *Cy) ExecuteOnBehalf( SourcePath: path, }, ) + return nil, err } func (c *Cy) Log(level zerolog.Level, message string) { diff --git a/pkg/io/protocol/messages.go b/pkg/io/protocol/messages.go index 35b72b81..6c796bbe 100644 --- a/pkg/io/protocol/messages.go +++ b/pkg/io/protocol/messages.go @@ -75,6 +75,7 @@ func (i ErrorMessage) Type() MessageType { return MessageTypeError } type RPCRequestMessage struct { Name string + JSON bool Args []byte } From bcc8235dc72dc60596553db75d8d2094eb4cf1b7 Mon Sep 17 00:00:00 2001 From: Caleb Foust Date: Mon, 19 Aug 2024 08:41:52 +0800 Subject: [PATCH 09/24] feat: JSON encode on *Value --- pkg/janet/exec.go | 6 +++--- pkg/janet/go-boot.janet | 10 ++++++++++ pkg/janet/module.go | 10 ++++++---- pkg/janet/translate.go | 24 ++++++++++++++++++++++++ pkg/janet/value.go | 37 ++++++++++++++++++++++--------------- pkg/janet/vm_test.go | 35 ++++++++++++++++++++--------------- 6 files changed, 85 insertions(+), 37 deletions(-) diff --git a/pkg/janet/exec.go b/pkg/janet/exec.go index 2f390253..ff10c072 100644 --- a/pkg/janet/exec.go +++ b/pkg/janet/exec.go @@ -140,10 +140,10 @@ func (v *VM) runCode(params Params, call Call) { v.runFiber(subParams, fiber, nil) select { - case <-params.Context.Done(): - params.Error(params.Context.Err()) + case <-subParams.Context.Done(): + params.Error(subParams.Context.Err()) return - case result := <-params.Result: + case result := <-subParams.Result: if result.Error != nil { params.Error(result.Error) return diff --git a/pkg/janet/go-boot.janet b/pkg/janet/go-boot.janet index 3e86b730..de88058f 100644 --- a/pkg/janet/go-boot.janet +++ b/pkg/janet/go-boot.janet @@ -100,3 +100,13 @@ (case status :value result :error (error (go/stacktrace (fiber/current) result 3)))) + +(defn + go/-/json/encode + [x & rest] + (json/encode x ;rest)) + +(defn + go/-/string/format + [format value] + (string/format format value)) diff --git a/pkg/janet/module.go b/pkg/janet/module.go index dcc5ebae..cc882364 100644 --- a/pkg/janet/module.go +++ b/pkg/janet/module.go @@ -60,6 +60,10 @@ func (v *VM) getFunction(env *C.JanetTable, name string) *Function { &fun, ) + if C.janet_checktype(fun, C.JANET_FUNCTION) != 1 { + panic("function not found: " + name) + } + return &Function{ Value: v.value(fun), function: C.janet_unwrap_function(fun), @@ -85,8 +89,8 @@ func (v *VM) poll(ctx context.Context, ready chan bool) { C.janet_gcroot(evaluate) v.evaluate = evaluate - v.jsonEncode = v.getFunction(env, "json/encode") - v.format = v.getFunction(env, "string/format") + v.jsonEncode = v.getFunction(env, "go/-/json/encode") + v.format = v.getFunction(env, "go/-/string/format") ready <- true @@ -143,8 +147,6 @@ func (v *VM) poll(ctx context.Context, ready chan bool) { } req.result <- v.value(value) - case stringRequest: - req.result <- prettyPrint(req.value) } } } diff --git a/pkg/janet/translate.go b/pkg/janet/translate.go index a5e911d4..6730031b 100644 --- a/pkg/janet/translate.go +++ b/pkg/janet/translate.go @@ -243,6 +243,16 @@ func (v *VM) marshal(item interface{}) (result C.Janet, err error) { } result = C.janet_wrap_struct(C.janet_struct_end(struct_)) case reflect.Array, reflect.Slice: + if type_.Kind() == reflect.Slice && type_.Elem().Kind() == reflect.Uint8 { + slice := value.Bytes() + buffer := C.janet_buffer(C.int(len(slice))) + for i := 0; i < len(slice); i++ { + C.janet_buffer_push_u8(buffer, C.uint8_t(slice[i])) + } + result = C.janet_wrap_buffer(buffer) + return + } + numElements := 0 if type_.Kind() == reflect.Array { numElements = type_.Len() @@ -509,6 +519,20 @@ func (v *VM) unmarshal(source C.Janet, dest interface{}) error { } } case reflect.Slice: + isBuffer := assertType(source, C.JANET_BUFFER) == nil + if isBuffer && type_.Elem().Kind() == reflect.Uint8 { + buffer := C.janet_unwrap_buffer(source) + length := int(buffer.count) + slice := make([]byte, length) + for i := 0; i < length; i++ { + slice[i] = *(*byte)(unsafe.Pointer( + uintptr(unsafe.Pointer(buffer.data)) + uintptr(i), + )) + } + value.Set(reflect.ValueOf(slice)) + return nil + } + if err := assertType( source, C.JANET_ARRAY, diff --git a/pkg/janet/value.go b/pkg/janet/value.go index dfd72e2e..4dd9833c 100644 --- a/pkg/janet/value.go +++ b/pkg/janet/value.go @@ -63,20 +63,15 @@ func (v *Value) Free() { } } -type stringRequest struct { - value C.Janet - result chan string -} - -func (v *Value) JSON(ctx context.Context) ([]byte, error) { +func (v *Value) JSON() ([]byte, error) { if v.IsFree() { return nil, ERROR_FREED } out, err := v.vm.jsonEncode.CallResult( - ctx, + context.Background(), nil, - v.janet, + v, ) if err != nil { return nil, err @@ -93,16 +88,28 @@ func (v *Value) JSON(ctx context.Context) ([]byte, error) { } func (v *Value) String() string { - if v.isSafe { - return prettyPrint(v.janet) + if v.IsFree() { + return "" + } + + out, err := v.vm.format.CallResult( + context.Background(), + nil, + "%n", + v, + ) + if err != nil { + return "" } - result := make(chan string) - v.vm.requests <- stringRequest{ - value: v.janet, - result: result, + var result string + err = out.Unmarshal(&result) + if err != nil { + out.Free() + return "" } - return <-result + + return result } type unmarshalRequest struct { diff --git a/pkg/janet/vm_test.go b/pkg/janet/vm_test.go index 27590457..51cc0191 100644 --- a/pkg/janet/vm_test.go +++ b/pkg/janet/vm_test.go @@ -61,13 +61,14 @@ func (c *CustomMarshal) UnmarshalJanet(value *Value) error { } type TestValue struct { - One int - Two bool - Three string - Four *int - Five *int - Ints [6]int - Bools []bool + One int + Two bool + Three string + Four *int + Five *int + Ints [6]int + Bools []bool + Buffer []byte } func TestVM(t *testing.T) { @@ -82,6 +83,7 @@ func TestVM(t *testing.T) { ok = true }) require.NoError(t, err) + t.Logf("callback") t.Run("callback", func(t *testing.T) { ok = false @@ -116,7 +118,7 @@ func TestVM(t *testing.T) { require.NoError(t, err) call := CallString(`(test-context)`) - err = vm.ExecuteCall(ctx, 1, call) + _, err = vm.ExecuteCall(ctx, 1, call) require.NoError(t, err) require.Equal(t, 1, state) }) @@ -135,7 +137,7 @@ func TestVM(t *testing.T) { require.NoError(t, err) call := CallString(`(test-context-ctx)`) - err = vm.ExecuteCall(ctx, 1, call) + _, err = vm.ExecuteCall(ctx, 1, call) require.NoError(t, err) require.Equal(t, 1, state) }) @@ -301,6 +303,7 @@ func TestVM(t *testing.T) { go func() { bools := make([]bool, 2) bools[0] = true + buffer := []byte{1, 2, 3} five := 2 structValue := TestValue{ One: 2, @@ -311,7 +314,8 @@ func TestVM(t *testing.T) { 2, 3, }, - Bools: bools, + Bools: bools, + Buffer: buffer, } cmp(t, vm, structValue) @@ -365,18 +369,19 @@ func TestVM(t *testing.T) { err = vm.Execute(ctx, `(json/encode [1 2 3])`) require.NoError(t, err) - out, err := vm.ExecuteCallResult( + out, err := vm.ExecuteCall( ctx, nil, - CallString(`(yield [1 2 3])`), + CallString(`(yield {:a 1 :b 2})`), ) require.NoError(t, err) require.NotNil(t, out) + require.NotNil(t, out.Yield) - json, err := out.JSON(ctx) + json, err := out.Yield.JSON() require.NoError(t, err) - - t.Logf("json: %s", json) + require.Equal(t, `{"a":1,"b":2}`, string(json)) + require.Equal(t, `{:a 1 :b 2}`, out.Yield.String()) }) } From fe146f750363a20fbd729690a067d1e42f418ef9 Mon Sep 17 00:00:00 2001 From: Caleb Foust Date: Mon, 19 Aug 2024 09:33:52 +0800 Subject: [PATCH 10/24] feat: working RPC! --- cmd/cy/exec.go | 6 +++++- cmd/cy/main.go | 14 +++++++------- cmd/cy/rpc.go | 43 +++++++++++++++++++++++++++++++++++++------ cmd/cy/server_test.go | 7 ++++++- 4 files changed, 55 insertions(+), 15 deletions(-) diff --git a/cmd/cy/exec.go b/cmd/cy/exec.go index 3f3dc482..9f812717 100644 --- a/cmd/cy/exec.go +++ b/cmd/cy/exec.go @@ -74,7 +74,7 @@ func execCommand() error { return err } - _, err = RPC[RPCExecArgs, RPCExecResponse]( + response, err := RPC[RPCExecArgs, RPCExecResponse]( conn, "exec", RPCExecArgs{ Source: source, Code: code, @@ -82,6 +82,10 @@ func execCommand() error { Dir: cwd, }, ) + if err != nil || len(response.Data) == 0 { + return err + } + _, err = os.Stdout.Write(response.Data) return err } diff --git a/cmd/cy/main.go b/cmd/cy/main.go index d243baba..9a640628 100644 --- a/cmd/cy/main.go +++ b/cmd/cy/main.go @@ -3,13 +3,11 @@ package main import ( "fmt" "os" - "time" "github.com/cfoust/cy/pkg/cy" "github.com/cfoust/cy/pkg/version" "github.com/alecthomas/kong" - "github.com/rs/zerolog" "github.com/rs/zerolog/log" ) @@ -29,6 +27,11 @@ var CLI struct { } `cmd:"" default:"1" help:"Connect to the cy server, starting one if necessary."` } +func writeError(err error) { + fmt.Fprintf(os.Stderr, "%s\n", err) + os.Exit(1) +} + func main() { ctx := kong.Parse(&CLI, kong.Name("cy"), @@ -39,9 +42,6 @@ func main() { Summary: true, })) - consoleWriter := zerolog.ConsoleWriter{Out: os.Stdout, TimeFormat: time.RFC3339} - log.Logger = log.Output(consoleWriter) - if CLI.Version { fmt.Printf( "cy %s (commit %s)\n", @@ -65,12 +65,12 @@ func main() { case "exec ": err := execCommand() if err != nil { - log.Fatal().Err(err).Msg("failed to execute Janet code") + writeError(err) } case "connect": err := connectCommand() if err != nil { - log.Fatal().Err(err).Msg("failed to connect") + writeError(err) } } } diff --git a/cmd/cy/rpc.go b/cmd/cy/rpc.go index 0c305fcd..58df61cd 100644 --- a/cmd/cy/rpc.go +++ b/cmd/cy/rpc.go @@ -4,7 +4,7 @@ import ( "fmt" P "github.com/cfoust/cy/pkg/io/protocol" - "github.com/cfoust/cy/pkg/mux/screen/tree" + "github.com/cfoust/cy/pkg/janet" "github.com/ugorji/go/codec" ) @@ -18,6 +18,7 @@ type RPCExecArgs struct { } type RPCExecResponse struct { + Data []byte } // RPC executes an RPC call on the server over the given Connection. @@ -105,17 +106,35 @@ func (s *Server) callRPC( return nil, err } - _, err := s.cy.ExecuteOnBehalf( + result, err := s.cy.ExecuteCall( conn.Ctx(), - tree.NodeID(args.Node), - args.Code, - args.Source, + // todo: infer + nil, + janet.Call{ + Code: args.Code, + SourcePath: args.Source, + }, ) if err != nil { return nil, err } - return nil, nil + response := RPCExecResponse{} + + if result.Yield == nil { + return response, nil + } + + if args.JSON { + response.Data, err = result.Yield.JSON() + if err != nil { + return nil, err + } + return response, nil + } + + response.Data = []byte(result.Yield.String()) + return response, nil } return nil, fmt.Errorf("unknown RPC: %s", request.Name) @@ -125,7 +144,19 @@ func (s *Server) callRPC( // encoding the response. func (s *Server) HandleRPC(conn Connection, request *P.RPCRequestMessage) { response, err := s.callRPC(conn, request) + + if err == nil && response == nil { + err = fmt.Errorf( + "no response from RPC call %s", + request.Name, + ) + } + if err != nil { + conn.Send(P.RPCResponseMessage{ + Errored: err != nil, + Error: err.Error(), + }) return } diff --git a/cmd/cy/server_test.go b/cmd/cy/server_test.go index b9907c6f..8356f884 100644 --- a/cmd/cy/server_test.go +++ b/cmd/cy/server_test.go @@ -84,7 +84,12 @@ func setupServer(t *testing.T) *TestServer { } go func() { - ws.Serve[P.Message](testServer.Ctx(), socketPath, P.Protocol, server) + ws.Serve[P.Message]( + testServer.Ctx(), + socketPath, + P.Protocol, + server, + ) os.RemoveAll(dir) }() From 136b5d7bab10d500c99124228820000efd6ca9ba Mon Sep 17 00:00:00 2001 From: Caleb Foust Date: Mon, 19 Aug 2024 09:49:48 +0800 Subject: [PATCH 11/24] feat: inference of client --- cmd/cy/exec.go | 1 + cmd/cy/main.go | 1 + cmd/cy/rpc.go | 11 +++++++++-- pkg/cy/api/key_test.janet | 4 ++++ pkg/cy/api_test.go | 2 +- pkg/cy/module.go | 4 ++-- 6 files changed, 18 insertions(+), 5 deletions(-) diff --git a/cmd/cy/exec.go b/cmd/cy/exec.go index 9f812717..63771aeb 100644 --- a/cmd/cy/exec.go +++ b/cmd/cy/exec.go @@ -80,6 +80,7 @@ func execCommand() error { Code: code, Node: id, Dir: cwd, + JSON: CLI.Exec.JSON, }, ) if err != nil || len(response.Data) == 0 { diff --git a/cmd/cy/main.go b/cmd/cy/main.go index 9a640628..bcab240e 100644 --- a/cmd/cy/main.go +++ b/cmd/cy/main.go @@ -18,6 +18,7 @@ var CLI struct { Exec struct { Command string `help:"Provide Janet code as a string argument." name:"command" short:"c" optional:"" default:""` + JSON bool `name:"json" optional:"" short:"j" help:"Write any yielded value as JSON."` File string `arg:"" optional:"" help:"Provide a file containing Janet code." type:"existingfile"` } `cmd:"" help:"Execute Janet code on the cy server."` diff --git a/cmd/cy/rpc.go b/cmd/cy/rpc.go index 58df61cd..1082101c 100644 --- a/cmd/cy/rpc.go +++ b/cmd/cy/rpc.go @@ -5,6 +5,7 @@ import ( P "github.com/cfoust/cy/pkg/io/protocol" "github.com/cfoust/cy/pkg/janet" + "github.com/cfoust/cy/pkg/mux/screen/tree" "github.com/ugorji/go/codec" ) @@ -106,10 +107,16 @@ func (s *Server) callRPC( return nil, err } + var context interface{} = nil + if client, found := s.cy.InferClient( + tree.NodeID(args.Node), + ); found { + context = client + } + result, err := s.cy.ExecuteCall( conn.Ctx(), - // todo: infer - nil, + context, janet.Call{ Code: args.Code, SourcePath: args.Source, diff --git a/pkg/cy/api/key_test.janet b/pkg/cy/api/key_test.janet index ad6c7220..b6a3952c 100644 --- a/pkg/cy/api/key_test.janet +++ b/pkg/cy/api/key_test.janet @@ -4,3 +4,7 @@ (key/remap :root ["ctrl+a"] ["`"]) (def after (key/get :root)) (assert (deep= (length before) (length after)))) + +(test "(key/current)" + # Should not error + (key/current)) diff --git a/pkg/cy/api_test.go b/pkg/cy/api_test.go index 0a50be77..6fbcee8d 100644 --- a/pkg/cy/api_test.go +++ b/pkg/cy/api_test.go @@ -55,7 +55,7 @@ func runTestFile(t *testing.T, file string) (failures []testFailure) { }) }) - err = server.ExecuteCall(server.Ctx(), nil, janet.Call{ + _, err = server.ExecuteCall(server.Ctx(), nil, janet.Call{ Code: API_TEST_FILE, SourcePath: "api_test.janet", Options: janet.DEFAULT_CALL_OPTIONS, diff --git a/pkg/cy/module.go b/pkg/cy/module.go index 80488e79..17daeb99 100644 --- a/pkg/cy/module.go +++ b/pkg/cy/module.go @@ -209,7 +209,7 @@ func (c *Cy) getClient(id ClientID) (client *Client, found bool) { return } -func (c *Cy) inferClient(node tree.NodeID) (client *Client, found bool) { +func (c *Cy) InferClient(node tree.NodeID) (client *Client, found bool) { c.RLock() write, haveWrite := c.lastWrite[node] visit, haveVisit := c.lastVisit[node] @@ -237,7 +237,7 @@ func (c *Cy) pollNodeEvents(ctx context.Context, events <-chan events.Msg) { continue } - client, ok := c.inferClient(nodeEvent.Id) + client, ok := c.InferClient(nodeEvent.Id) if !ok { continue } From 1a0c15335319b4b19a3008922b094fa8fa3b07f5 Mon Sep 17 00:00:00 2001 From: Caleb Foust Date: Mon, 19 Aug 2024 17:50:03 +0800 Subject: [PATCH 12/24] feat: format types for cy exec --- cmd/cy/exec.go | 29 ++++++++++++------ cmd/cy/main.go | 2 +- cmd/cy/rpc.go | 44 +++++++++++++++++++++------ cmd/cy/server_test.go | 60 +++++++++++++++++++++++++++++-------- pkg/io/protocol/messages.go | 1 - pkg/janet/go-boot.janet | 8 +++++ pkg/janet/module.go | 3 +- pkg/janet/value.go | 33 +++++++++++++++++++- 8 files changed, 145 insertions(+), 35 deletions(-) diff --git a/cmd/cy/exec.go b/cmd/cy/exec.go index 63771aeb..8b4b0d37 100644 --- a/cmd/cy/exec.go +++ b/cmd/cy/exec.go @@ -33,14 +33,9 @@ func execCommand() error { } var err error - var source, cwd string + var source string var code []byte - cwd, err = os.Getwd() - if err != nil { - return err - } - if CLI.Exec.Command != "" { source = "" code = []byte(CLI.Exec.Command) @@ -74,13 +69,29 @@ func execCommand() error { return err } + format := OutputFormatRaw + switch CLI.Exec.Format { + case "raw": + format = OutputFormatRaw + case "json": + format = OutputFormatJSON + case "janet": + format = OutputFormatJanet + default: + return fmt.Errorf( + "unknown output format: %s", + CLI.Exec.Format, + ) + } + response, err := RPC[RPCExecArgs, RPCExecResponse]( - conn, "exec", RPCExecArgs{ + conn, + RPCExec, + RPCExecArgs{ Source: source, Code: code, Node: id, - Dir: cwd, - JSON: CLI.Exec.JSON, + Format: format, }, ) if err != nil || len(response.Data) == 0 { diff --git a/cmd/cy/main.go b/cmd/cy/main.go index bcab240e..f0ce44a6 100644 --- a/cmd/cy/main.go +++ b/cmd/cy/main.go @@ -18,7 +18,7 @@ var CLI struct { Exec struct { Command string `help:"Provide Janet code as a string argument." name:"command" short:"c" optional:"" default:""` - JSON bool `name:"json" optional:"" short:"j" help:"Write any yielded value as JSON."` + Format string `name:"format" optional:"" enum:"raw,json,janet" short:"f" default:"raw" help:"Set the desired output format."` File string `arg:"" optional:"" help:"Provide a file containing Janet code." type:"existingfile"` } `cmd:"" help:"Execute Janet code on the cy server."` diff --git a/cmd/cy/rpc.go b/cmd/cy/rpc.go index 1082101c..5307f8b7 100644 --- a/cmd/cy/rpc.go +++ b/cmd/cy/rpc.go @@ -10,12 +10,25 @@ import ( "github.com/ugorji/go/codec" ) +type OutputFormat int + +const ( + OutputFormatRaw OutputFormat = iota + OutputFormatJSON + OutputFormatJanet +) + +const ( + RPCExec = "exec" +) + type RPCExecArgs struct { Source string + // The NodeID of a tree node, which will be used to infer which client + // on behalf of whom the code will be run. Node int Code []byte - Dir string - JSON bool + Format OutputFormat } type RPCExecResponse struct { @@ -64,7 +77,7 @@ func RPC[S any, T any]( } conn.Send(P.RPCRequestMessage{ - Name: "exec", + Name: name, Args: payload, }) @@ -98,7 +111,7 @@ func (s *Server) callRPC( handle := new(codec.MsgpackHandle) switch request.Name { - case "exec": + case RPCExec: var args RPCExecArgs if err := codec.NewDecoderBytes( request.Args, @@ -125,23 +138,36 @@ func (s *Server) callRPC( if err != nil { return nil, err } - response := RPCExecResponse{} if result.Yield == nil { return response, nil } - if args.JSON { + defer result.Yield.Free() + + switch args.Format { + case OutputFormatRaw: + response.Data, err = result.Yield.Raw() + if err != nil { + return nil, err + } + return response, nil + case OutputFormatJanet: + response.Data = []byte(result.Yield.String()) + return response, nil + case OutputFormatJSON: response.Data, err = result.Yield.JSON() if err != nil { return nil, err } return response, nil + default: + return nil, fmt.Errorf( + "unknown output format: %d", + args.Format, + ) } - - response.Data = []byte(result.Yield.String()) - return response, nil } return nil, fmt.Errorf("unknown RPC: %s", request.Name) diff --git a/cmd/cy/server_test.go b/cmd/cy/server_test.go index 8356f884..2b271ba5 100644 --- a/cmd/cy/server_test.go +++ b/cmd/cy/server_test.go @@ -145,21 +145,55 @@ func TestBadHandshake(t *testing.T) { require.Error(t, conn.Ctx().Err()) } -func TestRPC(t *testing.T) { +func TestExec(t *testing.T) { server := setupServer(t) defer server.Release() - conn, err := server.Connect() - require.NoError(t, err) - - result, err := RPC[RPCExecArgs, RPCExecResponse]( - conn, - "exec", - RPCExecArgs{ - Source: "", - Code: []byte(`(pp "hello")`), + for _, test := range []struct { + Args RPCExecArgs + Result []byte + }{ + { + Args: RPCExecArgs{ + Code: []byte(`(pp "hello")`), + }, + Result: nil, }, - ) - require.NoError(t, err) - require.NotNil(t, result) + { + Args: RPCExecArgs{ + Code: []byte(`(yield 2)`), + Format: OutputFormatRaw, + }, + Result: []byte(`2`), + }, + { + Args: RPCExecArgs{ + Code: []byte(`(yield {:a 2})`), + Format: OutputFormatJSON, + }, + Result: []byte(`{"a":2}`), + }, + { + Args: RPCExecArgs{ + Code: []byte(`(yield {:a 2})`), + Format: OutputFormatJanet, + }, + Result: []byte(`{:a 2}`), + }, + } { + conn, err := server.Connect() + require.NoError(t, err) + + result, err := RPC[RPCExecArgs, RPCExecResponse]( + conn, + RPCExec, + test.Args, + ) + require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, test.Result, result.Data) + + conn.Close() + } + } diff --git a/pkg/io/protocol/messages.go b/pkg/io/protocol/messages.go index 6c796bbe..35b72b81 100644 --- a/pkg/io/protocol/messages.go +++ b/pkg/io/protocol/messages.go @@ -75,7 +75,6 @@ func (i ErrorMessage) Type() MessageType { return MessageTypeError } type RPCRequestMessage struct { Name string - JSON bool Args []byte } diff --git a/pkg/janet/go-boot.janet b/pkg/janet/go-boot.janet index de88058f..0b2a8fd4 100644 --- a/pkg/janet/go-boot.janet +++ b/pkg/janet/go-boot.janet @@ -110,3 +110,11 @@ go/-/string/format [format value] (string/format format value)) + +(defn + go/-/raw + [value] + (cond + (or (string? value) (buffer? value)) value + (or (nil? value) (boolean? value) (number? value)) (string value) + (error "type cannot be encoded as raw"))) diff --git a/pkg/janet/module.go b/pkg/janet/module.go index cc882364..575a2ca8 100644 --- a/pkg/janet/module.go +++ b/pkg/janet/module.go @@ -28,7 +28,7 @@ type VM struct { callbacks map[string]*Callback evaluate C.Janet - jsonEncode, format *Function + jsonEncode, raw, format *Function requests chan Request @@ -90,6 +90,7 @@ func (v *VM) poll(ctx context.Context, ready chan bool) { v.evaluate = evaluate v.jsonEncode = v.getFunction(env, "go/-/json/encode") + v.raw = v.getFunction(env, "go/-/raw") v.format = v.getFunction(env, "go/-/string/format") ready <- true diff --git a/pkg/janet/value.go b/pkg/janet/value.go index 4dd9833c..850691d1 100644 --- a/pkg/janet/value.go +++ b/pkg/janet/value.go @@ -77,10 +77,41 @@ func (v *Value) JSON() ([]byte, error) { return nil, err } + defer out.Free() + + var result []byte + err = out.Unmarshal(&result) + if err != nil { + return nil, err + } + + return result, nil +} + +func (v *Value) Raw() ([]byte, error) { + if v.IsFree() { + return nil, ERROR_FREED + } + + out, err := v.vm.raw.CallResult( + context.Background(), + nil, + v, + ) + if err != nil { + return nil, err + } + + defer out.Free() + + var str string + if err := out.Unmarshal(&str); err == nil { + return []byte(str), nil + } + var result []byte err = out.Unmarshal(&result) if err != nil { - out.Free() return nil, err } From 923b84b7ff87bc03c133af495ba0a9f1e69d9b0a Mon Sep 17 00:00:00 2001 From: Caleb Foust Date: Tue, 20 Aug 2024 08:26:13 +0800 Subject: [PATCH 13/24] fix: don't disconnect client after RPC Disconnecting the client right away led to a race condition, and it also didn't make sense, so let's not do that. --- cmd/cy/client.go | 14 ++++---- cmd/cy/rpc.go | 19 ++++++++-- cmd/cy/server.go | 86 ++++++++++++++++++++++++--------------------- pkg/io/ws/client.go | 2 +- 4 files changed, 69 insertions(+), 52 deletions(-) diff --git a/cmd/cy/client.go b/cmd/cy/client.go index 0adf7078..12af0930 100644 --- a/cmd/cy/client.go +++ b/cmd/cy/client.go @@ -175,13 +175,13 @@ func connect(socketPath string, shouldStart bool) (Connection, error) { locked := false started := false for { - conn, err := ws.Connect(context.Background(), P.Protocol, socketPath) - if err == nil { - return conn, nil - } - - if !shouldStart { - return nil, err + conn, err := ws.Connect( + context.Background(), + P.Protocol, + socketPath, + ) + if err == nil || !shouldStart { + return conn, err } message := err.Error() diff --git a/cmd/cy/rpc.go b/cmd/cy/rpc.go index 5307f8b7..1255e224 100644 --- a/cmd/cy/rpc.go +++ b/cmd/cy/rpc.go @@ -2,6 +2,7 @@ package main import ( "fmt" + "time" P "github.com/cfoust/cy/pkg/io/protocol" "github.com/cfoust/cy/pkg/janet" @@ -76,14 +77,26 @@ func RPC[S any, T any]( return result, err } - conn.Send(P.RPCRequestMessage{ + err := conn.Send(P.RPCRequestMessage{ Name: name, Args: payload, }) + if err != nil { + return result, err + } + + done := make(chan struct{}, 1) + go func() { + <-conn.Ctx().Done() + time.Sleep(1 * time.Second) + done <- struct{}{} + }() select { - case <-conn.Ctx().Done(): - return result, conn.Ctx().Err() + case <-done: + return result, fmt.Errorf( + "connection closed by server", + ) case err := <-errc: return result, err case msg := <-response: diff --git a/cmd/cy/server.go b/cmd/cy/server.go index f33d562b..2d6ab3a0 100644 --- a/cmd/cy/server.go +++ b/cmd/cy/server.go @@ -6,7 +6,6 @@ import ( "io" "os" "syscall" - "time" "github.com/cfoust/cy/pkg/cy" "github.com/cfoust/cy/pkg/geom" @@ -75,50 +74,27 @@ func (c *Client) Write(data []byte) (n int, err error) { }) } -func (s *Server) HandleWSClient(conn ws.Client[P.Message]) { - events := conn.Subscribe(conn.Ctx()) - - // First we need to wait for the client's handshake to know how to - // handle its terminal - handshakeCtx, cancel := context.WithTimeout(conn.Ctx(), 1*time.Second) - defer cancel() - - wsClient := &Client{conn: conn} - var client *cy.Client - var err error - - select { - case <-conn.Ctx().Done(): - return - case <-handshakeCtx.Done(): - wsClient.closeError(fmt.Errorf("no handshake received")) - return - case msg := <-events.Recv(): - switch msg := msg.Contents.(type) { - case *P.HandshakeMessage: - client, err = s.cy.NewClient(conn.Ctx(), *msg) - case *P.RPCRequestMessage: - s.HandleRPC(conn, msg) - return - default: - err = fmt.Errorf("must send handshake first") - } - - if err != nil { - wsClient.closeError(err) - return - } +func (s *Server) handleCyClient( + conn Connection, + ws *Client, + handshake *P.HandshakeMessage, +) error { + cy, err := s.cy.NewClient(conn.Ctx(), *handshake) + if err != nil { + return err } - go func() { _, _ = io.Copy(wsClient, client) }() + events := conn.Subscribe(conn.Ctx()) + + go func() { _, _ = io.Copy(ws, cy) }() for { select { case <-conn.Ctx().Done(): - return - case <-client.Ctx().Done(): - wsClient.close() - return + return nil + case <-cy.Ctx().Done(): + ws.close() + return nil case packet := <-events.Recv(): if packet.Error != nil { // TODO(cfoust): 06/08/23 handle gracefully @@ -128,21 +104,49 @@ func (s *Server) HandleWSClient(conn ws.Client[P.Message]) { switch packet.Contents.Type() { case P.MessageTypeSize: msg := packet.Contents.(*P.SizeMessage) - client.Resize(geom.Vec2{ + cy.Resize(geom.Vec2{ R: msg.Rows, C: msg.Columns, }) case P.MessageTypeInput: msg := packet.Contents.(*P.InputMessage) - _, err := client.Write(msg.Data) + _, err := cy.Write(msg.Data) if err != nil { + return err + } + } + } + } +} + +func (s *Server) HandleWSClient(conn Connection) { + events := conn.Subscribe(conn.Ctx()) + + wsClient := &Client{conn: conn} + + for { + select { + case <-conn.Ctx().Done(): + return + case msg := <-events.Recv(): + switch msg := msg.Contents.(type) { + case *P.HandshakeMessage: + if err := s.handleCyClient( + conn, + wsClient, + msg, + ); err != nil { wsClient.closeError(err) return } + return + case *P.RPCRequestMessage: + s.HandleRPC(conn, msg) } } } + } func serve(path string) error { diff --git a/pkg/io/ws/client.go b/pkg/io/ws/client.go index 61773df9..113fae55 100644 --- a/pkg/io/ws/client.go +++ b/pkg/io/ws/client.go @@ -45,7 +45,7 @@ type WSClient[T any] struct { } const ( - WRITE_TIMEOUT = 1 * time.Second + WRITE_TIMEOUT = 5 * time.Second ) func (c *WSClient[T]) Send(data T) error { From 4dedbe82b69b58db5df45570740f42217e840039 Mon Sep 17 00:00:00 2001 From: Caleb Foust Date: Tue, 20 Aug 2024 09:16:06 +0800 Subject: [PATCH 14/24] docs: cy exec --- cmd/example/main.go | 2 +- docs/src/SUMMARY.md | 2 ++ docs/src/cli.md | 70 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 73 insertions(+), 1 deletion(-) create mode 100644 docs/src/cli.md diff --git a/cmd/example/main.go b/cmd/example/main.go index a0696db7..c5c8d570 100644 --- a/cmd/example/main.go +++ b/cmd/example/main.go @@ -38,7 +38,7 @@ func main() { panic(err) } - err = server.ExecuteCall( + _, err = server.ExecuteCall( context.Background(), client, janet.CallBytes(buffer[:n]), diff --git a/docs/src/SUMMARY.md b/docs/src/SUMMARY.md index 29309796..1c47f80f 100644 --- a/docs/src/SUMMARY.md +++ b/docs/src/SUMMARY.md @@ -26,6 +26,8 @@ # User guide +- [CLI](./cli.md) + - [Configuration](./configuration.md) - [Keybindings](./keybindings.md) diff --git a/docs/src/cli.md b/docs/src/cli.md new file mode 100644 index 00000000..ccdb408c --- /dev/null +++ b/docs/src/cli.md @@ -0,0 +1,70 @@ +# CLI + +`cy` supports a range of command line options. For the most authoritative information relevant to your version of `cy`, run `cy --help`. + +All `cy` functionality is divided into subcommands, which are executed using `cy `. If you do not provide a subcommand, `cy` defaults to the `connect` subcommand described below. + +### The `--socket-name` flag + +Just like `tmux`, `cy` supports running multiple servers at once. All subcommands support the `--socket-name` (short: `-L`) flag, which determines which `cy` server to connect to. For example, to start a new `cy` server named `foo`, you would run `cy --socket-name foo`. + +## Subcommands + +### connect + +`cy connect` connects to a `cy` server, starting a new one if there isn't one already running. It is similar to `tmux attach`. + +### exec + +`cy exec` runs Janet code on the `cy` server. This is useful for controlling `cy` programmatically, such as from a shell script or other program. + +If you run `cy exec` in a terminal session inside of `cy`, it will infer the client on behalf of whom the Janet code should be run. This means that API functions that take over the client's `cy` session, like {{api input/find}}, will work as expected. + +Some examples: + +```bash +# Create a new cy shell in the current directory and attach the client to it +cy exec -c "(shell/attach \"$(pwd)\")" + +# Set a parameter on the client +cy exec -c "(param/set :client :default-frame 'big-hex')" +``` + +#### Reading data from `cy` + +Janet code run using `cy exec` can also return values using `(yield)`, which will be printed to standard output in your desired format. In addition to letting you use `cy`'s `input/*` API functions for getting user input in arbitrary shell scripts, you can also read any state you want, such as parameters, from the `cy` server. + +```bash +cy exec -c "(yield (param/get :default-frame))" +# Output: big-hex +``` + +`cy exec` supports the `--format` flag, which determines the output format of `(yield)`ed Janet values. Valid values for `--format` are `raw` (default), `json`, and `janet`. + +##### `raw` + +`raw` is designed for easy interoperation with other programs. Primitive types such as strings, numbers, and booleans are printed as-is, without any additional formatting. For example, a string value `"hello"` will be printed as `hello`. Non-primitive types such as structs and tables cannot be printed in `raw` format. + +##### `json` + +`json` is designed for easy interoperation with other programs that can parse JSON, such as `jq`. All Janet values are converted to JSON, and the resulting JSON string is printed. For example, a table value `{:a 1}` will be printed as `{"a":1}`. Any Janet value that cannot be represented in JSON, such as functions, will cause an error. + +For example, the following code gets the current [layout](/layouts.md) and prints it as JSON: + +```bash +$ cy exec -c "(yield (layout/get))" -f json | jq +{ + "rows": 0, + "cols": 80, + "type": "margins", + "node": { + "id": 4, + "attached": true, + "type": "pane" + } +} +``` + +##### `janet` + +The `janet` format prints the `(yield)`ed value as a valid Janet expression. This is useful for debugging and for passing Janet values between `cy` and other Janet programs. However, just like `json`, the `janet` formatter does not support printing complex values like functions. From 5858ef38a3f0ac0cd277f9025531b5ea3acbd5e8 Mon Sep 17 00:00:00 2001 From: Caleb Foust Date: Tue, 20 Aug 2024 09:17:36 +0800 Subject: [PATCH 15/24] chore: formatting --- pkg/cy/constants.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/cy/constants.go b/pkg/cy/constants.go index 52c75a18..e02373ca 100644 --- a/pkg/cy/constants.go +++ b/pkg/cy/constants.go @@ -9,5 +9,5 @@ var ( CONTEXT_REGEX = regexp.MustCompile("^(?P\\w+):(?P\\d+)$") // Regex used for validating socket names, which must be alphanumeric and not // contain spaces. - SOCKET_REGEX = regexp.MustCompile("^(\\w+)$") + SOCKET_REGEX = regexp.MustCompile("^(\\w+)$") ) From ded79465dbc47fa2bbac3dd8e8b7fbb46a3b7985 Mon Sep 17 00:00:00 2001 From: Caleb Foust Date: Tue, 20 Aug 2024 09:20:40 +0800 Subject: [PATCH 16/24] docs: update roadmap --- docs/src/roadmap.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/src/roadmap.md b/docs/src/roadmap.md index 766501bd..b71f9ad1 100644 --- a/docs/src/roadmap.md +++ b/docs/src/roadmap.md @@ -8,7 +8,7 @@ - [x] [`v0.8.0`](https://github.com/cfoust/cy/releases/tag/v0.8.0) **Bars:** Users should be able to configure styled bars that appear above or below each window. These could be used to show the pane's current command, directory, time, et cetera. Ideally users would be able to provide a Janet function that could do anything they wanted. - [ ] **Floating panes\*:** It should be possible to spawn temporary layers that show a single pane that appears to float over all of the rest. - [ ] **Searching through all recorded sessions:** Right now {{api replay/open-file}} is not very useful. There should be a mechanism for searching all recorded `.borg` files for a string. -- [ ] **Command-line API access:** Users should be able to run Janet code with something like `cy -c '(some-code)'` to control `cy` programmatically just like they can control `tmux`. The result of this code could be written to standard output as JSON for easy interoperability. +- [x] [`v0.9.0`](https://github.com/cfoust/cy/releases/tag/v0.9.0) **Command-line API access:** Users should be able to run Janet code with something like `cy -c '(some-code)'` to control `cy` programmatically just like they can control `tmux`. The result of this code could be written to standard output as JSON for easy interoperability. - [ ] **fzf-cy\*:** `cy` literally uses `fzf`'s algorithm and its fuzzy finder should be able to be used as a drop-in replacement for `fzf` just like in [fzf-tmux](https://github.com/junegunn/fzf/blob/master/bin/fzf-tmux). In other words, `cy`'s fuzzy finder should support everything (within reason) that `fzf` does. - [ ] **Using the output of previous commands:** Similar to a Jupyter notebook, users should be able to access the output of previously executed commands from the command line. In essence, you could run `grep` on the output of a command you just ran without rerunning it: `cy -1 | grep 'some string'` where `-1` refers to the most recently executed command. - [ ] **Command history replacement\*:** The eventual goal of `cy` is to be able to replace `ctrl+r` in Bash (and other shells) with a command browser that not only lets you fuzzy-find a command among all of the commands you've ever run, but also see its output. Replaying a `.borg` file to find all the commands run inside it is an expensive operation, so it's likely that this would involve some kind of SQLite caching mechanism. From 5f7a82e1dc3991b41625a4ced940df2f036fec04 Mon Sep 17 00:00:00 2001 From: Caleb Foust Date: Tue, 20 Aug 2024 09:55:20 +0800 Subject: [PATCH 17/24] feat: parsing references --- cmd/cy/main.go | 21 ++++++++++ cmd/cy/output.go | 96 +++++++++++++++++++++++++++++++++++++++++++ cmd/cy/output_test.go | 57 +++++++++++++++++++++++++ 3 files changed, 174 insertions(+) create mode 100644 cmd/cy/output.go create mode 100644 cmd/cy/output_test.go diff --git a/cmd/cy/main.go b/cmd/cy/main.go index f0ce44a6..8dae7add 100644 --- a/cmd/cy/main.go +++ b/cmd/cy/main.go @@ -22,6 +22,10 @@ var CLI struct { File string `arg:"" optional:"" help:"Provide a file containing Janet code." type:"existingfile"` } `cmd:"" help:"Execute Janet code on the cy server."` + Output struct { + Reference string `arg:"" optional:"" help:"A reference to a command."` + } `cmd:"" help:"Recall the output of a previous command."` + Connect struct { CPU string `help:"Save a CPU performance report to the given path." name:"perf-file" optional:"" default:""` Trace string `help:"Save a trace report to the given path." name:"trace-file" optional:"" default:""` @@ -34,6 +38,18 @@ func writeError(err error) { } func main() { + // Shortcut for getting output e.g. cy -1 + if len(os.Args) == 2 { + arg := os.Args[1] + if _, err := parseReference(arg); err == nil { + err := outputCommand(arg) + if err != nil { + writeError(err) + } + return + } + } + ctx := kong.Parse(&CLI, kong.Name("cy"), kong.Description("the time traveling terminal multiplexer"), @@ -68,6 +84,11 @@ func main() { if err != nil { writeError(err) } + case "output ": + err := outputCommand(CLI.Output.Reference) + if err != nil { + writeError(err) + } case "connect": err := connectCommand() if err != nil { diff --git a/cmd/cy/output.go b/cmd/cy/output.go new file mode 100644 index 00000000..9fb99279 --- /dev/null +++ b/cmd/cy/output.go @@ -0,0 +1,96 @@ +package main + +import ( + "fmt" + "os" + "regexp" + "strconv" +) + +var ( + // Full references contain everything necessry to refer to a command + // uniquely, including which cy server it's on. They can be used outside of + // cy. + FULL_REFERENCE = regexp.MustCompile("^(?P\\w+):(?P\\d+):(?P-?\\d+)$") + // The latter two reference types are used within cy; the socket (and the + // node) are derived from the environment. + ABSOLUTE_REFERENCE = regexp.MustCompile("^(?P\\d+):(?P-?\\d+)$") + RELATIVE_REFERENCE = regexp.MustCompile("^(?P-?\\d+)$") +) + +type Reference struct { + Socket string + Node int + Index int +} + +// parseReference interprets a reference string and returns a normalized +// Reference. +func parseReference(value string) (*Reference, error) { + if match := FULL_REFERENCE.FindStringSubmatch(value); match != nil { + node, err := strconv.Atoi(match[FULL_REFERENCE.SubexpIndex("node")]) + if err != nil { + return nil, err + } + + index, err := strconv.Atoi(match[FULL_REFERENCE.SubexpIndex("index")]) + if err != nil { + return nil, err + } + + return &Reference{ + Socket: match[FULL_REFERENCE.SubexpIndex("socket")], + Node: node, + Index: index, + }, nil + } + + // Need context for everything else + socket, id, ok := getContext() + if !ok { + return nil, fmt.Errorf("no cy context available") + } + + if match := ABSOLUTE_REFERENCE.FindStringSubmatch(value); match != nil { + node, err := strconv.Atoi(match[ABSOLUTE_REFERENCE.SubexpIndex("node")]) + if err != nil { + return nil, err + } + + index, err := strconv.Atoi(match[ABSOLUTE_REFERENCE.SubexpIndex("index")]) + if err != nil { + return nil, err + } + + return &Reference{ + Socket: socket, + Node: node, + Index: index, + }, nil + } + + if match := RELATIVE_REFERENCE.FindStringSubmatch(value); match != nil { + index, err := strconv.Atoi(match[RELATIVE_REFERENCE.SubexpIndex("index")]) + if err != nil { + return nil, err + } + + return &Reference{ + Socket: socket, + Node: id, + Index: index, + }, nil + } + + return nil, fmt.Errorf("invalid reference: %s", value) +} + +func outputCommand(reference string) error { + ref, err := parseReference(reference) + if err != nil { + return err + } + + fmt.Fprintf(os.Stdout, "ref %+v\n", ref) + return nil +} diff --git a/cmd/cy/output_test.go b/cmd/cy/output_test.go new file mode 100644 index 00000000..7711f562 --- /dev/null +++ b/cmd/cy/output_test.go @@ -0,0 +1,57 @@ +package main + +import ( + "os" + "testing" + + "github.com/cfoust/cy/pkg/cy" + + "github.com/stretchr/testify/require" +) + +func TestParse(t *testing.T) { + os.Setenv(cy.CONTEXT_ENV, "default:1") + + for index, test := range []struct { + Value string + Expected *Reference + }{ + { + Value: "foobar", + }, + { + Value: "foo:2:3", + Expected: &Reference{ + Socket: "foo", + Node: 2, + Index: 3, + }, + }, + { + Value: "2:3", + Expected: &Reference{ + Socket: "default", + Node: 2, + Index: 3, + }, + }, + { + Value: "-1", + Expected: &Reference{ + Socket: "default", + Node: 1, + Index: -1, + }, + }, + } { + ref, err := parseReference(test.Value) + if test.Expected == nil { + require.Nil(t, ref, "test %d", index) + require.Error(t, err, "test %d", index) + continue + } + + require.NoError(t, err, "test %d", index) + require.Equal(t, test.Expected, ref, "test %d", index) + } +} From 57b0a6fe823ff4101c09c616c2d30728be742269 Mon Sep 17 00:00:00 2001 From: Caleb Foust Date: Tue, 20 Aug 2024 11:12:35 +0800 Subject: [PATCH 18/24] feat: proof of concept output --- cmd/cy/main.go | 1 + cmd/cy/output.go | 36 ++++++++++++++++++++++++++++++-- cmd/cy/rpc.go | 32 ++++++++++++++++++++++++++++- pkg/cy/module.go | 41 +++++++++++++++++++++++++++++++++++++ pkg/replay/player/module.go | 33 +++++++++++++++++++++++++++++ pkg/replay/replayable.go | 4 ++++ 6 files changed, 144 insertions(+), 3 deletions(-) diff --git a/cmd/cy/main.go b/cmd/cy/main.go index 8dae7add..1cf7a5e1 100644 --- a/cmd/cy/main.go +++ b/cmd/cy/main.go @@ -42,6 +42,7 @@ func main() { if len(os.Args) == 2 { arg := os.Args[1] if _, err := parseReference(arg); err == nil { + CLI.Socket = "default" err := outputCommand(arg) if err != nil { writeError(err) diff --git a/cmd/cy/output.go b/cmd/cy/output.go index 9fb99279..ce9be29f 100644 --- a/cmd/cy/output.go +++ b/cmd/cy/output.go @@ -91,6 +91,38 @@ func outputCommand(reference string) error { return err } - fmt.Fprintf(os.Stdout, "ref %+v\n", ref) - return nil + socketName := ref.Socket + if CLI.Socket != "default" { + socketName = CLI.Socket + } + + socketPath, err := getSocketPath(socketName) + if err != nil { + return err + } + + var conn Connection + conn, err = connect(socketPath, false) + if err != nil { + return err + } + + response, err := RPC[RPCOutputArgs, RPCOutputResponse]( + conn, + RPCOutput, + RPCOutputArgs{ + Node: ref.Node, + Index: ref.Index, + }, + ) + if err != nil { + return err + } + + if len(response.Data) == 0 { + return nil + } + + _, err = os.Stdout.Write(response.Data) + return err } diff --git a/cmd/cy/rpc.go b/cmd/cy/rpc.go index 1255e224..0ff53a27 100644 --- a/cmd/cy/rpc.go +++ b/cmd/cy/rpc.go @@ -20,7 +20,8 @@ const ( ) const ( - RPCExec = "exec" + RPCExec = "exec" + RPCOutput = "output" ) type RPCExecArgs struct { @@ -36,6 +37,15 @@ type RPCExecResponse struct { Data []byte } +type RPCOutputArgs struct { + Node int + Index int +} + +type RPCOutputResponse struct { + Data []byte +} + // RPC executes an RPC call on the server over the given Connection. func RPC[S any, T any]( conn Connection, @@ -181,6 +191,26 @@ func (s *Server) callRPC( args.Format, ) } + case RPCOutput: + var args RPCOutputArgs + if err := codec.NewDecoderBytes( + request.Args, + handle, + ).Decode(&args); err != nil { + return nil, err + } + + data, err := s.cy.Output( + tree.NodeID(args.Node), + args.Index, + ) + if err != nil { + return nil, err + } + + return RPCOutputResponse{ + Data: data, + }, nil } return nil, fmt.Errorf("unknown RPC: %s", request.Name) diff --git a/pkg/cy/module.go b/pkg/cy/module.go index 17daeb99..63e645b9 100644 --- a/pkg/cy/module.go +++ b/pkg/cy/module.go @@ -209,6 +209,47 @@ func (c *Cy) getClient(id ClientID) (client *Client, found bool) { return } +func (c *Cy) Output(node tree.NodeID, index int) ([]byte, error) { + treeNode, ok := c.tree.NodeById(node) + if !ok { + return nil, fmt.Errorf("node %d not found", node) + } + + pane, ok := treeNode.(*tree.Pane) + if !ok { + return nil, fmt.Errorf("node %d is not a pane", node) + } + + r, ok := pane.Screen().(*replay.Replayable) + if !ok { + return nil, fmt.Errorf("node %d was not a cmd", node) + } + + commands := r.Commands() + + // Skip pending command + if len(commands) > 0 && commands[len(commands)-1].Pending { + index-- + } + + if index < 0 { + index = len(commands) + index + } + + if index < 0 || index >= len(commands) { + return nil, fmt.Errorf("index %d out of range", index) + } + + command := commands[index] + + data, ok := r.Output(command.Executed+1, command.Completed+1) + if !ok { + return nil, fmt.Errorf("no output") + } + + return data, nil +} + func (c *Cy) InferClient(node tree.NodeID) (client *Client, found bool) { c.RLock() write, haveWrite := c.lastWrite[node] diff --git a/pkg/replay/player/module.go b/pkg/replay/player/module.go index fbd154d0..47cd1361 100644 --- a/pkg/replay/player/module.go +++ b/pkg/replay/player/module.go @@ -4,6 +4,7 @@ import ( "github.com/cfoust/cy/pkg/emu" "github.com/cfoust/cy/pkg/geom" "github.com/cfoust/cy/pkg/geom/tty" + P "github.com/cfoust/cy/pkg/io/protocol" "github.com/cfoust/cy/pkg/replay/detect" "github.com/cfoust/cy/pkg/replay/movement" "github.com/cfoust/cy/pkg/replay/movement/flow" @@ -59,6 +60,38 @@ func (p *Player) Release() { } } +// Output gets all of the output written in the range [start, end). +func (p *Player) Output(start, end int) (data []byte, ok bool) { + p.mu.RLock() + defer p.mu.RUnlock() + + events := p.events + + if start < 0 || start >= len(events) { + return + } + + if end < 0 || end > len(events) { + return + } + + ok = true + + if start >= end { + return + } + + for i := start; i < end; i++ { + event := events[i] + switch e := event.Message.(type) { + case P.OutputMessage: + data = append(data, e.Data...) + } + } + + return +} + func (p *Player) Events() []sessions.Event { p.mu.RLock() defer p.mu.RUnlock() diff --git a/pkg/replay/replayable.go b/pkg/replay/replayable.go index 51c10575..14c84c24 100644 --- a/pkg/replay/replayable.go +++ b/pkg/replay/replayable.go @@ -56,6 +56,10 @@ func (r *Replayable) Commands() []detect.Command { return r.player.Commands() } +func (r *Replayable) Output(start, end int) (data []byte, ok bool) { + return r.player.Output(start, end) +} + func (r *Replayable) Preview( location geom.Vec2, highlights []movement.Highlight, From 391dd7634af0e683238259fed01dd026eb37a38c Mon Sep 17 00:00:00 2001 From: Caleb Foust Date: Wed, 21 Aug 2024 08:09:17 +0800 Subject: [PATCH 19/24] fix: remove leading newline in output --- pkg/cy/module.go | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/pkg/cy/module.go b/pkg/cy/module.go index 63e645b9..d89c7d0f 100644 --- a/pkg/cy/module.go +++ b/pkg/cy/module.go @@ -209,6 +209,8 @@ func (c *Cy) getClient(id ClientID) (client *Client, found bool) { return } +// Output returns everything that was written by a command at the given index +// in the node's scrollback. func (c *Cy) Output(node tree.NodeID, index int) ([]byte, error) { treeNode, ok := c.tree.NodeById(node) if !ok { @@ -227,8 +229,10 @@ func (c *Cy) Output(node tree.NodeID, index int) ([]byte, error) { commands := r.Commands() + original := index + // Skip pending command - if len(commands) > 0 && commands[len(commands)-1].Pending { + if index < 0 && len(commands) > 0 && commands[len(commands)-1].Pending { index-- } @@ -237,19 +241,29 @@ func (c *Cy) Output(node tree.NodeID, index int) ([]byte, error) { } if index < 0 || index >= len(commands) { - return nil, fmt.Errorf("index %d out of range", index) + return nil, fmt.Errorf( + "index %d out of range", + original, + ) } command := commands[index] - data, ok := r.Output(command.Executed+1, command.Completed+1) if !ok { return nil, fmt.Errorf("no output") } + // Skip the newline produced when the user originally executed the + // command + if len(data) > 1 && data[0] == '\r' && data[1] == '\n' { + data = data[2:] + } + return data, nil } +// InferClient returns the client that most recently interacted with the given +// node. This uses the same strategy tmux does. func (c *Cy) InferClient(node tree.NodeID) (client *Client, found bool) { c.RLock() write, haveWrite := c.lastWrite[node] From c8cae0cfb7d4bc499de28303146b45827151c649 Mon Sep 17 00:00:00 2001 From: Caleb Foust Date: Wed, 21 Aug 2024 08:10:35 +0800 Subject: [PATCH 20/24] feat: rename to recall --- cmd/cy/main.go | 8 ++++---- cmd/cy/{output.go => recall.go} | 2 +- cmd/cy/{output_test.go => recall_test.go} | 0 3 files changed, 5 insertions(+), 5 deletions(-) rename cmd/cy/{output.go => recall.go} (98%) rename cmd/cy/{output_test.go => recall_test.go} (100%) diff --git a/cmd/cy/main.go b/cmd/cy/main.go index 1cf7a5e1..c99dc10f 100644 --- a/cmd/cy/main.go +++ b/cmd/cy/main.go @@ -22,7 +22,7 @@ var CLI struct { File string `arg:"" optional:"" help:"Provide a file containing Janet code." type:"existingfile"` } `cmd:"" help:"Execute Janet code on the cy server."` - Output struct { + Recall struct { Reference string `arg:"" optional:"" help:"A reference to a command."` } `cmd:"" help:"Recall the output of a previous command."` @@ -43,7 +43,7 @@ func main() { arg := os.Args[1] if _, err := parseReference(arg); err == nil { CLI.Socket = "default" - err := outputCommand(arg) + err := recallCommand(arg) if err != nil { writeError(err) } @@ -85,8 +85,8 @@ func main() { if err != nil { writeError(err) } - case "output ": - err := outputCommand(CLI.Output.Reference) + case "recall ": + err := recallCommand(CLI.Recall.Reference) if err != nil { writeError(err) } diff --git a/cmd/cy/output.go b/cmd/cy/recall.go similarity index 98% rename from cmd/cy/output.go rename to cmd/cy/recall.go index ce9be29f..04cde58e 100644 --- a/cmd/cy/output.go +++ b/cmd/cy/recall.go @@ -85,7 +85,7 @@ func parseReference(value string) (*Reference, error) { return nil, fmt.Errorf("invalid reference: %s", value) } -func outputCommand(reference string) error { +func recallCommand(reference string) error { ref, err := parseReference(reference) if err != nil { return err diff --git a/cmd/cy/output_test.go b/cmd/cy/recall_test.go similarity index 100% rename from cmd/cy/output_test.go rename to cmd/cy/recall_test.go From 26714b1cfc085a795d5e5601e4516cce91a9294b Mon Sep 17 00:00:00 2001 From: Caleb Foust Date: Wed, 21 Aug 2024 09:13:49 +0800 Subject: [PATCH 21/24] feat: pane/send-keys --- pkg/cy/api/docs-pane.md | 18 ++++++++++++++++++ pkg/cy/api/pane.go | 16 ++++++++++++++++ pkg/cy/api/pane_test.janet | 5 +++++ 3 files changed, 39 insertions(+) diff --git a/pkg/cy/api/docs-pane.md b/pkg/cy/api/docs-pane.md index 8f4ccaa6..042b64b3 100644 --- a/pkg/cy/api/docs-pane.md +++ b/pkg/cy/api/docs-pane.md @@ -21,3 +21,21 @@ Move forward in the pane history. Works in a similar way to vim's ctrl+ictrl+o. + +# doc: SendKeys + +(pane/send-keys pane keys) + +Send keys to the pane referred to by [NodeID](/api.md#nodeid). `keys` is an array of strings. Strings that are not [key specifiers](/preset-keys.md) will be written as-is. + +```janet +# { +(def pane (cmd/new :root)) +# } + +# Send the string "test" to the pane +(pane/send-keys pane @["test"]) + +# Send ctrl+c to the pane +(pane/send-keys pane @["ctrl+c"]) +``` diff --git a/pkg/cy/api/pane.go b/pkg/cy/api/pane.go index fdee9d8c..3fcf8b3e 100644 --- a/pkg/cy/api/pane.go +++ b/pkg/cy/api/pane.go @@ -3,6 +3,7 @@ package api import ( "github.com/cfoust/cy/pkg/janet" "github.com/cfoust/cy/pkg/mux/screen/tree" + "github.com/cfoust/cy/pkg/taro" ) type PaneModule struct { @@ -74,3 +75,18 @@ func (p *PaneModule) Screen(id *janet.Value) ([]string, error) { return lines, nil } + +func (p *PaneModule) SendKeys(id *janet.Value, keys []string) error { + defer id.Free() + + pane, err := resolvePane(p.Tree, id) + if err != nil { + return err + } + + for _, key := range taro.KeysToMsg(keys...) { + pane.Screen().Send(key) + } + + return nil +} diff --git a/pkg/cy/api/pane_test.janet b/pkg/cy/api/pane_test.janet index db14456f..0e1df1da 100644 --- a/pkg/cy/api/pane_test.janet +++ b/pkg/cy/api/pane_test.janet @@ -56,3 +56,8 @@ (action/prev-pane) (assert (= (pane/current) cmd2))) + +(test "(pane/send-keys)" + (def cmd (cmd/new :root)) + (pane/send-keys cmd @["test" + "ctrl+a"])) From 101983fc6cf686982122f666213a045eb9fd478b Mon Sep 17 00:00:00 2001 From: Caleb Foust Date: Wed, 21 Aug 2024 09:26:52 +0800 Subject: [PATCH 22/24] feat: action/recall-command --- pkg/cy/boot/actions.janet | 34 +++++++++++++++++++++++----------- 1 file changed, 23 insertions(+), 11 deletions(-) diff --git a/pkg/cy/boot/actions.janet b/pkg/cy/boot/actions.janet index 33d3fd91..7cfc1b2f 100644 --- a/pkg/cy/boot/actions.janet +++ b/pkg/cy/boot/actions.janet @@ -11,7 +11,7 @@ In a similar way to other modern applications, cy has a command palette (invoked (def func-name (string name)) ~(upscope (defn ,name ,docstring [] ,;body) - (,put ,actions ,func-name [,docstring ,name]))) + (,put actions ,func-name [,docstring ,name]))) (defmacro key/bind-many ````Bind many bindings at once in the same scope. @@ -289,18 +289,20 @@ For example: (var [ok commands] (protect (cmd/commands id))) (if (not ok) (set commands @[])) (default commands @[]) - (map |(tuple [(string/replace-all "\n" "↵" ($ :text)) (tree/path id)] - {:type :scrollback - :focus ((($ :input) 0) :from) - :highlights @[(($ :input) 0)] - :id id} - (result-func $)) commands)) + (map |(let [[index cmd] $] + [[(string/replace-all "\n" "↵" (cmd :text)) (tree/path id)] + {:type :scrollback + :focus (((cmd :input) 0) :from) + :highlights @[((cmd :input) 0)] + :id id} + (result-func index cmd)]) + (pairs commands))) (key/action action/jump-pane-command "Jump to a pane based on a command." (as?-> (group/leaves :root) _ - (mapcat |(get-pane-commands $ (fn [cmd] $)) _) + (mapcat |(get-pane-commands $ (fn [index cmd] $)) _) (input/find _ :prompt "search: pane (command)") (pane/attach _))) @@ -308,7 +310,7 @@ For example: action/jump-command "Jump to the output of a command." (as?-> (group/leaves :root) _ - (mapcat |(get-pane-commands $ (fn [cmd] [$ cmd])) _) + (mapcat |(get-pane-commands $ (fn [index cmd] [$ cmd])) _) (input/find _ :prompt "search: command") (let [[id cmd] _] (pane/attach id) @@ -317,6 +319,18 @@ For example: :main true :location (((cmd :input) 0) :from))))) +(key/action + action/recall-command + "Recall the output of a command to the current shell." + (as?-> (group/leaves :root) _ + (mapcat |(get-pane-commands $ (fn [index &] [$ index])) _) + (input/find _ :prompt "search: command") + (let [[node index] _] + (def ref (if + (= node (pane/current)) (string index) + (string node ":" index))) + (pane/send-keys (pane/current) @[(string "cy " ref)])))) + (key/action action/open-replay "Enter replay mode for the current pane." @@ -331,5 +345,3 @@ For example: action/trace "Save a trace to cy's socket directory." (cy/trace)) - -(merge-module root-env (curenv)) From baaf4d368ba13971df36c401e51502de25dd811b Mon Sep 17 00:00:00 2001 From: Caleb Foust Date: Thu, 22 Aug 2024 05:31:29 +0800 Subject: [PATCH 23/24] docs: recall docs --- docs/src/SUMMARY.md | 2 +- docs/src/cli.md | 42 ++++++++++++++++++- .../{replay-mode => }/command-detection.md | 16 ++++--- 3 files changed, 52 insertions(+), 8 deletions(-) rename docs/src/{replay-mode => }/command-detection.md (85%) diff --git a/docs/src/SUMMARY.md b/docs/src/SUMMARY.md index 1c47f80f..7692e6e3 100644 --- a/docs/src/SUMMARY.md +++ b/docs/src/SUMMARY.md @@ -48,7 +48,7 @@ - [Modes](./replay-mode/modes.md) - - [Command detection](./replay-mode/command-detection.md) +- [Command detection](./command-detection.md) - [User input](./user-input.md) diff --git a/docs/src/cli.md b/docs/src/cli.md index ccdb408c..e0440ebf 100644 --- a/docs/src/cli.md +++ b/docs/src/cli.md @@ -6,7 +6,7 @@ All `cy` functionality is divided into subcommands, which are executed using `cy ### The `--socket-name` flag -Just like `tmux`, `cy` supports running multiple servers at once. All subcommands support the `--socket-name` (short: `-L`) flag, which determines which `cy` server to connect to. For example, to start a new `cy` server named `foo`, you would run `cy --socket-name foo`. +Just like `tmux`, `cy` supports running multiple servers at once. All subcommands support the `--socket-name` (short: `-L`) flag, which determines which `cy` server to connect to. For example, to start a new `cy` server named `foo`, run `cy --socket-name foo`. ## Subcommands @@ -68,3 +68,43 @@ $ cy exec -c "(yield (layout/get))" -f json | jq ##### `janet` The `janet` format prints the `(yield)`ed value as a valid Janet expression. This is useful for debugging and for passing Janet values between `cy` and other Janet programs. However, just like `json`, the `janet` formatter does not support printing complex values like functions. + +### recall + +> For this to work, you must have [enabled command detection](/command-detection.md#enabling-command-detection). + +`cy recall ` prints the output of a command run in `cy` to standard output. In other words, if you run a command and later need to filter its output or pipe it to a file, you can do so without rerunning the command. `` is an identifier for a command run in `cy`. + +#### Relative references + +Inside of a pane in `cy`, running `cy recall -1` will write the output of the most recent command to standard output, e.g.: + +```bash +cy recall -1 | grep 'some string' +cy recall -1 > out.log +``` + +A negative number refers to a command relative to the end of the "list" of all commands run in the current pane so far. So `cy recall -2` refers to the second-latest command. + +`cy recall -1` can also be written as `cy -1`, a la: + +```bash +cy -1 | grep 'some string' +cy -1 > out.log +``` + +Note that running `cy -1` in two different panes will produce different output; `cy` understands where you run a `cy` command and uses that context to direct your query. + +#### Absolute references + +`recall` also supports absolute references in the form `[[server:]node:]index`. + +You do not have to come up with these yourself. Running the {{api action/recall-command}} action will let you choose a command, after which a `cy ` command will be written to your shell. + +`index` can be any integer and it refers to the index of a command inside of a pane starting from `0`. The command referred to by `cy -1` changes on every command run; `cy 0` on the other hand will always refer to the first command run in the pane. + +`server` and `node` are optional. These properties are used by references generated by `cy` to disambiguate a reference to a particular command. + +* `node` is an integer [NodeID](/api.md#nodeid) that specifies the pane from which the command will be read. +* `server` is the name of the socket the `cy` server is running on (the value of the `--socket-name` flag above). + diff --git a/docs/src/replay-mode/command-detection.md b/docs/src/command-detection.md similarity index 85% rename from docs/src/replay-mode/command-detection.md rename to docs/src/command-detection.md index db883c03..3f17bf1d 100644 --- a/docs/src/replay-mode/command-detection.md +++ b/docs/src/command-detection.md @@ -1,6 +1,6 @@ # Command detection -`cy` can detect the commands you run and the output they produce. It does this by ~using magic~ having you put a special string in your shell's prompt that lets it determine where the commands you enter begin and end. +`cy` can detect the commands you run and the output they produce. It does this using a special string that you put in your shell's prompt that lets it determine where the commands you enter begin and end. By enabling this feature, you gain access to a range of functionality for jumping between panes, copying command output, and much more. @@ -40,9 +40,9 @@ Put this somewhere in your `fish_prompt` or just add `\033Pcy\033\\` to any exis printf '\033Pcy\033\\' ``` -## Usage +## Features -## Replay mode +### Replay mode Enabling command detection adds additional features to replay mode both in time mode and copy mode. @@ -64,9 +64,13 @@ You can also quickly select the complete output of a command using {{bind :copy {{story cast replay/command/copy-jump-and-copy}} -## Switching panes +### Switching panes -`cy`'s default configuration also defines two actions that use the newly-detected commands to do interesting things: +`cy`'s default configuration also defines a few actions that use command detection to do interesting things: -* {{api action/jump-pane-command}} ({{bind :root ctrl+a c}}): Choose from a list of all of the commands run since the `cy` server started and jump to the pane where that command was run. * {{api action/jump-command}} ({{bind :root ctrl+a C}}): Choose from a list of all commands and jump to the location of that command in its pane's scrollback history. +* {{api action/jump-pane-command}} ({{bind :root ctrl+a c}}): Choose from a list of all of the commands run since the `cy` server started and jump to the pane where that command was run. + +### Recall + +[`cy recall`](/cli.md#recall) only works if command detection is enabled. From 122a6fca9cc03a843a82861553460f4b75cef0d5 Mon Sep 17 00:00:00 2001 From: Caleb Foust Date: Thu, 22 Aug 2024 05:38:57 +0800 Subject: [PATCH 24/24] docs: final updates --- docs/src/cli.md | 3 ++- docs/src/roadmap.md | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/src/cli.md b/docs/src/cli.md index e0440ebf..c5cf4871 100644 --- a/docs/src/cli.md +++ b/docs/src/cli.md @@ -71,7 +71,7 @@ The `janet` format prints the `(yield)`ed value as a valid Janet expression. Thi ### recall -> For this to work, you must have [enabled command detection](/command-detection.md#enabling-command-detection). +> For this to work, you must have [enabled command detection](/command-detection.md#enabling-command-detection) and `cy` must be installed on your system (ie available in your `$PATH`.) `cy recall ` prints the output of a command run in `cy` to standard output. In other words, if you run a command and later need to filter its output or pipe it to a file, you can do so without rerunning the command. `` is an identifier for a command run in `cy`. @@ -108,3 +108,4 @@ You do not have to come up with these yourself. Running the {{api action/recall- * `node` is an integer [NodeID](/api.md#nodeid) that specifies the pane from which the command will be read. * `server` is the name of the socket the `cy` server is running on (the value of the `--socket-name` flag above). +Both `server` and `node` can be derived by `cy` when `cy recall` is run in a pane in a `cy` server, but if `server` is specified, you can also run `cy recall` _outside of a cy server:_ `cy recall default:0:1`. diff --git a/docs/src/roadmap.md b/docs/src/roadmap.md index b71f9ad1..ecb109e4 100644 --- a/docs/src/roadmap.md +++ b/docs/src/roadmap.md @@ -10,7 +10,7 @@ - [ ] **Searching through all recorded sessions:** Right now {{api replay/open-file}} is not very useful. There should be a mechanism for searching all recorded `.borg` files for a string. - [x] [`v0.9.0`](https://github.com/cfoust/cy/releases/tag/v0.9.0) **Command-line API access:** Users should be able to run Janet code with something like `cy -c '(some-code)'` to control `cy` programmatically just like they can control `tmux`. The result of this code could be written to standard output as JSON for easy interoperability. - [ ] **fzf-cy\*:** `cy` literally uses `fzf`'s algorithm and its fuzzy finder should be able to be used as a drop-in replacement for `fzf` just like in [fzf-tmux](https://github.com/junegunn/fzf/blob/master/bin/fzf-tmux). In other words, `cy`'s fuzzy finder should support everything (within reason) that `fzf` does. -- [ ] **Using the output of previous commands:** Similar to a Jupyter notebook, users should be able to access the output of previously executed commands from the command line. In essence, you could run `grep` on the output of a command you just ran without rerunning it: `cy -1 | grep 'some string'` where `-1` refers to the most recently executed command. +- [x] [`v0.9.0`](https://github.com/cfoust/cy/releases/tag/v0.9.0) **Using the output of previous commands:** Similar to a Jupyter notebook, users should be able to access the output of previously executed commands from the command line. In essence, you could run `grep` on the output of a command you just ran without rerunning it: `cy -1 | grep 'some string'` where `-1` refers to the most recently executed command. - [ ] **Command history replacement\*:** The eventual goal of `cy` is to be able to replace `ctrl+r` in Bash (and other shells) with a command browser that not only lets you fuzzy-find a command among all of the commands you've ever run, but also see its output. Replaying a `.borg` file to find all the commands run inside it is an expensive operation, so it's likely that this would involve some kind of SQLite caching mechanism. - [ ] **Client session replay:** `cy` should record _everything_ that happens on screen and let users open replay mode for the whole session, not just for individual panes. It is up for debate whether this should be saved to disk. - [ ] **Smarter rendering algorithm:** `cy` uses a "damage" algorithm to detect what parts of the screen have changed and only rerender those portions. This is intended to minimize the burden on the client's terminal emulator. Unfortunately, the current approach will break searching the screen in client session recordings, so it needs to be rewritten to preserve the byte order of sequential cells that share the same styling.