Skip to content

Commit

Permalink
✨ feat: textutil - add StrTemplate for quick render template string
Browse files Browse the repository at this point in the history
  • Loading branch information
inhere committed Sep 8, 2023
1 parent 5b149e5 commit 8f8f7ee
Show file tree
Hide file tree
Showing 4 changed files with 372 additions and 2 deletions.
90 changes: 90 additions & 0 deletions strutil/textutil/gotpl.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
package textutil

import (
"bytes"
"io"
"strings"
"text/template"

"github.com/gookit/goutil"
"github.com/gookit/goutil/basefn"
"github.com/gookit/goutil/fsutil"
"github.com/gookit/goutil/strutil"
)

var builtInFuncs = template.FuncMap{
// don't escape content
"raw": func(s string) string {
return s
},
"trim": strings.TrimSpace,
// lower first char
"lcFirst": strutil.LowerFirst,
// upper first char
"upFirst": strutil.UpperFirst,
// upper case
"upper": strings.ToUpper,
// lower case
"lower": strings.ToLower,
// cut sub-string
"substr": strutil.Substr,
// default value
"default": func(v, defVal any) string {
if goutil.IsEmpty(v) {
return strutil.SafeString(defVal)
}
return strutil.SafeString(v)
},
// join strings
"join": func(ss []string, sep string) string {
return strings.Join(ss, sep)
},
}

// TextRenderOpt render text template options
type TextRenderOpt struct {
// Output use custom output writer
Output io.Writer
// Funcs add custom template functions
Funcs template.FuncMap
}

// RenderOptFn render option func
type RenderOptFn func(opt *TextRenderOpt)

// NewRenderOpt create a new render options
func NewRenderOpt(optFns []RenderOptFn) *TextRenderOpt {
opt := &TextRenderOpt{}
for _, fn := range optFns {
fn(opt)
}
return opt
}

// RenderGoTpl render input text or template file.
func RenderGoTpl(input string, data any, optFns ...RenderOptFn) string {
opt := NewRenderOpt(optFns)

t := template.New("text-renderer")
t.Funcs(builtInFuncs)
if len(opt.Funcs) > 0 {
t.Funcs(opt.Funcs)
}

if !strings.Contains(input, "{{") && fsutil.IsFile(input) {
template.Must(t.ParseFiles(input))
} else {
template.Must(t.Parse(input))
}

// use custom output writer
if opt.Output != nil {
basefn.MustOK(t.Execute(opt.Output, data))
return "" // return empty string
}

// use buffer receive rendered content
buf := new(bytes.Buffer)
basefn.MustOK(t.Execute(buf, data))
return buf.String()
}
250 changes: 250 additions & 0 deletions strutil/textutil/strtpl.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,250 @@
package textutil

import (
"fmt"
"io"
"regexp"
"strings"
"text/template"

"github.com/gookit/goutil/arrutil"
"github.com/gookit/goutil/basefn"
"github.com/gookit/goutil/fsutil"
"github.com/gookit/goutil/reflects"
"github.com/gookit/goutil/structs"
"github.com/gookit/goutil/strutil"
)

// STemplateOptFn template option func
type STemplateOptFn func(opt *StrTemplateOpt)

// StrTemplateOpt template options for StrTemplate
type StrTemplateOpt struct {
// func name alias map. eg: {"up_first": "upFirst"}
nameMp structs.Aliases
Funcs template.FuncMap

Left, Right string

ParseDef bool
ParseEnv bool
}

// StrTemplate implement a simple string template
//
// - support parse template vars
// - support access multi-level map field. eg: {{ user.name }}
// - support parse default value
// - support parse env vars
// - support custom pipeline func handle. eg: {{ name | upper }} {{ name | def:guest }}
//
// NOTE: not support control flow, eg: if/else/for/with
type StrTemplate struct {
StrTemplateOpt
vr VarReplacer
// template func map. refer the text/template
//
// Func allow return 1 or 2 values, if return 2 values, the second value is error.
fxs map[string]*reflects.FuncX
}

// NewStrTemplate instance
func NewStrTemplate(opFns ...STemplateOptFn) *StrTemplate {
st := &StrTemplate{
fxs: make(map[string]*reflects.FuncX),
vr: VarReplacer{
Left: "{{",
Right: "}}",
},
}

st.ParseDef = true
st.ParseEnv = true
st.vr.RenderFn = st.renderVars
for _, fn := range opFns {
fn(&st.StrTemplateOpt)
}

st.Init()
return st
}

// Init StrTemplate
func (t *StrTemplate) Init() {
if t.vr.init {
return
}

basefn.PanicIf(t.vr.Right == "", "var format Right chars is required")

t.vr.init = true
t.vr.parseDef = t.ParseDef
t.vr.parseEnv = t.ParseEnv

t.vr.lLen, t.vr.rLen = len(t.vr.Left), len(t.vr.Right)
// (?s:...) - 让 "." 匹配换行
// (?s:(.+?)) - 第二个 "?" 非贪婪匹配
t.vr.varReg = regexp.MustCompile(regexp.QuoteMeta(t.vr.Left) + `(?s:(.+?))` + regexp.QuoteMeta(t.vr.Right))

// add built-in funcs
t.AddFuncs(builtInFuncs)
t.nameMp.AddAliasMap(map[string]string{
"up_first": "upFirst",
"lc_first": "lcFirst",
"def": "default",
})
}

