cat / 012
clone

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

Add SPLIT, REGEXMATCH, REGEXEXTRACT and REGEXREPLACE

Dami committed 6c72003
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>
4 files changed, +277 -1
Mdocs/functions.md11+-
Minternal/functions/doc_test.go1+
Ainternal/functions/regex.go238++++++++++++++++++++++++++++++
Ainternal/sheet/functions_regex_test.go28++++
M docs/functions.md +10 -1
M internal/functions/doc_test.go +1 -0
A internal/functions/regex.go +238 -0
@@ -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+}
A internal/sheet/functions_regex_test.go +28 -0