A Job is a command and the NUON tables it reads; the script binds each from a file named by an environment variable, so no data is spliced into it, and ends in to nuon, read back through the NUON importer. Runner is an interface so tests use a fake; Nu runs nu -c with a timeout, and reports the line of standard error that says what failed.
- commit
- 2231a9a85d79578b5342efaa3a87815b5bc444da
- parent
- 1ef8b3bc0fb99faf9f46ea42881c81071d908257
- author
- Dami <dami.etoile@gmail.com>
- kind
- feat risk score 1.0 of 3, Jev's estimate
| A | internal/ | 256 | ++++++++++++++++++++++++++++++ |
| A | internal/ | 145 | +++++++++++++++++ |
@@ -0,0 +1,256 @@
1+// Package nushell runs a notebook's commands: each command is a nushell2+// pipeline run by `nu` as a separate process, reading other regions'3+// tables as variables ($r1) and the selection as $in, and writing a4+// table back as NUON, which becomes the region's cells. Tables reach nu5+// as NUON in files it opens, never as text spliced into the command.6+// The package knows neither the terminal nor the workbook's regions:7+// the UI hands it a Job and gets back a table.8+package nushell9+10+import (11+ "bufio"12+ "bytes"13+ "context"14+ "errors"15+ "fmt"16+ "io"17+ "os"18+ "os/exec"19+ "slices"20+ "strconv"21+ "strings"22+ "time"23+24+ "github.com/charmbracelet/x/ansi"25+26+ "github.com/FelineStateMachine/012/internal/fileio"27+ "github.com/FelineStateMachine/012/internal/sheet"28+)29+30+// Job is a command to run.31+type Job struct {32+ Command string33+ // Tables are the regions the command reads, by name, as NUON: each34+ // becomes the variable of its name.35+ Tables map[string][]byte36+ // Input is the table the command reads as $in, as NUON, or nil.37+ Input []byte38+ // Config runs nu with the user's config files rather than without.39+ Config bool40+}41+42+// Runner runs a job's script, writing what it prints to stdout. A test43+// substitutes a fake for the real nu.44+type Runner interface {45+ Run(ctx context.Context, job Job, script string, stdout io.Writer) error46+}47+48+// ErrMissing is what running a command says when nu isn't installed.49+var ErrMissing = errors.New("nu isn't installed or isn't on your PATH: see docs/terminal/nushell.md")50+51+// Error is a command that failed: nu's message, and all it wrote to52+// standard error.53+type Error struct {54+ Msg string55+ Stderr string56+}57+58+func (e *Error) Error() string { return e.Msg }59+60+// Script is what nu runs for job: the tables bound to their names, then61+// the command, its output as NUON. The files are named by environment62+// variables (NU012_TABLE_0 and so on) the runner sets, so no data and no63+// path is written into the script.64+func Script(job Job, names []string) string {65+ var b strings.Builder66+ for i, name := range names {67+ fmt.Fprintf(&b, "let %s = (open --raw $env.NU012_TABLE_%d | from nuon)\n", name, i)68+ }69+ if job.Input != nil {70+ b.WriteString("$in | from nuon | ")71+ }72+ b.WriteString("do {\n" + job.Command + "\n} | to nuon\n")73+ return b.String()74+}75+76+// Exec runs job and reads what it prints as a table, keeping at most77+// maxCells cells (0 for the max-cells setting), within timeout.78+func Exec(ctx context.Context, r Runner, job Job, timeout time.Duration, maxCells int) (*sheet.RegionData, error) {79+ if timeout > 0 {80+ var cancel context.CancelFunc81+ ctx, cancel = context.WithTimeoutCause(ctx, timeout, fmt.Errorf("stopped after %s", timeout))82+ defer cancel()83+ }84+ names := sortedNames(job.Tables)85+ pr, pw := io.Pipe()86+ done := make(chan error, 1)87+ go func() {88+ err := r.Run(ctx, job, Script(job, names), pw)89+ pw.CloseWithError(err)90+ done <- err91+ }()92+ data, readErr := readTable(ctx, pr, maxCells)93+ pr.CloseWithError(errors.New("done reading")) // the command stops if it's still writing94+ runErr := <-done95+ switch {96+ case ctx.Err() != nil:97+ return nil, context.Cause(ctx)98+ case runErr != nil:99+ return nil, runErr100+ case readErr != nil:101+ return nil, fmt.Errorf("nu's output isn't NUON: %w", readErr)102+ }103+ return data, nil104+}105+106+// readTable reads NUON as a region's table. A value that isn't a table,107+// record or list (a number, a string, null) is read as a list of one,108+// a table of one column named value.109+func readTable(ctx context.Context, r io.Reader, maxCells int) (*sheet.RegionData, error) {110+ br := bufio.NewReader(r)111+ first, err := firstByte(br)112+ if err != nil {113+ return nil, err114+ }115+ in := io.Reader(br)116+ if first != '[' && first != '{' {117+ in = io.MultiReader(strings.NewReader("["), br, strings.NewReader("]"))118+ }119+ res, err := fileio.ImportReader(ctx, "nu", in, fileio.Options{MaxCells: maxCells})120+ if err != nil {121+ return nil, err122+ }123+ d := sheet.RegionDataOf(res.Sheet)124+ d.Note = strings.Join(res.Notes, "; ")125+ return d, nil126+}127+128+// firstByte is the first byte past spaces, without taking it; 0 when129+// there's nothing else.130+func firstByte(br *bufio.Reader) (byte, error) {131+ for {132+ c, err := br.ReadByte()133+ switch {134+ case errors.Is(err, io.EOF):135+ return 0, nil136+ case err != nil:137+ return 0, err138+ case c == ' ' || c == '\t' || c == '\n' || c == '\r':139+ continue140+ }141+ return c, br.UnreadByte()142+ }143+}144+145+func sortedNames(tables map[string][]byte) []string {146+ names := make([]string, 0, len(tables))147+ for name := range tables {148+ names = append(names, name)149+ }150+ slices.Sort(names)151+ return names152+}153+154+// Commands lists nu's command names, for completing them.155+func Commands(ctx context.Context, r Runner, timeout time.Duration) ([]string, error) {156+ d, err := Exec(ctx, r, Job{Command: "help commands | get name"}, timeout, -1)157+ if err != nil {158+ return nil, err159+ }160+ var out []string161+ for row := 1; row < d.Rows; row++ {162+ if v, _ := d.At(row, 0); v.Kind == sheet.Text {163+ out = append(out, v.Str)164+ }165+ }166+ return out, nil167+}168+169+// Nu is the real runner: `nu -c` in a process of its own.170+type Nu struct {171+ // Path is the program; "" finds nu on the PATH.172+ Path string173+}174+175+// Run implements Runner.176+func (n Nu) Run(ctx context.Context, job Job, script string, stdout io.Writer) error {177+ path := n.Path178+ if path == "" {179+ var err error180+ if path, err = exec.LookPath("nu"); err != nil {181+ return ErrMissing182+ }183+ }184+ dir, err := os.MkdirTemp("", "012-nu-")185+ if err != nil {186+ return err187+ }188+ defer os.RemoveAll(dir)189+ env := append(os.Environ(), "NO_COLOR=1")190+ for i, name := range sortedNames(job.Tables) {191+ file := dir + string(os.PathSeparator) + strconv.Itoa(i) + ".nuon"192+ if err := os.WriteFile(file, job.Tables[name], 0o600); err != nil {193+ return err194+ }195+ env = append(env, "NU012_TABLE_"+strconv.Itoa(i)+"="+file)196+ }197+ args := []string{"-c", script}198+ if !job.Config {199+ args = append([]string{"--no-config-file"}, args...)200+ }201+ if job.Input != nil {202+ args = append([]string{"--stdin"}, args...)203+ }204+ cmd := exec.CommandContext(ctx, path, args...)205+ cmd.Env, cmd.Stdout = env, stdout206+ cmd.WaitDelay = 2 * time.Second207+ if job.Input != nil {208+ cmd.Stdin = bytes.NewReader(job.Input)209+ }210+ var stderr bytes.Buffer211+ cmd.Stderr = &limited{w: &stderr, n: 64 << 10}212+ if err := cmd.Run(); err != nil {213+ if ctx.Err() != nil {214+ return context.Cause(ctx)215+ }216+ text := ansi.Strip(stderr.String())217+ return &Error{Msg: Message(text, err), Stderr: text}218+ }219+ return nil220+}221+222+// Message is the line of nu's standard error that says what went wrong:223+// the one marked ×, or the first, or how the process ended.224+func Message(stderr string, err error) string {225+ first := ""226+ for line := range strings.SplitSeq(stderr, "\n") {227+ line = strings.TrimSpace(line)228+ if rest, ok := strings.CutPrefix(line, "×"); ok {229+ return strings.TrimSpace(rest)230+ }231+ if first == "" && line != "" {232+ first = line233+ }234+ }235+ if first != "" {236+ return first237+ }238+ return err.Error()239+}240+241+// limited writes at most n bytes to w, dropping the rest.242+type limited struct {243+ w io.Writer244+ n int245+}246+247+func (l *limited) Write(p []byte) (int, error) {248+ if l.n > 0 {249+ k := min(len(p), l.n)250+ l.n -= k251+ if _, err := l.w.Write(p[:k]); err != nil {252+ return 0, err253+ }254+ }255+ return len(p), nil256+}@@ -0,0 +1,145 @@
1+package nushell2+3+import (4+ "context"5+ "errors"6+ "io"7+ "os/exec"8+ "strings"9+ "testing"10+ "time"11+12+ "github.com/FelineStateMachine/012/internal/sheet"13+)14+15+// fake prints out, or fails with err, and keeps the script it was given.16+type fake struct {17+ out string18+ err error19+ script string20+ job Job21+}22+23+func (f *fake) Run(ctx context.Context, job Job, script string, stdout io.Writer) error {24+ f.script, f.job = script, job25+ if f.err != nil {26+ return f.err27+ }28+ _, err := io.WriteString(stdout, f.out)29+ return err30+}31+32+func cells(d *sheet.RegionData) [][]string {33+ var out [][]string34+ for r := range d.Rows {35+ var row []string36+ for c := range d.Cols {37+ v, f := d.At(r, c)38+ row = append(row, sheet.FormatText(v, f))39+ }40+ out = append(out, row)41+ }42+ return out43+}44+45+func TestScript(t *testing.T) {46+ got := Script(Job{Command: "$r1 | where size > 1kb", Tables: map[string][]byte{"r1": nil}, Input: []byte("[]")}, []string{"big", "r1"})47+ want := "let big = (open --raw $env.NU012_TABLE_0 | from nuon)\n" +48+ "let r1 = (open --raw $env.NU012_TABLE_1 | from nuon)\n" +49+ "$in | from nuon | do {\n$r1 | where size > 1kb\n} | to nuon\n"50+ if got != want {51+ t.Errorf("script:\n%s\nwant:\n%s", got, want)52+ }53+}54+55+func TestExecReadsTables(t *testing.T) {56+ for _, c := range []struct {57+ out string58+ want string59+ }{60+ {"[[name, size]; [a, 1kb], [b, 2b]]", "name size|a 1.0 kB|b 2 B"},61+ {"{a: 1, b: true}", "a b|1 TRUE"},62+ {"3", "value|3"},63+ {`"hello"`, "value|hello"},64+ {"null", "value"},65+ {"[1, 2]", "value|1|2"},66+ {"", ""},67+ } {68+ d, err := Exec(context.Background(), &fake{out: c.out}, Job{Command: "x"}, time.Second, 0)69+ if err != nil {70+ t.Errorf("%q: %v", c.out, err)71+ continue72+ }73+ var rows []string74+ for _, r := range cells(d) {75+ rows = append(rows, strings.TrimSpace(strings.Join(r, " ")))76+ }77+ if got := strings.Join(rows, "|"); got != c.want {78+ t.Errorf("%q read as %q, want %q", c.out, got, c.want)79+ }80+ }81+}82+83+func TestExecFails(t *testing.T) {84+ _, err := Exec(context.Background(), &fake{err: &Error{Msg: "Cannot find column 'x'"}}, Job{Command: "x"}, time.Second, 0)85+ var e *Error86+ if !errors.As(err, &e) || e.Msg != "Cannot find column 'x'" {87+ t.Errorf("error %v", err)88+ }89+ _, err = Exec(context.Background(), &fake{out: "[[a]; [1"}, Job{Command: "x"}, time.Second, 0)90+ if err == nil || !strings.Contains(err.Error(), "isn't NUON") {91+ t.Errorf("bad output: %v", err)92+ }93+ // Past max-cells, whole rows are kept and the note says so.94+ d, err := Exec(context.Background(), &fake{out: "[[a, b]; [1, 2], [3, 4], [5, 6]]"}, Job{Command: "x"}, time.Second, 4)95+ if err != nil || d.Rows != 2 || d.Note == "" {96+ t.Errorf("capped: %+v %v", d, err)97+ }98+}99+100+func TestMessage(t *testing.T) {101+ stderr := "Error: nu::shell::column_not_found\n\n × Cannot find column 'xyz'\n ╭─[source:1:6]\n"102+ if got := Message(stderr, errors.New("exit status 1")); got != "Cannot find column 'xyz'" {103+ t.Errorf("message %q", got)104+ }105+ if got := Message("", errors.New("exit status 1")); got != "exit status 1" {106+ t.Errorf("message %q", got)107+ }108+}109+110+// The real nu, when it's installed: tables go in as variables and $in,111+// and come back typed.112+func TestNu(t *testing.T) {113+ if _, err := exec.LookPath("nu"); err != nil {114+ t.Skip("nu isn't installed")115+ }116+ job := Job{117+ Command: "$in | append ($r1 | where size > 1kb)",118+ Tables: map[string][]byte{"r1": []byte("[[name, size]; [a, 2kb], [b, 10b]]")},119+ Input: []byte("[[name, size]; [c, 5mb]]"),120+ }121+ d, err := Exec(context.Background(), Nu{}, job, 30*time.Second, 0)122+ if err != nil {123+ t.Fatal(err)124+ }125+ got := cells(d)126+ if len(got) != 3 || got[1][0] != "c" || got[1][1] != "5.0 MB" || got[2][0] != "a" || got[2][1] != "2.0 kB" {127+ t.Errorf("table %v", got)128+ }129+ _, err = Exec(context.Background(), Nu{}, Job{Command: "ls | get nosuchcolumn"}, 30*time.Second, 0)130+ var e *Error131+ if !errors.As(err, &e) || !strings.Contains(e.Msg, "nosuchcolumn") {132+ t.Errorf("failure: %v", err)133+ }134+ _, err = Exec(context.Background(), Nu{}, Job{Command: "sleep 10sec"}, 200*time.Millisecond, 0)135+ if err == nil || !strings.Contains(err.Error(), "stopped after") {136+ t.Errorf("timeout: %v", err)137+ }138+ names, err := Commands(context.Background(), Nu{}, 30*time.Second)139+ if err != nil || len(names) < 100 {140+ t.Errorf("commands: %d %v", len(names), err)141+ }142+ if _, err := Exec(context.Background(), Nu{Path: "/nonexistent/nu"}, Job{Command: "1"}, time.Second, 0); err == nil {143+ t.Error("a missing nu ran")144+ }145+}