cat / 012
clone

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

Add spreadsheet engine with 1-2-3 formula parser and recalc

Dami committed a73d6a9
Sparse cell storage, A1 addressing up to IV8192, a Pratt parser for
1-2-3 syntax (+A1*2, @SUM(A1..B3), #AND#), lazy dependency-ordered
recalculation with cycle detection, General number formatting and a
JSON file format. Includes unit tests and a parser fuzz target.
commit
a73d6a9ac432ae46a668d647aa171ad11482abbd
author
Dami <dami.etoile@gmail.com>
10 files changed, +1432 -0
A.gitignore3+
Ago.mod26++
Ago.sum40++++
Ainternal/sheet/addr.go120+++++++++++
Ainternal/sheet/eval.go301++++++++++++++++++++++++++++
Ainternal/sheet/file.go64++++++
Ainternal/sheet/parse.go320++++++++++++++++++++++++++++++
Ainternal/sheet/sheet.go317++++++++++++++++++++++++++++++
Ainternal/sheet/sheet_test.go239++++++++++++++++++++++
Ainternal/sheet/testdata/fuzz/FuzzParse/5838cdfae7b16cde2+
A .gitignore +3 -0
@@ -0,0 +1,3 @@
1+bin/2+.deps/3+*.tmp
A go.mod +26 -0
A go.sum +40 -0
@@ -0,0 +1,40 @@
1+charm.land/bubbletea/v2 v2.0.10 h1:oolvo20VBpI0PfqE7iFjkZ1bx0WpmXGfnKz5Yldjq5o=2+charm.land/bubbletea/v2 v2.0.10/go.mod h1:QOatcnhOjYIfxzUSTz6raF7Ex4R/rIuHa3SnBdCCpMc=3+charm.land/lipgloss/v2 v2.0.6 h1:EaGKeuA8FvF+v2BT5VmZd2LoYLaMZJXA5n34th8nCIQ=4+charm.land/lipgloss/v2 v2.0.6/go.mod h1:ipDDJNSGa1hlwDtSfW1s2/xR8Vdhbut4PXh2zEKZd0Q=5+github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o=6+github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=7+github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q=8+github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=9+github.com/charmbracelet/ultraviolet v0.0.0-20260811164956-006e29f97886 h1:rdnVWKgJpTVXKuKuJyxDJ+NFJdUaUqGvyGy61OcvlbA=10+github.com/charmbracelet/ultraviolet v0.0.0-20260811164956-006e29f97886/go.mod h1:nAw0d9PhFp1qdzi2xhQU5YOu5sVpDIHWlaW2Uz/bCro=11+github.com/charmbracelet/x/ansi v0.11.8 h1:JMFwp0CgDC2+jcOB162HH5k7I3FVbgFSMMYg7dSPBQQ=12+github.com/charmbracelet/x/ansi v0.11.8/go.mod h1:ZNN+3mXny/516oTQPLMPIBeSINvNJJQ8uQXDgbeJxY0=13+github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA=14+github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I=15+github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=16+github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=17+github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=18+github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=19+github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM=20+github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k=21+github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8=22+github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=23+github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=24+github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=25+github.com/lucasb-eyer/go-colorful v1.4.1 h1:1EO+WB73+EH8EVbzlrG3KLAfEypQWVHIBqlTf+2hNss=26+github.com/lucasb-eyer/go-colorful v1.4.1/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=27+github.com/mattn/go-runewidth v0.0.24 h1:cpokDiIn0MGnhdHwuWnJBITySJ20QyNGnY2kR/ay2DU=28+github.com/mattn/go-runewidth v0.0.24/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=29+github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=30+github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=31+github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=32+github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=33+github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=34+github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=35+golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=36+golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=37+golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=38+golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=39+golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=40+golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
A internal/sheet/addr.go +120 -0
@@ -0,0 +1,120 @@
1+package sheet2+3+import (4+ "strconv"5+ "strings"6+)7+8+// Worksheet bounds, matching Lotus 1-2-3 Release 2 (A..IV, 1..8192).9+const (10+ MaxCols = 25611+ MaxRows = 819212+)13+14+// Addr identifies a cell by zero-based column and row.15+type Addr struct {16+ Col, Row int17+}18+19+// String returns the A1-style name of the cell.20+func (a Addr) String() string {21+ return ColName(a.Col) + strconv.Itoa(a.Row+1)22+}23+24+// Valid reports whether a lies inside the worksheet.25+func (a Addr) Valid() bool {26+ return a.Col >= 0 && a.Col < MaxCols && a.Row >= 0 && a.Row < MaxRows27+}28+29+// ColName converts a zero-based column index to letters: 0 -> A, 26 -> AA.30+func ColName(c int) string {31+ if c < 26 {32+ return string(rune('A' + c))33+ }34+ return string(rune('A'+c/26-1)) + string(rune('A'+c%26))35+}36+37+// ParseCol converts column letters (case-insensitive) to a zero-based index.38+func ParseCol(s string) (int, bool) {39+ if len(s) == 0 || len(s) > 2 {40+ return 0, false41+ }42+ c := 043+ for _, r := range strings.ToUpper(s) {44+ if r < 'A' || r > 'Z' {45+ return 0, false46+ }47+ c = c*26 + int(r-'A'+1)48+ }49+ c--50+ return c, c < MaxCols51+}52+53+// ParseAddr parses an A1-style reference. Absolute markers ($A$1) are54+// accepted and ignored.55+func ParseAddr(s string) (Addr, bool) {56+ s = strings.ReplaceAll(s, "$", "")57+ i := 058+ for i < len(s) && isLetter(s[i]) {59+ i++60+ }61+ col, ok := ParseCol(s[:i])62+ if !ok || i == len(s) {63+ return Addr{}, false64+ }65+ row, err := strconv.Atoi(s[i:])66+ if err != nil || s[i] == '+' || s[i] == '-' {67+ return Addr{}, false68+ }69+ a := Addr{Col: col, Row: row - 1}70+ return a, a.Valid()71+}72+73+// Rect is an inclusive rectangular range of cells.74+type Rect struct {75+ From, To Addr76+}77+78+// NewRect returns the normalized rectangle spanning a and b.79+func NewRect(a, b Addr) Rect {80+ return Rect{81+ From: Addr{Col: min(a.Col, b.Col), Row: min(a.Row, b.Row)},82+ To: Addr{Col: max(a.Col, b.Col), Row: max(a.Row, b.Row)},83+ }84+}85+86+// Contains reports whether a lies inside r.87+func (r Rect) Contains(a Addr) bool {88+ return a.Col >= r.From.Col && a.Col <= r.To.Col &&89+ a.Row >= r.From.Row && a.Row <= r.To.Row90+}91+92+// String returns the range in 1-2-3 notation, e.g. A1..B3.93+func (r Rect) String() string {94+ return r.From.String() + ".." + r.To.String()95+}96+97+// ParseRange parses "A1", "A1..B3" or "A1:B3".98+func ParseRange(s string) (Rect, bool) {99+ s = strings.TrimSpace(s)100+ sep := strings.Index(s, "..")101+ width := 2102+ if sep < 0 {103+ sep, width = strings.Index(s, ":"), 1104+ }105+ if sep < 0 {106+ a, ok := ParseAddr(s)107+ return Rect{From: a, To: a}, ok108+ }109+ a, ok1 := ParseAddr(s[:sep])110+ b, ok2 := ParseAddr(s[sep+width:])111+ return NewRect(a, b), ok1 && ok2112+}113+114+func isLetter(c byte) bool {115+ return (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z')116+}117+118+func isDigit(c byte) bool {119+ return c >= '0' && c <= '9'120+}
A internal/sheet/eval.go +301 -0
@@ -0,0 +1,301 @@
1+package sheet2+3+import (4+ "math"5+ "strconv"6+ "strings"7+)8+9+// Kind is the type of a computed cell value.10+type Kind int11+12+const (13+ Empty Kind = iota14+ Number15+ Label16+ Error17+)18+19+// Value is the computed contents of a cell.20+type Value struct {21+ Kind Kind22+ Num float6423+ Str string // label text or error name (ERR, NA)24+}25+26+var (27+ errValue = Value{Kind: Error, Str: "ERR"}28+ naValue = Value{Kind: Error, Str: "NA"}29+)30+31+func num(v float64) Value {32+ if math.IsNaN(v) || math.IsInf(v, 0) {33+ return errValue34+ }35+ return Value{Kind: Number, Num: v}36+}37+38+func boolNum(b bool) Value {39+ if b {40+ return num(1)41+ }42+ return num(0)43+}44+45+// asNum converts v for arithmetic. Like 1-2-3, labels and blanks count as 0.46+func asNum(v Value) float64 {47+ if v.Kind == Number {48+ return v.Num49+ }50+ return 051+}52+53+func asStr(v Value) string {54+ switch v.Kind {55+ case Label:56+ return v.Str57+ case Number:58+ return strconv.FormatFloat(v.Num, 'f', -1, 64)59+ }60+ return ""61+}62+63+// lookup resolves the current value of a referenced cell.64+type lookup func(Addr) Value65+66+func eval(n Node, get lookup) Value {67+ switch n := n.(type) {68+ case numLit:69+ return num(n.v)70+ case strLit:71+ return Value{Kind: Label, Str: n.v}72+ case refNode:73+ return get(n.a)74+ case rangeNode:75+ // A bare range outside a function is an error in 1-2-3.76+ return errValue77+ case unaryNode:78+ x := eval(n.x, get)79+ if x.Kind == Error {80+ return x81+ }82+ switch n.op {83+ case "-":84+ return num(-asNum(x))85+ case "#NOT#":86+ return boolNum(asNum(x) == 0)87+ }88+ // Unary + is identity, so string formulas like +B1&"x" work.89+ return x90+ case binaryNode:91+ return evalBinary(n, get)92+ case callNode:93+ return functions[n.name](n.args, get)94+ }95+ return errValue96+}97+98+func evalBinary(n binaryNode, get lookup) Value {99+ l, r := eval(n.l, get), eval(n.r, get)100+ if l.Kind == Error {101+ return l102+ }103+ if r.Kind == Error {104+ return r105+ }106+ a, b := asNum(l), asNum(r)107+ switch n.op {108+ case "+":109+ return num(a + b)110+ case "-":111+ return num(a - b)112+ case "*":113+ return num(a * b)114+ case "/":115+ if b == 0 {116+ return errValue117+ }118+ return num(a / b)119+ case "^":120+ return num(math.Pow(a, b))121+ case "&":122+ return Value{Kind: Label, Str: asStr(l) + asStr(r)}123+ case "#AND#":124+ return boolNum(a != 0 && b != 0)125+ case "#OR#":126+ return boolNum(a != 0 || b != 0)127+ }128+ cmp := compare(l, r)129+ switch n.op {130+ case "=":131+ return boolNum(cmp == 0)132+ case "<>":133+ return boolNum(cmp != 0)134+ case "<":135+ return boolNum(cmp < 0)136+ case ">":137+ return boolNum(cmp > 0)138+ case "<=":139+ return boolNum(cmp <= 0)140+ case ">=":141+ return boolNum(cmp >= 0)142+ }143+ return errValue144+}145+146+func compare(l, r Value) int {147+ if l.Kind == Label && r.Kind == Label {148+ return strings.Compare(strings.ToUpper(l.Str), strings.ToUpper(r.Str))149+ }150+ a, b := asNum(l), asNum(r)151+ switch {152+ case a < b:153+ return -1154+ case a > b:155+ return 1156+ }157+ return 0158+}159+160+type function func(args []Node, get lookup) Value161+162+var functions map[string]function163+164+func init() {165+ functions = map[string]function{166+ "SUM": aggregate(func(s stats) Value { return num(s.sum) }),167+ "COUNT": aggregate(func(s stats) Value { return num(float64(s.count)) }),168+ "AVG": aggregate(func(s stats) Value {169+ if s.count == 0 {170+ return errValue171+ }172+ return num(s.sum / float64(s.count))173+ }),174+ "MIN": aggregate(func(s stats) Value {175+ if s.nums == 0 {176+ return num(0)177+ }178+ return num(s.min)179+ }),180+ "MAX": aggregate(func(s stats) Value {181+ if s.nums == 0 {182+ return num(0)183+ }184+ return num(s.max)185+ }),186+ "ABS": math1(math.Abs),187+ "INT": math1(math.Trunc),188+ "SQRT": math1(math.Sqrt),189+ "ROUND": math2(func(x, places float64) float64 {190+ p := math.Pow(10, math.Trunc(places))191+ return math.Round(x*p) / p192+ }),193+ "MOD": math2(func(x, y float64) float64 {194+ if y == 0 {195+ return math.NaN()196+ }197+ return math.Mod(x, y)198+ }),199+ "PI": constant(num(math.Pi)),200+ "TRUE": constant(num(1)),201+ "FALSE": constant(num(0)),202+ "ERR": constant(errValue),203+ "NA": constant(naValue),204+ "IF": func(args []Node, get lookup) Value {205+ if len(args) != 3 {206+ return errValue207+ }208+ c := eval(args[0], get)209+ if c.Kind == Error {210+ return c211+ }212+ if asNum(c) != 0 {213+ return eval(args[1], get)214+ }215+ return eval(args[2], get)216+ },217+ }218+}219+220+type stats struct {221+ sum float64222+ count int // non-blank cells, as 1-2-3's @COUNT223+ nums int224+ min, max float64225+}226+227+// aggregate builds a list function such as @SUM. Ranges contribute every228+// non-blank cell; labels count toward @COUNT but add 0, as in 1-2-3.229+func aggregate(done func(stats) Value) function {230+ return func(args []Node, get lookup) Value {231+ s := stats{min: math.Inf(1), max: math.Inf(-1)}232+ add := func(v Value) *Value {233+ switch v.Kind {234+ case Error:235+ return &v236+ case Empty:237+ return nil238+ case Number:239+ s.nums++240+ s.min, s.max = math.Min(s.min, v.Num), math.Max(s.max, v.Num)241+ }242+ s.count++243+ s.sum += asNum(v)244+ return nil245+ }246+ for _, arg := range args {247+ if rn, ok := arg.(rangeNode); ok {248+ for r := rn.r.From.Row; r <= rn.r.To.Row; r++ {249+ for c := rn.r.From.Col; c <= rn.r.To.Col; c++ {250+ if e := add(get(Addr{Col: c, Row: r})); e != nil {251+ return *e252+ }253+ }254+ }255+ continue256+ }257+ if e := add(eval(arg, get)); e != nil {258+ return *e259+ }260+ }261+ return done(s)262+ }263+}264+265+func math1(f func(float64) float64) function {266+ return func(args []Node, get lookup) Value {267+ if len(args) != 1 {268+ return errValue269+ }270+ x := eval(args[0], get)271+ if x.Kind == Error {272+ return x273+ }274+ return num(f(asNum(x)))275+ }276+}277+278+func math2(f func(float64, float64) float64) function {279+ return func(args []Node, get lookup) Value {280+ if len(args) != 2 {281+ return errValue282+ }283+ x, y := eval(args[0], get), eval(args[1], get)284+ if x.Kind == Error {285+ return x286+ }287+ if y.Kind == Error {288+ return y289+ }290+ return num(f(asNum(x), asNum(y)))291+ }292+}293+294+func constant(v Value) function {295+ return func(args []Node, _ lookup) Value {296+ if len(args) != 0 {297+ return errValue298+ }299+ return v300+ }301+}
A internal/sheet/file.go +64 -0
@@ -0,0 +1,64 @@
1+package sheet2+3+import (4+ "encoding/json"5+ "fmt"6+ "io"7+)8+9+// FileExt is the extension of the native worksheet format.10+const FileExt = ".o23"11+12+type fileFormat struct {13+ Version int `json:"version"`14+ Widths map[string]int `json:"widths,omitempty"`15+ Cells map[string]string `json:"cells"`16+}17+18+// Write saves the worksheet as JSON, storing each cell's input as typed.19+func (s *Sheet) Write(w io.Writer) error {20+ f := fileFormat{21+ Version: 1,22+ Widths: make(map[string]int, len(s.widths)),23+ Cells: make(map[string]string, len(s.cells)),24+ }25+ for c, width := range s.widths {26+ f.Widths[ColName(c)] = width27+ }28+ for a, c := range s.cells {29+ f.Cells[a.String()] = c.Input30+ }31+ enc := json.NewEncoder(w)32+ enc.SetIndent("", " ")33+ return enc.Encode(f)34+}35+36+// Read loads a worksheet written by Write.37+func Read(r io.Reader) (*Sheet, error) {38+ var f fileFormat39+ if err := json.NewDecoder(r).Decode(&f); err != nil {40+ return nil, err41+ }42+ if f.Version != 1 {43+ return nil, fmt.Errorf("unsupported file version %d", f.Version)44+ }45+ s := New()46+ for name, width := range f.Widths {47+ c, ok := ParseCol(name)48+ if !ok {49+ return nil, fmt.Errorf("invalid column %q", name)50+ }51+ s.SetColWidth(c, width)52+ }53+ for name, input := range f.Cells {54+ a, ok := ParseAddr(name)55+ if !ok {56+ return nil, fmt.Errorf("invalid cell %q", name)57+ }58+ if err := s.put(a, input); err != nil {59+ return nil, fmt.Errorf("%s: %w", name, err)60+ }61+ }62+ s.RecalcAll()63+ return s, nil64+}
A internal/sheet/parse.go +320 -0
@@ -0,0 +1,320 @@
1+package sheet2+3+import (4+ "fmt"5+ "strconv"6+ "strings"7+)8+9+// Node is a parsed formula expression.10+type Node any11+12+type (13+ numLit struct{ v float64 }14+ strLit struct{ v string }15+ refNode struct{ a Addr }16+ rangeNode struct{ r Rect }17+ unaryNode struct {18+ op string19+ x Node20+ }21+ binaryNode struct {22+ op string23+ l, r Node24+ }25+ callNode struct {26+ name string27+ args []Node28+ }29+)30+31+type tokKind int32+33+const (34+ tokEOF tokKind = iota35+ tokNum36+ tokStr37+ tokRef38+ tokFunc39+ tokOp40+)41+42+type token struct {43+ kind tokKind44+ text string45+ pos int46+}47+48+// ParseError describes a formula that could not be parsed. Pos is the byte49+// offset where the problem was found, used to place the EDIT cursor.50+type ParseError struct {51+ Pos int52+ Msg string53+}54+55+func (e *ParseError) Error() string {56+ return fmt.Sprintf("%s at position %d", e.Msg, e.Pos+1)57+}58+59+func lex(src string) ([]token, error) {60+ var toks []token61+ i := 062+ for i < len(src) {63+ c := src[i]64+ start := i65+ switch {66+ case c == ' ':67+ i++68+ continue69+ case isDigit(c) || (c == '.' && i+1 < len(src) && isDigit(src[i+1])):70+ for i < len(src) && (isDigit(src[i]) || src[i] == '.') {71+ i++72+ }73+ if i < len(src) && (src[i] == 'e' || src[i] == 'E') {74+ j := i + 175+ if j < len(src) && (src[j] == '+' || src[j] == '-') {76+ j++77+ }78+ if j < len(src) && isDigit(src[j]) {79+ i = j80+ for i < len(src) && isDigit(src[i]) {81+ i++82+ }83+ }84+ }85+ toks = append(toks, token{tokNum, src[start:i], start})86+ case c == '"':87+ i++88+ for i < len(src) && src[i] != '"' {89+ i++90+ }91+ if i == len(src) {92+ return nil, &ParseError{start, "unterminated string"}93+ }94+ i++95+ toks = append(toks, token{tokStr, src[start+1 : i-1], start})96+ case c == '@':97+ i++98+ for i < len(src) && isLetter(src[i]) {99+ i++100+ }101+ if i == start+1 {102+ return nil, &ParseError{start, "missing function name"}103+ }104+ toks = append(toks, token{tokFunc, strings.ToUpper(src[start+1 : i]), start})105+ case c == '$' || isLetter(c):106+ for i < len(src) && (src[i] == '$' || isLetter(src[i]) || isDigit(src[i])) {107+ i++108+ }109+ if _, ok := ParseAddr(src[start:i]); !ok {110+ return nil, &ParseError{start, fmt.Sprintf("invalid cell reference %q", src[start:i])}111+ }112+ toks = append(toks, token{tokRef, strings.ToUpper(src[start:i]), start})113+ case c == '#':114+ end := strings.IndexByte(src[i+1:], '#')115+ if end < 0 {116+ return nil, &ParseError{start, "unterminated logical operator"}117+ }118+ op := strings.ToUpper(src[i : i+end+2])119+ if op != "#AND#" && op != "#OR#" && op != "#NOT#" {120+ return nil, &ParseError{start, "unknown operator " + op}121+ }122+ i += end + 2123+ toks = append(toks, token{tokOp, op, start})124+ case strings.HasPrefix(src[i:], ".."), strings.HasPrefix(src[i:], "<="),125+ strings.HasPrefix(src[i:], ">="), strings.HasPrefix(src[i:], "<>"):126+ i += 2127+ toks = append(toks, token{tokOp, src[start:i], start})128+ case strings.IndexByte("+-*/^=<>&(),;:", c) >= 0:129+ i++130+ toks = append(toks, token{tokOp, string(c), start})131+ default:132+ return nil, &ParseError{start, fmt.Sprintf("unexpected %q", c)}133+ }134+ }135+ return append(toks, token{tokEOF, "", len(src)}), nil136+}137+138+// Binding powers, lowest to highest, following 1-2-3's precedence table.139+var infixPower = map[string]int{140+ "#OR#": 10, "#AND#": 10,141+ "=": 30, "<>": 30, "<": 30, ">": 30, "<=": 30, ">=": 30,142+ "&": 35,143+ "+": 40, "-": 40,144+ "*": 50, "/": 50,145+ "^": 70,146+}147+148+const (149+ notPower = 20150+ unaryPower = 60151+)152+153+type parser struct {154+ toks []token155+ pos int156+}157+158+// Parse parses a value entry. A leading '=' is accepted for users coming159+// from modern spreadsheets; 1-2-3 itself starts formulas with '+'.160+func Parse(src string) (Node, error) {161+ body := strings.TrimPrefix(src, "=")162+ offset := len(src) - len(body)163+ toks, err := lex(body)164+ if err != nil {165+ return nil, shift(err, offset)166+ }167+ p := &parser{toks: toks}168+ n, err := p.expr(0)169+ if err == nil && p.peek().kind != tokEOF {170+ err = &ParseError{p.peek().pos, "unexpected " + p.peek().text}171+ }172+ if err != nil {173+ return nil, shift(err, offset)174+ }175+ return n, nil176+}177+178+func shift(err error, offset int) error {179+ if pe, ok := err.(*ParseError); ok {180+ pe.Pos += offset181+ }182+ return err183+}184+185+func (p *parser) peek() token { return p.toks[p.pos] }186+187+func (p *parser) next() token {188+ t := p.toks[p.pos]189+ if t.kind != tokEOF {190+ p.pos++191+ }192+ return t193+}194+195+func (p *parser) expect(op string) error {196+ if t := p.next(); t.kind != tokOp || t.text != op {197+ return &ParseError{t.pos, "expected " + op}198+ }199+ return nil200+}201+202+func (p *parser) expr(minPower int) (Node, error) {203+ left, err := p.prefix()204+ if err != nil {205+ return nil, err206+ }207+ for {208+ t := p.peek()209+ power, ok := infixPower[t.text]210+ if t.kind != tokOp || !ok || power <= minPower {211+ return left, nil212+ }213+ p.next()214+ right, err := p.expr(power)215+ if err != nil {216+ return nil, err217+ }218+ left = binaryNode{op: t.text, l: left, r: right}219+ }220+}221+222+func (p *parser) prefix() (Node, error) {223+ t := p.next()224+ switch t.kind {225+ case tokNum:226+ v, err := strconv.ParseFloat(t.text, 64)227+ if err != nil {228+ return nil, &ParseError{t.pos, "invalid number " + t.text}229+ }230+ return numLit{v}, nil231+ case tokStr:232+ return strLit{t.text}, nil233+ case tokRef:234+ a, _ := ParseAddr(t.text)235+ if n := p.peek(); n.kind == tokOp && (n.text == ".." || n.text == ":") {236+ p.next()237+ end := p.next()238+ if end.kind != tokRef {239+ return nil, &ParseError{end.pos, "expected cell reference after " + n.text}240+ }241+ b, _ := ParseAddr(end.text)242+ return rangeNode{NewRect(a, b)}, nil243+ }244+ return refNode{a}, nil245+ case tokFunc:246+ return p.call(t)247+ case tokOp:248+ switch t.text {249+ case "(":250+ n, err := p.expr(0)251+ if err != nil {252+ return nil, err253+ }254+ return n, p.expect(")")255+ case "+", "-":256+ x, err := p.expr(unaryPower)257+ if err != nil {258+ return nil, err259+ }260+ return unaryNode{op: t.text, x: x}, nil261+ case "#NOT#":262+ x, err := p.expr(notPower)263+ if err != nil {264+ return nil, err265+ }266+ return unaryNode{op: t.text, x: x}, nil267+ }268+ case tokEOF:269+ return nil, &ParseError{t.pos, "incomplete formula"}270+ }271+ return nil, &ParseError{t.pos, "unexpected " + t.text}272+}273+274+func (p *parser) call(fn token) (Node, error) {275+ if _, ok := functions[fn.text]; !ok {276+ return nil, &ParseError{fn.pos, "unknown function @" + fn.text}277+ }278+ n := callNode{name: fn.text}279+ if t := p.peek(); t.kind != tokOp || t.text != "(" {280+ return n, nil281+ }282+ p.next()283+ if t := p.peek(); t.kind == tokOp && t.text == ")" {284+ p.next()285+ return n, nil286+ }287+ for {288+ arg, err := p.expr(0)289+ if err != nil {290+ return nil, err291+ }292+ n.args = append(n.args, arg)293+ t := p.next()294+ if t.kind == tokOp && t.text == ")" {295+ return n, nil296+ }297+ if t.kind != tokOp || (t.text != "," && t.text != ";") {298+ return nil, &ParseError{t.pos, "expected , or )"}299+ }300+ }301+}302+303+// walkRefs calls fn for every single-cell reference and range in n.304+func walkRefs(n Node, ref func(Addr), rng func(Rect)) {305+ switch n := n.(type) {306+ case refNode:307+ ref(n.a)308+ case rangeNode:309+ rng(n.r)310+ case unaryNode:311+ walkRefs(n.x, ref, rng)312+ case binaryNode:313+ walkRefs(n.l, ref, rng)314+ walkRefs(n.r, ref, rng)315+ case callNode:316+ for _, a := range n.args {317+ walkRefs(a, ref, rng)318+ }319+ }320+}
A internal/sheet/sheet.go +317 -0
@@ -0,0 +1,317 @@
1+// Package sheet is the spreadsheet engine: cell storage, formula parsing and2+// dependency-ordered recalculation. It has no knowledge of the terminal UI.3+package sheet4+5+import (6+ "maps"7+ "sort"8+ "strconv"9+ "strings"10+ "unicode"11+)12+13+// DefaultWidth is the initial column width, as in 1-2-3.14+const DefaultWidth = 915+16+// Cell holds what the user typed and what it evaluates to.17+type Cell struct {18+ // Input is the entry as typed. Labels always carry an alignment19+ // prefix: ' (left), " (right) or ^ (center).20+ Input string21+ Value Value22+23+ expr Node // nil for labels24+ refs []Addr // single-cell references in expr25+ ranges []Rect // range references in expr26+}27+28+// IsLabel reports whether the cell holds a label rather than a value.29+func (c *Cell) IsLabel() bool { return c.expr == nil }30+31+// Align returns the label prefix character, or 0 for values.32+func (c *Cell) Align() byte {33+ if c.IsLabel() {34+ return c.Input[0]35+ }36+ return 037+}38+39+// Sheet is a sparse worksheet.40+type Sheet struct {41+ cells map[Addr]*Cell42+ widths map[int]int43+44+ // dependents maps a cell to the formula cells that reference it45+ // directly. Range references are kept on the formula cell itself and46+ // scanned separately, so a huge range doesn't create millions of edges.47+ dependents map[Addr]map[Addr]struct{}48+ rangeUsers map[Addr]struct{}49+50+ // Circular is set when the last recalculation found a cycle.51+ Circular bool52+}53+54+// New returns an empty worksheet.55+func New() *Sheet {56+ return &Sheet{57+ cells: make(map[Addr]*Cell),58+ widths: make(map[int]int),59+ dependents: make(map[Addr]map[Addr]struct{}),60+ rangeUsers: make(map[Addr]struct{}),61+ }62+}63+64+// Cell returns the cell at a, or nil if it is blank.65+func (s *Sheet) Cell(a Addr) *Cell { return s.cells[a] }66+67+// Value returns the computed value at a.68+func (s *Sheet) Value(a Addr) Value {69+ if c := s.cells[a]; c != nil {70+ return c.Value71+ }72+ return Value{}73+}74+75+// Len returns the number of non-blank cells.76+func (s *Sheet) Len() int { return len(s.cells) }77+78+// Addrs returns every non-blank cell in row-major order.79+func (s *Sheet) Addrs() []Addr {80+ out := make([]Addr, 0, len(s.cells))81+ for a := range s.cells {82+ out = append(out, a)83+ }84+ sort.Slice(out, func(i, j int) bool {85+ if out[i].Row != out[j].Row {86+ return out[i].Row < out[j].Row87+ }88+ return out[i].Col < out[j].Col89+ })90+ return out91+}92+93+// ColWidth returns the display width of column c.94+func (s *Sheet) ColWidth(c int) int {95+ if w, ok := s.widths[c]; ok {96+ return w97+ }98+ return DefaultWidth99+}100+101+// SetColWidth sets column c's width; w <= 0 resets it to the default.102+func (s *Sheet) SetColWidth(c, w int) {103+ if w <= 0 {104+ delete(s.widths, c)105+ return106+ }107+ s.widths[c] = min(w, 240)108+}109+110+// Widths returns the columns that have a non-default width.111+func (s *Sheet) Widths() map[int]int {112+ return maps.Clone(s.widths)113+}114+115+// IsValueEntry reports whether 1-2-3 would treat input as a value (number or116+// formula) rather than a label, based on its first character.117+func IsValueEntry(input string) bool {118+ if input == "" {119+ return false120+ }121+ return strings.IndexByte("0123456789+-.(@#$=", input[0]) >= 0122+}123+124+// Set stores an entry at a and recalculates affected cells. An empty input125+// erases the cell. Labels without a prefix get the default ' prefix. A126+// value entry that fails to parse is rejected with a *ParseError and the127+// sheet is left unchanged.128+func (s *Sheet) Set(a Addr, input string) error {129+ if err := s.put(a, input); err != nil {130+ return err131+ }132+ s.recalc([]Addr{a})133+ return nil134+}135+136+// put stores an entry without recalculating. Control characters are137+// dropped so a worksheet file can't smuggle escape sequences to the terminal.138+func (s *Sheet) put(a Addr, input string) error {139+ input = strings.Map(func(r rune) rune {140+ if unicode.IsControl(r) {141+ return -1142+ }143+ return r144+ }, input)145+ if input == "" {146+ s.erase(a)147+ return nil148+ }149+ c := &Cell{Input: input}150+ if IsValueEntry(input) {151+ n, err := Parse(input)152+ if err != nil {153+ return err154+ }155+ c.expr = n156+ walkRefs(n,157+ func(r Addr) { c.refs = append(c.refs, r) },158+ func(r Rect) { c.ranges = append(c.ranges, r) })159+ } else if strings.IndexByte(`'"^`, input[0]) < 0 {160+ c.Input = "'" + input161+ }162+ s.erase(a)163+ s.cells[a] = c164+ for _, r := range c.refs {165+ if s.dependents[r] == nil {166+ s.dependents[r] = make(map[Addr]struct{})167+ }168+ s.dependents[r][a] = struct{}{}169+ }170+ if len(c.ranges) > 0 {171+ s.rangeUsers[a] = struct{}{}172+ }173+ return nil174+}175+176+// EraseRange blanks every cell in r.177+func (s *Sheet) EraseRange(r Rect) {178+ var changed []Addr179+ for a := range s.cells {180+ if r.Contains(a) {181+ changed = append(changed, a)182+ }183+ }184+ for _, a := range changed {185+ s.erase(a)186+ }187+ s.recalc(changed)188+}189+190+func (s *Sheet) erase(a Addr) {191+ old := s.cells[a]192+ if old == nil {193+ return194+ }195+ for _, r := range old.refs {196+ delete(s.dependents[r], a)197+ if len(s.dependents[r]) == 0 {198+ delete(s.dependents, r)199+ }200+ }201+ delete(s.rangeUsers, a)202+ delete(s.cells, a)203+}204+205+// RecalcAll recomputes every formula, e.g. after loading a file.206+func (s *Sheet) RecalcAll() {207+ s.recalc(s.Addrs())208+}209+210+// recalc recomputes the changed cells and everything that transitively211+// depends on them. Cells are evaluated lazily in dependency order: reading a212+// dirty cell evaluates it first. A cell that is reached again while it is213+// still being evaluated is part of a cycle and becomes ERR.214+func (s *Sheet) recalc(changed []Addr) {215+ const (216+ dirty = iota + 1217+ visiting218+ done219+ )220+ state := make(map[Addr]int)221+ queue := append([]Addr(nil), changed...)222+ for len(queue) > 0 {223+ a := queue[0]224+ queue = queue[1:]225+ if state[a] == dirty {226+ continue227+ }228+ state[a] = dirty229+ for d := range s.dependents[a] {230+ queue = append(queue, d)231+ }232+ for u := range s.rangeUsers {233+ for _, r := range s.cells[u].ranges {234+ if r.Contains(a) {235+ queue = append(queue, u)236+ break237+ }238+ }239+ }240+ }241+242+ s.Circular = false243+ var compute func(Addr) Value244+ compute = func(a Addr) Value {245+ c := s.cells[a]246+ switch {247+ case c == nil:248+ return Value{}249+ case state[a] == visiting:250+ s.Circular = true251+ return errValue252+ case state[a] != dirty:253+ return c.Value254+ }255+ state[a] = visiting256+ if c.IsLabel() {257+ c.Value = Value{Kind: Label, Str: c.Input[1:]}258+ } else {259+ c.Value = eval(c.expr, compute)260+ }261+ state[a] = done262+ return c.Value263+ }264+ for a := range state {265+ compute(a)266+ }267+}268+269+// FormatValue renders v to fit in width columns using 1-2-3's General270+// format: numbers are right-aligned with a trailing space, and a number271+// that cannot fit is shown as asterisks.272+func FormatValue(v Value, width int) string {273+ switch v.Kind {274+ case Number:275+ return padLeft(formatGeneral(v.Num, width-1), width-1) + " "276+ case Error:277+ return padLeft(v.Str, width-1) + " "278+ }279+ return ""280+}281+282+func formatGeneral(v float64, width int) string {283+ if width <= 0 {284+ return ""285+ }286+ s := strconv.FormatFloat(v, 'f', -1, 64)287+ if len(s) <= width {288+ return s289+ }290+ // Try fewer decimals before falling back to scientific notation, but291+ // never round a non-zero number down to zero.292+ if dot := strings.IndexByte(s, '.'); dot >= 0 && dot <= width {293+ for prec := max(width-dot-1, 0); prec >= 0; prec-- {294+ f := strconv.FormatFloat(v, 'f', prec, 64)295+ if len(f) > width {296+ continue297+ }298+ if r, _ := strconv.ParseFloat(f, 64); r != 0 || v == 0 {299+ return f300+ }301+ break302+ }303+ }304+ for prec := width; prec >= 0; prec-- {305+ if e := strconv.FormatFloat(v, 'E', prec, 64); len(e) <= width {306+ return e307+ }308+ }309+ return strings.Repeat("*", width)310+}311+312+func padLeft(s string, width int) string {313+ if len(s) >= width {314+ return s315+ }316+ return strings.Repeat(" ", width-len(s)) + s317+}
A internal/sheet/sheet_test.go +239 -0
@@ -0,0 +1,239 @@
1+package sheet2+3+import (4+ "bytes"5+ "errors"6+ "testing"7+)8+9+func at(s string) Addr {10+ a, ok := ParseAddr(s)11+ if !ok {12+ panic("bad address " + s)13+ }14+ return a15+}16+17+func TestAddr(t *testing.T) {18+ tests := []struct {19+ in string20+ want Addr21+ ok bool22+ }{23+ {"A1", Addr{0, 0}, true},24+ {"z10", Addr{25, 9}, true},25+ {"AA1", Addr{26, 0}, true},26+ {"IV8192", Addr{255, 8191}, true},27+ {"$B$2", Addr{1, 1}, true},28+ {"IW1", Addr{}, false},29+ {"A0", Addr{}, false},30+ {"A8193", Addr{}, false},31+ {"ABC1", Addr{}, false},32+ {"A+1", Addr{}, false},33+ {"1A", Addr{}, false},34+ }35+ for _, tt := range tests {36+ got, ok := ParseAddr(tt.in)37+ if ok != tt.ok || (ok && got != tt.want) {38+ t.Errorf("ParseAddr(%q) = %v, %v; want %v, %v", tt.in, got, ok, tt.want, tt.ok)39+ }40+ }41+ for c := range MaxCols {42+ if got, ok := ParseCol(ColName(c)); !ok || got != c {43+ t.Fatalf("ParseCol(ColName(%d)) = %d, %v", c, got, ok)44+ }45+ }46+}47+48+func TestParseRange(t *testing.T) {49+ for _, in := range []string{"B3..A1", "a1:b3", "$A$1..$B$3"} {50+ r, ok := ParseRange(in)51+ if !ok || r.String() != "A1..B3" {52+ t.Errorf("ParseRange(%q) = %v, %v", in, r, ok)53+ }54+ }55+}56+57+func TestFormulas(t *testing.T) {58+ s := New()59+ for addr, in := range map[string]string{60+ "A1": "10", "A2": "20", "A3": "30", "B1": "Name", "B2": `"right`,61+ } {62+ if err := s.Set(at(addr), in); err != nil {63+ t.Fatal(err)64+ }65+ }66+ tests := []struct {67+ in string68+ want Value69+ }{70+ {"+A1+A2*2", num(50)},71+ {"(A1+A2)*2", num(60)},72+ {"-2^2", num(-4)},73+ {"=A1/4", num(2.5)},74+ {"@SUM(A1..A3)", num(60)},75+ {"@sum(A1:A3,5)", num(65)},76+ {"@AVG(A1..A3)", num(20)},77+ {"@COUNT(A1..B3)", num(5)},78+ {"@MIN(A1..A3)", num(10)},79+ {"@MAX(A1..A3)", num(30)},80+ {"@ROUND(@PI,2)", num(3.14)},81+ {"@MOD(7;3)", num(1)},82+ {"@IF(A1>5,1,2)", num(1)},83+ {"+A1>5#AND#A2<10", num(0)},84+ {"#NOT#A1=10", num(0)},85+ {"+B1+1", num(1)}, // labels count as zero86+ {`+B1&" X"`, Value{Kind: Label, Str: "Name X"}},87+ {"+A1/0", errValue},88+ {"@SQRT(-1)", errValue},89+ {"@NA", naValue},90+ {"+@NA+1", naValue},91+ {"1.5E3", num(1500)},92+ }93+ for _, tt := range tests {94+ if err := s.Set(at("C1"), tt.in); err != nil {95+ t.Errorf("Set(%q): %v", tt.in, err)96+ continue97+ }98+ if got := s.Value(at("C1")); got != tt.want {99+ t.Errorf("%q = %+v, want %+v", tt.in, got, tt.want)100+ }101+ }102+}103+104+func TestParseErrors(t *testing.T) {105+ for _, in := range []string{"+A1+", "@SUM(A1", "@NOPE(1)", "+ZZ1", "(1", "+A1..", "1 2", `+"abc`, "#AND#"} {106+ var pe *ParseError107+ if err := New().Set(at("A1"), in); !errors.As(err, &pe) {108+ t.Errorf("Set(%q) error = %v, want ParseError", in, err)109+ }110+ }111+}112+113+func TestLabels(t *testing.T) {114+ s := New()115+ s.Set(at("A1"), "Sales")116+ s.Set(at("A2"), "^Mid")117+ if c := s.Cell(at("A1")); c.Input != "'Sales" || c.Align() != '\'' || c.Value.Str != "Sales" {118+ t.Errorf("A1 = %+v", c)119+ }120+ if c := s.Cell(at("A2")); c.Align() != '^' || c.Value.Str != "Mid" {121+ t.Errorf("A2 = %+v", c)122+ }123+}124+125+func TestRecalcPropagates(t *testing.T) {126+ s := New()127+ s.Set(at("A1"), "1")128+ s.Set(at("A2"), "+A1*2")129+ s.Set(at("A3"), "+A2+A1")130+ s.Set(at("B1"), "@SUM(A1..A10)")131+132+ s.Set(at("A1"), "5")133+ if got := s.Value(at("A3")).Num; got != 15 {134+ t.Errorf("A3 = %v, want 15", got)135+ }136+ if got := s.Value(at("B1")).Num; got != 30 {137+ t.Errorf("B1 = %v, want 30", got)138+ }139+140+ // Filling a blank cell inside a range updates the range user.141+ s.Set(at("A9"), "100")142+ if got := s.Value(at("B1")).Num; got != 130 {143+ t.Errorf("B1 = %v, want 130", got)144+ }145+146+ s.EraseRange(NewRect(at("A1"), at("A1")))147+ if got := s.Value(at("A3")).Num; got != 0 {148+ t.Errorf("A3 after erase = %v, want 0", got)149+ }150+151+ // Replacing a formula drops its old dependency edges.152+ s.Set(at("A2"), "7")153+ if len(s.dependents[at("A1")]) != 1 { // only A3 remains154+ t.Errorf("A1 dependents = %v", s.dependents[at("A1")])155+ }156+}157+158+func TestCircular(t *testing.T) {159+ s := New()160+ s.Set(at("A1"), "+B1+1")161+ s.Set(at("B1"), "+A1+1")162+ if !s.Circular || s.Value(at("A1")) != errValue || s.Value(at("B1")) != errValue {163+ t.Errorf("circular: %v %+v %+v", s.Circular, s.Value(at("A1")), s.Value(at("B1")))164+ }165+ s.Set(at("B1"), "3")166+ if s.Circular || s.Value(at("A1")).Num != 4 {167+ t.Errorf("after break: %v %+v", s.Circular, s.Value(at("A1")))168+ }169+ s.Set(at("C1"), "@SUM(C1..C2)")170+ if !s.Circular {171+ t.Error("self-referencing range not flagged")172+ }173+}174+175+func TestFormatValue(t *testing.T) {176+ tests := []struct {177+ v float64178+ width int179+ want string180+ }{181+ {42, 9, " 42 "},182+ {-3.5, 9, " -3.5 "},183+ {1.0 / 3, 9, "0.333333 "},184+ {9.9999, 4, " 10 "},185+ {123456789012, 9, "1.23E+11 "},186+ {0.000000001, 9, "1.00E-09 "},187+ {0, 3, " 0 "},188+ {123456, 3, "** "},189+ }190+ for _, tt := range tests {191+ if got := FormatValue(num(tt.v), tt.width); got != tt.want {192+ t.Errorf("FormatValue(%v, %d) = %q, want %q", tt.v, tt.width, got, tt.want)193+ }194+ }195+}196+197+func FuzzParse(f *testing.F) {198+ for _, s := range []string{"+A1*2", "@SUM(A1..B3)", "@IF(A1>2#AND#B1,1,@NA)", `"a"&"b"`, "=1.5E-3^2"} {199+ f.Add(s)200+ }201+ f.Fuzz(func(t *testing.T, in string) {202+ s := New()203+ if err := s.Set(Addr{}, in); err == nil && in != "" && s.Cell(Addr{}) == nil {204+ t.Fatalf("Set(%q) succeeded but cell is blank", in)205+ }206+ })207+}208+209+func TestFileRoundTrip(t *testing.T) {210+ s := New()211+ s.Set(at("A1"), "Total")212+ s.Set(at("B1"), "@SUM(B2..B3)")213+ s.Set(at("B2"), "2")214+ s.Set(at("B3"), "+B2*10")215+ s.SetColWidth(0, 14)216+217+ var buf bytes.Buffer218+ if err := s.Write(&buf); err != nil {219+ t.Fatal(err)220+ }221+ got, err := Read(&buf)222+ if err != nil {223+ t.Fatal(err)224+ }225+ if v := got.Value(at("B1")).Num; v != 22 {226+ t.Errorf("B1 = %v, want 22", v)227+ }228+ if got.Cell(at("A1")).Input != "'Total" || got.ColWidth(0) != 14 {229+ t.Errorf("A1 = %q, width %d", got.Cell(at("A1")).Input, got.ColWidth(0))230+ }231+}232+233+func TestStripsControlChars(t *testing.T) {234+ s := New()235+ s.Set(at("A1"), "evil\x1b]52;c;aGk=\x07label")236+ if got := s.Cell(at("A1")).Input; got != "'evil]52;c;aGk=label" {237+ t.Errorf("Input = %q", got)238+ }239+}
A internal/sheet/testdata/fuzz/FuzzParse/5838cdfae7b16cde +2 -0