feat: add text/textvars for cutting preceding character when var is empty

This commit is contained in:
AJ ONeal 2026-01-25 19:04:44 -07:00
parent 160e26623b
commit 65b6438f6d
No known key found for this signature in database
4 changed files with 141 additions and 76 deletions

View File

@ -1,76 +0,0 @@
package smstmpl
import (
"fmt"
"maps"
"regexp"
"slices"
"strings"
"github.com/therootcompany/golib/net/smsgw/smscsv"
)
var reUnmatchedVars = regexp.MustCompile(`(\{[^}]+\})`)
func RenderAll(messages []smscsv.Message) ([]smscsv.Message, error) {
for i, message := range messages {
rowIndex := i + 1
message.SetText(ReplaceVar(message.Template(), "Name", message.Name()))
keyIter := maps.Keys(message.Vars)
keys := slices.Sorted(keyIter)
for _, key := range keys {
val := message.Vars[key]
message.SetText(ReplaceVar(message.Text(), key, val))
}
if tmpls := reUnmatchedVars.FindAllString(message.Text(), -1); len(tmpls) != 0 {
return nil, &smscsv.CSVWarn{
Index: rowIndex,
Code: "UnmatchedVars",
Message: fmt.Sprintf(
"failing due to row %d (%s): leftover template variable(s): %s",
rowIndex, message.Name(), strings.Join(tmpls, " "),
),
// Record: rec,
}
}
messages[i] = message
}
// TODO XXX AJ makes sure the copy retains its Text
return messages, nil
}
func ReplaceVar(text, key, val string) string {
if val != "" {
// No special treatment:
// "Hey {+Name}," => "Hey Doe,"
// "Bob,{Name}" => "Bob,Doe"
// "{Name-},Joe" => "Doe,Joe"
// "Hi {-Name-}, Joe" => "Hi Doe, Joe"
var reHasVar = regexp.MustCompile(fmt.Sprintf(`\{\+?%s-?\}`, regexp.QuoteMeta(key)))
return reHasVar.ReplaceAllString(text, val)
}
var metaKey = regexp.QuoteMeta(key)
// "Hey {+Name}," => "Hey ,"
var reEatNone = regexp.MustCompile(fmt.Sprintf(`\{\+%s\}`, metaKey))
text = reEatNone.ReplaceAllString(text, val)
// "Bob,{Name};" => "Bob;"
var reEatOneLeft = regexp.MustCompile(fmt.Sprintf(`.?\{%s\}`, metaKey))
text = reEatOneLeft.ReplaceAllString(text, val)
// ",{Name-};Joe" => ",Joe"
var reEatOneRight = regexp.MustCompile(fmt.Sprintf(`\{%s-\}.?`, metaKey))
text = reEatOneRight.ReplaceAllString(text, val)
// "Hi {-Name-}, Joe" => "Hi Joe"
var reEatOneBoth = regexp.MustCompile(fmt.Sprintf(`.?\{-%s-\}.?`, metaKey))
text = reEatOneBoth.ReplaceAllString(text, val)
return text
}

66
text/textvars/README.md Normal file
View File

