cat / 012
clone

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

Switch the engine to Google Sheets semantics

Dami committed 831c0ee
Entries follow Sheets: = starts a formula (a leading + or - that parses
as one also counts), numbers like $1,200 and 12% are detected, ' forces
text, and TRUE/FALSE are booleans. Formulas use plain function names and
A1:B3 ranges; 1-2-3's @SUM(A1..B3) still parses. Errors use Sheets codes
(#DIV/0!, #VALUE!, #NAME?, #N/A, #NUM!, #REF! for cycles), text in
arithmetic is #VALUE!, and COUNT/COUNTA/AVERAGE/INT/MOD match Sheets.

A function table carries each function's signature, description and
arity, so wrong argument counts are reported at entry with the expected
signature, and autocomplete and help can use the same data.
commit
831c0ee3c9fc05990afb631490c177fbe9f41383
parent
5acda22f33b03e0b53bd2f01173ddc7380c2b4c6
author
Dami <dami.etoile@gmail.com>
9 files changed, +839 -426
M.gitignore1+
Minternal/sheet/addr.go9+-
Minternal/sheet/eval.go327++++++++++++------------------
Ainternal/sheet/functions.go263++++++++++++++++++++++++
Ainternal/sheet/number.go128++++++++++++
Minternal/sheet/parse.go202+++++++++++-------
Minternal/sheet/sheet.go138+++++--------
Minternal/sheet/sheet_test.go195++++++++++++------
Ainternal/sheet/testdata/fuzz/FuzzParse/808279fbab08fe782+
M .gitignore +1 -0
M internal/sheet/addr.go +6 -3
M internal/sheet/eval.go +127 -200
@@ -12,52 +12,71 @@ type Kind int
1212 const (1313 Empty Kind = iota1414 Number15- Label15+ Text16+ Bool1617 Error1718 )1819 1920 // Value is the computed contents of a cell.2021 type Value struct {2122 Kind Kind22- Num float6423- Str string // label text or error name (ERR, NA)23+ Num float64 // numbers, and booleans as 1 or 024+ Str string // text, or the error code such as #DIV/0!2425 }2526 27+// String renders a value the way a cell shows it in General format.28+func (v Value) String() string {29+ switch v.Kind {30+ case Number:31+ return strconv.FormatFloat(v.Num, 'f', -1, 64)32+ case Bool:33+ if v.Num != 0 {34+ return "TRUE"35+ }36+ return "FALSE"37+ }38+ return v.Str39+}40+41+// Error values, using Google Sheets codes.2642 var (27- errValue = Value{Kind: Error, Str: "ERR"}28- naValue = Value{Kind: Error, Str: "NA"}43+ ErrDiv0 = Value{Kind: Error, Str: "#DIV/0!"}44+ ErrValue = Value{Kind: Error, Str: "#VALUE!"}45+ ErrName = Value{Kind: Error, Str: "#NAME?"}46+ ErrNA = Value{Kind: Error, Str: "#N/A"}47+ ErrNum = Value{Kind: Error, Str: "#NUM!"}48+ ErrRef = Value{Kind: Error, Str: "#REF!"} // also circular references2949 )3050 3151 func num(v float64) Value {3252 if math.IsNaN(v) || math.IsInf(v, 0) {33- return errValue53+ return ErrNum3454 }3555 return Value{Kind: Number, Num: v}3656 }3757 38-func boolNum(b bool) Value {58+func boolean(b bool) Value {3959 if b {40- return num(1)60+ return Value{Kind: Bool, Num: 1}4161 }42- return num(0)62+ return Value{Kind: Bool}4363 }4464 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 {65+// toNum coerces v for arithmetic as Sheets does: blanks are 0, booleans66+// 1 or 0, numeric text is its number, other text is #VALUE!.67+func toNum(v Value) (float64, *Value) {5468 switch v.Kind {55- case Label:56- return v.Str57- case Number:58- return strconv.FormatFloat(v.Num, 'f', -1, 64)69+ case Empty:70+ return 0, nil71+ case Number, Bool:72+ return v.Num, nil73+ case Text:74+ if n, ok := ParseNumber(v.Str); ok {75+ return n, nil76+ }77+ return 0, &ErrValue5978 }60- return ""79+ return 0, &v6180 }6281 6382 // lookup resolves the current value of a referenced cell.
@@ -68,12 +87,20 @@ func eval(n Node, get lookup) Value {
6887 case numLit:6988 return num(n.v)7089 case strLit:71- return Value{Kind: Label, Str: n.v}90+ return Value{Kind: Text, Str: n.v}91+ case boolLit:92+ return boolean(n.v)7293 case refNode:7394 return get(n.a)95+ case nameNode:96+ return ErrName7497 case rangeNode:75- // A bare range outside a function is an error in 1-2-3.76- return errValue98+ // A range outside a function: Sheets uses the top-left cell here99+ // for single-cell ranges and #VALUE! otherwise.100+ if n.r.From == n.r.To {101+ return get(n.r.From)102+ }103+ return ErrValue77104 case unaryNode:78105 x := eval(n.x, get)79106 if x.Kind == Error {
@@ -81,18 +108,25 @@ func eval(n Node, get lookup) Value {
81108 }82109 switch n.op {83110 case "-":84- return num(-asNum(x))111+ f, err := toNum(x)112+ if err != nil {113+ return *err114+ }115+ return num(-f)85116 case "#NOT#":86- return boolNum(asNum(x) == 0)117+ f, err := toNum(x)118+ if err != nil {119+ return *err120+ }121+ return boolean(f == 0)87122 }88- // Unary + is identity, so string formulas like +B1&"x" work.89- return x123+ return x // unary + is identity90124 case binaryNode:91125 return evalBinary(n, get)92126 case callNode:93- return functions[n.name](n.args, get)127+ return n.fn.call(n.args, get)94128 }95- return errValue129+ return ErrValue96130 }97131 98132 func evalBinary(n binaryNode, get lookup) Value {
@@ -103,7 +137,33 @@ func evalBinary(n binaryNode, get lookup) Value {
103137 if r.Kind == Error {104138 return r105139 }106- a, b := asNum(l), asNum(r)140+ switch n.op {141+ case "&":142+ return Value{Kind: Text, Str: text(l) + text(r)}143+ case "=", "<>", "<", ">", "<=", ">=":144+ c := compare(l, r)145+ switch n.op {146+ case "=":147+ return boolean(c == 0)148+ case "<>":149+ return boolean(c != 0)150+ case "<":151+ return boolean(c < 0)152+ case ">":153+ return boolean(c > 0)154+ case "<=":155+ return boolean(c <= 0)156+ }157+ return boolean(c >= 0)158+ }159+ a, err := toNum(l)160+ if err != nil {161+ return *err162+ }163+ b, err := toNum(r)164+ if err != nil {165+ return *err166+ }107167 switch n.op {108168 case "+":109169 return num(a + b)
@@ -113,189 +173,56 @@ func evalBinary(n binaryNode, get lookup) Value {
113173 return num(a * b)114174 case "/":115175 if b == 0 {116- return errValue176+ return ErrDiv0117177 }118178 return num(a / b)119179 case "^":120180 return num(math.Pow(a, b))121- case "&":122- return Value{Kind: Label, Str: asStr(l) + asStr(r)}123181 case "#AND#":124- return boolNum(a != 0 && b != 0)182+ return boolean(a != 0 && b != 0)125183 case "#OR#":126- return boolNum(a != 0 || b != 0)184+ return boolean(a != 0 || b != 0)127185 }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)186+ return ErrValue187+}188+189+// text converts a value for string concatenation.190+func text(v Value) string {191+ if v.Kind == Empty {192+ return ""142193 }143- return errValue194+ return v.String()144195 }145196 197+// compare orders values like Sheets: numbers < text < booleans, text is198+// case-insensitive, and a blank equals 0 or "".146199 func compare(l, r Value) int {147- if l.Kind == Label && r.Kind == Label {200+ rank := func(v Value) int {201+ switch v.Kind {202+ case Text:203+ return 1204+ case Bool:205+ return 2206+ }207+ return 0208+ }209+ if l.Kind == Empty && r.Kind == Text {210+ l = Value{Kind: Text}211+ }212+ if r.Kind == Empty && l.Kind == Text {213+ r = Value{Kind: Text}214+ }215+ if d := rank(l) - rank(r); d != 0 {216+ return d217+ }218+ if l.Kind == Text {148219 return strings.Compare(strings.ToUpper(l.Str), strings.ToUpper(r.Str))149220 }150- a, b := asNum(l), asNum(r)151221 switch {152- case a < b:222+ case l.Num < r.Num:153223 return -1154- case a > b:224+ case l.Num > r.Num:155225 return 1156226 }157227 return 0158228 }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/functions.go +263 -0
@@ -0,0 +1,263 @@
1+package sheet2+3+import (4+ "math"5+ "slices"6+)7+8+// FuncDef describes a spreadsheet function. The table drives parsing9+// (arity checks), evaluation, autocomplete and help.10+type FuncDef struct {11+ Name string12+ Args string // signature shown to users, e.g. "value1, [value2, ...]"13+ Desc string14+ Min int15+ Max int // -1 for variadic16+ eval func(args []Node, get lookup) Value17+}18+19+func (f *FuncDef) call(args []Node, get lookup) Value { return f.eval(args, get) }20+21+var funcs = map[string]*FuncDef{}22+23+// aliases maps alternative names (including 1-2-3's) to canonical ones.24+var aliases = map[string]string{"AVG": "AVERAGE"}25+26+func define(defs ...*FuncDef) {27+ for _, d := range defs {28+ funcs[d.Name] = d29+ }30+}31+32+// LookupFunc finds a function by name or alias, case-insensitively33+// (callers pass upper case).34+func LookupFunc(name string) (*FuncDef, bool) {35+ if canon, ok := aliases[name]; ok {36+ name = canon37+ }38+ f, ok := funcs[name]39+ return f, ok40+}41+42+// Funcs returns every function, sorted by name.43+func Funcs() []*FuncDef {44+ out := make([]*FuncDef, 0, len(funcs))45+ for _, f := range funcs {46+ out = append(out, f)47+ }48+ slices.SortFunc(out, func(a, b *FuncDef) int {49+ if a.Name < b.Name {50+ return -151+ }52+ return 153+ })54+ return out55+}56+57+func init() {58+ define(59+ &FuncDef{Name: "SUM", Args: "value1, [value2, ...]", Desc: "Sum of numbers", Min: 1, Max: -1,60+ eval: aggregate(func(s agg) Value { return num(s.sum) })},61+ &FuncDef{Name: "AVERAGE", Args: "value1, [value2, ...]", Desc: "Average of numbers, ignoring text", Min: 1, Max: -1,62+ eval: aggregate(func(s agg) Value {63+ if s.nums == 0 {64+ return ErrDiv065+ }66+ return num(s.sum / float64(s.nums))67+ })},68+ &FuncDef{Name: "COUNT", Args: "value1, [value2, ...]", Desc: "Count of numeric values", Min: 1, Max: -1,69+ eval: aggregate(func(s agg) Value { return num(float64(s.nums)) })},70+ &FuncDef{Name: "COUNTA", Args: "value1, [value2, ...]", Desc: "Count of non-empty values", Min: 1, Max: -1,71+ eval: aggregate(func(s agg) Value { return num(float64(s.count)) })},72+ &FuncDef{Name: "MIN", Args: "value1, [value2, ...]", Desc: "Smallest number", Min: 1, Max: -1,73+ eval: aggregate(func(s agg) Value {74+ if s.nums == 0 {75+ return num(0)76+ }77+ return num(s.min)78+ })},79+ &FuncDef{Name: "MAX", Args: "value1, [value2, ...]", Desc: "Largest number", Min: 1, Max: -1,80+ eval: aggregate(func(s agg) Value {81+ if s.nums == 0 {82+ return num(0)83+ }84+ return num(s.max)85+ })},86+ &FuncDef{Name: "ABS", Args: "value", Desc: "Absolute value", Min: 1, Max: 1, eval: math1(math.Abs)},87+ &FuncDef{Name: "INT", Args: "value", Desc: "Round down to the nearest integer", Min: 1, Max: 1, eval: math1(math.Floor)},88+ &FuncDef{Name: "SQRT", Args: "value", Desc: "Square root", Min: 1, Max: 1, eval: math1(math.Sqrt)},89+ &FuncDef{Name: "ROUND", Args: "value, [places]", Desc: "Round to a number of decimal places", Min: 1, Max: 2,90+ eval: numeric(func(x []float64) Value {91+ places := 0.092+ if len(x) > 1 {93+ places = math.Trunc(x[1])94+ }95+ p := math.Pow(10, places)96+ return num(math.Round(x[0]*p) / p)97+ })},98+ &FuncDef{Name: "MOD", Args: "dividend, divisor", Desc: "Remainder, with the sign of the divisor", Min: 2, Max: 2,99+ eval: numeric(func(x []float64) Value {100+ if x[1] == 0 {101+ return ErrDiv0102+ }103+ return num(x[0] - x[1]*math.Floor(x[0]/x[1]))104+ })},105+ &FuncDef{Name: "PI", Desc: "The number pi", Max: 0, eval: constant(num(math.Pi))},106+ &FuncDef{Name: "TRUE", Desc: "The logical value TRUE", Max: 0, eval: constant(boolean(true))},107+ &FuncDef{Name: "FALSE", Desc: "The logical value FALSE", Max: 0, eval: constant(boolean(false))},108+ &FuncDef{Name: "NA", Desc: "The #N/A error", Max: 0, eval: constant(ErrNA)},109+ &FuncDef{Name: "IF", Args: "condition, value_if_true, [value_if_false]", Desc: "Choose a value by a condition", Min: 2, Max: 3,110+ eval: func(args []Node, get lookup) Value {111+ c := eval(args[0], get)112+ if c.Kind == Error {113+ return c114+ }115+ f, err := toNum(c)116+ if err != nil {117+ return *err118+ }119+ if f != 0 {120+ return eval(args[1], get)121+ }122+ if len(args) < 3 {123+ return boolean(false)124+ }125+ return eval(args[2], get)126+ }},127+ &FuncDef{Name: "IFERROR", Args: "value, [value_if_error]", Desc: "A fallback when a value is an error", Min: 1, Max: 2,128+ eval: func(args []Node, get lookup) Value {129+ v := eval(args[0], get)130+ if v.Kind != Error {131+ return v132+ }133+ if len(args) < 2 {134+ return Value{Kind: Text}135+ }136+ return eval(args[1], get)137+ }},138+ &FuncDef{Name: "AND", Args: "logical1, [logical2, ...]", Desc: "TRUE if all arguments are true", Min: 1, Max: -1,139+ eval: logical(func(t, n int) bool { return t == n })},140+ &FuncDef{Name: "OR", Args: "logical1, [logical2, ...]", Desc: "TRUE if any argument is true", Min: 1, Max: -1,141+ eval: logical(func(t, _ int) bool { return t > 0 })},142+ &FuncDef{Name: "NOT", Args: "logical", Desc: "The opposite of a logical value", Min: 1, Max: 1,143+ eval: numeric(func(x []float64) Value { return boolean(x[0] == 0) })},144+ )145+}146+147+type agg struct {148+ sum float64149+ count int // non-empty values150+ nums int151+ min, max float64152+}153+154+// each calls fn for every value in args: every cell of a range, or the155+// value of any other argument. direct is false for range cells.156+func each(args []Node, get lookup, fn func(v Value, direct bool) *Value) *Value {157+ for _, arg := range args {158+ if rn, ok := arg.(rangeNode); ok {159+ for r := rn.r.From.Row; r <= rn.r.To.Row; r++ {160+ for c := rn.r.From.Col; c <= rn.r.To.Col; c++ {161+ if e := fn(get(Addr{Col: c, Row: r}), false); e != nil {162+ return e163+ }164+ }165+ }166+ continue167+ }168+ if e := fn(eval(arg, get), true); e != nil {169+ return e170+ }171+ }172+ return nil173+}174+175+// aggregate builds SUM-like functions with Sheets semantics: in ranges only176+// numbers count and text is ignored; direct arguments are coerced, so177+// SUM("a") is #VALUE!.178+func aggregate(done func(agg) Value) func([]Node, lookup) Value {179+ return func(args []Node, get lookup) Value {180+ s := agg{min: math.Inf(1), max: math.Inf(-1)}181+ e := each(args, get, func(v Value, direct bool) *Value {182+ switch v.Kind {183+ case Error:184+ return &v185+ case Empty:186+ return nil187+ }188+ s.count++189+ if v.Kind != Number && !direct {190+ return nil191+ }192+ f, err := toNum(v)193+ if err != nil {194+ return err195+ }196+ s.nums++197+ s.sum += f198+ s.min, s.max = math.Min(s.min, f), math.Max(s.max, f)199+ return nil200+ })201+ if e != nil {202+ return *e203+ }204+ return done(s)205+ }206+}207+208+func logical(test func(trues, n int) bool) func([]Node, lookup) Value {209+ return func(args []Node, get lookup) Value {210+ trues, n := 0, 0211+ e := each(args, get, func(v Value, direct bool) *Value {212+ switch {213+ case v.Kind == Error:214+ return &v215+ case v.Kind == Empty, v.Kind == Text && !direct:216+ return nil217+ }218+ f, err := toNum(v)219+ if err != nil {220+ return err221+ }222+ n++223+ if f != 0 {224+ trues++225+ }226+ return nil227+ })228+ switch {229+ case e != nil:230+ return *e231+ case n == 0:232+ return ErrValue233+ }234+ return boolean(test(trues, n))235+ }236+}237+238+// numeric evaluates every argument as a number, propagating errors.239+func numeric(f func([]float64) Value) func([]Node, lookup) Value {240+ return func(args []Node, get lookup) Value {241+ xs := make([]float64, len(args))242+ for i, a := range args {243+ v := eval(a, get)244+ if v.Kind == Error {245+ return v246+ }247+ x, err := toNum(v)248+ if err != nil {249+ return *err250+ }251+ xs[i] = x252+ }253+ return f(xs)254+ }255+}256+257+func math1(f func(float64) float64) func([]Node, lookup) Value {258+ return numeric(func(x []float64) Value { return num(f(x[0])) })259+}260+261+func constant(v Value) func([]Node, lookup) Value {262+ return func([]Node, lookup) Value { return v }263+}
A internal/sheet/number.go +128 -0
@@ -0,0 +1,128 @@
1+package sheet2+3+import (4+ "strconv"5+ "strings"6+)7+8+// ParseNumber recognizes numbers the way Google Sheets does on entry:9+// optional sign, optional leading currency symbol, thousands separators,10+// decimals, exponent, and a trailing percent sign ("12%" is 0.12).11+func ParseNumber(s string) (float64, bool) {12+ s = strings.TrimSpace(s)13+ if s == "" {14+ return 0, false15+ }16+ neg := false17+ if s[0] == '-' || s[0] == '+' {18+ neg = s[0] == '-'19+ s = s[1:]20+ }21+ s = strings.TrimPrefix(s, "$")22+ pct := strings.HasSuffix(s, "%")23+ s = strings.TrimSuffix(s, "%")24+ if s == "" || !isDigit(s[0]) && s[0] != '.' {25+ return 0, false26+ }27+ if strings.Contains(s, ",") {28+ if !validGrouping(s) {29+ return 0, false30+ }31+ s = strings.ReplaceAll(s, ",", "")32+ }33+ v, err := strconv.ParseFloat(s, 64)34+ if err != nil {35+ return 0, false36+ }37+ if pct {38+ v /= 10039+ }40+ if neg {41+ v = -v42+ }43+ return v, true44+}45+46+// validGrouping checks thousands separators: groups of three digits47+// before the decimal point.48+func validGrouping(s string) bool {49+ intPart, _, _ := strings.Cut(s, ".")50+ groups := strings.Split(intPart, ",")51+ if len(groups[0]) == 0 || len(groups[0]) > 3 {52+ return false53+ }54+ for _, g := range groups[1:] {55+ if len(g) != 3 {56+ return false57+ }58+ }59+ return true60+}61+62+// FormatValue renders v in width columns with one column of padding, the63+// way the grid shows it: numbers right-aligned, booleans and errors64+// centered. Text is not handled here because it can overflow into65+// neighboring cells.66+func FormatValue(v Value, width int) string {67+ inner := width - 168+ switch v.Kind {69+ case Number:70+ return padLeft(formatGeneral(v.Num, inner), inner) + " "71+ case Bool, Error:72+ return centerPad(v.String(), width)73+ }74+ return ""75+}76+77+// FormatNumber formats a number in the General format within width78+// columns, for use outside the grid (e.g. the status line).79+func FormatNumber(v float64, width int) string {80+ return formatGeneral(v, width)81+}82+83+// formatGeneral mimics General number format: as many digits as fit,84+// rounding decimals first, then scientific notation, then #s.85+func formatGeneral(v float64, width int) string {86+ if width <= 0 {87+ return ""88+ }89+ s := strconv.FormatFloat(v, 'f', -1, 64)90+ if len(s) <= width {91+ return s92+ }93+ // Try fewer decimals before falling back to scientific notation, but94+ // never round a non-zero number down to zero.95+ if dot := strings.IndexByte(s, '.'); dot >= 0 && dot <= width {96+ for prec := max(width-dot-1, 0); prec >= 0; prec-- {97+ f := strconv.FormatFloat(v, 'f', prec, 64)98+ if len(f) > width {99+ continue100+ }101+ if r, _ := strconv.ParseFloat(f, 64); r != 0 || v == 0 {102+ return f103+ }104+ break105+ }106+ }107+ for prec := width; prec >= 0; prec-- {108+ if e := strconv.FormatFloat(v, 'E', prec, 64); len(e) <= width {109+ return e110+ }111+ }112+ return strings.Repeat("#", width)113+}114+115+func padLeft(s string, width int) string {116+ if len(s) >= width {117+ return s118+ }119+ return strings.Repeat(" ", width-len(s)) + s120+}121+122+func centerPad(s string, width int) string {123+ if len(s) >= width {124+ return s[:width]125+ }126+ pad := width - len(s)127+ return strings.Repeat(" ", pad/2) + s + strings.Repeat(" ", pad-pad/2)128+}
M internal/sheet/parse.go +124 -78
@@ -12,8 +12,10 @@ type Node any
1212 type (1313 numLit struct{ v float64 }1414 strLit struct{ v string }15+ boolLit struct{ v bool }1516 refNode struct{ a Addr }1617 rangeNode struct{ r Rect }18+ nameNode struct{ name string } // an identifier that isn't a cell; #NAME? until names exist1719 unaryNode struct {1820 op string1921 x Node
@@ -23,7 +25,7 @@ type (
2325 l, r Node2426 }2527 callNode struct {26- name string28+ fn *FuncDef2729 args []Node2830 }2931 )
@@ -34,8 +36,8 @@ const (
3436 tokEOF tokKind = iota3537 tokNum3638 tokStr37- tokRef38- tokFunc39+ tokIdent // cell reference, TRUE/FALSE or a name40+ tokFunc // identifier directly followed by "("3941 tokOp4042 )4143
@@ -46,15 +48,18 @@ type token struct {
4648 }4749 4850 // 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.51+// offset in the entry where the problem was found, used to place the edit52+// cursor.5053 type ParseError struct {5154 Pos int5255 Msg string5356 }5457 55-func (e *ParseError) Error() string {56- return fmt.Sprintf("%s at position %d", e.Msg, e.Pos+1)57-}58+func (e *ParseError) Error() string { return e.Msg }59+60+func isIdentStart(c byte) bool { return isLetter(c) || c == '_' || c == '$' }61+62+func isIdentPart(c byte) bool { return isIdentStart(c) || isDigit(c) || c == '.' }5863 5964 func lex(src string) ([]token, error) {6065 var toks []token
@@ -67,7 +72,7 @@ func lex(src string) ([]token, error) {
6772 i++6873 continue6974 case isDigit(c) || (c == '.' && i+1 < len(src) && isDigit(src[i+1])):70- for i < len(src) && (isDigit(src[i]) || src[i] == '.') {75+ for i < len(src) && (isDigit(src[i]) || src[i] == '.' && !strings.HasPrefix(src[i:], "..")) {7176 i++7277 }7378 if i < len(src) && (src[i] == 'e' || src[i] == 'E') {
@@ -84,40 +89,56 @@ func lex(src string) ([]token, error) {
8489 }8590 toks = append(toks, token{tokNum, src[start:i], start})8691 case c == '"':92+ // Strings use "" to escape a quote, as in Sheets.93+ var sb strings.Builder8794 i++88- for i < len(src) && src[i] != '"' {95+ for {96+ if i >= len(src) {97+ return nil, &ParseError{start, "Missing closing quote"}98+ }99+ if src[i] == '"' {100+ if i+1 < len(src) && src[i+1] == '"' {101+ sb.WriteByte('"')102+ i += 2103+ continue104+ }105+ i++106+ break107+ }108+ sb.WriteByte(src[i])89109 i++90110 }91- if i == len(src) {92- return nil, &ParseError{start, "unterminated string"}111+ toks = append(toks, token{tokStr, sb.String(), start})112+ case c == '@' || isIdentStart(c):113+ if c == '@' { // 1-2-3 style @SUM114+ i++93115 }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]) {116+ for i < len(src) && isIdentPart(src[i]) && !strings.HasPrefix(src[i:], "..") {99117 i++100118 }101- if i == start+1 {102- return nil, &ParseError{start, "missing function name"}119+ name := strings.ToUpper(strings.TrimPrefix(src[start:i], "@"))120+ if name == "" {121+ return nil, &ParseError{start, "Missing function name after @"}103122 }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++123+ j := i124+ for j < len(src) && src[j] == ' ' {125+ j++108126 }109- if _, ok := ParseAddr(src[start:i]); !ok {110- return nil, &ParseError{start, fmt.Sprintf("invalid cell reference %q", src[start:i])}127+ kind := tokIdent128+ if j < len(src) && src[j] == '(' {129+ kind = tokFunc130+ } else if c == '@' {131+ kind = tokFunc // @PI without parentheses111132 }112- toks = append(toks, token{tokRef, strings.ToUpper(src[start:i]), start})133+ toks = append(toks, token{kind, name, start})113134 case c == '#':114135 end := strings.IndexByte(src[i+1:], '#')115136 if end < 0 {116- return nil, &ParseError{start, "unterminated logical operator"}137+ return nil, &ParseError{start, "Unexpected #"}117138 }118139 op := strings.ToUpper(src[i : i+end+2])119140 if op != "#AND#" && op != "#OR#" && op != "#NOT#" {120- return nil, &ParseError{start, "unknown operator " + op}141+ return nil, &ParseError{start, "Unknown operator " + op}121142 }122143 i += end + 2123144 toks = append(toks, token{tokOp, op, start})
@@ -125,17 +146,17 @@ func lex(src string) ([]token, error) {
125146 strings.HasPrefix(src[i:], ">="), strings.HasPrefix(src[i:], "<>"):126147 i += 2127148 toks = append(toks, token{tokOp, src[start:i], start})128- case strings.IndexByte("+-*/^=<>&(),;:", c) >= 0:149+ case strings.IndexByte("+-*/^=<>&(),;:%", c) >= 0:129150 i++130151 toks = append(toks, token{tokOp, string(c), start})131152 default:132- return nil, &ParseError{start, fmt.Sprintf("unexpected %q", c)}153+ return nil, &ParseError{start, fmt.Sprintf("Unexpected %q", c)}133154 }134155 }135156 return append(toks, token{tokEOF, "", len(src)}), nil136157 }137158 138-// Binding powers, lowest to highest, following 1-2-3's precedence table.159+// Binding powers, lowest to highest, following Sheets' precedence.139160 var infixPower = map[string]int{140161 "#OR#": 10, "#AND#": 10,141162 "=": 30, "<>": 30, "<": 30, ">": 30, "<=": 30, ">=": 30,
@@ -146,8 +167,9 @@ var infixPower = map[string]int{
146167 }147168 148169 const (149- notPower = 20150- unaryPower = 60170+ notPower = 20171+ unaryPower = 60172+ percentPower = 80151173 )152174 153175 type parser struct {
@@ -155,8 +177,8 @@ type parser struct {
155177 pos int156178 }157179 158-// Parse parses a value entry. A leading '=' is accepted for users coming159-// from modern spreadsheets; 1-2-3 itself starts formulas with '+'.180+// Parse parses a formula. src may start with "=", as typed in a cell;181+// error positions are relative to src.160182 func Parse(src string) (Node, error) {161183 body := strings.TrimPrefix(src, "=")162184 offset := len(src) - len(body)
@@ -167,7 +189,7 @@ func Parse(src string) (Node, error) {
167189 p := &parser{toks: toks}168190 n, err := p.expr(0)169191 if err == nil && p.peek().kind != tokEOF {170- err = &ParseError{p.peek().pos, "unexpected " + p.peek().text}192+ err = &ParseError{p.peek().pos, "Unexpected " + p.peek().text}171193 }172194 if err != nil {173195 return nil, shift(err, offset)
@@ -192,11 +214,9 @@ func (p *parser) next() token {
192214 return t193215 }194216 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 nil217+func (p *parser) isOp(op string) bool {218+ t := p.peek()219+ return t.kind == tokOp && t.text == op200220 }201221 202222 func (p *parser) expr(minPower int) (Node, error) {
@@ -205,6 +225,11 @@ func (p *parser) expr(minPower int) (Node, error) {
205225 return nil, err206226 }207227 for {228+ if p.isOp("%") && percentPower > minPower {229+ p.next()230+ left = binaryNode{op: "/", l: left, r: numLit{100}}231+ continue232+ }208233 t := p.peek()209234 power, ok := infixPower[t.text]210235 if t.kind != tokOp || !ok || power <= minPower {
@@ -225,23 +250,13 @@ func (p *parser) prefix() (Node, error) {
225250 case tokNum:226251 v, err := strconv.ParseFloat(t.text, 64)227252 if err != nil {228- return nil, &ParseError{t.pos, "invalid number " + t.text}253+ return nil, &ParseError{t.pos, "Invalid number " + t.text}229254 }230255 return numLit{v}, nil231256 case tokStr:232257 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}, nil258+ case tokIdent:259+ return p.ident(t)245260 case tokFunc:246261 return p.call(t)247262 case tokOp:
@@ -251,7 +266,11 @@ func (p *parser) prefix() (Node, error) {
251266 if err != nil {252267 return nil, err253268 }254- return n, p.expect(")")269+ if !p.isOp(")") {270+ return nil, &ParseError{p.peek().pos, "Missing )"}271+ }272+ p.next()273+ return n, nil255274 case "+", "-":256275 x, err := p.expr(unaryPower)257276 if err != nil {
@@ -266,38 +285,65 @@ func (p *parser) prefix() (Node, error) {
266285 return unaryNode{op: t.text, x: x}, nil267286 }268287 case tokEOF:269- return nil, &ParseError{t.pos, "incomplete formula"}288+ return nil, &ParseError{t.pos, "Formula is incomplete"}270289 }271- return nil, &ParseError{t.pos, "unexpected " + t.text}290+ return nil, &ParseError{t.pos, "Unexpected " + t.text}272291 }273292 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}293+func (p *parser) ident(t token) (Node, error) {294+ a, isRef := ParseAddr(t.text)295+ if !isRef {296+ switch t.text {297+ case "TRUE":298+ return boolLit{true}, nil299+ case "FALSE":300+ return boolLit{false}, nil301+ }302+ return nameNode{t.text}, nil277303 }278- n := callNode{name: fn.text}279- if t := p.peek(); t.kind != tokOp || t.text != "(" {280- return n, nil304+ if p.isOp(":") || p.isOp("..") {305+ sep := p.next()306+ end := p.next()307+ b, ok := ParseAddr(end.text)308+ if end.kind != tokIdent || !ok {309+ return nil, &ParseError{end.pos, "Expected a cell after " + sep.text}310+ }311+ return rangeNode{NewRect(a, b)}, nil281312 }282- p.next()283- if t := p.peek(); t.kind == tokOp && t.text == ")" {284- p.next()285- return n, nil313+ return refNode{a}, nil314+}315+316+func (p *parser) call(t token) (Node, error) {317+ fn, ok := LookupFunc(t.text)318+ if !ok {319+ return nil, &ParseError{t.pos, "Unknown function " + t.text}286320 }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 )"}321+ n := callNode{fn: fn}322+ if p.isOp("(") {323+ p.next()324+ if p.isOp(")") {325+ p.next()326+ } else {327+ for {328+ arg, err := p.expr(0)329+ if err != nil {330+ return nil, err331+ }332+ n.args = append(n.args, arg)333+ sep := p.next()334+ if sep.kind == tokOp && sep.text == ")" {335+ break336+ }337+ if sep.kind != tokOp || (sep.text != "," && sep.text != ";") {338+ return nil, &ParseError{sep.pos, "Expected , or ) in " + fn.Name}339+ }340+ }299341 }300342 }343+ if len(n.args) < fn.Min || (fn.Max >= 0 && len(n.args) > fn.Max) {344+ return nil, &ParseError{t.pos, fmt.Sprintf("Wrong number of arguments to %s(%s)", fn.Name, fn.Args)}345+ }346+ return n, nil301347 }302348 303349 // walkRefs calls fn for every single-cell reference and range in n.
M internal/sheet/sheet.go +53 -85
@@ -5,35 +5,28 @@ package sheet
55 import (66 "maps"77 "sort"8- "strconv"98 "strings"109 "unicode"1110 )1211 13-// DefaultWidth is the initial column width, as in 1-2-3.14-const DefaultWidth = 912+// DefaultWidth is the initial column width: nine characters plus padding.13+const DefaultWidth = 101514 1615 // Cell holds what the user typed and what it evaluates to.1716 type Cell struct {18- // Input is the entry as typed. Labels always carry an alignment19- // prefix: ' (left), " (right) or ^ (center).17+ // Input is the entry exactly as typed: text, a number such as "$1,200"18+ // or "12%", or a formula starting with "=".2019 Input string2120 Value Value2221 23- expr Node // nil for labels22+ expr Node // nil for text2423 refs []Addr // single-cell references in expr2524 ranges []Rect // range references in expr2625 }2726 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 027+// IsFormula reports whether the cell holds a formula.28+func (c *Cell) IsFormula() bool {29+ return c.expr != nil && IsFormulaEntry(c.Input)3730 }3831 3932 // Sheet is a sparse worksheet.
@@ -112,19 +105,47 @@ func (s *Sheet) Widths() map[int]int {
112105 return maps.Clone(s.widths)113106 }114107 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 == "" {108+// IsFormulaEntry reports whether input is written as a formula: it starts109+// with "=", or with "+" or "-" followed by something that isn't a plain110+// number (as Sheets accepts "+A1").111+func IsFormulaEntry(input string) bool {112+ if strings.HasPrefix(input, "=") {113+ return true114+ }115+ if !strings.HasPrefix(input, "+") && !strings.HasPrefix(input, "-") {119116 return false120117 }121- return strings.IndexByte("0123456789+-.(@#$=", input[0]) >= 0118+ if _, isNum := ParseNumber(input); isNum {119+ return false120+ }121+ _, err := Parse(input)122+ return err == nil123+}124+125+// classify turns an entry into an expression, or nil for text. Entries126+// starting with ' are always text.127+func classify(input string) (Node, error) {128+ if strings.HasPrefix(input, "'") {129+ return nil, nil130+ }131+ if v, ok := ParseNumber(input); ok {132+ return numLit{v}, nil133+ }134+ switch strings.ToUpper(input) {135+ case "TRUE":136+ return boolLit{true}, nil137+ case "FALSE":138+ return boolLit{false}, nil139+ }140+ if IsFormulaEntry(input) {141+ return Parse(input)142+ }143+ return nil, nil122144 }123145 124146 // 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.147+// erases the cell. A formula that fails to parse is rejected with a148+// *ParseError and the sheet is left unchanged.128149 func (s *Sheet) Set(a Addr, input string) error {129150 if err := s.put(a, input); err != nil {130151 return err
@@ -146,18 +167,15 @@ func (s *Sheet) put(a Addr, input string) error {
146167 s.erase(a)147168 return nil148169 }149- c := &Cell{Input: input}150- if IsValueEntry(input) {151- n, err := Parse(input)152- if err != nil {153- return err154- }155- c.expr = n170+ n, err := classify(input)171+ if err != nil {172+ return err173+ }174+ c := &Cell{Input: input, expr: n}175+ if n != nil {156176 walkRefs(n,157177 func(r Addr) { c.refs = append(c.refs, r) },158178 func(r Rect) { c.ranges = append(c.ranges, r) })159- } else if strings.IndexByte(`'"^`, input[0]) < 0 {160- c.Input = "'" + input161179 }162180 s.erase(a)163181 s.cells[a] = c
@@ -248,13 +266,13 @@ func (s *Sheet) recalc(changed []Addr) {
248266 return Value{}249267 case state[a] == visiting:250268 s.Circular = true251- return errValue269+ return ErrRef252270 case state[a] != dirty:253271 return c.Value254272 }255273 state[a] = visiting256- if c.IsLabel() {257- c.Value = Value{Kind: Label, Str: c.Input[1:]}274+ if c.expr == nil {275+ c.Value = Value{Kind: Text, Str: strings.TrimPrefix(c.Input, "'")}258276 } else {259277 c.Value = eval(c.expr, compute)260278 }
@@ -265,53 +283,3 @@ func (s *Sheet) recalc(changed []Addr) {
265283 compute(a)266284 }267285 }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-}
M internal/sheet/sheet_test.go +135 -60
@@ -3,7 +3,9 @@ package sheet
33 import (44 "bytes"55 "errors"6+ "strings"67 "testing"8+ "unicode"79 )810 911 func at(s string) Addr {
@@ -46,18 +48,20 @@ func TestAddr(t *testing.T) {
4648 }4749 4850 func TestParseRange(t *testing.T) {49- for _, in := range []string{"B3..A1", "a1:b3", "$A$1..$B$3"} {51+ for _, in := range []string{"B3..A1", "a1:b3", "$A$1:$B$3"} {5052 r, ok := ParseRange(in)51- if !ok || r.String() != "A1..B3" {53+ if !ok || r.String() != "A1:B3" {5254 t.Errorf("ParseRange(%q) = %v, %v", in, r, ok)5355 }5456 }5557 }5658 59+func txt(s string) Value { return Value{Kind: Text, Str: s} }60+5761 func TestFormulas(t *testing.T) {5862 s := New()5963 for addr, in := range map[string]string{60- "A1": "10", "A2": "20", "A3": "30", "B1": "Name", "B2": `"right`,64+ "A1": "10", "A2": "20", "A3": "30", "B1": "Name", "B2": "'42", "B3": "TRUE",6165 } {6266 if err := s.Set(at(addr), in); err != nil {6367 t.Fatal(err)
@@ -67,28 +71,47 @@ func TestFormulas(t *testing.T) {
6771 in string6872 want Value6973 }{70- {"+A1+A2*2", num(50)},71- {"(A1+A2)*2", num(60)},72- {"-2^2", num(-4)},74+ {"=A1+A2*2", num(50)},75+ {"=(A1+A2)*2", num(60)},76+ {"=-2^2", num(-4)},7377 {"=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)},78+ {"+A1*2", num(20)}, // Sheets accepts a leading +79+ {"=SUM(A1:A3)", num(60)},80+ {"=sum(A1:A3, 5)", num(65)},81+ {"=@SUM(A1..A3)", num(60)}, // 1-2-3 function syntax still parses82+ {"=AVERAGE(A1:B3)", num(20)},83+ {"=AVG(A1:A3)", num(20)},84+ {"=COUNT(A1:B3)", num(3)}, // numbers only85+ {"=COUNTA(A1:B3)", num(6)}, // anything non-empty86+ {"=MIN(A1:A3)", num(10)},87+ {"=MAX(A1:A3)", num(30)},88+ {"=ROUND(PI(), 2)", num(3.14)},89+ {"=ROUND(2.5)", num(3)},90+ {"=MOD(-7, 3)", num(2)},91+ {"=INT(-1.5)", num(-2)},92+ {`=IF(A1>5, "big", "small")`, txt("big")},93+ {"=IF(A1>50, 1)", boolean(false)},94+ {"=A1>5", boolean(true)},95+ {"=AND(A1>5, A2<10)", boolean(false)},96+ {"=OR(B3, FALSE)", boolean(true)},97+ {"=NOT(A1=10)", boolean(false)},98+ {"=B2*2", num(84)}, // numeric text coerces99+ {"=B1+1", ErrValue}, // other text doesn't100+ {"=SUM(B1)", ErrValue}, // nor as a direct argument101+ {"=B3+1", num(2)}, // TRUE is 1102+ {`=B1&" X"`, txt("Name X")},103+ {`="say ""hi"""`, txt(`say "hi"`)},104+ {"=A1&B3", txt("10TRUE")},105+ {"=A1/0", ErrDiv0},106+ {"=SQRT(-1)", ErrNum},107+ {"=NA()", ErrNA},108+ {"=NA()+1", ErrNA},109+ {"=IFERROR(A1/0, 0)", num(0)},110+ {"=Z99", Value{}},111+ {"=10%", num(0.1)},112+ {"=A1*50%", num(5)},113+ {"=profit", ErrName},114+ {"=1.5E3", num(1500)},92115 }93116 for _, tt := range tests {94117 if err := s.Set(at("C1"), tt.in); err != nil {
@@ -101,33 +124,66 @@ func TestFormulas(t *testing.T) {
101124 }102125 }103126 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)127+func TestEntryKinds(t *testing.T) {128+ tests := []struct {129+ in string130+ want Value131+ formula bool132+ }{133+ {"42", num(42), false},134+ {"-3.5", num(-3.5), false},135+ {"$1,200.50", num(1200.5), false},136+ {"12%", num(0.12), false},137+ {"1e3", num(1000), false},138+ {"true", boolean(true), false},139+ {"Rent", txt("Rent"), false},140+ {"'42", txt("42"), false},141+ {"1,23", txt("1,23"), false}, // bad grouping is text142+ {"-hello", Value{Kind: Error, Str: "#NAME?"}, true},143+ {"A1+1", txt("A1+1"), false}, // no = means text144+ {"=1+1", num(2), true},145+ {"-A1", num(0), true},146+ }147+ for _, tt := range tests {148+ s := New()149+ if err := s.Set(at("A2"), tt.in); err != nil {150+ t.Errorf("Set(%q): %v", tt.in, err)151+ continue152+ }153+ c := s.Cell(at("A2"))154+ if c.Value != tt.want || c.IsFormula() != tt.formula || c.Input != tt.in {155+ t.Errorf("%q = %+v formula=%v input=%q", tt.in, c.Value, c.IsFormula(), c.Input)109156 }110157 }111158 }112159 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)160+func TestParseErrors(t *testing.T) {161+ tests := []struct{ in, msg string }{162+ {"=A1+", "Formula is incomplete"},163+ {"=SUM(A1", "Expected , or ) in SUM"},164+ {"=NOPE(1)", "Unknown function NOPE"},165+ {"=(1", "Missing )"},166+ {"=A1:", "Expected a cell after :"},167+ {"=1 2", "Unexpected 2"},168+ {`="abc`, "Missing closing quote"},169+ {"=ROUND()", "Wrong number of arguments to ROUND(value, [places])"},170+ {"=PI(1)", "Wrong number of arguments to PI()"},119171 }120- if c := s.Cell(at("A2")); c.Align() != '^' || c.Value.Str != "Mid" {121- t.Errorf("A2 = %+v", c)172+ for _, tt := range tests {173+ var pe *ParseError174+ err := New().Set(at("A1"), tt.in)175+ if !errors.As(err, &pe) || pe.Msg != tt.msg {176+ t.Errorf("Set(%q) error = %v, want %q", tt.in, err, tt.msg)177+ }122178 }123179 }124180 125181 func TestRecalcPropagates(t *testing.T) {126182 s := New()127183 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)")184+ s.Set(at("A2"), "=A1*2")185+ s.Set(at("A3"), "=A2+A1")186+ s.Set(at("B1"), "=SUM(A1:A10)")131187 132188 s.Set(at("A1"), "5")133189 if got := s.Value(at("A3")).Num; got != 15 {
@@ -157,16 +213,16 @@ func TestRecalcPropagates(t *testing.T) {
157213 158214 func TestCircular(t *testing.T) {159215 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 {216+ s.Set(at("A1"), "=B1+1")217+ s.Set(at("B1"), "=A1+1")218+ if !s.Circular || s.Value(at("A1")) != ErrRef || s.Value(at("B1")) != ErrRef {163219 t.Errorf("circular: %v %+v %+v", s.Circular, s.Value(at("A1")), s.Value(at("B1")))164220 }165221 s.Set(at("B1"), "3")166222 if s.Circular || s.Value(at("A1")).Num != 4 {167223 t.Errorf("after break: %v %+v", s.Circular, s.Value(at("A1")))168224 }169- s.Set(at("C1"), "@SUM(C1..C2)")225+ s.Set(at("C1"), "=SUM(C1:C2)")170226 if !s.Circular {171227 t.Error("self-referencing range not flagged")172228 }
@@ -174,33 +230,52 @@ func TestCircular(t *testing.T) {
174230 175231 func TestFormatValue(t *testing.T) {176232 tests := []struct {177- v float64233+ v Value178234 width int179235 want string180236 }{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, "** "},237+ {num(42), 10, " 42 "},238+ {num(-3.5), 10, " -3.5 "},239+ {num(1.0 / 3), 10, "0.3333333 "},240+ {num(9.9999), 4, " 10 "},241+ {num(123456789012), 10, "1.235E+11 "},242+ {num(0.000000001), 10, "1.000E-09 "},243+ {num(0), 3, " 0 "},244+ {num(123456), 3, "## "},245+ {boolean(true), 10, " TRUE "},246+ {ErrDiv0, 10, " #DIV/0! "},189247 }190248 for _, tt := range tests {191- if got := FormatValue(num(tt.v), tt.width); got != tt.want {249+ if got := FormatValue(tt.v, tt.width); got != tt.want {192250 t.Errorf("FormatValue(%v, %d) = %q, want %q", tt.v, tt.width, got, tt.want)193251 }194252 }195253 }196254 255+func TestParseNumber(t *testing.T) {256+ for in, want := range map[string]float64{257+ "0": 0, "12": 12, "-12.5": -12.5, "+3": 3, ".5": 0.5, "1,234": 1234,258+ "1,234,567.89": 1234567.89, "$99": 99, "-$5": -5, "50%": 0.5, "2.5e2": 250,259+ } {260+ if got, ok := ParseNumber(in); !ok || got != want {261+ t.Errorf("ParseNumber(%q) = %v, %v", in, got, ok)262+ }263+ }264+ for _, in := range []string{"", "abc", "1,2", "12,34", "$", "%", "1.2.3", "--1", "1 2"} {265+ if _, ok := ParseNumber(in); ok {266+ t.Errorf("ParseNumber(%q) accepted", in)267+ }268+ }269+}270+197271 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"} {272+ for _, s := range []string{"=A1*2", "=SUM(A1:B3)", "=IF(AND(A1>2,B1),1,NA())", `="a"&"b"`, "=1.5E-3^2", "$1,200", "12%", "@SUM(A1..B3)"} {199273 f.Add(s)200274 }201275 f.Fuzz(func(t *testing.T, in string) {202276 s := New()203- if err := s.Set(Addr{}, in); err == nil && in != "" && s.Cell(Addr{}) == nil {277+ printable := strings.ContainsFunc(in, func(r rune) bool { return !unicode.IsControl(r) })278+ if err := s.Set(Addr{}, in); err == nil && printable && s.Cell(Addr{}) == nil {204279 t.Fatalf("Set(%q) succeeded but cell is blank", in)205280 }206281 })
@@ -209,9 +284,9 @@ func FuzzParse(f *testing.F) {
209284 func TestFileRoundTrip(t *testing.T) {210285 s := New()211286 s.Set(at("A1"), "Total")212- s.Set(at("B1"), "@SUM(B2..B3)")287+ s.Set(at("B1"), "=SUM(B2:B3)")213288 s.Set(at("B2"), "2")214- s.Set(at("B3"), "+B2*10")289+ s.Set(at("B3"), "=B2*10")215290 s.SetColWidth(0, 14)216291 217292 var buf bytes.Buffer
@@ -225,7 +300,7 @@ func TestFileRoundTrip(t *testing.T) {
225300 if v := got.Value(at("B1")).Num; v != 22 {226301 t.Errorf("B1 = %v, want 22", v)227302 }228- if got.Cell(at("A1")).Input != "'Total" || got.ColWidth(0) != 14 {303+ if got.Cell(at("A1")).Input != "Total" || got.ColWidth(0) != 14 {229304 t.Errorf("A1 = %q, width %d", got.Cell(at("A1")).Input, got.ColWidth(0))230305 }231306 }
@@ -233,7 +308,7 @@ func TestFileRoundTrip(t *testing.T) {
233308 func TestStripsControlChars(t *testing.T) {234309 s := New()235310 s.Set(at("A1"), "evil\x1b]52;c;aGk=\x07label")236- if got := s.Cell(at("A1")).Input; got != "'evil]52;c;aGk=label" {311+ if got := s.Cell(at("A1")).Input; got != "evil]52;c;aGk=label" {237312 t.Errorf("Input = %q", got)238313 }239314 }
A internal/sheet/testdata/fuzz/FuzzParse/808279fbab08fe78 +2 -0