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
4 files changed,
+268
−2
internal/sheets/calc/corpus_test.go+1 −1internal/sheets/calc/fn_text.go+166 −0internal/sheets/calc/fn_text_test.go+88 −0internal/sheets/calc/testdata/corpus/formulas.json+13 −1
Intent
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"}} |
| ] |
| } |