// AddFuncs add custom template functions
func (t *StrTemplate) AddFuncs(fns map[string]any) {
for name, fn := range fns {
t.fxs[name] = reflects.NewFunc(fn)
}
}

// RenderString render template string with vars
func (t *StrTemplate) RenderString(s string, vars map[string]any) string {
return t.vr.Replace(s, vars)
}

// RenderFile render template file with vars
func (t *StrTemplate) RenderFile(filePath string, vars map[string]any) (string, error) {
if !fsutil.FileExists(filePath) {
return "", fmt.Errorf("template file not exists: %s", filePath)
}

// read file contents
s, err := fsutil.ReadStringOrErr(filePath)
if err != nil {
return "", err
}

return t.vr.Replace(s, vars), nil
}

// RenderWrite render template string with vars, and write to writer
func (t *StrTemplate) RenderWrite(wr io.Writer, s string, vars map[string]any) error {
s = t.vr.Replace(s, vars)
_, err := io.WriteString(wr, s)
return err
}

func (t *StrTemplate) renderVars(s string, varMap map[string]string) string {
return t.vr.varReg.ReplaceAllStringFunc(s, func(sub string) string {
// var name or pipe expression.
name := strings.TrimSpace(sub[t.vr.lLen : len(sub)-t.vr.rLen])
name = strings.TrimLeft(name, ".")

var defVal string
var pipes []string
if strings.ContainsRune(name, '|') {
pipes = strutil.Split(name, "|")
// compatible default value. eg: {{ name | inhere }}
if len(pipes) == 2 && !strings.ContainsRune(pipes[1], ':') && !t.isFunc(pipes[1]) {
name, defVal = pipes[0], pipes[1]
pipes = nil // clear pipes
} else { // collect pipe functions
name, pipes = pipes[0], pipes[1:]
}
}

if val, ok := varMap[name]; ok {
if len(pipes) > 0 {
var err error
val, err = t.applyPipes(val, pipes)
if err != nil {
return fmt.Sprintf("Render var %q error: %v", name, err)
}
}
return val
}

// var not found
if len(defVal) > 0 {
return defVal
}

if t.vr.NotFound != nil {
if val, ok := t.vr.NotFound(name); ok {
return val
}
}

// check is default func. eg: {{ name | def:guest }}
if len(pipes) == 1 && strings.ContainsRune(pipes[0], ':') {
fName, argVal := strutil.TrimCut(pipes[0], ":")
if t.isDefaultFunc(fName) {
return argVal
}
}

t.vr.missVars = append(t.vr.missVars, name)
return sub
})
}

func (t *StrTemplate) applyPipes(val any, pipes []string) (string, error) {
var err error

// pipe expr: "trim|upper|substr:1,2"
// =>
// pipes: ["trim", "upper", "substr:1,2"]
for _, name := range pipes {
args := []any{val}

// has custom args. eg: "substr:1,2"
if strings.ContainsRune(name, ':') {
var argStr string
name, argStr = strutil.TrimCut(name, ":")

if otherArgs := parseArgStr(argStr); len(otherArgs) > 0 {
args = append(args, otherArgs...)
}
}

name = t.nameMp.ResolveAlias(name)

// call pipe func
if fx, ok := t.fxs[name]; ok {
val, err = fx.Call2(args...)
if err != nil {
return "", err
}
} else {
return "", fmt.Errorf("template func %q not found", name)
}
}

return strutil.ToString(val)
}

func (t *StrTemplate) isFunc(name string) bool {
_, ok := t.fxs[name]
if !ok {
// check name alias
return t.nameMp.HasAlias(name)
}
return ok
}

func (t *StrTemplate) isDefaultFunc(name string) bool {
return name == "default" || name == "def"
}

var stdTpl = NewStrTemplate()

// RenderString render str template string or file.
func RenderString(input string, data map[string]any, optFns ...RenderOptFn) string {
return stdTpl.RenderString(input, data)
}

func parseArgStr(argStr string) (ss []any) {
if argStr == "" { // no arg
return
}

if len(argStr) == 1 { // one char
return []any{argStr}
}
return arrutil.StringsToAnys(strutil.Split(argStr, ","))
}
28 changes: 28 additions & 0 deletions strutil/textutil/strtpl_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package textutil_test

import (
"testing"

"github.com/gookit/goutil/strutil/textutil"
"github.com/gookit/goutil/testutil/assert"
)

func TestRenderString(t *testing.T) {
data := map[string]any{
"name": "inhere",
"age": 2000,
}

t.Run("basic", func(t *testing.T) {
tpl := "hi, My name is {{ .name | upFirst }}, age is {{ .age }}"
str := textutil.RenderString(tpl, data)
assert.Eq(t, "hi, My name is Inhere, age is 2000", str)
})

// with default value and alias func
t.Run("with default value and alias func", func(t *testing.T) {
tpl := "name: {{ .name | default:guest }}, age: {{ .age | def:18 }}, city: {{ .city | cd }}"
str := textutil.RenderString(tpl, map[string]any{})
assert.Eq(t, "name: guest, age: 18, city: cd", str)
})
}
Loading

0 comments on commit 8f8f7ee

Please sign in to comment.