SPLIT spills the pieces across, as numbers where they read as numbers, and splits each entry into a row of its own in an array context. The REGEX functions take text only, as Sheets does, and use Go's regexp, which is RE2 as Sheets' is; REGEXEXTRACT with several capture groups spills them across. Compiled patterns are kept in a small shared cache, since a column or an ARRAYFORMULA uses one pattern for every row. Co-Authored-By: Claude <noreply@anthropic.com>
- commit
- 6c720034b15ba0f4de87c96b9fe1e470961a9cc8
- parent
- fdaa8e901b66f43263029f888b46398a15973d64
- author
- Dami <dami.etoile@gmail.com>
| M | docs/ | 11 | +- |
| M | internal/ | 1 | + |
| A | internal/ | 238 | ++++++++++++++++++++++++++++++ |
| A | internal/ | 28 | ++++ |
@@ -2,7 +2,7 @@
22 33 <!-- Generated from the engine's function table by TestFunctionsDoc; do not edit. -->44 5-012 has 138 functions. They follow Google Sheets' names, arguments and5+012 has 142 functions. They follow Google Sheets' names, arguments and66 semantics; `[brackets]` mark optional arguments. Function names are77 case-insensitive, and 1-2-3's `@SUM(A1..A5)` spelling still works.88 @@ -115,6 +115,15 @@ Aliases: `AVG` for `AVERAGE`.
115115 | `UPPER(text)` | Text in upper case |116116 | `VALUE(text)` | Text as a number; dates and times too |117117 118+## Split and regular expressions119+120+| Function | Description |121+|---|---|122+| `REGEXEXTRACT(text, regular_expression)` | The first match of a regular expression, or its capture groups across |123+| `REGEXMATCH(text, regular_expression)` | TRUE if text matches a regular expression |124+| `REGEXREPLACE(text, regular_expression, replacement)` | Text with every match replaced; $1 in the replacement is a capture group |125+| `SPLIT(text, delimiter, [split_by_each], [remove_empty_text])` | Text split at a delimiter into cells across (each character of it by default) |126+118127 ## Lookup119128 120129 | Function | Description |@@ -27,6 +27,7 @@ var categories = []struct{ file, title string }{
2727 {"stats.go", "Statistics"},2828 {"logic.go", "Logic and information"},2929 {"text.go", "Text"},30+ {"regex.go", "Split and regular expressions"},3031 {"lookup.go", "Lookup"},3132 {"dynamic.go", "Arrays"},3233 {"lambda.go", "LET and LAMBDA"},@@ -0,0 +1,238 @@
1+package functions2+3+import (4+ "regexp"5+ "strings"6+ "sync"7+8+ "github.com/FelineStateMachine/012/internal/value"9+)10+11+// Splitting text and regular expressions. Sheets' regular expressions12+// are RE2, as Go's regexp package is, so patterns mean the same.13+14+func init() {15+ define(16+ &FuncDef{Name: "SPLIT", Args: "text, delimiter, [split_by_each], [remove_empty_text]", Desc: "Text split at a delimiter into cells across (each character of it by default)", Min: 2, Max: 4,17+ eval: split, arrays: takesArrays},18+ &FuncDef{Name: "REGEXMATCH", Args: "text, regular_expression", Desc: "TRUE if text matches a regular expression", Min: 2, Max: 2,19+ eval: regexMatch},20+ &FuncDef{Name: "REGEXEXTRACT", Args: "text, regular_expression", Desc: "The first match of a regular expression, or its capture groups across", Min: 2, Max: 2,21+ eval: regexExtract, arrays: liftPass},22+ &FuncDef{Name: "REGEXREPLACE", Args: "text, regular_expression, replacement", Desc: "Text with every match replaced; $1 in the replacement is a capture group", Min: 3, Max: 3,23+ eval: regexReplace},24+ )25+}26+27+// Evaluators for the table above, in its order.28+29+// split splits text at a delimiter: at each of its characters unless30+// split_by_each is FALSE, leaving out empty pieces unless31+// remove_empty_text is FALSE. Pieces that read as numbers are numbers,32+// as in Sheets. In an array context each entry of text is split into a33+// row of its own.34+func split(args []Node, get lookup) Value {35+ delim, err := textArg(args[1], get)36+ if err != nil {37+ return *err38+ }39+ each, err := boolArg(args, 2, true, get)40+ if err != nil {41+ return *err42+ }43+ drop, err := boolArg(args, 3, true, get)44+ if err != nil {45+ return *err46+ }47+ if delim == "" {48+ return value.ErrValue49+ }50+ cut := func(s string) []Value { return splitText(s, delim, each, drop) }51+ if get.lift == 0 {52+ s, err := textArg(args[0], get)53+ if err != nil {54+ return *err55+ }56+ row := cut(s)57+ out := NewArray(1, len(row))58+ copy(out.V, row)59+ return get.arrayValue(out)60+ }61+ texts, err := arrayArg(args[0], get)62+ if err != nil {63+ return *err64+ }65+ return get.arrayValue(splitRows(texts, cut))66+}67+68+// splitRows splits each entry of texts (row by row) into a row of its69+// own, the rows as wide as the widest, blank past their pieces.70+func splitRows(texts *Array, cut func(string) []Value) *Array {71+ var rows [][]Value72+ width := 073+ for r := range texts.DRows {74+ for c := range texts.DCols {75+ v := texts.At(r, c)76+ var row []Value77+ if v.Kind == value.Error {78+ row = []Value{v}79+ } else if v.Kind != value.Empty {80+ row = cut(text(v))81+ }82+ rows = append(rows, row)83+ width = max(width, len(row))84+ }85+ }86+ width = max(width, 1)87+ if tooBig(len(rows), width) {88+ return &Array{Rows: 1, Cols: 1, DRows: 1, DCols: 1, V: []Value{value.ErrValue}}89+ }90+ out := NewArray(max(len(rows), 1), width)91+ for i, row := range rows {92+ copy(out.V[i*width:], row)93+ }94+ return out95+}96+97+// splitText is the pieces of s between delimiters, as values.98+func splitText(s, delim string, each, drop bool) []Value {99+ var parts []string100+ switch {101+ case each && drop:102+ parts = strings.FieldsFunc(s, func(r rune) bool { return strings.ContainsRune(delim, r) })103+ case each:104+ parts = splitEach(s, delim)105+ default:106+ parts = strings.Split(s, delim)107+ }108+ out := make([]Value, 0, len(parts))109+ for _, p := range parts {110+ if p == "" && drop {111+ continue112+ }113+ if n, _, ok := value.ParseValue(p); ok && strings.TrimSpace(p) == p {114+ out = append(out, num(n))115+ continue116+ }117+ out = append(out, str(p))118+ }119+ if len(out) == 0 {120+ out = append(out, Value{})121+ }122+ return out123+}124+125+// splitEach splits s at every character of delim, keeping empty pieces.126+func splitEach(s, delim string) []string {127+ var parts []string128+ start := 0129+ for i, r := range s {130+ if strings.ContainsRune(delim, r) {131+ parts = append(parts, s[start:i])132+ start = i + len(string(r))133+ }134+ }135+ return append(parts, s[start:])136+}137+138+func regexMatch(args []Node, get lookup) Value {139+ s, re, err := regexArgs(args, get)140+ if err != nil {141+ return *err142+ }143+ return boolean(re.MatchString(s))144+}145+146+func regexExtract(args []Node, get lookup) Value {147+ s, re, err := regexArgs(args, get)148+ if err != nil {149+ return *err150+ }151+ m := re.FindStringSubmatch(s)152+ switch {153+ case m == nil:154+ return value.ErrNA155+ case len(m) == 1:156+ return str(m[0])157+ case len(m) == 2:158+ return str(m[1])159+ }160+ out := NewArray(1, len(m)-1)161+ for i, g := range m[1:] {162+ out.V[i] = str(g)163+ }164+ return get.arrayValue(out)165+}166+167+func regexReplace(args []Node, get lookup) Value {168+ s, re, err := regexArgs(args, get)169+ if err != nil {170+ return *err171+ }172+ with, err := regexText(args[2], get)173+ if err != nil {174+ return *err175+ }176+ return capText(re.ReplaceAllString(s, with))177+}178+179+// regexArgs reads the text and the regular expression of a REGEX180+// function: text only, as Sheets takes (a number is #VALUE!), and a181+// pattern that compiles.182+func regexArgs(args []Node, get lookup) (string, *regexp.Regexp, *Value) {183+ s, err := regexText(args[0], get)184+ if err != nil {185+ return "", nil, err186+ }187+ pattern, err := textArg(args[1], get)188+ if err != nil {189+ return "", nil, err190+ }191+ re, ok := compiled(pattern)192+ if !ok {193+ return "", nil, &value.ErrValue194+ }195+ return s, re, nil196+}197+198+// regexText reads an argument that must be text.199+func regexText(n Node, get lookup) (string, *Value) {200+ v := eval1(n, get)201+ switch v.Kind {202+ case value.Error:203+ return "", errOf(v)204+ case value.Text, value.Empty:205+ return v.Str, nil206+ }207+ return "", &value.ErrValue208+}209+210+// regexCache keeps compiled patterns, as a column of REGEXMATCH or an211+// ARRAYFORMULA of one uses the same pattern for every row. It is shared212+// by every workbook (and every session of 012 serve), so it's locked,213+// and emptied when full.214+var regexCache = struct {215+ sync.Mutex216+ m map[string]*regexp.Regexp217+}{m: map[string]*regexp.Regexp{}}218+219+const maxRegexCache = 256220+221+// compiled is pattern compiled, or false when it isn't a valid RE2222+// pattern.223+func compiled(pattern string) (*regexp.Regexp, bool) {224+ regexCache.Lock()225+ defer regexCache.Unlock()226+ if re, ok := regexCache.m[pattern]; ok {227+ return re, re != nil228+ }229+ re, err := regexp.Compile(pattern)230+ if err != nil {231+ re = nil232+ }233+ if len(regexCache.m) >= maxRegexCache {234+ clear(regexCache.m)235+ }236+ regexCache.m[pattern] = re237+ return re, re != nil238+}@@ -0,0 +1,28 @@
1+package sheet2+3+import "testing"4+5+func TestSplitAndRegex(t *testing.T) {6+ checkArrays(t, fixture(t), []arrCase{7+ {`=SPLIT("a,b,,c", ",")`, []string{"a|b|c"}},8+ {`=SPLIT("a,b,,c", ",", TRUE, FALSE)`, []string{"a|b||c"}},9+ {`=SPLIT("a-b c", "- ")`, []string{"a|b|c"}},10+ {`=SPLIT("a--b", "--", FALSE)`, []string{"a|b"}},11+ {`=SPLIT("1,2", ",")`, []string{"1|2"}},12+ {`=SUM(SPLIT("1,2", ","))`, []string{"3"}},13+ {`=SPLIT("abc", "")`, []string{"#VALUE!"}},14+ {`=ARRAYFORMULA(SPLIT({"a b";"c d e"}, " "))`, []string{"a|b|", "c|d|e"}},15+ {`=REGEXMATCH("order 42", "[0-9]+")`, []string{"TRUE"}},16+ {`=REGEXMATCH("order", "^[0-9]+$")`, []string{"FALSE"}},17+ {`=REGEXMATCH(42, "4")`, []string{"#VALUE!"}},18+ {`=REGEXMATCH("a", "(")`, []string{"#VALUE!"}},19+ {`=REGEXEXTRACT("order 42", "[0-9]+")`, []string{"42"}},20+ {`=REGEXEXTRACT("key=val", "(\w+)=(\w+)")`, []string{"key|val"}},21+ {`=REGEXEXTRACT("key=val", "=(\w+)")`, []string{"val"}},22+ {`=REGEXEXTRACT("abc", "[0-9]")`, []string{"#N/A"}},23+ {`=REGEXREPLACE("2026-09-27", "(\d+)-(\d+)-(\d+)", "$3/$2/$1")`, []string{"27/09/2026"}},24+ {`=REGEXREPLACE("a b", " +", " ")`, []string{"a b"}},25+ {`=ARRAYFORMULA(REGEXMATCH(D1:D3, "an"))`, []string{"FALSE", "TRUE", "FALSE"}},26+ {`=FILTER(D1:D5, REGEXMATCH(D1:D5, "^A"))`, []string{"Apple", "Apple"}},27+ })28+}