Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add more list funcs #29

Open
wants to merge 9 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,16 @@ jobs:
steps:
- uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29
- uses: actions/setup-go@cdcb36043654635271a94b9a6d1392de5bb323a7
with:
go-version: 'stable'
- run: go test ./...
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29
- uses: actions/setup-go@cdcb36043654635271a94b9a6d1392de5bb323a7
with:
go-version: 'stable'
- uses: golangci/golangci-lint-action@a4f60bb28d35aeee14e6880718e0c85ff1882e64
with:
version: v1.58.1
Expand Down
72 changes: 72 additions & 0 deletions docs/templatefuncs.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,25 @@
# Template Functions

## `compact` *list*

`compact` removes all zero value items from *list*.

```text
{{ list "one" "" list "three" | compact }}

[one three]
```

## `concat` *list*...

`concat` concatenates *list*s into a new list.

```text
{{ concat (list 0 1 2) (list "a" "b" "c") }}

[0 1 2 a b c]
```

## `contains` *substring* *string*

`contains` returns whether *substring* is in *string*.
Expand Down Expand Up @@ -29,6 +49,16 @@ true
{{ `{ "foo": "bar" }` | fromJSON }}
```

## `has` *item* *list*

`has` returns whether *item* is in *list*.

```text
{{ list 1 2 3 | has 3 }}

true
```

## `hasPrefix` *prefix* *string*

`hasPrefix` returns whether *string* begins with *prefix*.
Expand Down Expand Up @@ -69,6 +99,17 @@ foobar
666f6f626172
```

## `indexOf` *item* *list*

`indexOf` returns the index of *item* in *list*, or -1 if *item* is not
in *list*.

```text
{{ list "a" "b" "c" | indexOf "b" }}

1
```

## `join` *delimiter* *list*

`join` returns a string containing each item in *list* joined with *delimiter*.
Expand Down Expand Up @@ -152,6 +193,27 @@ far
adcda
```

## `reverse` *list*

`reverse` returns a copy of *list* in reverse order.

```text
{{ list "a" "b" "c" | reverse }}

[c b a]
```

## `sort` *list*

`sort` returns a copy of *list* sorted in ascending order.
If *list* cannot be sorted, it is simply returned.

```text
{{ list list "c" "a" "b" | sort }}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: duplicated list.


[a b c]
```

## `stat` *path*

`stat` returns a map representation of executing
Expand Down Expand Up @@ -210,3 +272,13 @@ FOOBAR

foobar
```

## `uniq` *list*

`uniq` returns a new list containing only unique elements in *list*.

```text
{{ list 1 2 1 3 3 2 1 2 | uniq }}

[1 2 3]
```
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
module github.com/chezmoi/templatefuncs

go 1.19
go 1.22

require github.com/alecthomas/assert/v2 v2.9.0

Expand Down
133 changes: 133 additions & 0 deletions templatefuncs.go
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
package templatefuncs

