cat / 012
clone

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

012 get, set, recalc, export, diff and merge-driver

Dami committed 0e68606
Commands for scripts on a workbook file, without the screen: get writes
a cell or range (text, CSV, TSV, JSON or NUON; --input for what was
typed), set types entries and saves atomically, recalc saves and lists
the cells showing errors, export writes what File > Download writes.
--regions runs notebook commands only when the file is trusted here or
--trust says so, as the screen asks; --jev asks JEV. Usage errors exit 2.

diff exits like diff(1) and works as git's diff command or textconv;
merge-driver merges cell by cell for git, exiting 1 with a report on
conflicts. Tested with real git repositories.
commit
0e68606b8d2a96a1052552eae7cabe60eeab4c6b
parent
4c2e77f6da99956d47a7b843bffd6e7ba3e2f668
author
Dami <dami.etoile@gmail.com>
kind
feat risk score 1.6 of 3, Jev's estimate
8 files changed, +1132 -9
Acmd/012/args.go94+++++++++++++
Acmd/012/diffcmd.go209++++++++++++++++++++++++++++++
Acmd/012/evaluate.go102+++++++++++++++
Acmd/012/export.go117+++++++++++++++++
Acmd/012/git_test.go204+++++++++++++++++++++++++++++
Mcmd/012/main.go34++++-
Acmd/012/scripts.go178++++++++++++++++++++++++++
Acmd/012/scripts_test.go203+++++++++++++++++++++++++++++
A cmd/012/args.go +94 -0
@@ -0,0 +1,94 @@
1+package main2+3+import (4+ "errors"5+ "fmt"6+ "slices"7+ "strings"8+)9+10+// exitError is an error with the exit status it calls for. A nil err11+// exits with code saying nothing, as diff(1) does for "files differ".12+type exitError struct {13+ code int14+ err error15+}16+17+func (e *exitError) Error() string {18+ if e.err == nil {19+ return fmt.Sprintf("exit status %d", e.code)20+ }21+ return e.err.Error()22+}23+24+func (e *exitError) Unwrap() error { return e.err }25+26+// exitCode is the status err asks for, 1 unless it says otherwise, and27+// whether there is anything to print.28+func exitCode(err error) (code int, show bool) {29+ var ee *exitError30+ if errors.As(err, &ee) {31+ return ee.code, ee.err != nil32+ }33+ return 1, true34+}35+36+// usageError is a command used wrongly: status 2, as most tools use for37+// usage, with the command's usage line.38+func usageError(why, usage string) error {39+ switch {40+ case usage == "":41+ usage = why42+ case why != "":43+ usage = why + "\n" + usage44+ }45+ return &exitError{code: 2, err: errors.New(usage)}46+}47+48+// cliArgs are a subcommand's arguments: its flags, by name without the49+// dashes, and the rest in order.50+type cliArgs struct {51+ flags map[string]string52+ pos []string53+}54+55+func (a cliArgs) has(name string) bool { _, ok := a.flags[name]; return ok }56+57+// parseArgs splits args into flags and the rest. Flags start with --58+// and may come anywhere; valued ones take the next argument or =value.59+// Only flags the command knows are flags, so an argument such as -5 or60+// -A1 is a value; after --, everything is.61+func parseArgs(args []string, valued, bools []string) (cliArgs, error) {62+ out := cliArgs{flags: map[string]string{}}63+ for i := 0; i < len(args); i++ {64+ arg := args[i]65+ if arg == "--" {66+ out.pos = append(out.pos, args[i+1:]...)67+ break68+ }69+ if !strings.HasPrefix(arg, "--") || arg == "--" {70+ out.pos = append(out.pos, arg)71+ continue72+ }73+ name, value, hasValue := strings.Cut(arg[2:], "=")74+ switch {75+ case slices.Contains(bools, name):76+ if hasValue {77+ return out, fmt.Errorf("--%s takes no value", name)78+ }79+ out.flags[name] = ""80+ case slices.Contains(valued, name):81+ if !hasValue {82+ if i+1 == len(args) {83+ return out, fmt.Errorf("--%s needs a value", name)84+ }85+ i++86+ value = args[i]87+ }88+ out.flags[name] = value89+ default:90+ return out, fmt.Errorf("unknown flag --%s", name)91+ }92+ }93+ return out, nil94+}
A cmd/012/diffcmd.go +209 -0
@@ -0,0 +1,209 @@
1+package main2+3+import (4+ "bytes"5+ "fmt"6+ "io"7+ "io/fs"8+ "os"9+ "slices"10+ "strings"11+12+ "github.com/FelineStateMachine/012/internal/diff"13+ "github.com/FelineStateMachine/012/internal/headless"14+)15+16+// 012 diff and 012 merge-driver: workbooks compared and merged cell by17+// cell, alone or as git's diff and merge drivers (see docs/files/git.md).18+19+const (20+ diffUsage = "usage: 012 diff a.012 b.012 [--format text|json|nuon] [--color auto|always|never]\n" +21+ " 012 diff --textconv file.012"22+ mergeUsage = "usage: 012 merge-driver base.012 ours.012 theirs.012 [path]"23+)24+25+// runDiff is 012 diff: exit status 0 when the workbooks are the same, 126+// when they differ and 2 on trouble, as diff(1). Called by git as27+// diff.<driver>.command, with git's seven or nine arguments, it writes28+// the changes under a header naming the file and exits 0, as git29+// expects of an external diff; with --textconv it writes one workbook30+// as lines for git's own diff.31+func runDiff(args []string, e env) error {32+ a, err := parseArgs(args, []string{"format", "color"}, []string{"textconv", "help"})33+ switch {34+ case err != nil:35+ return usageError(err.Error(), diffUsage)36+ case a.has("help"):37+ fmt.Fprintln(e.stdout, diffUsage)38+ return nil39+ case a.has("textconv"):40+ if len(a.pos) != 1 {41+ return usageError("", diffUsage)42+ }43+ return textconv(a.pos[0], e.stdout)44+ }45+ format := strings.ToLower(a.flags["format"])46+ if format != "" && !slices.Contains(diff.Formats, format) {47+ return usageError("--format "+format+": 012 diff writes "+strings.Join(diff.Formats, ", "), diffUsage)48+ }49+ color, err := useColor(a.flags["color"], e)50+ if err != nil {51+ return usageError(err.Error(), diffUsage)52+ }53+ var oldPath, newPath, gitPath string54+ switch len(a.pos) {55+ case 2:56+ oldPath, newPath = a.pos[0], a.pos[1]57+ case 7, 9: // path old-file old-hex old-mode new-file new-hex new-mode [new-path xfrm]58+ gitPath, oldPath, newPath = a.pos[0], a.pos[1], a.pos[4]59+ default:60+ return usageError("", diffUsage)61+ }62+ changes, err := compare(oldPath, newPath)63+ if err != nil {64+ return &exitError{code: 2, err: err}65+ }66+ if gitPath != "" {67+ return gitDiff(e.stdout, gitPath, changes, format, color)68+ }69+ if err := diff.WriteChanges(e.stdout, changes, format, color && format != "json" && format != "nuon"); err != nil {70+ return &exitError{code: 2, err: err}71+ }72+ if len(changes) > 0 {73+ return &exitError{code: 1}74+ }75+ return nil76+}77+78+// gitDiff writes the changes to one file for git: a header naming it,79+// as git's own diff has, and exit status 0.80+func gitDiff(w io.Writer, path string, changes []diff.Change, format string, color bool) error {81+ head := "diff --012 a/" + path + " b/" + path82+ if color {83+ head = "\x1b[1m" + head + "\x1b[0m"84+ }85+ if format == "" || format == "text" {86+ fmt.Fprintln(w, head)87+ }88+ if err := diff.WriteChanges(w, changes, format, color); err != nil {89+ return &exitError{code: 2, err: err}90+ }91+ return nil92+}93+94+// useColor decides whether the text output is colored: --color always95+// or never, or by default when standard output is a terminal or git's96+// pager, unless NO_COLOR is set.97+func useColor(flag string, e env) (bool, error) {98+ switch flag {99+ case "always":100+ return true, nil101+ case "never":102+ return false, nil103+ case "", "auto":104+ return e.getenv("NO_COLOR") == "" && (e.stdoutTTY || e.getenv("GIT_PAGER_IN_USE") == "true"), nil105+ }106+ return false, fmt.Errorf("--color %s: auto, always or never", flag)107+}108+109+// compare reads two workbooks and lists what changed.110+func compare(oldPath, newPath string) ([]diff.Change, error) {111+ a, err := readBook(oldPath)112+ if err != nil {113+ return nil, err114+ }115+ b, err := readBook(newPath)116+ if err != nil {117+ return nil, err118+ }119+ return diff.Compare(a, b), nil120+}121+122+// readBook reads a workbook to compare; /dev/null, which git names for123+// a file added or deleted, is a workbook without sheets.124+func readBook(path string) (*diff.Book, error) {125+ data, err := readSide(path)126+ if err != nil {127+ return nil, err128+ }129+ b, err := diff.Read(data)130+ if err != nil {131+ return nil, fmt.Errorf("%s: %w", path, err)132+ }133+ return b, nil134+}135+136+func readSide(path string) ([]byte, error) {137+ if path == os.DevNull {138+ return nil, nil139+ }140+ return os.ReadFile(path)141+}142+143+// textconv is 012 diff --textconv: a workbook as lines of text.144+func textconv(path string, w io.Writer) error {145+ b, err := readBook(path)146+ if err != nil {147+ return &exitError{code: 2, err: err}148+ }149+ return diff.Dump(w, b)150+}151+152+// runMergeDriver is 012 merge-driver, git's merge driver for .012153+// files: it merges theirs into ours cell by cell, from their common154+// base, and writes the result over ours. With conflicts it keeps ours155+// for each, lists them on standard error and exits 1, which git takes156+// as a conflict to resolve; on trouble it leaves ours as it was and157+// exits 2.158+func runMergeDriver(args []string, e env) error {159+ a, err := parseArgs(args, nil, []string{"help"})160+ switch {161+ case err != nil:162+ return usageError(err.Error(), mergeUsage)163+ case a.has("help"):164+ fmt.Fprintln(e.stdout, mergeUsage)165+ return nil166+ case len(a.pos) != 3 && len(a.pos) != 4:167+ return usageError("", mergeUsage)168+ }169+ ours, label := a.pos[1], a.pos[1]170+ if len(a.pos) == 4 {171+ label = a.pos[3]172+ }173+ var sides [3][]byte174+ for i, path := range a.pos[:3] {175+ if sides[i], err = readSide(path); err != nil {176+ return &exitError{code: 2, err: err}177+ }178+ }179+ merged, conflicts, err := diff.Merge(sides[0], sides[1], sides[2])180+ if err != nil {181+ return &exitError{code: 2, err: fmt.Errorf("merging %s: %w; ours is left as it was", label, err)}182+ }183+ if merged != nil {184+ if err := headless.WriteAtomic(ours, fileMode(ours), func(w io.Writer) error {185+ _, err := io.Copy(w, bytes.NewReader(merged))186+ return err187+ }); err != nil {188+ return &exitError{code: 2, err: err}189+ }190+ }191+ if len(conflicts) == 0 {192+ return nil193+ }194+ fmt.Fprintf(e.stderr, "012 merge-driver: %d %s in %s, where ours is kept; a cell's note says what theirs had:\n",195+ len(conflicts), plural(len(conflicts), "conflict", "conflicts"), label)196+ for _, c := range conflicts {197+ fmt.Fprintln(e.stderr, " "+c.String())198+ }199+ return &exitError{code: 1}200+}201+202+// fileMode is path's permissions, or 0644 when it has none to keep.203+func fileMode(path string) fs.FileMode {204+ st, err := os.Stat(path)205+ if err != nil {206+ return 0o644207+ }208+ return st.Mode().Perm()209+}
A cmd/012/evaluate.go +102 -0
@@ -0,0 +1,102 @@
1+package main2+3+import (4+ "context"5+ "errors"6+ "fmt"7+ "os"8+ "os/signal"9+ "time"10+11+ "github.com/FelineStateMachine/012/internal/config"12+ "github.com/FelineStateMachine/012/internal/headless"13+ "github.com/FelineStateMachine/012/internal/jev"14+ "github.com/FelineStateMachine/012/internal/nushell"15+)16+17+// jevTimeout bounds each JEV question, as on the screen.18+const jevTimeout = 60 * time.Second19+20+// evaluate runs what the flags ask of a workbook before it's read:21+// --regions runs its notebook regions' commands with nu, when the22+// workbook is trusted here or --trust says so, as the screen asks before23+// running them; --jev answers its JEV functions over the network. Region24+// failures are reported on standard error as they happen.25+func evaluate(f *headless.File, a cliArgs, e env) evalFailures {26+ if a.has("trust") && !a.has("regions") {27+ return evalFailures{stop: usageError("--trust goes with --regions", "")}28+ }29+ if !a.has("regions") && !a.has("jev") {30+ return evalFailures{}31+ }32+ cfg, err := loadConfig(e, nil)33+ if err != nil {34+ return evalFailures{stop: err}35+ }36+ ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)37+ defer stop()38+ var out evalFailures39+ if a.has("regions") {40+ if err := mayRun(f, cfg, a.has("trust")); err != nil {41+ return evalFailures{stop: err}42+ }43+ out.regions = headless.RunRegions(ctx, f.Book, headless.RegionOptions{44+ Runner: e.nuRunner(), Timeout: cfg.Duration("nu-timeout"), NuConfig: cfg.Bool("nu-config")})45+ for _, r := range out.regions {46+ fmt.Fprintln(e.stderr, "012: "+r)47+ }48+ }49+ if a.has("jev") {50+ client, err := jevClient(ctx, cfg, e)51+ if err != nil {52+ return evalFailures{stop: err}53+ }54+ headless.AnswerJEV(ctx, f.Book, client, jevTimeout)55+ }56+ return out57+}58+59+// mayRun reports why a workbook's commands may not run: shell = off, or60+// a workbook from another computer without --trust (or shell = on). A61+// trusted workbook is marked trusted here, which a command that saves62+// keeps, as answering Run on the screen does.63+func mayRun(f *headless.File, cfg *config.Config, trust bool) error {64+ switch cfg.String("shell") {65+ case "off":66+ return errors.New("shell commands are off (shell = off in your config)")67+ case "on":68+ return nil69+ }70+ machine := machineID()71+ if headless.Trusted(f.Book, machine) {72+ return nil73+ }74+ if !trust {75+ return fmt.Errorf("%s was saved on another computer: its commands would run as you, with your files; --trust runs them", f.Path)76+ }77+ if machine != "" {78+ f.Book.SetMacroOrigin(machine)79+ }80+ return nil81+}82+83+// nuRunner runs region commands: nu, or the test's fake.84+func (e env) nuRunner() nushell.Runner {85+ if e.nu != nil {86+ return e.nu87+ }88+ return nushell.Nu{}89+}90+91+// jevClient connects to JEV with the key from the environment, the92+// credential store or jev-api-key-command, as the screen finds it.93+func jevClient(ctx context.Context, cfg *config.Config, e env) (jev.Client, error) {94+ key, _, err := jev.ResolveKey(ctx, jev.KeySources{Getenv: e.getenv, Store: keyStore(cfg, e), Command: cfg.String("jev-api-key-command")})95+ if err != nil {96+ return nil, fmt.Errorf("JEV API key: %w", err)97+ }98+ if key == "" {99+ return nil, errors.New("--jev needs an API key: run 012 config set-key, or set " + jev.KeyEnv)100+ }101+ return jev.NewClient(jevConfig(cfg, key))102+}
A cmd/012/export.go +117 -0
@@ -0,0 +1,117 @@
1+package main2+3+import (4+ "context"5+ "fmt"6+ "os"7+ "path/filepath"8+ "strings"9+10+ "github.com/FelineStateMachine/012/internal/fileio"11+ "github.com/FelineStateMachine/012/internal/headless"12+ "github.com/FelineStateMachine/012/internal/sheet"13+)14+15+const exportUsage = "usage: 012 export file.012 out.csv|tsv|xlsx|json|nuon|sqlite [ref] [--format kind] [--table name] [--regions] [--jev] [--trust]"16+17+// runExport is 012 export: a sheet, a range or (to formats holding18+// several sheets) the whole workbook written as File > Download writes19+// it.20+func runExport(args []string, e env) error {21+ a, err := parseArgs(args, []string{"format", "table"}, append([]string{"help"}, evalFlags...))22+ switch {23+ case err != nil:24+ return usageError(err.Error(), exportUsage)25+ case a.has("help"):26+ fmt.Fprintln(e.stdout, exportUsage)27+ return nil28+ case len(a.pos) < 2 || len(a.pos) > 3:29+ return usageError("", exportUsage)30+ }31+ src, out := a.pos[0], a.pos[1]32+ k, err := exportKind(out, a.flags["format"])33+ if err != nil {34+ return usageError(err.Error(), exportUsage)35+ }36+ if same(src, out) {37+ return fmt.Errorf("%s is the workbook being exported: name another file", out)38+ }39+ f, err := headless.Open(src, false)40+ if err != nil {41+ return err42+ }43+ ref := ""44+ if len(a.pos) == 3 {45+ ref = a.pos[2]46+ }47+ t, err := headless.Resolve(f.Book, ref)48+ if err != nil {49+ return err50+ }51+ failed := evaluate(f, a, e)52+ if err := failed.fatal(); err != nil {53+ return err54+ }55+ snap := exportSnap(t, k, ref == "")56+ res, err := fileio.Export(context.Background(), out, k, snap, fileio.ExportOptions{Table: a.flags["table"]})57+ if err != nil {58+ return fmt.Errorf("%s: %w", out, err)59+ }60+ for _, n := range res.Notes {61+ fmt.Fprintln(e.stderr, "012: note: "+n)62+ }63+ return failed.err()64+}65+66+// exportKind is the format to write: --format's, or out's extension's.67+func exportKind(out, named string) (fileio.Kind, error) {68+ k, ok := fileio.KindOf(out)69+ if named != "" {70+ k, ok = fileio.KindNamed(named)71+ if !ok {72+ return 0, fmt.Errorf("--format %s: %s", named, exportable())73+ }74+ }75+ switch {76+ case !ok && strings.EqualFold(filepath.Ext(out), sheet.FileExt):77+ return 0, fmt.Errorf("%s is a workbook: copy the file instead, or %s", out, exportable())78+ case !ok:79+ return 0, fmt.Errorf("%s: name the format with --format, or give the file one of %s's extensions", out, exportable())80+ case !k.CanExport():81+ return 0, fmt.Errorf("012 reads %s files but doesn't write them; %s", k, exportable())82+ }83+ return k, nil84+}85+86+// exportable lists the formats 012 export writes.87+func exportable() string {88+ var names []string89+ for _, k := range fileio.Kinds() {90+ if k.CanExport() {91+ names = append(names, strings.ToLower(k.String()))92+ }93+ }94+ return "012 export writes " + strings.Join(names, ", ")95+}96+97+// exportSnap is what's written, as File > Download writes it: every98+// sheet for formats that hold several when no reference narrows it,99+// else the target, with the rows a filter hides left out of formats100+// that can't hide them.101+func exportSnap(t headless.Target, k fileio.Kind, whole bool) *fileio.Snapshot {102+ if whole && k.HoldsSheets() {103+ return fileio.SnapBook(t.Sheet)104+ }105+ r := t.Range106+ if t.Whole {107+ r = sheet.Rect{}108+ }109+ return fileio.Snap(t.Sheet, r, t.Sheet.Name())110+}111+112+// same reports whether two paths name one file.113+func same(a, b string) bool {114+ sa, err1 := os.Stat(a)115+ sb, err2 := os.Stat(b)116+ return err1 == nil && err2 == nil && os.SameFile(sa, sb)117+}
A cmd/012/git_test.go +204 -0
@@ -0,0 +1,204 @@
1+package main2+3+import (4+ "bytes"5+ "os"6+ "os/exec"7+ "path/filepath"8+ "strings"9+ "testing"10+11+ "github.com/FelineStateMachine/012/internal/headless"12+ "github.com/FelineStateMachine/012/internal/sheet"13+)14+15+// With O12_AS_MAIN set, the test binary is 012 itself, so git can run16+// it as a diff and merge driver.17+func TestMain(m *testing.M) {18+ if os.Getenv("O12_AS_MAIN") == "1" {19+ main()20+ os.Exit(0)21+ }22+ os.Exit(m.Run())23+}24+25+// gitRepo is a repository in a temporary directory whose .012 files use26+// 012 (this test binary) as their diff and merge drivers, as27+// docs/files/git.md sets them up.28+type gitRepo struct {29+ t *testing.T30+ dir string31+ env []string32+}33+34+func newGitRepo(t *testing.T) *gitRepo {35+ t.Helper()36+ if _, err := exec.LookPath("git"); err != nil {37+ t.Skip("git isn't installed")38+ }39+ exe, err := os.Executable()40+ if err != nil {41+ t.Fatal(err)42+ }43+ home := t.TempDir()44+ r := &gitRepo{t: t, dir: t.TempDir(), env: append(os.Environ(),45+ "O12_AS_MAIN=1", "HOME="+home, "XDG_CONFIG_HOME="+home, "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL="+filepath.Join(home, "gitconfig"),46+ "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@example.com", "GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@example.com", "NO_COLOR=1")}47+ r.git("init", "-q", "-b", "main")48+ r.git("config", "diff.012.command", "'"+exe+"' diff")49+ r.git("config", "diff.012.textconv", "'"+exe+"' diff --textconv")50+ r.git("config", "merge.012.name", "012 cell by cell")51+ r.git("config", "merge.012.driver", "'"+exe+"' merge-driver %O %A %B %P")52+ r.write(".gitattributes", "*.012 diff=012 merge=012\n")53+ return r54+}55+56+// git runs git in the repository and returns its output, failing the57+// test when it fails.58+func (r *gitRepo) git(args ...string) string {59+ r.t.Helper()60+ out, err := r.try(args...)61+ if err != nil {62+ r.t.Fatalf("git %v: %v\n%s", args, err, out)63+ }64+ return out65+}66+67+func (r *gitRepo) try(args ...string) (string, error) {68+ cmd := exec.Command("git", args...)69+ cmd.Dir, cmd.Env = r.dir, r.env70+ out, err := cmd.CombinedOutput()71+ return string(out), err72+}73+74+func (r *gitRepo) write(name, text string) {75+ os.WriteFile(filepath.Join(r.dir, name), []byte(text), 0o644)76+}77+78+// set sets cells of book.012 with 012 set and commits.79+func (r *gitRepo) set(msg string, pairs ...string) {80+ r.t.Helper()81+ e, _, _ := testEnv(r.t, nil)82+ if err := run(append([]string{"set", filepath.Join(r.dir, "book.012")}, pairs...), e); err != nil {83+ r.t.Fatal(err)84+ }85+ r.git("add", "-A")86+ r.git("commit", "-q", "-m", msg)87+}88+89+func (r *gitRepo) cell(ref string, input bool) string {90+ r.t.Helper()91+ f, err := headless.Open(filepath.Join(r.dir, "book.012"), false)92+ if err != nil {93+ r.t.Fatal(err)94+ }95+ t, err := headless.Resolve(f.Book, ref)96+ if err != nil {97+ r.t.Fatal(err)98+ }99+ var b bytes.Buffer100+ headless.Get(&b, t, headless.GetOptions{Input: input})101+ return strings.TrimSuffix(b.String(), "\n")102+}103+104+func TestGitMerge(t *testing.T) {105+ r := newGitRepo(t)106+ r.set("base", "A1", "Item", "B1", "Price", "B2", "1", "B3", "2", "B4", "=SUM(B2:B3)")107+ r.git("checkout", "-q", "-b", "theirs")108+ r.set("theirs", "B2", "10", "C1", "Note")109+ r.git("checkout", "-q", "main")110+ r.set("ours", "B3", "20")111+ out := r.git("merge", "-q", "--no-edit", "theirs")112+ if r.cell("B2", true) != "10" || r.cell("B3", true) != "20" || r.cell("C1", true) != "Note" || r.cell("B4", false) != "30" {113+ t.Errorf("merged: B2 %s, B3 %s, C1 %s, B4 %s\n%s", r.cell("B2", true), r.cell("B3", true), r.cell("C1", true), r.cell("B4", false), out)114+ }115+116+ // Both change B2: a conflict, ours kept, theirs in B2's note.117+ r.git("checkout", "-q", "-b", "theirs2")118+ r.set("theirs2", "B2", "11")119+ r.git("checkout", "-q", "main")120+ r.set("ours2", "B2", "12", "D1", "x")121+ out, err := r.try("merge", "--no-edit", "theirs2")122+ if err == nil || !strings.Contains(out, "012 merge-driver: 1 conflict in book.012") || !strings.Contains(out, "Sheet1!B2 input: ours 12, theirs 11, base 10") ||123+ !strings.Contains(out, "CONFLICT") {124+ t.Errorf("a conflict: %v\n%s", err, out)125+ }126+ f, _ := headless.Open(filepath.Join(r.dir, "book.012"), false)127+ b2, _ := sheet.ParseAddr("B2")128+ note := f.Book.Sheet(0).Note(b2)129+ if r.cell("B2", true) != "12" || r.cell("D1", true) != "x" || note != "Merge conflict, kept ours; theirs had input 11" {130+ t.Errorf("after the conflict: B2 %s, D1 %s, note %q", r.cell("B2", true), r.cell("D1", true), note)131+ }132+}133+134+func TestGitDiff(t *testing.T) {135+ r := newGitRepo(t)136+ r.set("base", "A1", "Item", "B1", "1", "B2", "=B1*2")137+ e, _, _ := testEnv(t, nil)138+ run([]string{"set", filepath.Join(r.dir, "book.012"), "B1", "5", "A2", "new"}, e)139+ out := r.git("diff")140+ want := "diff --012 a/book.012 b/book.012\nSheet1!B1 input 1 → 5\nSheet1!A2 input + new\nSheet1!B2 value 2 → 10\n"141+ if out != want {142+ t.Errorf("git diff:\n%s\nwant\n%s", out, want)143+ }144+ out = r.git("diff", "--no-ext-diff")145+ for _, line := range []string{"-Sheet1!B1 1\n", "+Sheet1!B1 5\n", "+Sheet1!A2 new\n", "-Sheet1!B2 =B1*2 = 2\n", "+Sheet1!B2 =B1*2 = 10\n"} {146+ if !strings.Contains(out, line) {147+ t.Errorf("git diff with textconv lacks %q:\n%s", line, out)148+ }149+ }150+ // A file added shows every cell added.151+ r.git("add", "-A")152+ r.git("commit", "-q", "-m", "more")153+ os.WriteFile(filepath.Join(r.dir, "other.012"), []byte(`{"version": 2, "cells": {"A1": "hi"}}`), 0o644)154+ r.git("add", "other.012")155+ out = r.git("diff", "--cached")156+ if !strings.Contains(out, "diff --012 a/other.012 b/other.012\nsheet Sheet1 added\nSheet1!A1 input + hi\n") {157+ t.Errorf("a file added:\n%s", out)158+ }159+}160+161+func TestDiffCommand(t *testing.T) {162+ e, out, _ := scriptEnv(t)163+ dir := t.TempDir()164+ a, b := filepath.Join(dir, "a.012"), filepath.Join(dir, "b.012")165+ status(e, "set", a, "A1", "1")166+ status(e, "set", b, "A1", "2")167+ out.Reset()168+ if code, _ := status(e, "diff", a, a); code != 0 || out.Len() > 0 {169+ t.Errorf("the same: %d %q", code, out)170+ }171+ if code, _ := status(e, "diff", a, b, "--format", "nuon"); code != 1 || out.String() != "[[kind, sheet, item, field, old, new]; [cell, Sheet1, A1, input, \"1\", \"2\"]]\n" {172+ t.Errorf("different: %d %q", code, out)173+ }174+ out.Reset()175+ e.stdoutTTY = true176+ status(e, "diff", a, b)177+ if !strings.Contains(out.String(), "\x1b[31m1\x1b[0m") {178+ t.Errorf("no color on a terminal: %q", out)179+ }180+ for _, c := range []struct {181+ args []string182+ code int183+ want string184+ }{185+ {[]string{"diff", a}, 2, "usage: 012 diff"},186+ {[]string{"diff", a, filepath.Join(dir, "none.012")}, 2, "no such file"},187+ {[]string{"diff", a, b, "--format", "xml"}, 2, "--format xml"},188+ {[]string{"diff", a, b, "--color", "sometimes"}, 2, "--color sometimes"},189+ {[]string{"merge-driver", a, b}, 2, "usage: 012 merge-driver"},190+ {[]string{"merge-driver", a, b, filepath.Join(dir, "none.012")}, 2, "no such file"},191+ } {192+ if code, err := status(e, c.args...); code != c.code || err == nil || !strings.Contains(err.Error(), c.want) {193+ t.Errorf("%v: %d %v, want %d and %q", c.args, code, err, c.code, c.want)194+ }195+ }196+ os.WriteFile(filepath.Join(dir, "broken.012"), []byte("{"), 0o644)197+ before, _ := os.ReadFile(b)198+ if code, err := status(e, "merge-driver", a, b, filepath.Join(dir, "broken.012")); code != 2 || !strings.Contains(err.Error(), "ours is left as it was") {199+ t.Errorf("a broken side: %d %v", code, err)200+ }201+ if after, _ := os.ReadFile(b); !bytes.Equal(before, after) {202+ t.Error("a failed merge changed ours")203+ }204+}
M cmd/012/main.go +25 -9
@@ -15,14 +15,18 @@ import (
1515 "github.com/FelineStateMachine/012/internal/fileio"1616 "github.com/FelineStateMachine/012/internal/jev"1717 "github.com/FelineStateMachine/012/internal/keyring"18+ "github.com/FelineStateMachine/012/internal/nushell"1819 "github.com/FelineStateMachine/012/internal/sheet"1920 "github.com/FelineStateMachine/012/internal/ui"2021 )2122 2223 func main() {2324 if err := run(os.Args[1:], system()); err != nil {24- fmt.Fprintln(os.Stderr, "012:", err)25- os.Exit(1)25+ code, show := exitCode(err)26+ if show {27+ fmt.Fprintln(os.Stderr, "012:", err)28+ }29+ os.Exit(code)2630 }2731 }2832
@@ -41,13 +45,19 @@ type env struct {
4145 // openTTY opens the terminal for the UI when standard input and4246 // output belong to a pipeline (012 -, --pipe).4347 openTTY func() (io.ReadCloser, io.WriteCloser, error)48+ // stdoutTTY is set when standard output is a terminal, where 01249+ // diff colors what it writes.50+ stdoutTTY bool51+ // nu runs notebook regions' commands for 012 get, recalc and export52+ // --regions; nil is the real nu.53+ nu nushell.Runner4454 }4555 4656 func system() env {4757 return env{4858 getenv: os.Getenv, keys: keyring.System(),4959 stdin: os.Stdin, stdout: os.Stdout, stderr: os.Stderr,50- readKey: readPassword, isTTY: isTerminal(os.Stdin), editorIO: true,60+ readKey: readPassword, isTTY: isTerminal(os.Stdin), stdoutTTY: isTerminal(os.Stdout), editorIO: true,5161 runTUI: func(m tea.Model, opts ...tea.ProgramOption) error {5262 _, err := tea.NewProgram(m, append([]tea.ProgramOption{tea.WithFPS(ui.FrameRate)}, opts...)...).Run()5363 return err
@@ -70,21 +80,27 @@ func usage() error {
7080 " 012 nu [flags] [file]: a nushell notebook, at its prompt (see docs/nushell/notebooks.md)\n" +7181 " 012 nu --module | --install-module [--force] [path]: the nushell module with sheet (see docs/nushell/README.md)\n" +7282 " 012 serve [flags] [dir]: serve sheets in dir over SSH (see docs/terminal/ssh.md)\n" +83+ " 012 get|set|recalc|export file.012 ...: read and change a workbook without the screen (see docs/files/scripts.md)\n" +84+ " 012 diff a.012 b.012, 012 merge-driver base ours theirs: compare and merge workbooks (see docs/files/git.md)\n" +7385 " 012 config [path|edit|default|themes|set-key|delete-key]\n" +7486 " 012 version")7587 }7688 89+// subcommands run without the screen. A file with one of these names90+// opens as ./name.91+var subcommands = map[string]func([]string, env) error{92+ "config": runConfig, "serve": runServe,93+ "get": runGet, "set": runSet, "recalc": runRecalc, "export": runExport,94+ "diff": runDiff, "merge-driver": runMergeDriver,95+}96+7797 func run(args []string, e env) error {78- if len(args) > 0 && args[0] == "config" {79- return runConfig(args[1:], e)98+ if len(args) > 0 && subcommands[args[0]] != nil {99+ return subcommands[args[0]](args[1:], e)80100 }81101 if len(args) > 0 && (args[0] == "version" || args[0] == "--version") {82102 return runVersion(e)83103 }84- if len(args) > 0 && args[0] == "serve" {85- // A file called serve opens as ./serve.86- return runServe(args[1:], e)87- }88104 if len(args) > 0 && args[0] == "nu" && isModuleCommand(args[1:]) {89105 return runNuModule(args[1:], e)90106 }
A cmd/012/scripts.go +178 -0
@@ -0,0 +1,178 @@
1+package main2+3+import (4+ "errors"5+ "fmt"6+ "io"7+ "slices"8+ "strings"9+10+ "github.com/FelineStateMachine/012/internal/headless"11+ "github.com/FelineStateMachine/012/internal/sheet"12+)13+14+// 012 get, set and recalc: workbook files read and changed without the15+// screen, for scripts (see docs/files/scripts.md). 012 export is in16+// export.go, and what --regions, --jev and --trust run in evaluate.go.17+18+const (19+ getUsage = "usage: 012 get file.012 [ref] [--format text|csv|tsv|json|nuon] [--input] [--no-header] [--regions] [--jev] [--trust]"20+ setUsage = "usage: 012 set file.012 ref input [ref input ...] [--force]"21+ recalcUsage = "usage: 012 recalc file.012 [--regions] [--jev] [--trust]"22+)23+24+// evalFlags are the flags that let a command run a workbook's commands25+// or ask JEV; nothing runs without them.26+var evalFlags = []string{"regions", "jev", "trust"}27+28+// runGet is 012 get: a cell's value or input, or a range's as a table.29+func runGet(args []string, e env) error {30+ a, err := parseArgs(args, []string{"format"}, append([]string{"input", "no-header", "help"}, evalFlags...))31+ switch {32+ case err != nil:33+ return usageError(err.Error(), getUsage)34+ case a.has("help"):35+ fmt.Fprintln(e.stdout, getUsage)36+ return nil37+ case len(a.pos) < 1 || len(a.pos) > 2:38+ return usageError("", getUsage)39+ }40+ o := headless.GetOptions{Format: strings.ToLower(a.flags["format"]), Input: a.has("input"), NoHeader: a.has("no-header")}41+ if o.Format != "" && !slices.Contains(headless.Formats, o.Format) {42+ return usageError("--format "+o.Format+": 012 get writes "+strings.Join(headless.Formats, ", "), getUsage)43+ }44+ f, err := headless.Open(a.pos[0], false)45+ if err != nil {46+ return err47+ }48+ t, err := headless.Resolve(f.Book, strings.Join(a.pos[1:], ""))49+ if err != nil {50+ return err51+ }52+ failed := evaluate(f, a, e)53+ if err := failed.fatal(); err != nil {54+ return err55+ }56+ if err := headless.Get(e.stdout, t, o); err != nil {57+ return err58+ }59+ if !a.has("regions") && hasCommands(t.Sheet) {60+ fmt.Fprintln(e.stderr, "012: note: "+t.Sheet.Name()+"'s notebook regions are empty unless --regions runs them")61+ }62+ return failed.err()63+}64+65+// hasCommands reports whether s has regions filled by commands.66+func hasCommands(s *sheet.Sheet) bool {67+ for _, r := range s.Regions() {68+ if !r.Linked() {69+ return true70+ }71+ }72+ return false73+}74+75+// runSet is 012 set: type entries into cells and save.76+func runSet(args []string, e env) error {77+ a, err := parseArgs(args, nil, []string{"force", "help"})78+ switch {79+ case err != nil:80+ return usageError(err.Error(), setUsage)81+ case a.has("help"):82+ fmt.Fprintln(e.stdout, setUsage)83+ return nil84+ case len(a.pos) < 3 || len(a.pos)%2 == 0:85+ return usageError("", setUsage)86+ }87+ f, err := headless.Open(a.pos[0], true)88+ if err != nil {89+ return err90+ }91+ var entries []headless.Entry92+ for i := 1; i < len(a.pos); i += 2 {93+ entries = append(entries, headless.Entry{Ref: a.pos[i], Input: a.pos[i+1]})94+ }95+ warnings, err := headless.Set(f.Book, entries, headless.SetOptions{Force: a.has("force")})96+ if err != nil {97+ return fmt.Errorf("%w; %s is unchanged", err, f.Path)98+ }99+ for _, w := range warnings {100+ fmt.Fprintln(e.stderr, "012: warning: "+w)101+ }102+ return f.Save()103+}104+105+// runRecalc is 012 recalc: recalculate, save, and list the cells whose106+// formulas show errors, exiting 1 when there are any.107+func runRecalc(args []string, e env) error {108+ a, err := parseArgs(args, nil, append([]string{"help"}, evalFlags...))109+ switch {110+ case err != nil:111+ return usageError(err.Error(), recalcUsage)112+ case a.has("help"):113+ fmt.Fprintln(e.stdout, recalcUsage)114+ return nil115+ case len(a.pos) != 1:116+ return usageError("", recalcUsage)117+ }118+ f, err := headless.Open(a.pos[0], false)119+ if err != nil {120+ return err121+ }122+ failed := evaluate(f, a, e)123+ if err := failed.fatal(); err != nil {124+ return err125+ }126+ problems, circular := headless.Recalc(f.Book)127+ if err := f.Save(); err != nil {128+ return err129+ }130+ return reportProblems(e.stdout, problems, circular, failed)131+}132+133+// reportProblems lists the cells showing errors, one a line, and says134+// how many; the status is 1 when there are any or a region failed.135+func reportProblems(w io.Writer, problems []headless.Problem, circular bool, failed evalFailures) error {136+ for _, p := range problems {137+ line := p.At() + " " + p.Value138+ switch {139+ case p.JEV:140+ line += " JEV functions need --jev and an API key to be answered"141+ case p.Why != "":142+ line += " " + p.Why143+ }144+ fmt.Fprintln(w, line)145+ }146+ if circular {147+ fmt.Fprintln(w, "circular reference: a formula reads its own cell, directly or through others")148+ }149+ if len(problems) > 0 || circular {150+ return &exitError{code: 1, err: fmt.Errorf("%d %s showing errors", len(problems), plural(len(problems), "cell", "cells"))}151+ }152+ return failed.err()153+}154+155+func plural(n int, one, many string) string {156+ if n == 1 {157+ return one158+ }159+ return many160+}161+162+// evalFailures are what --regions and --jev couldn't do: a reason163+// nothing ran at all (fatal), or regions whose commands failed.164+type evalFailures struct {165+ stop error166+ regions []string167+}168+169+func (f evalFailures) fatal() error { return f.stop }170+171+// err is the status once the command's output is written: 1 when a172+// region failed, each already reported.173+func (f evalFailures) err() error {174+ if len(f.regions) == 0 {175+ return nil176+ }177+ return &exitError{code: 1, err: errors.New(fmt.Sprint(len(f.regions)) + " " + plural(len(f.regions), "region", "regions") + " failed")}178+}
A cmd/012/scripts_test.go +203 -0
@@ -0,0 +1,203 @@
1+package main2+3+import (4+ "bytes"5+ "context"6+ "io"7+ "os"8+ "path/filepath"9+ "strings"10+ "testing"11+12+ "github.com/FelineStateMachine/012/internal/nushell"13+ "github.com/FelineStateMachine/012/internal/sheet"14+)15+16+// scriptEnv is testEnv with standard error apart from standard output.17+func scriptEnv(t *testing.T) (env, *bytes.Buffer, *bytes.Buffer) {18+ t.Helper()19+ e, out, _ := testEnv(t, nil)20+ errOut := &bytes.Buffer{}21+ e.stderr = errOut22+ return e, out, errOut23+}24+25+// status runs 012 with args and returns its exit status, as main would.26+func status(e env, args ...string) (int, error) {27+ err := run(args, e)28+ if err == nil {29+ return 0, nil30+ }31+ code, _ := exitCode(err)32+ return code, err33+}34+35+func TestGetSetRecalc(t *testing.T) {36+ e, out, errOut := scriptEnv(t)37+ path := filepath.Join(t.TempDir(), "book.012")38+ if code, err := status(e, "set", path, "A1", "Item", "B1", "Price", "A2", "Apple", "B2", "-1.5", "B3", "=SUM(B2:B2)*2"); code != 0 {39+ t.Fatal(err)40+ }41+ for _, c := range []struct {42+ args []string43+ want string44+ }{45+ {[]string{"B3"}, "-3\n"},46+ {[]string{"Sheet1!B3", "--input"}, "=SUM(B2:B2)*2\n"},47+ {[]string{"A1:B2", "--format", "nuon"}, "[[Item, Price]; [Apple, -1.5]]\n"},48+ {[]string{"--format=json", "B3"}, "-3\n"},49+ } {50+ out.Reset()51+ if code, err := status(e, append([]string{"get", path}, c.args...)...); code != 0 || out.String() != c.want {52+ t.Errorf("get %v: %d %v %q, want %q", c.args, code, err, out, c.want)53+ }54+ }55+ for _, c := range []struct {56+ args []string57+ code int58+ want string59+ }{60+ {[]string{"get", path, "Q3!A1"}, 1, `no sheet named "Q3"`},61+ {[]string{"get", path, "A1", "--format", "xml"}, 2, "--format xml: 012 get writes text, csv, tsv, json, nuon"},62+ {[]string{"get", path, "A1", "--bogus"}, 2, "unknown flag --bogus"},63+ {[]string{"get", path, "A1", "--trust"}, 2, "--trust goes with --regions"},64+ {[]string{"get", filepath.Join(t.TempDir(), "none.012"), "A1"}, 1, "no such file"},65+ {[]string{"set", path, "A1"}, 2, "usage: 012 set"},66+ {[]string{"set", path, "B4", "=SUM(B1"}, 1, "Sheet1!B4: Expected , or ) in SUM, at character 8 of =SUM(B1; " + path + " is unchanged"},67+ {[]string{"recalc"}, 2, "usage: 012 recalc"},68+ } {69+ if code, err := status(e, c.args...); code != c.code || err == nil || !strings.Contains(err.Error(), c.want) {70+ t.Errorf("%v: %d %v, want %d and %q", c.args, code, err, c.code, c.want)71+ }72+ }73+74+ out.Reset()75+ if code, err := status(e, "recalc", path); code != 0 || out.Len() > 0 {76+ t.Errorf("recalc without errors: %d %v %q", code, err, out)77+ }78+ status(e, "set", path, "C1", "=1/0", "--", "C2", "--")79+ code, err := status(e, "recalc", path)80+ if code != 1 || err.Error() != "1 cell showing errors" || !strings.HasPrefix(out.String(), "Sheet1!C1 #DIV/0! ") {81+ t.Errorf("recalc with an error: %d %v %q", code, err, out)82+ }83+ out.Reset()84+ status(e, "get", path, "C2", "--input")85+ if out.String() != "--\n" {86+ t.Errorf("-- as a value after --: %q", out)87+ }88+ if errOut.Len() > 0 {89+ t.Errorf("stderr: %s", errOut)90+ }91+}92+93+func TestExport(t *testing.T) {94+ e, _, errOut := scriptEnv(t)95+ dir := t.TempDir()96+ path := filepath.Join(dir, "book.012")97+ status(e, "set", path, "A1", "n", "A2", "1", "A3", "=A2+1")98+ if code, err := status(e, "export", path, filepath.Join(dir, "out.csv")); code != 0 {99+ t.Fatal(err)100+ }101+ if data, _ := os.ReadFile(filepath.Join(dir, "out.csv")); string(data) != "n\n1\n2\n" {102+ t.Errorf("out.csv: %q", data)103+ }104+ if !strings.Contains(errOut.String(), "1 formula saved as values") {105+ t.Errorf("notes: %q", errOut)106+ }107+ if code, err := status(e, "export", path, filepath.Join(dir, "out.txt"), "A2:A3", "--format", "json"); code != 0 {108+ t.Fatal(err)109+ }110+ if data, _ := os.ReadFile(filepath.Join(dir, "out.txt")); string(data) != "[\n{\"1\":2}\n]\n" {111+ t.Errorf("out.txt: %q", data)112+ }113+ if code, err := status(e, "export", path, filepath.Join(dir, "out.xlsx")); code != 0 {114+ t.Fatal(err)115+ }116+ for _, c := range []struct {117+ args []string118+ want string119+ }{120+ {[]string{path, filepath.Join(dir, "copy.012")}, "is a workbook: copy the file instead"},121+ {[]string{path, filepath.Join(dir, "out.wk1")}, "012 reads WK1 files but doesn't write them"},122+ {[]string{path, filepath.Join(dir, "out")}, "name the format with --format"},123+ {[]string{path, path, "--format", "csv"}, "is the workbook being exported"},124+ } {125+ if _, err := status(e, append([]string{"export"}, c.args...)...); err == nil || !strings.Contains(err.Error(), c.want) {126+ t.Errorf("export %v: %v, want %q", c.args, err, c.want)127+ }128+ }129+}130+131+// fakeNu answers every command with one table.132+type fakeNu struct{ ran int }133+134+func (f *fakeNu) Run(_ context.Context, _ nushell.Job, _ string, stdout io.Writer) error {135+ f.ran++136+ _, err := io.WriteString(stdout, "[[name]; [a.txt]]")137+ return err138+}139+140+// notebookFile writes a workbook with a region, made on the computer141+// origin.142+func notebookFile(t *testing.T, origin string) string {143+ t.Helper()144+ w := sheet.NewBook()145+ s, _ := w.AddNotebook("Shell 1", nil)146+ if err := s.AddRegion(sheet.Region{Name: "r1", Command: "ls"}); err != nil {147+ t.Fatal(err)148+ }149+ w.SetMacroOrigin(origin)150+ path := filepath.Join(t.TempDir(), "nb.012")151+ var b bytes.Buffer152+ w.Write(&b)153+ os.WriteFile(path, b.Bytes(), 0o644)154+ return path155+}156+157+func TestRegionsNeedTrust(t *testing.T) {158+ e, out, errOut := scriptEnv(t)159+ nu := &fakeNu{}160+ e.nu = nu161+ path := notebookFile(t, "another computer")162+163+ status(e, "get", path, "'Shell 1'!A2")164+ if nu.ran != 0 || out.String() != "\n" || !strings.Contains(errOut.String(), "notebook regions are empty unless --regions runs them") {165+ t.Errorf("without --regions: ran %d, %q %q", nu.ran, out, errOut)166+ }167+ if code, err := status(e, "get", path, "'Shell 1'!A2", "--regions"); code != 1 || nu.ran != 0 || !strings.Contains(err.Error(), "saved on another computer") {168+ t.Errorf("untrusted: %d %v, ran %d", code, err, nu.ran)169+ }170+ out.Reset()171+ if code, err := status(e, "get", path, "'Shell 1'!A3", "--regions", "--trust"); code != 0 || nu.ran != 1 || out.String() != "a.txt\n" {172+ t.Errorf("--trust: %d %v, ran %d, %q", code, err, nu.ran, out)173+ }174+ // get saves nothing, so the file still asks; recalc saves the trust.175+ if _, err := status(e, "recalc", path, "--regions"); err == nil {176+ t.Error("get --trust saved the trust")177+ }178+ if code, err := status(e, "recalc", path, "--regions", "--trust"); code != 0 {179+ t.Fatal(err)180+ }181+ if code, err := status(e, "get", path, "'Shell 1'!A2", "--regions"); code != 0 || nu.ran != 3 {182+ t.Errorf("trusted by recalc: %d %v, ran %d", code, err, nu.ran)183+ }184+185+ writeConfig(t, "shell = off\n")186+ if _, err := status(e, "get", path, "A1", "--regions", "--trust"); err == nil || !strings.Contains(err.Error(), "shell = off") {187+ t.Errorf("shell = off: %v", err)188+ }189+ writeConfig(t, "shell = on\n")190+ other := notebookFile(t, "elsewhere")191+ if code, err := status(e, "get", other, "A1", "--regions"); code != 0 {192+ t.Errorf("shell = on: %v", err)193+ }194+}195+196+func TestJEVNeedsKey(t *testing.T) {197+ e, _, _ := scriptEnv(t)198+ path := filepath.Join(t.TempDir(), "book.012")199+ status(e, "set", path, "A1", "1")200+ if _, err := status(e, "get", path, "A1", "--jev"); err == nil || !strings.Contains(err.Error(), "--jev needs an API key") {201+ t.Errorf("--jev without a key: %v", err)202+ }203+}