cat / 012
clone

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

Add text functions

Dami committed 1b1b376
CONCATENATE, CONCAT, TEXTJOIN, LEFT, RIGHT, MID, LEN, UPPER, LOWER,
PROPER, TRIM, SUBSTITUTE, REPLACE, FIND, SEARCH, TEXT, VALUE, REPT and
EXACT. TEXT uses the same pattern engine as cell formats.
commit
1b1b376f3aca34d44babdcae3cc2d8800977ad7b
parent
5bb28c9c7cddb2237b3a42aa3ee96275058a1968
author
Dami <dami.etoile@gmail.com>
2 files changed, +397 -0
Ainternal/sheet/functions_text.go335++++++++++++++++++++++++++++++
Ainternal/sheet/functions_text_test.go62++++++
A internal/sheet/functions_text.go +335 -0
@@ -0,0 +1,335 @@
1+package sheet2+3+import (4+ "strings"5+ "unicode"6+)7+8+// maxText is the longest text a function may build, as in Sheets.9+const maxText = 5000010+11+func init() {12+ define(13+ &FuncDef{Name: "CONCATENATE", Args: "string1, [string2, ...]", Desc: "Join text, including every cell of ranges", Min: 1, Max: -1,14+ eval: func(args []Node, get lookup) Value {15+ parts, err := texts(args, get)16+ if err != nil {17+ return *err18+ }19+ return str(strings.Join(parts, ""))20+ }},21+ &FuncDef{Name: "CONCAT", Args: "value1, value2", Desc: "Join two values as text", Min: 2, Max: 2,22+ eval: func(args []Node, get lookup) Value {23+ a, err := textArg(args[0], get)24+ if err != nil {25+ return *err26+ }27+ b, err := textArg(args[1], get)28+ if err != nil {29+ return *err30+ }31+ return str(a + b)32+ }},33+ &FuncDef{Name: "TEXTJOIN", Args: "delimiter, ignore_empty, text1, [text2, ...]", Desc: "Join text with a delimiter", Min: 3, Max: -1,34+ eval: func(args []Node, get lookup) Value {35+ delim, err := textArg(args[0], get)36+ if err != nil {37+ return *err38+ }39+ skip, err := boolArg(args, 1, true, get)40+ if err != nil {41+ return *err42+ }43+ parts, err := texts(args[2:], get)44+ if err != nil {45+ return *err46+ }47+ if skip {48+ parts = slicesDeleteEmpty(parts)49+ }50+ return capText(strings.Join(parts, delim))51+ }},52+ &FuncDef{Name: "LEFT", Args: "string, [number_of_characters]", Desc: "The first characters of text", Min: 1, Max: 2,53+ eval: textSlice(func(r []rune, n int) string { return string(r[:min(n, len(r))]) })},54+ &FuncDef{Name: "RIGHT", Args: "string, [number_of_characters]", Desc: "The last characters of text", Min: 1, Max: 2,55+ eval: textSlice(func(r []rune, n int) string { return string(r[len(r)-min(n, len(r)):]) })},56+ &FuncDef{Name: "MID", Args: "string, starting_at, extract_length", Desc: "Characters from the middle of text", Min: 3, Max: 3,57+ eval: func(args []Node, get lookup) Value {58+ s, err := textArg(args[0], get)59+ if err != nil {60+ return *err61+ }62+ start, err := intArg(args, 1, 1, get)63+ if err != nil {64+ return *err65+ }66+ n, err := intArg(args, 2, 0, get)67+ if err != nil {68+ return *err69+ }70+ if start < 1 || n < 0 {71+ return ErrValue72+ }73+ r := []rune(s)74+ if start > len(r) {75+ return str("")76+ }77+ return str(string(r[start-1 : min(start-1+n, len(r))]))78+ }},79+ &FuncDef{Name: "LEN", Args: "text", Desc: "Number of characters in text", Min: 1, Max: 1,80+ eval: textFn(func(s string) Value { return num(float64(runeLen(s))) })},81+ &FuncDef{Name: "UPPER", Args: "text", Desc: "Text in upper case", Min: 1, Max: 1,82+ eval: textFn(func(s string) Value { return str(strings.ToUpper(s)) })},83+ &FuncDef{Name: "LOWER", Args: "text", Desc: "Text in lower case", Min: 1, Max: 1,84+ eval: textFn(func(s string) Value { return str(strings.ToLower(s)) })},85+ &FuncDef{Name: "PROPER", Args: "text", Desc: "Text with each word capitalized", Min: 1, Max: 1,86+ eval: textFn(func(s string) Value { return str(proper(s)) })},87+ &FuncDef{Name: "TRIM", Args: "text", Desc: "Text without leading, trailing and repeated spaces", Min: 1, Max: 1,88+ eval: textFn(func(s string) Value {89+ return str(strings.Join(strings.FieldsFunc(s, func(r rune) bool { return r == ' ' }), " "))90+ })},91+ &FuncDef{Name: "SUBSTITUTE", Args: "text, search_for, replace_with, [occurrence_number]", Desc: "Replace occurrences of text", Min: 3, Max: 4,92+ eval: substitute},93+ &FuncDef{Name: "REPLACE", Args: "text, position, length, new_text", Desc: "Replace characters at a position", Min: 4, Max: 4,94+ eval: func(args []Node, get lookup) Value {95+ s, err := textArg(args[0], get)96+ if err != nil {97+ return *err98+ }99+ pos, err := intArg(args, 1, 1, get)100+ if err != nil {101+ return *err102+ }103+ n, err := intArg(args, 2, 0, get)104+ if err != nil {105+ return *err106+ }107+ repl, err := textArg(args[3], get)108+ if err != nil {109+ return *err110+ }111+ if pos < 1 || n < 0 {112+ return ErrValue113+ }114+ r := []rune(s)115+ from := min(pos-1, len(r))116+ to := min(from+n, len(r))117+ return capText(string(r[:from]) + repl + string(r[to:]))118+ }},119+ &FuncDef{Name: "FIND", Args: "search_for, text_to_search, [starting_at]", Desc: "Position of text, case-sensitive", Min: 2, Max: 3,120+ eval: finder(false)},121+ &FuncDef{Name: "SEARCH", Args: "search_for, text_to_search, [starting_at]", Desc: "Position of text, ignoring case, with * and ? wildcards", Min: 2, Max: 3,122+ eval: finder(true)},123+ &FuncDef{Name: "TEXT", Args: "number, format", Desc: `A number as text in a format, e.g. "$#,##0.00" or "yyyy-mm-dd"`, Min: 2, Max: 2,124+ eval: func(args []Node, get lookup) Value {125+ v := eval(args[0], get)126+ if v.Kind == Error {127+ return v128+ }129+ pat, err := textArg(args[1], get)130+ if err != nil {131+ return *err132+ }133+ if v.Kind == Text {134+ n, _, ok := ParseValue(v.Str)135+ if !ok {136+ return v137+ }138+ v = num(n)139+ }140+ return str(FormatPattern(v.Num, pat))141+ }},142+ &FuncDef{Name: "VALUE", Args: "text", Desc: "Text as a number; dates and times too", Min: 1, Max: 1,143+ eval: func(args []Node, get lookup) Value {144+ v := eval(args[0], get)145+ switch v.Kind {146+ case Error, Number:147+ return v148+ case Empty:149+ return num(0)150+ case Bool:151+ return ErrValue152+ }153+ if n, _, ok := ParseValue(strings.TrimSpace(v.Str)); ok {154+ return num(n)155+ }156+ if strings.TrimSpace(v.Str) == "" {157+ return num(0)158+ }159+ return ErrValue160+ }},161+ &FuncDef{Name: "REPT", Args: "text, number_of_repetitions", Desc: "Text repeated a number of times", Min: 2, Max: 2,162+ eval: func(args []Node, get lookup) Value {163+ s, err := textArg(args[0], get)164+ if err != nil {165+ return *err166+ }167+ n, err := intArg(args, 1, 0, get)168+ if err != nil {169+ return *err170+ }171+ if n < 0 || n*len(s) > maxText {172+ return ErrValue173+ }174+ return str(strings.Repeat(s, n))175+ }},176+ &FuncDef{Name: "EXACT", Args: "string1, string2", Desc: "TRUE if two texts are identical, case included", Min: 2, Max: 2,177+ eval: func(args []Node, get lookup) Value {178+ a, err := textArg(args[0], get)179+ if err != nil {180+ return *err181+ }182+ b, err := textArg(args[1], get)183+ if err != nil {184+ return *err185+ }186+ return boolean(a == b)187+ }},188+ )189+}190+191+func capText(s string) Value {192+ if len(s) > maxText {193+ return ErrValue194+ }195+ return str(s)196+}197+198+func slicesDeleteEmpty(parts []string) []string {199+ out := parts[:0]200+ for _, p := range parts {201+ if p != "" {202+ out = append(out, p)203+ }204+ }205+ return out206+}207+208+func textFn(f func(string) Value) func([]Node, lookup) Value {209+ return func(args []Node, get lookup) Value {210+ s, err := textArg(args[0], get)211+ if err != nil {212+ return *err213+ }214+ return f(s)215+ }216+}217+218+// textSlice builds LEFT and RIGHT: n defaults to 1, negative is #VALUE!.219+func textSlice(f func(r []rune, n int) string) func([]Node, lookup) Value {220+ return func(args []Node, get lookup) Value {221+ s, err := textArg(args[0], get)222+ if err != nil {223+ return *err224+ }225+ n, err := intArg(args, 1, 1, get)226+ if err != nil {227+ return *err228+ }229+ if n < 0 {230+ return ErrValue231+ }232+ return str(f([]rune(s), n))233+ }234+}235+236+// proper capitalizes the first letter of every run of letters, as Sheets237+// does: "it's" becomes "It'S".238+func proper(s string) string {239+ r := []rune(s)240+ prevLetter := false241+ for i, c := range r {242+ if prevLetter {243+ r[i] = unicode.ToLower(c)244+ } else {245+ r[i] = unicode.ToUpper(c)246+ }247+ prevLetter = unicode.IsLetter(c)248+ }249+ return string(r)250+}251+252+func substitute(args []Node, get lookup) Value {253+ s, err := textArg(args[0], get)254+ if err != nil {255+ return *err256+ }257+ old, err := textArg(args[1], get)258+ if err != nil {259+ return *err260+ }261+ repl, err := textArg(args[2], get)262+ if err != nil {263+ return *err264+ }265+ if len(args) < 4 {266+ if old == "" {267+ return str(s)268+ }269+ return capText(strings.ReplaceAll(s, old, repl))270+ }271+ nth, err := intArg(args, 3, 0, get)272+ switch {273+ case err != nil:274+ return *err275+ case nth < 1:276+ return ErrValue277+ case old == "":278+ return str(s)279+ }280+ idx := 0281+ for i := 1; ; i++ {282+ j := strings.Index(s[idx:], old)283+ if j < 0 {284+ return str(s)285+ }286+ idx += j287+ if i == nth {288+ return capText(s[:idx] + repl + s[idx+len(old):])289+ }290+ idx += len(old)291+ }292+}293+294+// finder builds FIND (exact) and SEARCH (case-insensitive, wildcards).295+// Positions count characters from 1; not found is #VALUE!.296+func finder(search bool) func([]Node, lookup) Value {297+ return func(args []Node, get lookup) Value {298+ needle, err := textArg(args[0], get)299+ if err != nil {300+ return *err301+ }302+ hay, err := textArg(args[1], get)303+ if err != nil {304+ return *err305+ }306+ start, err := intArg(args, 2, 1, get)307+ if err != nil {308+ return *err309+ }310+ r := []rune(hay)311+ if start < 1 || start > len(r)+1 {312+ return ErrValue313+ }314+ if needle == "" {315+ return num(float64(start))316+ }317+ for i := start - 1; i < len(r); i++ {318+ rest := string(r[i:])319+ switch {320+ case !search && strings.HasPrefix(rest, needle):321+ return num(float64(i + 1))322+ case search && hasWildcards(needle) && prefixWild(needle, r[i:]):323+ return num(float64(i + 1))324+ case search && !hasWildcards(needle) && strings.HasPrefix(strings.ToLower(rest), strings.ToLower(needle)):325+ return num(float64(i + 1))326+ }327+ }328+ return ErrValue329+ }330+}331+332+// prefixWild reports whether some prefix of r matches the pattern.333+func prefixWild(pattern string, r []rune) bool {334+ return wildMatch(pattern+"*", string(r))335+}
A internal/sheet/functions_text_test.go +62 -0
@@ -0,0 +1,62 @@
1+package sheet2+3+import "testing"4+5+func TestTextFunctions(t *testing.T) {6+ checkFormulas(t, fixture(t), []fnCase{7+ {`=CONCATENATE("a", 1, TRUE)`, txt("a1TRUE")},8+ {"=CONCATENATE(D1:D3)", txt("AppleBananaCherry")},9+ {`=CONCAT(A1, "x")`, txt("10x")},10+ {`=TEXTJOIN(", ", TRUE, A3:A5)`, txt("30, apple")},11+ {`=TEXTJOIN("-", FALSE, A3:A5)`, txt("30-apple-")},12+ {`=LEFT("hello", 2)`, txt("he")},13+ {`=LEFT("hello")`, txt("h")},14+ {`=LEFT("hi", 10)`, txt("hi")},15+ {`=LEFT("hi", -1)`, ErrValue},16+ {`=RIGHT("hello", 3)`, txt("llo")},17+ {`=MID("spreadsheet", 7, 5)`, txt("sheet")},18+ {`=MID("abc", 5, 2)`, txt("")},19+ {`=MID("abc", 0, 2)`, ErrValue},20+ {`=LEN("héllo")`, num(5)},21+ {"=LEN(A1)", num(2)},22+ {`=UPPER("abc")`, txt("ABC")},23+ {`=LOWER("ABC")`, txt("abc")},24+ {`=PROPER("hello wORLD it's")`, txt("Hello World It'S")},25+ {`=TRIM(" a b ")`, txt("a b")},26+ {`=SUBSTITUTE("a-b-c", "-", "+")`, txt("a+b+c")},27+ {`=SUBSTITUTE("a-b-c", "-", "+", 2)`, txt("a-b+c")},28+ {`=SUBSTITUTE("a-b-c", "-", "+", 5)`, txt("a-b-c")},29+ {`=SUBSTITUTE("abc", "", "x")`, txt("abc")},30+ {`=SUBSTITUTE("abc", "b", "x", 0)`, ErrValue},31+ {`=REPLACE("abcdef", 2, 3, "X")`, txt("aXef")},32+ {`=REPLACE("abc", 10, 1, "X")`, txt("abcX")},33+ {`=REPLACE("abc", 0, 1, "X")`, ErrValue},34+ {`=FIND("b", "abcb")`, num(2)},35+ {`=FIND("b", "abcb", 3)`, num(4)},36+ {`=FIND("B", "abc")`, ErrValue}, // case-sensitive37+ {`=FIND("", "abc")`, num(1)},38+ {`=FIND("a", "abc", 5)`, ErrValue},39+ {`=SEARCH("B", "abc")`, num(2)},40+ {`=SEARCH("b?d", "abcbxd")`, num(4)},41+ {`=SEARCH("c*", "abc")`, num(3)},42+ {`=SEARCH("z", "abc")`, ErrValue},43+ {`=TEXT(1234.567, "$#,##0.00")`, txt("$1,234.57")},44+ {`=TEXT(0.256, "0.0%")`, txt("25.6%")},45+ {`=TEXT(F1, "yyyy-mm-dd")`, txt("2026-09-26")},46+ {`=TEXT(F1, "dddd")`, txt("Saturday")},47+ {`=TEXT(F3, "h:mm AM/PM")`, txt("2:30 PM")},48+ {`=TEXT("12", "000")`, txt("012")},49+ {`=TEXT("abc", "000")`, txt("abc")},50+ {`=VALUE("$1,200")`, num(1200)},51+ {`=VALUE("12%")`, num(0.12)},52+ {`=VALUE("2026-09-26")`, num(46291)},53+ {`=VALUE("")`, num(0)},54+ {`=VALUE("abc")`, ErrValue},55+ {"=VALUE(A1)", num(10)},56+ {`=REPT("ab", 3)`, txt("ababab")},57+ {`=REPT("ab", -1)`, ErrValue},58+ {`=EXACT("a", "A")`, boolean(false)},59+ {`=EXACT("a", "a")`, boolean(true)},60+ {`=EXACT(10, "10")`, boolean(true)},61+ })62+}