import (
"cmp"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io/fs"
"os"
"os/exec"
"reflect"
"regexp"
"slices"
"strconv"
"strings"
"text/template"
Expand All @@ -29,13 +32,17 @@ var fileModeTypeNames = map[fs.FileMode]string{
// functions.
func NewFuncMap() template.FuncMap {
return template.FuncMap{
"compact": compactTemplateFunc,
"concat": slices.Concat[[]any],
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This needs to be more generic so that it can accept []string and other slices. Note that Go cannot automatically convert []any to []string. See https://blog.merovius.de/posts/2018-06-03-why-doesnt-go-have-variance-in/ if you want the gory details.

"contains": reverseArgs2(strings.Contains),
"eqFold": eqFoldTemplateFunc,
"fromJSON": eachByteSliceErr(fromJSONTemplateFunc),
"has": reverseArgs2(slices.Contains[[]any]),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same comment as for concat.

"hasPrefix": reverseArgs2(strings.HasPrefix),
"hasSuffix": reverseArgs2(strings.HasSuffix),
"hexDecode": eachStringErr(hex.DecodeString),
"hexEncode": eachByteSlice(hex.EncodeToString),
"indexOf": reverseArgs2(slices.Index[[]any]),
"join": reverseArgs2(strings.Join),
"list": listTemplateFunc,
"lookPath": eachStringErr(lookPathTemplateFunc),
Expand All @@ -44,15 +51,24 @@ func NewFuncMap() template.FuncMap {
"quote": eachString(strconv.Quote),
"regexpReplaceAll": regexpReplaceAllTemplateFunc,
"replaceAll": replaceAllTemplateFunc,
"reverse": reverseTemplateFunc,
"sort": sortTemplateFunc,
"stat": eachString(statTemplateFunc),
"toJSON": toJSONTemplateFunc,
"toLower": eachString(strings.ToLower),
"toString": toStringTemplateFunc,
"toUpper": eachString(strings.ToUpper),
"trimSpace": eachString(strings.TrimSpace),
"uniq": uniqTemplateFunc,
}
}

// compactTemplateFunc is the core implementation of the `compact` template
// function.
func compactTemplateFunc(list []any) []any {
return slices.DeleteFunc(list, isZeroValue)
}

// eqFoldTemplateFunc is the core implementation of the `eqFold` template
// function.
func eqFoldTemplateFunc(first, second string, more ...string) bool {
Expand Down Expand Up @@ -159,6 +175,62 @@ func regexpReplaceAllTemplateFunc(expr, repl, s string) string {
return regexp.MustCompile(expr).ReplaceAllString(s, repl)
}

// reverseTemplateFunc is the core implementation of the `reverse`
// template function.
func reverseTemplateFunc(list []any) []any {
listcopy := append([]any(nil), list...)
slices.Reverse(listcopy)
return listcopy
}

// sortTemplateFunc is the core implementation of the `sort` template function.
func sortTemplateFunc(list []any) any {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Trying to write a generic sort function is hard. I would instead have separate sortInts and sortFloat64s functions, with sort converting all arguments to strings and then sorting those.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is what I described as terrible sort roughly sufficient?

if len(list) < 2 {
return list
}

firstElemType := reflect.TypeOf(list[0])

for _, elem := range list[1:] {
if reflect.TypeOf(elem) != firstElemType {
return list
}
}

switch firstElemType.Kind() { //nolint:exhaustive
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No need to be exhaustive here. It's sufficient to cover the common types (int, float64, string, and bool).

Copy link
Member Author

@bradenhilton bradenhilton May 20, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If I don't disable exhaustive the linter complains because I'm not accounting for the other Kind cases:

missing cases in switch of type reflect.Kind: reflect.Invalid, reflect.Bool, reflect.Complex64, reflect.Complex128, reflect.Array, reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Pointer|reflect.Ptr, reflect.Slice, reflect.Struct, reflect.UnsafePointer (exhaustive)

image

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can use a default: case which panics with an "unsupported type" message.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm already using a default case, though.

Granted, it doesn't panic, but I just changed it and it also doesn't satisfy the linter:

image

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, in that case feel free to disregard/disable the linter. Note that this exhaustive checking might be covered by multiple linters and you might need to disable all of them. Also, check to see if the warning is coming from golangci-lint with chezmoi's configuration, or if it's an extra linter being run by your IDE.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

https://pkg.go.dev/github.com/nishanths/exhaustive#hdr-Definition_of_exhaustiveness

We can add the -default-signifies-exhaustive flag to the linter if you'd prefer.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, I'm using golangci-lint as the linter in VS Code with Error Lens, as well as Run on Save to format:

{
  "go.lintTool": "golangci-lint",
  "go.lintFlags": ["--fast"],
  "emeraldwalk.runonsave": {
    "commands": [
      {
        "match": "\\.go$",
        "cmd": "gci write ${file} --skip-generated -s standard -s default -s prefix(github.com/chezmoi/templatefuncs)"
      },
      {
        "match": "\\.go$",
        "cmd": "golines --base-formatter=\"gofumpt -extra\" --max-len=128 --write-output ${file}"
      }
    ]
  }
}

case reflect.Int:
return convertAndSortSlice[int](list)
case reflect.Int8:
return convertAndSortSlice[int8](list)
case reflect.Int16:
return convertAndSortSlice[int16](list)
case reflect.Int32:
return convertAndSortSlice[int32](list)
case reflect.Int64:
return convertAndSortSlice[int64](list)
case reflect.Uint:
return convertAndSortSlice[uint](list)
case reflect.Uint8:
return convertAndSortSlice[uint8](list)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This will not work as expected, as []byte is an alias for []uint8 and in other template functions we treat []bytes as strings.

case reflect.Uint16:
return convertAndSortSlice[uint16](list)
case reflect.Uint32:
return convertAndSortSlice[uint32](list)
case reflect.Uint64:
return convertAndSortSlice[uint64](list)
case reflect.Uintptr:
return convertAndSortSlice[uintptr](list)
case reflect.Float32:
return convertAndSortSlice[float32](list)
case reflect.Float64:
return convertAndSortSlice[float64](list)
case reflect.String:
return convertAndSortSlice[string](list)
default:
return list
}
}

// statTemplateFunc is the core implementation of the `stat` template function.
func statTemplateFunc(name string) any {
switch fileInfo, err := os.Stat(name); {
Expand Down Expand Up @@ -207,6 +279,35 @@ func toStringTemplateFunc(arg any) string {
}
}

// uniqTemplateFunc is the core implementation of the `uniq` template function.
func uniqTemplateFunc(list []any) []any {
seen := make(map[any]struct{})
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This will not work as expected. For example, see https://go.dev/play/p/qemCBLcOyPh. I think it can fail in other ways too.

result := []any{}

for _, v := range list {
if _, ok := seen[v]; !ok {
result = append(result, v)
seen[v] = struct{}{}
}
}

return result
}

// convertAndSortSlice creates a `[]T` copy of its input and sorts it.
func convertAndSortSlice[T cmp.Ordered](slice []any) []T {
l := make([]T, len(slice))
for i, elem := range slice {
v, ok := elem.(T)
if !ok {
panic(fmt.Sprintf("unable to convert %v (type %T) to %T", elem, elem, v))
}
l[i] = v
}
slices.Sort(l)
return l
}

// 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.
Expand Down Expand Up @@ -377,6 +478,38 @@ func fileInfoToMap(fileInfo fs.FileInfo) map[string]any {
}
}

// isZeroValue returns whether a value is the zero value for its type.
// An empty array, map or slice is assumed to be a zero value.
func isZeroValue(v any) bool {
truth, ok := template.IsTrue(v)
if !ok {
panic(fmt.Sprintf("unable to determine zero value for %v", v))
}
return !truth
// vval := reflect.ValueOf(v)
// if !vval.IsValid() {
// return true
// }
// switch vval.Kind() { //nolint:exhaustive
// case reflect.Array, reflect.Map, reflect.Slice, reflect.String:
// return vval.Len() == 0
// case reflect.Bool:
// return !vval.Bool()
// case reflect.Complex64, reflect.Complex128:
// return vval.Complex() == 0
// case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
// return vval.Int() == 0
// case reflect.Float32, reflect.Float64:
// return vval.Float() == 0
// case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr:
// return vval.Uint() == 0
// case reflect.Struct:
// return false
// default:
// return vval.IsNil()
// }
}

// 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`.
Expand Down
Loading
Loading