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>
| A | internal/ | 335 | ++++++++++++++++++++++++++++++ |
| A | internal/ | 62 | ++++++ |
@@ -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+}@@ -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+}