cat / 012
clone

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

Merge branch 'main' into worktree-agent-a0b9e33bebde77f90

Dami committed c999d9d
# Conflicts:
#	ROADMAP.md
commit
c999d9d32f9a3b62645754d3d0ccb7d4b9b4e876
parent
0b04451fb700777efbe75520ed74e7cfa6b32e3e
parent
89ca25dd4e0ee089e709d2b9a2d7e31f9a87bd80
author
Dami <dami.etoile@gmail.com>
100 files changed, +7755 -170
MROADMAP.md11+-
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.go181+++++++++++++++
Acmd/012/scripts_test.go212+++++++++++++++++
Mdocs/README.md4+-
Mdocs/contributing/architecture.md22++-
Mdocs/contributing/testing.md39+++
Mdocs/contributing/ux.md2+-
Mdocs/files/README.md2+
Adocs/files/git.md152++++++++++++
Adocs/files/scripts.md153+++++++++++++
Mdocs/getting-started/install.md9+-
Mdocs/nushell/cookbook.md20++
Mdocs/nushell/notebooks.md43+++-
Mdocs/reference/README.md1+
Adocs/reference/command-line.md63+++++
Mdocs/reference/keys.md6+-
Mdocs/terminal/ssh.md4+-
Me2e/notebook_test.go11+
Me2e/screennotebook_test.go42+++-
Ae2e/testdata/nu/ide/error.ast.json1+
Ae2e/testdata/nu/ide/error.check.jsonl4+
Ae2e/testdata/nu/ide/error.nu4+
Ae2e/testdata/nu/ide/highlight.ast.json1+
Ae2e/testdata/nu/ide/highlight.nu4+
Me2e/testdata/nu/nu23++
Ae2e/testdata/screens/notebook-nu-error-high-contrast.html30++
Ae2e/testdata/screens/notebook-nu-error-light.html30++
Ae2e/testdata/screens/notebook-nu-error.html30++
Ae2e/testdata/screens/notebook-nu-highlight-high-contrast.html30++
Ae2e/testdata/screens/notebook-nu-highlight-light.html30++
Ae2e/testdata/screens/notebook-nu-highlight.html30++
Ainternal/diff/cells.go285+++++++++++++++++++++++
Ainternal/diff/diff.go203+++++++++++++++++
Ainternal/diff/diff_test.go216++++++++++++++++++
Ainternal/diff/fields.go187+++++++++++++++
Ainternal/diff/match.go103++++++++
Ainternal/diff/merge.go287+++++++++++++++++++++++
Ainternal/diff/merge_test.go180+++++++++++++++
Ainternal/diff/mergesheets.go165+++++++++++++
Ainternal/diff/notebook.go160+++++++++++++
Ainternal/diff/raw.go211+++++++++++++++++
Ainternal/diff/text.go199++++++++++++++++
Minternal/fileio/nuonwrite.go4+
Ainternal/fileio/sheetscorpus_test.go241++++++++++++++++++++
Ainternal/fileio/sheetscorpus_xlsx_test.go96++++++++
Ainternal/fileio/testdata/sheets_corpus.tsv320++++++++++++++++++++++++++
Ainternal/headless/get.go179+++++++++++++++
Ainternal/headless/headless.go125++++++++++
Ainternal/headless/headless_test.go316++++++++++++++++++++++++++
Ainternal/headless/jev.go54++++
Ainternal/headless/notebook.go237+++++++++++++++++++
Ainternal/headless/recalc.go51++++
Ainternal/headless/ref.go95++++++++
Ainternal/headless/set.go71++++++
Minternal/notebook/notebook_test.go8+
Minternal/notebook/refs.go21++
Ainternal/nushell/ide.go219++++++++++++++++++
Ainternal/nushell/ide_test.go127++++++++++
Minternal/nushell/nushell.go28++-
Ainternal/nushell/testdata/ide/ast.json1+
Ainternal/nushell/testdata/ide/ast.nu1+
Ainternal/nushell/testdata/ide/check.jsonl6+
Ainternal/nushell/testdata/ide/check.nu1+
Ainternal/nushell/testdata/ide/complete.json1+
Ainternal/nushell/testdata/ide/complete.nu1+
Ainternal/nushell/testdata/ide/version.txt1+
Minternal/ui/file.go2+-
Minternal/ui/lineedit/area.go6+
Minternal/ui/lineedit/lineedit.go3+
Ainternal/ui/lineedit/word.go98++++++++
Ainternal/ui/lineedit/word_test.go75++++++
Minternal/ui/monochrome_test.go23++
Ainternal/ui/nblang.go83+++++++
Ainternal/ui/nblang_test.go94++++++++
Minternal/ui/nbrun.go14+-
Minternal/ui/nbscreen.go4+-
Minternal/ui/nbview/edit.go183++++++---------
Ainternal/ui/nbview/highlight.go173++++++++++++++
Minternal/ui/nbview/keys.go2+
Ainternal/ui/nbview/nu.go219++++++++++++++++++
Ainternal/ui/nbview/nu_test.go367++++++++++++++++++++++++++++++
Ainternal/ui/nbview/nuprelude.go146++++++++++++
Ainternal/ui/nbview/testdata/nu/check.jsonl5+
Ainternal/ui/nbview/testdata/nu/check.nu4+
Ainternal/ui/nbview/testdata/nu/complete.json1+
Ainternal/ui/nbview/testdata/nu/complete.nu4+
Ainternal/ui/nbview/testdata/nu/highlight.json1+
Ainternal/ui/nbview/testdata/nu/highlight.nu4+
Ainternal/ui/nbview/testdata/nu/sheet.json1+
Ainternal/ui/nbview/testdata/nu/sheet.nu5+
Minternal/ui/nbview/view.go4+-
Minternal/ui/notebook.go27+--
Minternal/ui/notebook_test.go3+
Ainternal/ui/wordkeys_test.go20++
M ROADMAP.md +6 -5
@@ -29,12 +29,12 @@ rises in measured steps; see [Bounds of support](docs/contributing/limits.md#wha
2929 3030 Notebook tabs are a TUI Jupyter for nushell3131 ([Notebooks](docs/nushell/notebooks.md)): code and note cells, outputs in32-place, sent to sheets as live regions. What's ahead makes writing a cell33-feel like nushell's own prompt.32+place, sent to sheets as live regions, and cells highlighted, checked and33+completed by nu as they're written.3434 3535 | Item | Result | Size |3636 |---|---|---|37-| Code cells highlighted, completed and checked as you type by nushell itself: `nu --ide-ast` token shapes mapped to theme roles, `nu --ide-complete` plus 012's cell, region and sheet names on Tab, `nu --ide-check` errors underlined; debounced in the background, plain text when nu is missing or slow; hover docs through `nu --lsp` later | Writing pipelines in 012 feels like nushell's own prompt | S to M |37+| A command's signature and description for the word at the caret, on the context line, through `nu --ide-hover` or `nu --lsp` | Writing a cell without leaving for `help` | S |3838 3939 ### 3. Toward multiplayer4040
@@ -68,8 +68,6 @@ shared editing in section 3.
6868 6969 | Item | Result | Size |7070 |---|---|---|71-| Commands for scripts without the screen: `012 get`, `012 set`, `012 recalc`, `012 export` on a workbook file | 012 in scripts, cron and nushell pipelines | S to M |72-| `012 diff` cell by cell (values, formulas, formats, regions) and a git diff and merge driver for `.012` | Sheets kept in git review like code | M |7371 | Named tables with structured references (`Sales[Amount]`); notebook regions are tables | Formulas that read by column name | M |7472 | A sheet or chart exported as a static HTML page in 012's look | Sharing a sheet with someone without 012 | S |7573 | Release archives and an install script served from the owner's nzip server | Installing without Go | S |
@@ -140,11 +138,14 @@ stream above rather than a design of their own.
140138 - 012 as a stage in a pipeline: `012 -` reads a table from standard input, `012 --pipe` sends the sheet or selection on, with nushell's types kept through NUON: [Pipelines](docs/nushell/pipelines.md)141139 - The `sheet` command: a nushell module shipped in the binary (`012 nu --install-module`), so nu calls 012 without `^012` or NUON on either side: [Pipelines](docs/nushell/pipelines.md#the-sheet-command)142140 - Notebook tabs, a TUI Jupyter for nushell: code and note cells with Jupyter's keys, outputs drawn in place and opened full-screen, `$name` between cells, stale marks and a reactive mode, outputs sent to sheets as live regions (`nu.name`), saved up to a cap, and earlier notebook sheets converted on open: [Notebooks](docs/nushell/notebooks.md)141+- Code cells highlighted, checked and completed as they're written by nu itself (`--ide-ast`, `--ide-check`, `--ide-complete`), in the background, falling back to 012's own when nu is missing, old, slow or not trusted: [Notebooks](docs/nushell/notebooks.md#writing-a-cell)143142 144143 **Files**145144 146145 - Import CSV, TSV, JSON, NUON, XLSX, SQLite, Parquet and Lotus `.wk1`; export CSV, TSV, JSON, NUON, XLSX and SQLite; import locations; save-as and overwrite checks: [Files](docs/files/README.md)147146 - `.012` files read and written as a stream, cells straight into and out of the store: [The .012 format](docs/files/format.md#reading-and-writing)147+- Commands for scripts without the screen: `012 get` (text, CSV, TSV, JSON or NUON), `012 set`, `012 recalc` and `012 export`, running notebooks and JEV only behind flags: [Scripts](docs/files/scripts.md)148+- `012 diff` cell by cell, as git's diff command or textconv, and `012 merge-driver` merging cell by cell with conflicts noted on the cells: [Diff and merge in git](docs/files/git.md)148149 - Following files: a linked region follows a CSV, TSV, JSON lines or NUON file as it grows and any importable file as it's rewritten, keeping every row or the last ones, its rows arriving as the change stream: [Following files](docs/files/following.md)149150 150151 **Upkeep**
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+// --notebooks runs its notebook cells with nu, when the workbook is22+// trusted here or --trust says so, as the screen asks before running23+// them; --jev answers its JEV functions over the network. Cells that24+// fail are reported on standard error.25+func evaluate(f *headless.File, a cliArgs, e env) evalFailures {26+ if a.has("trust") && !a.has("notebooks") {27+ return evalFailures{stop: usageError("--trust goes with --notebooks", "")}28+ }29+ if !a.has("notebooks") && !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("notebooks") {40+ if err := mayRun(f, cfg, a.has("trust")); err != nil {41+ return evalFailures{stop: err}42+ }43+ out.cells = headless.RunNotebooks(ctx, f.Book, headless.NotebookOptions{44+ Runner: e.nuRunner(), Timeout: cfg.Duration("nu-timeout"), NuConfig: cfg.Bool("nu-config")})45+ for _, c := range out.cells {46+ fmt.Fprintln(e.stderr, "012: "+c)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 notebook cells may not run: shell =60+// off, or a workbook from another computer without --trust (or shell =61+// on). A trusted workbook is marked trusted here, which a command that62+// saves 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("notebook cells don't run (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 notebook cells 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 notebook cells: 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] [--notebooks] [--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 cells for 012 get, recalc and export52+ // --notebooks; 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]: the workbook's nushell notebook (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 +181 -0
@@ -0,0 +1,181 @@
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/notebook"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 --notebooks, --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] [--notebooks] [--jev] [--trust]"20+ setUsage = "usage: 012 set file.012 ref input [ref input ...] [--force]"21+ recalcUsage = "usage: 012 recalc file.012 [--notebooks] [--jev] [--trust]"22+)23+24+// evalFlags are the flags that let a command run a workbook's notebook25+// cells or ask JEV; nothing runs without them.26+var evalFlags = []string{"notebooks", "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 missing := headless.MissingOutputs(t.Sheet); !a.has("notebooks") && len(missing) > 0 {60+ fmt.Fprintf(e.stderr, "012: note: %s shows no rows for %s, whose output the file doesn't hold; --notebooks runs the notebook\n",61+ t.Sheet.Name(), strings.Join(missing, ", "))62+ }63+ return failed.err()64+}65+66+// runSet is 012 set: type entries into cells and save.67+func runSet(args []string, e env) error {68+ a, err := parseArgs(args, nil, []string{"force", "help"})69+ switch {70+ case err != nil:71+ return usageError(err.Error(), setUsage)72+ case a.has("help"):73+ fmt.Fprintln(e.stdout, setUsage)74+ return nil75+ case len(a.pos) < 3 || len(a.pos)%2 == 0:76+ return usageError("", setUsage)77+ }78+ f, err := headless.Open(a.pos[0], true)79+ if err != nil {80+ return err81+ }82+ var entries []headless.Entry83+ for i := 1; i < len(a.pos); i += 2 {84+ entries = append(entries, headless.Entry{Ref: a.pos[i], Input: a.pos[i+1]})85+ }86+ warnings, err := headless.Set(f.Book, entries, headless.SetOptions{Force: a.has("force")})87+ if err != nil {88+ return fmt.Errorf("%w; %s is unchanged", err, f.Path)89+ }90+ for _, w := range warnings {91+ fmt.Fprintln(e.stderr, "012: warning: "+w)92+ }93+ return save(f, e)94+}95+96+// runRecalc is 012 recalc: recalculate, save, and list the cells whose97+// formulas show errors, exiting 1 when there are any.98+func runRecalc(args []string, e env) error {99+ a, err := parseArgs(args, nil, append([]string{"help"}, evalFlags...))100+ switch {101+ case err != nil:102+ return usageError(err.Error(), recalcUsage)103+ case a.has("help"):104+ fmt.Fprintln(e.stdout, recalcUsage)105+ return nil106+ case len(a.pos) != 1:107+ return usageError("", recalcUsage)108+ }109+ f, err := headless.Open(a.pos[0], false)110+ if err != nil {111+ return err112+ }113+ failed := evaluate(f, a, e)114+ if err := failed.fatal(); err != nil {115+ return err116+ }117+ problems, circular := headless.Recalc(f.Book)118+ if err := save(f, e); err != nil {119+ return err120+ }121+ return reportProblems(e.stdout, problems, circular, failed)122+}123+124+// save saves a workbook, keeping as much of its notebook outputs as the125+// config's nu-save-cell-kb and nu-save-notebook-kb allow, as the screen126+// saves it.127+func save(f *headless.File, e env) error {128+ cfg, err := loadConfig(e, nil)129+ if err != nil {130+ return err131+ }132+ f.Book.SetOutputCaps(notebook.Caps{Cell: cfg.Int("nu-save-cell-kb") << 10, Total: cfg.Int("nu-save-notebook-kb") << 10})133+ return f.Save()134+}135+136+// reportProblems lists the cells showing errors, one a line, and says137+// how many; the status is 1 when there are any or a region failed.138+func reportProblems(w io.Writer, problems []headless.Problem, circular bool, failed evalFailures) error {139+ for _, p := range problems {140+ line := p.At() + " " + p.Value141+ switch {142+ case p.JEV:143+ line += " JEV functions need --jev and an API key to be answered"144+ case p.Why != "":145+ line += " " + p.Why146+ }147+ fmt.Fprintln(w, line)148+ }149+ if circular {150+ fmt.Fprintln(w, "circular reference: a formula reads its own cell, directly or through others")151+ }152+ if len(problems) > 0 || circular {153+ return &exitError{code: 1, err: fmt.Errorf("%d %s showing errors", len(problems), plural(len(problems), "cell", "cells"))}154+ }155+ return failed.err()156+}157+158+func plural(n int, one, many string) string {159+ if n == 1 {160+ return one161+ }162+ return many163+}164+165+// evalFailures are what --notebooks and --jev couldn't do: a reason166+// nothing ran at all (fatal), or notebook cells that failed.167+type evalFailures struct {168+ stop error169+ cells []string170+}171+172+func (f evalFailures) fatal() error { return f.stop }173+174+// err is the status once the command's output is written: 1 when a175+// notebook cell failed, each already reported.176+func (f evalFailures) err() error {177+ if len(f.cells) == 0 {178+ return nil179+ }180+ return &exitError{code: 1, err: errors.New(fmt.Sprint(len(f.cells)) + " notebook " + plural(len(f.cells), "cell", "cells") + " failed")}181+}
A cmd/012/scripts_test.go +212 -0
@@ -0,0 +1,212 @@
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/notebook"13+ "github.com/FelineStateMachine/012/internal/nushell"14+ "github.com/FelineStateMachine/012/internal/sheet"15+)16+17+// scriptEnv is testEnv with standard error apart from standard output.18+func scriptEnv(t *testing.T) (env, *bytes.Buffer, *bytes.Buffer) {19+ t.Helper()20+ e, out, _ := testEnv(t, nil)21+ errOut := &bytes.Buffer{}22+ e.stderr = errOut23+ return e, out, errOut24+}25+26+// status runs 012 with args and returns its exit status, as main would.27+func status(e env, args ...string) (int, error) {28+ err := run(args, e)29+ if err == nil {30+ return 0, nil31+ }32+ code, _ := exitCode(err)33+ return code, err34+}35+36+func TestGetSetRecalc(t *testing.T) {37+ e, out, errOut := scriptEnv(t)38+ path := filepath.Join(t.TempDir(), "book.012")39+ if code, err := status(e, "set", path, "A1", "Item", "B1", "Price", "A2", "Apple", "B2", "-1.5", "B3", "=SUM(B2:B2)*2"); code != 0 {40+ t.Fatal(err)41+ }42+ for _, c := range []struct {43+ args []string44+ want string45+ }{46+ {[]string{"B3"}, "-3\n"},47+ {[]string{"Sheet1!B3", "--input"}, "=SUM(B2:B2)*2\n"},48+ {[]string{"A1:B2", "--format", "nuon"}, "[[Item, Price]; [Apple, -1.5]]\n"},49+ {[]string{"--format=json", "B3"}, "-3\n"},50+ } {51+ out.Reset()52+ if code, err := status(e, append([]string{"get", path}, c.args...)...); code != 0 || out.String() != c.want {53+ t.Errorf("get %v: %d %v %q, want %q", c.args, code, err, out, c.want)54+ }55+ }56+ for _, c := range []struct {57+ args []string58+ code int59+ want string60+ }{61+ {[]string{"get", path, "Q3!A1"}, 1, `no sheet named "Q3"`},62+ {[]string{"get", path, "A1", "--format", "xml"}, 2, "--format xml: 012 get writes text, csv, tsv, json, nuon"},63+ {[]string{"get", path, "A1", "--bogus"}, 2, "unknown flag --bogus"},64+ {[]string{"get", path, "A1", "--trust"}, 2, "--trust goes with --notebooks"},65+ {[]string{"get", filepath.Join(t.TempDir(), "none.012"), "A1"}, 1, "no such file"},66+ {[]string{"set", path, "A1"}, 2, "usage: 012 set"},67+ {[]string{"set", path, "B4", "=SUM(B1"}, 1, "Sheet1!B4: Expected , or ) in SUM, at character 8 of =SUM(B1; " + path + " is unchanged"},68+ {[]string{"recalc"}, 2, "usage: 012 recalc"},69+ } {70+ if code, err := status(e, c.args...); code != c.code || err == nil || !strings.Contains(err.Error(), c.want) {71+ t.Errorf("%v: %d %v, want %d and %q", c.args, code, err, c.code, c.want)72+ }73+ }74+75+ out.Reset()76+ if code, err := status(e, "recalc", path); code != 0 || out.Len() > 0 {77+ t.Errorf("recalc without errors: %d %v %q", code, err, out)78+ }79+ status(e, "set", path, "C1", "=1/0", "--", "C2", "--")80+ code, err := status(e, "recalc", path)81+ if code != 1 || err.Error() != "1 cell showing errors" || !strings.HasPrefix(out.String(), "Sheet1!C1 #DIV/0! ") {82+ t.Errorf("recalc with an error: %d %v %q", code, err, out)83+ }84+ out.Reset()85+ status(e, "get", path, "C2", "--input")86+ if out.String() != "--\n" {87+ t.Errorf("-- as a value after --: %q", out)88+ }89+ if errOut.Len() > 0 {90+ t.Errorf("stderr: %s", errOut)91+ }92+}93+94+func TestExport(t *testing.T) {95+ e, _, errOut := scriptEnv(t)96+ dir := t.TempDir()97+ path := filepath.Join(dir, "book.012")98+ status(e, "set", path, "A1", "n", "A2", "1", "A3", "=A2+1")99+ if code, err := status(e, "export", path, filepath.Join(dir, "out.csv")); code != 0 {100+ t.Fatal(err)101+ }102+ if data, _ := os.ReadFile(filepath.Join(dir, "out.csv")); string(data) != "n\n1\n2\n" {103+ t.Errorf("out.csv: %q", data)104+ }105+ if !strings.Contains(errOut.String(), "1 formula saved as values") {106+ t.Errorf("notes: %q", errOut)107+ }108+ if code, err := status(e, "export", path, filepath.Join(dir, "out.txt"), "A2:A3", "--format", "json"); code != 0 {109+ t.Fatal(err)110+ }111+ if data, _ := os.ReadFile(filepath.Join(dir, "out.txt")); string(data) != "[\n{\"1\":2}\n]\n" {112+ t.Errorf("out.txt: %q", data)113+ }114+ if code, err := status(e, "export", path, filepath.Join(dir, "out.xlsx")); code != 0 {115+ t.Fatal(err)116+ }117+ for _, c := range []struct {118+ args []string119+ want string120+ }{121+ {[]string{path, filepath.Join(dir, "copy.012")}, "is a workbook: copy the file instead"},122+ {[]string{path, filepath.Join(dir, "out.wk1")}, "012 reads WK1 files but doesn't write them"},123+ {[]string{path, filepath.Join(dir, "out")}, "name the format with --format"},124+ {[]string{path, path, "--format", "csv"}, "is the workbook being exported"},125+ } {126+ if _, err := status(e, append([]string{"export"}, c.args...)...); err == nil || !strings.Contains(err.Error(), c.want) {127+ t.Errorf("export %v: %v, want %q", c.args, err, c.want)128+ }129+ }130+}131+132+// fakeNu answers every command with one table.133+type fakeNu struct{ ran int }134+135+func (f *fakeNu) Run(_ context.Context, _ nushell.Job, _ string, stdout io.Writer) error {136+ f.ran++137+ _, err := io.WriteString(stdout, "[[name]; [a.txt]]")138+ return err139+}140+141+// notebookFile writes a workbook whose notebook's cell files = ls is142+// sent to Sheet1!A1, made on the computer origin.143+func notebookFile(t *testing.T, origin string) string {144+ t.Helper()145+ w := sheet.NewBook()146+ nb, _ := w.AddNotebook("Notebook", w.Sheet(0))147+ nb.SetNotebookCells("add cell", []notebook.Cell{{Kind: notebook.Code, Source: "files = ls"}})148+ a1, _ := sheet.ParseAddr("A1")149+ if err := w.Sheet(0).AddRegion(sheet.Region{Name: "files", At: a1, Output: true}); err != nil {150+ t.Fatal(err)151+ }152+ w.SetMacroOrigin(origin)153+ path := filepath.Join(t.TempDir(), "nb.012")154+ var b bytes.Buffer155+ w.Write(&b)156+ os.WriteFile(path, b.Bytes(), 0o644)157+ return path158+}159+160+func TestNotebooksNeedTrust(t *testing.T) {161+ e, out, errOut := scriptEnv(t)162+ nu := &fakeNu{}163+ e.nu = nu164+ path := notebookFile(t, "another computer")165+166+ status(e, "get", path, "A2")167+ if nu.ran != 0 || out.String() != "\n" || !strings.Contains(errOut.String(), "012: note: Sheet1 shows no rows for files, whose output the file doesn't hold; --notebooks runs the notebook\n") {168+ t.Errorf("without --notebooks: ran %d, %q %q", nu.ran, out, errOut)169+ }170+ if code, err := status(e, "get", path, "A2", "--notebooks"); code != 1 || nu.ran != 0 || !strings.Contains(err.Error(), "saved on another computer") {171+ t.Errorf("untrusted: %d %v, ran %d", code, err, nu.ran)172+ }173+ out.Reset()174+ if code, err := status(e, "get", path, "A2", "--notebooks", "--trust"); code != 0 || nu.ran != 1 || out.String() != "a.txt\n" {175+ t.Errorf("--trust: %d %v, ran %d, %q", code, err, nu.ran, out)176+ }177+ // get saves nothing, so the file still asks; recalc saves the trust,178+ // and the output, which get then reads without running anything.179+ if _, err := status(e, "recalc", path, "--notebooks"); err == nil {180+ t.Error("get --trust saved the trust")181+ }182+ if code, err := status(e, "recalc", path, "--notebooks", "--trust"); code != 0 {183+ t.Fatal(err)184+ }185+ out.Reset()186+ errOut.Reset()187+ if code, err := status(e, "get", path, "A2"); code != 0 || nu.ran != 2 || out.String() != "a.txt\n" || errOut.Len() > 0 {188+ t.Errorf("the output saved: %d %v, ran %d, %q %q", code, err, nu.ran, out, errOut)189+ }190+ if code, err := status(e, "get", path, "A2", "--notebooks"); code != 0 || nu.ran != 3 {191+ t.Errorf("trusted by recalc: %d %v, ran %d", code, err, nu.ran)192+ }193+194+ writeConfig(t, "shell = off\n")195+ if _, err := status(e, "get", path, "A1", "--notebooks", "--trust"); err == nil || !strings.Contains(err.Error(), "shell = off") {196+ t.Errorf("shell = off: %v", err)197+ }198+ writeConfig(t, "shell = on\n")199+ other := notebookFile(t, "elsewhere")200+ if code, err := status(e, "get", other, "A1", "--notebooks"); code != 0 {201+ t.Errorf("shell = on: %v", err)202+ }203+}204+205+func TestJEVNeedsKey(t *testing.T) {206+ e, _, _ := scriptEnv(t)207+ path := filepath.Join(t.TempDir(), "book.012")208+ status(e, "set", path, "A1", "1")209+ if _, err := status(e, "get", path, "A1", "--jev"); err == nil || !strings.Contains(err.Error(), "--jev needs an API key") {210+ t.Errorf("--jev without a key: %v", err)211+ }212+}
M docs/README.md +2 -2
M docs/contributing/architecture.md +21 -1
@@ -9,7 +9,7 @@ sidebar_position: 2
99 Lip Gloss v2.1010 1111 ```12-cmd/012 entry point: flags and config, 012 config, 012 serve, JEV setup, opening or importing a file, 012 - and --pipe on the terminal12+cmd/012 entry point: flags and config, 012 config, 012 serve, JEV setup, opening or importing a file, 012 - and --pipe on the terminal, the commands without the screen1313 internal/config the config file and the registry of options1414 internal/sheet the engine: cells, recalculation, undo, files, names, pivots1515 internal/functions the function library: the FuncDef table, evaluation, decimal arithmetic, JEV questions
@@ -20,6 +20,8 @@ internal/locale the locales: separators, date order, currency, formula separato
2020 internal/fileio import and export: CSV, TSV, XLSX, SQLite, Parquet, Lotus .wk1, JSON, NUON; tables on streams2121 internal/nuon nushell's object notation: typed values, tables read a row at a time, written back2222 internal/live the sources of linked regions: files followed as they grow or are rewritten23+internal/headless workbook files without the screen: references, get, set, recalc, notebooks and JEV when asked, atomic saves24+internal/diff workbooks compared and merged cell by cell, from their files' fields2325 internal/chart chart layout, text rendering and kitty image encoding2426 internal/jev the API key's resolution, answer cache and TypeSafe client2527 internal/keyring the OS credential store the API key lives in
@@ -69,6 +71,7 @@ flowchart TD
6971 direction LR7072 ui[internal/ui] ~~~ fileio[internal/fileio] ~~~ live[internal/live] ~~~ nushell[internal/nushell]7173 chart[internal/chart] ~~~ jev[internal/jev] ~~~ stress[internal/stress] ~~~ oracle[oracle]74+ headless[internal/headless] ~~~ diff[internal/diff]7275 end7376 callers --> sheet[internal/sheet]7477 sheet --> functions[internal/functions]
@@ -624,6 +627,23 @@ written a row at a time. One package suits formats that share this much
624627 translation); a format that grew its own dependencies would move to a625628 subpackage behind the same table row.626629 630+`internal/headless` is the engine without the screen, for `012 get`,631+`set`, `recalc` and `export` ([Scripts](../files/scripts.md)): it632+resolves references, writes values through the same snapshots and633+encoders as downloads, types entries through `Sheet.Set` in one634+`Batch`, and saves with a write-then-rename. It sends the outputs a635+file kept to their sheets as the screen does on opening, runs notebook636+cells through `internal/nushell` and asks `internal/jev` only when its637+caller says so; `cmd/012` decides, by the flags and the macro-origin638+trust.639+640+`internal/diff` reads a `.012` file as its JSON fields: the workbook's,641+and each sheet's with its cells by address. Comparing and merging go642+field by field, so a field added to the format is compared and merged643+without teaching them about it, and the engine is used only to compute644+values (`Compare`) and to check and write the merged workbook645+(`Merge`), which it reads back as 012 would open it.646+627647 ## Macros628648 629649 Macros are Starlark scripts (`go.starlark.net`, pure Go) kept in the
M docs/contributing/testing.md +39 -0
@@ -69,6 +69,45 @@ formatted values with 012's. Known differences between Sheets and Excel,
6969 and places where excelize departs from Excel, are listed as skips with a7070 reason.7171 72+## The Sheets corpus73+74+012 follows Google Sheets' formula results where people rely on them75+([UX](ux.md)), and `internal/fileio/testdata/sheets_corpus.tsv` holds76+about 300 formulas that pin it down: dates and times, text, rounding and77+floating point, errors and how they propagate, coercion of text,78+booleans and blanks, lookups and criteria, array sizes, LET and LAMBDA,79+and `TEXT` formats. Each line is a formula, 012's result, Sheets'80+result and a note, separated by tabs. Results read `"text"` quoted,81+`12.5`, `TRUE`, `#N/A`, or `(blank)`. Formulas read a small fixture on a82+sheet named `Data`, and each computes one value: an array is measured83+with `ROWS`, `COLUMNS`, `INDEX` or `TEXTJOIN`, so its size is checked84+and nothing spills.85+86+`TestSheetsCorpus` holds 012 to the results recorded for it, and fails87+on a row where Sheets' result differs unless its note starts88+`on purpose:` and says why; that reason also goes in the docs page for89+the feature. Sheets' result reads `unconfirmed` until it is checked in90+Sheets:91+92+```sh93+# Add formulas as lines of their own, then record 012's results.94+go test ./internal/fileio -run SheetsCorpus -update-corpus95+# Write the corpus as a workbook with 012's XLSX exporter.96+go test ./internal/fileio -run SheetsCorpus -corpus-xlsx=$HOME/012-sheets-corpus.xlsx97+# In Sheets (locale United States): File > Import the workbook, then98+# File > Download > CSV of the Corpus sheet. Record Sheets' results:99+go test ./internal/fileio -run SheetsCorpus -update-from="$HOME/Downloads/012-sheets-corpus - Corpus.csv"100+```101+102+In the workbook, column C holds each formula, which Sheets recalculates103+on import; D and E read its result as text and its kind with formulas104+both 012 and Sheets compute, F holds 012's result and G says whether105+they agree (for two errors, compare C and F by eye). The exporter saves106+formulas Excel has nothing like (`SPLIT`, `REGEXMATCH`, `SORTN`,107+`FLATTEN`, `ARRAYFORMULA` inside a formula) as values, so those rows108+leave C empty and say so in the note: type the formula from B into C109+before downloading, or `-update-from` leaves them unconfirmed.110+72111 ## The XLSX differential test73112 74113 `TestXLSXDifferential` (`internal/fileio/xlsx_diff_test.go`) imports a
M docs/contributing/ux.md +1 -1
M docs/files/README.md +2 -0
A docs/files/git.md +152 -0
@@ -0,0 +1,152 @@
1+---2+title: "Diff and merge in git"3+sidebar_position: 74+---5+6+# Diff and merge in git7+8+`012 diff` compares two workbooks cell by cell, and `012 merge-driver`9+merges them, so sheets kept in git are reviewed and merged like code.10+11+## 012 diff12+13+```sh14+012 diff budget.012 budget-v2.01215+```16+17+```18+sheet Q4 renamed from Q319+sheet Notes added20+Q4!B7 input =SUM(A1:A5) → =SUM(A1:A6)21+Q4!B7 value 15 → 2122+Q4!C2 value 15 → 2123+Q4!D9 input - 4224+Q4!A12 input + Total25+Q4!B3 format currency, decimals 2 → currency, bold, decimals 226+Q4!B3 note check → checked27+Q4 widths C 10 → 1428+Notebook cell 2 source ls → ls -a29+name Sales range Q3!B2:B20 → Q4!B2:B2130+```31+32+A line says where, what about it changed, and `old → new`, `+ new` for33+something added or `- old` for something removed. On a terminal (and in34+git's pager) the old side is red and the new green; `--color never` or35+`NO_COLOR` turns that off, `--color always` on.36+37+What it compares:38+39+| Change | Lines |40+|---|---|41+| Sheets | `added`, `removed`, `renamed from`, `moved from 3 to 1`. A sheet under a new name that holds at least half the cells of one gone is that sheet renamed |42+| Cells | `input` (what was typed: a formula or an entry), `value` (what a formula computes, or an array spilled), `format` (the cell's formatting, by its fields in the file), `note` |43+| Notebooks | Each [notebook](../nushell/notebooks.md) cell, lined up by its source: `added`, `removed`, or which field changed (`source`, `output`, `error`) |44+| Regions | Each [linked file](following.md) or notebook output sent to a sheet, by name: added, removed, or which field changed |45+| Layout | Every other field of a sheet, by its name in [the file](format.md): `widths`, `heights` and `lines` key by key, the rest (`charts`, `conditionalFormats`, `freeze`) whole |46+| Workbook | Named ranges, macros, the locale and decimal arithmetic |47+48+A value is shown only for formulas and cells an array filled, since a49+typed entry's value is its input. Values that change on every50+recalculation (`NOW()`, `RAND()` and what reads them, on any sheet)51+aren't compared, and neither is anything that needs a notebook cell or52+JEV run: `012 diff` only reads the two files, whose notebook outputs are53+compared as they were saved. The sheet shown, the54+file's version, the notebook prompt's history and which computer55+trusted its commands aren't changes either.56+57+`--format json` and `--format nuon` write the changes as a table with58+the columns `kind`, `sheet`, `item`, `field`, `old` and `new`, values59+with their types:60+61+```nu62+012 diff a.012 b.012 --format nuon | from nuon | where kind == cell and field == value63+```64+65+The exit status is diff(1)'s: 0 when the workbooks are the same, 1 when66+they differ, 2 on trouble (a file that can't be read).67+68+## Git69+70+Tell git which files are workbooks, in `.gitattributes` at the top of71+the repository (committed, so everyone's clone knows):72+73+```74+*.012 diff=012 merge=01275+```76+77+and what `012` means for them, in each clone or in your global git78+config (git doesn't take commands from a repository):79+80+```sh81+git config diff.012.command '012 diff'82+git config diff.012.textconv '012 diff --textconv'83+git config merge.012.name '012 cell by cell'84+git config merge.012.driver '012 merge-driver %O %A %B %P'85+```86+87+- **`diff.012.command`** makes `git diff`, `git show` and `git log -p`88+ show 012's diff for each workbook, under a `diff --012 a/budget.01289+ b/budget.012` header. Git calls it with its own seven arguments, which90+ `012 diff` recognizes; it exits 0 then, as git expects.91+- **`diff.012.textconv`** is for what doesn't use the external diff:92+ `git diff --no-ext-diff`, `git log -p --no-ext-diff`, `git blame`,93+ `git grep` and hosting sites that honor textconv. `012 diff --textconv94+ file` writes the workbook as one line per cell and field, each naming95+ its sheet (`Q3!B7 =SUM(A1:A6) = 21 [currency, decimals 2]`), and git96+ diffs those lines. Either can be set alone.97+- **`merge.012.driver`** merges workbooks cell by cell, below.98+99+## Merging100+101+`012 merge-driver base ours theirs [path]` merges theirs into ours from102+their common base and writes the result over ours, which is what git103+asks of a merge driver (`%O %A %B %P`). Each part of a workbook merges104+on its own:105+106+```mermaid107+flowchart TD108+ part["A part: a cell's input, format or note; a sheet's name; a column's width; a region; a notebook's cells; a named range"]109+ part --> same{"Did both sides change it?"}110+ same -- "One side, or neither" --> take["Take the side that changed it"]111+ same -- "Both, alike" --> take112+ same -- "Both, differently" --> conflict["Conflict: keep ours, note what theirs had"]113+```114+115+So two people editing different cells of one sheet, or one formatting a116+cell while the other retypes it, merge without conflicts. Sheets are117+matched by name, or by their cells when renamed (as `012 diff` matches118+them): a sheet renamed on one side and edited on the other merges both.119+A sheet removed on one side goes, unless the other side changed it,120+which is a conflict: ours stays (kept when ours changed it, gone when121+ours removed it). Sheets added on both sides under one name merge cell122+by cell. A notebook's cells are one part, their sources compared: the123+side that changed them brings its cells and their outputs, and a124+notebook run on both sides with the same cells keeps ours' outputs.125+126+On conflicts the merge keeps ours for each, adds to the cell's note what127+theirs had (`Merge conflict, kept ours; theirs had input 11`), so the128+cells show the note's mark in the grid, lists them on standard error and129+exits 1:130+131+```132+012 merge-driver: 1 conflict in budget.012, where ours is kept; a cell's note says what theirs had:133+ Sheet1!B2 input: ours 12, theirs 11, base 10134+```135+136+Git then reports the file as conflicted. Open it in 012, fix the cells137+and delete the notes, then `git add` it; or take one side whole with138+`git checkout --ours` or `--theirs`. A merge that can't be made at all139+(a side that isn't a workbook, or a result the engine refuses, such as a140+named range left on a sheet the other side removed) leaves ours as it141+was and exits 2, which git also takes as a conflict.142+143+Two limits:144+145+- Inserting or deleting rows or columns moves every cell below or to146+ the right, which a merge by address can't tell from edits to those147+ cells. Merge such a change in 012 instead: open both versions and copy148+ across.149+- Trust in a file's commands follows [macros' rule](../sheets/macros.md#macros-from-other-computers):150+ the merged file keeps ours' trust only when its macros, notebook cells151+ and linked files are ours', or theirs were trusted on the same152+ computer. Otherwise the next time the file runs one, 012 asks first.
A docs/files/scripts.md +153 -0
@@ -0,0 +1,153 @@
1+---2+title: "Scripts"3+sidebar_position: 64+---5+6+# Scripts7+8+Four commands read and change a `.012` workbook without the screen, for9+shell scripts, cron jobs, Makefiles and nushell pipelines:10+11+```sh12+012 get budget.012 B7 # a cell's value, as the sheet shows it13+012 get budget.012 'Q3 plan'!A1:C9 --format csv14+012 set budget.012 B7 '=SUM(A1:A6)' B8 1200 # type entries, then save15+012 recalc budget.012 # recalculate, save, list the errors16+012 export budget.012 budget.xlsx # what File > Download writes17+```18+19+None of them runs a program or reaches the network unless a flag asks:20+see [Notebooks and JEV functions](#notebooks-and-jev-functions). The flags21+and exit statuses of every command are in22+[Command line](../reference/command-line.md).23+24+## References25+26+A reference names cells as a formula does, with the sheet in front when27+it isn't the one shown when the file was saved:28+29+| Reference | Names |30+|---|---|31+| `B7`, `A1:C9`, `A:A`, `$B$7` | Cells of the sheet shown when the file was saved |32+| `Q3!B7`, `'Q3 plan'!A1:C9` | Cells of the sheet named, quoted as in formulas when the name has spaces (the shell needs quotes around it too) |33+| `Sales` | A [named range](../formulas/references.md) |34+| `Q3`, `Q3!` | The whole sheet, from A1 to its last cell with contents |35+36+A reference that names nothing says what the workbook has: `no sheet37+named "Q4" (sheets: Q1, Q2, Q3)`.38+39+## get40+41+`012 get file ref` writes the value of a cell, or a table of a range or42+sheet (the sheet shown when there's no reference). `--format` picks the43+form:44+45+| Format | One cell | A range or sheet |46+|---|---|---|47+| `text`, the default | As the cell shows it: `$1,200.00`, `9/29/2026`, `#DIV/0!` | Aligned columns, numbers to the right |48+| `csv`, `tsv` | One line | Rows as shown, in the workbook's [locale](../sheets/locale.md), as a CSV download |49+| `json` | The value: a number, a string, `true`, `null` for a blank cell | A list of records named by the range's first row |50+| `nuon` | The value with its nushell type: a date, a file size, a duration | A nushell table named by the range's first row |51+52+JSON and NUON type values by their cells' formats as a53+[NUON download](../nushell/types.md) does, and take the columns' names54+from the range's first row, as a table in 012 has its header there.55+`--no-header` names the columns by their letters instead (`A`, `B`) and56+makes the first row a record too. A filter doesn't hide rows from `get`:57+a script reads all the range holds.58+59+`--input` writes what was typed rather than what it computes: a60+formula, or an entry as the file stores it (numbers and dates in61+en-US's form whatever the workbook's locale), so62+`012 get budget.012 B7 --input` prints `=SUM(A1:A6)`.63+64+In nushell, NUON comes back as a table with its types:65+66+```nu67+012 get budget.012 A1:C9 --format nuon | from nuon | where amount > 10068+012 get budget.012 B7 --format nuon | from nuon # a number, not a string69+```70+71+## set72+73+`012 set file ref input [ref input ...]` types each input into its cell,74+in order, as one change, and saves the file atomically: into a temporary75+file beside it, renamed over it once complete. An empty input clears the76+cell's contents, keeping its formatting and note, as Delete does. A file77+that doesn't exist is created, as `012 budget.012` does.78+79+Inputs are what the file stores, not what the sheet's locale types: `1.5`80+and `=ROUND(A1, 2)` in every workbook, so a script means the same in a81+de-DE workbook as in an en-US one. Entries that imply a format (`$1,200`,82+`12%`, `2026-09-29`) set it, as typing them does.83+84+`set` stops, and leaves the file as it was, on the first input a cell85+can't take, naming the cell:86+87+| Refused | Says |88+|---|---|89+| A formula that doesn't parse | `Sheet1!B7: Expected , or ) in SUM, at character 11 of =SUM(A1:A6` |90+| An entry a validation rule rejects | the rule's help, as the context line would |91+| A cell of an array's result, a pivot table, a linked file or a notebook's output | the same message as on the screen |92+| A cell in a [protected range](../sheets/notes-protection.md) | `Sheet1!B7 is protected (B1:B9): --force sets it anyway` |93+| A reference to a range, not one cell | `A1:B2 is the range A1:B2: name one cell` |94+95+An entry a rule only marks invalid is set, with a warning on standard96+error. An input that starts with `--` (a flag's shape) comes after `--`:97+`012 set budget.012 -- A1 --`.98+99+## recalc100+101+`012 recalc file` recomputes every formula, saves the file (leaving it102+untouched when nothing it stores changed) and lists each formula showing103+an error, one a line, with what the screen's context line says about it:104+105+```106+Q3!B7 #DIV/0! Division by zero in B6/C6107+Q3!D2 #N/A JEV functions need --jev and an API key to be answered108+```109+110+It exits 1 when any cell shows an error or a formula reads its own cell,111+so `012 recalc budget.012 && deploy` stops on a broken sheet.112+113+## export114+115+`012 export file out [ref]` writes the workbook in another format, as116+File > Download does: the format by `out`'s extension or `--format`117+(`csv`, `tsv`, `xlsx`, `sqlite`, `json`, `nuon`). XLSX gets every sheet;118+the others get the sheet shown, or `ref`'s range or sheet. SQLite writes119+a table named by `--table`, replacing one of that name. Formulas are120+written as their values where the format has no formulas, and a note on121+standard error says how many. CSV, TSV, JSON and NUON leave out the rows122+a filter hides, as a download does.123+124+## Notebooks and JEV functions125+126+A `.012` file stores what was typed and computes the rest, so a few127+things need more than the file:128+129+- **[Notebook](../nushell/notebooks.md) outputs** are kept in the file130+ as their cells last left them, and these commands read them as saved,131+ sent to sheets as on the screen; `get` notes on standard error when132+ the sheet it reads has an output the file doesn't hold (never run, or133+ too large to save). `--notebooks` runs every notebook's134+ code cells first, each after the cells it reads, as F9 does; a cell135+ that fails stops the rest of its notebook, and is reported. A cell136+ reading `$selection` fails, having no selection to read.137+- As on the screen, a notebook138+ [saved on another computer](../nushell/notebooks.md#saving-and-trust)139+ asks first: `--notebooks` refuses it unless `--trust` gives the answer140+ Run would, and `recalc --notebooks --trust` saves that trust in the141+ file as the screen does, so later runs don't need it. `shell = off` in142+ the [configuration](../reference/config.md#shell) turns `--notebooks`143+ off, `shell = on` runs every file's cells, each cell stops after144+ `nu-timeout`, and saving keeps as much of the outputs as145+ `nu-save-cell-kb` and `nu-save-notebook-kb` allow.146+- **[Linked files](following.md)** are followed only on the screen; their147+ regions are empty here.148+- **[JEV functions](../formulas/jev.md)** ask a hosted model over the149+ network, and show `#N/A` here unless `--jev` asks it, with the API key150+ from the environment, the credential store or `jev-api-key-command`.151+152+`get`, `recalc` and `export` take these flags; `set` computes values only153+to check entries.
M docs/getting-started/install.md +8 -1
M docs/nushell/cookbook.md +20 -0
M docs/nushell/notebooks.md +42 -1
@@ -138,7 +138,7 @@ stateDiagram-v2
138138 | Enter | A new line, as indented as the one before |139139 | Up, Down, Home, End | Move by the lines on screen, wrapped lines too |140140 | Ctrl+A, Ctrl+E | The start, the end of the line |141-| Tab | Complete the word at the caret: a cell's `$name`, `$selection`, a linked file, or one of nu's commands |141+| Tab | Complete the word at the caret: a cell's `$name`, `$selection`, a linked file, then what nu completes there (commands, flags, paths); one completion goes in at once ([Writing a cell](#writing-a-cell)) |142142 143143 A terminal without the kitty keyboard protocol sends Shift+Enter and144144 Ctrl+Enter as Enter ([Keys the terminal has to tell
@@ -150,6 +150,47 @@ shortcuts (Ctrl+/), and File, Edit and the sheet tabs work as anywhere;
150150 commands for a sheet's cells are off on a notebook's tab. Changes to151151 cells are undo steps, as any edit is.152152 153+## Writing a cell154+155+The cell being written is read by nu itself, as nushell's own prompt156+reads what you type: once typing pauses, 012 asks `nu --ide-ast` what157+each word is and colors it (commands, strings, variables, numbers,158+keywords, operators; flags stay plain), and `nu --ide-check` what's159+wrong. A problem is underlined with a curly line, and with the caret on160+it the context line says what nu said:161+162+```163+ [ ] not run164+│ $files | sort-by size --revrse165+ ~~~~~~~~166+The `sort-by` command doesn't have flag `revrse`.167+```168+169+Tab asks `nu --ide-complete` too, after the notebook's own names, so it170+completes flags, subcommands and paths as well as cells and commands.171+nu doesn't know the variables a notebook binds, so it's asked about the172+cell with `$name` for each named cell and linked file, `$selection` and173+`$sheet` declared before it; a `$sheet.A1:C9` range reads as one174+variable, and `name =` isn't part of what nu reads.175+176+Questions to nu run in the background and never on each key: one177+process for each question after a pause, at most two at a time, each178+stopped once the text changes and after a second and a half. Until nu179+answers, the cell shows 012's own highlighting, and what's unchanged180+keeps nu's colors, so nothing flickers or moves. 012 falls back to its181+own highlighting and names, without saying so, when:182+183+- nu isn't installed, or is older than 0.100;184+- nu timed out three times in a row (asked again in the next session);185+- the cells couldn't run without asking: a file from another computer186+ not yet trusted ([Saving and trust](#saving-and-trust)), `shell =187+ off`, or [012 serve](../terminal/ssh.md#notebooks) without188+ `serve-shell`.189+190+nu reads the cell without your `config.nu` or its standard library, so191+your own commands aren't known to it; they still run when the cell does192+with `nu-config`.193+153194 ## Running154195 155196 Cells run in the background, one at a time, so the screen stays live:
M docs/reference/README.md +1 -0
A docs/reference/command-line.md +63 -0
@@ -0,0 +1,63 @@
1+---2+title: "Command line"3+sidebar_position: 64+---5+6+# Command line7+8+Every way to start 012, with the page that describes each. A file named9+like a command (`get`, `diff`) opens as `./get`.10+11+## With the screen12+13+| Command | Does |14+|---|---|15+| `012 [flags] [file]` | Opens a `.012` workbook (created on saving when it doesn't exist), or imports another format: [Files](../files/README.md) |16+| `012 [flags] -` | A table from standard input: [Pipelines](../nushell/pipelines.md) |17+| `012 [flags] --pipe [--to nuon\|json\|csv\|tsv] [--send ask\|selection\|sheet] [file]` | On quitting, sends a table to standard output: [Pipelines](../nushell/pipelines.md) |18+| `012 nu [flags] [file]` | A nushell notebook, at its prompt: [Notebooks](../nushell/notebooks.md) |19+| `012 serve [flags] [dir]` | Serves the sheets in a directory over SSH: [Serving over SSH](../terminal/ssh.md) |20+21+The flags are the options' flags, listed in [Configuration](config.md#options).22+23+## Without the screen24+25+| Command | Does |26+|---|---|27+| `012 get file.012 [ref]` | Writes a cell's value, or a range or sheet as a table: [Scripts](../files/scripts.md#get) |28+| `012 set file.012 ref input [ref input ...]` | Types entries into cells and saves: [Scripts](../files/scripts.md#set) |29+| `012 recalc file.012` | Recalculates, saves and lists the cells showing errors: [Scripts](../files/scripts.md#recalc) |30+| `012 export file.012 out [ref]` | Writes the workbook, a sheet or a range in another format: [Scripts](../files/scripts.md#export) |31+| `012 diff a.012 b.012` | Lists what changed, cell by cell: [Diff and merge in git](../files/git.md#012-diff) |32+| `012 diff --textconv file.012` | Writes a workbook as lines, for git's textconv: [Diff and merge in git](../files/git.md#git) |33+| `012 merge-driver base ours theirs [path]` | Merges theirs into ours, for git: [Diff and merge in git](../files/git.md#merging) |34+| `012 nu --module`, `012 nu --install-module [--force] [path]` | Prints or installs the nushell module: [Nushell](../nushell/README.md#install-the-sheet-command) |35+| `012 config [path\|edit\|default\|themes\|set-key\|delete-key]` | The settings: [Configuration](config.md#commands) |36+| `012 version` | Prints the version |37+38+`get`, `set`, `recalc`, `export`, `diff` and `merge-driver` take their39+flags anywhere after the command's name, and `--help` prints their40+usage. `--` ends the flags, so what follows is taken as it is41+(`012 set book.012 -- A1 --`), and an argument such as `-5` is never a42+flag.43+44+| Flag | Commands | Does |45+|---|---|---|46+| `--format text\|csv\|tsv\|json\|nuon` | `get` | The form of what's written; text by default |47+| `--input` | `get` | What was typed (a formula) rather than its value |48+| `--no-header` | `get` | JSON and NUON columns named by their letters, the first row a record |49+| `--force` | `set` | Sets cells in protected ranges |50+| `--format kind`, `--table name` | `export` | The format when the file's extension doesn't say; SQLite's table |51+| `--notebooks` | `get`, `recalc`, `export` | Runs the notebooks' cells first, when the file is trusted here |52+| `--trust` | with `--notebooks` | Runs the cells of a file saved on another computer |53+| `--jev` | `get`, `recalc`, `export` | Asks JEV for the JEV functions' answers first |54+| `--format text\|json\|nuon` | `diff` | The form of the changes; text by default |55+| `--color auto\|always\|never` | `diff` | Colors the text; auto colors on a terminal or git's pager unless `NO_COLOR` is set |56+57+## Exit status58+59+| Status | Means |60+|---|---|61+| 0 | Done; for `diff`, the workbooks are the same |62+| 1 | An error, said on standard error; for `recalc`, cells show errors (after saving); for `diff`, the workbooks differ; for `merge-driver`, conflicts; for `get`, `recalc` and `export` with `--notebooks`, a notebook cell failed (after writing) |63+| 2 | The command was used wrongly (its usage is printed); for `diff` and `merge-driver`, trouble reading or merging the files |
M docs/reference/keys.md +5 -1
@@ -35,6 +35,9 @@ mouse and the vim keymap added.
3535 | Ctrl+Enter | Enter the same entry in every selected cell |3636 | Arrows, Shift+arrows | While typing a formula, after an operator: point at a cell, or a range |3737 | F4 | While typing a formula: cycle the reference at the caret through `A1`, `$A$1`, `A$1`, `$A1` |38+| Ctrl+Left, Ctrl+Right (Option+arrows on macOS; Alt+arrows outside a cell) | While editing any text: move by word; in ENTER mode Ctrl+arrows commit and jump, as arrows do |39+| Ctrl+Backspace, Option+Backspace, Ctrl+W; Alt+D, Ctrl+Delete | While editing any text: delete the word before the caret; the word after it |40+| Ctrl+U, Cmd+Backspace | While editing any text: delete to the start of the line |3841 | Up, Down; Tab, Enter | While suggestions for a function, range or sheet name show: pick one; insert it (a sheet as `Summary!`, then arrows point into it). Esc hides them |3942 4043 ## Selecting
@@ -153,8 +156,9 @@ on a sheet, `!` starts an entry as any character does.
153156 |---|---|154157 | Esc | Back to command mode, keeping what was typed |155158 | Shift+Enter, Ctrl+Enter, Alt+Enter | Run, as in command mode |156-| Enter, Tab | A new line; complete the word at the caret |159+| Enter, Tab | A new line; complete the word at the caret, with what nu completes there ([Writing a cell](../nushell/notebooks.md#writing-a-cell)) |157160 | Up, Down, Home, End, Ctrl+A, Ctrl+E | Move by the lines on screen; the start, the end of the line |161+| Word keys | As [anywhere text is edited](#entering-data); Ctrl+U deletes to the start of the line, not the cell |158162 159163 | Key | On an output full-screen |160164 |---|---|
M docs/terminal/ssh.md +3 -1
M e2e/notebook_test.go +11 -0
M e2e/screennotebook_test.go +37 -5
@@ -3,14 +3,17 @@ package e2e
33 import (44 "os"55 "path/filepath"6+ "regexp"7+ "strings"68 )79 810 // Golden screens for notebooks: cells with a note and table outputs, a911 // cell edited with a long pipeline wrapped (at 80 and 60 columns), a10-// failure, a cell running and one waiting, stale outputs, an output11-// full-screen, and an output sent to a sheet. Each is recorded on a12-// light terminal and in the high-contrast theme too. nu is testdata/nu/nu,13-// which answers what the screens run with fixed outputs.12+// failure, nu's highlighting and a problem nu found, a cell running and13+// one waiting, stale outputs, an output full-screen, and an output sent14+// to a sheet. Each is recorded on a light terminal and in the15+// high-contrast theme too. nu is testdata/nu/nu, which answers what the16+// screens run, and what the code editor asks, with fixed outputs.1417 1518 // fakeNu puts the stand-in nu first on the PATH.1619 var fakeNu = func() []string {
@@ -21,6 +24,24 @@ var fakeNu = func() []string {
2124 return []string{"PATH=" + dir + string(os.PathListSeparator) + os.Getenv("PATH")}2225 }()2326 27+// nuHighlighted reports whether the line of screen html holding mark28+// draws word twice in two styles, as nu's shapes of `where size > 1kb29+// | sort-by size` do (a column, then a string) where the tokenizer30+// leaves both plain.31+func nuHighlighted(html, mark, word string) bool {32+ for line := range strings.SplitSeq(html, "\n") {33+ if !strings.Contains(line, mark) {34+ continue35+ }36+ var styles []string37+ for _, m := range regexp.MustCompile(`<span style="([^"]*)">`+regexp.QuoteMeta(word)+`</span>`).FindAllStringSubmatch(line, -1) {38+ styles = append(styles, m[1])39+ }40+ return len(styles) == 2 && styles[0] != styles[1]41+ }42+ return false43+}44+2445 // longPipeline is a pipeline too long for one line at 80 columns.2546 const longPipeline = "$files | where size > 1kb | where type == file | sort-by size --reverse | select name size | first 10 | rename file bytes"2647
@@ -60,7 +81,8 @@ var notebookScreens = []screen{
6081 {name: "notebook-running", setup: func(s *session) {6182 filesNotebook(s)6283 s.keys("<enter>", "sleep 10min", "<esc>", "b", "<enter>", "{name: 1}", "<esc>", "<f9>")63- s.waitFor("waiting")84+ // F9 queues every cell; the first two finish, then sleep runs.85+ s.waitFor("running 1, 1 waiting")6486 }},6587 {name: "notebook-stale", setup: func(s *session) {6688 filesNotebook(s)
@@ -72,6 +94,16 @@ var notebookScreens = []screen{
7294 s.keys("<up>", "<up>", "<up>", "<enter>")7395 s.waitFor("full-screen")7496 }},97+ {name: "notebook-nu-highlight", setup: func(s *session) {98+ filesNotebook(s)99+ s.keys("<enter>", "big = $files | where size > 1kb | sort-by size --reverse # largest")100+ s.eventually("nu's highlighting", func() bool { return nuHighlighted(s.html(), "largest", "size") })101+ }},102+ {name: "notebook-nu-error", setup: func(s *session) {103+ filesNotebook(s)104+ s.keys("<enter>", "$files | sort-by size --revrse")105+ s.waitFor("doesn't have flag `revrse`")106+ }},75107 {name: "notebook-sent", setup: func(s *session) {76108 filesNotebook(s)77109 s.keys("<up>", "<up>", "<up>", "<up>", "G", "<enter>")
A e2e/testdata/nu/ide/error.ast.json +1 -0
A e2e/testdata/nu/ide/error.check.jsonl +4 -0
A e2e/testdata/nu/ide/error.nu +4 -0
A e2e/testdata/nu/ide/highlight.ast.json +1 -0
@@ -0,0 +1 @@
1+[{"type":"ast","span":{"start":0,"end":3},"shape":"shape_internalcall","content":"let"},{"type":"ast","span":{"start":4,"end":13},"shape":"shape_vardecl","content":"selection"},{"type":"ast","span":{"start":21,"end":23},"shape":"shape_list","content":"[]"},{"type":"ast","span":{"start":24,"end":27},"shape":"shape_internalcall","content":"let"},{"type":"ast","span":{"start":28,"end":33},"shape":"shape_vardecl","content":"sheet"},{"type":"ast","span":{"start":41,"end":43},"shape":"shape_record","content":"{}"},{"type":"ast","span":{"start":44,"end":47},"shape":"shape_internalcall","content":"let"},{"type":"ast","span":{"start":48,"end":53},"shape":"shape_vardecl","content":"files"},{"type":"ast","span":{"start":61,"end":63},"shape":"shape_list","content":"[]"},{"type":"ast","span":{"start":70,"end":76},"shape":"shape_variable","content":"$files"},{"type":"ast","span":{"start":77,"end":78},"shape":"shape_pipe","content":"|"},{"type":"ast","span":{"start":79,"end":84},"shape":"shape_internalcall","content":"where"},{"type":"ast","span":{"start":85,"end":89},"shape":"shape_string","content":"size"},{"type":"ast","span":{"start":85,"end":89},"shape":"shape_variable","content":"size"},{"type":"ast","span":{"start":90,"end":91},"shape":"shape_operator","content":">"},{"type":"ast","span":{"start":92,"end":93},"shape":"shape_int","content":"1"},{"type":"ast","span":{"start":93,"end":95},"shape":"shape_string","content":"kb"},{"type":"ast","span":{"start":96,"end":97},"shape":"shape_pipe","content":"|"},{"type":"ast","span":{"start":98,"end":105},"shape":"shape_internalcall","content":"sort-by"},{"type":"ast","span":{"start":106,"end":110},"shape":"shape_string","content":"size"},{"type":"ast","span":{"start":111,"end":120},"shape":"shape_flag","content":"--reverse"}]
A e2e/testdata/nu/ide/highlight.nu +4 -0
M e2e/testdata/nu/nu +23 -0
A e2e/testdata/screens/notebook-nu-error-high-contrast.html +30 -0
@@ -0,0 +1,30 @@
1+<pre class="screen"><span style="background:#1f1f1f"> </span><span style="color:#ffffff;background:#1f1f1f;text-decoration:underline">F</span><span style="color:#ffffff;background:#1f1f1f">ile</span><span style="background:#1f1f1f"> </span><span style="color:#ffffff;background:#1f1f1f;text-decoration:underline">E</span><span style="color:#ffffff;background:#1f1f1f">dit</span><span style="background:#1f1f1f"> </span><span style="color:#ffffff;background:#1f1f1f;text-decoration:underline">V</span><span style="color:#ffffff;background:#1f1f1f">iew</span><span style="background:#1f1f1f"> </span><span style="color:#ffffff;background:#1f1f1f;text-decoration:underline">I</span><span style="color:#ffffff;background:#1f1f1f">nsert</span><span style="background:#1f1f1f"> </span><span style="color:#ffffff;background:#1f1f1f">F</span><span style="color:#ffffff;background:#1f1f1f;text-decoration:underline">o</span><span style="color:#ffffff;background:#1f1f1f">rmat</span><span style="background:#1f1f1f"> </span><span style="color:#ffffff;background:#1f1f1f;text-decoration:underline">D</span><span style="color:#ffffff;background:#1f1f1f">ata</span><span style="background:#1f1f1f"> </span><span style="color:#ffffff;background:#1f1f1f;text-decoration:underline">H</span><span style="color:#ffffff;background:#1f1f1f">elp</span><span style="background:#1f1f1f"> </span><span style="background:#40e0ff"> </span><span style="color:#000000;background:#40e0ff;font-weight:bold">EDIT</span><span style="background:#40e0ff"> </span>2+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">cell</span><span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">4</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span><span style="color:#959595;background:#000000">a</span><span style="background:#000000"> </span><span style="color:#959595;background:#000000">nushell</span><span style="background:#000000"> </span><span style="color:#959595;background:#000000">pipeline</span><span style="background:#000000"> </span>3+<span style="color:#ffe040;background:#000000">The</span><span style="background:#000000"> </span><span style="color:#ffe040;background:#000000">`sort-by`</span><span style="background:#000000"> </span><span style="color:#ffe040;background:#000000">command</span><span style="background:#000000"> </span><span style="color:#ffe040;background:#000000">doesn&#39;t</span><span style="background:#000000"> </span><span style="color:#ffe040;background:#000000">have</span><span style="background:#000000"> </span><span style="color:#ffe040;background:#000000">flag</span><span style="background:#000000"> </span><span style="color:#ffe040;background:#000000">`revrse`.</span><span style="background:#000000"> </span><span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">Shift+Enter</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span><span style="color:#959595;background:#000000">run</span><span style="background:#000000"> </span><span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">Esc</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span><span style="color:#959595;background:#000000">done</span>4+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">Notebook</span><span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">4</span><span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">cells</span><span style="background:#3a3a3a"> </span>5+<span style="background:#000000"> </span><span style="color:#959595;background:#000000">Markdown</span><span style="background:#000000"> </span>6+<span style="color:#757575;background:#000000">│</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000;font-weight:bold;text-decoration:underline">Files</span><span style="background:#000000"> </span>7+<span style="color:#757575;background:#000000">│</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">What&#39;s</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">big</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">in</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">the</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">repo,</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">from</span><span style="background:#000000"> </span><span style="color:#40e0ff;background:#000000">ls</span><span style="color:#ffffff;background:#000000">.</span><span style="background:#000000"> </span>8+<span style="background:#000000"> </span>9+<span style="background:#000000"> </span><span style="color:#959595;background:#000000">[1]</span><span style="background:#000000"> </span><span style="color:#959595;background:#000000">files</span><span style="background:#000000"> </span><span style="color:#959595;background:#000000">✓</span><span style="background:#000000"> </span><span style="color:#959595;background:#000000">&lt;1s</span><span style="background:#000000"> </span>10+<span style="color:#757575;background:#000000">│</span><span style="background:#000000"> </span><span style="color:#ff80ff;background:#000000">files</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000;font-weight:bold">=</span><span style="background:#000000"> </span><span style="color:#40e0ff;background:#000000">ls</span><span style="background:#000000"> </span>11+<span style="background:#000000"> </span><span style="color:#ffffff;background:#000000;font-weight:bold;text-decoration:underline">name</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000;font-weight:bold;text-decoration:underline">type</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000;font-weight:bold;text-decoration:underline">size</span><span style="background:#000000"> </span>12+<span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">012</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">file</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">4.2</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">kB</span><span style="background:#000000"> </span>13+<span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">CLAUDE.md</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">file</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">1.0</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">kB</span><span style="background:#000000"> </span>14+<span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">README.md</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">file</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">9.8</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">kB</span><span style="background:#000000"> </span>15+<span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">demos</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">dir</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">3.1</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">kB</span><span style="background:#000000"> </span>16+<span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">go.mod</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">file</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">812</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">B</span><span style="background:#000000"> </span>17+<span style="background:#000000"> </span>18+<span style="background:#000000"> </span><span style="color:#959595;background:#000000">[2]</span><span style="background:#000000"> </span><span style="color:#959595;background:#000000">✓</span><span style="background:#000000"> </span><span style="color:#959595;background:#000000">&lt;1s</span><span style="background:#000000"> </span>19+<span style="color:#757575;background:#000000">│</span><span style="background:#000000"> </span><span style="color:#ff80ff;background:#000000">$files</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000;font-weight:bold">|</span><span style="background:#000000"> </span><span style="color:#40e0ff;background:#000000">where</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">size</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000;font-weight:bold">&gt;</span><span style="background:#000000"> </span><span style="color:#ffe040;background:#000000">1kb</span><span style="background:#000000"> </span>20+<span style="background:#000000"> </span><span style="color:#ffffff;background:#000000;font-weight:bold;text-decoration:underline">name</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000;font-weight:bold;text-decoration:underline">type</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000;font-weight:bold;text-decoration:underline">size</span><span style="background:#000000"> </span>21+<span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">012</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">file</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">4.2</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">kB</span><span style="background:#000000"> </span>22+<span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">README.md</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">file</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">9.8</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">kB</span><span style="background:#000000"> </span>23+<span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">demos</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">dir</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">3.1</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">kB</span><span style="background:#000000"> </span>24+<span style="background:#000000"> </span>25+<span style="background:#40e0ff"> </span><span style="color:#000000;background:#40e0ff">[</span><span style="background:#40e0ff"> </span><span style="color:#000000;background:#40e0ff">]</span><span style="background:#40e0ff"> </span><span style="color:#000000;background:#40e0ff">not</span><span style="background:#40e0ff"> </span><span style="color:#000000;background:#40e0ff">run</span><span style="background:#40e0ff"> </span><span style="background:#000000"> </span>26+<span style="color:#757575;background:#000000">│</span><span style="background:#000000"> </span><span style="color:#ff80ff;background:#000000">$files</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000;font-weight:bold">|</span><span style="background:#000000"> </span><span style="color:#40e0ff;background:#000000">sort-by</span><span style="background:#000000"> </span><span style="color:#40e040;background:#000000">size</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000;text-decoration:underline wavy #ff8080">--revrse</span><span style="background:#000000"> </span>27+<span style="background:#000000"> </span>28+<span style="background:#000000"> </span>29+<span style="background:#000000"> </span>30+<span style="background:#40e0ff"> </span><span style="color:#000000;background:#40e0ff;font-weight:bold">❯</span><span style="background:#40e0ff"> </span><span style="color:#000000;background:#40e0ff;font-weight:bold">Notebook</span><span style="background:#40e0ff"> </span><span style="background:#1f1f1f"> </span><span style="color:#959595;background:#1f1f1f">+</span><span style="background:#1f1f1f"> </span><span style="color:#757575;background:#1f1f1f">│</span><span style="background:#1f1f1f"> </span><span style="color:#ffffff;background:#1f1f1f">untitled</span><span style="background:#1f1f1f"> </span><span style="color:#959595;background:#1f1f1f">modified</span><span style="background:#1f1f1f"> </span><span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">Ctrl+K</span><span style="background:#3a3a3a"> </span><span style="background:#1f1f1f"> </span><span style="color:#959595;background:#1f1f1f">search</span><span style="background:#1f1f1f"> </span><span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">Ctrl+/</span><span style="background:#3a3a3a"> </span><span style="background:#1f1f1f"> </span><span style="color:#959595;background:#1f1f1f">shortcuts</span><span style="background:#1f1f1f"> </span><span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">F10</span><span style="background:#3a3a3a"> </span><span style="background:#1f1f1f"> </span><span style="color:#959595;background:#1f1f1f">menu</span></pre>\ No newline at end of file
A e2e/testdata/screens/notebook-nu-error-light.html +30 -0
@@ -0,0 +1,30 @@
1+<pre class="screen"> <span style="text-decoration:underline">F</span>ile <span style="text-decoration:underline">E</span>dit <span style="text-decoration:underline">V</span>iew <span style="text-decoration:underline">I</span>nsert F<span style="text-decoration:underline">o</span>rmat <span style="text-decoration:underline">D</span>ata <span style="text-decoration:underline">H</span>elp <span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">EDIT</span><span style="background:var(--vt-palette-6)"> </span>2+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">cell</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">4</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-0)">a</span> <span style="color:var(--vt-palette-0)">nushell</span> <span style="color:var(--vt-palette-0)">pipeline</span>3+<span style="color:var(--vt-palette-3)">The</span> <span style="color:var(--vt-palette-3)">`sort-by`</span> <span style="color:var(--vt-palette-3)">command</span> <span style="color:var(--vt-palette-3)">doesn&#39;t</span> <span style="color:var(--vt-palette-3)">have</span> <span style="color:var(--vt-palette-3)">flag</span> <span style="color:var(--vt-palette-3)">`revrse`.</span> <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Shift+Enter</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-0)">run</span> <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Esc</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-0)">done</span>4+ Notebook 4 cells5+ <span style="color:var(--vt-palette-0)">Markdown</span>6+<span style="color:var(--vt-palette-8)">│</span> <span style="font-weight:bold;text-decoration:underline">Files</span>7+<span style="color:var(--vt-palette-8)">│</span> What&#39;s big in the repo, from <span style="color:var(--vt-palette-4)">ls</span>.8+9+ <span style="color:var(--vt-palette-0)">[1]</span> <span style="color:var(--vt-palette-0)">files</span> <span style="color:var(--vt-palette-0)">✓</span> <span style="color:var(--vt-palette-0)">&lt;1s</span>10+<span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-5)">files</span> <span style="font-weight:bold">=</span> <span style="color:var(--vt-palette-4)">ls</span>11+ <span style="font-weight:bold;text-decoration:underline">name</span> <span style="font-weight:bold;text-decoration:underline">type</span> <span style="font-weight:bold;text-decoration:underline">size</span>12+ 012 file 4.2 kB13+ CLAUDE.md file 1.0 kB14+ README.md file 9.8 kB15+ demos dir 3.1 kB16+ go.mod file 812 B17+18+ <span style="color:var(--vt-palette-0)">[2]</span> <span style="color:var(--vt-palette-0)">✓</span> <span style="color:var(--vt-palette-0)">&lt;1s</span>19+<span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-5)">$files</span> <span style="font-weight:bold">|</span> <span style="color:var(--vt-palette-4)">where</span> size <span style="font-weight:bold">&gt;</span> <span style="color:var(--vt-palette-1)">1kb</span>20+ <span style="font-weight:bold;text-decoration:underline">name</span> <span style="font-weight:bold;text-decoration:underline">type</span> <span style="font-weight:bold;text-decoration:underline">size</span>21+ 012 file 4.2 kB22+ README.md file 9.8 kB23+ demos dir 3.1 kB24+25+<span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">[</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">]</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">not</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">run</span><span style="background:var(--vt-palette-6)"> </span>26+<span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-5)">$files</span> <span style="font-weight:bold">|</span> <span style="color:var(--vt-palette-4)">sort-by</span> <span style="color:var(--vt-palette-2)">size</span> <span style="text-decoration:underline wavy var(--vt-palette-1)">--revrse</span>27+28+29+30+<span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">❯</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">Notebook</span><span style="background:var(--vt-palette-6)"> </span> <span style="color:var(--vt-palette-0)">+</span> <span style="color:var(--vt-palette-8)">│</span> untitled <span style="color:var(--vt-palette-0)">modified</span> <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Ctrl+K</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-0)">search</span> <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Ctrl+/</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-0)">shortcuts</span> <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">F10</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-0)">menu</span></pre>\ No newline at end of file
A e2e/testdata/screens/notebook-nu-error.html +30 -0
@@ -0,0 +1,30 @@
1+<pre class="screen"> <span style="text-decoration:underline">F</span>ile <span style="text-decoration:underline">E</span>dit <span style="text-decoration:underline">V</span>iew <span style="text-decoration:underline">I</span>nsert F<span style="text-decoration:underline">o</span>rmat <span style="text-decoration:underline">D</span>ata <span style="text-decoration:underline">H</span>elp <span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">EDIT</span><span style="background:var(--vt-palette-6)"> </span>2+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">cell</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">4</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">a</span> <span style="color:var(--vt-palette-8)">nushell</span> <span style="color:var(--vt-palette-8)">pipeline</span>3+<span style="color:var(--vt-palette-3)">The</span> <span style="color:var(--vt-palette-3)">`sort-by`</span> <span style="color:var(--vt-palette-3)">command</span> <span style="color:var(--vt-palette-3)">doesn&#39;t</span> <span style="color:var(--vt-palette-3)">have</span> <span style="color:var(--vt-palette-3)">flag</span> <span style="color:var(--vt-palette-3)">`revrse`.</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Shift+Enter</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">run</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Esc</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">done</span>4+ Notebook 4 cells5+ <span style="color:var(--vt-palette-8)">Markdown</span>6+<span style="color:var(--vt-palette-8)">│</span> <span style="font-weight:bold;text-decoration:underline">Files</span>7+<span style="color:var(--vt-palette-8)">│</span> What&#39;s big in the repo, from <span style="color:var(--vt-palette-6)">ls</span>.8+9+ <span style="color:var(--vt-palette-8)">[1]</span> <span style="color:var(--vt-palette-8)">files</span> <span style="color:var(--vt-palette-8)">✓</span> <span style="color:var(--vt-palette-8)">&lt;1s</span>10+<span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-5)">files</span> <span style="font-weight:bold">=</span> <span style="color:var(--vt-palette-6)">ls</span>11+ <span style="font-weight:bold;text-decoration:underline">name</span> <span style="font-weight:bold;text-decoration:underline">type</span> <span style="font-weight:bold;text-decoration:underline">size</span>12+ 012 file 4.2 kB13+ CLAUDE.md file 1.0 kB14+ README.md file 9.8 kB15+ demos dir 3.1 kB16+ go.mod file 812 B17+18+ <span style="color:var(--vt-palette-8)">[2]</span> <span style="color:var(--vt-palette-8)">✓</span> <span style="color:var(--vt-palette-8)">&lt;1s</span>19+<span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-5)">$files</span> <span style="font-weight:bold">|</span> <span style="color:var(--vt-palette-6)">where</span> size <span style="font-weight:bold">&gt;</span> <span style="color:var(--vt-palette-3)">1kb</span>20+ <span style="font-weight:bold;text-decoration:underline">name</span> <span style="font-weight:bold;text-decoration:underline">type</span> <span style="font-weight:bold;text-decoration:underline">size</span>21+ 012 file 4.2 kB22+ README.md file 9.8 kB23+ demos dir 3.1 kB24+25+<span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">[</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">]</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">not</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">run</span><span style="background:var(--vt-palette-6)"> </span>26+<span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-5)">$files</span> <span style="font-weight:bold">|</span> <span style="color:var(--vt-palette-6)">sort-by</span> <span style="color:var(--vt-palette-2)">size</span> <span style="text-decoration:underline wavy var(--vt-palette-1)">--revrse</span>27+28+29+30+<span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">❯</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">Notebook</span><span style="background:var(--vt-palette-6)"> </span> <span style="color:var(--vt-palette-8)">+</span> <span style="color:var(--vt-palette-8)">│</span> untitled <span style="color:var(--vt-palette-8)">modified</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+K</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">search</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+/</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">shortcuts</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">F10</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">menu</span></pre>\ No newline at end of file
A e2e/testdata/screens/notebook-nu-highlight-high-contrast.html +30 -0
@@ -0,0 +1,30 @@
1+<pre class="screen"><span style="background:#1f1f1f"> </span><span style="color:#ffffff;background:#1f1f1f;text-decoration:underline">F</span><span style="color:#ffffff;background:#1f1f1f">ile</span><span style="background:#1f1f1f"> </span><span style="color:#ffffff;background:#1f1f1f;text-decoration:underline">E</span><span style="color:#ffffff;background:#1f1f1f">dit</span><span style="background:#1f1f1f"> </span><span style="color:#ffffff;background:#1f1f1f;text-decoration:underline">V</span><span style="color:#ffffff;background:#1f1f1f">iew</span><span style="background:#1f1f1f"> </span><span style="color:#ffffff;background:#1f1f1f;text-decoration:underline">I</span><span style="color:#ffffff;background:#1f1f1f">nsert</span><span style="background:#1f1f1f"> </span><span style="color:#ffffff;background:#1f1f1f">F</span><span style="color:#ffffff;background:#1f1f1f;text-decoration:underline">o</span><span style="color:#ffffff;background:#1f1f1f">rmat</span><span style="background:#1f1f1f"> </span><span style="color:#ffffff;background:#1f1f1f;text-decoration:underline">D</span><span style="color:#ffffff;background:#1f1f1f">ata</span><span style="background:#1f1f1f"> </span><span style="color:#ffffff;background:#1f1f1f;text-decoration:underline">H</span><span style="color:#ffffff;background:#1f1f1f">elp</span><span style="background:#1f1f1f"> </span><span style="background:#40e0ff"> </span><span style="color:#000000;background:#40e0ff;font-weight:bold">EDIT</span><span style="background:#40e0ff"> </span>2+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">cell</span><span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">4</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span><span style="color:#959595;background:#000000">a</span><span style="background:#000000"> </span><span style="color:#959595;background:#000000">nushell</span><span style="background:#000000"> </span><span style="color:#959595;background:#000000">pipeline</span><span style="background:#000000"> </span>3+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">Shift+Enter</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span><span style="color:#959595;background:#000000">run</span><span style="background:#000000"> </span><span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">Ctrl+Enter</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span><span style="color:#959595;background:#000000">run</span><span style="background:#000000"> </span><span style="color:#959595;background:#000000">here</span><span style="background:#000000"> </span><span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">Tab</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span><span style="color:#959595;background:#000000">complete</span><span style="background:#000000"> </span><span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">Esc</span><span style="background:#3a3a3a"> </span><span style="background:#000000"> </span><span style="color:#959595;background:#000000">done</span>4+<span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">Notebook</span><span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">4</span><span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">cells</span><span style="background:#3a3a3a"> </span>5+<span style="background:#000000"> </span><span style="color:#959595;background:#000000">Markdown</span><span style="background:#000000"> </span>6+<span style="color:#757575;background:#000000">│</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000;font-weight:bold;text-decoration:underline">Files</span><span style="background:#000000"> </span>7+<span style="color:#757575;background:#000000">│</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">What&#39;s</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">big</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">in</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">the</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">repo,</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">from</span><span style="background:#000000"> </span><span style="color:#40e0ff;background:#000000">ls</span><span style="color:#ffffff;background:#000000">.</span><span style="background:#000000"> </span>8+<span style="background:#000000"> </span>9+<span style="background:#000000"> </span><span style="color:#959595;background:#000000">[1]</span><span style="background:#000000"> </span><span style="color:#959595;background:#000000">files</span><span style="background:#000000"> </span><span style="color:#959595;background:#000000">✓</span><span style="background:#000000"> </span><span style="color:#959595;background:#000000">&lt;1s</span><span style="background:#000000"> </span>10+<span style="color:#757575;background:#000000">│</span><span style="background:#000000"> </span><span style="color:#ff80ff;background:#000000">files</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000;font-weight:bold">=</span><span style="background:#000000"> </span><span style="color:#40e0ff;background:#000000">ls</span><span style="background:#000000"> </span>11+<span style="background:#000000"> </span><span style="color:#ffffff;background:#000000;font-weight:bold;text-decoration:underline">name</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000;font-weight:bold;text-decoration:underline">type</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000;font-weight:bold;text-decoration:underline">size</span><span style="background:#000000"> </span>12+<span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">012</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">file</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">4.2</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">kB</span><span style="background:#000000"> </span>13+<span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">CLAUDE.md</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">file</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">1.0</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">kB</span><span style="background:#000000"> </span>14+<span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">README.md</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">file</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">9.8</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">kB</span><span style="background:#000000"> </span>15+<span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">demos</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">dir</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">3.1</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">kB</span><span style="background:#000000"> </span>16+<span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">go.mod</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">file</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">812</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">B</span><span style="background:#000000"> </span>17+<span style="background:#000000"> </span>18+<span style="background:#000000"> </span><span style="color:#959595;background:#000000">[2]</span><span style="background:#000000"> </span><span style="color:#959595;background:#000000">✓</span><span style="background:#000000"> </span><span style="color:#959595;background:#000000">&lt;1s</span><span style="background:#000000"> </span>19+<span style="color:#757575;background:#000000">│</span><span style="background:#000000"> </span><span style="color:#ff80ff;background:#000000">$files</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000;font-weight:bold">|</span><span style="background:#000000"> </span><span style="color:#40e0ff;background:#000000">where</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">size</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000;font-weight:bold">&gt;</span><span style="background:#000000"> </span><span style="color:#ffe040;background:#000000">1kb</span><span style="background:#000000"> </span>20+<span style="background:#000000"> </span><span style="color:#ffffff;background:#000000;font-weight:bold;text-decoration:underline">name</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000;font-weight:bold;text-decoration:underline">type</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000;font-weight:bold;text-decoration:underline">size</span><span style="background:#000000"> </span>21+<span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">012</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">file</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">4.2</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">kB</span><span style="background:#000000"> </span>22+<span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">README.md</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">file</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">9.8</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">kB</span><span style="background:#000000"> </span>23+<span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">demos</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">dir</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">3.1</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">kB</span><span style="background:#000000"> </span>24+<span style="background:#000000"> </span>25+<span style="background:#40e0ff"> </span><span style="color:#000000;background:#40e0ff">[</span><span style="background:#40e0ff"> </span><span style="color:#000000;background:#40e0ff">]</span><span style="background:#40e0ff"> </span><span style="color:#000000;background:#40e0ff">not</span><span style="background:#40e0ff"> </span><span style="color:#000000;background:#40e0ff">run</span><span style="background:#40e0ff"> </span><span style="background:#000000"> </span>26+<span style="color:#757575;background:#000000">│</span><span style="background:#000000"> </span><span style="color:#ff80ff;background:#000000">big</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000;font-weight:bold">=</span><span style="background:#000000"> </span><span style="color:#ff80ff;background:#000000">$files</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000;font-weight:bold">|</span><span style="background:#000000"> </span><span style="color:#40e0ff;background:#000000">where</span><span style="background:#000000"> </span><span style="color:#ff80ff;background:#000000">size</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000;font-weight:bold">&gt;</span><span style="background:#000000"> </span><span style="color:#ffe040;background:#000000">1kb</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000;font-weight:bold">|</span><span style="background:#000000"> </span><span style="color:#40e0ff;background:#000000">sort-by</span><span style="background:#000000"> </span><span style="color:#40e040;background:#000000">size</span><span style="background:#000000"> </span><span style="color:#ffffff;background:#000000">--reverse</span><span style="background:#000000"> </span><span style="color:#959595;background:#000000;font-style:italic">#</span><span style="background:#000000"> </span><span style="color:#959595;background:#000000;font-style:italic">largest</span><span style="background:#000000"> </span>27+<span style="background:#000000"> </span>28+<span style="background:#000000"> </span>29+<span style="background:#000000"> </span>30+<span style="background:#40e0ff"> </span><span style="color:#000000;background:#40e0ff;font-weight:bold">❯</span><span style="background:#40e0ff"> </span><span style="color:#000000;background:#40e0ff;font-weight:bold">Notebook</span><span style="background:#40e0ff"> </span><span style="background:#1f1f1f"> </span><span style="color:#959595;background:#1f1f1f">+</span><span style="background:#1f1f1f"> </span><span style="color:#757575;background:#1f1f1f">│</span><span style="background:#1f1f1f"> </span><span style="color:#ffffff;background:#1f1f1f">untitled</span><span style="background:#1f1f1f"> </span><span style="color:#959595;background:#1f1f1f">modified</span><span style="background:#1f1f1f"> </span><span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">Ctrl+K</span><span style="background:#3a3a3a"> </span><span style="background:#1f1f1f"> </span><span style="color:#959595;background:#1f1f1f">search</span><span style="background:#1f1f1f"> </span><span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">Ctrl+/</span><span style="background:#3a3a3a"> </span><span style="background:#1f1f1f"> </span><span style="color:#959595;background:#1f1f1f">shortcuts</span><span style="background:#1f1f1f"> </span><span style="background:#3a3a3a"> </span><span style="color:#ffffff;background:#3a3a3a">F10</span><span style="background:#3a3a3a"> </span><span style="background:#1f1f1f"> </span><span style="color:#959595;background:#1f1f1f">menu</span></pre>\ No newline at end of file
A e2e/testdata/screens/notebook-nu-highlight-light.html +30 -0
@@ -0,0 +1,30 @@
1+<pre class="screen"> <span style="text-decoration:underline">F</span>ile <span style="text-decoration:underline">E</span>dit <span style="text-decoration:underline">V</span>iew <span style="text-decoration:underline">I</span>nsert F<span style="text-decoration:underline">o</span>rmat <span style="text-decoration:underline">D</span>ata <span style="text-decoration:underline">H</span>elp <span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">EDIT</span><span style="background:var(--vt-palette-6)"> </span>2+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">cell</span><span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">4</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-0)">a</span> <span style="color:var(--vt-palette-0)">nushell</span> <span style="color:var(--vt-palette-0)">pipeline</span>3+<span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Shift+Enter</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-0)">run</span> <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Ctrl+Enter</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-0)">run</span> <span style="color:var(--vt-palette-0)">here</span> <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Tab</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-0)">complete</span> <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Esc</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-0)">done</span>4+ Notebook 4 cells5+ <span style="color:var(--vt-palette-0)">Markdown</span>6+<span style="color:var(--vt-palette-8)">│</span> <span style="font-weight:bold;text-decoration:underline">Files</span>7+<span style="color:var(--vt-palette-8)">│</span> What&#39;s big in the repo, from <span style="color:var(--vt-palette-4)">ls</span>.8+9+ <span style="color:var(--vt-palette-0)">[1]</span> <span style="color:var(--vt-palette-0)">files</span> <span style="color:var(--vt-palette-0)">✓</span> <span style="color:var(--vt-palette-0)">&lt;1s</span>10+<span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-5)">files</span> <span style="font-weight:bold">=</span> <span style="color:var(--vt-palette-4)">ls</span>11+ <span style="font-weight:bold;text-decoration:underline">name</span> <span style="font-weight:bold;text-decoration:underline">type</span> <span style="font-weight:bold;text-decoration:underline">size</span>12+ 012 file 4.2 kB13+ CLAUDE.md file 1.0 kB14+ README.md file 9.8 kB15+ demos dir 3.1 kB16+ go.mod file 812 B17+18+ <span style="color:var(--vt-palette-0)">[2]</span> <span style="color:var(--vt-palette-0)">✓</span> <span style="color:var(--vt-palette-0)">&lt;1s</span>19+<span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-5)">$files</span> <span style="font-weight:bold">|</span> <span style="color:var(--vt-palette-4)">where</span> size <span style="font-weight:bold">&gt;</span> <span style="color:var(--vt-palette-1)">1kb</span>20+ <span style="font-weight:bold;text-decoration:underline">name</span> <span style="font-weight:bold;text-decoration:underline">type</span> <span style="font-weight:bold;text-decoration:underline">size</span>21+ 012 file 4.2 kB22+ README.md file 9.8 kB23+ demos dir 3.1 kB24+25+<span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">[</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">]</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">not</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">run</span><span style="background:var(--vt-palette-6)"> </span>26+<span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-5)">big</span> <span style="font-weight:bold">=</span> <span style="color:var(--vt-palette-5)">$files</span> <span style="font-weight:bold">|</span> <span style="color:var(--vt-palette-4)">where</span> <span style="color:var(--vt-palette-5)">size</span> <span style="font-weight:bold">&gt;</span> <span style="color:var(--vt-palette-1)">1kb</span> <span style="font-weight:bold">|</span> <span style="color:var(--vt-palette-4)">sort-by</span> <span style="color:var(--vt-palette-2)">size</span> --reverse <span style="color:var(--vt-palette-0);font-style:italic">#</span> <span style="color:var(--vt-palette-0);font-style:italic">largest</span>27+28+29+30+<span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">❯</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">Notebook</span><span style="background:var(--vt-palette-6)"> </span> <span style="color:var(--vt-palette-0)">+</span> <span style="color:var(--vt-palette-8)">│</span> untitled <span style="color:var(--vt-palette-0)">modified</span> <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Ctrl+K</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-0)">search</span> <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">Ctrl+/</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-0)">shortcuts</span> <span style="background:var(--vt-palette-7)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-7)">F10</span><span style="background:var(--vt-palette-7)"> </span> <span style="color:var(--vt-palette-0)">menu</span></pre>\ No newline at end of file
A e2e/testdata/screens/notebook-nu-highlight.html +30 -0
@@ -0,0 +1,30 @@
1+<pre class="screen"> <span style="text-decoration:underline">F</span>ile <span style="text-decoration:underline">E</span>dit <span style="text-decoration:underline">V</span>iew <span style="text-decoration:underline">I</span>nsert F<span style="text-decoration:underline">o</span>rmat <span style="text-decoration:underline">D</span>ata <span style="text-decoration:underline">H</span>elp <span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">EDIT</span><span style="background:var(--vt-palette-6)"> </span>2+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">cell</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">4</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">a</span> <span style="color:var(--vt-palette-8)">nushell</span> <span style="color:var(--vt-palette-8)">pipeline</span>3+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Shift+Enter</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">run</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+Enter</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">run</span> <span style="color:var(--vt-palette-8)">here</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Tab</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">complete</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Esc</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">done</span>4+ Notebook 4 cells5+ <span style="color:var(--vt-palette-8)">Markdown</span>6+<span style="color:var(--vt-palette-8)">│</span> <span style="font-weight:bold;text-decoration:underline">Files</span>7+<span style="color:var(--vt-palette-8)">│</span> What&#39;s big in the repo, from <span style="color:var(--vt-palette-6)">ls</span>.8+9+ <span style="color:var(--vt-palette-8)">[1]</span> <span style="color:var(--vt-palette-8)">files</span> <span style="color:var(--vt-palette-8)">✓</span> <span style="color:var(--vt-palette-8)">&lt;1s</span>10+<span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-5)">files</span> <span style="font-weight:bold">=</span> <span style="color:var(--vt-palette-6)">ls</span>11+ <span style="font-weight:bold;text-decoration:underline">name</span> <span style="font-weight:bold;text-decoration:underline">type</span> <span style="font-weight:bold;text-decoration:underline">size</span>12+ 012 file 4.2 kB13+ CLAUDE.md file 1.0 kB14+ README.md file 9.8 kB15+ demos dir 3.1 kB16+ go.mod file 812 B17+18+ <span style="color:var(--vt-palette-8)">[2]</span> <span style="color:var(--vt-palette-8)">✓</span> <span style="color:var(--vt-palette-8)">&lt;1s</span>19+<span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-5)">$files</span> <span style="font-weight:bold">|</span> <span style="color:var(--vt-palette-6)">where</span> size <span style="font-weight:bold">&gt;</span> <span style="color:var(--vt-palette-3)">1kb</span>20+ <span style="font-weight:bold;text-decoration:underline">name</span> <span style="font-weight:bold;text-decoration:underline">type</span> <span style="font-weight:bold;text-decoration:underline">size</span>21+ 012 file 4.2 kB22+ README.md file 9.8 kB23+ demos dir 3.1 kB24+25+<span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">[</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">]</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">not</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">run</span><span style="background:var(--vt-palette-6)"> </span>26+<span style="color:var(--vt-palette-8)">│</span> <span style="color:var(--vt-palette-5)">big</span> <span style="font-weight:bold">=</span> <span style="color:var(--vt-palette-5)">$files</span> <span style="font-weight:bold">|</span> <span style="color:var(--vt-palette-6)">where</span> <span style="color:var(--vt-palette-5)">size</span> <span style="font-weight:bold">&gt;</span> <span style="color:var(--vt-palette-3)">1kb</span> <span style="font-weight:bold">|</span> <span style="color:var(--vt-palette-6)">sort-by</span> <span style="color:var(--vt-palette-2)">size</span> --reverse <span style="color:var(--vt-palette-8);font-style:italic">#</span> <span style="color:var(--vt-palette-8);font-style:italic">largest</span>27+28+29+30+<span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">❯</span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">Notebook</span><span style="background:var(--vt-palette-6)"> </span> <span style="color:var(--vt-palette-8)">+</span> <span style="color:var(--vt-palette-8)">│</span> untitled <span style="color:var(--vt-palette-8)">modified</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+K</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">search</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+/</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">shortcuts</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">F10</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">menu</span></pre>\ No newline at end of file
A internal/diff/cells.go +285 -0
@@ -0,0 +1,285 @@
1+package diff2+3+import (4+ "encoding/json"5+ "maps"6+ "slices"7+ "strconv"8+ "strings"9+10+ "github.com/FelineStateMachine/012/internal/fileio"11+ "github.com/FelineStateMachine/012/internal/formula"12+ "github.com/FelineStateMachine/012/internal/functions"13+ "github.com/FelineStateMachine/012/internal/nuon"14+ "github.com/FelineStateMachine/012/internal/sheet"15+)16+17+// cellParts are the parts of a cell that change separately: what was18+// typed, its note, and its formatting (every other field of the cell19+// in the file), which merging keeps apart too.20+type cellParts struct {21+ input, note string22+ format json.RawMessage // a compact object, or nil for none23+}24+25+// splitCell reads a cell as the file stores it: its input alone, or an26+// object with the input, the note and the formatting.27+func splitCell(raw json.RawMessage) cellParts {28+ var p cellParts29+ if raw == nil || json.Unmarshal(raw, &p.input) == nil {30+ return p31+ }32+ var fields map[string]json.RawMessage33+ if json.Unmarshal(raw, &fields) != nil {34+ return p35+ }36+ json.Unmarshal(fields["input"], &p.input)37+ json.Unmarshal(fields["note"], &p.note)38+ delete(fields, "input")39+ delete(fields, "note")40+ if len(fields) > 0 {41+ p.format, _ = json.Marshal(fields) // keys sorted42+ }43+ return p44+}45+46+// joinCell is the inverse of splitCell; nil for a cell with nothing.47+func joinCell(p cellParts) json.RawMessage {48+ if p.format == nil && p.note == "" {49+ if p.input == "" {50+ return nil51+ }52+ raw, _ := json.Marshal(p.input)53+ return raw54+ }55+ fields := map[string]json.RawMessage{}56+ json.Unmarshal(p.format, &fields)57+ if p.input != "" {58+ fields["input"], _ = json.Marshal(p.input)59+ }60+ if p.note != "" {61+ fields["note"], _ = json.Marshal(p.note)62+ }63+ raw, _ := json.Marshal(fields)64+ return raw65+}66+67+// formatText describes a cell's formatting in words from its fields:68+// currency, decimals 2, bold.69+func formatText(raw json.RawMessage) string {70+ if raw == nil {71+ return ""72+ }73+ var fields map[string]json.RawMessage74+ if json.Unmarshal(raw, &fields) != nil {75+ return string(raw)76+ }77+ var parts []string78+ for _, k := range slices.Sorted(maps.Keys(fields)) {79+ v := string(fields[k])80+ var s string81+ switch {82+ case v == "true":83+ parts = append(parts, k)84+ case k == "format" && json.Unmarshal(fields[k], &s) == nil:85+ parts = append([]string{s}, parts...)86+ case json.Unmarshal(fields[k], &s) == nil:87+ parts = append(parts, k+" "+s)88+ default:89+ parts = append(parts, k+" "+v)90+ }91+ }92+ return strings.Join(parts, ", ")93+}94+95+// sheetDiff compares a sheet's cells, regions and other fields; name is96+// what the changes call the sheet.97+func sheetDiff(a, b side, name string) []Change {98+ out := cellChanges(a, b, name)99+ out = append(out, notebookChanges(name, field(a.raw, "notebookCells"), field(b.raw, "notebookCells"))...)100+ out = append(out, listChanges(KindRegion, name, field(a.raw, "regions"), field(b.raw, "regions"))...)101+ keys := map[string]bool{}102+ for _, s := range []*rawSheet{a.raw, b.raw} {103+ for k := range s.fields {104+ keys[k] = true105+ }106+ }107+ for _, k := range slices.Sorted(maps.Keys(keys)) {108+ if k != "regions" && k != "notebookCells" {109+ out = append(out, fieldChanges(KindLayout, name, k, a.raw.fields[k], b.raw.fields[k])...)110+ }111+ }112+ return out113+}114+115+func field(s *rawSheet, k string) json.RawMessage { return s.fields[k] }116+117+// cellChanges compares each cell either side has contents, formatting118+// or a value in, row by row.119+func cellChanges(a, b side, name string) []Change {120+ var out []Change121+ for _, at := range cellAddrs(a, b) {122+ out = append(out, cellChange(a, b, name, at)...)123+ }124+ return out125+}126+127+// cellAddrs are the cells either side has something in, in row order.128+func cellAddrs(a, b side) []sheet.Addr {129+ addrs := map[sheet.Addr]bool{}130+ for _, sd := range []side{a, b} {131+ for k := range sd.raw.cells {132+ if at, ok := sheet.ParseAddr(k); ok {133+ addrs[at] = true134+ }135+ }136+ if sd.s != nil {137+ for _, at := range sd.s.Addrs() {138+ addrs[at] = true139+ }140+ }141+ }142+ return slices.SortedFunc(maps.Keys(addrs), byRow)143+}144+145+func byRow(x, y sheet.Addr) int {146+ if x.Row != y.Row {147+ return x.Row - y.Row148+ }149+ return x.Col - y.Col150+}151+152+// cellChange compares one cell's input, value, format and note.153+func cellChange(a, b side, name string, at sheet.Addr) []Change {154+ key := at.String()155+ pa, pb := splitCell(a.raw.cells[key]), splitCell(b.raw.cells[key])156+ item := func(field, old, new string) Change { return textChange(KindCell, name, key, field, old, new) }157+ var out []Change158+ if pa.input != pb.input {159+ out = append(out, item("input", pa.input, pb.input))160+ }161+ if showsValue(pa, pb) && !a.vol[at] && !b.vol[at] {162+ if c, ok := valueChange(a.s, b.s, at); ok {163+ c.Sheet, c.Item = name, key164+ out = append(out, c)165+ }166+ }167+ if !equal(pa.format, pb.format) {168+ c := item("format", formatText(pa.format), formatText(pb.format))169+ c.Old, c.New = recordValue(pa.format), recordValue(pb.format)170+ out = append(out, c)171+ }172+ if pa.note != pb.note {173+ out = append(out, item("note", pa.note, pb.note))174+ }175+ return out176+}177+178+// showsValue reports whether a cell's value says more than its input:179+// it's a formula on either side, or it has no input on either (a value180+// an array spilled or a region's table).181+func showsValue(a, b cellParts) bool {182+ return sheet.IsFormulaEntry(a.input) || sheet.IsFormulaEntry(b.input) || a.input == "" && b.input == ""183+}184+185+// valueChange compares the values the two sheets compute at a.186+func valueChange(a, b *sheet.Sheet, at sheet.Addr) (Change, bool) {187+ oldV, oldT := cellValue(a, at)188+ newV, newT := cellValue(b, at)189+ if oldT == newT && nuon.Equal(oldV, newV) {190+ return Change{}, false191+ }192+ return Change{Kind: KindCell, Field: "value", Old: oldV, New: newV, oldText: oldT, newText: newT}, true193+}194+195+// cellValue is a cell's value typed by its format, and as it shows.196+func cellValue(s *sheet.Sheet, at sheet.Addr) (nuon.Value, string) {197+ if s == nil || s.Value(at).Kind == sheet.Empty {198+ return nuon.NullValue(), ""199+ }200+ return fileio.CellValue(fileio.SnapCell{Value: s.Value(at), Format: s.DisplayFormat(at)}), s.LocalText(at)201+}202+203+// recordValue is a JSON object as a NUON record, or null.204+func recordValue(raw json.RawMessage) nuon.Value {205+ if raw == nil {206+ return nuon.NullValue()207+ }208+ v, err := nuon.Parse(raw)209+ if err != nil {210+ return nuon.StringValue(string(raw))211+ }212+ return v213+}214+215+// volatiles are the cells of w whose values change on every216+// recalculation, which comparing would report as changed each time:217+// formulas calling a volatile function (NOW, RAND), what reads them,218+// directly or not, on any sheet, and the cells their arrays spill into.219+func volatiles(w *sheet.Workbook) map[*sheet.Sheet]map[sheet.Addr]bool {220+ out := map[*sheet.Sheet]map[sheet.Addr]bool{}221+ if w == nil {222+ return out223+ }224+ var queue []sheet.Target225+ mark := func(s *sheet.Sheet, at sheet.Addr) {226+ if out[s] == nil {227+ out[s] = map[sheet.Addr]bool{}228+ }229+ if !out[s][at] {230+ out[s][at] = true231+ queue = append(queue, sheet.Target{Sheet: s, Range: sheet.Rect{From: at, To: at}})232+ }233+ }234+ for _, s := range w.Sheets() {235+ for _, at := range s.Addrs() {236+ if c := s.Cell(at); c != nil && c.IsFormula() && callsVolatile(c.Input) {237+ mark(s, at)238+ }239+ }240+ }241+ for len(queue) > 0 {242+ t := queue[0]243+ queue = queue[1:]244+ if area, ok := t.Sheet.SpillArea(t.Range.From); ok {245+ for row := area.From.Row; row <= area.To.Row; row++ {246+ for col := area.From.Col; col <= area.To.Col; col++ {247+ mark(t.Sheet, sheet.Addr{Col: col, Row: row})248+ }249+ }250+ }251+ for _, d := range t.Sheet.Dependents(t.Range.From) {252+ mark(d.Sheet, d.Range.From)253+ }254+ }255+ return out256+}257+258+// callsVolatile reports whether a formula calls a volatile function.259+func callsVolatile(input string) bool {260+ n, err := sheet.Parse(input)261+ if err != nil {262+ return false263+ }264+ found := false265+ var walk func(formula.Node)266+ walk = func(n formula.Node) {267+ if call, ok := n.(formula.Call); ok {268+ if f := functions.Of(call); f != nil && f.Volatile {269+ found = true270+ }271+ }272+ formula.EachChild(n, walk)273+ }274+ walk(n)275+ return found276+}277+278+// quoted writes text as the text output shows a side: as it is, or279+// quoted when spaces at its ends or line breaks would hide.280+func quoted(s string) string {281+ if s != strings.TrimSpace(s) || strings.ContainsAny(s, "\n\t") {282+ return strconv.Quote(s)283+ }284+ return s285+}
A internal/diff/diff.go +203 -0
@@ -0,0 +1,203 @@
1+// Package diff compares and merges .012 workbooks cell by cell, for2+// 012 diff and 012 merge-driver (see docs/files/git.md). Compare lists3+// what changed between two workbooks: sheets added, removed, renamed or4+// moved, each cell's input, value, format and note, notebook cells,5+// regions, named ranges, macros and the other fields of sheets and6+// workbooks. Merge7+// combines two workbooks changed from a common one, and conflicts only8+// where both changed the same part of the same cell or field.9+//10+// Both read files as their JSON fields (raw.go), so nothing a file holds11+// goes unseen, and use the engine only to compute formulas' values,12+// which the file doesn't store. Nothing here runs a notebook cell or13+// asks JEV: values come from what the formulas compute alone.14+package diff15+16+import (17+ "bytes"18+ "fmt"19+20+ "github.com/FelineStateMachine/012/internal/nuon"21+ "github.com/FelineStateMachine/012/internal/sheet"22+)23+24+// Book is a workbook read for comparing: its file's fields and the25+// workbook they make.26+type Book struct {27+ raw *rawBook28+ wb *sheet.Workbook // nil for a workbook without sheets29+ vol map[*sheet.Sheet]map[sheet.Addr]bool30+}31+32+// Read reads a workbook file's contents. Empty contents are a workbook33+// without sheets, which is what git hands over for a file that doesn't34+// exist on one side.35+func Read(data []byte) (*Book, error) {36+ raw, err := parseRaw(data)37+ if err != nil {38+ return nil, err39+ }40+ b := &Book{raw: raw}41+ if len(raw.sheets) > 0 {42+ if b.wb, err = sheet.ReadBook(bytes.NewReader(data)); err != nil {43+ return nil, err44+ }45+ b.vol = volatiles(b.wb)46+ }47+ return b, nil48+}49+50+// Kinds of Change.51+const (52+ KindSheet = "sheet" // a sheet added, removed, renamed or moved53+ KindCell = "cell" // a cell's input, value, format or note54+ KindRegion = "region" // a notebook region or linked file55+ KindLayout = "layout" // another field of a sheet: widths, rules, charts56+ KindName = "name" // a named range57+ KindMacro = "macro" // a macro58+ KindWorkbook = "workbook" // a field of the workbook: locale, arithmetic59+)60+61+// Change is one difference. Old or New is null where the thing isn't on62+// that side.63+type Change struct {64+ Kind string65+ Sheet string // the sheet, by its name in the second workbook where it has one66+ Item string // the cell, region, name, macro or field that changed67+ // Field is what about it changed: for a cell input, value, format or68+ // note; for a sheet added, removed, renamed or moved; for a region,69+ // name or macro added, removed or its field's name; for a layout70+ // field, its key when it has keys (a column's width), else "".71+ Field string72+ Old, New nuon.Value73+ // oldText and newText are Old and New as the text output shows them.74+ oldText, newText string75+}76+77+// Where names the change's place as the text output does: Q3!B7,78+// sheet Q3, Q3 region r1, Q3 widths, name Sales.79+func (c Change) Where() string {80+ switch c.Kind {81+ case KindCell:82+ return sheet.QuoteSheet(c.Sheet) + "!" + c.Item83+ case KindSheet:84+ return "sheet " + c.Sheet85+ case KindRegion:86+ return c.Sheet + " region " + c.Item87+ case KindLayout, KindNotebook:88+ return c.Sheet + " " + c.Item89+ }90+ return c.Kind + " " + c.Item91+}92+93+// Compare lists what changed from a to b: sheets first, then each94+// sheet's cells in row order, a notebook tab's cells, its regions and95+// other fields, then the workbook's names, macros and settings.96+func Compare(a, b *Book) []Change {97+ var out []Change98+ p := matchSheets(a.raw, b.raw)99+ out = append(out, sheetChanges(a.raw, b.raw, p)...)100+ empty := &rawSheet{fields: nil, cells: nil}101+ for _, i := range p.onlyA {102+ sa := a.raw.sheets[i]103+ out = append(out, sheetDiff(a.side(sa), side{raw: empty}, sa.name)...)104+ }105+ for _, pr := range p.pairs {106+ sa, sb := a.raw.sheets[pr[0]], b.raw.sheets[pr[1]]107+ out = append(out, sheetDiff(a.side(sa), b.side(sb), sb.name)...)108+ }109+ for _, j := range p.onlyB {110+ sb := b.raw.sheets[j]111+ out = append(out, sheetDiff(side{raw: empty}, b.side(sb), sb.name)...)112+ }113+ return append(out, bookChanges(a.raw, b.raw)...)114+}115+116+// side is one sheet of a comparison: its fields, and the sheet the117+// engine made of them, nil for a sheet that isn't there.118+type side struct {119+ raw *rawSheet120+ s *sheet.Sheet121+ vol map[sheet.Addr]bool // its cells whose values change on every recalculation122+}123+124+func (b *Book) side(s *rawSheet) side {125+ sd := side{raw: s}126+ if b.wb != nil {127+ sd.s = b.wb.Lookup(s.name)128+ sd.vol = b.vol[sd.s]129+ }130+ return sd131+}132+133+// sheetChanges are the sheets added, removed, renamed and moved.134+func sheetChanges(a, b *rawBook, p pairing) []Change {135+ var out []Change136+ for _, i := range p.onlyA {137+ name := a.sheets[i].name138+ out = append(out, textChange(KindSheet, name, "", "removed", name, ""))139+ }140+ for _, j := range p.onlyB {141+ name := b.sheets[j].name142+ out = append(out, textChange(KindSheet, name, "", "added", "", name))143+ }144+ for _, pr := range p.pairs {145+ na, nb := a.sheets[pr[0]].name, b.sheets[pr[1]].name146+ if na != nb {147+ out = append(out, textChange(KindSheet, nb, "", "renamed", na, nb))148+ }149+ }150+ // Moves: the fewest sheets that, taken out, leave the rest in the151+ // same order on both sides.152+ var orderB []int153+ for _, pr := range p.pairs {154+ orderB = append(orderB, pr[1])155+ }156+ stay := increasing(orderB)157+ for k, pr := range p.pairs {158+ if !stay[k] {159+ nb := b.sheets[pr[1]].name160+ c := textChange(KindSheet, nb, "", "moved", fmt.Sprint(pr[0]+1), fmt.Sprint(pr[1]+1))161+ c.Old, c.New = nuon.IntValue(int64(pr[0]+1)), nuon.IntValue(int64(pr[1]+1))162+ out = append(out, c)163+ }164+ }165+ return out166+}167+168+// increasing marks a longest increasing run (not necessarily adjacent)169+// of vs.170+func increasing(vs []int) []bool {171+ n := len(vs)172+ length, prev := make([]int, n), make([]int, n)173+ best := -1174+ for i := range vs {175+ length[i], prev[i] = 1, -1176+ for j := range i {177+ if vs[j] < vs[i] && length[j]+1 > length[i] {178+ length[i], prev[i] = length[j]+1, j179+ }180+ }181+ if best < 0 || length[i] > length[best] {182+ best = i183+ }184+ }185+ out := make([]bool, n)186+ for i := best; i >= 0; i = prev[i] {187+ out[i] = true188+ }189+ return out190+}191+192+// textChange is a change whose sides are text, "" for none.193+func textChange(kind, sheetName, item, field, old, new string) Change {194+ return Change{Kind: kind, Sheet: sheetName, Item: item, Field: field,195+ Old: textValue(old), New: textValue(new), oldText: old, newText: new}196+}197+198+func textValue(s string) nuon.Value {199+ if s == "" {200+ return nuon.NullValue()201+ }202+ return nuon.StringValue(s)203+}
A internal/diff/diff_test.go +216 -0
@@ -0,0 +1,216 @@
1+package diff2+3+import (4+ "bytes"5+ "strings"6+ "testing"7+8+ "github.com/FelineStateMachine/012/internal/nuon"9+ "github.com/FelineStateMachine/012/internal/sheet"10+)11+12+func addr(s string) sheet.Addr {13+ a, _ := sheet.ParseAddr(s)14+ return a15+}16+17+// file writes a workbook of sheets, each a map of cells to inputs, after18+// edit has its way with it.19+func file(t *testing.T, sheets []string, cells []map[string]string, edit func(w *sheet.Workbook)) []byte {20+ t.Helper()21+ w := sheet.NewBook()22+ for i, name := range sheets {23+ s := w.Sheet(0)24+ if i == 0 {25+ if err := w.RenameSheet(s, name); err != nil {26+ t.Fatal(err)27+ }28+ } else {29+ var err error30+ if s, err = w.AddSheet(name, i); err != nil {31+ t.Fatal(err)32+ }33+ }34+ for a, in := range cells[i] {35+ if err := s.Set(addr(a), in); err != nil {36+ t.Fatal(err)37+ }38+ }39+ }40+ if edit != nil {41+ edit(w)42+ }43+ var b bytes.Buffer44+ if err := w.Write(&b); err != nil {45+ t.Fatal(err)46+ }47+ return b.Bytes()48+}49+50+func read(t *testing.T, data []byte) *Book {51+ t.Helper()52+ b, err := Read(data)53+ if err != nil {54+ t.Fatal(err)55+ }56+ return b57+}58+59+// lines is the text output of comparing a with b.60+func lines(t *testing.T, a, b []byte) string {61+ t.Helper()62+ var out bytes.Buffer63+ if err := WriteChanges(&out, Compare(read(t, a), read(t, b)), "text", false); err != nil {64+ t.Fatal(err)65+ }66+ return out.String()67+}68+69+func TestCompareCells(t *testing.T) {70+ a := file(t, []string{"Sheet1"}, []map[string]string{{"A1": "Item", "B1": "5", "B2": "=B1*2", "C1": "gone", "D1": "=NOW()", "D2": "=D1+1"}}, nil)71+ b := file(t, []string{"Sheet1"}, []map[string]string{{"A1": "Item", "B1": "6", "B2": "=B1*2", "C2": "new", "D1": "=NOW()", "D2": "=D1+1"}}, func(w *sheet.Workbook) {72+ s := w.Sheet(0)73+ s.SetStyle(sheet.NewRect(addr("A1"), addr("A1")), func(st *sheet.Style) { st.Bold = true })74+ s.SetNote(addr("A1"), "the thing")75+ s.SetColWidth(2, 14)76+ })77+ want := `Sheet1!A1 format + bold78+Sheet1!A1 note + the thing79+Sheet1!B1 input 5 → 680+Sheet1!C1 input - gone81+Sheet1!B2 value 10 → 1282+Sheet1!C2 input + new83+Sheet1 widths C + 1484+`85+ if got := lines(t, a, b); got != want {86+ t.Errorf("got\n%s\nwant\n%s", got, want)87+ }88+ if got := lines(t, a, a); got != "" {89+ t.Errorf("a workbook against itself:\n%s", got)90+ }91+}92+93+func TestCompareSheets(t *testing.T) {94+ cells := map[string]string{"A1": "a", "A2": "b", "A3": "c", "A4": "d"}95+ edited := map[string]string{"A1": "a", "A2": "b", "A3": "c", "A4": "D"}96+ a := file(t, []string{"One", "Two", "Three", "Gone"}, []map[string]string{{"A1": "1"}, cells, {"A1": "3"}, {"A1": "x"}}, nil)97+ b := file(t, []string{"Three", "One", "Renamed", "Added"}, []map[string]string{{"A1": "3"}, {"A1": "1"}, edited, {"A1": "y"}}, nil)98+ got := lines(t, a, b)99+ for _, want := range []string{100+ "sheet Gone removed\n",101+ "sheet Added added\n",102+ "sheet Renamed renamed from Two\n",103+ "sheet Three moved from 3 to 1\n",104+ "Renamed!A4 input d → D\n",105+ "Gone!A1 input - x\n",106+ "Added!A1 input + y\n",107+ } {108+ if !strings.Contains(got, want) {109+ t.Errorf("lacks %q:\n%s", want, got)110+ }111+ }112+ if strings.Contains(got, "sheet One moved") {113+ t.Errorf("more moves than needed:\n%s", got)114+ }115+}116+117+func TestCompareShapes(t *testing.T) {118+ // One sheet written as a version 2 file and as a version 4 file is119+ // the same workbook.120+ v2 := []byte(`{"version": 2, "names": {"Sales": "A1:A2"}, "cells": {"A1": "1", "A2": "=A1+1"}}`)121+ v4 := []byte(`{"version": 4, "names": {"Sales": "Sheet1!A1:A2"}, "sheets": [{"name": "Sheet1", "cells": {"A1": "1", "A2": "=A1 + 1"}}]}`)122+ got := lines(t, v2, v4)123+ if got != "Sheet1!A2 input =A1+1 → =A1 + 1\n" {124+ t.Errorf("got\n%s", got)125+ }126+ empty := lines(t, nil, v2)127+ if !strings.HasPrefix(empty, "sheet Sheet1 added\n") || !strings.Contains(empty, "name Sales added + Sheet1!A1:A2") {128+ t.Errorf("from nothing:\n%s", empty)129+ }130+}131+132+func TestCompareRegionsAndBook(t *testing.T) {133+ a := []byte(`{"version": 4, "locale": "en-US", "macros": [{"name": "m1", "api": 1, "source": "x"}], "sheets": [{"name": "S", "regions": [{"name": "app", "at": "A1", "path": "app.csv"}, {"name": "old", "at": "D1", "path": "old.csv"}], "cells": {}}]}`)134+ b := []byte(`{"version": 4, "locale": "de-DE", "active": 0, "sheets": [{"name": "S", "regions": [{"name": "app", "at": "A1", "path": "app.log"}, {"name": "sales", "at": "H1", "output": true}], "cells": {}}]}`)135+ want := `S region app path app.csv → app.log136+S region old removed - old.csv137+S region sales added + a notebook cell's output138+macro m1 removed - x139+workbook locale en-US → de-DE140+`141+ if got := lines(t, a, b); got != want {142+ t.Errorf("got\n%s\nwant\n%s", got, want)143+ }144+}145+146+func TestCompareNotebooks(t *testing.T) {147+ nb := func(cells string) []byte {148+ return []byte(`{"version": 4, "sheets": [{"name": "Sheet1", "cells": {}}, {"name": "Notebook", "tab": "notebook", "notebookCells": [` + cells + `], "cells": {}}]}`)149+ }150+ a := nb(`{"kind": "note", "source": "# Sales"}, {"source": "sales = open s.csv", "output": "[[n]; [1]]"}, {"source": "$sales | length", "output": "1"}, {"source": "gone"}`)151+ b := nb(`{"source": "new = ls"}, {"kind": "note", "source": "# Sales"}, {"source": "sales = open s.csv", "output": "[[n]; [2]]"}, {"source": "$sales | length", "error": "boom"}`)152+ want := `Notebook cell 1 added + new = ls153+Notebook cell 3 output [[n]; [1]] → [[n]; [2]]154+Notebook cell 4 error + boom155+Notebook cell 4 output - 1156+Notebook cell 4 removed - gone157+`158+ if got := lines(t, a, b); got != want {159+ t.Errorf("got\n%s\nwant\n%s", got, want)160+ }161+ var out bytes.Buffer162+ Dump(&out, read(t, b))163+ if !strings.Contains(out.String(), "Notebook cell 3 sales = open s.csv = [[n]; [2]]\nNotebook cell 4 $sales | length error: boom\n") {164+ t.Errorf("dump:\n%s", out.String())165+ }166+}167+168+func TestWriteChanges(t *testing.T) {169+ a := file(t, []string{"Sheet1"}, []map[string]string{{"A1": "=1+1", "B1": "x"}}, nil)170+ b := file(t, []string{"Sheet1"}, []map[string]string{{"A1": "=1+2"}}, nil)171+ changes := Compare(read(t, a), read(t, b))172+ var out bytes.Buffer173+ WriteChanges(&out, changes, "nuon", false)174+ v, err := nuon.Parse(out.Bytes())175+ if err != nil || v.Kind != nuon.List || len(v.List) != 3 {176+ t.Fatalf("NUON %v: %s", err, out.String())177+ }178+ if got := v.List[1].String(); got != `{kind: cell, sheet: Sheet1, item: A1, field: value, old: 2, new: 3}` {179+ t.Errorf("a value's record: %s", got)180+ }181+ out.Reset()182+ WriteChanges(&out, changes, "json", false)183+ if !strings.Contains(out.String(), `{"kind":"cell","sheet":"Sheet1","item":"B1","field":"input","old":"x","new":null}`) {184+ t.Errorf("JSON:\n%s", out.String())185+ }186+ out.Reset()187+ WriteChanges(&out, changes[:1], "text", true)188+ if out.String() != "\x1b[1mSheet1!A1\x1b[0m \x1b[36minput\x1b[0m \x1b[31m=1+1\x1b[0m → \x1b[32m=1+2\x1b[0m\n" {189+ t.Errorf("colored: %q", out.String())190+ }191+ if err := WriteChanges(&out, changes, "xml", false); err == nil {192+ t.Error("xml")193+ }194+}195+196+func TestDump(t *testing.T) {197+ data := file(t, []string{"Sheet1"}, []map[string]string{{"A1": "Rent", "B1": "1450", "B2": "=B1*12", "C1": "=RAND()"}}, func(w *sheet.Workbook) {198+ s := w.Sheet(0)199+ s.SetNote(addr("A1"), "monthly")200+ s.SetColWidth(0, 12)201+ })202+ var out bytes.Buffer203+ if err := Dump(&out, read(t, data)); err != nil {204+ t.Fatal(err)205+ }206+ want := `sheet Sheet1207+Sheet1 widths {"A":12}208+Sheet1!A1 Rent note: monthly209+Sheet1!B1 1450210+Sheet1!C1 =RAND()211+Sheet1!B2 =B1*12 = 17400212+`213+ if out.String() != want {214+ t.Errorf("got\n%s\nwant\n%s", out.String(), want)215+ }216+}
A internal/diff/fields.go +187 -0
@@ -0,0 +1,187 @@
1+package diff2+3+import (4+ "encoding/json"5+ "maps"6+ "slices"7+8+ "github.com/FelineStateMachine/012/internal/nuon"9+)10+11+// quietKeys are the workbook's fields that change without the workbook12+// changing: the file version, the sheet shown, the computer its macros13+// were trusted on and the notebook prompt's history.14+var quietKeys = map[string]bool{"version": true, "active": true, "macroOrigin": true, "shellHistory": true}15+16+// bookChanges compares the workbook's fields: named ranges, macros and17+// settings.18+func bookChanges(a, b *rawBook) []Change {19+ var out []Change20+ out = append(out, keyedChanges(KindName, "range", a.top["names"], b.top["names"])...)21+ out = append(out, listChanges(KindMacro, "", a.top["macros"], b.top["macros"])...)22+ keys := map[string]bool{}23+ for _, t := range []map[string]json.RawMessage{a.top, b.top} {24+ for k := range t {25+ keys[k] = true26+ }27+ }28+ for _, k := range slices.Sorted(maps.Keys(keys)) {29+ if !quietKeys[k] && k != "names" && k != "macros" {30+ out = append(out, fieldChanges(KindWorkbook, "", k, a.top[k], b.top[k])...)31+ }32+ }33+ return out34+}35+36+// fieldChanges compares a field: key by key when it's an object (a37+// column's width), else whole.38+func fieldChanges(kind, sheetName, item string, a, b json.RawMessage) []Change {39+ if equal(a, b) {40+ return nil41+ }42+ oa, okA := object(a)43+ ob, okB := object(b)44+ if okA && okB {45+ var out []Change46+ for _, k := range unionKeys(oa, ob) {47+ if !equal(oa[k], ob[k]) {48+ out = append(out, rawChange(kind, sheetName, item, k, oa[k], ob[k]))49+ }50+ }51+ return out52+ }53+ return []Change{rawChange(kind, sheetName, item, "", a, b)}54+}55+56+// keyedChanges compares an object whose keys are things (named ranges):57+// each added, removed, or its value changed, as field.58+func keyedChanges(kind, field string, a, b json.RawMessage) []Change {59+ oa, _ := object(a)60+ ob, _ := object(b)61+ var out []Change62+ for _, k := range unionKeys(oa, ob) {63+ switch va, vb := oa[k], ob[k]; {64+ case va == nil:65+ out = append(out, rawChange(kind, "", k, "added", nil, vb))66+ case vb == nil:67+ out = append(out, rawChange(kind, "", k, "removed", va, nil))68+ case !equal(va, vb):69+ out = append(out, rawChange(kind, "", k, field, va, vb))70+ }71+ }72+ return out73+}74+75+// listChanges compares lists of things with names (regions, macros):76+// each added, removed, or changed field by field.77+func listChanges(kind, sheetName string, a, b json.RawMessage) []Change {78+ la, lb := named(a), named(b)79+ var out []Change80+ for _, k := range unionKeys(la, lb) {81+ switch ea, eb := la[k], lb[k]; {82+ case ea == nil:83+ c := rawChange(kind, sheetName, k, "added", nil, eb)84+ c.newText = summary(eb)85+ out = append(out, c)86+ case eb == nil:87+ c := rawChange(kind, sheetName, k, "removed", ea, nil)88+ c.oldText = summary(ea)89+ out = append(out, c)90+ default:91+ fa, _ := object(ea)92+ fb, _ := object(eb)93+ for _, f := range unionKeys(fa, fb) {94+ if !equal(fa[f], fb[f]) {95+ out = append(out, rawChange(kind, sheetName, k, f, fa[f], fb[f]))96+ }97+ }98+ }99+ }100+ return out101+}102+103+// named reads a list of objects by their "name" fields, as compact104+// JSON.105+func named(raw json.RawMessage) map[string]json.RawMessage {106+ var list []json.RawMessage107+ json.Unmarshal(raw, &list)108+ out := map[string]json.RawMessage{}109+ for _, e := range list {110+ var n struct{ Name string }111+ json.Unmarshal(e, &n)112+ out[n.Name] = compact(e)113+ }114+ return out115+}116+117+// summary is what the text output shows of a region or macro added or118+// removed: the file a region follows or that it's a notebook's output;119+// a macro's script's first line.120+func summary(raw json.RawMessage) string {121+ fields, _ := object(raw)122+ var s string123+ switch {124+ case json.Unmarshal(fields["path"], &s) == nil && s != "":125+ return s126+ case string(fields["output"]) == "true":127+ return "a notebook cell's output"128+ case json.Unmarshal(fields["source"], &s) == nil && s != "":129+ return firstLine(s)130+ }131+ return string(raw)132+}133+134+// rawChange is a change between two JSON values, nil for none.135+func rawChange(kind, sheetName, item, field string, a, b json.RawMessage) Change {136+ return Change{Kind: kind, Sheet: sheetName, Item: item, Field: field,137+ Old: jsonValue(a), New: jsonValue(b), oldText: rawText(a), newText: rawText(b)}138+}139+140+func jsonValue(raw json.RawMessage) nuon.Value {141+ if raw == nil {142+ return nuon.NullValue()143+ }144+ v, err := nuon.Parse(raw)145+ if err != nil {146+ return nuon.StringValue(string(raw))147+ }148+ return v149+}150+151+// rawText is a JSON value as the text output shows it: a string as it152+// is, anything else as compact JSON.153+func rawText(raw json.RawMessage) string {154+ var s string155+ if raw == nil {156+ return ""157+ }158+ if json.Unmarshal(raw, &s) == nil {159+ return s160+ }161+ return string(raw)162+}163+164+func object(raw json.RawMessage) (map[string]json.RawMessage, bool) {165+ var m map[string]json.RawMessage166+ if raw == nil {167+ return map[string]json.RawMessage{}, true168+ }169+ if json.Unmarshal(raw, &m) != nil || m == nil {170+ return nil, false171+ }172+ for k, v := range m {173+ m[k] = compact(v)174+ }175+ return m, true176+}177+178+func unionKeys(a, b map[string]json.RawMessage) []string {179+ keys := map[string]bool{}180+ for k := range a {181+ keys[k] = true182+ }183+ for k := range b {184+ keys[k] = true185+ }186+ return slices.Sorted(maps.Keys(keys))187+}
A internal/diff/match.go +103 -0
@@ -0,0 +1,103 @@
1+package diff2+3+import "sort"4+5+// minSimilar is how alike two sheets' cells must be, as the share of6+// cells they hold the same, for a sheet gone under one name and a sheet7+// come under another to count as one sheet renamed.8+const minSimilar = 0.59+10+// pairing says which sheets of two workbooks are the same sheet: by11+// index in a and in b.12+type pairing struct {13+ pairs [][2]int // matched, in a's order14+ onlyA []int // a's sheets without a match: removed15+ onlyB []int // b's sheets without a match: added16+}17+18+// matchSheets pairs a's sheets with b's: first by name, ignoring case,19+// then each sheet left in a with the most alike left in b, when they20+// share at least half their cells (a rename, perhaps with edits).21+func matchSheets(a, b *rawBook) pairing {22+ var p pairing23+ usedB := make([]bool, len(b.sheets))24+ var leftA []int25+ for i, s := range a.sheets {26+ j := b.sheetIndex(s.name)27+ if j < 0 || usedB[j] {28+ leftA = append(leftA, i)29+ continue30+ }31+ usedB[j] = true32+ p.pairs = append(p.pairs, [2]int{i, j})33+ }34+ type candidate struct {35+ i, j int36+ score float6437+ }38+ var cands []candidate39+ for _, i := range leftA {40+ for j := range b.sheets {41+ if !usedB[j] {42+ if sc := similarity(a.sheets[i], b.sheets[j]); sc >= minSimilar {43+ cands = append(cands, candidate{i, j, sc})44+ }45+ }46+ }47+ }48+ sort.SliceStable(cands, func(x, y int) bool { return cands[x].score > cands[y].score })49+ usedA := map[int]bool{}50+ for _, c := range cands {51+ if !usedA[c.i] && !usedB[c.j] {52+ usedA[c.i], usedB[c.j] = true, true53+ p.pairs = append(p.pairs, [2]int{c.i, c.j})54+ }55+ }56+ sort.Slice(p.pairs, func(x, y int) bool { return p.pairs[x][0] < p.pairs[y][0] })57+ for _, i := range leftA {58+ if !usedA[i] {59+ p.onlyA = append(p.onlyA, i)60+ }61+ }62+ for j := range b.sheets {63+ if !usedB[j] {64+ p.onlyB = append(p.onlyB, j)65+ }66+ }67+ return p68+}69+70+// similarity is the share of cells two sheets hold the same, 1 for two71+// empty sheets.72+func similarity(a, b *rawSheet) float64 {73+ n := max(len(a.cells), len(b.cells))74+ if n == 0 {75+ return 176+ }77+ same := 078+ for k, v := range a.cells {79+ if equal(v, b.cells[k]) {80+ same++81+ }82+ }83+ return float64(same) / float64(n)84+}85+86+// sameSheet reports whether two sheets are alike in every field, their87+// names included.88+func sameSheet(a, b *rawSheet) bool {89+ if a.name != b.name || len(a.cells) != len(b.cells) || len(a.fields) != len(b.fields) {90+ return false91+ }92+ for k, v := range a.cells {93+ if !equal(v, b.cells[k]) {94+ return false95+ }96+ }97+ for k, v := range a.fields {98+ if !equal(v, b.fields[k]) {99+ return false100+ }101+ }102+ return true103+}
A internal/diff/merge.go +287 -0
@@ -0,0 +1,287 @@
1+package diff2+3+import (4+ "encoding/json"5+ "maps"6+ "slices"7+ "strconv"8+9+ "github.com/FelineStateMachine/012/internal/sheet"10+)11+12+// Conflict is a part of a workbook both sides changed differently. The13+// merge keeps ours; the report says what theirs had.14+type Conflict struct {15+ Where string // as a Change's: Q3!B7, sheet Q3, Q3 widths, name Sales16+ Field string // what about it: input, format, note, a field's key, or ""17+ // Base, Ours and Theirs are the three versions as text, "" where18+ // the thing isn't there; What says what happened instead, when the19+ // conflict is about a whole sheet.20+ Base, Ours, Theirs string21+ What string22+}23+24+// String is the conflict as the merge report lists it.25+func (c Conflict) String() string {26+ s := c.Where27+ if c.Field != "" {28+ s += " " + c.Field29+ }30+ if c.What != "" {31+ return s + ": " + c.What32+ }33+ return s + ": ours " + side3(c.Ours) + ", theirs " + side3(c.Theirs) + ", base " + side3(c.Base)34+}35+36+func side3(s string) string {37+ if s == "" {38+ return "(none)"39+ }40+ return quoted(s)41+}42+43+// Merge combines ours and theirs, two workbook files changed from base,44+// and returns the merged file as 012 saves it. Each cell's input, format45+// and note, each field of a sheet or the workbook (a column's width, a46+// region, a named range, a macro) and each sheet's name and presence47+// merge on their own: a part changed on one side only takes that side's48+// version, and a part both changed alike takes it too. A part both49+// changed differently is a conflict: the merge keeps ours, and a cell's50+// note says what theirs had (on top of its own note), so the conflicts51+// can be found in the grid. Empty contents stand for a missing file.52+//53+// Sheets are matched by name or, when renamed, by their cells (see54+// matchSheets). Rows or columns inserted on one side shift the cells55+// below or right of them, which a cell-by-cell merge can't tell from56+// edits: merge such changes in 012 rather than here.57+func Merge(base, ours, theirs []byte) ([]byte, []Conflict, error) {58+ var books [3]*rawBook59+ for i, data := range [][]byte{base, ours, theirs} {60+ b, err := parseRaw(data)61+ if err != nil {62+ return nil, nil, err63+ }64+ books[i] = b65+ }66+ m := &merger{}67+ merged := m.books(books[0], books[1], books[2])68+ if len(merged.sheets) == 0 {69+ return nil, m.conflicts, nil70+ }71+ out, err := normalize(merged)72+ return out, m.conflicts, err73+}74+75+// merger collects the conflicts of a merge.76+type merger struct {77+ conflicts []Conflict78+}79+80+func (m *merger) conflict(where, field string, b, o, t json.RawMessage) {81+ m.conflicts = append(m.conflicts, Conflict{Where: where, Field: field, Base: rawText(b), Ours: rawText(o), Theirs: rawText(t)})82+}83+84+// said records a conflict that isn't between three values, in words.85+func (m *merger) said(where, field, what string) {86+ m.conflicts = append(m.conflicts, Conflict{Where: where, Field: field, What: what})87+}88+89+// merge3 merges one value: what changed on one side, or both alike.90+func merge3(b, o, t json.RawMessage) (json.RawMessage, bool) {91+ switch {92+ case equal(o, t), equal(b, t):93+ return o, false94+ case equal(b, o):95+ return t, false96+ }97+ return o, true98+}99+100+// value merges one value, reporting a conflict at where.101+func (m *merger) value(where, field string, b, o, t json.RawMessage) json.RawMessage {102+ v, clash := merge3(b, o, t)103+ if clash {104+ m.conflict(where, field, b, o, t)105+ }106+ return v107+}108+109+// books merges the workbooks' fields, then their sheets.110+func (m *merger) books(b, o, t *rawBook) *rawBook {111+ out := &rawBook{top: map[string]json.RawMessage{}}112+ for _, k := range unionKeys3(b.top, o.top, t.top) {113+ var v json.RawMessage114+ switch k {115+ case "version":116+ v = maxVersion(b.top[k], o.top[k], t.top[k])117+ case "names":118+ v = m.object(b.top[k], o.top[k], t.top[k], func(key string) (string, string) { return "name " + key, "" })119+ case "macros":120+ v = m.list(b.top[k], o.top[k], t.top[k], func(key string) string { return "macro " + key })121+ case "active", "shellHistory", "macroOrigin":122+ v, _ = merge3(b.top[k], o.top[k], t.top[k]) // what's shown or was typed: ours if both differ123+ default:124+ v = m.value("workbook "+k, "", b.top[k], o.top[k], t.top[k])125+ }126+ if v != nil {127+ out.top[k] = v128+ }129+ }130+ out.sheets = m.sheets(b, o, t)131+ if n, err := strconv.Atoi(string(out.top["active"])); err == nil && n >= len(out.sheets) {132+ delete(out.top, "active") // the sheet shown went: show the first133+ }134+ trust(out, o, t)135+ return out136+}137+138+// trust keeps the computer ours trusted its commands on only when the139+// merge's commands (macros, notebook cells and linked files) are ours',140+// or theirs were trusted on the same computer, so a merge never makes141+// someone else's commands run without asking.142+func trust(out, o, t *rawBook) {143+ if equal(o.top["macroOrigin"], t.top["macroOrigin"]) || code(out) == code(o) {144+ if origin := o.top["macroOrigin"]; origin != nil {145+ out.top["macroOrigin"] = origin146+ }147+ return148+ }149+ delete(out.top, "macroOrigin")150+}151+152+// code is what a workbook can make 012 run or read: its macros, its153+// notebooks' cells and the files it links, in one string to compare.154+func code(b *rawBook) string {155+ var parts []string156+ parts = append(parts, string(b.top["macros"]))157+ for _, s := range b.sheets {158+ parts = append(parts, string(sources(s.fields["notebookCells"])))159+ for _, r := range named(s.fields["regions"]) {160+ f, _ := object(r)161+ parts = append(parts, string(f["command"])+string(f["path"])+string(f["table"])+string(f["query"]))162+ }163+ }164+ slices.Sort(parts)165+ raw, _ := json.Marshal(parts)166+ return string(raw)167+}168+169+func maxVersion(vs ...json.RawMessage) json.RawMessage {170+ best, out := 0, json.RawMessage(nil)171+ for _, v := range vs {172+ if n, err := strconv.Atoi(string(v)); err == nil && n > best {173+ best, out = n, v174+ }175+ }176+ return out177+}178+179+// object merges an object key by key (named ranges, column widths);180+// where names each key's place and field in conflicts.181+func (m *merger) object(b, o, t json.RawMessage, where func(key string) (string, string)) json.RawMessage {182+ ob, okB := object(b)183+ oo, okO := object(o)184+ ot, okT := object(t)185+ if !okB || !okO || !okT {186+ at, _ := where("")187+ return m.value(at, "", b, o, t)188+ }189+ out := map[string]json.RawMessage{}190+ for _, k := range unionKeys3(ob, oo, ot) {191+ at, field := where(k)192+ if v := m.value(at, field, ob[k], oo[k], ot[k]); v != nil {193+ out[k] = v194+ }195+ }196+ if len(out) == 0 && o == nil && t == nil {197+ return nil198+ }199+ raw, _ := json.Marshal(out)200+ return raw201+}202+203+// list merges a list of things with names (regions, macros) thing by204+// thing, in ours' order with theirs' additions after.205+func (m *merger) list(b, o, t json.RawMessage, where func(string) string) json.RawMessage {206+ lb, lo, lt := named(b), named(o), named(t)207+ var order []string208+ for _, raw := range []json.RawMessage{o, t} {209+ var items []struct{ Name string }210+ json.Unmarshal(raw, &items)211+ for _, it := range items {212+ if !slices.Contains(order, it.Name) {213+ order = append(order, it.Name)214+ }215+ }216+ }217+ var out []json.RawMessage218+ for _, k := range order {219+ if v := m.value(where(k), "", lb[k], lo[k], lt[k]); v != nil {220+ out = append(out, v)221+ }222+ }223+ if len(out) == 0 {224+ return nil225+ }226+ raw, _ := json.Marshal(out)227+ return raw228+}229+230+// cell merges a cell's input, format and note apart. Where they231+// conflict, ours stays and the note says what theirs had.232+func (m *merger) cell(where string, b, o, t json.RawMessage) json.RawMessage {233+ pb, po, pt := splitCell(b), splitCell(o), splitCell(t)234+ str := func(s string) json.RawMessage {235+ if s == "" {236+ return nil237+ }238+ raw, _ := json.Marshal(s)239+ return raw240+ }241+ out := cellParts{}242+ var said []string243+ part := func(field string, b, o, t json.RawMessage) json.RawMessage {244+ v, clash := merge3(b, o, t)245+ if clash {246+ text := rawText247+ if field == "format" {248+ text = formatText249+ }250+ m.conflicts = append(m.conflicts, Conflict{Where: where, Field: field, Base: text(b), Ours: text(o), Theirs: text(t)})251+ said = append(said, field+" "+side3(text(t)))252+ }253+ return v254+ }255+ json.Unmarshal(part("input", str(pb.input), str(po.input), str(pt.input)), &out.input)256+ out.format = part("format", pb.format, po.format, pt.format)257+ json.Unmarshal(part("note", str(pb.note), str(po.note), str(pt.note)), &out.note)258+ if len(said) > 0 {259+ out.note = conflictNote(out.note, said)260+ }261+ return joinCell(out)262+}263+264+// conflictNote is a cell's note with what theirs had added below it.265+func conflictNote(note string, said []string) string {266+ text := "Merge conflict, kept ours; theirs had "267+ for i, s := range said {268+ if i > 0 {269+ text += "; "270+ }271+ text += s272+ }273+ if note != "" {274+ text = note + "\n\n" + text275+ }276+ return sheet.CleanNote(text)277+}278+279+func unionKeys3(a, b, c map[string]json.RawMessage) []string {280+ keys := map[string]bool{}281+ for _, m := range []map[string]json.RawMessage{a, b, c} {282+ for k := range m {283+ keys[k] = true284+ }285+ }286+ return slices.Sorted(maps.Keys(keys))287+}
A internal/diff/merge_test.go +180 -0
@@ -0,0 +1,180 @@
1+package diff2+3+import (4+ "bytes"5+ "strings"6+ "testing"7+8+ "github.com/FelineStateMachine/012/internal/sheet"9+)10+11+func merge(t *testing.T, base, ours, theirs []byte) (*sheet.Workbook, []string) {12+ t.Helper()13+ out, conflicts, err := Merge(base, ours, theirs)14+ if err != nil {15+ t.Fatal(err)16+ }17+ w, err := sheet.ReadBook(bytes.NewReader(out))18+ if err != nil {19+ t.Fatalf("the merge doesn't read: %v\n%s", err, out)20+ }21+ var said []string22+ for _, c := range conflicts {23+ said = append(said, c.String())24+ }25+ return w, said26+}27+28+func input(w *sheet.Workbook, ref string) string {29+ name, rest := sheet.SplitSheet(ref)30+ s := w.Lookup(name)31+ if s == nil {32+ return "(no sheet " + name + ")"33+ }34+ c := s.Cell(addr(rest))35+ if c == nil {36+ return ""37+ }38+ return c.Input39+}40+41+func TestMergeCells(t *testing.T) {42+ base := file(t, []string{"Sheet1"}, []map[string]string{{"A1": "Item", "B1": "1", "B2": "2", "B3": "3", "B4": "=SUM(B1:B3)"}}, nil)43+ ours := file(t, []string{"Sheet1"}, []map[string]string{{"A1": "Item", "B1": "10", "B2": "2", "B3": "30", "B4": "=SUM(B1:B3)", "C1": "ours"}}, nil)44+ theirs := file(t, []string{"Sheet1"}, []map[string]string{{"A1": "Item", "B1": "1", "B2": "20", "B3": "31", "B4": "=SUM(B1:B3)", "D1": "theirs"}}, func(w *sheet.Workbook) {45+ s := w.Sheet(0)46+ s.SetStyle(sheet.NewRect(addr("B1"), addr("B1")), func(st *sheet.Style) { st.Bold = true })47+ s.SetColWidth(3, 20)48+ })49+ w, said := merge(t, base, ours, theirs)50+ for ref, want := range map[string]string{"Sheet1!B1": "10", "Sheet1!B2": "20", "Sheet1!B3": "30", "Sheet1!C1": "ours", "Sheet1!D1": "theirs"} {51+ if got := input(w, ref); got != want {52+ t.Errorf("%s = %q, want %q", ref, got, want)53+ }54+ }55+ s := w.Sheet(0)56+ if !s.CellStyle(addr("B1")).Bold || s.ColWidth(3) != 20 {57+ t.Error("theirs' formatting and width were lost")58+ }59+ if v := s.Value(addr("B4")); v.Num != 60 {60+ t.Errorf("B4 = %v", v)61+ }62+ if len(said) != 1 || said[0] != "Sheet1!B3 input: ours 30, theirs 31, base 3" {63+ t.Errorf("conflicts: %v", said)64+ }65+ if note := s.Note(addr("B3")); note != "Merge conflict, kept ours; theirs had input 31" {66+ t.Errorf("B3's note: %q", note)67+ }68+}69+70+func TestMergeSheets(t *testing.T) {71+ cells := map[string]string{"A1": "a", "A2": "b", "A3": "c"}72+ base := file(t, []string{"Plan", "Old", "Keep"}, []map[string]string{cells, {"A1": "old"}, {"A1": "k"}}, nil)73+ // Ours renames Plan and adds a sheet; theirs edits Plan, removes Old74+ // and adds a sheet of the same name as ours'.75+ ours := file(t, []string{"Q3 plan", "Old", "Keep", "Notes"}, []map[string]string{cells, {"A1": "old"}, {"A1": "k"}, {"A1": "ours"}}, nil)76+ edited := map[string]string{"A1": "a", "A2": "B", "A3": "c"}77+ theirs := file(t, []string{"Plan", "Keep", "Notes"}, []map[string]string{edited, {"A1": "k"}, {"B1": "theirs"}}, nil)78+ w, said := merge(t, base, ours, theirs)79+ var names []string80+ for _, s := range w.Sheets() {81+ names = append(names, s.Name())82+ }83+ if strings.Join(names, ",") != "Q3 plan,Keep,Notes" {84+ t.Errorf("sheets: %v", names)85+ }86+ if input(w, "'Q3 plan'!A2") != "B" || input(w, "Notes!A1") != "ours" || input(w, "Notes!B1") != "theirs" {87+ t.Errorf("cells: %q %q %q", input(w, "'Q3 plan'!A2"), input(w, "Notes!A1"), input(w, "Notes!B1"))88+ }89+ if len(said) != 0 {90+ t.Errorf("conflicts: %v", said)91+ }92+93+ // A sheet removed on one side and changed on the other conflicts;94+ // ours stays.95+ oursEdit := file(t, []string{"Plan", "Old", "Keep"}, []map[string]string{cells, {"A1": "older"}, {"A1": "k"}}, nil)96+ w, said = merge(t, base, oursEdit, theirs)97+ if w.Lookup("Old") == nil || len(said) != 1 || said[0] != "sheet Old: changed in ours, removed in theirs" {98+ t.Errorf("removed in theirs: %v %v", w.Lookup("Old"), said)99+ }100+ theirsEdit := file(t, []string{"Plan", "Old", "Keep"}, []map[string]string{cells, {"A1": "older"}, {"A1": "k"}}, nil)101+ oursGone := file(t, []string{"Plan", "Keep"}, []map[string]string{cells, {"A1": "k"}}, nil)102+ w, said = merge(t, base, oursGone, theirsEdit)103+ if w.Lookup("Old") != nil || len(said) != 1 || said[0] != "sheet Old: removed in ours, changed in theirs" {104+ t.Errorf("removed in ours: %v %v", w.Lookup("Old"), said)105+ }106+107+ // Both renaming one sheet differently conflicts.108+ theirsRenamed := file(t, []string{"Plan B", "Old", "Keep"}, []map[string]string{cells, {"A1": "old"}, {"A1": "k"}}, nil)109+ _, said = merge(t, base, ours, theirsRenamed)110+ if len(said) != 1 || said[0] != "sheet Q3 plan name: ours Q3 plan, theirs Plan B, base Plan" {111+ t.Errorf("renamed twice: %v", said)112+ }113+}114+115+func TestMergeTrust(t *testing.T) {116+ book := func(origin, command string) []byte {117+ return []byte(`{"version": 4, "macroOrigin": "` + origin + `", "sheets": [{"name": "S", "cells": {}}, {"name": "N", "tab": "notebook", "notebookCells": [{"source": "` + command + `"}], "cells": {}}]}`)118+ }119+ base := book("here", "ls")120+ cases := []struct {121+ ours, theirs []byte122+ want string123+ }{124+ {book("here", "ls -a"), book("here", "ls"), "here"}, // theirs brought no commands125+ {book("here", "ls"), book("there", "ls"), "here"}, // only theirs' origin differs126+ {book("here", "echo"), book("there", "echo"), "here"}, // both made the same change127+ {book("here", "ls -a"), book("there", "ls -b"), "here"}, // a conflict keeps ours' command128+ {book("here", "ls"), book("here", "ls -l"), "here"}, // theirs trusted on this computer too129+ {book("there", "ls"), book("there", "ls -l"), "there"}, // both from elsewhere, as ours was130+ {book("here", "ls"), book("there", "rm -rf ~"), ""}, // theirs' command from elsewhere131+ {book("here", "ls"), book("", "ls -l"), ""}, // theirs' command, never trusted132+ {book("here", "ls"), book("here2", "ls -l"), ""}, // another computer's id133+ {book("", "ls"), book("here", "ls -l"), ""}, // ours stays untrusted134+ }135+ for i, c := range cases {136+ w, _ := merge(t, base, c.ours, c.theirs)137+ if got := w.MacroOrigin(); got != c.want {138+ t.Errorf("case %d: origin %q, want %q", i, got, c.want)139+ }140+ }141+}142+143+func TestMergeBroken(t *testing.T) {144+ if _, _, err := Merge([]byte("{"), nil, nil); err == nil {145+ t.Error("a broken base merged")146+ }147+ // Names that end up pointing at a sheet no longer there make a148+ // workbook the engine refuses, which is an error, not a merge.149+ base := []byte(`{"version": 4, "sheets": [{"name": "A", "cells": {}}, {"name": "B", "cells": {}}]}`)150+ ours := []byte(`{"version": 4, "sheets": [{"name": "A", "cells": {}}]}`)151+ theirs := []byte(`{"version": 4, "names": {"N": "B!A1"}, "sheets": [{"name": "A", "cells": {}}, {"name": "B", "cells": {}}]}`)152+ if _, _, err := Merge(base, ours, theirs); err == nil {153+ t.Error("a merge naming a sheet removed")154+ }155+}156+157+func TestMergeOneSheetFiles(t *testing.T) {158+ base := []byte(`{"version": 3, "names": {"Sales": "A1:A2"}, "cells": {"A1": "1", "A2": "2"}}`)159+ ours := []byte(`{"version": 3, "names": {"Sales": "A1:A3"}, "cells": {"A1": "1", "A2": "2", "A3": "3"}}`)160+ theirs := []byte(`{"version": 3, "names": {"Sales": "A1:A2", "Cost": "B1"}, "widths": {"B": 14}, "cells": {"A1": "1", "A2": "2", "B1": "=SUM(Sales)"}}`)161+ out, conflicts, err := Merge(base, ours, theirs)162+ if err != nil || len(conflicts) > 0 {163+ t.Fatal(err, conflicts)164+ }165+ want := `{166+ "version": 3,167+ "widths": {"B": 14},168+ "names": {"Cost": "B1", "Sales": "A1:A3"},169+ "cells": {170+ "A1": "1",171+ "B1": "=SUM(Sales)",172+ "A2": "2",173+ "A3": "3"174+ }175+}176+`177+ if string(out) != want {178+ t.Errorf("got\n%s\nwant\n%s", out, want)179+ }180+}
A internal/diff/mergesheets.go +165 -0
@@ -0,0 +1,165 @@
1+package diff2+3+import (4+ "encoding/json"5+ "strings"6+7+ "github.com/FelineStateMachine/012/internal/sheet"8+)9+10+// sheets merges the workbooks' sheets. A sheet is matched across the11+// three by name or, renamed, by its cells; one removed on one side goes12+// when the other left it as it was, and stays (a conflict) when ours13+// changed it, or goes (a conflict) when only theirs did. Sheets added14+// on both sides under one name merge cell by cell. The order is ours',15+// with theirs' additions after the sheet they follow in theirs.16+func (m *merger) sheets(b, o, t *rawBook) []*rawSheet {17+ oOf, oBase := matchIndex(matchSheets(b, o))18+ tOf, tBase := matchIndex(matchSheets(b, t))19+ tDone := make([]bool, len(t.sheets))20+ var out []*rawSheet21+ for j, so := range o.sheets {22+ i, inBase := oBase[j]23+ switch {24+ case !inBase:25+ out = append(out, m.added(so, t, tBase, tDone))26+ case hasKey(tOf, i):27+ tDone[tOf[i]] = true28+ out = append(out, m.sheet(b.sheets[i], so, t.sheets[tOf[i]]))29+ case sameSheet(b.sheets[i], so):30+ // removed in theirs, unchanged in ours: removed31+ default:32+ m.said("sheet "+so.name, "", "changed in ours, removed in theirs")33+ out = append(out, so)34+ }35+ }36+ for k, st := range t.sheets {37+ if tDone[k] {38+ continue39+ }40+ i, inBase := tBase[k]41+ switch {42+ case !inBase:43+ out = insertAfter(out, st, t, k)44+ case !hasKey(oOf, i) && !sameSheet(b.sheets[i], st):45+ m.said("sheet "+st.name, "", "removed in ours, changed in theirs")46+ }47+ }48+ return m.unique(out)49+}50+51+// matchIndex is a pairing as maps: a's index to b's, and back.52+func matchIndex(p pairing) (toB, toA map[int]int) {53+ toB, toA = map[int]int{}, map[int]int{}54+ for _, pr := range p.pairs {55+ toB[pr[0]], toA[pr[1]] = pr[1], pr[0]56+ }57+ return toB, toA58+}59+60+func hasKey(m map[int]int, k int) bool { _, ok := m[k]; return ok }61+62+// added is a sheet ours added, merged with one theirs added under the63+// same name, if any, as if both started empty.64+func (m *merger) added(so *rawSheet, t *rawBook, tBase map[int]int, tDone []bool) *rawSheet {65+ k := t.sheetIndex(so.name)66+ if _, inBase := tBase[k]; k < 0 || inBase || tDone[k] {67+ return so68+ }69+ tDone[k] = true70+ return m.sheet(&rawSheet{name: so.name, fields: map[string]json.RawMessage{}, cells: map[string]json.RawMessage{}}, so, t.sheets[k])71+}72+73+// insertAfter puts s, theirs' k-th sheet, after the sheet before it in74+// theirs, or first.75+func insertAfter(out []*rawSheet, s *rawSheet, t *rawBook, k int) []*rawSheet {76+ at := 077+ for p := k - 1; p >= 0; p-- {78+ if i := indexNamed(out, t.sheets[p].name); i >= 0 {79+ at = i + 180+ break81+ }82+ }83+ out = append(out, nil)84+ copy(out[at+1:], out[at:])85+ out[at] = s86+ return out87+}88+89+func indexNamed(list []*rawSheet, name string) int {90+ for i, s := range list {91+ if strings.EqualFold(s.name, name) {92+ return i93+ }94+ }95+ return -196+}97+98+// unique renames sheets that came out with one name, ignoring case, as99+// when ours renamed a sheet to the name of one theirs added.100+func (m *merger) unique(list []*rawSheet) []*rawSheet {101+ for i, s := range list {102+ if indexNamed(list[:i], s.name) < 0 {103+ continue104+ }105+ name := s.name + " (theirs)"106+ for n := 2; indexNamed(list, name) >= 0; n++ {107+ name = s.name + " (theirs " + itoa(n) + ")"108+ }109+ m.said("sheet "+s.name, "name", "a second sheet of this name, from theirs, is named "+name)110+ s.name = name111+ }112+ return list113+}114+115+func itoa(n int) string {116+ raw, _ := json.Marshal(n)117+ return string(raw)118+}119+120+func str(s string) json.RawMessage {121+ raw, _ := json.Marshal(s)122+ return raw123+}124+125+// notebook merges a notebook tab's cells as one part: their sources,126+// with the outputs of the side taken.127+func (m *merger) notebook(sheetName string, b, o, t json.RawMessage) json.RawMessage {128+ sb, so, st := sources(b), sources(o), sources(t)129+ switch {130+ case equal(so, st), equal(sb, st):131+ return o132+ case equal(sb, so):133+ return t134+ }135+ m.said(sheetName+" notebook", "", "cells changed on both sides")136+ return o137+}138+139+// sheet merges one sheet: its name, each cell and each field.140+func (m *merger) sheet(b, o, t *rawSheet) *rawSheet {141+ out := &rawSheet{fields: map[string]json.RawMessage{}, cells: map[string]json.RawMessage{}}142+ json.Unmarshal(m.value("sheet "+o.name, "name", str(b.name), str(o.name), str(t.name)), &out.name)143+ for _, k := range unionKeys3(b.cells, o.cells, t.cells) {144+ if v := m.cell(sheet.QuoteSheet(out.name)+"!"+k, b.cells[k], o.cells[k], t.cells[k]); v != nil {145+ out.cells[k] = v146+ }147+ }148+ for _, k := range unionKeys3(b.fields, o.fields, t.fields) {149+ var v json.RawMessage150+ switch k {151+ case "regions":152+ v = m.list(b.fields[k], o.fields[k], t.fields[k], func(key string) string { return out.name + " region " + key })153+ case "notebookCells":154+ v = m.notebook(out.name, b.fields[k], o.fields[k], t.fields[k])155+ case "widths", "heights", "lines":156+ v = m.object(b.fields[k], o.fields[k], t.fields[k], func(key string) (string, string) { return out.name + " " + k, key })157+ default:158+ v = m.value(out.name+" "+k, "", b.fields[k], o.fields[k], t.fields[k])159+ }160+ if v != nil {161+ out.fields[k] = v162+ }163+ }164+ return out165+}
A internal/diff/notebook.go +160 -0
@@ -0,0 +1,160 @@
1+package diff2+3+import (4+ "encoding/json"5+ "fmt"6+ "strings"7+)8+9+// A notebook tab keeps its cells in the file as a list, each with its10+// source and what its last run left (see docs/files/format.md). Cells11+// have no names of their own to match them by, so comparing lines them12+// up by their sources, and merging treats the list of sources as one13+// part, whose outputs follow the side it's taken from.14+15+// KindNotebook is a Change to a notebook tab's cell: its source, or16+// what its last run left.17+const KindNotebook = "notebook"18+19+// nbCell is a notebook cell as the file has it.20+type nbCell struct {21+ raw json.RawMessage22+ fields map[string]json.RawMessage23+ source string24+}25+26+func nbCells(raw json.RawMessage) []nbCell {27+ var list []json.RawMessage28+ json.Unmarshal(raw, &list)29+ out := make([]nbCell, len(list))30+ for i, e := range list {31+ out[i].raw = compact(e)32+ out[i].fields, _ = object(e)33+ json.Unmarshal(out[i].fields["source"], &out[i].source)34+ }35+ return out36+}37+38+// sources is the part of a notebook merging compares: each cell's kind39+// and source, without the outputs runs leave.40+func sources(raw json.RawMessage) json.RawMessage {41+ if raw == nil {42+ return nil43+ }44+ var parts []string45+ for _, c := range nbCells(raw) {46+ parts = append(parts, string(c.fields["kind"])+" "+c.source)47+ }48+ out, _ := json.Marshal(parts)49+ return out50+}51+52+// notebookChanges compares two notebooks' cells: those whose sources53+// match, in order, by what their runs left; the others as sources54+// changed, added or removed.55+func notebookChanges(sheetName string, a, b json.RawMessage) []Change {56+ ca, cb := nbCells(a), nbCells(b)57+ var out []Change58+ i, j := 0, 059+ for _, m := range commonSources(ca, cb) {60+ out = append(out, unmatched(sheetName, ca[i:m[0]], cb[j:m[1]], i, j)...)61+ out = append(out, cellFields(sheetName, m[1], ca[m[0]], cb[m[1]])...)62+ i, j = m[0]+1, m[1]+163+ }64+ return append(out, unmatched(sheetName, ca[i:], cb[j:], i, j)...)65+}66+67+// unmatched compares the cells between two matched ones: side by side68+// as sources changed, the rest added or removed. i and j are where they69+// start on each side.70+func unmatched(sheetName string, a, b []nbCell, i, j int) []Change {71+ var out []Change72+ for k := 0; k < len(a) || k < len(b); k++ {73+ switch {74+ case k >= len(a):75+ out = append(out, nbChange(sheetName, j+k, "added", nil, b[k].fields["source"]))76+ case k >= len(b):77+ out = append(out, nbChange(sheetName, i+k, "removed", a[k].fields["source"], nil))78+ default:79+ out = append(out, cellFields(sheetName, j+k, a[k], b[k])...)80+ }81+ }82+ return out83+}84+85+// cellFields compares two cells field by field.86+func cellFields(sheetName string, at int, a, b nbCell) []Change {87+ var out []Change88+ for _, k := range unionKeys(a.fields, b.fields) {89+ if !equal(a.fields[k], b.fields[k]) {90+ out = append(out, nbChange(sheetName, at, k, a.fields[k], b.fields[k]))91+ }92+ }93+ return out94+}95+96+func nbChange(sheetName string, at int, field string, a, b json.RawMessage) Change {97+ return rawChange(KindNotebook, sheetName, fmt.Sprintf("cell %d", at+1), field, a, b)98+}99+100+// commonSources pairs the cells of a and b whose sources are the same,101+// as many as keep their order (a longest common subsequence), by index.102+func commonSources(a, b []nbCell) [][2]int {103+ n, m := len(a), len(b)104+ lcs := make([][]int, n+1)105+ for i := range lcs {106+ lcs[i] = make([]int, m+1)107+ }108+ for i := n - 1; i >= 0; i-- {109+ for j := m - 1; j >= 0; j-- {110+ if a[i].source == b[j].source {111+ lcs[i][j] = lcs[i+1][j+1] + 1112+ } else {113+ lcs[i][j] = max(lcs[i+1][j], lcs[i][j+1])114+ }115+ }116+ }117+ var out [][2]int118+ for i, j := 0, 0; i < n && j < m; {119+ switch {120+ case a[i].source == b[j].source:121+ out = append(out, [2]int{i, j})122+ i, j = i+1, j+1123+ case lcs[i+1][j] >= lcs[i][j+1]:124+ i++125+ default:126+ j++127+ }128+ }129+ return out130+}131+132+// dumpNotebook writes a notebook's cells as Dump's lines: the source,133+// and the first line of what the last run printed or said.134+func dumpNotebook(sheetName string, raw json.RawMessage) []string {135+ var out []string136+ for i, c := range nbCells(raw) {137+ line := fmt.Sprintf("%s cell %d %s", sheetName, i+1, quoted(c.source))138+ var s string139+ switch {140+ case json.Unmarshal(c.fields["error"], &s) == nil && s != "":141+ line += " error: " + firstLine(s)142+ case json.Unmarshal(c.fields["output"], &s) == nil && s != "":143+ line += " = " + firstLine(s)144+ }145+ out = append(out, line)146+ }147+ return out148+}149+150+// firstLine is s's first line, at most 120 characters of it.151+func firstLine(s string) string {152+ s, _, cut := strings.Cut(s, "\n")153+ if r := []rune(s); len(r) > 120 {154+ s, cut = string(r[:120]), true155+ }156+ if cut {157+ s += "…"158+ }159+ return s160+}
A internal/diff/raw.go +211 -0
@@ -0,0 +1,211 @@
1+package diff2+3+import (4+ "bytes"5+ "encoding/json"6+ "fmt"7+ "maps"8+ "slices"9+ "strconv"10+ "strings"11+12+ "github.com/FelineStateMachine/012/internal/sheet"13+)14+15+// A .012 file is JSON whose sheets hold their cells as a map by address16+// and their other fields (widths, rules, regions) beside them; see17+// docs/files/format.md. Diffing and merging read it as that JSON, field18+// by field, so every field is compared and kept, including ones this19+// package knows nothing about, and the workbook the fields make is read20+// with the engine only for the values formulas compute.21+22+// rawBook is a file's fields: the workbook's, and each sheet's.23+type rawBook struct {24+ top map[string]json.RawMessage // the workbook's fields, compact, without "sheets"25+ sheets []*rawSheet26+}27+28+// rawSheet is one sheet's fields, compact.29+type rawSheet struct {30+ name string31+ fields map[string]json.RawMessage // everything but name and cells32+ cells map[string]json.RawMessage // by address33+}34+35+// bookKeys are the workbook's fields; in a file of one sheet (versions36+// 1 to 3) every other top-level field is the sheet's.37+var bookKeys = map[string]bool{"version": true, "names": true, "active": true, "arithmetic": true,38+ "locale": true, "macroOrigin": true, "shellHistory": true, "macros": true, "sheets": true}39+40+// parseRaw reads a file's fields. An empty file (git's stand-in for a41+// file that isn't there) is a workbook without sheets.42+func parseRaw(data []byte) (*rawBook, error) {43+ b := &rawBook{top: map[string]json.RawMessage{}}44+ if len(bytes.TrimSpace(data)) == 0 {45+ return b, nil46+ }47+ var top map[string]json.RawMessage48+ if err := json.Unmarshal(data, &top); err != nil {49+ return nil, err50+ }51+ if raw, ok := top["sheets"]; ok {52+ var sheets []map[string]json.RawMessage53+ if err := json.Unmarshal(raw, &sheets); err != nil {54+ return nil, fmt.Errorf("sheets: %w", err)55+ }56+ for _, fields := range sheets {57+ s, err := parseSheet(fields)58+ if err != nil {59+ return nil, err60+ }61+ b.sheets = append(b.sheets, s)62+ }63+ } else {64+ fields := map[string]json.RawMessage{}65+ for k, v := range top {66+ if !bookKeys[k] {67+ fields[k] = v68+ }69+ }70+ s, err := parseSheet(fields)71+ if err != nil {72+ return nil, err73+ }74+ if s.name == "" {75+ s.name = "Sheet1"76+ }77+ b.sheets = []*rawSheet{s}78+ if err := qualifyNames(top, s.name); err != nil {79+ return nil, err80+ }81+ }82+ for k, v := range top {83+ if bookKeys[k] && k != "sheets" {84+ b.top[k] = compact(v)85+ }86+ }87+ return b, nil88+}89+90+// parseSheet splits a sheet's fields into its name, cells and the rest.91+func parseSheet(fields map[string]json.RawMessage) (*rawSheet, error) {92+ s := &rawSheet{fields: map[string]json.RawMessage{}, cells: map[string]json.RawMessage{}}93+ if raw, ok := fields["name"]; ok {94+ if err := json.Unmarshal(raw, &s.name); err != nil {95+ return nil, fmt.Errorf("sheet name: %w", err)96+ }97+ }98+ if raw, ok := fields["cells"]; ok && string(raw) != "null" {99+ if err := json.Unmarshal(raw, &s.cells); err != nil {100+ return nil, fmt.Errorf("sheet %s: cells: %w", s.name, err)101+ }102+ }103+ for k, v := range s.cells {104+ s.cells[k] = compact(v)105+ }106+ for k, v := range fields {107+ if k != "name" && k != "cells" {108+ s.fields[k] = compact(v)109+ }110+ }111+ return s, nil112+}113+114+// qualifyNames writes a one-sheet file's named ranges with their sheet,115+// as files of several sheets have them, so files of both shapes compare.116+func qualifyNames(top map[string]json.RawMessage, sheetName string) error {117+ raw, ok := top["names"]118+ if !ok {119+ return nil120+ }121+ var names map[string]string122+ if err := json.Unmarshal(raw, &names); err != nil {123+ return fmt.Errorf("names: %w", err)124+ }125+ for k, ref := range names {126+ if ref != "#REF!" && !strings.Contains(ref, "!") {127+ names[k] = sheet.QuoteSheet(sheetName) + "!" + ref128+ }129+ }130+ top["names"], _ = json.Marshal(names)131+ return nil132+}133+134+// compact is raw without insignificant space, so equal values compare135+// equal byte for byte.136+func compact(raw json.RawMessage) json.RawMessage {137+ var b bytes.Buffer138+ if json.Compact(&b, raw) != nil {139+ return raw140+ }141+ return b.Bytes()142+}143+144+// marshal writes the fields as a file of several sheets, which the145+// engine reads whatever the count; the engine's own writer then gives146+// the file its usual shape.147+func (b *rawBook) marshal() []byte {148+ var out bytes.Buffer149+ out.WriteString("{\n")150+ version := json.RawMessage("4")151+ if n, err := strconv.Atoi(string(b.top["version"])); err == nil && n > 4 {152+ version = b.top["version"]153+ }154+ fmt.Fprintf(&out, "%q: %s", "version", version)155+ for _, k := range slices.Sorted(maps.Keys(b.top)) {156+ if k != "version" {157+ fmt.Fprintf(&out, ",\n%q: %s", k, b.top[k])158+ }159+ }160+ out.WriteString(",\n\"sheets\": [")161+ for i, s := range b.sheets {162+ if i > 0 {163+ out.WriteByte(',')164+ }165+ name, _ := json.Marshal(s.name)166+ fmt.Fprintf(&out, "\n{\"name\": %s", name)167+ for _, k := range slices.Sorted(maps.Keys(s.fields)) {168+ fmt.Fprintf(&out, ",\n%q: %s", k, s.fields[k])169+ }170+ out.WriteString(",\n\"cells\": {")171+ for j, k := range slices.Sorted(maps.Keys(s.cells)) {172+ if j > 0 {173+ out.WriteByte(',')174+ }175+ fmt.Fprintf(&out, "\n%q: %s", k, s.cells[k])176+ }177+ out.WriteString("}}")178+ }179+ out.WriteString("]\n}\n")180+ return out.Bytes()181+}182+183+// normalize reads merged fields with the engine and writes them as 012184+// saves a workbook, checking that they make one.185+func normalize(b *rawBook) ([]byte, error) {186+ w, err := sheet.ReadBook(bytes.NewReader(b.marshal()))187+ if err != nil {188+ return nil, err189+ }190+ var out bytes.Buffer191+ if err := w.Write(&out); err != nil {192+ return nil, err193+ }194+ return out.Bytes(), nil195+}196+197+// equal reports whether two compact values are the same; nil is a198+// field or cell that isn't there.199+func equal(a, b json.RawMessage) bool {200+ return (a == nil) == (b == nil) && bytes.Equal(a, b)201+}202+203+// sheetIndex finds a sheet by name, ignoring case as the engine does.204+func (b *rawBook) sheetIndex(name string) int {205+ for i, s := range b.sheets {206+ if strings.EqualFold(s.name, name) {207+ return i208+ }209+ }210+ return -1211+}
A internal/diff/text.go +199 -0
@@ -0,0 +1,199 @@
1+package diff2+3+import (4+ "bufio"5+ "encoding/json"6+ "fmt"7+ "io"8+ "maps"9+ "slices"10+ "strings"11+12+ "github.com/FelineStateMachine/012/internal/nuon"13+ "github.com/FelineStateMachine/012/internal/sheet"14+)15+16+// SGR codes of the text output's colors, from the 16 ANSI colors so the17+// terminal's palette applies; the words carry the meaning without them.18+const (19+ sgrWhere = "\x1b[1m" // bold: the place20+ sgrField = "\x1b[36m" // cyan: what about it21+ sgrOld = "\x1b[31m" // red: what it was22+ sgrNew = "\x1b[32m" // green: what it is23+ sgrReset = "\x1b[0m"24+)25+26+// Formats are what WriteChanges writes.27+var Formats = []string{"text", "json", "nuon"}28+29+// WriteChanges writes changes in format: text, a line each (colored with30+// color), or a table of records with the columns kind, sheet, item,31+// field, old and new as JSON or NUON.32+func WriteChanges(w io.Writer, changes []Change, format string, color bool) error {33+ switch format {34+ case "", "text":35+ return writeText(w, changes, color)36+ case "json":37+ return writeJSON(w, changes)38+ case "nuon":39+ return writeNUON(w, changes)40+ }41+ return fmt.Errorf("--format %s: 012 diff writes %s", format, strings.Join(Formats, ", "))42+}43+44+// writeText writes a line a change: its place, what about it changed,45+// and "old → new", "+ new" for something added or "- old" for46+// something removed.47+func writeText(w io.Writer, changes []Change, color bool) error {48+ bw := bufio.NewWriter(w)49+ paint := func(sgr, s string) string {50+ if !color || s == "" {51+ return s52+ }53+ return sgr + s + sgrReset54+ }55+ for _, c := range changes {56+ line := paint(sgrWhere, c.Where())57+ if c.Field != "" {58+ line += " " + paint(sgrField, c.Field)59+ }60+ switch {61+ case c.Kind == KindSheet:62+ line += sheetText(c)63+ case c.Old.Kind == nuon.Null:64+ line += " " + paint(sgrNew, "+ "+quoted(c.newText))65+ case c.New.Kind == nuon.Null:66+ line += " " + paint(sgrOld, "- "+quoted(c.oldText))67+ default:68+ line += " " + paint(sgrOld, quoted(c.oldText)) + " → " + paint(sgrNew, quoted(c.newText))69+ }70+ bw.WriteString(line + "\n")71+ }72+ return bw.Flush()73+}74+75+// sheetText is the end of a sheet's line: its name or place in the76+// first workbook, for a sheet renamed or put elsewhere.77+func sheetText(c Change) string {78+ switch c.Field {79+ case "renamed":80+ return " from " + c.oldText81+ case "moved":82+ return " from " + c.oldText + " to " + c.newText83+ }84+ return ""85+}86+87+var recordCols = []string{"kind", "sheet", "item", "field", "old", "new"}88+89+func (c Change) values() []nuon.Value {90+ str := func(s string) nuon.Value { return nuon.StringValue(s) }91+ return []nuon.Value{str(c.Kind), str(c.Sheet), str(c.Item), str(c.Field), c.Old, c.New}92+}93+94+func writeNUON(w io.Writer, changes []Change) error {95+ tw := nuon.NewTableWriter(w, recordCols)96+ for _, c := range changes {97+ if err := tw.Write(c.values()); err != nil {98+ return err99+ }100+ }101+ return tw.Close()102+}103+104+func writeJSON(w io.Writer, changes []Change) error {105+ bw := bufio.NewWriter(w)106+ bw.WriteString("[")107+ fields := make([]nuon.Field, len(recordCols))108+ for i, c := range changes {109+ for j, v := range c.values() {110+ fields[j] = nuon.Field{Key: recordCols[j], Value: v}111+ }112+ if i > 0 {113+ bw.WriteString(",")114+ }115+ bw.WriteString("\n")116+ bw.Write(nuon.AppendJSONRecord(nil, fields))117+ }118+ if len(changes) > 0 {119+ bw.WriteString("\n")120+ }121+ bw.WriteString("]\n")122+ return bw.Flush()123+}124+125+// Dump writes a workbook as lines of text, one for each cell and each126+// field, each naming its sheet: what git diffs when 012 diff --textconv127+// is a .012 file's textconv. A formula's line ends with its value,128+// unless it changes on every recalculation (NOW, RAND and what reads129+// them), which would show as a change each time.130+func Dump(w io.Writer, b *Book) error {131+ bw := bufio.NewWriter(w)132+ for _, s := range b.raw.sheets {133+ sd := b.side(s)134+ q := sheet.QuoteSheet(s.name)135+ fmt.Fprintf(bw, "sheet %s\n", s.name)136+ for _, k := range slices.Sorted(maps.Keys(s.fields)) {137+ if k == "notebookCells" {138+ for _, line := range dumpNotebook(s.name, s.fields[k]) {139+ bw.WriteString(line + "\n")140+ }141+ continue142+ }143+ if k == "regions" {144+ for _, name := range slices.Sorted(maps.Keys(named(s.fields[k]))) {145+ fmt.Fprintf(bw, "%s region %s %s\n", s.name, name, named(s.fields[k])[name])146+ }147+ continue148+ }149+ fmt.Fprintf(bw, "%s %s %s\n", s.name, k, s.fields[k])150+ }151+ for _, at := range sortedAddrs(s.cells) {152+ bw.WriteString(q + "!" + cellLine(sd, at, s.cells[at.String()]) + "\n")153+ }154+ }155+ names, _ := object(b.raw.top["names"])156+ for _, k := range slices.Sorted(maps.Keys(names)) {157+ fmt.Fprintf(bw, "name %s %s\n", k, rawText(names[k]))158+ }159+ macros := named(b.raw.top["macros"])160+ for _, k := range slices.Sorted(maps.Keys(macros)) {161+ fmt.Fprintf(bw, "macro %s %s\n", k, macros[k])162+ }163+ for _, k := range slices.Sorted(maps.Keys(b.raw.top)) {164+ if !quietKeys[k] && k != "names" && k != "macros" {165+ fmt.Fprintf(bw, "workbook %s %s\n", k, b.raw.top[k])166+ }167+ }168+ return bw.Flush()169+}170+171+// cellLine is a cell as Dump writes it after its sheet: B7 =SUM(A1:A6)172+// = 21 [currency, decimals 2] note: …173+func cellLine(sd side, at sheet.Addr, raw json.RawMessage) string {174+ p := splitCell(raw)175+ line := at.String() + " " + quoted(p.input)176+ if sheet.IsFormulaEntry(p.input) && sd.s != nil && !sd.vol[at] {177+ _, shown := cellValue(sd.s, at)178+ line += " = " + quoted(shown)179+ }180+ if p.format != nil {181+ line += " [" + formatText(p.format) + "]"182+ }183+ if p.note != "" {184+ line += " note: " + quoted(p.note)185+ }186+ return line187+}188+189+// sortedAddrs are the cells' addresses in row order.190+func sortedAddrs(cells map[string]json.RawMessage) []sheet.Addr {191+ var out []sheet.Addr192+ for k := range cells {193+ if at, ok := sheet.ParseAddr(k); ok {194+ out = append(out, at)195+ }196+ }197+ slices.SortFunc(out, byRow)198+ return out199+}
M internal/fileio/nuonwrite.go +4 -0
A internal/fileio/sheetscorpus_test.go +241 -0
@@ -0,0 +1,241 @@
1+package fileio2+3+import (4+ "flag"5+ "fmt"6+ "os"7+ "strconv"8+ "strings"9+ "testing"10+11+ "github.com/FelineStateMachine/012/internal/sheet"12+)13+14+// The Sheets corpus (testdata/sheets_corpus.tsv) holds formulas whose15+// results 012 matches with Google Sheets on purpose, each with 012's16+// result and Sheets' as checked in Sheets. TestSheetsCorpus holds 012 to17+// its recorded results and asks every difference from Sheets for a18+// reason. The corpus reaches Sheets as an XLSX workbook written by 012's19+// own exporter (sheetscorpus_xlsx_test.go), and Sheets' answers come back20+// as a CSV download of its corpus sheet.21+var (22+ updateCorpus = flag.Bool("update-corpus", false, "record 012's current results in testdata/sheets_corpus.tsv")23+ corpusXLSX = flag.String("corpus-xlsx", "", "write the Sheets corpus as an XLSX workbook to this path, to import into Sheets")24+ corpusFrom = flag.String("update-from", "", "record Sheets' results from a CSV download of the imported corpus sheet")25+)26+27+const (28+ corpusPath = "testdata/sheets_corpus.tsv"29+ unconfirmed = "unconfirmed" // Sheets' result, until checked in Sheets30+ onPurpose = "on purpose:" // starts the note of a deliberate difference31+)32+33+// corpusFixture is the Data sheet the corpus formulas read, typed as a34+// user would: the oracle's fixture, with a text number, an error, a35+// FALSE and a #N/A in column J.36+//37+// A B C D E F G H I J38+// 1 10 north 1 Apple 1.5 9/26/2026 1 -1000 30 '339+// 2 20 south 2 Banana 2.5 2026-01-31 2 300 20 =1/040+// 3 30 north 3 Cherry 3.5 14:30 2 400 10 FALSE41+// 4 apple east 4 date x 2/29/2024 3 500 =NA()42+// 5 north 5 Apple 9 12/31/2025 543+// 6 ="" 6 TRUE44+// 7 -5 Ab*d 745+var corpusFixture = map[string]string{46+ "A1": "10", "A2": "20", "A3": "30", "A4": "apple", "A7": "-5",47+ "B1": "north", "B2": "south", "B3": "north", "B4": "east", "B5": "north", "B6": `=""`, "B7": "Ab*d",48+ "C1": "1", "C2": "2", "C3": "3", "C4": "4", "C5": "5", "C6": "6", "C7": "7",49+ "D1": "Apple", "D2": "Banana", "D3": "Cherry", "D4": "date", "D5": "Apple",50+ "E1": "1.5", "E2": "2.5", "E3": "3.5", "E4": "x", "E5": "9", "E6": "TRUE",51+ "F1": "9/26/2026", "F2": "2026-01-31", "F3": "14:30", "F4": "2/29/2024", "F5": "12/31/2025",52+ "G1": "1", "G2": "2", "G3": "2", "G4": "3",53+ "H1": "-1000", "H2": "300", "H3": "400", "H4": "500",54+ "I1": "30", "I2": "20", "I3": "10",55+ "J1": "'3", "J2": "=1/0", "J3": "FALSE", "J4": "=NA()",56+}57+58+// The corpus sheet's columns: each formula is in C, and D and E read its59+// result as text and its kind with formulas both 012 and Sheets compute,60+// so both answer in the same terms.61+const (62+ colSection = iota63+ colFormula64+ colResult65+ colText66+ colKind67+ colOurs68+ colCheck69+ colNote70+)71+72+var corpusHeaders = []string{"section", "formula", "result", "as text", "kind", "012 result", "check", "note"}73+74+// corpusLine is a line of the corpus file: a formula row, or a comment,75+// section heading or blank line kept as it is.76+type corpusLine struct {77+ text string78+ section string79+ formula string80+ ours string // 012's recorded result81+ sheets string // Sheets' result, or unconfirmed82+ note string83+}84+85+func readCorpus(t *testing.T) []*corpusLine {86+ t.Helper()87+ data, err := os.ReadFile(corpusPath)88+ if err != nil {89+ t.Fatal(err)90+ }91+ var out []*corpusLine92+ section := ""93+ for _, text := range strings.Split(strings.TrimSuffix(string(data), "\n"), "\n") {94+ if !strings.HasPrefix(text, "=") {95+ if h, ok := strings.CutPrefix(text, "## "); ok {96+ section = h97+ }98+ out = append(out, &corpusLine{text: text})99+ continue100+ }101+ f := append(strings.Split(text, "\t"), "", "", "")102+ out = append(out, &corpusLine{section: section, formula: f[0], ours: f[1], sheets: f[2], note: f[3]})103+ }104+ return out105+}106+107+func writeCorpus(t *testing.T, lines []*corpusLine) {108+ t.Helper()109+ var b strings.Builder110+ for _, l := range lines {111+ if l.formula == "" {112+ b.WriteString(l.text + "\n")113+ continue114+ }115+ if l.sheets == "" {116+ l.sheets = unconfirmed117+ }118+ b.WriteString(strings.TrimRight(strings.Join([]string{l.formula, l.ours, l.sheets, l.note}, "\t"), "\t") + "\n")119+ }120+ if err := os.WriteFile(corpusPath, []byte(b.String()), 0o644); err != nil {121+ t.Fatal(err)122+ }123+}124+125+// corpusRows are the formula rows of lines, in order.126+func corpusRows(lines []*corpusLine) []*corpusLine {127+ var rows []*corpusLine128+ for _, l := range lines {129+ if l.formula != "" {130+ rows = append(rows, l)131+ }132+ }133+ return rows134+}135+136+// corpusBook builds the workbook the corpus is computed in: the Corpus137+// sheet, a row per formula under a header row, and the Data sheet.138+func corpusBook(t *testing.T, rows []*corpusLine) *sheet.Sheet {139+ t.Helper()140+ s := sheet.New()141+ book := s.Book()142+ if err := book.RenameSheet(s, "Corpus"); err != nil {143+ t.Fatal(err)144+ }145+ data, err := book.AddSheet("Data", 1)146+ if err != nil {147+ t.Fatal(err)148+ }149+ for a, in := range corpusFixture {150+ at := addr(t, a)151+ setAt(t, data, at.Col, at.Row, in)152+ }153+ for c, h := range corpusHeaders {154+ setAt(t, s, c, 0, "'"+h)155+ }156+ for i, r := range rows {157+ n := strconv.Itoa(i + 2)158+ setAt(t, s, colSection, i+1, r.section)159+ setAt(t, s, colFormula, i+1, "'"+r.formula)160+ setAt(t, s, colResult, i+1, r.formula)161+ setAt(t, s, colText, i+1, `=C`+n+`&""`)162+ setAt(t, s, colKind, i+1, kindFormula("C"+n))163+ }164+ for c, w := range []int{22, 48, 14, 24, 8, 24, 8, 30} {165+ s.SetColWidth(c, w)166+ }167+ return s168+}169+170+// kindFormula names the kind of the value in ref, in words both 012 and171+// Sheets compute alike.172+func kindFormula(ref string) string {173+ return fmt.Sprintf(`=IF(ISERROR(%[1]s), "error", IF(ISNUMBER(%[1]s), "number", IF(ISTEXT(%[1]s), "text", `+174+ `IF(ISLOGICAL(%[1]s), "logical", IF(ISBLANK(%[1]s), "blank", "other")))))`, ref)175+}176+177+// setAt types in into the cell at col and row of s.178+func setAt(t *testing.T, s *sheet.Sheet, col, row int, in string) {179+ t.Helper()180+ a := sheet.Addr{Col: col, Row: row}181+ if err := s.Set(a, in); err != nil {182+ t.Errorf("%s %q: %v", a, in, err)183+ }184+}185+186+// corpusResult reads what 012 computed for the formula on row i of the187+// corpus sheet, in the corpus file's terms.188+func corpusResult(s *sheet.Sheet, i int) string {189+ text := s.Value(sheet.Addr{Col: colText, Row: i + 1})190+ kind := s.Value(sheet.Addr{Col: colKind, Row: i + 1})191+ return encodeResult(text.String(), kind.String())192+}193+194+// encodeResult writes a result, given as text and its kind, as the195+// corpus file does: text quoted, (blank) for an empty cell, and196+// numbers, booleans and errors as they read.197+func encodeResult(text, kind string) string {198+ switch kind {199+ case "number", "logical", "error":200+ return text201+ case "text":202+ return strconv.Quote(text)203+ case "blank":204+ return "(blank)"205+ }206+ return "(" + kind + ") " + text207+}208+209+func TestSheetsCorpus(t *testing.T) {210+ lines := readCorpus(t)211+ rows := corpusRows(lines)212+ s := corpusBook(t, rows)213+ if *corpusFrom != "" {214+ foldSheets(t, rows, *corpusFrom)215+ }216+ for i, r := range rows {217+ got := corpusResult(s, i)218+ if *updateCorpus {219+ r.ours = got220+ }221+ checkCorpusRow(t, r, got)222+ }223+ if *updateCorpus || *corpusFrom != "" {224+ writeCorpus(t, lines)225+ }226+ if *corpusXLSX != "" {227+ exportCorpus(t, s, rows, *corpusXLSX)228+ }229+}230+231+// checkCorpusRow holds 012 to its recorded result, and a difference232+// from Sheets to a note saying why.233+func checkCorpusRow(t *testing.T, r *corpusLine, got string) {234+ t.Helper()235+ if got != r.ours {236+ t.Errorf("%s = %s, recorded %s (go test ./internal/fileio -run SheetsCorpus -update-corpus records it)", r.formula, got, r.ours)237+ }238+ if r.sheets != "" && r.sheets != unconfirmed && r.sheets != r.ours && !strings.HasPrefix(r.note, onPurpose) {239+ t.Errorf("%s = %s in 012 and %s in Sheets: make 012 agree, or say why in a note starting %q", r.formula, r.ours, r.sheets, onPurpose)240+ }241+}
A internal/fileio/sheetscorpus_xlsx_test.go +96 -0
@@ -0,0 +1,96 @@
1+package fileio2+3+import (4+ "context"5+ "encoding/csv"6+ "os"7+ "slices"8+ "strconv"9+ "testing"10+11+ "github.com/FelineStateMachine/012/internal/sheet"12+)13+14+// sheetsOnlyNote marks a corpus row whose formula 012's exporter saves as15+// its value, because Excel has nothing the same (formula.go): in Sheets16+// the formula has to be typed into C.17+const sheetsOnlyNote = "saved as a value: type the formula in B into C"18+19+// exportCorpus writes the corpus workbook s as an XLSX file at name with20+// 012's exporter, for importing into Sheets. Sheets recalculates C, D21+// and E on import; F keeps 012's results, and G compares the two.22+func exportCorpus(t *testing.T, s *sheet.Sheet, rows []*corpusLine, name string) {23+ t.Helper()24+ for i, r := range rows {25+ n := strconv.Itoa(i + 2)26+ setAt(t, s, colOurs, i+1, "'"+corpusResult(s, i))27+ setAt(t, s, colCheck, i+1, checkFormula(n))28+ note := r.note29+ if !excelKeeps(r.formula) {30+ setAt(t, s, colResult, i+1, "")31+ note = sheetsOnlyNote32+ }33+ setAt(t, s, colNote, i+1, note)34+ }35+ res, err := Export(context.Background(), name, XLSX, SnapBook(s), ExportOptions{})36+ if err != nil {37+ t.Fatal(err)38+ }39+ for _, n := range res.Notes {40+ t.Log(n)41+ }42+ t.Logf("wrote %d formulas to %s", len(rows), name)43+}44+45+// checkFormula compares, on row n, the result in D and E with 012's in46+// F, writing it as encodeResult does. Errors only say both are errors;47+// the error in C and the one in F are compared by eye.48+func checkFormula(n string) string {49+ d, e, f := "D"+n, "E"+n, "F"+n50+ enc := `IF(` + e + `="text", """" & ` + d + ` & """", IF(` + e + `="blank", "(blank)", ` + d + ` & ""))`51+ return `=IFERROR(IF(EXACT(` + enc + `, ` + f + `), "same", "DIFFERS"), IF(LEFT(` + f + `, 1)="#", "both errors", "DIFFERS"))`52+}53+54+// excelKeeps reports whether 012's exporter keeps formula as a formula.55+func excelKeeps(formula string) bool {56+ _, ok := toExcelFormula(formula, nil)57+ return ok58+}59+60+// foldSheets records Sheets' results from name, a CSV download of the61+// corpus sheet after importing the XLSX workbook into Sheets. Rows left62+// out of the download, and formulas saved as values that weren't typed63+// in, stay as they are.64+func foldSheets(t *testing.T, rows []*corpusLine, name string) {65+ t.Helper()66+ f, err := os.Open(name)67+ if err != nil {68+ t.Fatal(err)69+ }70+ defer f.Close()71+ recs, err := csv.NewReader(f).ReadAll()72+ if err != nil {73+ t.Fatal(err)74+ }75+ if len(recs) == 0 || !slices.Equal(recs[0][:colOurs], corpusHeaders[:colOurs]) {76+ t.Fatalf("%s: expected a CSV download of the corpus sheet, with headers %q", name, corpusHeaders[:colOurs])77+ }78+ sheets := map[string]string{}79+ for _, rec := range recs[1:] {80+ if len(rec) > colKind {81+ sheets[rec[colFormula]] = encodeResult(rec[colText], rec[colKind])82+ }83+ }84+ skipped := 085+ for _, r := range rows {86+ got, ok := sheets[r.formula]87+ if !ok || (got == "(blank)" && !excelKeeps(r.formula)) {88+ skipped++89+ continue90+ }91+ r.sheets = got92+ }93+ if skipped > 0 {94+ t.Logf("%d formulas have no result from Sheets in %s", skipped, name)95+ }96+}
A internal/fileio/testdata/sheets_corpus.tsv +320 -0
@@ -0,0 +1,320 @@
1+# Formulas where 012 matches Google Sheets on purpose (docs/contributing/testing.md).2+# Tab-separated: formula, 012's result, Sheets' result, note.3+# Results read "text" (quoted), 12.5, TRUE, #N/A, or (blank) for an empty cell.4+# Sheets' result is "unconfirmed" until checked in Sheets. A row where it5+# differs from 012's needs a note starting "on purpose:" that says why.6+# Every formula computes one value; array results are measured with ROWS,7+# COLUMNS, INDEX or TEXTJOIN. Data! is the fixture sheet (sheetscorpus_test.go).8+## Dates and times9+=DATE(2026, 9, 26) 46291 unconfirmed10+=DATE(2026, 14, 1) 46419 unconfirmed11+=DATE(2026, 1, 0) 46022 unconfirmed12+=DATE(26, 1, 1) 9498 unconfirmed13+=DATE(1899, 12, 30) 693961 unconfirmed14+=DATE(1900, 1, 1) 2 unconfirmed15+=DATE(1900, 2, 29) 61 unconfirmed16+=DATE(-1, 1, 1) #NUM! unconfirmed17+=DATE("2026", "9", "26") 46291 unconfirmed18+=TIME(14, 30, 0) 0.604166666666667 unconfirmed19+=TIME(25, 0, 0) 0.0416666666666667 unconfirmed20+=TIME(0, -1, 0) #NUM! unconfirmed21+=YEAR(0) 1899 unconfirmed22+=DAY(Data!A5) 30 unconfirmed23+=YEAR(Data!A4) #VALUE! unconfirmed24+=WEEKDAY(Data!F1) 7 unconfirmed25+=WEEKDAY(Data!F1, 2) 6 unconfirmed26+=WEEKDAY(Data!F1, 4) #NUM! unconfirmed27+=HOUR(0.99999999) 0 unconfirmed28+=HOUR(1.75) 18 unconfirmed29+=MINUTE("14:30:59") 30 unconfirmed30+=SECOND(59.6/86400) 0 unconfirmed31+=EDATE(DATE(2024, 2, 29), 12) 45716 unconfirmed32+=EDATE(Data!F1, 1.9) 46321 unconfirmed33+=EOMONTH(DATE(2026, 1, 31), 1) 46081 unconfirmed34+=DATEDIF(Data!F4, Data!F1, "MD") 28 unconfirmed35+=DATEDIF(Data!F4, Data!F1, "YD") 210 unconfirmed36+=DATEDIF(DATE(2024, 1, 31), DATE(2024, 3, 1), "MD") -1 unconfirmed37+=DATEDIF(Data!F2, Data!F5, "D") #NUM! unconfirmed38+=DAYS(Data!F1, Data!F2) 238 unconfirmed39+=NETWORKDAYS(Data!F5, Data!F2, Data!F5) 22 unconfirmed40+=NETWORKDAYS(Data!F1, Data!F1) 0 unconfirmed41+=DATEVALUE("9/26/2026") 46291 unconfirmed42+=DATEVALUE("Sep 26, 2026") 46291 unconfirmed43+=DATEVALUE("9/26/2026 18:00") 46291 unconfirmed44+=DATEVALUE("2/30/2026") #VALUE! unconfirmed45+=DATEVALUE(46291) #VALUE! unconfirmed46+=TIMEVALUE("2:30 PM") 0.604166666666667 unconfirmed47+=TIMEVALUE("9/26/2026 6:00") 0.25 unconfirmed48+=TIMEVALUE("noon") #VALUE! unconfirmed49+="2026-09-27"-Data!F1 1 unconfirmed50+=Data!F1+Data!F3 46291.6041666667 unconfirmed51+=Data!F1="2026-09-26" FALSE unconfirmed52+## Text53+=CONCATENATE("a", 1, TRUE) "a1TRUE" unconfirmed54+=CONCATENATE(Data!A1:A3) "102030" unconfirmed55+=TEXTJOIN(", ", TRUE, Data!A3:A5) "30, apple" unconfirmed56+=TEXTJOIN(",", FALSE, Data!B5:B7) "north,,Ab*d" unconfirmed57+=LEFT("hi", -1) #VALUE! unconfirmed58+=LEFT(12345, 2) "12" unconfirmed59+=LEFT(TRUE, 1) "T" unconfirmed60+=MID("abc", 5, 2) "" unconfirmed61+=MID("abc", 0, 2) #VALUE! unconfirmed62+=LEN(Data!F1) 5 unconfirmed63+=LEN(1/4) 4 unconfirmed64+=LEN("héllo") 5 unconfirmed65+=LEN("😀") 1 unconfirmed66+=LEFT("日本語", 2) "日本" unconfirmed67+=UPPER("straße") "STRAßE" unconfirmed68+=UPPER(1.5) "1.5" unconfirmed69+=PROPER("hello wORLD it's") "Hello World It'S" unconfirmed70+=PROPER("o'neil mcdonald 3rd") "O'Neil Mcdonald 3Rd" unconfirmed71+=TRIM(" a b ") "a b" unconfirmed72+=SUBSTITUTE("aaa", "aa", "b") "ba" unconfirmed73+=SUBSTITUTE("abc", "", "x") "abc" unconfirmed74+=REPLACE("abc", 10, 1, "X") "abcX" unconfirmed75+=FIND("B", "abc") #VALUE! unconfirmed76+=FIND("", "abc") 1 unconfirmed77+=SEARCH("b?d", "abcbxd") 4 unconfirmed78+=SEARCH("~*", "a*b") 2 unconfirmed79+=REPT("ab", 2.9) "abab" unconfirmed80+=REPT("ab", -1) #VALUE! unconfirmed81+=EXACT(1, "1") TRUE unconfirmed82+=EXACT(TRUE, "TRUE") TRUE unconfirmed83+="a"&Data!A5 "a" unconfirmed84+=1&2 "12" unconfirmed85+=VALUE("1,234.5") 1234.5 unconfirmed86+=VALUE(" 42 ") 42 unconfirmed87+=VALUE("") 0 unconfirmed88+=VALUE("abc") #VALUE! unconfirmed89+=VALUE("9/26/2026") 46291 unconfirmed90+=VALUE(TRUE) #VALUE! unconfirmed91+## Regular expressions and SPLIT92+=REGEXMATCH("ABC", "(?i)abc") TRUE unconfirmed93+=REGEXMATCH(123, "1") #VALUE! unconfirmed94+=REGEXEXTRACT("abc", "\d+") #N/A unconfirmed95+=TEXTJOIN("|", TRUE, REGEXEXTRACT("a1b2", "(\d)b(\d)")) "1" unconfirmed96+=REGEXREPLACE("john smith", "(\w+) (\w+)", "$2, $1") "smith, john" unconfirmed97+=COLUMNS(SPLIT("a,b,,c", ",", TRUE, FALSE)) 4 unconfirmed98+=SUM(SPLIT("1,2,3", ",")) 6 unconfirmed99+=COLUMNS(SPLIT("a-b_c", "-_")) 3 unconfirmed100+## TEXT and number formats101+=TEXT(1234.567, "$#,##0.00") "$1,234.57" unconfirmed102+=TEXT(1234567.891, "#,##0,") "1,235" unconfirmed103+=TEXT(1234.5, "0.00E+00") "1.23E+03" unconfirmed104+=TEXT(0.0000000001, "0.00E+00") "1.00E-10" unconfirmed105+=TEXT(-1234.5, "#,##0.00;(#,##0.00)") "(1,234.50)" unconfirmed106+=TEXT(0, "0;-0;\z\e\r\o") "zero" unconfirmed107+=TEXT(3, "0.###") "3." unconfirmed108+=TEXT(2.5, "0") "3" unconfirmed109+=TEXT(-0.5, "0") "-1" unconfirmed110+=TEXT(1.005, "0.00") "1.01" unconfirmed111+=TEXT(0.75, "# ?/?") " /1" unconfirmed112+=TEXT("abc", "0") "abc" unconfirmed113+=TEXT("12", "0.00") "12.00" unconfirmed114+=TEXT(TRUE, "0") "1" unconfirmed115+=TEXT(Data!F1, "dddd") "Saturday" unconfirmed116+=TEXT(Data!F1, "mmm d, yyyy") "Sep 26, 2026" unconfirmed117+=TEXT(Data!F1, "dddd, mmmm d, yyyy") "Saturday, September 26, 2026" unconfirmed118+=TEXT(Data!F3, "h:mm AM/PM") "2:30 PM" unconfirmed119+=TEXT(0.75, "h AM/PM") "6 PM" unconfirmed120+=TEXT(Data!F1+Data!F3, "yyyy-mm-dd hh:mm") "2026-09-26 14:30" unconfirmed121+=TEXT(1.5, "[h]:mm") "36:00" unconfirmed122+=TEXT(90/1440, "[mm]:ss") "90:00" unconfirmed123+## Rounding and floating point124+=ROUND(1.005, 2) 1.01 unconfirmed125+=ROUND(2.675, 2) 2.68 unconfirmed126+=ROUND(0.285, 2) 0.29 unconfirmed127+=ROUND(-2.5) -3 unconfirmed128+=ROUND(1.5, 0.9) 2 unconfirmed129+=ROUND(1.23456789012345, 14) 1.23456789012345 unconfirmed130+=ROUNDUP(0.1+0.2, 1) 0.3 unconfirmed131+=ROUNDDOWN(1-0.9, 1) 0.1 unconfirmed132+=TRUNC(1234, -2) 1200 unconfirmed133+=INT(-1.5) -2 unconfirmed134+=INT(1-0.9+0.9) 1 unconfirmed135+=CEILING(0.3, 0.1) 0.3 unconfirmed136+=CEILING(-2.5, 1) -2 unconfirmed137+=CEILING(-2.5, -1) -3 unconfirmed138+=CEILING(2.5, -1) #NUM! unconfirmed139+=CEILING(2.5, 0) 0 unconfirmed140+=FLOOR(-2.5, 1) -3 unconfirmed141+=FLOOR(-2.5, -1) -2 unconfirmed142+=MOD(-7, 3) 2 unconfirmed143+=MOD(10, 0) #DIV/0! unconfirmed144+=MOD(0.3, 0.1) 0.1 unconfirmed145+=QUOTIENT(-7, 2) -3 unconfirmed146+=0.1+0.2 0.3 unconfirmed147+=0.1+0.2=0.3 FALSE unconfirmed148+=0.1+0.2-0.3 5.55111512312578E-17 unconfirmed149+=1-0.9 0.1 unconfirmed150+=(1-0.9)=0.1 FALSE unconfirmed151+=SUM(0.1, 0.2, 0.3)=0.6 FALSE unconfirmed152+=1/3 0.333333333333333 unconfirmed153+=100*1.1 110 unconfirmed154+=1E+21 1E+21 unconfirmed155+=123456789012345678 123456789012346000 unconfirmed156+=9007199254740993 9007199254740990 unconfirmed157+=0.000001 0.000001 unconfirmed158+=-0 0 unconfirmed159+=-2^2 4 unconfirmed160+=0^0 1 unconfirmed161+=POWER(0, 0) 1 unconfirmed162+=POWER(-8, 1/3) #NUM! unconfirmed163+=(-8)^(1/3) #NUM! unconfirmed164+=1E+308*10 #NUM! unconfirmed165+=LOG(10, 1) #DIV/0! unconfirmed166+## Errors and their propagation167+=0/0 #DIV/0! unconfirmed168+=Data!J2+1 #DIV/0! unconfirmed169+=SUM(Data!J2, 1) #DIV/0! unconfirmed170+=SUM(1, NA()) #N/A unconfirmed171+=NA()+1/0 #N/A unconfirmed172+=1/0+NA() #DIV/0! unconfirmed173+=IFERROR(1/0, "x") "x" unconfirmed174+=IFERROR(NA()) "" unconfirmed175+=IFERROR(Data!J2, Data!J4) #N/A unconfirmed176+=IFNA(1/0, 0) #DIV/0! unconfirmed177+=ISERR(NA()) FALSE unconfirmed178+=IF(1/0, 1, 2) #DIV/0! unconfirmed179+=IF("abc", 1, 2) #VALUE! unconfirmed180+=AND(TRUE, 1/0) #DIV/0! unconfirmed181+=OR(TRUE, NA()) #N/A unconfirmed182+=Data!A1+nosuchname #NAME? unconfirmed183+=CHOOSE(0, "a") #VALUE! unconfirmed184+=INDEX(Data!A1:A3, 5) #REF! unconfirmed185+=AVERAGEIF(Data!A1:A7, ">100") #DIV/0! unconfirmed186+=SWITCH(Data!B4, "north", 1) #N/A unconfirmed187+=PMT(0, 0, 1000) #NUM! unconfirmed188+=COUNT(Data!J1:J4) #DIV/0! unconfirmed189+=COUNTA(Data!J1:J4) #DIV/0! unconfirmed190+## Coercion191+=" 3 "*1 3 unconfirmed192+="1,000"*1 1000 unconfirmed193+="$5"*2 10 unconfirmed194+="50%"*2 1 unconfirmed195+="9/26/2026"*1 46291 unconfirmed196+="TRUE"*1 #VALUE! unconfirmed197+=-TRUE -1 unconfirmed198+=Data!A5&"x" "x" unconfirmed199+=Data!A5="" TRUE unconfirmed200+=Data!B6=0 FALSE unconfirmed201+=ISBLANK(Data!B6) FALSE unconfirmed202+=Data!J1+1 4 unconfirmed203+=SUM(Data!J1) 0 unconfirmed204+=SUM("3", 1) 4 unconfirmed205+=SUM(Data!E6, 1) 1 unconfirmed206+=AVERAGE(Data!E6, 3) 3 unconfirmed207+=COUNT("1", 2) 2 unconfirmed208+=COUNT(TRUE) 1 unconfirmed209+=COUNTBLANK(Data!B1:B7) 1 unconfirmed210+="10"<9 FALSE unconfirmed211+=1<"a" TRUE unconfirmed212+=TRUE>1 TRUE unconfirmed213+=TRUE="TRUE" FALSE unconfirmed214+=Data!A5<"a" TRUE unconfirmed215+=""=0 FALSE unconfirmed216+=IF("TRUE", 1, 2) #VALUE! unconfirmed217+=IF(Data!A5, "y", "n") "n" unconfirmed218+=AND("abc") #VALUE! unconfirmed219+=AND(Data!A4) #VALUE! unconfirmed220+=NOT("") #VALUE! unconfirmed221+=ROUND("2.5") 3 unconfirmed222+=LEFT(Data!F1, 3) "462" unconfirmed223+## Lookups and criteria224+=VLOOKUP("B*", Data!D1:E3, 2, FALSE) 2.5 unconfirmed225+=VLOOKUP("Blueberry", Data!D1:E3, 2) 2.5 unconfirmed226+=VLOOKUP("Aardvark", Data!D1:E3, 2, TRUE) #N/A unconfirmed227+=VLOOKUP(2.5, Data!C1:D5, 2) "Banana" unconfirmed228+=VLOOKUP("1", Data!C1:D5, 2, FALSE) #N/A unconfirmed229+=VLOOKUP("Apple", Data!D1:E3, 3, FALSE) #REF! unconfirmed230+=VLOOKUP("Apple", Data!D1:E3, 0, FALSE) #VALUE! unconfirmed231+=HLOOKUP("Apple", Data!D1:E2, 2, FALSE) "Banana" unconfirmed232+=MATCH("cherry", Data!D1:D3, 0) 3 unconfirmed233+=MATCH("B*", Data!D1:D3, 0) 2 unconfirmed234+=MATCH(3.2, Data!C1:C7) 3 unconfirmed235+=MATCH(0, Data!C1:C7) #N/A unconfirmed236+=MATCH(35, Data!I1:I3, -1) #N/A unconfirmed237+=MATCH(3, {1, 3, 5}, 0) 2 unconfirmed238+=INDEX(Data!C1:E1, 2) "Apple" unconfirmed239+=INDEX(Data!D1:E3, 4, 1) #REF! unconfirmed240+=ROWS(INDEX(Data!A1:C3, 0, 1)) 1 unconfirmed241+=SUM(INDEX(Data!C1:E3, 0, 1)) 1 unconfirmed242+=XLOOKUP("kiwi", Data!D1:D3, Data!E1:E3) #N/A unconfirmed243+=XLOOKUP("Apple", Data!D1:D5, Data!E1:E5, , 0, -1) 9 unconfirmed244+=XLOOKUP(3.2, Data!C1:C7, Data!D1:D7, , -1) "Cherry" unconfirmed245+=XLOOKUP(3.2, Data!C1:C7, Data!D1:D7, , 1) "date" unconfirmed246+=XLOOKUP("Ch*", Data!D1:D3, Data!E1:E3, , 2) 3.5 unconfirmed247+=XLOOKUP(2, Data!G1:G4, Data!H1:H4, , 0, -1) 400 unconfirmed248+=CHOOSE(2.9, "a", "b", "c") "b" unconfirmed249+=SUMIF(Data!A1:A7, "<>10") 45 unconfirmed250+=SUMIF(Data!C1:C7, ">=" & Data!C5) 18 unconfirmed251+=SUMIFS(Data!C1:C5, Data!B1:B5, "north", Data!A1:A5, ">10") 3 unconfirmed252+=COUNTIF(Data!B1:B7, "?o*") 4 unconfirmed253+=COUNTIF(Data!B1:B7, "Ab~*d") 1 unconfirmed254+=COUNTIF(Data!B1:B7, "<>north") 4 unconfirmed255+=COUNTIF(Data!A1:A7, "") 2 unconfirmed256+=COUNTIF(Data!A1:A7, "10") 1 unconfirmed257+=COUNTIF(Data!J1:J4, 3) 0 unconfirmed258+=COUNTIF(Data!E1:E7, TRUE) 1 unconfirmed259+=COUNTIF(Data!F1:F5, ">" & DATE(2026, 1, 1)) 2 unconfirmed260+## Arrays and spills261+=SUM(SEQUENCE(3, 3, 0, 2)) 72 unconfirmed262+=INDEX(SEQUENCE(3, 2, 10, 5), 3, 2) 35 unconfirmed263+=ROWS(SEQUENCE(0)) 1 unconfirmed264+=ROWS(FILTER(Data!A1:A7, Data!C1:C7>3)) 4 unconfirmed265+=TEXTJOIN(",", TRUE, FILTER(Data!D1:D5, Data!B1:B5="north")) "Apple,Cherry,Apple" unconfirmed266+=FILTER(Data!A1:A3, Data!A1:A3>100) #N/A unconfirmed267+=TEXTJOIN(",", TRUE, SORT(Data!C1:C5, 1, FALSE)) "5,4,3,2,1" unconfirmed268+=INDEX(SORT(Data!D1:E5, 2, FALSE), 1, 1) "date" unconfirmed269+=TEXTJOIN(",", TRUE, SORT(Data!D1:D5)) "Apple,Apple,Banana,Cherry,date" unconfirmed270+=TEXTJOIN(",", TRUE, UNIQUE(Data!B1:B5)) "north,south,east" unconfirmed271+=TEXTJOIN(",", TRUE, UNIQUE(Data!G1:G4, FALSE, TRUE)) "1,3" unconfirmed272+=TEXTJOIN(",", TRUE, FLATTEN(Data!C1:D2)) "1,Apple,2,Banana" unconfirmed273+=ROWS(FLATTEN(Data!C1:D2, Data!G1:G2)) 6 unconfirmed274+=TEXTJOIN(",", TRUE, SORTN(Data!C1:C7, 3, 0, 1, FALSE)) "7,6,5" unconfirmed275+=ROWS(SORTN(Data!G1:G4, 2, 1, 1, TRUE)) 3 unconfirmed276+=TEXTJOIN(",", TRUE, CHOOSECOLS(Data!C1:E1, -1, 1)) "1.5,1" unconfirmed277+=ROWS(CHOOSEROWS(Data!A1:A7, 1, 3)) 2 unconfirmed278+=SUM(ARRAYFORMULA(Data!C1:C3*Data!G1:G3)) 11 unconfirmed279+=ARRAYFORMULA(SUM(Data!C1:C3^2)) 14 unconfirmed280+=SUM(Data!C1:C3*2) 12 unconfirmed281+=SUMPRODUCT(Data!C1:C3>1) 0 unconfirmed282+=SUMPRODUCT(Data!A1:A4, Data!C1:C4) 140 unconfirmed283+=SUM({1, 2; 3, 4}) 10 unconfirmed284+=INDEX({1, 2; 3, 4}, 2, 1) 3 unconfirmed285+## LET and LAMBDA286+=LET(x, 2, y, x+1, x*y) 6 unconfirmed287+=LET(x, SEQUENCE(3), SUM(x)) 6 unconfirmed288+=LET(x, 1/0, 5) #DIV/0! unconfirmed289+=LAMBDA(x, x+1)() #N/A unconfirmed290+=LET(f, LAMBDA(x, x+1), f(f(1))) 3 unconfirmed291+=SUM(MAP({1, 2, 3}, LAMBDA(x, x*10))) 60 unconfirmed292+=SUM(MAP(Data!A1:A3, Data!C1:C3, LAMBDA(a, b, a+b))) 66 unconfirmed293+=REDUCE(0, Data!C1:C5, LAMBDA(a, b, a+b)) 15 unconfirmed294+=TEXTJOIN(",", TRUE, SCAN(0, {1, 2, 3}, LAMBDA(a, b, a+b))) "1,3,6" unconfirmed295+=SUM(BYROW(Data!C1:E3, LAMBDA(r, COUNT(r)))) 6 unconfirmed296+=TEXTJOIN(",", TRUE, BYCOL(Data!C1:E3, LAMBDA(c, COUNTA(c)))) "3,3,3" unconfirmed297+=SUM(MAKEARRAY(3, 3, LAMBDA(r, c, r*c))) 36 unconfirmed298+## Logic299+=IFS(Data!A1>20, "big", Data!A1>5, "medium") "medium" unconfirmed300+=SWITCH(Data!B2, "north", 1, "south", 2) 2 unconfirmed301+=SWITCH(Data!B4, "north", 1, "south", 2, 0) 0 unconfirmed302+=XOR(Data!A1:A3) TRUE unconfirmed303+## Math, statistics and finance304+=PRODUCT(2, "3") 6 unconfirmed305+=MEDIAN(1, 2, 3, 4) 2.5 unconfirmed306+=MODE(Data!G1:G4) 2 unconfirmed307+=MODE(Data!C1:C5) #N/A unconfirmed308+=STDEV(Data!A1:A7) 14.9303940559741 unconfirmed309+=VAR(Data!A1:A7) 222.916666666667 unconfirmed310+=RANK(2, Data!G1:G4) 2 unconfirmed311+=RANK(99, Data!A1:A7) #N/A unconfirmed312+=ROUND(PMT(0.05/12, 360, 200000), 6) -1073.643246 unconfirmed313+=PMT(0, 10, 1000) -100 unconfirmed314+=ROUND(PV(0.05/12, 360, -1000), 6) 186281.617046 unconfirmed315+=ROUND(NPER(0.01, -1000, 10000), 9) 10.588644459 unconfirmed316+=NPER(0.01, 100, 10000) -69.6607168935748 unconfirmed317+=ROUND(RATE(48, -200, 8000), 9) 0.007701472 unconfirmed318+=ROUND(NPV(0.1, Data!H1:H4), 6) -19.124377 unconfirmed319+=ROUND(IRR(Data!H1:H4), 9) 0.088963395 unconfirmed320+=IRR(Data!C1:C5) #NUM! unconfirmed
A internal/headless/get.go +179 -0
@@ -0,0 +1,179 @@
1+package headless2+3+import (4+ "fmt"5+ "io"6+ "strings"7+ "unicode/utf8"8+9+ "github.com/FelineStateMachine/012/internal/fileio"10+ "github.com/FelineStateMachine/012/internal/nuon"11+ "github.com/FelineStateMachine/012/internal/sheet"12+)13+14+// Formats are what Get writes: text as the sheet shows it, or a table15+// as 012 --pipe and exports write it.16+var Formats = []string{"text", "csv", "tsv", "json", "nuon"}17+18+// GetOptions say how Get writes.19+type GetOptions struct {20+ Format string // one of Formats; "" is text21+ // Input writes what was typed (a formula, or an entry as stored)22+ // rather than the value.23+ Input bool24+ // NoHeader names a JSON or NUON table's columns by their letters and25+ // makes every row a record, rather than taking the names from the26+ // first row.27+ NoHeader bool28+}29+30+// Get writes the target's values. One cell is written alone: as it31+// shows, or as a JSON or NUON value typed by its format (a number, a32+// date, a file size). A range or sheet is written as a table: aligned33+// columns as text, or as CSV, TSV, JSON or NUON, whose columns take34+// their names from the first row (see GetOptions.NoHeader).35+func Get(out io.Writer, t Target, o GetOptions) error {36+ if o.Format == "" {37+ o.Format = "text"38+ }39+ if t.Cell() {40+ return getCell(out, t.Sheet, t.Range.From, o)41+ }42+ r := t.Range43+ if t.Whole {44+ r = sheet.Rect{}45+ }46+ snap := fileio.Snap(t.Sheet, r, t.Sheet.Name())47+ snap.HiddenRows = nil // a script reads the whole range, whatever a filter shows48+ if o.Input {49+ inputs(snap)50+ }51+ if o.Format == "text" {52+ return writeText(out, t.Sheet, snap, o.Input)53+ }54+ k, ok := fileio.KindNamed(o.Format)55+ if !ok || !k.IsText() {56+ return fmt.Errorf("--format %s: 012 get writes %s", o.Format, strings.Join(Formats, ", "))57+ }58+ if o.NoHeader && (k == fileio.JSON || k == fileio.NUON) {59+ snap = lettered(snap)60+ }61+ _, err := fileio.Encode(out, k, snap)62+ return err63+}64+65+// getCell writes one cell's value or input.66+func getCell(out io.Writer, s *sheet.Sheet, a sheet.Addr, o GetOptions) error {67+ c := fileio.SnapCell{Value: s.Value(a), Format: s.DisplayFormat(a)}68+ text := s.LocalText(a)69+ if o.Input {70+ input := ""71+ if cell := s.Cell(a); cell != nil {72+ input = cell.Input73+ }74+ c, text = fileio.SnapCell{Value: sheet.Value{Kind: sheet.Text, Str: input}}, input75+ if input == "" {76+ c.Value = sheet.Value{}77+ }78+ }79+ var line []byte80+ switch o.Format {81+ case "text":82+ line = []byte(text)83+ case "csv", "tsv":84+ return getTable(out, s, a, o)85+ case "json":86+ line = nuon.AppendJSON(nil, fileio.CellValue(c))87+ case "nuon":88+ line = nuon.Append(nil, fileio.CellValue(c))89+ default:90+ return fmt.Errorf("--format %s: 012 get writes %s", o.Format, strings.Join(Formats, ", "))91+ }92+ _, err := out.Write(append(line, '\n'))93+ return err94+}95+96+// getTable writes one cell as a table of one row: a CSV or TSV line.97+func getTable(out io.Writer, s *sheet.Sheet, a sheet.Addr, o GetOptions) error {98+ snap := fileio.Snap(s, sheet.Rect{From: a, To: a}, s.Name())99+ snap.Range.To = a100+ if o.Input {101+ inputs(snap)102+ }103+ k, _ := fileio.KindNamed(o.Format)104+ _, err := fileio.Encode(out, k, snap)105+ return err106+}107+108+// inputs makes each cell of snap hold its input as text in place of its109+// value; a cell an array spilled into has none.110+func inputs(snap *fileio.Snapshot) {111+ for a, c := range snap.Cells {112+ v := sheet.Value{Kind: sheet.Text, Str: c.Input}113+ if c.Input == "" {114+ v = sheet.Value{}115+ }116+ snap.Cells[a] = fileio.SnapCell{Input: c.Input, Value: v}117+ }118+}119+120+// lettered is snap with a first row naming each column by its letter,121+// so the range's own first row becomes a record.122+func lettered(snap *fileio.Snapshot) *fileio.Snapshot {123+ out := *snap124+ r := snap.Range125+ out.Range.To.Row++126+ out.Cells = make(map[sheet.Addr]fileio.SnapCell, len(snap.Cells)+r.To.Col-r.From.Col+1)127+ for a, c := range snap.Cells {128+ out.Cells[sheet.Addr{Col: a.Col, Row: a.Row + 1}] = c129+ }130+ for col := r.From.Col; col <= r.To.Col; col++ {131+ name := sheet.ColName(col)132+ out.Cells[sheet.Addr{Col: col, Row: r.From.Row}] = fileio.SnapCell{Input: name, Value: sheet.Value{Kind: sheet.Text, Str: name}}133+ }134+ return &out135+}136+137+// writeText writes snap's range as aligned columns of what each cell138+// shows (or its input), numbers to the right, two spaces between139+// columns and no spaces at the ends of lines.140+func writeText(out io.Writer, s *sheet.Sheet, snap *fileio.Snapshot, input bool) error {141+ r := snap.Range142+ cols := r.To.Col - r.From.Col + 1143+ text := func(a sheet.Addr) (string, bool) {144+ c, ok := snap.Cells[a]145+ if !ok {146+ return "", false147+ }148+ if input {149+ return c.Input, false150+ }151+ return s.LocalText(a), c.Value.Kind == sheet.Number152+ }153+ widths := make([]int, cols)154+ for a := range snap.Cells {155+ t, _ := text(a)156+ widths[a.Col-r.From.Col] = max(widths[a.Col-r.From.Col], utf8.RuneCountInString(t))157+ }158+ var b strings.Builder159+ for row := r.From.Row; row <= r.To.Row; row++ {160+ var line strings.Builder161+ for col := r.From.Col; col <= r.To.Col; col++ {162+ t, right := text(sheet.Addr{Col: col, Row: row})163+ if col > r.From.Col {164+ line.WriteString(" ")165+ }166+ pad := strings.Repeat(" ", widths[col-r.From.Col]-utf8.RuneCountInString(t))167+ if right {168+ t = pad + t169+ } else {170+ t += pad171+ }172+ line.WriteString(t)173+ }174+ b.WriteString(strings.TrimRight(line.String(), " "))175+ b.WriteByte('\n')176+ }177+ _, err := io.WriteString(out, b.String())178+ return err179+}
A internal/headless/headless.go +125 -0
@@ -0,0 +1,125 @@
1+// Package headless works on workbook files without the screen, for2+// 012 get, set, recalc and export (see docs/files/scripts.md): it opens3+// a .012 file, finds the cells a reference names, reads and writes4+// them, lists the errors formulas show and saves atomically. Nothing5+// here runs a program or reaches the network unless its caller asks:6+// notebook cells run through RunNotebooks and JEV functions through7+// AnswerJEV, which the command line calls only behind flags.8+package headless9+10+import (11+ "errors"12+ "fmt"13+ "io"14+ "io/fs"15+ "os"16+ "path/filepath"17+18+ "github.com/FelineStateMachine/012/internal/sheet"19+)20+21+// File is a workbook opened from a .012 file.22+type File struct {23+ Path string24+ Book *sheet.Workbook25+ // New is set when there was no file at Path and Book is empty.26+ New bool27+ mode fs.FileMode28+}29+30+// Open reads the workbook at path, with the notebook outputs it kept31+// sent on to their sheets, as the screen opens it. With create, a path32+// with no file opens as an empty workbook that Save writes there, as33+// opening a new name in 012 does.34+func Open(path string, create bool) (*File, error) {35+ f := &File{Path: path, mode: 0o644}36+ file, err := os.Open(path)37+ switch {38+ case errors.Is(err, fs.ErrNotExist) && create:39+ f.Book, f.New = sheet.NewBook(), true40+ return f, nil41+ case err != nil:42+ return nil, err43+ }44+ defer file.Close()45+ if st, err := file.Stat(); err == nil {46+ if st.IsDir() {47+ return nil, fmt.Errorf("%s is a folder, not a %s workbook", path, sheet.FileExt)48+ }49+ f.mode = st.Mode().Perm()50+ }51+ if f.Book, err = sheet.ReadBook(file); err != nil {52+ return nil, fmt.Errorf("%s: %w", path, err)53+ }54+ SyncOutputs(f.Book)55+ return f, nil56+}57+58+// Save writes the workbook back to its file atomically: into a59+// temporary file beside it, renamed over it once complete, so a failed60+// write never leaves half a workbook. A file that would come out the61+// same isn't touched, so its modification time says when it last62+// changed.63+func (f *File) Save() error {64+ if !f.New {65+ if old, err := os.ReadFile(f.Path); err == nil && sameAs(f.Book, old) {66+ return nil67+ }68+ }69+ return WriteAtomic(f.Path, f.mode, f.Book.Write)70+}71+72+// sameAs reports whether w writes exactly old.73+func sameAs(w *sheet.Workbook, old []byte) bool {74+ c := &compareWriter{want: old}75+ return w.Write(c) == nil && c.same && c.n == len(old)76+}77+78+// compareWriter checks what's written against want without keeping it.79+type compareWriter struct {80+ want []byte81+ n int82+ same bool83+}84+85+func (c *compareWriter) Write(p []byte) (int, error) {86+ if c.n == 0 {87+ c.same = true88+ }89+ if c.same && (c.n+len(p) > len(c.want) || string(c.want[c.n:c.n+len(p)]) != string(p)) {90+ c.same = false91+ }92+ c.n += len(p)93+ return len(p), nil94+}95+96+// WriteAtomic writes a file with write into a temporary file of its own97+// beside path, then renames it over path with mode.98+func WriteAtomic(path string, mode fs.FileMode, write func(io.Writer) error) error {99+ tmp, err := os.CreateTemp(filepath.Dir(path), "."+filepath.Base(path)+".*.tmp")100+ if err != nil {101+ return err102+ }103+ err = write(tmp)104+ if err == nil {105+ err = tmp.Chmod(mode)106+ }107+ if cerr := tmp.Close(); err == nil {108+ err = cerr109+ }110+ if err == nil {111+ err = os.Rename(tmp.Name(), path)112+ }113+ if err != nil {114+ os.Remove(tmp.Name())115+ }116+ return err117+}118+119+// Trusted reports whether the workbook's commands may run here without120+// asking, by the rule macros follow: they were made or trusted on this121+// computer, whose id is machine.122+func Trusted(w *sheet.Workbook, machine string) bool {123+ o := w.MacroOrigin()124+ return o != "" && o == machine125+}
A internal/headless/headless_test.go +316 -0
@@ -0,0 +1,316 @@
1+package headless2+3+import (4+ "bytes"5+ "context"6+ "io"7+ "os"8+ "path/filepath"9+ "strings"10+ "testing"11+ "time"12+13+ "github.com/FelineStateMachine/012/internal/notebook"14+ "github.com/FelineStateMachine/012/internal/nushell"15+ "github.com/FelineStateMachine/012/internal/sheet"16+)17+18+func addr(s string) sheet.Addr {19+ a, _ := sheet.ParseAddr(s)20+ return a21+}22+23+// book is a workbook of two sheets, Sheet1 and 'Q3 plan', and a name.24+func book(t *testing.T) *sheet.Workbook {25+ t.Helper()26+ w := sheet.NewBook()27+ s := w.Sheet(0)28+ for a, in := range map[string]string{"A1": "Item", "B1": "Price", "A2": "Apple", "B2": "1.5", "A3": "Pear", "B3": "$2", "B4": "=SUM(B2:B3)", "C1": "=1/0"} {29+ if err := s.Set(addr(a), in); err != nil {30+ t.Fatal(err)31+ }32+ }33+ q, err := w.AddSheet("Q3 plan", 1)34+ if err != nil {35+ t.Fatal(err)36+ }37+ q.Set(addr("A1"), "2026-09-29")38+ q.Set(addr("B2"), "=Sheet1!B4*2")39+ if err := w.DefineName("Prices", s, sheet.NewRect(addr("B2"), addr("B3"))); err != nil {40+ t.Fatal(err)41+ }42+ w.SetActive(s)43+ return w44+}45+46+func TestResolve(t *testing.T) {47+ w := book(t)48+ for ref, want := range map[string]string{49+ "": "Sheet1",50+ "B7": "Sheet1!B7",51+ "$B$7": "Sheet1!B7",52+ "A1:C9": "Sheet1!A1:C9",53+ "'Q3 plan'!B2": "'Q3 plan'!B2",54+ "Q3 plan!B2": "'Q3 plan'!B2",55+ "Q3 plan": "'Q3 plan'",56+ "'Q3 plan'!": "'Q3 plan'",57+ "Prices": "Sheet1!B2:B3",58+ } {59+ got, err := Resolve(w, ref)60+ if err != nil || got.String() != want {61+ t.Errorf("Resolve(%q) = %s, %v; want %s", ref, got, err, want)62+ }63+ }64+ for ref, want := range map[string]string{65+ "Q4!A1": `no sheet named "Q4" (sheets: Sheet1, Q3 plan)`,66+ "what": `"what" isn't a cell, a range, a named range or a sheet`,67+ } {68+ if _, err := Resolve(w, ref); err == nil || !strings.Contains(err.Error(), want) {69+ t.Errorf("Resolve(%q): %v, want %q", ref, err, want)70+ }71+ }72+ if _, _, err := ResolveCell(w, "A1:B2"); err == nil || !strings.Contains(err.Error(), "name one cell") {73+ t.Errorf("ResolveCell of a range: %v", err)74+ }75+}76+77+func get(t *testing.T, w *sheet.Workbook, ref string, o GetOptions) string {78+ t.Helper()79+ target, err := Resolve(w, ref)80+ if err != nil {81+ t.Fatal(err)82+ }83+ var b bytes.Buffer84+ if err := Get(&b, target, o); err != nil {85+ t.Fatalf("get %s: %v", ref, err)86+ }87+ return b.String()88+}89+90+func TestGet(t *testing.T) {91+ w := book(t)92+ for _, c := range []struct {93+ ref string94+ o GetOptions95+ want string96+ }{97+ {"B4", GetOptions{}, "3.5\n"},98+ {"B3", GetOptions{}, "$2\n"},99+ {"B3", GetOptions{Format: "nuon"}, "2\n"},100+ {"B3", GetOptions{Format: "json"}, "2\n"},101+ {"B4", GetOptions{Input: true}, "=SUM(B2:B3)\n"},102+ {"B4", GetOptions{Input: true, Format: "json"}, "\"=SUM(B2:B3)\"\n"},103+ {"A1", GetOptions{Format: "nuon"}, "Item\n"},104+ {"Z9", GetOptions{Format: "nuon"}, "null\n"},105+ {"Z9", GetOptions{}, "\n"},106+ {"C1", GetOptions{}, "#DIV/0!\n"},107+ {"'Q3 plan'!B2", GetOptions{}, "7\n"},108+ {"A1:B3", GetOptions{}, "Item Price\nApple 1.5\nPear $2\n"},109+ {"A1:B3", GetOptions{Format: "csv"}, "Item,Price\nApple,1.5\nPear,$2\n"},110+ {"A1:B3", GetOptions{Format: "nuon"}, "[[Item, Price]; [Apple, 1.5],\n[Pear, 2]]\n"},111+ {"A2:B3", GetOptions{Format: "json", NoHeader: true}, "[\n{\"A\":\"Apple\",\"B\":1.5},\n{\"A\":\"Pear\",\"B\":2}\n]\n"},112+ {"B1:B4", GetOptions{Format: "json", Input: true}, "[\n{\"Price\":\"1.5\"},\n{\"Price\":\"$2\"},\n{\"Price\":\"=SUM(B2:B3)\"}\n]\n"},113+ } {114+ if got := get(t, w, c.ref, c.o); got != c.want {115+ t.Errorf("get %s %+v = %q, want %q", c.ref, c.o, got, c.want)116+ }117+ }118+ if got := get(t, w, "'Q3 plan'!A1", GetOptions{Format: "nuon"}); !strings.HasPrefix(got, "2026-09-29T00:00:00") {119+ t.Errorf("a date as NUON: %q", got)120+ }121+ target, _ := Resolve(w, "A1:B2")122+ if err := Get(io.Discard, target, GetOptions{Format: "xml"}); err == nil {123+ t.Error("--format xml was taken")124+ }125+}126+127+func TestSet(t *testing.T) {128+ w := book(t)129+ warn, err := Set(w, []Entry{{"B2", "10"}, {"'Q3 plan'!C1", "=B2+1"}, {"A2", ""}}, SetOptions{})130+ if err != nil || len(warn) > 0 {131+ t.Fatal(warn, err)132+ }133+ s := w.Sheet(0)134+ if v := s.Value(addr("B4")); v.Num != 12 {135+ t.Errorf("B4 = %v after setting B2", v)136+ }137+ if s.Filled(addr("A2")) {138+ t.Error("an empty input didn't clear A2")139+ }140+ if !w.CanUndo() {141+ t.Error("set wasn't a change")142+ }143+ for _, c := range []struct {144+ e Entry145+ want string146+ }{147+ {Entry{"B5", "=SUM(B2:B3"}, "Sheet1!B5: Expected , or ) in SUM, at character 11 of =SUM(B2:B3"},148+ {Entry{"A1:A2", "x"}, "A1:A2 is the range A1:A2: name one cell"},149+ {Entry{"Q9!A1", "x"}, `no sheet named "Q9"`},150+ } {151+ if _, err := Set(w, []Entry{c.e}, SetOptions{}); err == nil || !strings.Contains(err.Error(), c.want) {152+ t.Errorf("set %v: %v, want %q", c.e, err, c.want)153+ }154+ }155+}156+157+func TestSetRules(t *testing.T) {158+ w := book(t)159+ s := w.Sheet(0)160+ s.Protect(sheet.Protection{Range: sheet.NewRect(addr("D1"), addr("D9"))})161+ if _, err := Set(w, []Entry{{"D2", "1"}}, SetOptions{}); err == nil || !strings.Contains(err.Error(), "Sheet1!D2 is protected (D1:D9): --force") {162+ t.Errorf("a protected cell: %v", err)163+ }164+ if _, err := Set(w, []Entry{{"D2", "1"}}, SetOptions{Force: true}); err != nil {165+ t.Errorf("--force: %v", err)166+ }167+ s.AddValidation(sheet.Validation{Ranges: []sheet.Rect{sheet.NewRect(addr("E1"), addr("E9"))}, Kind: sheet.ValidNumber, Op: sheet.RuleBetween, Args: [2]string{"1", "9"}, Reject: true})168+ s.AddValidation(sheet.Validation{Ranges: []sheet.Rect{sheet.NewRect(addr("F1"), addr("F9"))}, Kind: sheet.ValidNumber, Op: sheet.RuleBetween, Args: [2]string{"1", "9"}})169+ if _, err := Set(w, []Entry{{"E1", "5"}, {"E2", "50"}}, SetOptions{}); err == nil || !strings.HasPrefix(err.Error(), "Sheet1!E2: ") {170+ t.Errorf("a rejected entry: %v", err)171+ }172+ warn, err := Set(w, []Entry{{"F1", "50"}}, SetOptions{})173+ if err != nil || len(warn) != 1 || !strings.HasPrefix(warn[0], "Sheet1!F1: ") {174+ t.Errorf("an entry marked invalid: %v %v", warn, err)175+ }176+}177+178+func TestOpenSave(t *testing.T) {179+ dir := t.TempDir()180+ path := filepath.Join(dir, "book.012")181+ if _, err := Open(path, false); err == nil {182+ t.Error("opened a missing file")183+ }184+ f, err := Open(path, true)185+ if err != nil || !f.New {186+ t.Fatal(f, err)187+ }188+ if _, err := Set(f.Book, []Entry{{"A1", "5"}, {"A2", "=A1*2"}}, SetOptions{}); err != nil {189+ t.Fatal(err)190+ }191+ if err := f.Save(); err != nil {192+ t.Fatal(err)193+ }194+ os.Chmod(path, 0o600)195+ old := time.Now().Add(-time.Hour)196+ os.Chtimes(path, old, old)197+ f, err = Open(path, false)198+ if err != nil {199+ t.Fatal(err)200+ }201+ if v := f.Book.Sheet(0).Value(addr("A2")); v.Num != 10 {202+ t.Errorf("A2 = %v", v)203+ }204+ if err := f.Save(); err != nil {205+ t.Fatal(err)206+ }207+ if st, _ := os.Stat(path); !st.ModTime().Equal(old) {208+ t.Error("saving an unchanged workbook rewrote it")209+ }210+ Set(f.Book, []Entry{{"A1", "6"}}, SetOptions{})211+ if err := f.Save(); err != nil {212+ t.Fatal(err)213+ }214+ st, _ := os.Stat(path)215+ if st.ModTime().Equal(old) || st.Mode().Perm() != 0o600 {216+ t.Errorf("saved %v with mode %v", st.ModTime(), st.Mode())217+ }218+ if entries, _ := os.ReadDir(dir); len(entries) != 1 {219+ t.Errorf("left files behind: %v", entries)220+ }221+ os.WriteFile(path, []byte("{nope"), 0o644)222+ if _, err := Open(path, false); err == nil || !strings.HasPrefix(err.Error(), path+": ") {223+ t.Errorf("a broken file: %v", err)224+ }225+}226+227+func TestRecalc(t *testing.T) {228+ w := book(t)229+ w.Sheet(0).Set(addr("D1"), "=D2")230+ w.Sheet(0).Set(addr("D2"), "=D1")231+ problems, circular := Recalc(w)232+ if !circular {233+ t.Error("no circular reference")234+ }235+ var got []string236+ for _, p := range problems {237+ got = append(got, p.At()+" "+p.Value)238+ }239+ if strings.Join(got, ", ") != "Sheet1!C1 #DIV/0!, Sheet1!D1 #REF!, Sheet1!D2 #REF!" {240+ t.Errorf("problems: %v", got)241+ }242+ if problems[0].Why == "" {243+ t.Error("no explanation")244+ }245+}246+247+// fakeNu answers commands with NUON.248+type fakeNu map[string]string249+250+func (f fakeNu) Run(_ context.Context, job nushell.Job, _ string, stdout io.Writer) error {251+ out, ok := f[job.Command]252+ if !ok {253+ return &nushell.Error{Msg: "Command `" + job.Command + "` not found"}254+ }255+ _, err := io.WriteString(stdout, out)256+ return err257+}258+259+func TestRunNotebooks(t *testing.T) {260+ w := sheet.NewBook()261+ grid := w.Sheet(0)262+ grid.Set(addr("D1"), "7")263+ nb, err := w.AddNotebook("Notebook", grid)264+ if err != nil {265+ t.Fatal(err)266+ }267+ nb.SetNotebookCells("add cells", []notebook.Cell{268+ {Kind: notebook.Code, Source: "files = ls"},269+ {Kind: notebook.Note, Source: "# Files"},270+ {Kind: notebook.Code, Source: "n = $files | length"},271+ {Kind: notebook.Code, Source: "$sheet.D1:D1"},272+ {Kind: notebook.Code, Source: "boom"},273+ {Kind: notebook.Code, Source: "last = 1"},274+ })275+ if err := grid.AddRegion(sheet.Region{Name: "files", At: addr("A1"), Output: true}); err != nil {276+ t.Fatal(err)277+ }278+ nu := fakeNu{"ls": "[[name, size]; [a, 1], [b, 2]]", "$files | length": "2", "$__sheet1": "[[D]; [7]]"}279+ failed := RunNotebooks(context.Background(), w, NotebookOptions{Runner: nu, Timeout: time.Second})280+ if len(failed) != 1 || failed[0] != "Notebook cell 5: Command `boom` not found" {281+ t.Errorf("failed: %q", failed)282+ }283+ target, _ := Resolve(w, "A1:B3")284+ var b bytes.Buffer285+ Get(&b, target, GetOptions{Format: "csv"})286+ if b.String() != "name,size\na,1\nb,2\n" {287+ t.Errorf("the output sent to Sheet1:\n%s", b.String())288+ }289+ cells := nb.NotebookCells()290+ if o := w.Output(cells[2].ID); o == nil || string(o.NUON) != "2" || o.Reads["files"] == 0 {291+ t.Errorf("$files | length: %+v", o)292+ }293+ if o := w.Output(cells[4].ID); o == nil || o.Err != "Command `boom` not found" {294+ t.Errorf("boom: %+v", o)295+ }296+ if o := w.Output(cells[5].ID); o != nil {297+ t.Errorf("a cell after a failure ran: %+v", o)298+ }299+ if o := w.Output(cells[1].ID); o != nil {300+ t.Errorf("a note ran: %+v", o)301+ }302+}303+304+func TestOpenSendsOutputs(t *testing.T) {305+ path := filepath.Join(t.TempDir(), "nb.012")306+ os.WriteFile(path, []byte(`{"version": 4, "sheets": [307+{"name": "Sheet1", "regions": [{"name": "files", "at": "B2", "output": true}], "cells": {}},308+{"name": "Notebook", "tab": "notebook", "notebookCells": [{"source": "files = ls", "output": "[[name]; [a.txt]]"}], "cells": {}}]}`), 0o644)309+ f, err := Open(path, false)310+ if err != nil {311+ t.Fatal(err)312+ }313+ if got := f.Book.Sheet(0).LocalText(addr("B3")); got != "a.txt" {314+ t.Errorf("B3 = %q, want the output the file kept", got)315+ }316+}
A internal/headless/jev.go +54 -0
A internal/headless/notebook.go +237 -0
@@ -0,0 +1,237 @@
1+package headless2+3+import (4+ "bytes"5+ "context"6+ "errors"7+ "fmt"8+ "time"9+10+ "github.com/FelineStateMachine/012/internal/fileio"11+ "github.com/FelineStateMachine/012/internal/notebook"12+ "github.com/FelineStateMachine/012/internal/nushell"13+ "github.com/FelineStateMachine/012/internal/sheet"14+)15+16+// NotebookOptions say how RunNotebooks runs cells.17+type NotebookOptions struct {18+ Runner nushell.Runner // nu, or a fake in tests19+ Timeout time.Duration // for each cell20+ NuConfig bool // run nu with the user's config files21+}22+23+// maxOutput is the most a run may print, as on the screen.24+const maxOutput = 512 << 2025+26+// RunNotebooks runs the code cells of each notebook tab, each after the27+// cells it reads, as Run all does on the screen: each cell's output28+// replaces the one the file kept and goes on to the sheet it was sent29+// to. A failure stops its notebook's run, as on the screen. It returns30+// a line for each cell that failed or couldn't run.31+func RunNotebooks(ctx context.Context, w *sheet.Workbook, o NotebookOptions) []string {32+ var failed []string33+ for _, s := range w.Sheets() {34+ if s.IsNotebook() {35+ failed = append(failed, runNotebook(ctx, w, s, o)...)36+ }37+ }38+ return failed39+}40+41+func runNotebook(ctx context.Context, w *sheet.Workbook, s *sheet.Sheet, o NotebookOptions) []string {42+ cells := s.NotebookCells()43+ all := make([]int, len(cells))44+ for i := range all {45+ all[i] = i46+ }47+ order, cycle := notebook.Order(cells, all)48+ var failed []string49+ for _, i := range cycle {50+ failed = append(failed, fmt.Sprintf("%s cell %d reads itself, through the cells it reads: it doesn't run", s.Name(), i+1))51+ }52+ for n, i := range order {53+ if err := runCell(ctx, w, s, cells, i, n+1, o); err != nil {54+ return append(failed, fmt.Sprintf("%s cell %d: %s", s.Name(), i+1, err))55+ }56+ }57+ return failed58+}59+60+// runCell runs cell i, the count-th run, keeping its output or why it61+// failed.62+func runCell(ctx context.Context, w *sheet.Workbook, s *sheet.Sheet, cells []notebook.Cell, i, count int, o NotebookOptions) error {63+ c := cells[i]64+ out := &notebook.Output{Count: count, Source: c.Source}65+ defer func() {66+ w.SetOutput(c.ID, out)67+ feedOutput(w, c.Name())68+ }()69+ if why := notebook.Taken(cells, i); why != "" {70+ out.Err = why71+ return errors.New(why)72+ }73+ job, reads, err := jobFor(w, s, cells, c, o.NuConfig)74+ if err != nil {75+ out.Err = err.Error()76+ return err77+ }78+ out.Reads = reads79+ start := time.Now()80+ data, err := nushell.Exec(ctx, o.Runner, job, o.Timeout, maxOutput)81+ out.Took = time.Since(start)82+ if err != nil {83+ out.Err = err.Error()84+ var nuErr *nushell.Error85+ if errors.As(err, &nuErr) {86+ out.Err, out.Detail = nuErr.Msg, nuErr.Help()87+ }88+ return errors.New(out.Err)89+ }90+ out.NUON = append([]byte{}, data...)91+ return nil92+}93+94+// jobFor is what running cell c takes: the outputs, linked files and95+// ranges it reads, as NUON, and the outputs it read by name.96+func jobFor(w *sheet.Workbook, s *sheet.Sheet, cells []notebook.Cell, c notebook.Cell, config bool) (nushell.Job, map[string]int, error) {97+ pipeline, ranges := notebook.Bind(c.Pipeline())98+ job := nushell.Job{Command: pipeline, Tables: map[string][]byte{}, Config: config}99+ reads := map[string]int{}100+ names := notebook.Names(cells)101+ for _, name := range notebook.Refs(pipeline) {102+ if j, ok := names[name]; ok {103+ o := w.Output(cells[j].ID)104+ switch {105+ case o == nil || o.Unsaved:106+ return job, nil, fmt.Errorf("it reads $%s, which hasn't run", name)107+ case o.Failed():108+ return job, nil, fmt.Errorf("it reads $%s, which failed", name)109+ }110+ job.Tables[name], reads[name] = o.NUON, o.Seq111+ continue112+ }113+ if t, r, ok := w.Region(name); ok && r.Linked() && r.Name == name {114+ rng, shown := t.RegionTable(r.Name)115+ snap := fileio.Snap(t, rng, r.Name)116+ if !shown {117+ snap = fileio.Snap(t, sheet.Rect{From: r.At, To: r.At}, r.Name)118+ snap.Cells = nil119+ }120+ data, err := encodeNUON(snap)121+ if err != nil {122+ return job, nil, err123+ }124+ job.Tables[name] = data125+ }126+ }127+ if notebook.ReadsSelection(pipeline) {128+ return job, nil, errors.New("it reads $selection, which only the screen has")129+ }130+ for _, ref := range ranges {131+ t, rng, err := rangeRef(w, ref.Ref)132+ if err != nil {133+ return job, nil, err134+ }135+ data, err := encodeNUON(fileio.Snap(t, rng, t.Name()))136+ if err != nil {137+ return job, nil, err138+ }139+ job.Tables[ref.Var] = data140+ }141+ return job, reads, nil142+}143+144+// rangeRef is the sheet and range $sheet.ref reads: of the sheet it145+// names, or else of the sheet shown when the file was saved, or the146+// first sheet that isn't a notebook.147+func rangeRef(w *sheet.Workbook, ref string) (*sheet.Sheet, sheet.Rect, error) {148+ name, cells := sheet.SplitSheet(ref)149+ t := w.Sheet(w.Active())150+ if name != "" {151+ t = w.Lookup(name)152+ } else if t.IsNotebook() {153+ for _, s := range w.Sheets() {154+ if !s.IsNotebook() {155+ t = s156+ break157+ }158+ }159+ }160+ rng, ok := sheet.ParseRange(cells)161+ if t == nil || t.IsNotebook() || !ok {162+ return nil, sheet.Rect{}, fmt.Errorf("$sheet.%s isn't a range of a sheet", ref)163+ }164+ return t, rng, nil165+}166+167+func encodeNUON(snap *fileio.Snapshot) ([]byte, error) {168+ var b bytes.Buffer169+ _, err := fileio.Encode(&b, fileio.NUON, snap)170+ return b.Bytes(), err171+}172+173+// SyncOutputs sends the outputs a file kept to the sheets they were sent174+// to, as opening the file on the screen does: reading, not running.175+func SyncOutputs(w *sheet.Workbook) {176+ for _, name := range w.StaleOutputs() {177+ feedOutput(w, name)178+ }179+}180+181+// feedOutput sends the output of the cell named name to the region it182+// was sent to, if it was: its rows, none when it hasn't run, or why when183+// it failed or is gone.184+func feedOutput(w *sheet.Workbook, name string) {185+ if name == "" {186+ return187+ }188+ _, r, ok := w.Region(name)189+ if !ok || !r.Output {190+ return191+ }192+ op := sheet.LiveOp{Region: r.Name, Reset: true}193+ c, ok := cellNamed(w, r.Name)194+ switch o := w.Output(c.ID); {195+ case !ok:196+ op = sheet.LiveOp{Region: r.Name, Err: "its cell is gone"}197+ case o == nil || o.Unsaved:198+ case o.Failed():199+ op = sheet.LiveOp{Region: r.Name, Err: o.Err}200+ default:201+ rows, note, err := fileio.NUONRows(context.Background(), o.NUON, 0)202+ if err != nil {203+ op = sheet.LiveOp{Region: r.Name, Err: err.Error()}204+ break205+ }206+ op.Header, op.Rows, op.Note = rows.Header, rows.Rows, note207+ }208+ w.ApplyLive(op)209+}210+211+// MissingOutputs names the notebook outputs sent to s that show no rows212+// because the file holds no output for their cells: never run, too213+// large to save, or their cells gone.214+func MissingOutputs(s *sheet.Sheet) []string {215+ var out []string216+ for _, r := range s.Regions() {217+ if !r.Output {218+ continue219+ }220+ c, ok := cellNamed(s.Book(), r.Name)221+ if o := s.Book().Output(c.ID); !ok || o == nil || o.Unsaved {222+ out = append(out, r.Name)223+ }224+ }225+ return out226+}227+228+// cellNamed is the notebook cell that gives its output name.229+func cellNamed(w *sheet.Workbook, name string) (notebook.Cell, bool) {230+ for _, s := range w.Sheets() {231+ cells := s.NotebookCells()232+ if i, ok := notebook.Names(cells)[name]; ok {233+ return cells[i], true234+ }235+ }236+ return notebook.Cell{}, false237+}
A internal/headless/recalc.go +51 -0
@@ -0,0 +1,51 @@
1+package headless2+3+import (4+ "github.com/FelineStateMachine/012/internal/sheet"5+)6+7+// Problem is a cell whose formula shows an error after recalculating.8+type Problem struct {9+ Sheet string10+ Addr sheet.Addr11+ Value string // the error as the cell shows it: #DIV/0!12+ Why string // what the screen's context line says about it, or ""13+ // JEV is set when the error is a JEV function's with nothing to14+ // answer it: --jev asks the model.15+ JEV bool16+}17+18+// At is the problem's cell with its sheet: Q3!B7.19+func (p Problem) At() string {20+ return sheet.Qualified(p.Sheet, sheet.Rect{From: p.Addr, To: p.Addr})21+}22+23+// Recalc recomputes every formula of w and returns the cells showing24+// errors, sheet by sheet in tab order and row by row, and whether a25+// circular reference was found.26+func Recalc(w *sheet.Workbook) (problems []Problem, circular bool) {27+ w.RecalcAll()28+ return Errors(w), w.Circular29+}30+31+// Errors returns the cells of w showing errors, without recalculating.32+// A cell an array spilled an error into is its formula's problem, so33+// only formulas are listed.34+func Errors(w *sheet.Workbook) []Problem {35+ var out []Problem36+ for _, s := range w.Sheets() {37+ for _, a := range s.Addrs() {38+ v := s.Value(a)39+ if v.Kind != sheet.Error {40+ continue41+ }42+ c := s.Cell(a)43+ if c == nil || !c.IsFormula() {44+ continue45+ }46+ out = append(out, Problem{Sheet: s.Name(), Addr: a, Value: v.Str, Why: s.ExplainError(a),47+ JEV: len(s.RemoteCalls(a)) > 0 && (v == sheet.ErrNoRemote || sheet.IsPending(v))})48+ }49+ }50+ return out51+}
A internal/headless/ref.go +95 -0
@@ -0,0 +1,95 @@
1+package headless2+3+import (4+ "fmt"5+ "strings"6+7+ "github.com/FelineStateMachine/012/internal/sheet"8+)9+10+// Target is what a reference names: a range of one sheet, or the whole11+// sheet (from A1 to its last cell with contents).12+type Target struct {13+ Sheet *sheet.Sheet14+ Range sheet.Rect15+ Whole bool16+}17+18+// Cell reports whether the target is one cell.19+func (t Target) Cell() bool { return !t.Whole && t.Range.From == t.Range.To }20+21+// String names the target as a formula would, with its sheet.22+func (t Target) String() string {23+ if t.Whole {24+ return sheet.QuoteSheet(t.Sheet.Name())25+ }26+ return sheet.Qualified(t.Sheet.Name(), t.Range)27+}28+29+// Resolve finds what ref names in w: a cell or range (B7, A1:C9, A:A),30+// on a sheet (Q3!B7, 'Q3 plan'!A1:C9) or else the sheet shown when the31+// file was saved; a named range (Sales); or a sheet by name (Q3, or32+// Q3!), meaning all of it. "" is the whole sheet shown.33+func Resolve(w *sheet.Workbook, ref string) (Target, error) {34+ ref = strings.TrimSpace(ref)35+ shown := w.Sheet(w.Active())36+ if ref == "" {37+ return Target{Sheet: shown, Whole: true}, nil38+ }39+ name, rest := sheet.SplitSheet(ref)40+ s := shown41+ if name != "" {42+ if s = w.Lookup(name); s == nil {43+ return Target{}, noSheet(w, name)44+ }45+ if rest == "" {46+ return Target{Sheet: s, Whole: true}, nil47+ }48+ }49+ if r, ok := sheet.ParseRange(strings.ReplaceAll(rest, "$", "")); ok {50+ return Target{Sheet: s, Range: r}, nil51+ }52+ if name == "" {53+ if n, ok := w.LookupName(rest); ok {54+ if n.Gone() {55+ return Target{}, fmt.Errorf("the named range %s lost its cells (#REF!)", n.Name)56+ }57+ return Target{Sheet: n.Sheet, Range: n.Range}, nil58+ }59+ if s := w.Lookup(rest); s != nil {60+ return Target{Sheet: s, Whole: true}, nil61+ }62+ }63+ return Target{}, fmt.Errorf("%q isn't a cell, a range, a named range or a sheet (sheets: %s)", ref, sheetList(w))64+}65+66+// ResolveCell is Resolve for a reference that must be one cell.67+func ResolveCell(w *sheet.Workbook, ref string) (*sheet.Sheet, sheet.Addr, error) {68+ t, err := Resolve(w, ref)69+ if err != nil {70+ return nil, sheet.Addr{}, err71+ }72+ if !t.Cell() {73+ return nil, sheet.Addr{}, fmt.Errorf("%s is %s: name one cell", ref, describe(t))74+ }75+ return t.Sheet, t.Range.From, nil76+}77+78+func describe(t Target) string {79+ if t.Whole {80+ return "a whole sheet"81+ }82+ return "the range " + t.Range.String()83+}84+85+func noSheet(w *sheet.Workbook, name string) error {86+ return fmt.Errorf("no sheet named %q (sheets: %s)", name, sheetList(w))87+}88+89+func sheetList(w *sheet.Workbook) string {90+ names := make([]string, 0, w.Len())91+ for _, s := range w.Sheets() {92+ names = append(names, s.Name())93+ }94+ return strings.Join(names, ", ")95+}
A internal/headless/set.go +71 -0

Not shown: this page already shows 6000 lines of diff.

M internal/notebook/notebook_test.go +8 -0

Not shown: this page already shows 6000 lines of diff.

M internal/notebook/refs.go +21 -0

Not shown: this page already shows 6000 lines of diff.

A internal/nushell/ide.go +219 -0

Not shown: this page already shows 6000 lines of diff.

A internal/nushell/ide_test.go +127 -0

Not shown: this page already shows 6000 lines of diff.

M internal/nushell/nushell.go +25 -3

Not shown: this page already shows 6000 lines of diff.

A internal/nushell/testdata/ide/ast.json +1 -0

Not shown: this page already shows 6000 lines of diff.

A internal/nushell/testdata/ide/ast.nu +1 -0

Not shown: this page already shows 6000 lines of diff.

A internal/nushell/testdata/ide/check.jsonl +6 -0

Not shown: this page already shows 6000 lines of diff.

A internal/nushell/testdata/ide/check.nu +1 -0

Not shown: this page already shows 6000 lines of diff.

A internal/nushell/testdata/ide/complete.json +1 -0

Not shown: this page already shows 6000 lines of diff.

A internal/nushell/testdata/ide/complete.nu +1 -0

Not shown: this page already shows 6000 lines of diff.

A internal/nushell/testdata/ide/version.txt +1 -0

Not shown: this page already shows 6000 lines of diff.

M internal/ui/file.go +1 -1

Not shown: this page already shows 6000 lines of diff.

M internal/ui/lineedit/area.go +6 -0

Not shown: this page already shows 6000 lines of diff.

M internal/ui/lineedit/lineedit.go +3 -0

Not shown: this page already shows 6000 lines of diff.

A internal/ui/lineedit/word.go +98 -0

Not shown: this page already shows 6000 lines of diff.

A internal/ui/lineedit/word_test.go +75 -0

Not shown: this page already shows 6000 lines of diff.

M internal/ui/monochrome_test.go +23 -0

Not shown: this page already shows 6000 lines of diff.

A internal/ui/nblang.go +83 -0

Not shown: this page already shows 6000 lines of diff.

A internal/ui/nblang_test.go +94 -0

Not shown: this page already shows 6000 lines of diff.

M internal/ui/nbrun.go +12 -2

Not shown: this page already shows 6000 lines of diff.

M internal/ui/nbscreen.go +3 -1

Not shown: this page already shows 6000 lines of diff.

M internal/ui/nbview/edit.go +74 -109

Not shown: this page already shows 6000 lines of diff.

A internal/ui/nbview/highlight.go +173 -0

Not shown: this page already shows 6000 lines of diff.

M internal/ui/nbview/keys.go +2 -0

Not shown: this page already shows 6000 lines of diff.

A internal/ui/nbview/nu.go +219 -0

Not shown: this page already shows 6000 lines of diff.

A internal/ui/nbview/nu_test.go +367 -0

Not shown: this page already shows 6000 lines of diff.

A internal/ui/nbview/nuprelude.go +146 -0

Not shown: this page already shows 6000 lines of diff.

A internal/ui/nbview/testdata/nu/check.jsonl +5 -0

Not shown: this page already shows 6000 lines of diff.

A internal/ui/nbview/testdata/nu/check.nu +4 -0

Not shown: this page already shows 6000 lines of diff.

A internal/ui/nbview/testdata/nu/complete.json +1 -0

Not shown: this page already shows 6000 lines of diff.

A internal/ui/nbview/testdata/nu/complete.nu +4 -0

Not shown: this page already shows 6000 lines of diff.

A internal/ui/nbview/testdata/nu/highlight.json +1 -0

Not shown: this page already shows 6000 lines of diff.

A internal/ui/nbview/testdata/nu/highlight.nu +4 -0

Not shown: this page already shows 6000 lines of diff.

A internal/ui/nbview/testdata/nu/sheet.json +1 -0

Not shown: this page already shows 6000 lines of diff.

A internal/ui/nbview/testdata/nu/sheet.nu +5 -0

Not shown: this page already shows 6000 lines of diff.

M internal/ui/nbview/view.go +2 -2

Not shown: this page already shows 6000 lines of diff.

M internal/ui/notebook.go +2 -25

Not shown: this page already shows 6000 lines of diff.

M internal/ui/notebook_test.go +3 -0

Not shown: this page already shows 6000 lines of diff.

A internal/ui/wordkeys_test.go +20 -0

Not shown: this page already shows 6000 lines of diff.