- commit
- 4c0bf0052b7ecc0da2954a23ec7e6b449c806704
- parent
- 66366c6f963ccd13ed736a24b127bc9528ead85a
- author
- Dami <dami.etoile@gmail.com>
| M | go.mod | 3 | +- |
| M | go.sum | 12 | +- |
| A | internal/ | 409 | ++++++++++++++++++++++++++++++ |
| A | internal/ | 169 | ++++++++++++ |
| A | internal/ | 210 | +++++++++++++++ |
| A | internal/ | 169 | ++++++++++++ |
@@ -12,6 +12,7 @@ require (
1212 github.com/parquet-go/parquet-go v0.32.01313 github.com/sahilm/fuzzy v0.1.31414 github.com/xuri/excelize/v2 v2.11.015+ go.starlark.net v0.0.0-20260908191801-89a6a09411d51516 golang.org/x/text v0.38.01617 modernc.org/sqlite v1.59.01718 )@@ -48,7 +49,7 @@ require (
4849 golang.org/x/net v0.56.0 // indirect4950 golang.org/x/sync v0.22.0 // indirect5051 golang.org/x/sys v0.47.0 // indirect51- google.golang.org/protobuf v1.34.2 // indirect52+ google.golang.org/protobuf v1.36.11 // indirect5253 modernc.org/libc v1.75.7 // indirect5354 modernc.org/mathutil v1.7.1 // indirect5455 modernc.org/memory v1.12.1 // indirect@@ -38,8 +38,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
3838 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=3939 github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=4040 github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=41-github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=42-github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=41+github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=42+github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=4343 github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=4444 github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=4545 github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=@@ -100,6 +100,8 @@ github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 h1:+C0TIdyyYmzadGaL/HBL
100100 github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ=101101 github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=102102 github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=103+go.starlark.net v0.0.0-20260908191801-89a6a09411d5 h1:X8HyonnLxrmAbdeMIEGEJVZ/yg6WykLZyAZmpCLSfMA=104+go.starlark.net v0.0.0-20260908191801-89a6a09411d5/go.mod h1:Iue6g6iirlfLoVi/DYCi5/x0h/bAOuWF3dULTKpt2Vo=103105 golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=104106 golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=105107 golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=@@ -118,10 +120,8 @@ golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
118120 golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=119121 golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=120122 golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=121-golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=122-golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=123-google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg=124-google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw=123+google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=124+google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=125125 gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=126126 gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=127127 modernc.org/cc/v4 v4.29.2 h1:h6+9ciCnPKutf4I03CvheAvDLX7+IHlqR6Iy6J+cgd8=@@ -0,0 +1,409 @@
1+package macro2+3+import (4+ "fmt"5+ "math"6+ "slices"7+ "strconv"8+ "strings"9+10+ "go.starlark.net/starlark"11+12+ "github.com/FelineStateMachine/012/internal/formula"13+)14+15+// The functions scripts call. Each checks its arguments, then makes one16+// call to the Host through Env.Do. docs/macros.md documents them; keep17+// the two in step.18+19+type builtinFn func(args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error)20+21+func (r *Run) builtins() starlark.StringDict {22+ fns := map[string]builtinFn{23+ "get": r.get, "get_formula": r.getFormula, "set": r.set, "set_formula": r.setFormula,24+ "clear": r.clear, "number_format": r.numberFormat, "get_number_format": r.getNumberFormat,25+ "selection": r.selection, "active_cell": r.activeCell, "select": r.selectRange,26+ "move": r.move, "extend": r.extend, "jump": r.jump, "enter": r.enter, "paste_text": r.pasteText,27+ "sheets": r.sheets, "active_sheet": r.activeSheet, "activate_sheet": r.activateSheet,28+ "add_sheet": r.addSheet, "move_sheet": r.moveSheet,29+ "run": r.run, "set_width": r.setWidth, "fill": r.fill, "offset": offset,30+ }31+ out := make(starlark.StringDict, len(fns))32+ for name, fn := range fns {33+ out[name] = starlark.NewBuiltin(name, func(_ *starlark.Thread, b *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) {34+ return fn(args, kwargs)35+ })36+ }37+ return out38+}39+40+// host makes one call to the Host where it may be used.41+func (r *Run) host(fn func(h Host) error) error {42+ r.calls++43+ var err error44+ r.env.Do(func() { err = fn(r.env.Host) })45+ return err46+}47+48+// unpack reads arguments for the function whose name leads pairs'49+// error messages; see starlark.UnpackArgs.50+func unpack(fn string, args starlark.Tuple, kwargs []starlark.Tuple, pairs ...any) error {51+ return starlark.UnpackArgs(fn, args, kwargs, pairs...)52+}53+54+func (r *Run) get(args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) {55+ var ref string56+ if err := unpack("get", args, kwargs, "ref", &ref); err != nil {57+ return nil, err58+ }59+ var rows [][]any60+ if err := r.host(func(h Host) (err error) { rows, err = h.Values(ref); return }); err != nil {61+ return nil, err62+ }63+ return shape(ref, rows, toValue), nil64+}65+66+func (r *Run) getFormula(args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) {67+ var ref string68+ if err := unpack("get_formula", args, kwargs, "ref", &ref); err != nil {69+ return nil, err70+ }71+ var rows [][]string72+ if err := r.host(func(h Host) (err error) { rows, err = h.Formulas(ref); return }); err != nil {73+ return nil, err74+ }75+ return shape(ref, rows, func(s string) starlark.Value { return starlark.String(s) }), nil76+}77+78+// shape returns one value for a single cell, or a list of rows.79+func shape[T any](ref string, rows [][]T, conv func(T) starlark.Value) starlark.Value {80+ if len(rows) == 1 && len(rows[0]) == 1 && !strings.Contains(ref, ":") {81+ return conv(rows[0][0])82+ }83+ out := make([]starlark.Value, len(rows))84+ for i, row := range rows {85+ vals := make([]starlark.Value, len(row))86+ for j, v := range row {87+ vals[j] = conv(v)88+ }89+ out[i] = starlark.NewList(vals)90+ }91+ return starlark.NewList(out)92+}93+94+// toValue converts a cell's value: whole numbers become ints, as they95+// read in a sheet.96+func toValue(v any) starlark.Value {97+ switch v := v.(type) {98+ case float64:99+ if v == math.Trunc(v) && math.Abs(v) < 1<<53 {100+ return starlark.MakeInt64(int64(v))101+ }102+ return starlark.Float(v)103+ case string:104+ return starlark.String(v)105+ case bool:106+ return starlark.Bool(v)107+ }108+ return starlark.None109+}110+111+// input is a value as it would be typed into a cell.112+func input(fn string, v starlark.Value) (string, error) {113+ switch v := v.(type) {114+ case starlark.NoneType:115+ return "", nil116+ case starlark.String:117+ return string(v), nil118+ case starlark.Bool:119+ if v {120+ return "TRUE", nil121+ }122+ return "FALSE", nil123+ case starlark.Int:124+ return v.String(), nil125+ case starlark.Float:126+ f := float64(v)127+ if math.IsInf(f, 0) || math.IsNaN(f) {128+ return "", fmt.Errorf("%s: %v can't go in a cell", fn, v)129+ }130+ return strconv.FormatFloat(f, 'g', -1, 64), nil131+ }132+ return "", fmt.Errorf("%s: a cell takes a number, text, True, False or None, not %s", fn, v.Type())133+}134+135+func (r *Run) set(args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) {136+ var ref string137+ var v starlark.Value138+ if err := unpack("set", args, kwargs, "ref", &ref, "value", &v); err != nil {139+ return nil, err140+ }141+ if list, ok := v.(*starlark.List); ok {142+ rows, err := inputRows(list)143+ if err != nil {144+ return nil, err145+ }146+ return starlark.None, r.host(func(h Host) error { return h.SetInputs(ref, rows) })147+ }148+ in, err := input("set", v)149+ if err != nil {150+ return nil, err151+ }152+ return starlark.None, r.host(func(h Host) error { return h.SetInput(ref, in) })153+}154+155+// inputRows reads a list of rows, each a list of values.156+func inputRows(list *starlark.List) ([][]string, error) {157+ rows := make([][]string, list.Len())158+ for i := range list.Len() {159+ row, ok := list.Index(i).(*starlark.List)160+ if !ok {161+ return nil, fmt.Errorf("set: a range takes a list of rows, e.g. [[1, 2], [3, 4]]")162+ }163+ for j := range row.Len() {164+ in, err := input("set", row.Index(j))165+ if err != nil {166+ return nil, err167+ }168+ rows[i] = append(rows[i], in)169+ }170+ }171+ return rows, nil172+}173+174+func (r *Run) setFormula(args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) {175+ var ref, f string176+ if err := unpack("set_formula", args, kwargs, "ref", &ref, "formula", &f); err != nil {177+ return nil, err178+ }179+ if !strings.HasPrefix(f, "=") {180+ f = "=" + f181+ }182+ return starlark.None, r.host(func(h Host) error { return h.SetFormula(ref, f) })183+}184+185+func (r *Run) clear(args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) {186+ var ref string187+ if err := unpack("clear", args, kwargs, "ref?", &ref); err != nil {188+ return nil, err189+ }190+ return starlark.None, r.host(func(h Host) error { return h.Clear(ref) })191+}192+193+func (r *Run) numberFormat(args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) {194+ var ref, kind, pattern string195+ var decimals starlark.Value = starlark.None196+ if err := unpack("number_format", args, kwargs, "ref", &ref, "kind", &kind, "decimals?", &decimals, "pattern?", &pattern); err != nil {197+ return nil, err198+ }199+ d := -1200+ if decimals != starlark.None {201+ var err error202+ if d, err = starlark.AsInt32(decimals); err != nil || d < 0 {203+ return nil, fmt.Errorf("number_format: decimals is a whole number from 0, not %s", decimals)204+ }205+ }206+ return starlark.None, r.host(func(h Host) error { return h.SetNumberFormat(ref, kind, d, pattern) })207+}208+209+func (r *Run) getNumberFormat(args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) {210+ var ref, kind string211+ if err := unpack("get_number_format", args, kwargs, "ref", &ref); err != nil {212+ return nil, err213+ }214+ err := r.host(func(h Host) (err error) { kind, _, err = h.NumberFormat(ref); return })215+ return starlark.String(kind), err216+}217+218+func (r *Run) selection(args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) {219+ if err := unpack("selection", args, kwargs); err != nil {220+ return nil, err221+ }222+ var sel string223+ r.host(func(h Host) error { sel, _ = h.Selection(); return nil })224+ return starlark.String(sel), nil225+}226+227+func (r *Run) activeCell(args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) {228+ if err := unpack("active_cell", args, kwargs); err != nil {229+ return nil, err230+ }231+ var active string232+ r.host(func(h Host) error { _, active = h.Selection(); return nil })233+ return starlark.String(active), nil234+}235+236+func (r *Run) selectRange(args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) {237+ var ref, active string238+ if err := unpack("select", args, kwargs, "ref", &ref, "active?", &active); err != nil {239+ return nil, err240+ }241+ return starlark.None, r.host(func(h Host) error { return h.Select(ref, active) })242+}243+244+func (r *Run) move(args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) {245+ var cols, rows int246+ if err := unpack("move", args, kwargs, "cols?", &cols, "rows?", &rows); err != nil {247+ return nil, err248+ }249+ return starlark.None, r.host(func(h Host) error { return h.Move(cols, rows) })250+}251+252+func (r *Run) extend(args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) {253+ var cols, rows int254+ var whole string255+ if err := unpack("extend", args, kwargs, "cols?", &cols, "rows?", &rows, "whole?", &whole); err != nil {256+ return nil, err257+ }258+ if whole != "" && whole != "columns" && whole != "rows" {259+ return nil, fmt.Errorf("extend: whole is \"columns\" or \"rows\", not %q", whole)260+ }261+ return starlark.None, r.host(func(h Host) error { return h.Extend(cols, rows, whole) })262+}263+264+func (r *Run) jump(args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) {265+ var to string266+ var ext bool267+ if err := unpack("jump", args, kwargs, "to", &to, "extend?", &ext); err != nil {268+ return nil, err269+ }270+ if !slices.Contains(JumpTargets, to) {271+ return nil, fmt.Errorf("jump: to is one of %s, not %q", strings.Join(JumpTargets, ", "), to)272+ }273+ return starlark.None, r.host(func(h Host) error { return h.Jump(to, ext) })274+}275+276+func (r *Run) enter(args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) {277+ var v starlark.Value278+ var fill bool279+ if err := unpack("enter", args, kwargs, "text", &v, "fill?", &fill); err != nil {280+ return nil, err281+ }282+ text, err := input("enter", v)283+ if err != nil {284+ return nil, err285+ }286+ return starlark.None, r.host(func(h Host) error { return h.Enter(text, fill) })287+}288+289+func (r *Run) pasteText(args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) {290+ var text string291+ if err := unpack("paste_text", args, kwargs, "text", &text); err != nil {292+ return nil, err293+ }294+ return starlark.None, r.host(func(h Host) error { return h.PasteText(text) })295+}296+297+func (r *Run) sheets(args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) {298+ if err := unpack("sheets", args, kwargs); err != nil {299+ return nil, err300+ }301+ var names []string302+ r.host(func(h Host) error { names = h.Sheets(); return nil })303+ vals := make([]starlark.Value, len(names))304+ for i, n := range names {305+ vals[i] = starlark.String(n)306+ }307+ return starlark.NewList(vals), nil308+}309+310+func (r *Run) activeSheet(args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) {311+ if err := unpack("active_sheet", args, kwargs); err != nil {312+ return nil, err313+ }314+ var name string315+ r.host(func(h Host) error { name = h.ActiveSheet(); return nil })316+ return starlark.String(name), nil317+}318+319+func (r *Run) activateSheet(args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) {320+ var name string321+ if err := unpack("activate_sheet", args, kwargs, "name", &name); err != nil {322+ return nil, err323+ }324+ return starlark.None, r.host(func(h Host) error { return h.ActivateSheet(name) })325+}326+327+func (r *Run) addSheet(args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) {328+ var name string329+ if err := unpack("add_sheet", args, kwargs, "name?", &name); err != nil {330+ return nil, err331+ }332+ var got string333+ err := r.host(func(h Host) (err error) { got, err = h.AddSheet(name); return })334+ return starlark.String(got), err335+}336+337+func (r *Run) moveSheet(args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) {338+ var pos int339+ if err := unpack("move_sheet", args, kwargs, "position", &pos); err != nil {340+ return nil, err341+ }342+ return starlark.None, r.host(func(h Host) error { return h.MoveSheet(pos) })343+}344+345+func (r *Run) run(args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) {346+ var id string347+ var answer starlark.Value = starlark.None348+ if err := unpack("run", args, kwargs, "id", &id, "answer?", &answer); err != nil {349+ return nil, err350+ }351+ var a *string352+ if answer != starlark.None {353+ text, err := input("run", answer)354+ if err != nil {355+ return nil, err356+ }357+ a = &text358+ }359+ return starlark.None, r.host(func(h Host) error { return h.Run(id, a) })360+}361+362+func (r *Run) setWidth(args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) {363+ var cols string364+ var width int365+ if err := unpack("set_width", args, kwargs, "cols", &cols, "width", &width); err != nil {366+ return nil, err367+ }368+ return starlark.None, r.host(func(h Host) error { return h.SetWidth(cols, width) })369+}370+371+func (r *Run) fill(args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) {372+ var to string373+ var rows, cols int374+ if err := unpack("fill", args, kwargs, "to?", &to, "rows?", &rows, "cols?", &cols); err != nil {375+ return nil, err376+ }377+ if (to != "") == (rows != 0 || cols != 0) || rows != 0 && cols != 0 {378+ return nil, fmt.Errorf("fill: give either to, or one of rows and cols")379+ }380+ return starlark.None, r.host(func(h Host) error { return h.Fill(to, rows, cols) })381+}382+383+// offset is ref moved by cols and rows, keeping its sheet and size; it384+// doesn't need the spreadsheet.385+func offset(args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) {386+ var ref string387+ var cols, rows int388+ if err := unpack("offset", args, kwargs, "ref", &ref, "cols?", &cols, "rows?", &rows); err != nil {389+ return nil, err390+ }391+ name, rest := formula.SplitSheet(ref)392+ rng, ok := formula.ParseRange(rest)393+ if !ok {394+ return nil, fmt.Errorf("offset: not a cell or range: %s", ref)395+ }396+ rng.From.Col, rng.To.Col = rng.From.Col+cols, rng.To.Col+cols397+ rng.From.Row, rng.To.Row = rng.From.Row+rows, rng.To.Row+rows398+ if !rng.From.Valid() || !rng.To.Valid() {399+ return nil, fmt.Errorf("offset: %s moved by %d, %d is off the sheet", ref, cols, rows)400+ }401+ out := rng.String()402+ if rng.From == rng.To && !strings.Contains(rest, ":") {403+ out = rng.From.String()404+ }405+ if name != "" {406+ out = formula.QuoteSheet(name) + "!" + out407+ }408+ return starlark.String(out), nil409+}@@ -0,0 +1,169 @@
1+// Package macro runs macros: Starlark scripts that act on a spreadsheet2+// through a small API, as Google Sheets macros are Apps Script. A3+// recorded macro is a list of Actions written out as a script (Source),4+// so recorded and hand-written macros run the same way.5+//6+// Scripts can't reach the file system, the network, the clock or random7+// numbers: the only way out is the Host. Every run has a step limit and8+// can be cancelled from another goroutine. The package knows nothing of9+// the terminal or the engine; internal/ui implements Host.10+package macro11+12+import (13+ "fmt"14+ "strconv"15+ "strings"16+17+ "go.starlark.net/syntax"18+)19+20+// Host is the spreadsheet a script acts on. References are A1 strings,21+// optionally with a sheet ("Sheet2!B3:C9"); a range is a cell or a22+// rectangle. Its methods are only called inside Env.Do.23+type Host interface {24+ // Values returns the computed values of ref, row by row: nil for a25+ // blank, float64, string, bool, or an error value's code as a string26+ // ("#DIV/0!").27+ Values(ref string) ([][]any, error)28+ // Formulas returns what was typed in each cell of ref, row by row,29+ // when it's a formula, and "" otherwise.30+ Formulas(ref string) ([][]string, error)31+ // SetInput enters input, as if typed, in every cell of ref.32+ SetInput(ref, input string) error33+ // SetInputs enters a block of inputs, as if typed, from ref's first34+ // cell.35+ SetInputs(ref string, rows [][]string) error36+ // SetFormula enters formula in ref's first cell and fills it over the37+ // rest, adjusting relative references as a copy does.38+ SetFormula(ref, formula string) error39+ // Clear erases the contents of ref, or of the selection when ref is "".40+ Clear(ref string) error41+ // NumberFormat is the number format of ref's first cell: its kind42+ // ("auto", "number", "currency"...) and decimals.43+ NumberFormat(ref string) (kind string, decimals int, err error)44+ // SetNumberFormat formats ref; decimals < 0 keeps the kind's default,45+ // and pattern is for the "custom" kind.46+ SetNumberFormat(ref, kind string, decimals int, pattern string) error47+48+ // Selection is the selected range and the active cell in it.49+ Selection() (sel, active string)50+ // Select selects ref, switching sheets if it names one, with active51+ // as the active cell ("" for ref's first cell).52+ Select(ref, active string) error53+ // Move moves the active cell by cols and rows and drops the54+ // selection.55+ Move(cols, rows int) error56+ // Extend selects from the active cell to cols and rows away from it;57+ // whole is "", "columns" or "rows".58+ Extend(cols, rows int, whole string) error59+ // Jump moves as Ctrl+arrows, Home, Ctrl+Home or Ctrl+End do (see60+ // JumpTargets), extending the selection when extend is set.61+ Jump(to string, extend bool) error62+ // Enter stores text in the active cell as if typed and accepted, or63+ // in every selected cell, references adjusted, when fill is set.64+ Enter(text string, fill bool) error65+ // PasteText pastes tab-separated text at the active cell, as pasting66+ // from another program does.67+ PasteText(text string) error68+69+ // Sheets lists the sheet names in tab order.70+ Sheets() []string71+ // ActiveSheet names the sheet shown.72+ ActiveSheet() string73+ // ActivateSheet shows the named sheet.74+ ActivateSheet(name string) error75+ // AddSheet adds a sheet after the one shown and shows it, returning76+ // its name; name "" picks the next free one.77+ AddSheet(name string) (string, error)78+ // MoveSheet moves the sheet shown to position pos, counting from 1.79+ MoveSheet(pos int) error80+81+ // Run runs a registered command by id. A command that asks a82+ // question (a width, a name, a confirmation) gets answer, which is83+ // required then.84+ Run(id string, answer *string) error85+ // SetWidth sets the width of the columns cols, e.g. "B" or "B:D".86+ SetWidth(cols string, width int) error87+ // Fill fills from the selection as dragging the fill handle does: to88+ // the range to, or by rows (down, or up when negative) or cols.89+ Fill(to string, rows, cols int) error90+}91+92+// JumpTargets are the places Jump goes: the edge of the data in a93+// direction (Ctrl+arrows), the row's first column (Home), A1 (Ctrl+Home)94+// and the last used cell (Ctrl+End).95+var JumpTargets = []string{"up", "down", "left", "right", "home", "start", "end"}96+97+// Action is one step of a recording: a call of one of the script's98+// functions, or a comment when Func is "". It is plain data, so a log99+// can be kept, compared or saved before it becomes a script.100+type Action struct {101+ Func string `json:"func,omitempty"`102+ Args []any `json:"args,omitempty"` // string, int or bool103+ Named []Arg `json:"named,omitempty"`104+ Comment string `json:"comment,omitempty"`105+}106+107+// Arg is a named argument, e.g. fill=True.108+type Arg struct {109+ Name string `json:"name"`110+ Value any `json:"value"`111+}112+113+// Call is a shorthand for an Action calling fn with args.114+func Call(fn string, args ...any) Action { return Action{Func: fn, Args: args} }115+116+// Note is a comment in a recording.117+func Note(text string) Action { return Action{Comment: text} }118+119+// With adds a named argument.120+func (a Action) With(name string, v any) Action {121+ a.Named = append(a.Named, Arg{name, v})122+ return a123+}124+125+// String is the action as a line of Starlark.126+func (a Action) String() string {127+ if a.Func == "" {128+ return "# " + strings.ReplaceAll(a.Comment, "\n", " ")129+ }130+ parts := make([]string, 0, len(a.Args)+len(a.Named))131+ for _, v := range a.Args {132+ parts = append(parts, literal(v))133+ }134+ for _, n := range a.Named {135+ parts = append(parts, n.Name+"="+literal(n.Value))136+ }137+ return a.Func + "(" + strings.Join(parts, ", ") + ")"138+}139+140+// literal writes a Go value as a Starlark literal.141+func literal(v any) string {142+ switch v := v.(type) {143+ case string:144+ return syntax.Quote(v, false)145+ case int:146+ return strconv.Itoa(v)147+ case bool:148+ if v {149+ return "True"150+ }151+ return "False"152+ case nil:153+ return "None"154+ }155+ panic(fmt.Sprintf("macro: no literal for %T", v))156+}157+158+// Source writes a recording as a script, one action per line, under a159+// header comment.160+func Source(header string, actions []Action) string {161+ var b strings.Builder162+ for _, l := range strings.Split(header, "\n") {163+ b.WriteString(strings.TrimRight("# "+l, " ") + "\n")164+ }165+ for _, a := range actions {166+ b.WriteString(a.String() + "\n")167+ }168+ return b.String()169+}@@ -0,0 +1,210 @@
1+package macro2+3+import (4+ "errors"5+ "fmt"6+ "strings"7+ "testing"8+ "time"9+)10+11+// fakeHost records the calls it gets and answers from a few cells.12+type fakeHost struct {13+ calls []string14+ cells map[string]any15+}16+17+func (f *fakeHost) log(format string, args ...any) error {18+ f.calls = append(f.calls, fmt.Sprintf(format, args...))19+ return nil20+}21+22+func (f *fakeHost) Values(ref string) ([][]any, error) {23+ if ref == "bad" {24+ return nil, errors.New("not a cell or range: bad")25+ }26+ if strings.Contains(ref, ":") {27+ return [][]any{{f.cells["A1"], f.cells["B1"]}}, nil28+ }29+ return [][]any{{f.cells[ref]}}, nil30+}31+func (f *fakeHost) Formulas(ref string) ([][]string, error) { return [][]string{{"=1+1"}}, nil }32+func (f *fakeHost) SetInput(ref, in string) error {33+ f.cells[ref] = in34+ return f.log("set %s %q", ref, in)35+}36+func (f *fakeHost) SetInputs(ref string, rows [][]string) error { return f.log("set %s %q", ref, rows) }37+func (f *fakeHost) SetFormula(ref, fm string) error { return f.log("formula %s %s", ref, fm) }38+func (f *fakeHost) Clear(ref string) error { return f.log("clear %q", ref) }39+func (f *fakeHost) NumberFormat(string) (string, int, error) { return "currency", 2, nil }40+func (f *fakeHost) SetNumberFormat(ref, kind string, d int, p string) error {41+ return f.log("format %s %s %d %q", ref, kind, d, p)42+}43+func (f *fakeHost) Selection() (string, string) { return "B2:C3", "B2" }44+func (f *fakeHost) Select(ref, active string) error { return f.log("select %s %q", ref, active) }45+func (f *fakeHost) Move(c, r int) error { return f.log("move %d %d", c, r) }46+func (f *fakeHost) Extend(c, r int, whole string) error { return f.log("extend %d %d %q", c, r, whole) }47+func (f *fakeHost) Jump(to string, ext bool) error { return f.log("jump %s %v", to, ext) }48+func (f *fakeHost) Enter(text string, fill bool) error { return f.log("enter %q %v", text, fill) }49+func (f *fakeHost) PasteText(text string) error { return f.log("paste %q", text) }50+func (f *fakeHost) Sheets() []string { return []string{"Sheet1", "Two"} }51+func (f *fakeHost) ActiveSheet() string { return "Sheet1" }52+func (f *fakeHost) ActivateSheet(name string) error { return f.log("activate %s", name) }53+func (f *fakeHost) AddSheet(name string) (string, error) { return "Sheet3", f.log("add %q", name) }54+func (f *fakeHost) MoveSheet(pos int) error { return f.log("move sheet %d", pos) }55+func (f *fakeHost) Run(id string, answer *string) error {56+ if answer == nil {57+ return f.log("run %s", id)58+ }59+ return f.log("run %s %q", id, *answer)60+}61+func (f *fakeHost) SetWidth(cols string, w int) error { return f.log("width %s %d", cols, w) }62+func (f *fakeHost) Fill(to string, rows, cols int) error {63+ return f.log("fill %q %d %d", to, rows, cols)64+}65+66+func newFake() *fakeHost { return &fakeHost{cells: map[string]any{"A1": 2.0, "B1": "x", "C1": 2.5}} }67+68+func TestSourceRunsAsRecorded(t *testing.T) {69+ actions := []Action{70+ Call("select", "B2"),71+ Call("enter", "=SUM(A1:A3)"),72+ Call("move", 0, 1),73+ Call("extend", 2, 0).With("whole", "columns"),74+ Call("jump", "down").With("extend", true),75+ Call("enter", "total").With("fill", true),76+ Call("paste_text", "a\tb\n\"c\""),77+ Note("not recorded: sort A1:C9"),78+ Call("run", "column.width").With("answer", "12"),79+ Call("run", "format.bold"),80+ Call("activate_sheet", "Q3 plan"),81+ Call("set_width", "B:C", 14),82+ Call("fill").With("rows", 3),83+ Call("move_sheet", 2),84+ }85+ src := Source("Recorded with absolute references.\n\nSecond line", actions)86+ if !strings.HasPrefix(src, "# Recorded with absolute references.\n#\n# Second line\nselect(\"B2\")\n") {87+ t.Errorf("source:\n%s", src)88+ }89+ f := newFake()90+ if _, err := New("m", src, Env{Host: f}).Exec(); err != nil {91+ t.Fatalf("%v\n%s", err, src)92+ }93+ want := []string{94+ `select B2 ""`, `enter "=SUM(A1:A3)" false`, "move 0 1", `extend 2 0 "columns"`, "jump down true",95+ `enter "total" true`, `paste "a\tb\n\"c\""`, `run column.width "12"`, "run format.bold",96+ "activate Q3 plan", "width B:C 14", `fill "" 3 0`, "move sheet 2",97+ }98+ if strings.Join(f.calls, "\n") != strings.Join(want, "\n") {99+ t.Errorf("calls:\n%s\nwant:\n%s", strings.Join(f.calls, "\n"), strings.Join(want, "\n"))100+ }101+}102+103+func TestScriptAPI(t *testing.T) {104+ src := `105+a = get("A1")106+b = get("B1")107+row = get("A1:B1")108+set("D1", a * 3)109+set("D2", get("C1") * 2)110+set("D3", True)111+set("D4", None)112+set("E1:F2", [[1, "two"], [3.5, False]])113+set_formula("G1:G9", "A1*2")114+set("H1", "%s %s %s" % (type(a), b, row))115+set("H2", get_formula("G1") + get_number_format("A1"))116+set("H3", selection() + " " + active_cell() + " " + active_sheet() + " " + ",".join(sheets()))117+set("H4", offset("A1", 2, 3) + " " + offset("'Q3 plan'!A1:B2", 1, 0))118+number_format("A1:A3", "percent", decimals=1)119+clear()120+x = add_sheet()121+print("done", x)122+`123+ f := newFake()124+ var printed []string125+ st, err := New("api", src, Env{Host: f, Print: func(s string) { printed = append(printed, s) }}).Exec()126+ if err != nil {127+ t.Fatal(err)128+ }129+ for ref, want := range map[string]string{130+ "D1": "6", "D2": "5", "D3": "TRUE", "D4": "", "H1": `int x [[2, "x"]]`, "H2": "=1+1currency",131+ "H3": "B2:C3 B2 Sheet1 Sheet1,Two", "H4": "C4 'Q3 plan'!B1:C2",132+ } {133+ if got := f.cells[ref]; got != want {134+ t.Errorf("%s = %q, want %q", ref, got, want)135+ }136+ }137+ for _, want := range []string{`set E1:F2 [["1" "two"] ["3.5" "FALSE"]]`, "formula G1:G9 =A1*2", `format A1:A3 percent 1 ""`, `clear ""`, `add ""`} {138+ if !strings.Contains(strings.Join(f.calls, "\n"), want) {139+ t.Errorf("no call %q in\n%s", want, strings.Join(f.calls, "\n"))140+ }141+ }142+ if len(printed) != 1 || printed[0] != "done Sheet3" {143+ t.Errorf("printed %q", printed)144+ }145+ if st.Calls < 15 || st.Steps == 0 {146+ t.Errorf("stats %+v", st)147+ }148+}149+150+func TestErrorsHavePositions(t *testing.T) {151+ for _, tc := range []struct {152+ src, want string153+ compiles bool154+ }{155+ {"move(0, 1)\nset(\"A1\"", "m:2:9: got end of file, want ')'", false},156+ {"x = 1\nnope(2)", "m:2:1: undefined: nope", false},157+ {"move(0, 1)\n get(\"bad\")", "m:2:3: got indent, want primary expression", false},158+ {"move()\nget(\"bad\")", "m:2:4: get: not a cell or range: bad", true},159+ {"def f():\n jump(\"sideways\")\nf()", "m:2:9: jump: to is one of up, down, left, right, home, start, end, not \"sideways\"", true},160+ {"set(\"A1\", {})", "m:1:4: set: a cell takes a number, text, True, False or None, not dict", true},161+ {"move(1, 2, 3)", "m:1:5: move: got 3 arguments, want at most 2", true},162+ } {163+ _, err := New("m", tc.src, Env{Host: newFake()}).Exec()164+ if err == nil || err.Error() != tc.want {165+ t.Errorf("%q: %v, want %q", tc.src, err, tc.want)166+ }167+ if err := Check("m", tc.src); (err == nil) != tc.compiles {168+ t.Errorf("Check(%q) = %v", tc.src, err)169+ }170+ }171+}172+173+func TestStepLimitStopsALoop(t *testing.T) {174+ _, err := New("loop", "while True:\n pass\n", Env{Host: newFake(), MaxSteps: 10_000}).Exec()175+ var me *Error176+ if !errors.As(err, &me) || !me.Limit || !strings.HasPrefix(err.Error(), "loop:1:1: stopped after 1000") {177+ t.Fatalf("err %v", err)178+ }179+}180+181+func TestCancelStopsARun(t *testing.T) {182+ r := New("spin", "while True:\n move(0, 0)\n", Env{Host: newFake(), MaxSteps: 1 << 62})183+ done := make(chan error)184+ go func() { _, err := r.Exec(); done <- err }()185+ time.Sleep(10 * time.Millisecond)186+ r.Cancel()187+ select {188+ case err := <-done:189+ var me *Error190+ if !errors.As(err, &me) || !me.Cancel || !strings.Contains(err.Error(), "stopped with Esc") {191+ t.Fatalf("err %v", err)192+ }193+ case <-time.After(5 * time.Second):194+ t.Fatal("cancel didn't stop the run")195+ }196+}197+198+func TestNoWayOut(t *testing.T) {199+ // Starlark has no file, network, time or random access of its own,200+ // and without a loader nothing else can be brought in.201+ for _, name := range []string{"open", "time", "random", "exec", "eval", "load_module", "__import__"} {202+ if err := Check("m", name+"()"); err == nil {203+ t.Errorf("%s is reachable", name)204+ }205+ }206+ _, err := New("m", `load("time.star", "now")`, Env{Host: newFake()}).Exec()207+ if err == nil {208+ t.Error("load works")209+ }210+}@@ -0,0 +1,169 @@
1+package macro2+3+import (4+ "errors"5+ "fmt"6+ "strings"7+ "sync/atomic"8+9+ "go.starlark.net/resolve"10+ "go.starlark.net/starlark"11+ "go.starlark.net/syntax"12+)13+14+// DefaultMaxSteps bounds a run: enough for loops over tens of thousands15+// of cells, few enough that a script that never ends stops within a16+// second or so.17+const DefaultMaxSteps = 10_000_00018+19+// Env is where a run happens.20+type Env struct {21+ Host Host22+ // Do runs fn where Host may be used and returns once it has; nil23+ // runs it directly. The UI hands calls to its own goroutine here, so24+ // the script can run on another.25+ Do func(fn func())26+ // Print shows a line printed by the script; called inside Do.27+ Print func(msg string)28+ // MaxSteps bounds the run; 0 means DefaultMaxSteps.29+ MaxSteps uint6430+}31+32+// Stats describe a finished run.33+type Stats struct {34+ Steps uint64 // Starlark computation steps35+ Calls int // calls to the spreadsheet36+}37+38+// ErrCancelled is the reason a cancelled run gives.39+var ErrCancelled = errors.New("stopped")40+41+// Error is a script's failure at a position in it, e.g.42+// "Totals:3:5: set: not a cell or range: ZZ".43+type Error struct {44+ Pos string // name:line:column, or "" when not at a place in the script45+ Msg string46+ Cancel bool // the run was cancelled47+ Limit bool // the run hit its step limit48+}49+50+func (e *Error) Error() string {51+ if e.Pos == "" {52+ return e.Msg53+ }54+ return e.Pos + ": " + e.Msg55+}56+57+// options are the language options: top-level if, for and while, so a58+// recording can be a plain list of statements and scripts don't need a59+// main function, and reassigning globals, as in Python.60+var options = &syntax.FileOptions{Set: true, While: true, TopLevelControl: true, GlobalReassign: true}61+62+// Run is one run of a script.63+type Run struct {64+ name, src string65+ env Env66+ thread *starlark.Thread67+ calls int68+ cancelled atomic.Bool69+}70+71+// New prepares a run of src, named name in error positions.72+func New(name, src string, env Env) *Run {73+ if env.Do == nil {74+ env.Do = func(fn func()) { fn() }75+ }76+ if env.Print == nil {77+ env.Print = func(string) {}78+ }79+ r := &Run{name: name, src: src, env: env}80+ r.thread = &starlark.Thread{Name: name}81+ r.thread.Print = func(_ *starlark.Thread, msg string) { r.env.Do(func() { r.env.Print(msg) }) }82+ // No Load: scripts can't load modules, so nothing but the builtins83+ // below is reachable.84+ max := env.MaxSteps85+ if max == 0 {86+ max = DefaultMaxSteps87+ }88+ r.thread.SetMaxExecutionSteps(max)89+ return r90+}91+92+// Cancel stops the run as soon as the script next takes a step. It is93+// safe to call from any goroutine.94+func (r *Run) Cancel() {95+ r.cancelled.Store(true)96+ r.thread.Cancel(ErrCancelled.Error())97+}98+99+// Exec runs the script to its end on the calling goroutine. Host calls100+// go through Env.Do.101+func (r *Run) Exec() (Stats, error) {102+ _, err := starlark.ExecFileOptions(options, r.thread, r.name, r.src, r.builtins())103+ st := Stats{Steps: r.thread.ExecutionSteps(), Calls: r.calls}104+ return st, r.explain(err)105+}106+107+// Check parses and resolves src without running it, reporting the first108+// problem with its position.109+func Check(name, src string) error {110+ _, _, err := starlark.SourceProgramOptions(options, name, src, func(n string) bool { return predeclared[n] })111+ return (&Run{name: name}).explain(err)112+}113+114+// predeclared are the names scripts see besides Starlark's own.115+var predeclared = func() map[string]bool {116+ out := map[string]bool{}117+ for n := range (&Run{}).builtins() {118+ out[n] = true119+ }120+ return out121+}()122+123+// explain turns an error from Starlark into an Error with the script124+// position it happened at.125+func (r *Run) explain(err error) error {126+ if err == nil {127+ return nil128+ }129+ var se syntax.Error130+ var re resolve.ErrorList131+ var ee *starlark.EvalError132+ switch {133+ case errors.As(err, &se):134+ return &Error{Pos: position(se.Pos), Msg: se.Msg}135+ case errors.As(err, &re):136+ return &Error{Pos: position(re[0].Pos), Msg: re[0].Msg}137+ case errors.As(err, &ee):138+ return r.evalError(ee)139+ }140+ return &Error{Msg: err.Error()}141+}142+143+// evalError places an evaluation error at the innermost line of the144+// script, naming the function that failed when it's one of ours.145+func (r *Run) evalError(ee *starlark.EvalError) *Error {146+ e := &Error{Msg: ee.Msg}147+ stack := ee.CallStack148+ if n := len(stack); n > 0 && stack[n-1].Pos.Filename() == "<builtin>" {149+ if name := stack[n-1].Name; !strings.HasPrefix(e.Msg, name+":") {150+ e.Msg = name + ": " + e.Msg151+ }152+ stack = stack[:n-1]153+ }154+ if n := len(stack); n > 0 {155+ e.Pos = position(stack[n-1].Pos)156+ }157+ switch {158+ case r.cancelled.Load() && strings.Contains(ee.Msg, ErrCancelled.Error()):159+ e.Msg, e.Cancel = "stopped with Esc", true160+ case strings.Contains(ee.Msg, "too many steps"):161+ e.Msg = fmt.Sprintf("stopped after %d steps: does it loop forever?", r.thread.ExecutionSteps())162+ e.Limit = true163+ }164+ return e165+}166+167+func position(p syntax.Position) string {168+ return fmt.Sprintf("%s:%d:%d", p.Filename(), p.Line, p.Col)169+}