Value, its kinds and errors, Format and its kinds, and the parsing of typed numbers, dates and times (with the clock, value.Now) go below the engine, so the function library can move out of it next. The engine names them as before through aliases, so callers don't change.
- commit
- cb54b970fafa98bf188a6acebce6bce9e6f091c8
- parent
- d0ad2d89b2e46ae06edad2c238976edc6b13785a
- author
- Dami <dami.etoile@gmail.com>
| M | internal/ | 6 | +- |
| R | internal/ | 99 | +--------------------- |
| M | internal/ | 123 | -------------------------- |
| M | internal/ | 2 | +- |
| M | internal/ | 8 | +- |
| M | internal/ | 9 | +- |
| M | internal/ | 4 | +- |
| A | internal/ | 64 | ++++++++++++++ |
| A | internal/ | 81 | +++++++++++++++++ |
| R | internal/ | 8 | +- |
| R | internal/ | 83 | ++---------------- |
| A | internal/ | 108 | +++++++++++++++++++++++ |
| A | internal/ | 140 | ++++++++++++++++++++++++++++++ |
@@ -36,7 +36,7 @@ func (s *Sheet) DisplayFormat(a Addr) Format {
3636 // cells, which keep it for when something is typed. Whole columns and3737 // rows keep it as their line's format rather than on each cell.3838 func (s *Sheet) SetFormat(r Rect, f Format) {39- s.change("format "+r.String()+" as "+f.Kind.label(), r, func() {39+ s.change("format "+r.String()+" as "+kindLabel(f.Kind), r, func() {4040 s.eachFormat(r, !f.IsZero(), func(_ Addr, l *lineFmt) { l.Format = f })4141 })4242 }@@ -145,12 +145,12 @@ func inferFormat(n Node, at func(string, Addr) Format) Format {
145145 l, r := inferFormat(n.L, at), inferFormat(n.R, at)146146 switch n.Op {147147 case "+", "-":148- if n.Op == "-" && l.Kind.isTime() && r.Kind.isTime() {148+ if n.Op == "-" && l.Kind.IsTime() && r.Kind.IsTime() {149149 return Format{} // days between two dates150150 }151151 return firstFormat(l, r)152152 case "*", "/":153- if l.Kind.isTime() || r.Kind.isTime() {153+ if l.Kind.IsTime() || r.Kind.IsTime() {154154 return Format{}155155 }156156 return firstFormat(l, r)@@ -1,107 +1,12 @@
11 package sheet22 33 import (4- "strconv"54 "strings"65 "unicode/utf8"76 87 "github.com/FelineStateMachine/012/internal/numfmt"98 )109 11-// ParseNumber recognizes numbers the way Google Sheets does on entry:12-// optional sign, optional leading currency symbol, thousands separators,13-// decimals, exponent, and a trailing percent sign ("12%" is 0.12).14-func ParseNumber(s string) (float64, bool) {15- v, _, ok := parseNumberFormat(s)16- return v, ok17-}18-19-// ParseValue recognizes everything Sheets turns into a number on entry:20-// numbers, currency, percentages, dates and times. It also returns the21-// format Sheets applies, e.g. Currency for "$1,200" or Date for22-// "9/26/2026"; plain numbers get Automatic.23-func ParseValue(s string) (float64, Format, bool) {24- if v, f, ok := parseNumberFormat(s); ok {25- return v, f, true26- }27- return parseDateTime(s)28-}29-30-func parseNumberFormat(s string) (float64, Format, bool) {31- s = strings.TrimSpace(s)32- if s == "" {33- return 0, Format{}, false34- }35- neg := false36- if s[0] == '-' || s[0] == '+' {37- neg = s[0] == '-'38- s = s[1:]39- }40- cur := strings.HasPrefix(s, "$")41- s = strings.TrimPrefix(s, "$")42- pct := strings.HasSuffix(s, "%")43- s = strings.TrimSuffix(s, "%")44- if s == "" || !isDigit(s[0]) && s[0] != '.' {45- return 0, Format{}, false46- }47- grouped := strings.Contains(s, ",")48- if grouped {49- if !validGrouping(s) {50- return 0, Format{}, false51- }52- s = strings.ReplaceAll(s, ",", "")53- }54- v, err := strconv.ParseFloat(s, 64)55- if err != nil || strings.ContainsAny(s, "xXpP_") || strings.EqualFold(s, "inf") {56- return 0, Format{}, false57- }58- if pct {59- // Shift the decimal point rather than divide, so "0.7%" is the60- // double nearest 0.007 and not 0.006999999999999999.61- if p, err := strconv.ParseFloat(s+"e-2", 64); err == nil {62- v = p63- } else {64- v /= 10065- }66- }67- if neg {68- v = -v69- }70- // The format Sheets infers: two decimals if any were typed.71- dec := 072- if strings.Contains(s, ".") {73- dec = 274- }75- var f Format76- switch {77- case cur:78- f = Format{Kind: FmtCurrency, Decimals: dec}79- case pct:80- f = Format{Kind: FmtPercent, Decimals: dec}81- case strings.ContainsAny(s, "eE"):82- f = Preset(FmtScientific)83- case grouped:84- f = Format{Kind: FmtNumber, Decimals: dec}85- }86- return v, f, true87-}88-89-// validGrouping checks thousands separators: groups of three digits90-// before the decimal point.91-func validGrouping(s string) bool {92- intPart, _, _ := strings.Cut(s, ".")93- groups := strings.Split(intPart, ",")94- if len(groups[0]) == 0 || len(groups[0]) > 3 {95- return false96- }97- for _, g := range groups[1:] {98- if len(g) != 3 {99- return false100- }101- }102- return true103-}104-10510 // Display renders v under format f for a cell width columns wide, as the10611 // grid shows it: the text without padding, and where it goes. Numbers go10712 // right, text left, booleans and errors center. A formatted number that@@ -129,7 +34,7 @@ func Display(v Value, f Format, width int) (string, Align) {
12934 }13035 return strings.Repeat("#", inner), AlignRight13136 }132- s := numfmt.Format(v.Num, f.pattern())37+ s := numfmt.Format(v.Num, f.Code())13338 if utf8.RuneCountInString(s) > inner {13439 s = strings.Repeat("#", inner)13540 }@@ -149,7 +54,7 @@ func FormatText(v Value, f Format) string {
14954 case FmtAuto, FmtText:15055 return numfmt.General(v.Num)15156 }152- return numfmt.Format(v.Num, f.pattern())57+ return numfmt.Format(v.Num, f.Code())15358 }15459 15560 // FormatValue renders v in width columns with one column of padding, the@@ -2,92 +2,10 @@ package sheet
22 33 import (44 "math"5- "strings"65 76 "github.com/FelineStateMachine/012/internal/formula"8- "github.com/FelineStateMachine/012/internal/numfmt"97 )108 11-// Kind is the type of a computed cell value.12-type Kind int13-14-const (15- Empty Kind = iota16- Number17- Text18- Bool19- Error20-)21-22-// Value is the computed contents of a cell.23-type Value struct {24- Kind Kind25- Num float64 // numbers, and booleans as 1 or 026- Str string // text, or the error code such as #DIV/0!27-}28-29-// String renders a value the way a cell shows it in General format.30-func (v Value) String() string {31- switch v.Kind {32- case Number:33- return numfmt.General(v.Num)34- case Bool:35- if v.Num != 0 {36- return "TRUE"37- }38- return "FALSE"39- }40- return v.Str41-}42-43-// Error values, using Google Sheets codes.44-var (45- ErrDiv0 = Value{Kind: Error, Str: "#DIV/0!"}46- ErrValue = Value{Kind: Error, Str: "#VALUE!"}47- ErrName = Value{Kind: Error, Str: "#NAME?"}48- ErrNA = Value{Kind: Error, Str: "#N/A"}49- ErrNum = Value{Kind: Error, Str: "#NUM!"}50- ErrRef = Value{Kind: Error, Str: "#REF!"} // also circular references51-)52-53-func num(v float64) Value {54- if math.IsNaN(v) || math.IsInf(v, 0) {55- return ErrNum56- }57- return Value{Kind: Number, Num: v}58-}59-60-func boolean(b bool) Value {61- if b {62- return Value{Kind: Bool, Num: 1}63- }64- return Value{Kind: Bool}65-}66-67-// toNum coerces v for arithmetic as Sheets does: blanks are 0, booleans68-// 1 or 0, numeric text (including dates such as "2026-09-26") is its69-// number, other text is #VALUE!.70-// errOf returns a pointer to a copy of v, for the *Value error results of71-// argument helpers. Taking &v of a parameter directly would move it to72-// the heap on every call, erroneous or not: one allocation per cell read73-// by SUM over a range.74-func errOf(v Value) *Value { return &v }75-76-func toNum(v Value) (float64, *Value) {77- switch v.Kind {78- case Empty:79- return 0, nil80- case Number, Bool:81- return v.Num, nil82- case Text:83- if n, _, ok := ParseValue(v.Str); ok {84- return n, nil85- }86- return 0, &ErrValue87- }88- return 0, errOf(v)89-}90-919 // lookup is how a formula reads other cells (see reader): one at a time,9210 // or a whole range at once, so reading a range needn't cost a lookup per9311 // cell and can later be served from column blocks or cached aggregates.@@ -237,44 +155,3 @@ func evalBinary(n formula.Binary, get lookup, dec bool) Value {
237155 }238156 return ErrValue239157 }240-241-// text converts a value for string concatenation.242-func text(v Value) string {243- if v.Kind == Empty {244- return ""245- }246- return v.String()247-}248-249-// compare orders values like Sheets: numbers < text < booleans, text is250-// case-insensitive, and a blank equals 0 or "".251-func compare(l, r Value) int {252- rank := func(v Value) int {253- switch v.Kind {254- case Text:255- return 1256- case Bool:257- return 2258- }259- return 0260- }261- if l.Kind == Empty && r.Kind == Text {262- l = Value{Kind: Text}263- }264- if r.Kind == Empty && l.Kind == Text {265- r = Value{Kind: Text}266- }267- if d := rank(l) - rank(r); d != 0 {268- return d269- }270- if l.Kind == Text {271- return strings.Compare(strings.ToUpper(l.Str), strings.ToUpper(r.Str))272- }273- switch {274- case l.Num < r.Num:275- return -1276- case l.Num > r.Num:277- return 1278- }279- return 0280-}@@ -141,7 +141,7 @@ func encodeCell(c *Cell) (json.RawMessage, error) {
141141 if !c.Format.IsZero() {142142 fc.Format = c.Format.Kind.String()143143 }144- if c.Format.Kind.hasDecimals() {144+ if c.Format.Kind.HasDecimals() {145145 d := c.Format.Decimals146146 fc.Decimals = &d147147 }@@ -5,14 +5,16 @@ import (
55 "strings"66 "testing"77 "time"8+9+ "github.com/FelineStateMachine/012/internal/value"810 )911 1012 // fixedNow pins TODAY() and entries without a year to 2026-09-26 14:30.1113 func fixedNow(t *testing.T) {1214 t.Helper()13- old := Now14- Now = func() time.Time { return time.Date(2026, 9, 26, 14, 30, 0, 0, time.Local) }15- t.Cleanup(func() { Now = old })15+ old := value.Now16+ value.Now = func() time.Time { return time.Date(2026, 9, 26, 14, 30, 0, 0, time.Local) }17+ t.Cleanup(func() { value.Now = old })1618 }1719 1820 func TestDisplay(t *testing.T) {@@ -5,6 +5,7 @@ import (
55 "strings"66 77 "github.com/FelineStateMachine/012/internal/numfmt"8+ "github.com/FelineStateMachine/012/internal/value"89 )910 1011 var (@@ -20,9 +21,9 @@ func init() {
2021 &FuncDef{Name: "TIME", Args: "hour, minute, second", Desc: "A time of day from its parts", Min: 3, Max: 3,2122 eval: numeric(timeOfDay), format: timeFormat},2223 &FuncDef{Name: "TODAY", Desc: "Today's date, updated on every change", Max: 0, Volatile: true,23- eval: func([]Node, lookup) Value { return num(math.Floor(numfmt.SerialOf(Now()))) }, format: dateFormat},24+ eval: func([]Node, lookup) Value { return num(math.Floor(numfmt.SerialOf(value.Now()))) }, format: dateFormat},2425 &FuncDef{Name: "NOW", Desc: "The current date and time, updated on every change", Max: 0, Volatile: true,25- eval: func([]Node, lookup) Value { return num(numfmt.SerialOf(Now())) }, format: dateTimeFormat},26+ eval: func([]Node, lookup) Value { return num(numfmt.SerialOf(value.Now())) }, format: dateTimeFormat},2627 &FuncDef{Name: "YEAR", Args: "date", Desc: "Year of a date", Min: 1, Max: 1,2728 eval: datePart(func(y, _, _ int) int { return y })},2829 &FuncDef{Name: "MONTH", Args: "date", Desc: "Month of a date, 1 to 12", Min: 1, Max: 1,@@ -118,7 +119,7 @@ func dateValue(args []Node, get lookup) Value {
118119 if ok != nil {119120 return *ok120121 }121- d, f, parsed := parseDateTime(v)122+ d, f, parsed := value.ParseDateTime(v)122123 if !parsed || f.Kind == FmtTime || f.Kind == FmtDuration {123124 return ErrValue124125 }@@ -130,7 +131,7 @@ func timeValue(args []Node, get lookup) Value {
130131 if ok != nil {131132 return *ok132133 }133- if d, _, parsed := parseDateTime(v); parsed {134+ if d, _, parsed := value.ParseDateTime(v); parsed {134135 return num(d - math.Floor(d))135136 }136137 return ErrValue@@ -3,6 +3,8 @@ package sheet
33 import (44 "testing"55 "time"6+7+ "github.com/FelineStateMachine/012/internal/value"68 )79 810 func TestDateFunctions(t *testing.T) {@@ -104,7 +106,7 @@ func TestVolatileRecalc(t *testing.T) {
104106 if got := s.Value(at("A2")).Num; got != 46292 {105107 t.Fatalf("A2 = %v", got)106108 }107- Now = func() time.Time { return time.Date(2026, 9, 27, 9, 0, 0, 0, time.Local) }109+ value.Now = func() time.Time { return time.Date(2026, 9, 27, 9, 0, 0, 0, time.Local) }108110 s.Set(at("Z1"), "unrelated") // any change recalculates volatile cells109111 if got := s.Value(at("A2")).Num; got != 46293 {110112 t.Errorf("A2 after a day = %v, want 46293", got)@@ -0,0 +1,64 @@
1+package sheet2+3+// kindLabel names a format kind in undo labels: "format B3 as date time".4+func kindLabel(k FormatKind) string {5+ switch k {6+ case FmtAuto:7+ return "automatic"8+ case FmtText:9+ return "plain text"10+ case FmtDateTime:11+ return "date time"12+ case FmtCustom:13+ return "custom"14+ }15+ return k.String()16+}17+18+// Align is a cell's horizontal alignment.19+type Align uint820+21+const (22+ AlignAuto Align = iota // numbers right, text left, booleans and errors centered23+ AlignLeft //24+ AlignCenter //25+ AlignRight //26+ // AlignFill is only returned by Display: the text spans the cell27+ // exactly and isn't padded (Accounting's $ at the left edge).28+ AlignFill29+)30+31+var alignNames = [...]string{"", "left", "center", "right"}32+33+// String returns the alignment's name as stored in files.34+func (a Align) String() string {35+ if int(a) < len(alignNames) {36+ return alignNames[a]37+ }38+ return ""39+}40+41+// ParseAlign is the inverse of Align.String.42+func ParseAlign(s string) (Align, bool) {43+ for i, n := range alignNames {44+ if n == s {45+ return Align(i), true46+ }47+ }48+ return AlignAuto, false49+}50+51+// Style is a cell's text style. It is a plain value so cells can be52+// copied freely.53+type Style struct {54+ Bold, Italic, Underline, Strikethrough bool55+ Align Align56+57+ // own marks a cell's format and style as wholly its own, not falling58+ // back on its row's or column's even where they are Automatic or59+ // plain; see lines.go.60+ own bool61+}62+63+// IsZero reports whether s is the default style.64+func (s Style) IsZero() bool { return s == Style{} }@@ -0,0 +1,81 @@
1+package sheet2+3+import "github.com/FelineStateMachine/012/internal/value"4+5+// Values, formats and the parsing of typed entries live in6+// internal/value, below the engine. The engine's API keeps their names,7+// as it does the formula package's, so callers see one package.8+9+type (10+ // Kind is the type of a computed cell value.11+ Kind = value.Kind12+ // Value is the computed contents of a cell.13+ Value = value.Value14+ // FormatKind is a number format from Sheets' Format > Number menu.15+ FormatKind = value.FormatKind16+ // Format is a cell's number format.17+ Format = value.Format18+)19+20+// Kinds of values.21+const (22+ Empty = value.Empty23+ Number = value.Number24+ Text = value.Text25+ Bool = value.Bool26+ Error = value.Error27+)28+29+// Error values, using Google Sheets codes.30+var (31+ ErrDiv0 = value.ErrDiv032+ ErrValue = value.ErrValue33+ ErrName = value.ErrName34+ ErrNA = value.ErrNA35+ ErrNum = value.ErrNum36+ ErrRef = value.ErrRef // also circular references37+)38+39+// Number formats.40+const (41+ FmtAuto = value.FmtAuto42+ FmtText = value.FmtText43+ FmtNumber = value.FmtNumber44+ FmtPercent = value.FmtPercent45+ FmtScientific = value.FmtScientific46+ FmtAccounting = value.FmtAccounting47+ FmtFinancial = value.FmtFinancial48+ FmtCurrency = value.FmtCurrency49+ FmtDate = value.FmtDate50+ FmtTime = value.FmtTime51+ FmtDateTime = value.FmtDateTime52+ FmtDuration = value.FmtDuration53+ FmtCustom = value.FmtCustom54+55+ // MaxDecimals caps Increase decimal places.56+ MaxDecimals = value.MaxDecimals57+)58+59+// Preset returns kind with Sheets' default decimals: two for the number60+// kinds.61+func Preset(k FormatKind) Format { return value.Preset(k) }62+63+// ParseFormatKind is the inverse of FormatKind.String.64+func ParseFormatKind(s string) (FormatKind, bool) { return value.ParseFormatKind(s) }65+66+// ParseNumber recognizes numbers the way Google Sheets does on entry.67+func ParseNumber(s string) (float64, bool) { return value.ParseNumber(s) }68+69+// ParseValue recognizes everything Sheets turns into a number on entry:70+// numbers, currency, percentages, dates and times, with the format71+// Sheets applies.72+func ParseValue(s string) (float64, Format, bool) { return value.ParseValue(s) }73+74+func num(v float64) Value { return value.Num(v) }75+func boolean(b bool) Value { return value.Boolean(b) }76+func errOf(v Value) *Value { return value.ErrOf(v) }77+func toNum(v Value) (float64, *Value) { return value.ToNum(v) }78+func text(v Value) string { return value.AsText(v) }79+func compare(l, r Value) int { return value.Compare(l, r) }80+81+func clampInt(v, lo, hi int) int { return max(lo, min(v, hi)) }@@ -1,4 +1,4 @@
1-package sheet1+package value22 33 import (44 "strconv"@@ -15,12 +15,12 @@ var Now = time.Now
1515 // Typed dates and times become serial day numbers, as in Sheets; see1616 // numfmt's calendar.1717 18-// parseDateTime recognizes dates and times typed into a cell the way18+// ParseDateTime recognizes dates and times typed into a cell the way1919 // Sheets does in the en-US locale: 9/26/2026, 9/26/26, 9/26 (this year),2020 // 2026-09-26, Sep 26, 2026, 26 Sep 2026, 14:30, 2:30 PM, 2pm, 25:30 (a2121 // duration) and a date followed by a time. It returns the serial and the2222 // format Sheets would apply.23-func parseDateTime(s string) (float64, Format, bool) {23+func ParseDateTime(s string) (float64, Format, bool) {2424 s = strings.TrimSpace(s)2525 if s == "" || !(isDigit(s[0]) || isLetter(s[0])) {2626 return 0, Format{}, false@@ -120,7 +120,7 @@ func clockParts(parts []string) (h, m int, sec float64, ok bool) {
120120 return h, m, sec, true121121 }122122 123-// parseDate recognizes the date forms listed at parseDateTime.123+// parseDate recognizes the date forms listed at ParseDateTime.124124 func parseDate(s string) (float64, Format, bool) {125125 s = strings.TrimSpace(s)126126 switch {@@ -1,4 +1,4 @@
1-package sheet1+package value22 33 import (44 "strconv"@@ -47,23 +47,8 @@ func ParseFormatKind(s string) (FormatKind, bool) {
4747 return FmtAuto, false4848 }4949 50-// label names the kind in undo labels: "format B3 as date time".51-func (k FormatKind) label() string {52- switch k {53- case FmtAuto:54- return "automatic"55- case FmtText:56- return "plain text"57- case FmtDateTime:58- return "date time"59- case FmtCustom:60- return "custom"61- }62- return k.String()63-}64-65-// hasDecimals reports whether the kind takes a number of decimal places.66-func (k FormatKind) hasDecimals() bool {50+// HasDecimals reports whether the kind takes a number of decimal places.51+func (k FormatKind) HasDecimals() bool {6752 switch k {6853 case FmtNumber, FmtPercent, FmtScientific, FmtAccounting, FmtFinancial, FmtCurrency:6954 return true@@ -71,8 +56,8 @@ func (k FormatKind) hasDecimals() bool {
7156 return false7257 }7358 74-// isTime reports whether the kind shows a date, time or duration.75-func (k FormatKind) isTime() bool {59+// IsTime reports whether the kind shows a date, time or duration.60+func (k FormatKind) IsTime() bool {7661 return k >= FmtDate && k <= FmtDuration7762 }7863 @@ -93,7 +78,7 @@ const MaxDecimals = 15
9378 // Preset returns kind with Sheets' default decimals: two for the number9479 // kinds.9580 func Preset(k FormatKind) Format {96- if k.hasDecimals() {81+ if k.HasDecimals() {9782 return Format{Kind: k, Decimals: 2}9883 }9984 return Format{Kind: k}@@ -102,14 +87,14 @@ func Preset(k FormatKind) Format {
10287 // IsZero reports whether f is Automatic.10388 func (f Format) IsZero() bool { return f == Format{} }10489 105-// pattern returns the Sheets-style pattern f renders with, or "" for90+// Code returns the Sheets-style format pattern f renders with, or "" for10691 // Automatic and Plain text.107-func (f Format) pattern() string {92+func (f Format) Code() string {10893 dec := ""10994 if f.Decimals > 0 {11095 dec = "." + strings.Repeat("0", min(f.Decimals, MaxDecimals))11196 }112- if f.Pattern != "" && (f.Kind == FmtCustom || f.Kind.isTime()) {97+ if f.Pattern != "" && (f.Kind == FmtCustom || f.Kind.IsTime()) {11398 return f.Pattern11499 }115100 switch f.Kind {@@ -143,7 +128,7 @@ func (f Format) pattern() string {
143128 // Formats without decimals (dates, plain text) are returned unchanged.144129 func (f Format) WithDecimals(delta int, v float64) Format {145130 switch {146- case f.Kind.hasDecimals():131+ case f.Kind.HasDecimals():147132 f.Decimals = clampInt(f.Decimals+delta, 0, MaxDecimals)148133 case f.Kind == FmtAuto:149134 d := clampInt(visibleDecimals(v)+delta, 0, MaxDecimals)@@ -168,51 +153,3 @@ func visibleDecimals(v float64) int {
168153 }169154 170155 func clampInt(v, lo, hi int) int { return max(lo, min(v, hi)) }171-172-// Align is a cell's horizontal alignment.173-type Align uint8174-175-const (176- AlignAuto Align = iota // numbers right, text left, booleans and errors centered177- AlignLeft //178- AlignCenter //179- AlignRight //180- // AlignFill is only returned by Display: the text spans the cell181- // exactly and isn't padded (Accounting's $ at the left edge).182- AlignFill183-)184-185-var alignNames = [...]string{"", "left", "center", "right"}186-187-// String returns the alignment's name as stored in files.188-func (a Align) String() string {189- if int(a) < len(alignNames) {190- return alignNames[a]191- }192- return ""193-}194-195-// ParseAlign is the inverse of Align.String.196-func ParseAlign(s string) (Align, bool) {197- for i, n := range alignNames {198- if n == s {199- return Align(i), true200- }201- }202- return AlignAuto, false203-}204-205-// Style is a cell's text style. It is a plain value so cells can be206-// copied freely.207-type Style struct {208- Bold, Italic, Underline, Strikethrough bool209- Align Align210-211- // own marks a cell's format and style as wholly its own, not falling212- // back on its row's or column's even where they are Automatic or213- // plain; see lines.go.214- own bool215-}216-217-// IsZero reports whether s is the default style.218-func (s Style) IsZero() bool { return s == Style{} }@@ -0,0 +1,108 @@
1+package value2+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+ v, _, ok := parseNumberFormat(s)13+ return v, ok14+}15+16+// ParseValue recognizes everything Sheets turns into a number on entry:17+// numbers, currency, percentages, dates and times. It also returns the18+// format Sheets applies, e.g. Currency for "$1,200" or Date for19+// "9/26/2026"; plain numbers get Automatic.20+func ParseValue(s string) (float64, Format, bool) {21+ if v, f, ok := parseNumberFormat(s); ok {22+ return v, f, true23+ }24+ return ParseDateTime(s)25+}26+27+func parseNumberFormat(s string) (float64, Format, bool) {28+ s = strings.TrimSpace(s)29+ if s == "" {30+ return 0, Format{}, false31+ }32+ neg := false33+ if s[0] == '-' || s[0] == '+' {34+ neg = s[0] == '-'35+ s = s[1:]36+ }37+ cur := strings.HasPrefix(s, "$")38+ s = strings.TrimPrefix(s, "$")39+ pct := strings.HasSuffix(s, "%")40+ s = strings.TrimSuffix(s, "%")41+ if s == "" || !isDigit(s[0]) && s[0] != '.' {42+ return 0, Format{}, false43+ }44+ grouped := strings.Contains(s, ",")45+ if grouped {46+ if !validGrouping(s) {47+ return 0, Format{}, false48+ }49+ s = strings.ReplaceAll(s, ",", "")50+ }51+ v, err := strconv.ParseFloat(s, 64)52+ if err != nil || strings.ContainsAny(s, "xXpP_") || strings.EqualFold(s, "inf") {53+ return 0, Format{}, false54+ }55+ if pct {56+ // Shift the decimal point rather than divide, so "0.7%" is the57+ // double nearest 0.007 and not 0.006999999999999999.58+ if p, err := strconv.ParseFloat(s+"e-2", 64); err == nil {59+ v = p60+ } else {61+ v /= 10062+ }63+ }64+ if neg {65+ v = -v66+ }67+ // The format Sheets infers: two decimals if any were typed.68+ dec := 069+ if strings.Contains(s, ".") {70+ dec = 271+ }72+ var f Format73+ switch {74+ case cur:75+ f = Format{Kind: FmtCurrency, Decimals: dec}76+ case pct:77+ f = Format{Kind: FmtPercent, Decimals: dec}78+ case strings.ContainsAny(s, "eE"):79+ f = Preset(FmtScientific)80+ case grouped:81+ f = Format{Kind: FmtNumber, Decimals: dec}82+ }83+ return v, f, true84+}85+86+// validGrouping checks thousands separators: groups of three digits87+// before the decimal point.88+func validGrouping(s string) bool {89+ intPart, _, _ := strings.Cut(s, ".")90+ groups := strings.Split(intPart, ",")91+ if len(groups[0]) == 0 || len(groups[0]) > 3 {92+ return false93+ }94+ for _, g := range groups[1:] {95+ if len(g) != 3 {96+ return false97+ }98+ }99+ return true100+}101+102+func isLetter(c byte) bool {103+ return (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z')104+}105+106+func isDigit(c byte) bool {107+ return c >= '0' && c <= '9'108+}@@ -0,0 +1,140 @@
1+// Package value holds what a cell computes to and how it is shown: the2+// Value of a cell, its number Format, and the parsing of typed text into3+// numbers, dates and times. It sits below the function library4+// (internal/functions) and the engine (internal/sheet), which both work5+// in these terms; the engine's API names them too, so callers see one6+// package.7+package value8+9+import (10+ "math"11+ "strings"12+13+ "github.com/FelineStateMachine/012/internal/numfmt"14+)15+16+// Kind is the type of a computed cell value.17+type Kind int18+19+const (20+ Empty Kind = iota21+ Number22+ Text23+ Bool24+ Error25+)26+27+// Value is the computed contents of a cell.28+type Value struct {29+ Kind Kind30+ Num float64 // numbers, and booleans as 1 or 031+ Str string // text, or the error code such as #DIV/0!32+}33+34+// String renders a value the way a cell shows it in General format.35+func (v Value) String() string {36+ switch v.Kind {37+ case Number:38+ return numfmt.General(v.Num)39+ case Bool:40+ if v.Num != 0 {41+ return "TRUE"42+ }43+ return "FALSE"44+ }45+ return v.Str46+}47+48+// Error values, using Google Sheets codes.49+var (50+ ErrDiv0 = Value{Kind: Error, Str: "#DIV/0!"}51+ ErrValue = Value{Kind: Error, Str: "#VALUE!"}52+ ErrName = Value{Kind: Error, Str: "#NAME?"}53+ ErrNA = Value{Kind: Error, Str: "#N/A"}54+ ErrNum = Value{Kind: Error, Str: "#NUM!"}55+ ErrRef = Value{Kind: Error, Str: "#REF!"} // also circular references56+)57+58+// Num is the number v, or #NUM! for NaN and infinities.59+func Num(v float64) Value {60+ if math.IsNaN(v) || math.IsInf(v, 0) {61+ return ErrNum62+ }63+ return Value{Kind: Number, Num: v}64+}65+66+// Boolean is TRUE or FALSE.67+func Boolean(b bool) Value {68+ if b {69+ return Value{Kind: Bool, Num: 1}70+ }71+ return Value{Kind: Bool}72+}73+74+// Str is the text s.75+func Str(s string) Value { return Value{Kind: Text, Str: s} }76+77+// ErrOf returns a pointer to a copy of v, for the *Value error results of78+// argument helpers. Taking &v of a parameter directly would move it to79+// the heap on every call, erroneous or not: one allocation per cell read80+// by SUM over a range.81+func ErrOf(v Value) *Value { return &v }82+83+// ToNum coerces v for arithmetic as Sheets does: blanks are 0, booleans84+// 1 or 0, numeric text (including dates such as "2026-09-26") is its85+// number, other text is #VALUE!.86+func ToNum(v Value) (float64, *Value) {87+ switch v.Kind {88+ case Empty:89+ return 0, nil90+ case Number, Bool:91+ return v.Num, nil92+ case Text:93+ if n, _, ok := ParseValue(v.Str); ok {94+ return n, nil95+ }96+ return 0, &ErrValue97+ }98+ return 0, ErrOf(v)99+}100+101+// AsText converts a value for string concatenation.102+func AsText(v Value) string {103+ if v.Kind == Empty {104+ return ""105+ }106+ return v.String()107+}108+109+// Compare orders values like Sheets: numbers < text < booleans, text is110+// case-insensitive, and a blank equals 0 or "".111+func Compare(l, r Value) int {112+ rank := func(v Value) int {113+ switch v.Kind {114+ case Text:115+ return 1116+ case Bool:117+ return 2118+ }119+ return 0120+ }121+ if l.Kind == Empty && r.Kind == Text {122+ l = Value{Kind: Text}123+ }124+ if r.Kind == Empty && l.Kind == Text {125+ r = Value{Kind: Text}126+ }127+ if d := rank(l) - rank(r); d != 0 {128+ return d129+ }130+ if l.Kind == Text {131+ return strings.Compare(strings.ToUpper(l.Str), strings.ToUpper(r.Str))132+ }133+ switch {134+ case l.Num < r.Num:135+ return -1136+ case l.Num > r.Num:137+ return 1138+ }139+ return 0140+}