@ -0,0 +1,66 @@
# [textvars](https://github.com/therootcompany/golib/tree/main/text/textvars)
[![Go Reference](https://pkg.go.dev/badge/github.com/therootcompany/golib/text/textvars.svg)](https://pkg.go.dev/github.com/therootcompany/golib/text/textvars)
Text replacement functions that handle the empty string / trailing comma problem in a sane way: \
(cuts the character to the left when empty)
Example: Leading space:
```go
textvars.ReplaceVar(`Hey {Name}!`, "Name", "Joe")
// "Hey Joe!"
textvars.ReplaceVar(`Hey {Name}!`, "Name", "")
// "Hey!" 👍
strings.ReplaceAll(`Hey {Name}!`, "{Name}", "")
// "Hey !" 🫤
```
Example: Leading comma:
```go
textvars.ReplaceVar(`Apples,{Fruit},Bananas`, "Fruit", "Oranges")
// "Apples,Oranges,Bananas"
textvars.ReplaceVar(`Apples,{Fruit},Bananas`, "Fruit", "")
// "Apples,Bananas" 👍
strings.ReplaceAll(`Apples,{Fruit},Bananas`, "{Fruit}", "")
// "Apples,,Bananas" 🫤
```
Example: Multiple Vars
```go
tmpl := `{#}. {Name}`
vars := map[string]string{
"#": "1",
"Name": "Joe",
}
text, err := textvars.ReplaceVars(tmpl, vars)
// "1. Joe"
// errors if any {...} are left over
```
**Note**: This is the sort of thing that's it's probably better to copy and paste rather than to have as a dependency, but I wanted to have it for myself as a convenience in my own repo of tools, so here it is.
## Other Uses
It seemed like an okay idea at the time, so I also baked in some other uses:
| Syntax | Example | "Joe" | Empty ("") | Comment |
| ---------- | -------------- | ----------- | ---------- | ----------------------------- |
| `{Name}` | `Hey {Name}!` | `Hey Joe!` | `Hey!` | cuts left character if empty |
| `{Name-}` | `1,{Name-},3` | `1,Joe,3` | `1,3` | cuts right character if empty |
| `{-Name-}` | `Hey! {Name}!` | `Hey! Joe!` | `Hey!` | cuts left and right if empty |
| `{+Name}` | `Name:{+Name}` | `Name:Joe` | `Name:` | keeps left character always |
However, I haven't actually had the use case for those yet and you probably won't either... so don't use what you don't need. 🙃
I DO NOT plan on making a robust template system. I was only interested in solving the _leading space_ / _trailing comma_ problem for [sendsms](https://github.com/therootcompany/golib/tree/main/cmd/sendsms).
# Legal
CC0-1.0 (Public Domain)

3
text/textvars/go.mod Normal file
View File

@ -0,0 +1,3 @@
module github.com/therootcompany/golib/text/textvars
go 1.25.4

72
text/textvars/textvars.go Normal file
View File

@ -0,0 +1,72 @@
// Authored in 2026 by AJ ONeal <aj@therootcompany.com> (https://therootcompany.com)
//
// To the extent possible under law, the author(s) have dedicated all copyright
// and related and neighboring rights to this software to the public domain
// worldwide. This software is distributed without any warranty.
//
// You should have received a copy of the CC0 Public Domain Dedication along with
// this software. If not, see <https://creativecommons.org/publicdomain/zero/1.0/>.
//
// SPDX-License-Identifier: CC0-1.0
package textvars
import (
"fmt"
"maps"
"regexp"
"slices"
"strings"
)
var reUnmatchedVars = regexp.MustCompile(`(\{[^}]+\})`)
func GetPlaceholders(tmpl string) []string {
return reUnmatchedVars.FindAllString(tmpl, -1)
}
func ReplaceVars(text string, vars map[string]string) (string, error) {
keyIter := maps.Keys(vars)
keys := slices.Sorted(keyIter)
for _, key := range keys {
val := vars[key]
text = ReplaceVar(text, key, val)
}
if tmpls := GetPlaceholders(text); len(tmpls) != 0 {
return "", fmt.Errorf("leftover template variable(s): %s", strings.Join(tmpls, " "))
}
return text, nil
}
func ReplaceVar(text, key, val string) string {
if val != "" {
// No special treatment:
// "Hey {+Name}," => "Hey Doe,"
// "Bob,{Name}" => "Bob,Doe"
// "{Name-},Joe" => "Doe,Joe"
// "Hi {-Name-}, Joe" => "Hi Doe, Joe"
var reHasVar = regexp.MustCompile(fmt.Sprintf(`\{\+?%s-?\}`, regexp.QuoteMeta(key)))
return reHasVar.ReplaceAllString(text, val)
}
var metaKey = regexp.QuoteMeta(key)
// "Hey {+Name}," => "Hey ,"
text = strings.ReplaceAll(text, `{+`+key+`}`, val)
// "Bob,{Name};" => "Bob;"
var reEatOneLeft = regexp.MustCompile(fmt.Sprintf(`.?\{%s\}`, metaKey))
text = reEatOneLeft.ReplaceAllString(text, val)
// ",{Name-};Joe" => ",Joe"
var reEatOneRight = regexp.MustCompile(fmt.Sprintf(`\{%s-\}.?`, metaKey))
text = reEatOneRight.ReplaceAllString(text, val)
// "Hi {-Name-}, Joe" => "Hi Joe"
var reEatOneBoth = regexp.MustCompile(fmt.Sprintf(`.?\{-%s-\}.?`, metaKey))
text = reEatOneBoth.ReplaceAllString(text, val)
return text
}