diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index c4e248d..fb62814 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -20,7 +20,7 @@ jobs: - uses: actions/setup-go@0c52d547c9bc32b1aa3301fd7a9cb496313a4491 - uses: golangci/golangci-lint-action@9d1e0624a798bb64f6c3cea93db47765312263dc with: - version: v1.55.2 + version: v1.58.1 - uses: DavidAnson/markdownlint-cli2-action@b4c9feab76d8025d1e83c653fa3990936df0e6c8 with: globs: '**/*.md' diff --git a/.golangci.yml b/.golangci.yml index 8783805..9d6ccad 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -11,11 +11,11 @@ linters: - dupl - dupword - durationcheck + - err113 - errcheck - errchkjson - errname - errorlint - - execinquery - exhaustive - exportloopref - forbidigo @@ -30,7 +30,6 @@ linters: - gocritic - gocyclo - godot - - goerr113 - gofmt - gofumpt - goheader @@ -130,14 +129,14 @@ issues: - linters: - dupl - scopelint - path: "_test\\.go" + path: _test\.go - linters: - forbidigo - gosec - path: "internal/" + path: internal/ - linters: - - goerr113 + - err113 text: do not define dynamic errors, use wrapped static errors instead - linters: - gochecknoinits - path: "docs/docs.go" + path: docs\.go diff --git a/booleanfuncs/booleanfuncs.go b/booleanfuncs/booleanfuncs.go new file mode 100644 index 0000000..866b5c9 --- /dev/null +++ b/booleanfuncs/booleanfuncs.go @@ -0,0 +1,26 @@ +package booleanfuncs + +import ( + "strings" + "text/template" +) + +func NewFuncMap() template.FuncMap { + return template.FuncMap{ + "eqFold": eqFoldTemplateFunc, + } +} + +// eqFoldTemplateFunc is the core implementation of the `eqFold` template +// function. +func eqFoldTemplateFunc(first, second string, more ...string) bool { + if strings.EqualFold(first, second) { + return true + } + for _, s := range more { + if strings.EqualFold(first, s) { + return true + } + } + return false +} diff --git a/conversionfuncs/conversionfuncs.go b/conversionfuncs/conversionfuncs.go new file mode 100644 index 0000000..89bd1ae --- /dev/null +++ b/conversionfuncs/conversionfuncs.go @@ -0,0 +1,39 @@ +package conversionfuncs + +import ( + "fmt" + "strconv" + "text/template" +) + +func NewFuncMap() template.FuncMap { + return template.FuncMap{ + "toString": toStringTemplateFunc, + } +} + +// toStringTemplateFunc is the core implementation of the `toString` template +// function. +func toStringTemplateFunc(arg any) string { + // FIXME add more types + switch arg := arg.(type) { + case string: + return arg + case []byte: + return string(arg) + case bool: + return strconv.FormatBool(arg) + case float32: + return strconv.FormatFloat(float64(arg), 'f', -1, 32) + case float64: + return strconv.FormatFloat(arg, 'f', -1, 64) + case int: + return strconv.Itoa(arg) + case int32: + return strconv.FormatInt(int64(arg), 10) + case int64: + return strconv.FormatInt(arg, 10) + default: + panic(fmt.Sprintf("%T: unsupported type", arg)) + } +} diff --git a/docs/booleanfuncs.md b/docs/booleanfuncs.md new file mode 100644 index 0000000..102e9a9 --- /dev/null +++ b/docs/booleanfuncs.md @@ -0,0 +1,12 @@ +# Boolean Functions + +## `eqFold` *string1* *string2* [*extraStrings*...] + +`eqFold` returns the boolean truth of comparing *string1* with *string2* +and any number of *extraStrings* under Unicode case-folding. + +```text +{{ eqFold "föö" "FOO" }} + +true +``` diff --git a/docs/conversionfuncs.md b/docs/conversionfuncs.md new file mode 100644 index 0000000..76c2fa3 --- /dev/null +++ b/docs/conversionfuncs.md @@ -0,0 +1,9 @@ +# Conversion Functions + +## `toString` *input* + +`toString` returns the string representation of *input*. + +```text +{{ toString 10 }} +``` diff --git a/docs/docs.go b/docs/docs.go index 5bc018b..4e6be32 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -1,37 +1,63 @@ package docs import ( - _ "embed" + "embed" + "log" "regexp" "strings" ) -//go:embed templatefuncs.md -var templateFuncsStr string - type Reference struct { + Type string Title string Body string Example string } -var References map[string]Reference +//go:embed *.md +var f embed.FS -func init() { - newlineRx := regexp.MustCompile(`\r?\n`) +var ( + References map[string]Reference + newlineRx = regexp.MustCompile(`\r?\n`) + pageTitleRx = regexp.MustCompile(`^#\s+(\S+)`) // Template function names must start with a letter or underscore // and can subsequently contain letters, underscores and digits. - funcNameRx := regexp.MustCompile("`" + `([a-zA-Z_]\w*)` + "`") + funcNameRx = regexp.MustCompile("`" + `([a-zA-Z_]\w*)` + "`") +) - References = make(map[string]Reference) +func readFiles() []string { + fileContents := []string{} + + fileInfos, err := f.ReadDir(".") + if err != nil { + log.Fatal(err) + } + + for _, fileInfo := range fileInfos { + content, err := f.ReadFile(fileInfo.Name()) + if err != nil { + log.Fatal(err) + } + fileContents = append(fileContents, string(content)) + } + + return fileContents +} + +func parseFile(file string) map[string]Reference { var reference Reference var funcName string var b strings.Builder var e strings.Builder inExample := false - for _, line := range newlineRx.Split(templateFuncsStr, -1) { + lines := newlineRx.Split(file, -1) + funcType := pageTitleRx.FindStringSubmatch(lines[0])[1] + lines = lines[1:] + + for _, line := range lines { switch { case strings.HasPrefix(line, "## "): if reference.Title != "" { @@ -39,6 +65,7 @@ func init() { } funcName = funcNameRx.FindStringSubmatch(line)[1] reference = Reference{} + reference.Type = funcType reference.Title = strings.TrimPrefix(line, "## ") case strings.HasPrefix(line, "```"): if !inExample { @@ -65,4 +92,16 @@ func init() { if reference.Title != "" { References[funcName] = reference } + + return References +} + +func init() { + References = make(map[string]Reference) + + files := readFiles() + + for _, file := range files { + parseFile(file) + } } diff --git a/docs/docs_test.go b/docs/docs_test.go index 70cdb00..59d7536 100644 --- a/docs/docs_test.go +++ b/docs/docs_test.go @@ -10,6 +10,7 @@ import ( func TestReferences(t *testing.T) { assert.Equal(t, docs.Reference{ + Type: "String", Title: "`contains` *substring* *string*", Body: "`contains` returns whether *substring* is in *string*.", Example: "" + @@ -20,12 +21,16 @@ func TestReferences(t *testing.T) { "```", }, docs.References["contains"]) assert.Equal(t, docs.Reference{ - Title: "`trimSpace` *string*", - Body: "`trimSpace` returns *string* with all spaces removed.", - Example: "```text\n" + - "{{ \" foobar \" | trimSpace }}\n" + + Type: "Boolean", + Title: "`eqFold` *string1* *string2* [*extraStrings*...]", + Body: "" + + "`eqFold` returns the boolean truth of comparing *string1* with *string2*\n" + + "and any number of *extraStrings* under Unicode case-folding.", + Example: "" + + "```text\n" + + "{{ eqFold \"föö\" \"FOO\" }}\n" + "\n" + - "foobar\n" + + "true\n" + "```", - }, docs.References["trimSpace"]) + }, docs.References["eqFold"]) } diff --git a/docs/encodingfuncs.md b/docs/encodingfuncs.md new file mode 100644 index 0000000..8f66b50 --- /dev/null +++ b/docs/encodingfuncs.md @@ -0,0 +1,39 @@ +# Encoding Functions + +## `fromJSON` *jsontext* + +`fromJSON` parses *jsontext* as JSON and returns the parsed value. + +```text +{{ `{ "foo": "bar" }` | fromJSON }} +``` + +## `hexDecode` *hextext* + +`hexDecode` returns the bytes represented by *hextext*. + +```text +{{ hexDecode "666f6f626172" }} + +foobar +``` + +## `hexEncode` *string* + +`hexEncode` returns the hexadecimal encoding of *string*. + +```text +{{ hexEncode "foobar" }} + +666f6f626172 +``` + +## `toJSON` *input* + +`toJSON` returns a JSON string representation of *input*. + +```text +{{ list "foo" "bar" "baz" }} + +["foo","bar","baz"] +``` diff --git a/docs/listfuncs.md b/docs/listfuncs.md new file mode 100644 index 0000000..57d44d6 --- /dev/null +++ b/docs/listfuncs.md @@ -0,0 +1,32 @@ +# List Functions + +## `join` *delimiter* *list* + +`join` returns a string containing each item in *list* joined with *delimiter*. + +```text +{{ list "foo" "bar" "baz" | join "," }} + +foo,bar,baz +``` + +## `list` *items*... + +`list` creates a new list containing *items*. + +```text +{{ list "foo" "bar" "baz" }} +``` + +## `prefixLines` *prefix* *list* + +`prefixLines` returns a string consisting of each item in *list* +with the prefix *prefix*. + +```text +{{ list "this is" "a multi-line" "comment" | prefixLines "# " }} + +# this is +# a multi-line +# comment +``` diff --git a/docs/stringfuncs.md b/docs/stringfuncs.md new file mode 100644 index 0000000..ced34ff --- /dev/null +++ b/docs/stringfuncs.md @@ -0,0 +1,83 @@ +# String Functions + +## `contains` *substring* *string* + +`contains` returns whether *substring* is in *string*. + +```text +{{ "abc" | contains "ab" }} + +true +``` + +## `hasPrefix` *prefix* *string* + +`hasPrefix` returns whether *string* begins with *prefix*. + +```text +{{ "foobar" | hasPrefix "foo" }} + +true +``` + +## `hasSuffix` *suffix* *string* + +`hasSuffix` returns whether *string* ends with *suffix*. + +```text +{{ "foobar" | hasSuffix "bar" }} + +true +``` + +## `quote` *input* + +`quote` returns a double-quoted string literal containing *input*. +*input* can be a string or list of strings. + +```text +{{ "foobar" | quote }} + +"foobar" +``` + +## `regexpReplaceAll` *pattern* *replacement* *string* + +`regexpReplaceAll` replaces all instances of *pattern* +with *replacement* in *string*. + +```text +{{ "foobar" | regexpReplaceAll "o*b" "" }} + +far +``` + +## `toLower` *string* + +`toLower` returns *string* with all letters converted to lower case. + +```text +{{ toLower "FOOBAR" }} + +foobar +``` + +## `toUpper` *string* + +`toUpper` returns *string* with all letters converted to upper case. + +```text +{{ toUpper "foobar" }} + +FOOBAR +``` + +## `trimSpace` *string* + +`trimSpace` returns *string* with all spaces removed. + +```text +{{ " foobar " | trimSpace }} + +foobar +``` diff --git a/docs/templatefuncs.md b/docs/templatefuncs.md index 0a41b79..d4a82a2 100644 --- a/docs/templatefuncs.md +++ b/docs/templatefuncs.md @@ -1,92 +1,5 @@ # Template Functions -## `contains` *substring* *string* - -`contains` returns whether *substring* is in *string*. - -```text -{{ "abc" | contains "ab" }} - -true -``` - -## `eqFold` *string1* *string2* [*extraStrings*...] - -`eqFold` returns the boolean truth of comparing *string1* with *string2* -and any number of *extraStrings* under Unicode case-folding. - -```text -{{ eqFold "föö" "FOO" }} - -true -``` - -## `fromJSON` *jsontext* - -`fromJSON` parses *jsontext* as JSON and returns the parsed value. - -```text -{{ `{ "foo": "bar" }` | fromJSON }} -``` - -## `hasPrefix` *prefix* *string* - -`hasPrefix` returns whether *string* begins with *prefix*. - -```text -{{ "foobar" | hasPrefix "foo" }} - -true -``` - -## `hasSuffix` *suffix* *string* - -`hasSuffix` returns whether *string* ends with *suffix*. - -```text -{{ "foobar" | hasSuffix "bar" }} - -true -``` - -## `hexDecode` *hextext* - -`hexDecode` returns the bytes represented by *hextext*. - -```text -{{ hexDecode "666f6f626172" }} - -foobar -``` - -## `hexEncode` *string* - -`hexEncode` returns the hexadecimal encoding of *string*. - -```text -{{ hexEncode "foobar" }} - -666f6f626172 -``` - -## `join` *delimiter* *list* - -`join` returns a string containing each item in *list* joined with *delimiter*. - -```text -{{ list "foo" "bar" "baz" | join "," }} - -foo,bar,baz -``` - -## `list` *items*... - -`list` creates a new list containing *items*. - -```text -{{ list "foo" "bar" "baz" }} -``` - ## `lookPath` *file* `lookPath` searches for the executable *file* in the users `PATH` @@ -107,41 +20,6 @@ environment variable and returns its path. file ``` -## `prefixLines` *prefix* *list* - -`prefixLines` returns a string consisting of each item in *list* -with the prefix *prefix*. - -```text -{{ list "this is" "a multi-line" "comment" | prefixLines "# " }} - -# this is -# a multi-line -# comment -``` - -## `quote` *input* - -`quote` returns a double-quoted string literal containing *input*. -*input* can be a string or list of strings. - -```text -{{ "foobar" | quote }} - -"foobar" -``` - -## `regexpReplaceAll` *pattern* *replacement* *string* - -`regexpReplaceAll` replaces all instances of *pattern* -with *replacement* in *string*. - -```text -{{ "foobar" | regexpReplaceAll "o*b" "" }} - -far -``` - ## `stat` *path* `stat` returns a map representation of executing @@ -152,51 +30,3 @@ far file ``` - -## `toJSON` *input* - -`toJSON` returns a JSON string representation of *input*. - -```text -{{ list "foo" "bar" "baz" }} - -["foo","bar","baz"] -``` - -## `toLower` *string* - -`toLower` returns *string* with all letters converted to lower case. - -```text -{{ toLower "FOOBAR" }} - -foobar -``` - -## `toString` *input* - -`toString` returns the string representation of *input*. - -```text -{{ toString 10 }} -``` - -## `toUpper` *string* - -`toUpper` returns *string* with all letters converted to upper case. - -```text -{{ toUpper "foobar" }} - -FOOBAR -``` - -## `trimSpace` *string* - -`trimSpace` returns *string* with all spaces removed. - -```text -{{ " foobar " | trimSpace }} - -foobar -``` diff --git a/encodingfuncs/encodingfuncs.go b/encodingfuncs/encodingfuncs.go new file mode 100644 index 0000000..e04eac5 --- /dev/null +++ b/encodingfuncs/encodingfuncs.go @@ -0,0 +1,38 @@ +package encodingfuncs + +import ( + "encoding/hex" + "encoding/json" + "text/template" + + "github.com/chezmoi/templatefuncs/internal/transform" +) + +func NewFuncMap() template.FuncMap { + return template.FuncMap{ + "fromJSON": transform.EachByteSliceErr(fromJSONTemplateFunc), + "hexDecode": transform.EachStringErr(hex.DecodeString), + "hexEncode": transform.EachByteSlice(hex.EncodeToString), + "toJSON": toJSONTemplateFunc, + } +} + +// fromJSONTemplateFunc is the core implementation of the `fromJSON` template +// function. +func fromJSONTemplateFunc(data []byte) (any, error) { + var result any + if err := json.Unmarshal(data, &result); err != nil { + return nil, err + } + return result, nil +} + +// toJSONTemplateFunc is the core implementation of the `toJSON` template +// function. +func toJSONTemplateFunc(arg any) []byte { + data, err := json.Marshal(arg) + if err != nil { + panic(err) + } + return data +} diff --git a/internal/transform/transform.go b/internal/transform/transform.go new file mode 100644 index 0000000..39e9e3e --- /dev/null +++ b/internal/transform/transform.go @@ -0,0 +1,196 @@ +package transform + +import ( + "fmt" + "io/fs" +) + +// fileModeTypeNames maps file mode types to human-readable strings. +var fileModeTypeNames = map[fs.FileMode]string{ + 0: "file", + fs.ModeDir: "dir", + fs.ModeSymlink: "symlink", + fs.ModeNamedPipe: "named pipe", + fs.ModeSocket: "socket", + fs.ModeDevice: "device", + fs.ModeCharDevice: "char device", +} + +// EachByteSlice transforms a function that takes a single `[]byte` and returns +// a `T` to a function that takes zero or more `[]byte`-like arguments and +// returns zero or more `T`s. +func EachByteSlice[T any](f func([]byte) T) func(any) any { + return func(arg any) any { + switch arg := arg.(type) { + case []byte: + return f(arg) + case [][]byte: + result := make([]T, 0, len(arg)) + for _, a := range arg { + result = append(result, f(a)) + } + return result + case string: + return f([]byte(arg)) + case []string: + result := make([]T, 0, len(arg)) + for _, a := range arg { + result = append(result, f([]byte(a))) + } + return result + default: + panic(fmt.Sprintf("%T: unsupported argument type", arg)) + } + } +} + +// EachByteSliceErr transforms a function that takes a single `[]byte` and +// returns a `T` and an `error` into a function that takes zero or more +// `[]byte`-like arguments and returns zero or more `Ts` and an error. +func EachByteSliceErr[T any](f func([]byte) (T, error)) func(any) any { + return func(arg any) any { + switch arg := arg.(type) { + case []byte: + result, err := f(arg) + if err != nil { + panic(err) + } + return result + case [][]byte: + result := make([]T, 0, len(arg)) + for _, a := range arg { + r, err := f(a) + if err != nil { + panic(err) + } + result = append(result, r) + } + return result + case string: + result, err := f([]byte(arg)) + if err != nil { + panic(err) + } + return result + case []string: + result := make([]T, 0, len(arg)) + for _, a := range arg { + r, err := f([]byte(a)) + if err != nil { + panic(err) + } + result = append(result, r) + } + return result + default: + panic(fmt.Sprintf("%T: unsupported argument type", arg)) + } + } +} + +// EachString transforms a function that takes a single `string`-like argument +// and returns a `T` into a function that takes zero or more `string`-like +// arguments and returns zero or more `T`s. +func EachString[T any](f func(string) T) func(any) any { + return func(arg any) any { + switch arg := arg.(type) { + case string: + return f(arg) + case []string: + result := make([]T, 0, len(arg)) + for _, a := range arg { + result = append(result, f(a)) + } + return result + case []byte: + return f(string(arg)) + case [][]byte: + result := make([]T, 0, len(arg)) + for _, a := range arg { + result = append(result, f(string(a))) + } + return result + case []any: + result := make([]T, 0, len(arg)) + for _, a := range arg { + switch a := a.(type) { + case string: + result = append(result, f(a)) + case []byte: + result = append(result, f(string(a))) + default: + panic(fmt.Sprintf("%T: unsupported argument type", a)) + } + } + return result + default: + panic(fmt.Sprintf("%T: unsupported argument type", arg)) + } + } +} + +// EachStringErr transforms a function that takes a single `string`-like argument +// and returns a `T` and an `error` into a function that takes zero or more +// `string`-like arguments and returns zero or more `T`s and an `error`. +func EachStringErr[T any](f func(string) (T, error)) func(any) any { + return func(arg any) any { + switch arg := arg.(type) { + case string: + result, err := f(arg) + if err != nil { + panic(err) + } + return result + case []string: + result := make([]T, 0, len(arg)) + for _, a := range arg { + r, err := f(a) + if err != nil { + panic(err) + } + result = append(result, r) + } + return result + case []byte: + result, err := f(string(arg)) + if err != nil { + panic(err) + } + return result + case [][]byte: + result := make([]T, 0, len(arg)) + for _, a := range arg { + r, err := f(string(a)) + if err != nil { + panic(err) + } + result = append(result, r) + } + return result + default: + panic(fmt.Sprintf("%T: unsupported argument type", arg)) + } + } +} + +// FileInfoToMap returns a `map[string]any` of `fileInfo`'s fields. +func FileInfoToMap(fileInfo fs.FileInfo) map[string]any { + return map[string]any{ + "name": fileInfo.Name(), + "size": fileInfo.Size(), + "mode": int(fileInfo.Mode()), + "perm": int(fileInfo.Mode().Perm()), + "modTime": fileInfo.ModTime().Unix(), + "isDir": fileInfo.IsDir(), + "type": fileModeTypeNames[fileInfo.Mode()&fs.ModeType], + } +} + +// ReverseArgs2 transforms a function that takes two arguments and returns an +// `R` into a function that takes the arguments in reverse order and returns an +// `R`. +func ReverseArgs2[T1, T2, R any](f func(T1, T2) R) func(T2, T1) R { + return func(arg1 T2, arg2 T1) R { + return f(arg2, arg1) + } +} diff --git a/listfuncs/listfuncs.go b/listfuncs/listfuncs.go new file mode 100644 index 0000000..7848ab1 --- /dev/null +++ b/listfuncs/listfuncs.go @@ -0,0 +1,57 @@ +package listfuncs + +import ( + "strings" + "text/template" + + "github.com/chezmoi/templatefuncs/internal/transform" +) + +func NewFuncMap() template.FuncMap { + return template.FuncMap{ + "join": transform.ReverseArgs2(strings.Join), + "list": listTemplateFunc, + "prefixLines": prefixLinesTemplateFunc, + } +} + +// listTemplateFunc is the core implementation of the `list` template function. +func listTemplateFunc(args ...any) []any { + return args +} + +// prefixLinesTemplateFunc is the core implementation of the `prefixLines` +// template function. +func prefixLinesTemplateFunc(prefix, s string) string { + type stateType int + const ( + startOfLine stateType = iota + inLine + ) + + state := startOfLine + var builder strings.Builder + builder.Grow(2 * len(s)) + for _, r := range s { + switch state { + case startOfLine: + if _, err := builder.WriteString(prefix); err != nil { + panic(err) + } + if _, err := builder.WriteRune(r); err != nil { + panic(err) + } + if r != '\n' { + state = inLine + } + case inLine: + if _, err := builder.WriteRune(r); err != nil { + panic(err) + } + if r == '\n' { + state = startOfLine + } + } + } + return builder.String() +} diff --git a/stringfuncs/stringfuncs.go b/stringfuncs/stringfuncs.go new file mode 100644 index 0000000..08835ac --- /dev/null +++ b/stringfuncs/stringfuncs.go @@ -0,0 +1,29 @@ +package stringfuncs + +import ( + "regexp" + "strconv" + "strings" + "text/template" + + "github.com/chezmoi/templatefuncs/internal/transform" +) + +func NewFuncMap() template.FuncMap { + return template.FuncMap{ + "contains": transform.ReverseArgs2(strings.Contains), + "hasPrefix": transform.ReverseArgs2(strings.HasPrefix), + "hasSuffix": transform.ReverseArgs2(strings.HasSuffix), + "quote": transform.EachString(strconv.Quote), + "regexpReplaceAll": regexpReplaceAllTemplateFunc, + "toLower": transform.EachString(strings.ToLower), + "toUpper": transform.EachString(strings.ToUpper), + "trimSpace": transform.EachString(strings.TrimSpace), + } +} + +// regexpReplaceAllTemplateFunc is the core implementation of the +// `regexpReplaceAll` template function. +func regexpReplaceAllTemplateFunc(expr, repl, s string) string { + return regexp.MustCompile(expr).ReplaceAllString(s, repl) +} diff --git a/templatefuncs.go b/templatefuncs.go index d44298e..506cd94 100644 --- a/templatefuncs.go +++ b/templatefuncs.go @@ -1,120 +1,39 @@ package templatefuncs import ( - "encoding/hex" - "encoding/json" "errors" - "fmt" "io/fs" + "maps" "os" "os/exec" - "regexp" - "strconv" - "strings" "text/template" -) -// fileModeTypeNames maps file mode types to human-readable strings. -var fileModeTypeNames = map[fs.FileMode]string{ - 0: "file", - fs.ModeDir: "dir", - fs.ModeSymlink: "symlink", - fs.ModeNamedPipe: "named pipe", - fs.ModeSocket: "socket", - fs.ModeDevice: "device", - fs.ModeCharDevice: "char device", -} + "github.com/chezmoi/templatefuncs/booleanfuncs" + "github.com/chezmoi/templatefuncs/conversionfuncs" + "github.com/chezmoi/templatefuncs/encodingfuncs" + "github.com/chezmoi/templatefuncs/internal/transform" + "github.com/chezmoi/templatefuncs/listfuncs" + "github.com/chezmoi/templatefuncs/stringfuncs" +) // NewFuncMap returns a new [text/template.FuncMap] containing all template // functions. func NewFuncMap() template.FuncMap { - return template.FuncMap{ - "contains": reverseArgs2(strings.Contains), - "eqFold": eqFoldTemplateFunc, - "fromJSON": eachByteSliceErr(fromJSONTemplateFunc), - "hasPrefix": reverseArgs2(strings.HasPrefix), - "hasSuffix": reverseArgs2(strings.HasSuffix), - "hexDecode": eachStringErr(hex.DecodeString), - "hexEncode": eachByteSlice(hex.EncodeToString), - "join": reverseArgs2(strings.Join), - "list": listTemplateFunc, - "lookPath": eachStringErr(lookPathTemplateFunc), - "lstat": eachString(lstatTemplateFunc), - "prefixLines": prefixLinesTemplateFunc, - "quote": eachString(strconv.Quote), - "regexpReplaceAll": regexpReplaceAllTemplateFunc, - "stat": eachString(statTemplateFunc), - "toJSON": toJSONTemplateFunc, - "toLower": eachString(strings.ToLower), - "toString": toStringTemplateFunc, - "toUpper": eachString(strings.ToUpper), - "trimSpace": eachString(strings.TrimSpace), - } -} + funcMap := make(template.FuncMap) -// prefixLinesTemplateFunc is the core implementation of the `prefixLines` -// template function. -func prefixLinesTemplateFunc(prefix, s string) string { - type stateType int - const ( - startOfLine stateType = iota - inLine - ) + maps.Copy(funcMap, template.FuncMap{ + "lookPath": transform.EachStringErr(lookPathTemplateFunc), + "lstat": transform.EachString(lstatTemplateFunc), + "stat": transform.EachString(statTemplateFunc), + }) - state := startOfLine - var builder strings.Builder - builder.Grow(2 * len(s)) - for _, r := range s { - switch state { - case startOfLine: - if _, err := builder.WriteString(prefix); err != nil { - panic(err) - } - if _, err := builder.WriteRune(r); err != nil { - panic(err) - } - if r != '\n' { - state = inLine - } - case inLine: - if _, err := builder.WriteRune(r); err != nil { - panic(err) - } - if r == '\n' { - state = startOfLine - } - } - } - return builder.String() -} + maps.Copy(funcMap, booleanfuncs.NewFuncMap()) + maps.Copy(funcMap, conversionfuncs.NewFuncMap()) + maps.Copy(funcMap, encodingfuncs.NewFuncMap()) + maps.Copy(funcMap, listfuncs.NewFuncMap()) + maps.Copy(funcMap, stringfuncs.NewFuncMap()) -// eqFoldTemplateFunc is the core implementation of the `eqFold` template -// function. -func eqFoldTemplateFunc(first, second string, more ...string) bool { - if strings.EqualFold(first, second) { - return true - } - for _, s := range more { - if strings.EqualFold(first, s) { - return true - } - } - return false -} - -// fromJSONTemplateFunc is the core implementation of the `fromJSON` template -// function. -func fromJSONTemplateFunc(data []byte) (any, error) { - var result any - if err := json.Unmarshal(data, &result); err != nil { - return nil, err - } - return result, nil -} - -// listTemplateFunc is the core implementation of the `list` template function. -func listTemplateFunc(args ...any) []any { - return args + return funcMap } // lookPathTemplateFunc is the core implementation of the `lookPath` template @@ -137,7 +56,7 @@ func lookPathTemplateFunc(file string) (string, error) { func lstatTemplateFunc(name string) any { switch fileInfo, err := os.Lstat(name); { case err == nil: - return fileInfoToMap(fileInfo) + return transform.FileInfoToMap(fileInfo) case errors.Is(err, fs.ErrNotExist): return nil default: @@ -145,235 +64,14 @@ func lstatTemplateFunc(name string) any { } } -// regexpReplaceAllTemplateFunc is the core implementation of the -// `regexpReplaceAll` template function. -func regexpReplaceAllTemplateFunc(expr, repl, s string) string { - return regexp.MustCompile(expr).ReplaceAllString(s, repl) -} - // statTemplateFunc is the core implementation of the `stat` template function. func statTemplateFunc(name string) any { switch fileInfo, err := os.Stat(name); { case err == nil: - return fileInfoToMap(fileInfo) + return transform.FileInfoToMap(fileInfo) case errors.Is(err, fs.ErrNotExist): return nil default: panic(err) } } - -// toJSONTemplateFunc is the core implementation of the `toJSON` template -// function. -func toJSONTemplateFunc(arg any) []byte { - data, err := json.Marshal(arg) - if err != nil { - panic(err) - } - return data -} - -// toStringTemplateFunc is the core implementation of the `toString` template -// function. -func toStringTemplateFunc(arg any) string { - // FIXME add more types - switch arg := arg.(type) { - case string: - return arg - case []byte: - return string(arg) - case bool: - return strconv.FormatBool(arg) - case float32: - return strconv.FormatFloat(float64(arg), 'f', -1, 32) - case float64: - return strconv.FormatFloat(arg, 'f', -1, 64) - case int: - return strconv.Itoa(arg) - case int32: - return strconv.FormatInt(int64(arg), 10) - case int64: - return strconv.FormatInt(arg, 10) - default: - panic(fmt.Sprintf("%T: unsupported type", arg)) - } -} - -// eachByteSlice transforms a function that takes a single `[]byte` and returns -// a `T` to a function that takes zero or more `[]byte`-like arguments and -// returns zero or more `T`s. -func eachByteSlice[T any](f func([]byte) T) func(any) any { - return func(arg any) any { - switch arg := arg.(type) { - case []byte: - return f(arg) - case [][]byte: - result := make([]T, 0, len(arg)) - for _, a := range arg { - result = append(result, f(a)) - } - return result - case string: - return f([]byte(arg)) - case []string: - result := make([]T, 0, len(arg)) - for _, a := range arg { - result = append(result, f([]byte(a))) - } - return result - default: - panic(fmt.Sprintf("%T: unsupported argument type", arg)) - } - } -} - -// eachByteSliceErr transforms a function that takes a single `[]byte` and -// returns a `T` and an `error` into a function that takes zero or more -// `[]byte`-like arguments and returns zero or more `Ts` and an error. -func eachByteSliceErr[T any](f func([]byte) (T, error)) func(any) any { - return func(arg any) any { - switch arg := arg.(type) { - case []byte: - result, err := f(arg) - if err != nil { - panic(err) - } - return result - case [][]byte: - result := make([]T, 0, len(arg)) - for _, a := range arg { - r, err := f(a) - if err != nil { - panic(err) - } - result = append(result, r) - } - return result - case string: - result, err := f([]byte(arg)) - if err != nil { - panic(err) - } - return result - case []string: - result := make([]T, 0, len(arg)) - for _, a := range arg { - r, err := f([]byte(a)) - if err != nil { - panic(err) - } - result = append(result, r) - } - return result - default: - panic(fmt.Sprintf("%T: unsupported argument type", arg)) - } - } -} - -// eachString transforms a function that takes a single `string`-like argument -// and returns a `T` into a function that takes zero or more `string`-like -// arguments and returns zero or more `T`s. -func eachString[T any](f func(string) T) func(any) any { - return func(arg any) any { - switch arg := arg.(type) { - case string: - return f(arg) - case []string: - result := make([]T, 0, len(arg)) - for _, a := range arg { - result = append(result, f(a)) - } - return result - case []byte: - return f(string(arg)) - case [][]byte: - result := make([]T, 0, len(arg)) - for _, a := range arg { - result = append(result, f(string(a))) - } - return result - case []any: - result := make([]T, 0, len(arg)) - for _, a := range arg { - switch a := a.(type) { - case string: - result = append(result, f(a)) - case []byte: - result = append(result, f(string(a))) - default: - panic(fmt.Sprintf("%T: unsupported argument type", a)) - } - } - return result - default: - panic(fmt.Sprintf("%T: unsupported argument type", arg)) - } - } -} - -// eachStringErr transforms a function that takes a single `string`-like argument -// and returns a `T` and an `error` into a function that takes zero or more -// `string`-like arguments and returns zero or more `T`s and an `error`. -func eachStringErr[T any](f func(string) (T, error)) func(any) any { - return func(arg any) any { - switch arg := arg.(type) { - case string: - result, err := f(arg) - if err != nil { - panic(err) - } - return result - case []string: - result := make([]T, 0, len(arg)) - for _, a := range arg { - r, err := f(a) - if err != nil { - panic(err) - } - result = append(result, r) - } - return result - case []byte: - result, err := f(string(arg)) - if err != nil { - panic(err) - } - return result - case [][]byte: - result := make([]T, 0, len(arg)) - for _, a := range arg { - r, err := f(string(a)) - if err != nil { - panic(err) - } - result = append(result, r) - } - return result - default: - panic(fmt.Sprintf("%T: unsupported argument type", arg)) - } - } -} - -// fileInfoToMap returns a `map[string]any` of `fileInfo`'s fields. -func fileInfoToMap(fileInfo fs.FileInfo) map[string]any { - return map[string]any{ - "name": fileInfo.Name(), - "size": fileInfo.Size(), - "mode": int(fileInfo.Mode()), - "perm": int(fileInfo.Mode().Perm()), - "modTime": fileInfo.ModTime().Unix(), - "isDir": fileInfo.IsDir(), - "type": fileModeTypeNames[fileInfo.Mode()&fs.ModeType], - } -} - -// reverseArgs2 transforms a function that takes two arguments and returns an -// `R` into a function that takes the arguments in reverse order and returns an -// `R`. -func reverseArgs2[T1, T2, R any](f func(T1, T2) R) func(T2, T1) R { - return func(arg1 T2, arg2 T1) R { - return f(arg2, arg1) - } -} diff --git a/templatefuncs_test.go b/templatefuncs_test.go index 72029dd..c3bf79b 100644 --- a/templatefuncs_test.go +++ b/templatefuncs_test.go @@ -7,6 +7,8 @@ import ( "text/template" "github.com/alecthomas/assert/v2" + + "github.com/chezmoi/templatefuncs/internal/transform" ) func TestEachString(t *testing.T) { @@ -37,7 +39,7 @@ func TestEachString(t *testing.T) { }, } { t.Run(strconv.Itoa(i), func(t *testing.T) { - f := eachString(tc.f) + f := transform.EachString(tc.f) assert.Equal(t, tc.expected, f(tc.arg)) }) }