cat / 012
clone

a spreadsheet for the terminal: lotus 1-2-3 looks, google sheets keys and formulas, one go binary

012 - and 012 --pipe: tables in on standard input, out on standard output

Dami committed 2527713
- reads standard input as a sheet; --pipe (with - implied, or a file)
writes what quitting sends to standard output, in the input's format or
--to's, and exits with status 1 when nothing was sent. The UI runs on
/dev/tty (CONIN$ and CONOUT$ on Windows) so the pipeline keeps standard
input and output.
commit
252771320f901eb414eead86c00dc55232198007
parent
0cb910c76f100bac97c7f2bce9c1de028cb9e91f
author
Dami <dami.etoile@gmail.com>
kind
feat risk score 1.8 of 3, Jev's estimate
6 files changed, +383 -6
Mcmd/012/main.go45++++++-
Mcmd/012/main_test.go2+-
Acmd/012/pipe.go120+++++++++++++++++++
Acmd/012/pipe_test.go187++++++++++++++++++++++++++++++
Acmd/012/tty_other.go15++
Acmd/012/tty_windows.go20+++
M cmd/012/main.go +40 -5
@@ -36,8 +36,11 @@ type env struct {
3636 stderr io.Writer3737 readKey func(prompt string) (string, error) // reads a secret from the terminal without echo3838 isTTY bool // stdin is a terminal39- runTUI func(m tea.Model) error39+ runTUI func(m tea.Model, opts ...tea.ProgramOption) error4040 editorIO bool // run the editor attached to the terminal41+ // openTTY opens the terminal for the UI when standard input and42+ // output belong to a pipeline (012 -, --pipe).43+ openTTY func() (io.ReadCloser, io.WriteCloser, error)4144 }4245 4346 func system() env {
@@ -45,13 +48,25 @@ func system() env {
4548 getenv: os.Getenv, keys: keyring.System(),4649 stdin: os.Stdin, stdout: os.Stdout, stderr: os.Stderr,4750 readKey: readPassword, isTTY: isTerminal(os.Stdin), editorIO: true,48- runTUI: func(m tea.Model) error { _, err := tea.NewProgram(m, tea.WithFPS(ui.FrameRate)).Run(); return err },51+ runTUI: func(m tea.Model, opts ...tea.ProgramOption) error {52+ _, err := tea.NewProgram(m, append([]tea.ProgramOption{tea.WithFPS(ui.FrameRate)}, opts...)...).Run()53+ return err54+ },55+ openTTY: func() (io.ReadCloser, io.WriteCloser, error) {56+ in, out, err := openTTY()57+ if err != nil {58+ return nil, nil, err59+ }60+ return in, out, nil61+ },4962 }5063 }5164 5265 func usage() error {5366 return errors.New("usage: 012 " + config.FlagUsage() + " [file]: a " + sheet.FileExt +54- " sheet, or a .csv, .tsv, .xlsx, .sqlite, .parquet or .wk1 file to import\n" +67+ " sheet, or a .csv, .tsv, .json, .nuon, .xlsx, .sqlite, .parquet or .wk1 file to import\n" +68+ " 012 [flags] -: a table from standard input (NUON, JSON, CSV or TSV)\n" +69+ " 012 [flags] --pipe [--to nuon|json|csv|tsv] [file]: on quitting, send the table to standard output\n" +5570 " 012 serve [flags] [dir]: serve sheets in dir over SSH (see docs/terminal/ssh.md)\n" +5671 " 012 config [path|edit|default|themes|set-key|delete-key]\n" +5772 " 012 version")
@@ -69,9 +84,19 @@ func run(args []string, e env) error {
6984 return runServe(args[1:], e)7085 }7186 flags, args, err := config.ParseFlags(args)72- if err != nil || len(args) > 1 {87+ if err != nil {88+ return usage()89+ }90+ pipe, args, err := parsePipe(args)91+ if err != nil {92+ return err93+ }94+ if len(args) > 1 {7395 return usage()7496 }97+ if err := pipe.check(args, e.isTTY); err != nil {98+ return err99+ }75100 cfg, err := loadConfig(e, flags)76101 if err != nil {77102 return err
@@ -100,7 +125,17 @@ func run(args []string, e env) error {
100125 m.EnableJEV(client, jev.NewCache())101126 }102127 m.Configure(settings)103- return e.runTUI(m)128+ setPipe(m, pipe, e.stdin)129+ opts, closeTTY, err := tuiOptions(pipe, e)130+ if err != nil {131+ return err132+ }133+ err = e.runTUI(m, opts...)134+ closeTTY()135+ if err != nil {136+ return err137+ }138+ return sendPiped(m, pipe, e.stdout)104139 }105140 106141 // loadConfig reads the config file with the environment and flags.
M cmd/012/main_test.go +1 -1
A cmd/012/pipe.go +120 -0
@@ -0,0 +1,120 @@
1+package main2+3+import (4+ "errors"5+ "fmt"6+ "io"7+ "strings"8+9+ tea "charm.land/bubbletea/v2"10+11+ "github.com/FelineStateMachine/012/internal/fileio"12+ "github.com/FelineStateMachine/012/internal/ui"13+)14+15+// 012 - and 012 --pipe (see docs/terminal/nushell.md): standard input16+// may be a table to read, and with --pipe standard output is where the17+// result goes when the UI quits. The UI can't use either, so it runs on18+// the terminal itself, opened as /dev/tty (CONIN$ and CONOUT$ on19+// Windows).20+21+// pipeMode is what the command line asked of standard input and output.22+type pipeMode struct {23+ stdin bool // 012 -, or --pipe without a file: read a table from standard input24+ pipe bool // --pipe: write the result to standard output on quitting25+ to fileio.Kind // --to: the format to write, or 0 for the input's26+}27+28+// on reports whether the UI must run on the terminal rather than on29+// standard input and output.30+func (p pipeMode) on() bool { return p.stdin || p.pipe }31+32+// errNotSent is 012 --pipe quitting without sending, which exits with33+// status 1 so the pipeline can tell.34+var errNotSent = errors.New("quit without sending anything to the pipeline")35+36+// parsePipe takes -, --pipe and --to from args, returning the rest.37+func parsePipe(args []string) (pipeMode, []string, error) {38+ var p pipeMode39+ var rest []string40+ for i := 0; i < len(args); i++ {41+ name, value, hasValue := strings.Cut(args[i], "=")42+ switch {43+ case args[i] == "-":44+ p.stdin = true45+ case args[i] == "--pipe":46+ p.pipe = true47+ case name == "--to":48+ if !hasValue {49+ if i+1 == len(args) {50+ return p, nil, errors.New("--to needs a format: nuon, json, csv or tsv")51+ }52+ i++53+ value = args[i]54+ }55+ k, ok := fileio.KindNamed(value)56+ if !ok || !k.IsText() {57+ return p, nil, fmt.Errorf("--to %s: 012 writes nuon, json, csv or tsv", value)58+ }59+ p.to = k60+ default:61+ rest = append(rest, args[i])62+ }63+ }64+ return p, rest, nil65+}66+67+// check reports what doesn't go together: --to without --pipe, a file68+// and -, or nothing to read.69+func (p *pipeMode) check(files []string, stdinTTY bool) error {70+ switch {71+ case p.to != 0 && !p.pipe:72+ return errors.New("--to goes with --pipe")73+ case p.stdin && len(files) > 0:74+ return errors.New("012 - reads standard input; name a file or -, not both")75+ case p.pipe && len(files) == 0:76+ p.stdin = true // --pipe reads standard input unless given a file77+ }78+ if p.stdin && stdinTTY {79+ return errors.New("nothing piped in: 012 - reads a table from standard input, e.g. ls | to nuon | 012 -")80+ }81+ return nil82+}83+84+// tuiOptions opens the terminal for the UI when standard input or output85+// is the pipeline's, returning the options that point Bubble Tea at it86+// and a function that closes it.87+func tuiOptions(p pipeMode, e env) ([]tea.ProgramOption, func(), error) {88+ if !p.on() {89+ return nil, func() {}, nil90+ }91+ in, out, err := e.openTTY()92+ if err != nil {93+ return nil, nil, fmt.Errorf("no terminal to draw on: %w", err)94+ }95+ return []tea.ProgramOption{tea.WithInput(in), tea.WithOutput(out)}, func() { in.Close(); out.Close() }, nil96+}97+98+// setPipe gives the model standard input to read and a pipeline to send99+// to, as p asks.100+func setPipe(m *ui.Model, p pipeMode, stdin io.Reader) {101+ if p.stdin {102+ m.ReadStdin(stdin)103+ }104+ if p.pipe {105+ m.SetPipe(p.to)106+ }107+}108+109+// sendPiped writes what the UI sent on quitting to standard output.110+func sendPiped(m *ui.Model, p pipeMode, stdout io.Writer) error {111+ if !p.pipe {112+ return nil113+ }114+ snap, k, ok := m.Piped()115+ if !ok {116+ return errNotSent117+ }118+ _, err := fileio.Encode(stdout, k, snap)119+ return err120+}
A cmd/012/pipe_test.go +187 -0
@@ -0,0 +1,187 @@
1+package main2+3+import (4+ "errors"5+ "io"6+ "os"7+ "path/filepath"8+ "reflect"9+ "strings"10+ "testing"11+ "time"12+13+ tea "charm.land/bubbletea/v2"14+)15+16+var (17+ ctrlQ = tea.KeyPressMsg{Code: 'q', Mod: tea.ModCtrl}18+ enter = tea.KeyPressMsg{Code: tea.KeyEnter}19+ keyD = tea.KeyPressMsg{Code: 'd', Text: "d"}20+)21+22+// fakeTerminal stands in for the terminal and Bubble Tea: it checks the23+// UI was given a terminal of its own (when tty is set), carries out the24+// model's commands as the program would, feeding their messages back,25+// then presses keys until the model quits.26+type fakeTerminal struct {27+ t *testing.T28+ keys []tea.KeyPressMsg29+ opened bool // openTTY was called30+ given int // the options the program was given31+}32+33+func (f *fakeTerminal) install(e *env) {34+ e.openTTY = func() (io.ReadCloser, io.WriteCloser, error) {35+ f.opened = true36+ r, w := io.Pipe()37+ return r, w, nil38+ }39+ e.runTUI = func(m tea.Model, opts ...tea.ProgramOption) error {40+ f.given = len(opts)41+ if f.drive(m, m.Init()) {42+ f.t.Fatal("quit on starting")43+ }44+ f.drive(m, func() tea.Msg { return tea.WindowSizeMsg{Width: 100, Height: 30} })45+ for _, k := range f.keys {46+ if f.drive(m, func() tea.Msg { return k }) {47+ return nil48+ }49+ }50+ f.t.Fatal("the keys didn't quit")51+ return nil52+ }53+}54+55+// drive runs cmd and what its messages lead to, reporting whether the56+// model quit. A command that takes more than a few seconds (waiting on57+// the terminal) is dropped.58+func (f *fakeTerminal) drive(m tea.Model, cmd tea.Cmd) bool {59+ if cmd == nil {60+ return false61+ }62+ done := make(chan tea.Msg, 1)63+ go func() { done <- cmd() }()64+ var msg tea.Msg65+ select {66+ case msg = <-done:67+ case <-time.After(3 * time.Second):68+ return false69+ }70+ if _, ok := msg.(tea.QuitMsg); ok {71+ return true72+ }73+ // Batches and sequences are slices of commands.74+ if v := reflect.ValueOf(msg); v.Kind() == reflect.Slice && v.Type().Elem() == reflect.TypeFor[tea.Cmd]() {75+ for i := range v.Len() {76+ if c, ok := v.Index(i).Interface().(tea.Cmd); ok && f.drive(m, c) {77+ return true78+ }79+ }80+ return false81+ }82+ if msg == nil {83+ return false84+ }85+ _, next := m.Update(msg)86+ return f.drive(m, next)87+}88+89+const lsNUON = "[[name, size]; [CLAUDE.md, 1646b], [go.mod, 1500b]]\n"90+91+func pipeEnv(t *testing.T, stdin string, keys ...tea.KeyPressMsg) (env, *strings.Builder, *fakeTerminal) {92+ t.Helper()93+ e, _, _ := testEnv(t, nil)94+ out := &strings.Builder{}95+ e.stdin, e.stdout, e.stderr = strings.NewReader(stdin), out, io.Discard96+ f := &fakeTerminal{t: t, keys: keys}97+ f.install(&e)98+ return e, out, f99+}100+101+func TestPipeSendsTheSheet(t *testing.T) {102+ e, out, f := pipeEnv(t, lsNUON, ctrlQ, enter)103+ if err := run([]string{"--pipe"}, e); err != nil {104+ t.Fatal(err)105+ }106+ want := "[[name, size]; [CLAUDE.md, 1646b],\n[go.mod, 1500b]]\n"107+ if out.String() != want {108+ t.Errorf("stdout %q, want %q", out, want)109+ }110+ if !f.opened || f.given != 2 {111+ t.Errorf("the UI wasn't given the terminal: opened %v, %d options", f.opened, f.given)112+ }113+}114+115+func TestPipeTo(t *testing.T) {116+ e, out, _ := pipeEnv(t, lsNUON, ctrlQ, enter)117+ if err := run([]string{"--pipe", "--to", "csv"}, e); err != nil {118+ t.Fatal(err)119+ }120+ if out.String() != "name,size\nCLAUDE.md,1.6 kB\ngo.mod,1.5 kB\n" {121+ t.Errorf("stdout %q", out)122+ }123+ e, out, _ = pipeEnv(t, "a\tb\n1\t2\n", ctrlQ, enter)124+ if err := run([]string{"--pipe", "--to=json"}, e); err != nil {125+ t.Fatal(err)126+ }127+ if out.String() != "[\n{\"a\":1,\"b\":2}\n]\n" {128+ t.Errorf("stdout %q", out)129+ }130+}131+132+func TestPipeQuitWithoutSending(t *testing.T) {133+ e, out, _ := pipeEnv(t, lsNUON, ctrlQ, keyD)134+ if err := run([]string{"--pipe"}, e); !errors.Is(err, errNotSent) {135+ t.Errorf("error %v", err)136+ }137+ if out.Len() != 0 {138+ t.Errorf("stdout %q", out)139+ }140+}141+142+// TestStdinWithoutPipe: 012 - reads standard input, writes nothing to143+// standard output, and asks before quitting with the data unsaved.144+func TestStdinWithoutPipe(t *testing.T) {145+ e, out, f := pipeEnv(t, lsNUON, ctrlQ, keyD)146+ if err := run([]string{"-"}, e); err != nil {147+ t.Fatal(err)148+ }149+ if out.Len() != 0 || !f.opened {150+ t.Errorf("stdout %q, terminal opened %v", out, f.opened)151+ }152+}153+154+func TestPipeAFile(t *testing.T) {155+ dir := t.TempDir()156+ t.Chdir(dir)157+ os.WriteFile(filepath.Join(dir, "data.tsv"), []byte("a\tb\n1\t2\n"), 0o600)158+ e, out, _ := pipeEnv(t, "", ctrlQ, enter)159+ e.isTTY = true160+ if err := run([]string{"--pipe", "data.tsv"}, e); err != nil {161+ t.Fatal(err)162+ }163+ if out.String() != "a\tb\n1\t2\n" {164+ t.Errorf("stdout %q", out)165+ }166+}167+168+func TestPipeArguments(t *testing.T) {169+ for _, tc := range []struct {170+ args []string171+ tty bool172+ want string173+ }{174+ {[]string{"-"}, true, "nothing piped in"},175+ {[]string{"--pipe"}, true, "nothing piped in"},176+ {[]string{"--to", "csv"}, false, "--to goes with --pipe"},177+ {[]string{"--pipe", "--to", "xlsx"}, false, "012 writes nuon, json, csv or tsv"},178+ {[]string{"--pipe", "--to"}, false, "--to needs a format"},179+ {[]string{"-", "a.csv"}, false, "not both"},180+ } {181+ e, _, _ := pipeEnv(t, lsNUON)182+ e.isTTY = tc.tty183+ if err := run(tc.args, e); err == nil || !strings.Contains(err.Error(), tc.want) {184+ t.Errorf("%q: %v, want %q", tc.args, err, tc.want)185+ }186+ }187+}
A cmd/012/tty_other.go +15 -0
A cmd/012/tty_windows.go +20 -0