oficina / sheets Public

calc: text family -- CONCAT, LEFT, RIGHT, MID, LEN, TRIM, UPPER, LOWER

8 functions, plus 12 corpus rows and an updated corpusSHA256.

LEFT/RIGHT/MID/LEN operate on runes, not bytes, matching Excel's own
"character," not "byte," unit. TRIM collapses whitespace via
strings.Fields, slightly broader than Excel's ASCII-space-only TRIM --
flagged in the doc comment as a known, deliberate divergence rather
than left for someone to discover silently.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01APtNStXDZ5H2fAc1ZuGqtS
Paul Campbell b25a356a651bc2124bc1dc8ec36088653c00c62a parent 2c20146
4 files changed, +268 −2
  • internal/sheets/calc/corpus_test.go +1 −1
  • internal/sheets/calc/fn_text.go +166 −0
  • internal/sheets/calc/fn_text_test.go +88 −0
  • internal/sheets/calc/testdata/corpus/formulas.json +13 −1
diff --git a/internal/sheets/calc/corpus_test.go b/internal/sheets/calc/corpus_test.go
index 6f9b440..1bf4b89 100644
--- a/internal/sheets/calc/corpus_test.go
+++ b/internal/sheets/calc/corpus_test.go
@@ -21,7 +21,7 @@ import (
// without also changing the file (or vice versa) is exactly the silent
// drift this test exists to catch — a corpus change is a deliberate act
// with a regenerated hash, never a side effect of an unrelated edit.
-const corpusSHA256 = "617ece1e4b0fc83dd9934d572b4b961fa650053d78ac1cf7ba88e1c109ecc1b5"
+const corpusSHA256 = "f55a322e0aea03a0a48c28663e67995b1b683c2c059914e04c8b1f9329f41757"
// corpusValue is formulas.json's wire format for a Value: kind names one
// of "empty", "num", "str", "bool", "err", and only the matching field is
diff --git a/internal/sheets/calc/fn_text.go b/internal/sheets/calc/fn_text.go
new file mode 100644
index 0000000..d5cbb46
--- /dev/null
+++ b/internal/sheets/calc/fn_text.go
@@ -0,0 +1,166 @@
+package calc
+
+import "strings"
+
+func init() {
+ Register("CONCAT", fnConcat)
+ Register("LEFT", fnLeft)
+ Register("RIGHT", fnRight)
+ Register("MID", fnMid)
+ Register("LEN", fnLen)
+ Register("TRIM", fnTrim)
+ Register("UPPER", fnUpper)
+ Register("LOWER", fnLower)
+}
+
+func fnConcat(args []Value) Value {
+ var sb strings.Builder
+ for _, a := range args {
+ if a.Kind == KindErr {
+ return a
+ }
+ sb.WriteString(toStr(a))
+ }
+ return Value{Kind: KindStr, Str: sb.String()}
+}
+
+// textAndCount pulls the (text, count) shape shared by LEFT and RIGHT:
+// text is required, count defaults to defaultCount and, when given, must
+// coerce to a non-negative integer.
+func textAndCount(args []Value, defaultCount int) (text string, count int, errv Value, ok bool) {
+ if len(args) < 1 || len(args) > 2 {
+ return "", 0, errValue(ErrValue), false
+ }
+ if args[0].Kind == KindErr {
+ return "", 0, args[0], false
+ }
+ text = toStr(args[0])
+ count = defaultCount
+ if len(args) == 2 {
+ if args[1].Kind == KindErr {
+ return "", 0, args[1], false
+ }
+ n, ek := toNumber(args[1])
+ if ek != ErrNone {
+ return "", 0, errValue(ek), false
+ }
+ count = int(n)
+ }
+ if count < 0 {
+ return "", 0, errValue(ErrValue), false
+ }
+ return text, count, Value{}, true
+}
+
+// fnLeft and fnRight operate on runes, not bytes, so a multi-byte
+// character counts as one character the way Excel's own LEN/LEFT/RIGHT
+// do (their unit is "character," not "byte").
+func fnLeft(args []Value) Value {
+ text, n, errv, ok := textAndCount(args, 1)
+ if !ok {
+ return errv
+ }
+ r := []rune(text)
+ if n > len(r) {
+ n = len(r)
+ }
+ return Value{Kind: KindStr, Str: string(r[:n])}
+}
+
+func fnRight(args []Value) Value {
+ text, n, errv, ok := textAndCount(args, 1)
+ if !ok {
+ return errv
+ }
+ r := []rune(text)
+ if n > len(r) {
+ n = len(r)
+ }
+ return Value{Kind: KindStr, Str: string(r[len(r)-n:])}
+}
+
+// fnMid implements MID(text, start_num, num_chars): start_num is
+// 1-based, per Excel; a start past the end of text returns "" rather
+// than an error, matching Excel.
+func fnMid(args []Value) Value {
+ if len(args) != 3 {
+ return errValue(ErrValue)
+ }
+ if args[0].Kind == KindErr {
+ return args[0]
+ }
+ if args[1].Kind == KindErr {
+ return args[1]
+ }
+ if args[2].Kind == KindErr {
+ return args[2]
+ }
+ text := toStr(args[0])
+ startF, ek := toNumber(args[1])
+ if ek != ErrNone {
+ return errValue(ek)
+ }
+ countF, ek := toNumber(args[2])
+ if ek != ErrNone {
+ return errValue(ek)
+ }
+ start, count := int(startF), int(countF)
+ if start < 1 || count < 0 {
+ return errValue(ErrValue)
+ }
+ r := []rune(text)
+ if start > len(r) {
+ return Value{Kind: KindStr, Str: ""}
+ }
+ end := start - 1 + count
+ if end > len(r) {
+ end = len(r)
+ }
+ return Value{Kind: KindStr, Str: string(r[start-1 : end])}
+}
+
+func fnLen(args []Value) Value {
+ if len(args) != 1 {
+ return errValue(ErrValue)
+ }
+ if args[0].Kind == KindErr {
+ return args[0]
+ }
+ return Value{Kind: KindNum, Num: float64(len([]rune(toStr(args[0]))))}
+}
+
+// fnTrim collapses runs of whitespace to a single space and strips
+// leading/trailing whitespace, via strings.Fields — slightly broader
+// than Excel's own TRIM (which targets the plain ASCII space character
+// specifically, not tabs or newlines); close enough for this task's
+// scope, and flagged here rather than left to be discovered as a silent
+// divergence.
+func fnTrim(args []Value) Value {
+ if len(args) != 1 {
+ return errValue(ErrValue)
+ }
+ if args[0].Kind == KindErr {
+ return args[0]
+ }
+ return Value{Kind: KindStr, Str: strings.Join(strings.Fields(toStr(args[0])), " ")}
+}
+
+func fnUpper(args []Value) Value {
+ if len(args) != 1 {
+ return errValue(ErrValue)
+ }
+ if args[0].Kind == KindErr {
+ return args[0]
+ }
+ return Value{Kind: KindStr, Str: strings.ToUpper(toStr(args[0]))}
+}
+
+func fnLower(args []Value) Value {
+ if len(args) != 1 {
+ return errValue(ErrValue)
+ }
+ if args[0].Kind == KindErr {
+ return args[0]
+ }
+ return Value{Kind: KindStr, Str: strings.ToLower(toStr(args[0]))}
+}
diff --git a/internal/sheets/calc/fn_text_test.go b/internal/sheets/calc/fn_text_test.go
new file mode 100644
index 0000000..4206b6e
--- /dev/null
+++ b/internal/sheets/calc/fn_text_test.go
@@ -0,0 +1,88 @@
+package calc
+
+import "testing"
+
+func evalStr(t *testing.T, src string, env Env) string {
+ t.Helper()
+ v := Eval(mustParse(t, src), env)
+ if v.Kind != KindStr {
+ t.Fatalf("result Kind = %v, want KindStr (value %+v)", v.Kind, v)
+ }
+ return v.Str
+}
+
+func TestConcat(t *testing.T) {
+ if got := evalStr(t, `CONCAT("foo","bar")`, emptyEnv{}); got != "foobar" {
+ t.Errorf("CONCAT = %q, want %q", got, "foobar")
+ }
+ if got := evalStr(t, `CONCAT("a",1,"b")`, emptyEnv{}); got != "a1b" {
+ t.Errorf("CONCAT with number = %q, want %q", got, "a1b")
+ }
+}
+
+func TestLeftRight(t *testing.T) {
+ if got := evalStr(t, `LEFT("hello",3)`, emptyEnv{}); got != "hel" {
+ t.Errorf("LEFT = %q, want %q", got, "hel")
+ }
+ if got := evalStr(t, `LEFT("hello")`, emptyEnv{}); got != "h" {
+ t.Errorf("LEFT default = %q, want %q", got, "h")
+ }
+ if got := evalStr(t, `RIGHT("hello",3)`, emptyEnv{}); got != "llo" {
+ t.Errorf("RIGHT = %q, want %q", got, "llo")
+ }
+}
+
+func TestLeftLongerThanTextReturnsWholeText(t *testing.T) {
+ if got := evalStr(t, `LEFT("hi",10)`, emptyEnv{}); got != "hi" {
+ t.Errorf("LEFT overrun = %q, want %q", got, "hi")
+ }
+}
+
+func TestMid(t *testing.T) {
+ if got := evalStr(t, `MID("hello",2,3)`, emptyEnv{}); got != "ell" {
+ t.Errorf("MID = %q, want %q", got, "ell")
+ }
+}
+
+func TestMidStartPastEndIsEmptyNotError(t *testing.T) {
+ if got := evalStr(t, `MID("hi",10,3)`, emptyEnv{}); got != "" {
+ t.Errorf("MID past end = %q, want empty string", got)
+ }
+}
+
+func TestLen(t *testing.T) {
+ v := Eval(mustParse(t, `LEN("hello")`), emptyEnv{})
+ if v.Kind != KindNum || v.Num != 5 {
+ t.Fatalf(`LEN("hello") = %v, want 5`, v)
+ }
+}
+
+func TestTrim(t *testing.T) {
+ if got := evalStr(t, `TRIM(" a b ")`, emptyEnv{}); got != "a b" {
+ t.Errorf("TRIM = %q, want %q", got, "a b")
+ }
+}
+
+func TestUpperLower(t *testing.T) {
+ if got := evalStr(t, `UPPER("MixedCase")`, emptyEnv{}); got != "MIXEDCASE" {
+ t.Errorf("UPPER = %q, want %q", got, "MIXEDCASE")
+ }
+ if got := evalStr(t, `LOWER("MixedCase")`, emptyEnv{}); got != "mixedcase" {
+ t.Errorf("LOWER = %q, want %q", got, "mixedcase")
+ }
+}
+
+func TestConcatPropagatesError(t *testing.T) {
+ env := mapEnv{{1, 1}: {Kind: KindErr, Err: ErrRef}}
+ v := Eval(mustParse(t, `CONCAT(A1,"x")`), env)
+ if v.Kind != KindErr || v.Err != ErrRef {
+ t.Fatalf(`CONCAT(A1,"x") = %v, want #REF!`, v)
+ }
+}
+
+func TestLeftNegativeCountIsValueError(t *testing.T) {
+ v := Eval(mustParse(t, `LEFT("hi",-1)`), emptyEnv{})
+ if v.Kind != KindErr || v.Err != ErrValue {
+ t.Fatalf(`LEFT("hi",-1) = %v, want #VALUE!`, v)
+ }
+}
diff --git a/internal/sheets/calc/testdata/corpus/formulas.json b/internal/sheets/calc/testdata/corpus/formulas.json
index be07186..6f231d4 100644
--- a/internal/sheets/calc/testdata/corpus/formulas.json
+++ b/internal/sheets/calc/testdata/corpus/formulas.json
@@ -38,6 +38,18 @@
{"function": "NOT", "expr": "NOT(TRUE)", "want": {"kind": "bool", "bool": false}},
{"function": "NOT", "expr": "NOT(FALSE)", "want": {"kind": "bool", "bool": true}},
{"function": "IFERROR", "expr": "IFERROR(1/0,\"fallback\")", "want": {"kind": "str", "str": "fallback"}},
- {"function": "IFERROR", "expr": "IFERROR(5,\"fallback\")", "want": {"kind": "num", "num": 5}}
+ {"function": "IFERROR", "expr": "IFERROR(5,\"fallback\")", "want": {"kind": "num", "num": 5}},
+ {"function": "CONCAT", "expr": "CONCAT(\"foo\",\"bar\")", "want": {"kind": "str", "str": "foobar"}},
+ {"function": "CONCAT", "expr": "CONCAT(\"a\",1,\"b\")", "want": {"kind": "str", "str": "a1b"}},
+ {"function": "LEFT", "expr": "LEFT(\"hello\",3)", "want": {"kind": "str", "str": "hel"}},
+ {"function": "LEFT", "expr": "LEFT(\"hello\")", "want": {"kind": "str", "str": "h"}},
+ {"function": "RIGHT", "expr": "RIGHT(\"hello\",3)", "want": {"kind": "str", "str": "llo"}},
+ {"function": "RIGHT", "expr": "RIGHT(\"hello\")", "want": {"kind": "str", "str": "o"}},
+ {"function": "MID", "expr": "MID(\"hello\",2,3)", "want": {"kind": "str", "str": "ell"}},
+ {"function": "LEN", "expr": "LEN(\"hello\")", "want": {"kind": "num", "num": 5}},
+ {"function": "LEN", "expr": "LEN(\"\")", "want": {"kind": "num", "num": 0}},
+ {"function": "TRIM", "expr": "TRIM(\" a b \")", "want": {"kind": "str", "str": "a b"}},
+ {"function": "UPPER", "expr": "UPPER(\"MixedCase\")", "want": {"kind": "str", "str": "MIXEDCASE"}},
+ {"function": "LOWER", "expr": "LOWER(\"MixedCase\")", "want": {"kind": "str", "str": "mixedcase"}}
]
}