From 33f25372165d7ec0535e005b21bded4bbb0bf7e1 Mon Sep 17 00:00:00 2001 From: Dave Henderson Date: Tue, 26 Jul 2022 13:29:27 -0400 Subject: [PATCH] Add coll.GoSlice and deprecate slice alias Signed-off-by: Dave Henderson --- docs-src/content/functions/coll.yml | 44 +++++-- docs-src/content/functions/conv.yml | 8 +- docs-src/content/functions/func_doc.md.tmpl | 2 +- docs-src/content/functions/strings.yml | 4 +- docs/content/functions/coll.md | 43 ++++++- funcs/coll.go | 17 ++- funcs/coll_test.go | 34 ++++++ funcs/conv.go | 2 +- internal/tests/integration/collection_test.go | 6 +- internal/texttemplate/exec.go | 24 ++++ internal/texttemplate/exec_test.go | 107 ++++++++++++++++++ internal/texttemplate/funcs.go | 79 +++++++++++++ 12 files changed, 349 insertions(+), 21 deletions(-) create mode 100644 internal/texttemplate/exec.go create mode 100644 internal/texttemplate/exec_test.go create mode 100644 internal/texttemplate/funcs.go diff --git a/docs-src/content/functions/coll.yml b/docs-src/content/functions/coll.yml index cf267fe11..3482214d1 100644 --- a/docs-src/content/functions/coll.yml +++ b/docs-src/content/functions/coll.yml @@ -43,6 +43,7 @@ funcs: Hello world! Hello everybody! - name: coll.Slice + deprecated: The `slice` alias is deprecated, use the full name `coll.Slice` instead. alias: slice description: | Creates a slice (like an array or list). Useful when needing to `range` over a bunch of variables. @@ -53,10 +54,39 @@ funcs: description: the elements of the slice examples: - | - $ gomplate -i '{{ range slice "Bart" "Lisa" "Maggie" }}Hello, {{ . }}{{ end }}' + $ gomplate -i '{{ range coll.Slice "Bart" "Lisa" "Maggie" }}Hello, {{ . }}{{ end }}' Hello, Bart Hello, Lisa Hello, Maggie + - name: coll.GoSlice + description: | + This exposes the `slice` function from Go's [`text/template`](https://golang.org/pkg/text/template/#hdr-Functions) + package. Note that using `slice` will use the `coll.Slice` function instead, + which may not be desired. + For some background on this, see [this issue](https://github.com/hairyhenderson/gomplate/issues/1461). + + Here is the upstream documentation: + + ``` + slice returns the result of slicing its first argument by the + remaining arguments. Thus "slice x 1 2" is, in Go syntax, x[1:2], + while "slice x" is x[:], "slice x 1" is x[1:], and "slice x 1 2 3" + is x[1:2:3]. The first argument must be a string, slice, or array. + ``` + + See the [Go language spec](https://go.dev/ref/spec#Slice_expressions) for + more details. + pipeline: false + arguments: + - name: item + required: true + description: the string, slice, or array to slice + - name: indexes... + required: false + description: the indexes to slice the item by (0 to 3 arguments) + examples: + - | + $ gomplate -i '{{ $l := coll.Slice "foo" "bar" "baz" }}{{ if has $l "bar" }}a{{else}}no{{end}} bar' - name: coll.Has alias: has description: | @@ -71,7 +101,7 @@ funcs: description: The item to search for examples: - | - $ gomplate -i '{{ $l := slice "foo" "bar" "baz" }}there is {{ if has $l "bar" }}a{{else}}no{{end}} bar' + $ gomplate -i '{{ $l := coll.Slice "foo" "bar" "baz" }}there is {{ if has $l "bar" }}a{{else}}no{{end}} bar' there is a bar - | $ export DATA='{"foo": "bar"}' @@ -163,7 +193,7 @@ funcs: description: the slice or array to append to examples: - | - $ gomplate -i '{{ slice 1 1 2 3 | append 5 }}' + $ gomplate -i '{{ coll.Slice 1 1 2 3 | append 5 }}' [1 1 2 3 5] - name: coll.Prepend alias: prepend @@ -183,7 +213,7 @@ funcs: description: the slice or array to prepend to examples: - | - $ gomplate -i '{{ slice 4 3 2 1 | prepend 5 }}' + $ gomplate -i '{{ coll.Slice 4 3 2 1 | prepend 5 }}' [5 4 3 2 1] - name: coll.Uniq alias: uniq @@ -198,7 +228,7 @@ funcs: description: the input list examples: - | - $ gomplate -i '{{ slice 1 2 3 2 3 4 1 5 | uniq }}' + $ gomplate -i '{{ coll.Slice 1 2 3 2 3 4 1 5 | uniq }}' [1 2 3 4 5] - name: coll.Flatten alias: flatten @@ -235,7 +265,7 @@ funcs: description: the list to reverse examples: - | - $ gomplate -i '{{ slice 4 3 2 1 | reverse }}' + $ gomplate -i '{{ coll.Slice 4 3 2 1 | reverse }}' [1 2 3 4] - name: coll.Sort alias: sort @@ -257,7 +287,7 @@ funcs: description: the slice or array to sort examples: - | - $ gomplate -i '{{ slice "foo" "bar" "baz" | coll.Sort }}' + $ gomplate -i '{{ coll.Slice "foo" "bar" "baz" | coll.Sort }}' [bar baz foo] - | $ gomplate -i '{{ sort (slice 3 4 1 2 5) }}' diff --git a/docs-src/content/functions/conv.yml b/docs-src/content/functions/conv.yml index 10aa2fc3e..175d58faf 100644 --- a/docs-src/content/functions/conv.yml +++ b/docs-src/content/functions/conv.yml @@ -65,7 +65,7 @@ funcs: For creating more complex maps, see [`data.JSON`](../data/#data-json) or [`data.YAML`](../data/#data-yaml). - For creating arrays, see [`conv.Slice`](#conv-slice). + For creating arrays, see [`coll.Slice`](#coll-slice). arguments: - name: in... required: true @@ -97,7 +97,7 @@ funcs: description: the elements of the slice examples: - | - $ gomplate -i '{{ range slice "Bart" "Lisa" "Maggie" }}Hello, {{ . }}{{ end }}' + $ gomplate -i '{{ range coll.Slice "Bart" "Lisa" "Maggie" }}Hello, {{ . }}{{ end }}' Hello, Bart Hello, Lisa Hello, Maggie @@ -116,7 +116,7 @@ funcs: description: The item to search for examples: - | - $ gomplate -i '{{ $l := slice "foo" "bar" "baz" }}there is {{ if has $l "bar" }}a{{else}}no{{end}} bar' + $ gomplate -i '{{ $l := coll.Slice "foo" "bar" "baz" }}there is {{ if has $l "bar" }}a{{else}}no{{end}} bar' there is a bar - | $ export DATA='{"foo": "bar"}' @@ -141,7 +141,7 @@ funcs: description: the separator examples: - | - $ gomplate -i '{{ $a := slice 1 2 3 }}{{ join $a "-" }}' + $ gomplate -i '{{ $a := coll.Slice 1 2 3 }}{{ join $a "-" }}' 1-2-3 - name: conv.URL alias: urlParse diff --git a/docs-src/content/functions/func_doc.md.tmpl b/docs-src/content/functions/func_doc.md.tmpl index 0e8668de6..36adc7128 100644 --- a/docs-src/content/functions/func_doc.md.tmpl +++ b/docs-src/content/functions/func_doc.md.tmpl @@ -1,7 +1,7 @@ {{ define "argName" }}{{ if not .required }}[{{ .name }}]{{else}}{{ .name }}{{end}}{{ end }} {{- define "usage" }}### Usage -{{- $arguments := index . "arguments" | default slice }} +{{- $arguments := index . "arguments" | default coll.Slice }} {{ if has . "rawUsage" }}{{ .rawUsage | strings.TrimSpace }}{{ else }} ```go {{ .name }}{{ range $a := $arguments }} {{template "argName" $a }}{{end}} diff --git a/docs-src/content/functions/strings.yml b/docs-src/content/functions/strings.yml index bdc127aa8..4631f5f93 100644 --- a/docs-src/content/functions/strings.yml +++ b/docs-src/content/functions/strings.yml @@ -135,7 +135,7 @@ funcs: description: The list to sort examples: - | - $ gomplate -i '{{ (slice "foo" "bar" "baz") | strings.Sort }}' + $ gomplate -i '{{ (coll.Slice "foo" "bar" "baz") | strings.Sort }}' [bar baz foo] - name: strings.Split description: | @@ -267,7 +267,7 @@ funcs: description: The input to quote examples: - | - $ gomplate -i "{{ slice \"one word\" \"foo='bar baz'\" | shellQuote }}" + $ gomplate -i "{{ coll.Slice \"one word\" \"foo='bar baz'\" | shellQuote }}" 'one word' 'foo='"'"'bar baz'"'"'' - | $ gomplate -i "{{ strings.ShellQuote \"it's a banana\" }}" diff --git a/docs/content/functions/coll.md b/docs/content/functions/coll.md index 318783332..c8b9547ba 100644 --- a/docs/content/functions/coll.md +++ b/docs/content/functions/coll.md @@ -60,7 +60,8 @@ Hello world! Hello everybody! ``` -## `coll.Slice` +## `coll.Slice` _(deprecated)_ +**Deprecation Notice:** `slice` alias is deprecated, use `coll.Slice` instead **Alias:** `slice` @@ -81,7 +82,45 @@ coll.Slice in... ### Examples ```console -$ gomplate -i '{{ range slice "Bart" "Lisa" "Maggie" }}Hello, {{ . }}{{ end }}' +$ gomplate -i '{{ range coll.Slice "Bart" "Lisa" "Maggie" }}Hello, {{ . }}{{ end }}' +Hello, Bart +Hello, Lisa +Hello, Maggie +``` + +## `coll.GoSlice` + +This exposes the `slice` function from Go's [`text/template`](https://golang.org/pkg/text/template/#hdr-Functions) +package. Note that using `slice` will use the `coll.Slice` function instead, +which may not be desired. +For some background on this, see [this issue](https://github.com/hairyhenderson/gomplate/issues/1461). + +Here is the upstream documentation: + +``` +slice returns the result of slicing its first argument by the +remaining arguments. Thus "slice x 1 2" is, in Go syntax, x[1:2], +while "slice x" is x[:], "slice x 1" is x[1:], and "slice x 1 2 3" +is x[1:2:3]. The first argument must be a string, slice, or array. +``` + +### Usage + +```go +coll.GoSlice item [indexes...] +``` + +### Arguments + +| name | description | +|------|-------------| +| `item` | _(required)_ the string, slice, or array to slice | +| `indexes...` | _(optional)_ the indexes to slice the item by | + +### Examples + +```console +$ gomplate -i '{{ range coll.Slice "Bart" "Lisa" "Maggie" }}Hello, {{ . }}{{ end }}' Hello, Bart Hello, Lisa Hello, Maggie diff --git a/funcs/coll.go b/funcs/coll.go index 8c6e9f477..9be0bfc2b 100644 --- a/funcs/coll.go +++ b/funcs/coll.go @@ -2,8 +2,11 @@ package funcs import ( "context" + "reflect" "github.com/hairyhenderson/gomplate/v3/conv" + "github.com/hairyhenderson/gomplate/v3/internal/deprecated" + "github.com/hairyhenderson/gomplate/v3/internal/texttemplate" "github.com/hairyhenderson/gomplate/v3/coll" "github.com/pkg/errors" @@ -31,7 +34,7 @@ func CreateCollFuncs(ctx context.Context) map[string]interface{} { f["coll"] = func() interface{} { return ns } f["has"] = ns.Has - f["slice"] = ns.Slice + f["slice"] = ns.deprecatedSlice f["dict"] = ns.Dict f["keys"] = ns.Keys f["values"] = ns.Values @@ -56,6 +59,18 @@ func (CollFuncs) Slice(args ...interface{}) []interface{} { return coll.Slice(args...) } +// deprecatedSlice - +// Deprecated: use coll.Slice instead +func (f *CollFuncs) deprecatedSlice(args ...interface{}) []interface{} { + deprecated.WarnDeprecated(f.ctx, "the 'slice' alias for coll.Slice is deprecated - use coll.Slice instead") + return coll.Slice(args...) +} + +// GoSlice - same as text/template's 'slice' function +func (CollFuncs) GoSlice(item reflect.Value, indexes ...reflect.Value) (reflect.Value, error) { + return texttemplate.GoSlice(item, indexes...) +} + // Has - func (CollFuncs) Has(in interface{}, key string) bool { return coll.Has(in, key) diff --git a/funcs/coll_test.go b/funcs/coll_test.go index 9b6a0b9a8..96bb9248b 100644 --- a/funcs/coll_test.go +++ b/funcs/coll_test.go @@ -2,6 +2,7 @@ package funcs import ( "context" + "reflect" "strconv" "testing" @@ -142,3 +143,36 @@ func TestOmit(t *testing.T) { assert.NoError(t, err) assert.EqualValues(t, map[string]interface{}{}, out) } + +func TestGoSlice(t *testing.T) { + t.Parallel() + + c := &CollFuncs{} + + in := reflect.ValueOf(nil) + _, err := c.GoSlice(in) + assert.Error(t, err) + + in = reflect.ValueOf(42) + _, err = c.GoSlice(in) + assert.Error(t, err) + + // invalid index type + in = reflect.ValueOf([]interface{}{1}) + _, err = c.GoSlice(in, reflect.ValueOf([]interface{}{[]int{2}})) + assert.Error(t, err) + + // valid slice, no slicing + in = reflect.ValueOf([]int{1}) + out, err := c.GoSlice(in) + assert.NoError(t, err) + assert.Equal(t, reflect.TypeOf([]int{}), out.Type()) + assert.EqualValues(t, []int{1}, out.Interface()) + + // valid slice, slicing + in = reflect.ValueOf([]string{"foo", "bar", "baz"}) + out, err = c.GoSlice(in, reflect.ValueOf(1), reflect.ValueOf(3)) + assert.NoError(t, err) + assert.Equal(t, reflect.TypeOf([]string{}), out.Type()) + assert.EqualValues(t, []string{"bar", "baz"}, out.Interface()) +} diff --git a/funcs/conv.go b/funcs/conv.go index 823dccdf0..9fc16fcc4 100644 --- a/funcs/conv.go +++ b/funcs/conv.go @@ -61,7 +61,7 @@ func (ConvFuncs) ToBools(in ...interface{}) []bool { } // Slice - -// Deprecated: use coll.Slice instead +// Deprecated: use [coll.Slice] instead func (f *ConvFuncs) Slice(args ...interface{}) []interface{} { deprecated.WarnDeprecated(f.ctx, "conv.Slice is deprecated - use coll.Slice instead") return coll.Slice(args...) diff --git a/internal/tests/integration/collection_test.go b/internal/tests/integration/collection_test.go index ccdc7a15b..cc83e14d6 100644 --- a/internal/tests/integration/collection_test.go +++ b/internal/tests/integration/collection_test.go @@ -61,9 +61,9 @@ func TestColl_Sort(t *testing.T) { `, "foo\nbaz\nbar\n") inOutTest(t, ` -{{- coll.Sort (slice "b" "a" "c" "aa") }} -{{ coll.Sort (slice "b" 14 "c" "aa") }} -{{ coll.Sort (slice 3.14 3.0 4.0) }} +{{- coll.Sort (coll.Slice "b" "a" "c" "aa") }} +{{ coll.Sort (coll.Slice "b" 14 "c" "aa") }} +{{ coll.Sort (coll.Slice 3.14 3.0 4.0) }} {{ coll.Sort "Scheme" (coll.Slice (conv.URL "zzz:///") (conv.URL "https:///") (conv.URL "http:///")) }} `, `[a aa b c] [b 14 c aa] diff --git a/internal/texttemplate/exec.go b/internal/texttemplate/exec.go new file mode 100644 index 000000000..f4906cb3e --- /dev/null +++ b/internal/texttemplate/exec.go @@ -0,0 +1,24 @@ +// Taken and adapted from the stdlib text/template/funcs.go. +// Copyright 2011 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package texttemplate + +import ( + "reflect" +) + +// indirectInterface returns the concrete value in an interface value, +// or else the zero reflect.Value. +// That is, if v represents the interface value x, the result is the same as reflect.ValueOf(x): +// the fact that x was an interface value is forgotten. +func indirectInterface(v reflect.Value) reflect.Value { + if v.Kind() != reflect.Interface { + return v + } + if v.IsNil() { + return reflect.Value{} + } + return v.Elem() +} diff --git a/internal/texttemplate/exec_test.go b/internal/texttemplate/exec_test.go new file mode 100644 index 000000000..4696b3c4c --- /dev/null +++ b/internal/texttemplate/exec_test.go @@ -0,0 +1,107 @@ +// Taken and adapted from the stdlib text/template/exec_test.go. +// Copyright 2011 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package texttemplate + +import ( + "bytes" + "testing" + gotemplate "text/template" +) + +// T has lots of interesting pieces to use to test execution. +type T struct { + Tmpl *gotemplate.Template + Empty3 any + S string + SI []int + SICap []int + AI [3]int +} + +var tVal = &T{ + S: "xyz", + SI: []int{3, 4, 5}, + SICap: make([]int, 5, 10), + AI: [3]int{3, 4, 5}, + Empty3: []int{7, 8}, + Tmpl: gotemplate.Must(gotemplate.New("").Parse("test template")), +} + +//nolint:govet +type execTest struct { + name string + input string + output string + data any + ok bool +} + +var execTests = []execTest{ + // Slicing. + {"slice[:]", "{{slice .SI}}", "[3 4 5]", tVal, true}, + {"slice[1:]", "{{slice .SI 1}}", "[4 5]", tVal, true}, + {"slice[1:2]", "{{slice .SI 1 2}}", "[4]", tVal, true}, + {"slice[-1:]", "{{slice .SI -1}}", "", tVal, false}, + {"slice[1:-2]", "{{slice .SI 1 -2}}", "", tVal, false}, + {"slice[1:2:-1]", "{{slice .SI 1 2 -1}}", "", tVal, false}, + {"slice[2:1]", "{{slice .SI 2 1}}", "", tVal, false}, + {"slice[2:2:1]", "{{slice .SI 2 2 1}}", "", tVal, false}, + {"out of range", "{{slice .SI 4 5}}", "", tVal, false}, + {"out of range", "{{slice .SI 2 2 5}}", "", tVal, false}, + {"len(s) < indexes < cap(s)", "{{slice .SICap 6 10}}", "[0 0 0 0]", tVal, true}, + {"len(s) < indexes < cap(s)", "{{slice .SICap 6 10 10}}", "[0 0 0 0]", tVal, true}, + {"indexes > cap(s)", "{{slice .SICap 10 11}}", "", tVal, false}, + {"indexes > cap(s)", "{{slice .SICap 6 10 11}}", "", tVal, false}, + {"array[:]", "{{slice .AI}}", "[3 4 5]", tVal, true}, + {"array[1:]", "{{slice .AI 1}}", "[4 5]", tVal, true}, + {"array[1:2]", "{{slice .AI 1 2}}", "[4]", tVal, true}, + {"string[:]", "{{slice .S}}", "xyz", tVal, true}, + {"string[0:1]", "{{slice .S 0 1}}", "x", tVal, true}, + {"string[1:]", "{{slice .S 1}}", "yz", tVal, true}, + {"string[1:2]", "{{slice .S 1 2}}", "y", tVal, true}, + {"out of range", "{{slice .S 1 5}}", "", tVal, false}, + {"3-index slice of string", "{{slice .S 1 2 2}}", "", tVal, false}, + {"slice of an interface field", "{{slice .Empty3 0 1}}", "[7]", tVal, true}, +} + +func testExecute(execTests []execTest, template *gotemplate.Template, t *testing.T) { + b := new(bytes.Buffer) + funcs := gotemplate.FuncMap{"slice": GoSlice} + + for _, test := range execTests { + var tmpl *gotemplate.Template + var err error + if template == nil { + tmpl, err = gotemplate.New(test.name).Funcs(funcs).Parse(test.input) + } else { + tmpl, err = template.New(test.name).Funcs(funcs).Parse(test.input) + } + if err != nil { + t.Errorf("%s: parse error: %s", test.name, err) + continue + } + b.Reset() + err = tmpl.Execute(b, test.data) + switch { + case !test.ok && err == nil: + t.Errorf("%s: expected error; got none", test.name) + continue + case test.ok && err != nil: + t.Errorf("%s: unexpected execute error: %s", test.name, err) + continue + case !test.ok && err != nil: + // expected error, got one + } + result := b.String() + if result != test.output { + t.Errorf("%s: expected\n\t%q\ngot\n\t%q", test.name, test.output, result) + } + } +} + +func TestExecute(t *testing.T) { + testExecute(execTests, nil, t) +} diff --git a/internal/texttemplate/funcs.go b/internal/texttemplate/funcs.go new file mode 100644 index 000000000..8658af6f3 --- /dev/null +++ b/internal/texttemplate/funcs.go @@ -0,0 +1,79 @@ +// Taken and adapted from the stdlib text/template/funcs.go. +// Copyright 2011 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package texttemplate + +import ( + "fmt" + "reflect" +) + +// indexArg checks if a reflect.Value can be used as an index, and converts it to int if possible. +func indexArg(index reflect.Value, cap int) (int, error) { + var x int64 + switch index.Kind() { + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + x = index.Int() + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr: + x = int64(index.Uint()) + case reflect.Invalid: + return 0, fmt.Errorf("cannot index slice/array with nil") + default: + return 0, fmt.Errorf("cannot index slice/array with type %s", index.Type()) + } + if x < 0 || int(x) < 0 || int(x) > cap { + return 0, fmt.Errorf("index out of range: %d", x) + } + return int(x), nil +} + +// Slicing. + +// slice returns the result of slicing its first argument by the remaining +// arguments. Thus "slice x 1 2" is, in Go syntax, x[1:2], while "slice x" +// is x[:], "slice x 1" is x[1:], and "slice x 1 2 3" is x[1:2:3]. The first +// argument must be a string, slice, or array. +func GoSlice(item reflect.Value, indexes ...reflect.Value) (reflect.Value, error) { + item = indirectInterface(item) + if !item.IsValid() { + return reflect.Value{}, fmt.Errorf("slice of untyped nil") + } + if len(indexes) > 3 { + return reflect.Value{}, fmt.Errorf("too many slice indexes: %d", len(indexes)) + } + var cap int + switch item.Kind() { + case reflect.String: + if len(indexes) == 3 { + return reflect.Value{}, fmt.Errorf("cannot 3-index slice a string") + } + cap = item.Len() + case reflect.Array, reflect.Slice: + cap = item.Cap() + default: + return reflect.Value{}, fmt.Errorf("can't slice item of type %s", item.Type()) + } + // set default values for cases item[:], item[i:]. + idx := [3]int{0, item.Len()} + for i, index := range indexes { + x, err := indexArg(index, cap) + if err != nil { + return reflect.Value{}, err + } + idx[i] = x + } + // given item[i:j], make sure i <= j. + if idx[0] > idx[1] { + return reflect.Value{}, fmt.Errorf("invalid slice index: %d > %d", idx[0], idx[1]) + } + if len(indexes) < 3 { + return item.Slice(idx[0], idx[1]), nil + } + // given item[i:j:k], make sure i <= j <= k. + if idx[1] > idx[2] { + return reflect.Value{}, fmt.Errorf("invalid slice index: %d > %d", idx[1], idx[2]) + } + return item.Slice3(idx[0], idx[1], idx[2]), nil +}