diff --git a/cmd/cy/client.go b/cmd/cy/client.go index 013073fa..12af0930 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 @@ -175,9 +175,13 @@ func connect(socketPath string) (Connection, error) { locked := false started := false for { - conn, err := ws.Connect(context.Background(), P.Protocol, socketPath) - if err == nil { - return conn, nil + conn, err := ws.Connect( + context.Background(), + P.Protocol, + socketPath, + ) + if err == nil || !shouldStart { + return conn, err } message := err.Error() diff --git a/cmd/cy/connect.go b/cmd/cy/connect.go new file mode 100644 index 00000000..b9f3fd31 --- /dev/null +++ b/cmd/cy/connect.go @@ -0,0 +1,105 @@ +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 + + 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, true) + 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..8b4b0d37 --- /dev/null +++ b/cmd/cy/exec.go @@ -0,0 +1,103 @@ +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 { + if CLI.Exec.Command == "" && CLI.Exec.File == "" { + return fmt.Errorf("no Janet code provided") + } + + var err error + var source string + var code []byte + + 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 + } + + 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, + RPCExec, + RPCExecArgs{ + Source: source, + Code: code, + Node: id, + Format: format, + }, + ) + 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 8b640a05..c99dc10f 100644 --- a/cmd/cy/main.go +++ b/cmd/cy/main.go @@ -2,28 +2,56 @@ package main import ( "fmt" - "net/http" "os" - "runtime/pprof" - "runtime/trace" + "github.com/cfoust/cy/pkg/cy" "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:""` + 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."` + + Recall 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:""` + } `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() { - kong.Parse(&CLI, + // Shortcut for getting output e.g. cy -1 + if len(os.Args) == 2 { + arg := os.Args[1] + if _, err := parseReference(arg); err == nil { + CLI.Socket = "default" + err := recallCommand(arg) + if err != nil { + writeError(err) + } + return + } + } + + ctx := kong.Parse(&CLI, kong.Name("cy"), kong.Description("the time traveling terminal multiplexer"), kong.UsageOnError(), @@ -45,69 +73,27 @@ 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) - if err != nil { - log.Panic().Err(err).Msg("failed to detect socket path") - } - socketPath = label + if !cy.SOCKET_REGEX.MatchString(CLI.Socket) { + log.Fatal().Msg("invalid socket name, the socket name must be alphanumeric") } - if daemon.WasReborn() { - cntx := new(daemon.Context) - _, err := cntx.Reborn() + switch ctx.Command() { + case "exec": + fallthrough + case "exec ": + err := execCommand() if err != nil { - log.Panic().Err(err).Msg("failed to reincarnate") + writeError(err) } - - 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() - } - - 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() + case "recall ": + err := recallCommand(CLI.Recall.Reference) + if err != nil { + writeError(err) } - - err = serve(socketPath) - if err != nil && err != http.ErrServerClosed { - log.Panic().Err(err).Msg("failed to start cy") + case "connect": + err := connectCommand() + if err != nil { + writeError(err) } - 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") } } diff --git a/cmd/cy/recall.go b/cmd/cy/recall.go new file mode 100644 index 00000000..04cde58e --- /dev/null +++ b/cmd/cy/recall.go @@ -0,0 +1,128 @@ +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 recallCommand(reference string) error { + ref, err := parseReference(reference) + if err != nil { + return err + } + + 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/recall_test.go b/cmd/cy/recall_test.go new file mode 100644 index 00000000..7711f562 --- /dev/null +++ b/cmd/cy/recall_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) + } +} diff --git a/cmd/cy/rpc.go b/cmd/cy/rpc.go new file mode 100644 index 00000000..0ff53a27 --- /dev/null +++ b/cmd/cy/rpc.go @@ -0,0 +1,258 @@ +package main + +import ( + "fmt" + "time" + + 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" +) + +type OutputFormat int + +const ( + OutputFormatRaw OutputFormat = iota + OutputFormatJSON + OutputFormatJanet +) + +const ( + RPCExec = "exec" + RPCOutput = "output" +) + +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 + Format OutputFormat +} + +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, + 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 + } + + 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 <-done: + return result, fmt.Errorf( + "connection closed by server", + ) + 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 +} + +// callRPC executes an RPC call and returns the result. +func (s *Server) callRPC( + conn Connection, + request *P.RPCRequestMessage, +) (interface{}, error) { + handle := new(codec.MsgpackHandle) + + switch request.Name { + case RPCExec: + var args RPCExecArgs + if err := codec.NewDecoderBytes( + request.Args, + handle, + ).Decode(&args); err != nil { + 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(), + context, + janet.Call{ + Code: args.Code, + SourcePath: args.Source, + }, + ) + if err != nil { + return nil, err + } + response := RPCExecResponse{} + + if result.Yield == nil { + return response, nil + } + + 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, + ) + } + 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) +} + +// 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 && 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 + } + + var responseBytes []byte + if response != nil { + enc := codec.NewEncoderBytes( + &responseBytes, + new(codec.MsgpackHandle), + ) + err = enc.Encode(response) + } + + msg := P.RPCResponseMessage{ + Errored: err != nil, + Response: responseBytes, + } + + if err != nil { + msg.Error = err.Error() + } + + conn.Send(msg) +} diff --git a/cmd/cy/server.go b/cmd/cy/server.go index 09b5f9db..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,47 +74,28 @@ func (c *Client) Write(data []byte) (n int, err error) { }) } -func (s *Server) HandleWSClient(conn ws.Client[P.Message]) { - events := conn.Receive() - - // 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 <-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 { - 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 - case packet := <-events: + 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 continue @@ -124,26 +104,55 @@ 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 { 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/server_test.go b/cmd/cy/server_test.go index 47c4ca34..2b271ba5 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) }() @@ -128,12 +133,67 @@ 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 TestExec(t *testing.T) { + server := setupServer(t) + defer server.Release() + + for _, test := range []struct { + Args RPCExecArgs + Result []byte + }{ + { + Args: RPCExecArgs{ + Code: []byte(`(pp "hello")`), + }, + Result: nil, + }, + { + 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/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/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..7692e6e3 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) @@ -46,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 new file mode 100644 index 00000000..c5cf4871 --- /dev/null +++ b/docs/src/cli.md @@ -0,0 +1,111 @@ +# 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`, 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. + +### recall + +> 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`. + +#### 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). + +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/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. diff --git a/docs/src/roadmap.md b/docs/src/roadmap.md index 766501bd..ecb109e4 100644 --- a/docs/src/roadmap.md +++ b/docs/src/roadmap.md @@ -8,9 +8,9 @@ - [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. +- [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. 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/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/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/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/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"])) 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/boot/actions.janet b/pkg/cy/boot/actions.janet index a054fc16..7cfc1b2f 100644 --- a/pkg/cy/boot/actions.janet +++ b/pkg/cy/boot/actions.janet @@ -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." 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/cy/client.go b/pkg/cy/client.go index b432f3e9..b55227cc 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(), @@ -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/constants.go b/pkg/cy/constants.go new file mode 100644 index 00000000..e02373ca --- /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..22226742 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, @@ -75,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 f0d1c8d2..d89c7d0f 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 @@ -89,12 +90,30 @@ 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, +) (*janet.Value, error) { + _, err := c.ExecuteCall( + ctx, + // todo: infer + nil, + janet.Call{ + Code: code, + SourcePath: path, + }, + ) + return nil, err +} + func (c *Cy) Log(level zerolog.Level, message string) { c.log.WithLevel(level).Msgf(message) } 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 +121,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 +137,7 @@ func (c *Cy) reloadConfig() error { } c.Lock() - c.configPath = path + c.options.Config = path c.Unlock() return c.loadConfig() @@ -190,7 +209,62 @@ func (c *Cy) getClient(id ClientID) (client *Client, found bool) { return } -func (c *Cy) inferClient(node tree.NodeID) (client *Client, found bool) { +// 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 { + 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() + + original := index + + // Skip pending command + if index < 0 && 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", + 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] visit, haveVisit := c.lastVisit[node] @@ -218,7 +292,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 } @@ -234,6 +308,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 +319,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 +374,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/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..113fae55 100644 --- a/pkg/io/ws/client.go +++ b/pkg/io/ws/client.go @@ -39,12 +39,13 @@ type RawClient Client[[]byte] type WSClient[T any] struct { util.Lifetime + *util.Publisher[P.Packet[T]] Conn *websocket.Conn protocol Protocol[T] } const ( - WRITE_TIMEOUT = 1 * time.Second + WRITE_TIMEOUT = 5 * time.Second ) func (c *WSClient[T]) Send(data T) error { @@ -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/api.c b/pkg/janet/api.c index 0c528330..f3105b30 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,9 @@ 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); + 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 32f0190a..ff10c072 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, @@ -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,17 +91,18 @@ 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) - 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 @@ -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 <-subParams.Context.Done(): + params.Error(subParams.Context.Err()) return + case result := <-subParams.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,7 +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 { +// 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, +) (*Result, error) { result := make(chan Result) req := callRequest{ Params: Params{ @@ -192,13 +213,15 @@ func (v *VM) ExecuteCall(ctx context.Context, user interface{}, call Call) error Call: call, } v.requests <- req - return req.WaitErr() + return req.WaitResult() } 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 { @@ -208,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 8deda144..0b2a8fd4 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)) @@ -95,8 +95,26 @@ 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 :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)) + +(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/interop.go b/pkg/janet/interop.go index 234344ca..2a0a6251 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,9 +582,10 @@ 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) + _, err = v.ExecuteCall(context.Background(), nil, call) if err != nil { return err } 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..575a2ca8 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, raw, format *Function + requests chan Request env *Table @@ -50,6 +52,24 @@ 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, + ) + + 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), + } +} + // 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 @@ -60,7 +80,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 @@ -69,6 +89,10 @@ func (v *VM) poll(ctx context.Context, ready chan bool) { C.janet_gcroot(evaluate) 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 for { @@ -124,8 +148,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 c35f4309..850691d1 100644 --- a/pkg/janet/value.go +++ b/pkg/janet/value.go @@ -63,22 +63,84 @@ func (v *Value) Free() { } } -type stringRequest struct { - value C.Janet - result chan string +func (v *Value) JSON() ([]byte, error) { + if v.IsFree() { + return nil, ERROR_FREED + } + + out, err := v.vm.jsonEncode.CallResult( + context.Background(), + nil, + v, + ) + if err != nil { + 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 { + return nil, err + } + + return result, nil } func (v *Value) String() string { - if v.isSafe { - return prettyPrint(v.janet) + if v.IsFree() { + return "" } - result := make(chan string) - v.vm.requests <- stringRequest{ - value: v.janet, - result: result, + out, err := v.vm.format.CallResult( + context.Background(), + nil, + "%n", + v, + ) + if err != nil { + return "" } - return <-result + + var result string + err = out.Unmarshal(&result) + if err != nil { + out.Free() + return "" + } + + return result } type unmarshalRequest struct { @@ -174,7 +236,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 72253c6b..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) @@ -360,4 +364,24 @@ 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) + + out, err := vm.ExecuteCall( + ctx, + nil, + CallString(`(yield {:a 1 :b 2})`), + ) + require.NoError(t, err) + require.NotNil(t, out) + require.NotNil(t, out.Yield) + + json, err := out.Yield.JSON() + require.NoError(t, err) + require.Equal(t, `{"a":1,"b":2}`, string(json)) + require.Equal(t, `{:a 1 :b 2}`, out.Yield.String()) + }) + } 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{ 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,