Resolves Sheet!A1 references, named ranges and sheets; writes a cell or a range as text, CSV, TSV, JSON or NUON; sets entries as one change, refusing what validation, protection, arrays or regions refuse; lists the formulas showing errors; runs notebook regions with nu and answers JEV functions only when asked; saves atomically and leaves an unchanged file alone. fileio.CellValue types one cell as its tables do.
- commit
- 52f3f996b45b40a3bd9b16cbb62c123564484e28
- parent
- 92e5253fa14897097fc97c6f1c28f2d8bd5d5af4
- author
- Dami <dami.etoile@gmail.com>
- kind
- feat risk score 1.3 of 3, Jev's estimate
| M | internal/ | 4 | + |
| A | internal/ | 179 | +++++++++++++++++++ |
| A | internal/ | 123 | +++++++++++++ |
| A | internal/ | 284 | ++++++++++++++++++++++++++++++ |
| A | internal/ | 51 | +++++ |
| A | internal/ | 95 | ++++++++++ |
| A | internal/ | 138 | +++++++++++++++ |
| A | internal/ | 71 | ++++++++ |
@@ -148,6 +148,10 @@ func snapRecords(snap *Snapshot, n int) func(yield func([]nuon.Value) bool) {
148148 }149149 }150150 151+// CellValue is a cell as a NUON value, typed by its format as a table152+// written as NUON or JSON types it: what 012 get writes for one cell.153+func CellValue(c SnapCell) nuon.Value { return nuonValue(c, zone()) }154+151155 // nuonValue is a cell as a NUON value, typed by its format.152156 func nuonValue(c SnapCell, loc *time.Location) nuon.Value {153157 switch v := c.Value; v.Kind {@@ -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+}@@ -0,0 +1,123 @@
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 regions run through RunRegions 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 create, a path with no file31+// opens as an empty workbook that Save writes there, as opening a new32+// name in 012 does.33+func Open(path string, create bool) (*File, error) {34+ f := &File{Path: path, mode: 0o644}35+ file, err := os.Open(path)36+ switch {37+ case errors.Is(err, fs.ErrNotExist) && create:38+ f.Book, f.New = sheet.NewBook(), true39+ return f, nil40+ case err != nil:41+ return nil, err42+ }43+ defer file.Close()44+ if st, err := file.Stat(); err == nil {45+ if st.IsDir() {46+ return nil, fmt.Errorf("%s is a folder, not a %s workbook", path, sheet.FileExt)47+ }48+ f.mode = st.Mode().Perm()49+ }50+ if f.Book, err = sheet.ReadBook(file); err != nil {51+ return nil, fmt.Errorf("%s: %w", path, err)52+ }53+ return f, nil54+}55+56+// Save writes the workbook back to its file atomically: into a57+// temporary file beside it, renamed over it once complete, so a failed58+// write never leaves half a workbook. A file that would come out the59+// same isn't touched, so its modification time says when it last60+// changed.61+func (f *File) Save() error {62+ if !f.New {63+ if old, err := os.ReadFile(f.Path); err == nil && sameAs(f.Book, old) {64+ return nil65+ }66+ }67+ return WriteAtomic(f.Path, f.mode, f.Book.Write)68+}69+70+// sameAs reports whether w writes exactly old.71+func sameAs(w *sheet.Workbook, old []byte) bool {72+ c := &compareWriter{want: old}73+ return w.Write(c) == nil && c.same && c.n == len(old)74+}75+76+// compareWriter checks what's written against want without keeping it.77+type compareWriter struct {78+ want []byte79+ n int80+ same bool81+}82+83+func (c *compareWriter) Write(p []byte) (int, error) {84+ if c.n == 0 {85+ c.same = true86+ }87+ if c.same && (c.n+len(p) > len(c.want) || string(c.want[c.n:c.n+len(p)]) != string(p)) {88+ c.same = false89+ }90+ c.n += len(p)91+ return len(p), nil92+}93+94+// WriteAtomic writes a file with write into a temporary file of its own95+// beside path, then renames it over path with mode.96+func WriteAtomic(path string, mode fs.FileMode, write func(io.Writer) error) error {97+ tmp, err := os.CreateTemp(filepath.Dir(path), "."+filepath.Base(path)+".*.tmp")98+ if err != nil {99+ return err100+ }101+ err = write(tmp)102+ if err == nil {103+ err = tmp.Chmod(mode)104+ }105+ if cerr := tmp.Close(); err == nil {106+ err = cerr107+ }108+ if err == nil {109+ err = os.Rename(tmp.Name(), path)110+ }111+ if err != nil {112+ os.Remove(tmp.Name())113+ }114+ return err115+}116+117+// Trusted reports whether the workbook's commands may run here without118+// asking, by the rule macros follow: they were made or trusted on this119+// computer, whose id is machine.120+func Trusted(w *sheet.Workbook, machine string) bool {121+ o := w.MacroOrigin()122+ return o != "" && o == machine123+}@@ -0,0 +1,284 @@
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/nushell"14+ "github.com/FelineStateMachine/012/internal/sheet"15+)16+17+func addr(s string) sheet.Addr {18+ a, _ := sheet.ParseAddr(s)19+ return a20+}21+22+// book is a workbook of two sheets, Sheet1 and 'Q3 plan', and a name.23+func book(t *testing.T) *sheet.Workbook {24+ t.Helper()25+ w := sheet.NewBook()26+ s := w.Sheet(0)27+ 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"} {28+ if err := s.Set(addr(a), in); err != nil {29+ t.Fatal(err)30+ }31+ }32+ q, err := w.AddSheet("Q3 plan", 1)33+ if err != nil {34+ t.Fatal(err)35+ }36+ q.Set(addr("A1"), "2026-09-29")37+ q.Set(addr("B2"), "=Sheet1!B4*2")38+ if err := w.DefineName("Prices", s, sheet.NewRect(addr("B2"), addr("B3"))); err != nil {39+ t.Fatal(err)40+ }41+ w.SetActive(s)42+ return w43+}44+45+func TestResolve(t *testing.T) {46+ w := book(t)47+ for ref, want := range map[string]string{48+ "": "Sheet1",49+ "B7": "Sheet1!B7",50+ "$B$7": "Sheet1!B7",51+ "A1:C9": "Sheet1!A1:C9",52+ "'Q3 plan'!B2": "'Q3 plan'!B2",53+ "Q3 plan!B2": "'Q3 plan'!B2",54+ "Q3 plan": "'Q3 plan'",55+ "'Q3 plan'!": "'Q3 plan'",56+ "Prices": "Sheet1!B2:B3",57+ } {58+ got, err := Resolve(w, ref)59+ if err != nil || got.String() != want {60+ t.Errorf("Resolve(%q) = %s, %v; want %s", ref, got, err, want)61+ }62+ }63+ for ref, want := range map[string]string{64+ "Q4!A1": `no sheet named "Q4" (sheets: Sheet1, Q3 plan)`,65+ "what": `"what" isn't a cell, a range, a named range or a sheet`,66+ } {67+ if _, err := Resolve(w, ref); err == nil || !strings.Contains(err.Error(), want) {68+ t.Errorf("Resolve(%q): %v, want %q", ref, err, want)69+ }70+ }71+ if _, _, err := ResolveCell(w, "A1:B2"); err == nil || !strings.Contains(err.Error(), "name one cell") {72+ t.Errorf("ResolveCell of a range: %v", err)73+ }74+}75+76+func get(t *testing.T, w *sheet.Workbook, ref string, o GetOptions) string {77+ t.Helper()78+ target, err := Resolve(w, ref)79+ if err != nil {80+ t.Fatal(err)81+ }82+ var b bytes.Buffer83+ if err := Get(&b, target, o); err != nil {84+ t.Fatalf("get %s: %v", ref, err)85+ }86+ return b.String()87+}88+89+func TestGet(t *testing.T) {90+ w := book(t)91+ for _, c := range []struct {92+ ref string93+ o GetOptions94+ want string95+ }{96+ {"B4", GetOptions{}, "3.5\n"},97+ {"B3", GetOptions{}, "$2\n"},98+ {"B3", GetOptions{Format: "nuon"}, "2\n"},99+ {"B3", GetOptions{Format: "json"}, "2\n"},100+ {"B4", GetOptions{Input: true}, "=SUM(B2:B3)\n"},101+ {"B4", GetOptions{Input: true, Format: "json"}, "\"=SUM(B2:B3)\"\n"},102+ {"A1", GetOptions{Format: "nuon"}, "Item\n"},103+ {"Z9", GetOptions{Format: "nuon"}, "null\n"},104+ {"Z9", GetOptions{}, "\n"},105+ {"C1", GetOptions{}, "#DIV/0!\n"},106+ {"'Q3 plan'!B2", GetOptions{}, "7\n"},107+ {"A1:B3", GetOptions{}, "Item Price\nApple 1.5\nPear $2\n"},108+ {"A1:B3", GetOptions{Format: "csv"}, "Item,Price\nApple,1.5\nPear,$2\n"},109+ {"A1:B3", GetOptions{Format: "nuon"}, "[[Item, Price]; [Apple, 1.5],\n[Pear, 2]]\n"},110+ {"A2:B3", GetOptions{Format: "json", NoHeader: true}, "[\n{\"A\":\"Apple\",\"B\":1.5},\n{\"A\":\"Pear\",\"B\":2}\n]\n"},111+ {"B1:B4", GetOptions{Format: "json", Input: true}, "[\n{\"Price\":\"1.5\"},\n{\"Price\":\"$2\"},\n{\"Price\":\"=SUM(B2:B3)\"}\n]\n"},112+ } {113+ if got := get(t, w, c.ref, c.o); got != c.want {114+ t.Errorf("get %s %+v = %q, want %q", c.ref, c.o, got, c.want)115+ }116+ }117+ if got := get(t, w, "'Q3 plan'!A1", GetOptions{Format: "nuon"}); !strings.HasPrefix(got, "2026-09-29T00:00:00") {118+ t.Errorf("a date as NUON: %q", got)119+ }120+ target, _ := Resolve(w, "A1:B2")121+ if err := Get(io.Discard, target, GetOptions{Format: "xml"}); err == nil {122+ t.Error("--format xml was taken")123+ }124+}125+126+func TestSet(t *testing.T) {127+ w := book(t)128+ warn, err := Set(w, []Entry{{"B2", "10"}, {"'Q3 plan'!C1", "=B2+1"}, {"A2", ""}}, SetOptions{})129+ if err != nil || len(warn) > 0 {130+ t.Fatal(warn, err)131+ }132+ s := w.Sheet(0)133+ if v := s.Value(addr("B4")); v.Num != 12 {134+ t.Errorf("B4 = %v after setting B2", v)135+ }136+ if s.Filled(addr("A2")) {137+ t.Error("an empty input didn't clear A2")138+ }139+ if !w.CanUndo() {140+ t.Error("set wasn't a change")141+ }142+ for _, c := range []struct {143+ e Entry144+ want string145+ }{146+ {Entry{"B5", "=SUM(B2:B3"}, "Sheet1!B5: Expected , or ) in SUM, at character 11 of =SUM(B2:B3"},147+ {Entry{"A1:A2", "x"}, "A1:A2 is the range A1:A2: name one cell"},148+ {Entry{"Q9!A1", "x"}, `no sheet named "Q9"`},149+ } {150+ if _, err := Set(w, []Entry{c.e}, SetOptions{}); err == nil || !strings.Contains(err.Error(), c.want) {151+ t.Errorf("set %v: %v, want %q", c.e, err, c.want)152+ }153+ }154+}155+156+func TestSetRules(t *testing.T) {157+ w := book(t)158+ s := w.Sheet(0)159+ s.Protect(sheet.Protection{Range: sheet.NewRect(addr("D1"), addr("D9"))})160+ if _, err := Set(w, []Entry{{"D2", "1"}}, SetOptions{}); err == nil || !strings.Contains(err.Error(), "Sheet1!D2 is protected (D1:D9): --force") {161+ t.Errorf("a protected cell: %v", err)162+ }163+ if _, err := Set(w, []Entry{{"D2", "1"}}, SetOptions{Force: true}); err != nil {164+ t.Errorf("--force: %v", err)165+ }166+ 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})167+ s.AddValidation(sheet.Validation{Ranges: []sheet.Rect{sheet.NewRect(addr("F1"), addr("F9"))}, Kind: sheet.ValidNumber, Op: sheet.RuleBetween, Args: [2]string{"1", "9"}})168+ if _, err := Set(w, []Entry{{"E1", "5"}, {"E2", "50"}}, SetOptions{}); err == nil || !strings.HasPrefix(err.Error(), "Sheet1!E2: ") {169+ t.Errorf("a rejected entry: %v", err)170+ }171+ warn, err := Set(w, []Entry{{"F1", "50"}}, SetOptions{})172+ if err != nil || len(warn) != 1 || !strings.HasPrefix(warn[0], "Sheet1!F1: ") {173+ t.Errorf("an entry marked invalid: %v %v", warn, err)174+ }175+}176+177+func TestOpenSave(t *testing.T) {178+ dir := t.TempDir()179+ path := filepath.Join(dir, "book.012")180+ if _, err := Open(path, false); err == nil {181+ t.Error("opened a missing file")182+ }183+ f, err := Open(path, true)184+ if err != nil || !f.New {185+ t.Fatal(f, err)186+ }187+ if _, err := Set(f.Book, []Entry{{"A1", "5"}, {"A2", "=A1*2"}}, SetOptions{}); err != nil {188+ t.Fatal(err)189+ }190+ if err := f.Save(); err != nil {191+ t.Fatal(err)192+ }193+ os.Chmod(path, 0o600)194+ old := time.Now().Add(-time.Hour)195+ os.Chtimes(path, old, old)196+ f, err = Open(path, false)197+ if err != nil {198+ t.Fatal(err)199+ }200+ if v := f.Book.Sheet(0).Value(addr("A2")); v.Num != 10 {201+ t.Errorf("A2 = %v", v)202+ }203+ if err := f.Save(); err != nil {204+ t.Fatal(err)205+ }206+ if st, _ := os.Stat(path); !st.ModTime().Equal(old) {207+ t.Error("saving an unchanged workbook rewrote it")208+ }209+ Set(f.Book, []Entry{{"A1", "6"}}, SetOptions{})210+ if err := f.Save(); err != nil {211+ t.Fatal(err)212+ }213+ st, _ := os.Stat(path)214+ if st.ModTime().Equal(old) || st.Mode().Perm() != 0o600 {215+ t.Errorf("saved %v with mode %v", st.ModTime(), st.Mode())216+ }217+ if entries, _ := os.ReadDir(dir); len(entries) != 1 {218+ t.Errorf("left files behind: %v", entries)219+ }220+ os.WriteFile(path, []byte("{nope"), 0o644)221+ if _, err := Open(path, false); err == nil || !strings.HasPrefix(err.Error(), path+": ") {222+ t.Errorf("a broken file: %v", err)223+ }224+}225+226+func TestRecalc(t *testing.T) {227+ w := book(t)228+ w.Sheet(0).Set(addr("D1"), "=D2")229+ w.Sheet(0).Set(addr("D2"), "=D1")230+ problems, circular := Recalc(w)231+ if !circular {232+ t.Error("no circular reference")233+ }234+ var got []string235+ for _, p := range problems {236+ got = append(got, p.At()+" "+p.Value)237+ }238+ if strings.Join(got, ", ") != "Sheet1!C1 #DIV/0!, Sheet1!D1 #REF!, Sheet1!D2 #REF!" {239+ t.Errorf("problems: %v", got)240+ }241+ if problems[0].Why == "" {242+ t.Error("no explanation")243+ }244+}245+246+// fakeNu answers commands with NUON.247+type fakeNu map[string]string248+249+func (f fakeNu) Run(_ context.Context, job nushell.Job, _ string, stdout io.Writer) error {250+ out, ok := f[job.Command]251+ if !ok {252+ return &nushell.Error{Msg: "Command `" + job.Command + "` not found"}253+ }254+ _, err := io.WriteString(stdout, out)255+ return err256+}257+258+func TestRunRegions(t *testing.T) {259+ w := sheet.NewBook()260+ s, err := w.AddNotebook("Shell 1", nil)261+ if err != nil {262+ t.Fatal(err)263+ }264+ if err := s.AddRegion(sheet.Region{Name: "r1", Command: "ls"}); err != nil {265+ t.Fatal(err)266+ }267+ if err := s.AddRegion(sheet.Region{Name: "r2", Command: "$r1 | length", Deps: []string{"r1"}, At: addr("A10")}); err != nil {268+ t.Fatal(err)269+ }270+ if err := s.AddRegion(sheet.Region{Name: "r3", Command: "boom", At: addr("A20")}); err != nil {271+ t.Fatal(err)272+ }273+ nu := fakeNu{"ls": "[[name, size]; [a, 1], [b, 2]]", "$r1 | length": "[[n]; [2]]"}274+ failed := RunRegions(context.Background(), w, RegionOptions{Runner: nu, Timeout: time.Second})275+ if len(failed) != 1 || !strings.Contains(failed[0], "region r3 (boom): Command `boom` not found") {276+ t.Errorf("failed: %v", failed)277+ }278+ target, _ := Resolve(w, "'Shell 1'!A2:B4")279+ var b bytes.Buffer280+ Get(&b, target, GetOptions{Format: "csv"})281+ if b.String() != "name,size\na,1\nb,2\n" {282+ t.Errorf("r1's table:\n%s", b.String())283+ }284+}@@ -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+}@@ -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+}@@ -0,0 +1,138 @@
1+package headless2+3+import (4+ "bytes"5+ "context"6+ "fmt"7+ "sync"8+ "time"9+10+ "github.com/FelineStateMachine/012/internal/fileio"11+ "github.com/FelineStateMachine/012/internal/jev"12+ "github.com/FelineStateMachine/012/internal/nushell"13+ "github.com/FelineStateMachine/012/internal/sheet"14+)15+16+// RegionOptions say how RunRegions runs commands.17+type RegionOptions struct {18+ Runner nushell.Runner // nu, or a fake in tests19+ Timeout time.Duration // for each command20+ NuConfig bool // run nu with the user's config files21+}22+23+// RunRegions runs every notebook region's command with nu, each after24+// the regions it reads, as Run all regions does on the screen, and25+// shows each table. It returns what failed, one line per region; a26+// region that fails leaves the table it had, which in a file just read27+// is none. Linked files' regions are left empty: following a file is28+// the screen's.29+func RunRegions(ctx context.Context, w *sheet.Workbook, o RegionOptions) []string {30+ var failed []string31+ for _, name := range w.RunOrder() {32+ s, r, ok := w.Region(name)33+ if !ok || r.Linked() {34+ continue35+ }36+ if err := runRegion(ctx, s, r, o); err != nil {37+ failed = append(failed, fmt.Sprintf("region %s (%s): %s", r.Name, r.Command, err))38+ }39+ }40+ return failed41+}42+43+func runRegion(ctx context.Context, s *sheet.Sheet, r sheet.Region, o RegionOptions) error {44+ job, err := jobFor(s.Book(), r, o.NuConfig)45+ if err != nil {46+ return err47+ }48+ data, err := nushell.Exec(ctx, o.Runner, job, o.Timeout, 0)49+ if err != nil {50+ return err51+ }52+ return s.ShowRegion(r.Name, data)53+}54+55+// jobFor is what running r takes: the tables it reads, as NUON.56+func jobFor(w *sheet.Workbook, r sheet.Region, config bool) (nushell.Job, error) {57+ job := nushell.Job{Command: r.Command, Tables: map[string][]byte{}, Config: config}58+ for _, d := range r.Deps {59+ s, dr, ok := w.Region(d)60+ if !ok {61+ return job, fmt.Errorf("it reads $%s, which isn't a region any more", d)62+ }63+ t, ok := s.RegionTable(dr.Name)64+ if !ok {65+ t = sheet.Rect{From: dr.At, To: dr.At}66+ }67+ snap := fileio.Snap(s, t, dr.Name)68+ snap.HiddenRows = nil69+ if !ok {70+ snap.Cells = nil71+ }72+ data, err := encodeNUON(snap)73+ if err != nil {74+ return job, err75+ }76+ job.Tables[d] = data77+ }78+ if r.Input != "" {79+ t, err := Resolve(w, r.Input)80+ if err != nil {81+ return job, fmt.Errorf("its input %s is gone", r.Input)82+ }83+ if job.Input, err = encodeNUON(fileio.Snap(t.Sheet, t.Range, t.Sheet.Name())); err != nil {84+ return job, err85+ }86+ }87+ return job, nil88+}89+90+func encodeNUON(snap *fileio.Snapshot) ([]byte, error) {91+ var b bytes.Buffer92+ _, err := fileio.Encode(&b, fileio.NUON, snap)93+ return b.Bytes(), err94+}95+96+// jevParallel is how many questions are asked at once, as on the97+// screen.98+const jevParallel = 899+100+// maxJEVRounds bounds the rounds of questions: answers can make new101+// questions (a JEV function reading another's answer), and each round102+// asks those the last one made.103+const maxJEVRounds = 16104+105+// AnswerJEV answers w's JEV functions with client, asking what the106+// workbook's formulas ask, a few questions at a time, until none is107+// waiting, each within timeout. It returns how many were asked.108+func AnswerJEV(ctx context.Context, w *sheet.Workbook, client jev.Client, timeout time.Duration) int {109+ cache := jev.NewCache()110+ w.SetRemote(cache)111+ asked := 0112+ for range maxJEVRounds {113+ calls := cache.Take(1 << 30)114+ if len(calls) == 0 {115+ break116+ }117+ asked += len(calls)118+ answers := make([]sheet.RemoteAnswer, len(calls))119+ var wg sync.WaitGroup120+ sem := make(chan struct{}, jevParallel)121+ for i, c := range calls {122+ wg.Add(1)123+ sem <- struct{}{}124+ go func() {125+ defer func() { <-sem; wg.Done() }()126+ ctx, cancel := context.WithTimeout(ctx, timeout)127+ defer cancel()128+ answers[i] = jev.Ask(ctx, client, c)129+ }()130+ }131+ wg.Wait()132+ for i, c := range calls {133+ cache.Store(c, answers[i])134+ }135+ w.RecalcAnswered(calls)136+ }137+ return asked138+}@@ -0,0 +1,71 @@
1+package headless2+3+import (4+ "errors"5+ "fmt"6+7+ "github.com/FelineStateMachine/012/internal/sheet"8+)9+10+// Entry is one cell to set: a reference to one cell, and what to type11+// in it, as the file stores entries (numbers and dates in en-US's form,12+// formulas with commas, whatever the workbook's locale); "" clears it.13+type Entry struct {14+ Ref, Input string15+}16+17+// SetOptions tune Set.18+type SetOptions struct {19+ // Force sets cells in protected ranges, which are refused otherwise,20+ // as the screen asks before editing them.21+ Force bool22+}23+24+// Set types each entry into its cell, in order, as one change. An entry25+// a cell can't take (a formula that doesn't parse, a validation rule26+// that rejects it, part of an array, a pivot table or a region, a27+// protected range without Force) stops it with an error naming the28+// cell, and the workbook should then be thrown away rather than saved.29+// Entries a rule would only mark invalid are set, and returned as30+// warnings.31+func Set(w *sheet.Workbook, entries []Entry, o SetOptions) (warnings []string, err error) {32+ err = w.Batch(sheet.Change{Label: "set"}, func() error {33+ for _, e := range entries {34+ warn, err := setOne(w, e, o)35+ if err != nil {36+ return err37+ }38+ if warn != "" {39+ warnings = append(warnings, warn)40+ }41+ }42+ return nil43+ })44+ return warnings, err45+}46+47+func setOne(w *sheet.Workbook, e Entry, o SetOptions) (string, error) {48+ s, a, err := ResolveCell(w, e.Ref)49+ if err != nil {50+ return "", err51+ }52+ at := sheet.Qualified(s.Name(), sheet.Rect{From: a, To: a})53+ if p, ok := s.Protecting(sheet.Rect{From: a, To: a}); ok && !o.Force {54+ return "", fmt.Errorf("%s is protected (%s): --force sets it anyway", at, p.Label())55+ }56+ warn := ""57+ if bad := s.CheckEntry(a, e.Input); bad != nil {58+ if bad.Reject {59+ return "", fmt.Errorf("%s: %s", at, bad.Help)60+ }61+ warn = at + ": " + bad.Help62+ }63+ if err := s.Set(a, e.Input); err != nil {64+ var pe *sheet.ParseError65+ if errors.As(err, &pe) {66+ return "", fmt.Errorf("%s: %s, at character %d of %s", at, pe.Msg, pe.Pos+1, e.Input)67+ }68+ return "", fmt.Errorf("%s: %w", at, err)69+ }70+ return warn, nil71+}