diff --git a/.github/codecov.yml b/.github/codecov.yml index c279ff5..1893f1c 100644 --- a/.github/codecov.yml +++ b/.github/codecov.yml @@ -15,6 +15,7 @@ github_checks: ignore: - benchmark/** - docs/** + - sprig_functions_not_included_in_sprout.go comment: behavior: new require_changes: true diff --git a/README.md b/README.md index 8f9cb84..5b7098f 100644 --- a/README.md +++ b/README.md @@ -184,17 +184,19 @@ To see all the benchmarks, please refer to the [benchmarks](benchmarks/README.md goos: linux goarch: amd64 pkg: sprout_benchmarks -cpu: Intel(R) Core(TM) i7-9750H CPU @ 2.60GHz -BenchmarkSprig-12 1 3869134593 ns/op 45438616 B/op 24098 allocs/op -BenchmarkSprout-12 1 1814126036 ns/op 38284040 B/op 11627 allocs/op +cpu: Intel(R) Core(TM) i9-9900K CPU @ 3.60GHz +BenchmarkSprig-16 1 2152506421 ns/op 44671136 B/op 21938 allocs/op +BenchmarkSprout-16 1 1020721871 ns/op 37916984 B/op 11173 allocs/op PASS -ok sprout_benchmarks 5.910s +ok sprout_benchmarks 3.720s ``` -**Time improvement**: 53.1% -**Memory improvement**: 15.7% +**Time improvement**: ((2152506421 - 1020721871) / 2152506421) * 100 = 52.6% +**Memory improvement**: ((44671136 - 37916984) / 44671136) * 100 = 15.1% -So, Sprout v0.2 is approximately 53.1% faster and uses 15.7% less memory than Sprig v3.2.3. 🚀 +So, Sprout v0.3 is approximately 52.6% faster and uses 15.1% less memory than Sprig v3.2.3. 🚀 + +You can see the full benchmark results [here](benchmarks/README.md). ## Development Philosophy (Currently in reflexion to create our) diff --git a/SPRIG_TO_SPROUT_CHANGES_NOTES.md b/SPRIG_TO_SPROUT_CHANGES_NOTES.md new file mode 100644 index 0000000..3d74912 --- /dev/null +++ b/SPRIG_TO_SPROUT_CHANGES_NOTES.md @@ -0,0 +1,101 @@ +# Migration Notes for Sprout Library +This document outlines the key differences and migration changes between the +Sprig and Sprout libraries. The changes are designed to enhance stability and +usability in the Sprout library. + +This document will help contributors and maintainers understand the changes made +between the fork date and version 1.0.0 of the Sprout library. + +It will be updated to reflect changes in future versions of the library. + +This document will also assist in creating the migration guide when version 1.0 is ready. + + +## Error Handling Enhancements +### General Error Handling +In Sprig, errors within certain functions cause a panic. +In contrast, Sprout opts for returning nil or an empty value, improving safety +and predictability. + +**Old Behavior (Sprig)**: Triggers a panic on error +```go +if err != nil { + panic("deepCopy error: " + err.Error()) +} +``` + +**New Behavior (Sprout)**: Returns nil or an empty value on error +```go +if err != nil { + return nil, err +} +``` + +Methods that previously caused a panic in Sprig : +- DeepCopy +- MustDeepCopy +- ToRawJson +- Append +- Prepend +- Concat +- Chunk +- Uniq +- Compact +- Slice +- Without +- Rest +- Initial +- Reverse +- First +- Last +- Has +- Dig +- RandAlphaNumeric +- RandAlpha +- RandAscii +- RandNumeric +- RandBytes + +## Function-Specific Changes + +### MustDeepCopy + +- **Sprig**: Accepts `nil` input, causing an internal panic. +- **Sprout**: Returns `nil` if input is `nil`, avoiding panic. + +## Rand Functions + +- **Sprig**: Causes an internal panic if the length parameter is zero. +- **Sprout**: Returns an empty string if the length is zero, ensuring stability. + +## DateAgo + +- **Sprig**: Does not support int32 and *time.Time; returns "0s". +- **Sprout**: Supports int32 and *time.Time and returns the correct duration. + +## DateRound +- **Sprig**: Returns a corrected duration in positive form, even for negative inputs. +- **Sprout**: Accurately returns the duration, preserving the sign of the input. + +## Base32Decode / Base64Decode +- **Sprig**: Decoding functions return the error string when the input is not a valid base64 encoded string. +- **Sprout**: Decoding functions return an empty string if the input is not a valid base64 encoded string, simplifying error handling. + +## Dig +> Consider the example dictionary defined as follows: +> ```go +> dict := map[string]any{ +> "a": map[string]any{ +> "b": 2, +> }, +> } +> ``` + +- **Sprig**: Previously, the `dig` function would return the last map in the access chain. +```go +{{ $dict | dig "a" "b" }} // Output: map[b:2] +``` +- **Sprout**: Now, the `dig` function returns the final object in the chain, regardless of its type (map, array, string, etc.). +```go +{{ $dict | dig "a" "b" }} // Output: 2 +``` diff --git a/alias.go b/alias.go index 517a749..3de8ac4 100644 --- a/alias.go +++ b/alias.go @@ -7,17 +7,42 @@ type FunctionAliasMap map[string][]string // The following functions are provided for backwards compatibility with the // original sprig methods. They are not recommended for use in new code. var bc_registerSprigFuncs = FunctionAliasMap{ - "dateModify": {"date_modify"}, //! Deprecated: Should use dateModify instead - "dateInZone": {"date_in_zone"}, //! Deprecated: Should use dateInZone instead - "mustDateModify": {"must_date_modify"}, //! Deprecated: Should use mustDateModify instead - "ellipsis": {"abbrev"}, //! Deprecated: Should use ellipsis instead - "ellipsisBoth": {"abbrevboth"}, //! Deprecated: Should use ellipsisBoth instead - "trimAll": {"trimall"}, //! Deprecated: Should use trimAll instead - "int": {"atoi"}, //! Deprecated: Should use toInt instead - "append": {"push"}, //! Deprecated: Should use append instead - "mustAppend": {"mustPush"}, //! Deprecated: Should use mustAppend instead - "list": {"tuple"}, // FIXME: with the addition of append/prepend these are no longer immutable. - "max": {"biggest"}, + "dateModify": {"date_modify"}, //! Deprecated: Should use dateModify instead + "dateInZone": {"date_in_zone"}, //! Deprecated: Should use dateInZone instead + "mustDateModify": {"must_date_modify"}, //! Deprecated: Should use mustDateModify instead + "ellipsis": {"abbrev"}, //! Deprecated: Should use ellipsis instead + "ellipsisBoth": {"abbrevboth"}, //! Deprecated: Should use ellipsisBoth instead + "trimAll": {"trimall"}, //! Deprecated: Should use trimAll instead + "append": {"push"}, //! Deprecated: Should use append instead + "mustAppend": {"mustPush"}, //! Deprecated: Should use mustAppend instead + "list": {"tuple"}, // FIXME: with the addition of append/prepend these are no longer immutable. + "max": {"biggest"}, //! Deprecated: Should use max instead + "toUpper": {"upper", "toupper", "uppercase"}, //! Deprecated: Should use toUpper instead + "toLower": {"lower", "tolower", "lowercase"}, //! Deprecated: Should use toLower instead + "add": {"addf"}, //! Deprecated: Should use add instead + "add1": {"add1f"}, //! Deprecated: Should use add1 instead + "sub": {"subf"}, //! Deprecated: Should use sub instead + "toTitleCase": {"title", "titlecase"}, //! Deprecated: Should use toTitleCase instead + "toCamelCase": {"camel", "camelcase"}, //! Deprecated: Should use toCamelCase instead + "toSnakeCase": {"snake", "snakecase"}, //! Deprecated: Should use toSnakeCase instead + "toKebabCase": {"kebab", "kebabcase"}, //! Deprecated: Should use toKebabCase instead + "swapCase": {"swapcase"}, //! Deprecated: Should use swapCase instead + "base64Encode": {"b64enc"}, //! Deprecated: Should use base64Encode instead + "base64Decode": {"b64dec"}, //! Deprecated: Should use base64Decode instead + "base32Encode": {"b32enc"}, //! Deprecated: Should use base32Encode instead + "base32Decode": {"b32dec"}, //! Deprecated: Should use base32Decode instead + "pathBase": {"base"}, //! Deprecated: Should use pathBase instead + "pathDir": {"dir"}, //! Deprecated: Should use pathDir instead + "pathExt": {"ext"}, //! Deprecated: Should use pathExt instead + "pathClean": {"clean"}, //! Deprecated: Should use pathClean instead + "pathIsAbs": {"isAbs"}, //! Deprecated: Should use pathIsAbs instead + "expandEnv": {"expandenv"}, //! Deprecated: Should use expandEnv instead + "dateAgo": {"ago"}, //! Deprecated: Should use dateAgo instead + "strSlice": {"toStrings"}, //! Deprecated: Should use strSlice instead + "toInt": {"int", "atoi"}, //! Deprecated: Should use toInt instead + "toInt64": {"int64"}, //! Deprecated: Should use toInt64 instead + "toFloat64": {"float64"}, //! Deprecated: Should use toFloat64 instead + "toOctal": {"toDecimal"}, //! Deprecated: Should use toOctal instead } //\ BACKWARDS COMPATIBILITY @@ -89,19 +114,19 @@ func WithAliases(aliases FunctionAliasMap) FunctionHandlerOption { // It should be called after all aliases have been added through the WithAlias // option and before the function map is used to ensure all aliases are properly // registered. -func (p *FunctionHandler) registerAliases() { +func (fh *FunctionHandler) registerAliases() { // BACKWARDS COMPATIBILITY // Register the sprig function aliases for originalFunction, aliases := range bc_registerSprigFuncs { for _, alias := range aliases { - p.funcMap[alias] = p.funcMap[originalFunction] + fh.funcMap[alias] = fh.funcMap[originalFunction] } } //\ BACKWARDS COMPATIBILITY - for originalFunction, aliases := range p.funcsAlias { + for originalFunction, aliases := range fh.funcsAlias { for _, alias := range aliases { - p.funcMap[alias] = p.funcMap[originalFunction] + fh.funcMap[alias] = fh.funcMap[originalFunction] } } } diff --git a/alias_test.go b/alias_test.go index f71ff7e..b7d3760 100644 --- a/alias_test.go +++ b/alias_test.go @@ -94,7 +94,7 @@ func TestAliasesInTemplate(t *testing.T) { WithAlias(originalFuncName, alias1, alias2)(handler) // Create a template with the aliases. - result, err := runTemplate(t, handler, `{{originalFunc}} {{alias1}} {{alias2}}`) + result, err := runTemplate(t, handler, `{{originalFunc}} {{alias1}} {{alias2}}`, nil) assert.NoError(t, err) assert.Equal(t, "cheese cheese cheese", result) } diff --git a/benchmarks/README.md b/benchmarks/README.md index 4e67cbf..89429ed 100644 --- a/benchmarks/README.md +++ b/benchmarks/README.md @@ -1,6 +1,6 @@ # Benchmarks outputs -## Sprig v3.2.3 vs Sprout v0.1 +## Sprig v3.2.3 vs Sprout v0.2 ``` go test -count=1 -bench ^Benchmark -benchmem -cpuprofile cpu.out -memprofile mem.out goos: linux @@ -16,4 +16,23 @@ ok sprout_benchmarks 5.910s **Time improvement**: ((3869134593 - 1814126036) / 3869134593) * 100 = 53.1% **Memory improvement**: ((45438616 - 38284040) / 45438616) * 100 = 15.7% -So, Sprout v0.1 is approximately 53.1% faster and uses 15.7% less memory than Sprig v3.2.3. +So, Sprout v0.2 is approximately 53.1% faster and uses 15.7% less memory than Sprig v3.2.3. + +## Sprig v3.2.3 vs Sprout v0.3 + +``` +go test -count=1 -bench ^Benchmark -benchmem -cpuprofile cpu.out -memprofile mem.out +goos: linux +goarch: amd64 +pkg: sprout_benchmarks +cpu: Intel(R) Core(TM) i9-9900K CPU @ 3.60GHz +BenchmarkSprig-16 1 2152506421 ns/op 44671136 B/op 21938 allocs/op +BenchmarkSprout-16 1 1020721871 ns/op 37916984 B/op 11173 allocs/op +PASS +ok sprout_benchmarks 3.720s +``` + +**Time improvement**: ((2152506421 - 1020721871) / 2152506421) * 100 = 52.6% +**Memory improvement**: ((44671136 - 37916984) / 44671136) * 100 = 15.1% + +So, Sprout v0.3 is approximately 52.6% faster and uses 15.1% less memory than Sprig v3.2.3. diff --git a/benchmarks/allFunctions.sprig.tmpl b/benchmarks/allFunctions.sprig.tmpl index de2f5c2..44b37de 100644 --- a/benchmarks/allFunctions.sprig.tmpl +++ b/benchmarks/allFunctions.sprig.tmpl @@ -184,7 +184,7 @@ Without: {{without (list 1 2 3) 2}} Has: {{hasKey $dict "key"}} Slice: {{slice (list 1 2 3) 1 2}} Concat: {{concat (list 1 2) (list 3 4)}} -Dig: {{dict "a" 1 | dig "a" ""}} +Dig: {{ dig "b" "a" (dict "a" 1 "b" (dict "a" 2)) }} Chunk: {{list 1 2 3 4 5 | chunk 2}} {{/* Crypt Functions */}} diff --git a/benchmarks/allFunctions.sprout.tmpl b/benchmarks/allFunctions.sprout.tmpl index de2f5c2..44b37de 100644 --- a/benchmarks/allFunctions.sprout.tmpl +++ b/benchmarks/allFunctions.sprout.tmpl @@ -184,7 +184,7 @@ Without: {{without (list 1 2 3) 2}} Has: {{hasKey $dict "key"}} Slice: {{slice (list 1 2 3) 1 2}} Concat: {{concat (list 1 2) (list 3 4)}} -Dig: {{dict "a" 1 | dig "a" ""}} +Dig: {{ dig "b" "a" (dict "a" 1 "b" (dict "a" 2)) }} Chunk: {{list 1 2 3 4 5 | chunk 2}} {{/* Crypt Functions */}} diff --git a/benchmarks/comparaison_test.go b/benchmarks/comparaison_test.go index 39eb131..06be1c1 100644 --- a/benchmarks/comparaison_test.go +++ b/benchmarks/comparaison_test.go @@ -3,11 +3,13 @@ package benchmarks_test import ( "bytes" "log/slog" + "sync" "testing" "text/template" "github.com/42atomys/sprout" "github.com/Masterminds/sprig/v3" + "github.com/stretchr/testify/assert" ) /** @@ -73,3 +75,35 @@ func BenchmarkSprout(b *testing.B) { buf.Reset() } } + +func TestCompareSprigAndSprout(t *testing.T) { + wg := sync.WaitGroup{} + + wg.Add(2) + + var bufSprig, bufSprout bytes.Buffer + + go func() { + defer wg.Done() + + tmplSprig, err := template.New("compare").Funcs(sprig.FuncMap()).ParseFiles("compare.tmpl") + assert.NoError(t, err) + + err = tmplSprig.ExecuteTemplate(&bufSprig, "compare.tmpl", nil) + assert.NoError(t, err) + }() + + go func() { + defer wg.Done() + + tmplSprout, err := template.New("compare").Funcs(sprout.FuncMap()).ParseGlob("compare.tmpl") + assert.NoError(t, err) + + err = tmplSprout.ExecuteTemplate(&bufSprout, "compare.tmpl", nil) + assert.NoError(t, err) + }() + + wg.Wait() + // sprig is expected and sprout is actual + assert.Equal(t, bufSprig.String(), bufSprout.String()) +} diff --git a/benchmarks/compare.tmpl b/benchmarks/compare.tmpl new file mode 100644 index 0000000..b5f8988 --- /dev/null +++ b/benchmarks/compare.tmpl @@ -0,0 +1,220 @@ +{{/* compare.tpl: Used to compare sprig to sprout */}} + +Hello: {{ hello }} + +{{- $str := "Hello, World!" -}} +{{- $dict := dict "key" "value" -}} +{{- $list := list 1 2 3 -}} +{{- $tuple := tuple 1 2 3 -}} +{{- $set := set $dict "key2" "value2" -}} +{{- $date := toDate "2006-01-02" "2017-12-31" -}} + +{{- $d := dict "one" 1 "two" 222222 -}} +{{- $d2 := dict "one" 1 "two" 33333 -}} +{{- $d3 := dict "one" 1 -}} +{{- $d4 := dict "one" 1 "two" 4444 -}} + +{{/* Date functions */}} +Ago: {{ago $date }} +Date: {{date "Monday, 02 Jan 2006 15:04:05 MST" 0}} +DateInZone: {{dateInZone "Monday, 02 Jan 2006 15:04:05 MST" 0 "UTC"}} +DateModify: {{$date | dateModify "-1.5h"}} +Duration: {{duration "1h30m"}} +DurationRound: {{durationRound "2h10m5s"}} | {{ durationRound "2400h10m5s" }} +HtmlDate: {{htmlDate "2006-01-02T15:04:05Z"}} +HtmlDateInZone: {{htmlDateInZone "2006-01-02T15:04:05Z" "UTC"}} +MustDateModify: {{$date | mustDateModify "-1.5h"}} +MustToDate: {{mustToDate "2006-01-02" "2017-12-31" | date "02/01/2006"}} +Now: {{now | date "02/01/2006"}} +ToDate: {{toDate "2006-01-02" "2017-12-31" | date "02/01/2006"}} +UnixEpoch: {{$date | unixEpoch}} + + +{{/* String Functions */}} +Abbrev: {{$str | abbrev 5}} +AbbrevBoth: {{$str | abbrevboth 1 2}} +Trunc: {{$str | trunc 5}} | {{$str | trunc -5}} +Trim: {{trim " hello "}} +Upper: {{upper $str}} +Lower: {{lower $str}} +Title: {{title $str}} +UnTitle: {{untitle $str}} +Substr: {{substr 0 5 "hello world"}} +Repeat: {{repeat 3 "hello"}} +TrimAll: {{trimAll "$" "$5.00"}} +TrimSuffix: {{trimSuffix "-" "hello-"}} +TrimPrefix: {{trimPrefix "-" "-hello"}} +NoSpace: {{nospace "Hello, World!"}} +Initials: {{initials "Hello, World!"}} +SwapCase: {{swapcase "Hello, World!"}} +SnakeCase: {{snakecase "Hello, World!"}} +CamelCase: {{camelcase "Hello, World!"}} +KebabCase: {{kebabcase "Hello, World!"}} +Wrap: {{"Hello, World!" | wrap 5}} +WrapWith: {{"Hello World!" | wrapWith 10 "\\n"}} +Contains: {{contains $str "World"}} +HasPrefix: {{hasPrefix $str "Hello"}} +HasSuffix: {{hasSuffix $str "World"}} +Quote: {{quote $str}} +SQuote: {{squote $str}} +Cat: {{cat "Hello" ", " "World!"}} +Indent: {{"Hello, World!" | indent 5}} +Nindent: {{"Hello, World!" | nindent 5}} +Replace: {{$str | replace "World" "Sprig"}} +Plural: {{2 | plural "apple" "apples"}} | {{1 | plural "apple" "apples"}} +Sha1sum: {{sha1sum $str}} +Sha256sum: {{sha256sum $str}} +Adler32sum: {{adler32sum $str}} +toString: {{toString 5}} + +Atoi: {{atoi "5"}} +Int64: {{int64 5}} +Int: {{int 5}} +Float64: {{float64 5}} +ToDecimal: {{toDecimal "0777"}} + +Split: {{"Hello, World!" | split ", "}} +SplitList: {{"Hello, World!" | splitList ", "}} +SplitN: {{"Hello, World!" | splitn ", " 1}} +toStrings: {{list 1 2 3 | toStrings}} +Until: {{until 5}} +UntilStep: {{untilStep 3 6 2}} + +Add1: {{add1 5}} +Add: {{add 5 10}} +Sub: {{sub 10 5}} +Mul: {{mul 5 10}} +Div: {{div 10 5}} +Mod: {{mod 10 5}} +Add1f: {{add1f 5}} +Addf: {{addf 5 10}} +Subf: {{subf 10 5}} +Mulf: {{mulf 5 10}} +Divf: {{divf 10 5}} +Biggest: {{biggest 5 10}} +Max: {{max 5 6 10}} +Min: {{min 5 6 10}} +Ceil: {{ceil 123.001}} +Floor: {{floor 123.9999}} +Round: {{round 123.555555 3}} +Maxf: {{maxf 5.5 10.5}} +Minf: {{minf 5.5 10.5}} +Seq: {{seq 5}} | {{seq -3}} | {{seq 0 2}} | {{seq 2 -2}} | {{seq 0 2 10}} | {{seq 0 -2 -5}} + +Join: {{list "Hello" "World!" | join ", "}} | {{list 1 2 3 | join "+"}} +SortAlpha: {{list "Hello" "World" "from" "Sprig" | sortAlpha}} + +{{/* Default Functions */}} +{{- $defaultVal := "defaultValue" -}} +Default: {{default "DefaultValue" $defaultVal}} +Empty: {{empty ""}} +Coal: {{coalesce "" "Hello, World!"}} +All: {{all "" "Hello, World!"}} +Any: {{any "" "Hello, World!"}} +Compact: {{list 1 "a" "foo" "" | compact}} +MustCompact: {{list 1 "a" "foo" "" | mustCompact}} +FromJson: {{fromJson "{\"key\": \"value\"}"}} +MustFromJson: {{mustFromJson "{\"key\": \"value\"}"}} +ToJson: {{toJson $dict}} +MustToJson: {{mustToJson $dict}} +ToPrettyJson: {{toPrettyJson $dict}} +ToRawJson: {{toRawJson $dict}} +Ternary: {{true | ternary "true" "false"}} +DeepCopy: {{deepCopy $dict}} +MustDeepCopy: {{mustDeepCopy $dict}} + +TypeOf: {{typeOf 5}} +TypeIs: {{5 | typeIs "int"}} +TypeIsLike: {{5 | typeIsLike "int"}} +KindOf: {{kindOf 5}} +KindIs: {{5 | kindIs "int"}} +DeepEqual: {{ deepEqual (list 1 2 3) (list 1 2 3) }} + + +{{/* OS Functions */}} +Env: {{env "PATH"}} +Expandenv: {{expandenv "$PATH"}} +Base Path: {{base "/path/to/file.txt"}} +Dir: {{dir "/path/to/file.txt"}} +Ext: {{ext "/path/to/file.txt"}} +Clean: {{clean "/path/to/file.txt"}} +IsAbs: {{isAbs "/path/to/file.txt"}} + +OsBase: {{osBase "/path/to/file.txt"}} +OsDir: {{osDir "/path/to/file.txt"}} +OsExt: {{osExt "/path/to/file.txt"}} +OsClean: {{osClean "/path/to/file.txt"}} +OsIsAbs: {{osIsAbs "/path/to/file.txt"}} + +{{/* Encoding Functions */}} +Base64Encode: {{b64enc "Hello, World!"}} +Base64Decode: {{b64dec "SGVsbG8sIFdvcmxkIQ=="}} +Base32Encode: {{b32enc "Hello, World!"}} +Base32Decode: {{b32dec "JBSWY3DPEBLW64TMMQQQ===="}} + +{{/* Data Struct Functions */}} +Tuple: {{tuple 1 2 3}} +List: {{list 1 2 3}} +Dict: {{dict "key" "value"}} +Get: {{get $dict "key"}} +Set: {{set $dict "key" "newValue"}} +Unset: {{unset $dict "key"}} +has: {{has 4 (list 1 2 4)}} +Pluck: {{pluck "two" $d $d2 $d3 $d4}} +Keys: {{keys $dict}} +Pick: {{pick $dict "key"}} +Omit: {{omit $dict "key"}} +Merge: {{merge $dict (dict "key2" "value2")}} +MergeOverwrite: {{mergeOverwrite $dict (dict "key" "value2")}} +Values: {{values $dict}} +Append: {{append (list 1 2) 3}} +Prepend: {{prepend (list 2 3) 1}} +First: {{first (list 1 2 3)}} +Rest: {{rest (list 1 2 3)}} +Last: {{last (list 1 2 3)}} +Initial: {{initial (list 1 2 3)}} +Reverse: {{reverse (list 1 2 3)}} +Uniq: {{uniq (list 1 2 2 3)}} +Without: {{without (list 1 2 3) 2}} | {{without (list 1 2 3) 2 3}} +HasKey: {{hasKey $dict "key"}} +Slice: {{slice (list 1 2 3) 1 2}} | {{slice (list 1 2 3) 1}} +Concat: {{concat (list 1 2) (list 3 4)}} +# Incompatible with sprig +# Dig: {{/* dig "b" "a" (dict "a" 1 "b" (dict "a" 2)) */}} +Chunk: {{list 1 2 3 4 5 | chunk 2}} + +{{/* Crypt Functions */}} +derivePassword: {{derivePassword 1 "long" "password" "user" "example.com"}} + +Semver: {{semver "1.2.3"}} +SemverCompare: {{semverCompare "^1.2.0" "1.2.3"}} + +{{/* Regex Functions */}} +RegexMatch: {{regexMatch "^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$" "test@acme.com"}} +RegexFindAll: {{regexFindAll "[2,4,6,8]" "123456789" -1}} +RegexFind: {{regexFind "[a-zA-Z][1-9]" "abcd1234"}} +RegexReplaceAll: {{regexReplaceAll "a(x*)b" "-ab-axxb-" "${1}W"}} +RegexReplaceAllLiteral: {{regexReplaceAllLiteral "a(x*)b" "-ab-axxb-" "${1}"}} +RegexSplit: {{regexSplit "z+" "pizza" -1}} +RegexQuoteMeta: {{regexQuoteMeta "1.2.3"}} + +{{/* URL Functions */}} +UrlParse: {{index ( urlParse "proto://auth@host:80/path?query#fragment" ) "host" }} +UrlJoin: {{urlJoin (dict "fragment" "fragment" "host" "host:80" "path" "/path" "query" "query" "scheme" "proto")}} + + +{{/* Cannot be matched due to random */}} +{{/* UUIDv4: {{uuidv4}} */}} +{{/* RandBytes: {{randBytes 10}} */}} +{{/* EncryptAES: {{encryptAES "secretkey" "plaintext"}} */}} +{{/* DecryptAES: {{"30tEfhuJSVRhpG97XCuWgz2okj7L8vQ1s6V9zVUPeDQ=" | decryptAES "secretkey"}} */}} +{{/* Bcrypt: {{bcrypt "Hello, World!"}} */}} +{{/* Htpasswd: {{htpasswd "myUser" "myPassword"}} */}} +{{/* genPrivateKey: {{genPrivateKey "rsa"}} | {{genPrivateKey "dsa"}} | {{genPrivateKey "ecdsa"}} | {{genPrivateKey "ed25519"}} */}} +{{/* RandInt: {{randInt 5 10}} */}} +{{/* RandAlphaNum: {{randAlphaNum 10}} */}} +{{/* RandAlpha: {{randAlpha 10}} */}} +{{/* RandAscii: {{randAscii 10}} */}} +{{/* RandNumeric: {{randNumeric 10}} */}} +{{/* Shuffle: {{shuffle "Hello, World!"}} */}} +{{/* GetHostByName: {{getHostByName "localhost"}} */}} diff --git a/benchmarks/go.mod b/benchmarks/go.mod index 049af7f..bf6fc88 100644 --- a/benchmarks/go.mod +++ b/benchmarks/go.mod @@ -5,21 +5,25 @@ go 1.21.0 require ( github.com/42atomys/sprout v0.0.0-20240331203623-2cbfca5974e1 github.com/Masterminds/sprig/v3 v3.2.3 + github.com/stretchr/testify v1.9.0 ) require ( dario.cat/mergo v1.0.0 // indirect github.com/Masterminds/goutils v1.1.1 // indirect github.com/Masterminds/semver/v3 v3.2.1 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect github.com/google/uuid v1.6.0 // indirect github.com/huandu/xstrings v1.4.0 // indirect github.com/imdario/mergo v0.3.11 // indirect github.com/mitchellh/copystructure v1.2.0 // indirect github.com/mitchellh/reflectwalk v1.0.2 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect github.com/shopspring/decimal v1.3.1 // indirect github.com/spf13/cast v1.6.0 // indirect golang.org/x/crypto v0.21.0 // indirect golang.org/x/text v0.14.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) replace github.com/42atomys/sprout => ../ diff --git a/benchmarks/go.sum b/benchmarks/go.sum index 6da2bcf..3bc5fea 100644 --- a/benchmarks/go.sum +++ b/benchmarks/go.sum @@ -1,7 +1,5 @@ dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk= dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= -github.com/42atomys/sprout v0.0.0-20240331203623-2cbfca5974e1 h1:NAL4g6Fa/Aa6weg9sXXNbCBN0+F5MA+NrQrCHQo+BGM= -github.com/42atomys/sprout v0.0.0-20240331203623-2cbfca5974e1/go.mod h1:pg/mR1FJSxgn9Ejjq0vDwUZOesleHL21mbddSOBdMQE= github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI= github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU= github.com/Masterminds/semver/v3 v3.2.0/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ= @@ -81,6 +79,7 @@ golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGm golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.3.0 h1:clyUAQHOM3G0M3f5vQj7LuJrETvjVot3Z5el9nffUtU= diff --git a/conversion_functions.go b/conversion_functions.go new file mode 100644 index 0000000..7f3195a --- /dev/null +++ b/conversion_functions.go @@ -0,0 +1,149 @@ +package sprout + +import ( + "fmt" + "strconv" + "time" + + "github.com/spf13/cast" +) + +// ToInt converts a value to an int using robust type casting. +// +// Parameters: +// +// v any - the value to convert to an int. +// +// Returns: +// +// int - the integer representation of the value. +// +// Example: +// +// {{ "123" | toInt }} // Output: 123 +func (fh *FunctionHandler) ToInt(v any) int { + return cast.ToInt(v) +} + +// ToInt64 converts a value to an int64, accommodating larger integer values. +// +// Parameters: +// +// v any - the value to convert to an int64. +// +// Returns: +// +// int64 - the int64 representation of the value. +// +// Example: +// +// {{ "123456789012" | toInt64 }} // Output: 123456789012 +func (fh *FunctionHandler) ToInt64(v any) int64 { + return cast.ToInt64(v) +} + +// ToFloat64 converts a value to a float64. +// +// Parameters: +// +// v any - the value to convert to a float64. +// +// Returns: +// +// float64 - the float64 representation of the value. +// +// Example: +// +// {{ "123.456" | toFloat64 }} // Output: 123.456 +func (fh *FunctionHandler) ToFloat64(v any) float64 { + return cast.ToFloat64(v) +} + +// ToOctal parses a string value as an octal (base 8) integer. +// +// Parameters: +// +// v any - the string representing an octal number. +// +// Returns: +// +// int64 - the decimal (base 10) representation of the octal value. +// If parsing fails, returns 0. +// +// Example: +// +// {{ "123" | toOctal }} // Output: 83 (since "123" in octal is 83 in decimal) +func (fh *FunctionHandler) ToOctal(v any) int64 { + result, err := strconv.ParseInt(fmt.Sprint(v), 8, 64) + if err != nil { + return 0 + } + return result +} + +// ToString converts a value to a string, handling various types effectively. +// +// Parameters: +// +// v any - the value to convert to a string. +// +// Returns: +// +// string - the string representation of the value. +// +// Example: +// +// {{ 123 | toString }} // Output: "123" +func (fh *FunctionHandler) ToString(v any) string { + switch v := v.(type) { + case string: + return v + case []byte: + return string(v) + case error: + return v.Error() + case fmt.Stringer: + return v.String() + default: + return fmt.Sprintf("%v", v) + } +} + +// ToDate converts a string to a time.Time object based on a format specification. +// +// Parameters: +// +// fmt string - the date format string. +// str string - the date string to parse. +// +// Returns: +// +// time.Time - the parsed date. +// +// Example: +// +// {{ "2006-01-02", "2023-05-04" | toDate }} // Output: 2023-05-04 00:00:00 +0000 UTC +func (fh *FunctionHandler) ToDate(fmt, str string) time.Time { + result, _ := fh.MustToDate(fmt, str) + return result +} + +// MustToDate tries to parse a string into a time.Time object based on a format, +// returning an error if parsing fails. +// +// Parameters: +// +// fmt string - the date format string. +// str string - the date string to parse. +// +// Returns: +// +// time.Time - the parsed date. +// error - error if the date string does not conform to the format. +// +// Example: +// +// {{ "2006-01-02", "2023-05-04" | mustToDate }} // Output: 2023-05-04 00:00:00 +0000 UTC, nil +func (fh *FunctionHandler) MustToDate(fmt, str string) (time.Time, error) { + return time.ParseInLocation(fmt, str, time.Local) +} diff --git a/conversion_functions_test.go b/conversion_functions_test.go new file mode 100644 index 0000000..2a103b0 --- /dev/null +++ b/conversion_functions_test.go @@ -0,0 +1,93 @@ +package sprout + +import ( + "fmt" + "testing" +) + +func TestToInt(t *testing.T) { + var tests = testCases{ + {"TestInt", `{{$v := toInt .V }}{{kindOf $v}}-{{$v}}`, "int-1", map[string]any{"V": 1}}, + {"TestInt32", `{{$v := toInt .V }}{{kindOf $v}}-{{$v}}`, "int-1", map[string]any{"V": int32(1)}}, + {"TestFloat64", `{{$v := toInt .V }}{{kindOf $v}}-{{$v}}`, "int-1", map[string]any{"V": float64(1.42)}}, + {"TestBool", `{{$v := toInt .V }}{{kindOf $v}}-{{$v}}`, "int-1", map[string]any{"V": true}}, + {"TestString", `{{$v := toInt .V }}{{kindOf $v}}-{{$v}}`, "int-1", map[string]any{"V": "1"}}, + } + + runTestCases(t, tests) +} + +func TestToInt64(t *testing.T) { + var tests = testCases{ + {"TestInt", `{{$v := toInt64 .V }}{{typeOf $v}}-{{$v}}`, "int64-1", map[string]any{"V": 1}}, + {"TestInt32", `{{$v := toInt64 .V }}{{typeOf $v}}-{{$v}}`, "int64-1", map[string]any{"V": int32(1)}}, + {"TestFloat64", `{{$v := toInt64 .V }}{{typeOf $v}}-{{$v}}`, "int64-1", map[string]any{"V": float64(1.42)}}, + {"TestBool", `{{$v := toInt64 .V }}{{typeOf $v}}-{{$v}}`, "int64-1", map[string]any{"V": true}}, + {"TestString", `{{$v := toInt64 .V }}{{typeOf $v}}-{{$v}}`, "int64-1", map[string]any{"V": "1"}}, + } + + runTestCases(t, tests) +} + +func TestToFloat64(t *testing.T) { + var tests = testCases{ + {"TestInt", `{{$v := toFloat64 .V }}{{typeOf $v}}-{{$v}}`, "float64-1", map[string]any{"V": 1}}, + {"TestInt32", `{{$v := toFloat64 .V }}{{typeOf $v}}-{{$v}}`, "float64-1", map[string]any{"V": int32(1)}}, + {"TestFloat64", `{{$v := toFloat64 .V }}{{typeOf $v}}-{{$v}}`, "float64-1.42", map[string]any{"V": float64(1.42)}}, + {"TestBool", `{{$v := toFloat64 .V }}{{typeOf $v}}-{{$v}}`, "float64-1", map[string]any{"V": true}}, + {"TestString", `{{$v := toFloat64 .V }}{{typeOf $v}}-{{$v}}`, "float64-1", map[string]any{"V": "1"}}, + } + + runTestCases(t, tests) +} + +func TestToOctal(t *testing.T) { + var tests = testCases{ + {"TestInt", `{{$v := toOctal .V }}{{typeOf $v}}-{{$v}}`, "int64-511", map[string]any{"V": 777}}, + {"TestInt32", `{{$v := toOctal .V }}{{typeOf $v}}-{{$v}}`, "int64-504", map[string]any{"V": int32(770)}}, + {"TestString", `{{$v := toOctal .V }}{{typeOf $v}}-{{$v}}`, "int64-1", map[string]any{"V": "1"}}, + {"TestInvalid", `{{$v := toOctal .V }}{{typeOf $v}}-{{$v}}`, "int64-0", map[string]any{"V": 1.1}}, + } + + runTestCases(t, tests) +} + +type testStringer struct{} + +func (s testStringer) String() string { + return "stringer" +} + +func TestToString(t *testing.T) { + + var tests = testCases{ + {"TestInt", `{{$v := toString .V }}{{typeOf $v}}-{{$v}}`, "string-1", map[string]any{"V": 1}}, + {"TestInt32", `{{$v := toString .V }}{{typeOf $v}}-{{$v}}`, "string-1", map[string]any{"V": int32(1)}}, + {"TestFloat64", `{{$v := toString .V }}{{typeOf $v}}-{{$v}}`, "string-1.42", map[string]any{"V": float64(1.42)}}, + {"TestBool", `{{$v := toString .V }}{{typeOf $v}}-{{$v}}`, "string-true", map[string]any{"V": true}}, + {"TestString", `{{$v := toString .V }}{{typeOf $v}}-{{$v}}`, "string-1", map[string]any{"V": "1"}}, + {"TestError", `{{$v := toString .V }}{{typeOf $v}}-{{$v}}`, "string-error", map[string]any{"V": fmt.Errorf("error")}}, + {"TestStringer", `{{$v := toString .V }}{{typeOf $v}}-{{$v}}`, "string-stringer", map[string]any{"V": testStringer{}}}, + {"TestSliceOfBytes", `{{$v := toString .V }}{{typeOf $v}}-{{$v}}`, "string-abc", map[string]any{"V": []byte("abc")}}, + } + + runTestCases(t, tests) +} + +func TestToDate(t *testing.T) { + var tests = testCases{ + {"TestDate", `{{$v := toDate "2006-01-02" .V }}{{typeOf $v}}-{{$v}}`, "time.Time-2024-05-09 00:00:00 +0000 UTC", map[string]any{"V": "2024-05-09"}}, + } + + runTestCases(t, tests) +} + +func TestMustToDate(t *testing.T) { + var tests = mustTestCases{ + {testCase{"TestDate", `{{$v := mustToDate "2006-01-02" .V }}{{typeOf $v}}-{{$v}}`, "time.Time-2024-05-09 00:00:00 +0000 UTC", map[string]any{"V": "2024-05-09"}}, ""}, + {testCase{"TestInvalidValue", `{{$v := mustToDate "2006-01-02" .V }}{{typeOf $v}}-{{$v}}`, "", map[string]any{"V": ""}}, "cannot parse \"\" as \"2006\""}, + {testCase{"TestInvalidLayout", `{{$v := mustToDate "invalid" .V }}{{typeOf $v}}-{{$v}}`, "", map[string]any{"V": "2024-05-09"}}, "cannot parse \"2024-05-09\" as \"invalid\""}, + } + + runMustTestCases(t, tests) +} diff --git a/crypto_test.go b/crypto_test.go deleted file mode 100644 index 6487bb8..0000000 --- a/crypto_test.go +++ /dev/null @@ -1,424 +0,0 @@ -package sprout - -import ( - "crypto/x509" - "encoding/base64" - "encoding/pem" - "fmt" - "strings" - "testing" - - "github.com/stretchr/testify/assert" - bcrypt_lib "golang.org/x/crypto/bcrypt" -) - -const ( - beginCertificate = "-----BEGIN CERTIFICATE-----" - endCertificate = "-----END CERTIFICATE-----" -) - -var ( - // fastCertKeyAlgos is the list of private key algorithms that are supported for certificate use, and - // are fast to generate. - fastCertKeyAlgos = []string{ - "ecdsa", - "ed25519", - } -) - -func TestSha256Sum(t *testing.T) { - tpl := `{{"abc" | sha256sum}}` - if err := runt(tpl, "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"); err != nil { - t.Error(err) - } -} -func TestSha1Sum(t *testing.T) { - tpl := `{{"abc" | sha1sum}}` - if err := runt(tpl, "a9993e364706816aba3e25717850c26c9cd0d89d"); err != nil { - t.Error(err) - } -} - -func TestAdler32Sum(t *testing.T) { - tpl := `{{"abc" | adler32sum}}` - if err := runt(tpl, "38600999"); err != nil { - t.Error(err) - } -} - -func TestBcrypt(t *testing.T) { - out, err := runRaw(`{{"abc" | bcrypt}}`, nil) - if err != nil { - t.Error(err) - } - if bcrypt_lib.CompareHashAndPassword([]byte(out), []byte("abc")) != nil { - t.Error("Generated hash is not the equivalent for password:", "abc") - } -} - -type HtpasswdCred struct { - Username string - Password string - Valid bool -} - -func TestHtpasswd(t *testing.T) { - expectations := []HtpasswdCred{ - {Username: "myUser", Password: "myPassword", Valid: true}, - {Username: "special'o79Cv_*qFe,) year: - return strconv.FormatUint(u/year, 10) + "y" - case u > month: - return strconv.FormatUint(u/month, 10) + "mo" - case u > day: - return strconv.FormatUint(u/day, 10) + "d" - case u > hour: - return strconv.FormatUint(u/hour, 10) + "h" - case u > minute: - return strconv.FormatUint(u/minute, 10) + "m" - case u > second: - return strconv.FormatUint(u/second, 10) + "s" - } - return "0s" -} - -func toDate(fmt, str string) time.Time { - t, _ := time.ParseInLocation(fmt, str, time.Local) - return t -} - -func mustToDate(fmt, str string) (time.Time, error) { - return time.ParseInLocation(fmt, str, time.Local) -} - -func unixEpoch(date time.Time) string { - return strconv.FormatInt(date.Unix(), 10) -} diff --git a/date_test.go b/date_test.go deleted file mode 100644 index 187c492..0000000 --- a/date_test.go +++ /dev/null @@ -1,120 +0,0 @@ -package sprout - -import ( - "testing" - "time" -) - -func TestHtmlDate(t *testing.T) { - t.Skip() - tpl := `{{ htmlDate 0}}` - if err := runt(tpl, "1970-01-01"); err != nil { - t.Error(err) - } -} - -func TestAgo(t *testing.T) { - tpl := "{{ ago .Time }}" - if err := runtv(tpl, "2m5s", map[string]interface{}{"Time": time.Now().Add(-125 * time.Second)}); err != nil { - t.Error(err) - } - - if err := runtv(tpl, "2h34m17s", map[string]interface{}{"Time": time.Now().Add(-(2*3600 + 34*60 + 17) * time.Second)}); err != nil { - t.Error(err) - } - - if err := runtv(tpl, "-5s", map[string]interface{}{"Time": time.Now().Add(5 * time.Second)}); err != nil { - t.Error(err) - } -} - -func TestToDate(t *testing.T) { - tpl := `{{toDate "2006-01-02" "2017-12-31" | date "02/01/2006"}}` - if err := runt(tpl, "31/12/2017"); err != nil { - t.Error(err) - } -} - -func TestUnixEpoch(t *testing.T) { - tm, err := time.Parse("02 Jan 06 15:04:05 MST", "13 Jun 19 20:39:39 GMT") - if err != nil { - t.Error(err) - } - tpl := `{{unixEpoch .Time}}` - - if err = runtv(tpl, "1560458379", map[string]interface{}{"Time": tm}); err != nil { - t.Error(err) - } -} - -func TestDateInZone(t *testing.T) { - tm, err := time.Parse("02 Jan 06 15:04:05 MST", "13 Jun 19 20:39:39 GMT") - if err != nil { - t.Error(err) - } - tpl := `{{ date_in_zone "02 Jan 06 15:04 -0700" .Time "UTC" }}` - - // Test time.Time input - if err = runtv(tpl, "13 Jun 19 20:39 +0000", map[string]interface{}{"Time": tm}); err != nil { - t.Error(err) - } - - // Test pointer to time.Time input - if err = runtv(tpl, "13 Jun 19 20:39 +0000", map[string]interface{}{"Time": &tm}); err != nil { - t.Error(err) - } - - // Test no time input. This should be close enough to time.Now() we can test - loc, _ := time.LoadLocation("UTC") - if err = runtv(tpl, time.Now().In(loc).Format("02 Jan 06 15:04 -0700"), map[string]interface{}{"Time": ""}); err != nil { - t.Error(err) - } - - // Test unix timestamp as int64 - if err = runtv(tpl, "13 Jun 19 20:39 +0000", map[string]interface{}{"Time": int64(1560458379)}); err != nil { - t.Error(err) - } - - // Test unix timestamp as int32 - if err = runtv(tpl, "13 Jun 19 20:39 +0000", map[string]interface{}{"Time": int32(1560458379)}); err != nil { - t.Error(err) - } - - // Test unix timestamp as int - if err = runtv(tpl, "13 Jun 19 20:39 +0000", map[string]interface{}{"Time": int(1560458379)}); err != nil { - t.Error(err) - } - - // Test case of invalid timezone - tpl = `{{ date_in_zone "02 Jan 06 15:04 -0700" .Time "foobar" }}` - if err = runtv(tpl, "13 Jun 19 20:39 +0000", map[string]interface{}{"Time": tm}); err != nil { - t.Error(err) - } -} - -func TestDuration(t *testing.T) { - tpl := "{{ duration .Secs }}" - if err := runtv(tpl, "1m1s", map[string]interface{}{"Secs": "61"}); err != nil { - t.Error(err) - } - if err := runtv(tpl, "1h0m0s", map[string]interface{}{"Secs": "3600"}); err != nil { - t.Error(err) - } - // 1d2h3m4s but go is opinionated - if err := runtv(tpl, "26h3m4s", map[string]interface{}{"Secs": "93784"}); err != nil { - t.Error(err) - } -} - -func TestDurationRound(t *testing.T) { - tpl := "{{ durationRound .Time }}" - if err := runtv(tpl, "2h", map[string]interface{}{"Time": "2h5s"}); err != nil { - t.Error(err) - } - if err := runtv(tpl, "1d", map[string]interface{}{"Time": "24h5s"}); err != nil { - t.Error(err) - } - if err := runtv(tpl, "3mo", map[string]interface{}{"Time": "2400h5s"}); err != nil { - t.Error(err) - } -} diff --git a/defaults.go b/defaults.go deleted file mode 100644 index 0fa4df7..0000000 --- a/defaults.go +++ /dev/null @@ -1,163 +0,0 @@ -package sprout - -import ( - "bytes" - "encoding/json" - "math/rand" - "reflect" - "strings" - "time" -) - -func init() { - rand.Seed(time.Now().UnixNano()) -} - -// dfault checks whether `given` is set, and returns default if not set. -// -// This returns `d` if `given` appears not to be set, and `given` otherwise. -// -// For numeric types 0 is unset. -// For strings, maps, arrays, and slices, len() = 0 is considered unset. -// For bool, false is unset. -// Structs are never considered unset. -// -// For everything else, including pointers, a nil value is unset. -func dfault(d interface{}, given ...interface{}) interface{} { - - if empty(given) || empty(given[0]) { - return d - } - return given[0] -} - -// empty returns true if the given value has the zero value for its type. -func empty(given interface{}) bool { - g := reflect.ValueOf(given) - if !g.IsValid() { - return true - } - - // Basically adapted from text/template.isTrue - switch g.Kind() { - default: - return g.IsNil() - case reflect.Array, reflect.Slice, reflect.Map, reflect.String: - return g.Len() == 0 - case reflect.Bool: - return !g.Bool() - case reflect.Complex64, reflect.Complex128: - return g.Complex() == 0 - case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: - return g.Int() == 0 - case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr: - return g.Uint() == 0 - case reflect.Float32, reflect.Float64: - return g.Float() == 0 - case reflect.Struct: - return false - } -} - -// coalesce returns the first non-empty value. -func coalesce(v ...interface{}) interface{} { - for _, val := range v { - if !empty(val) { - return val - } - } - return nil -} - -// all returns true if empty(x) is false for all values x in the list. -// If the list is empty, return true. -func all(v ...interface{}) bool { - for _, val := range v { - if empty(val) { - return false - } - } - return true -} - -// any returns true if empty(x) is false for any x in the list. -// If the list is empty, return false. -func any(v ...interface{}) bool { - for _, val := range v { - if !empty(val) { - return true - } - } - return false -} - -// fromJson decodes JSON into a structured value, ignoring errors. -func fromJson(v string) interface{} { - output, _ := mustFromJson(v) - return output -} - -// mustFromJson decodes JSON into a structured value, returning errors. -func mustFromJson(v string) (interface{}, error) { - var output interface{} - err := json.Unmarshal([]byte(v), &output) - return output, err -} - -// toJson encodes an item into a JSON string -func toJson(v interface{}) string { - output, _ := json.Marshal(v) - return string(output) -} - -func mustToJson(v interface{}) (string, error) { - output, err := json.Marshal(v) - if err != nil { - return "", err - } - return string(output), nil -} - -// toPrettyJson encodes an item into a pretty (indented) JSON string -func toPrettyJson(v interface{}) string { - output, _ := json.MarshalIndent(v, "", " ") - return string(output) -} - -func mustToPrettyJson(v interface{}) (string, error) { - output, err := json.MarshalIndent(v, "", " ") - if err != nil { - return "", err - } - return string(output), nil -} - -// toRawJson encodes an item into a JSON string with no escaping of HTML characters. -func toRawJson(v interface{}) string { - output, err := mustToRawJson(v) - if err != nil { - panic(err) - } - return string(output) -} - -// mustToRawJson encodes an item into a JSON string with no escaping of HTML characters. -func mustToRawJson(v interface{}) (string, error) { - buf := new(bytes.Buffer) - enc := json.NewEncoder(buf) - enc.SetEscapeHTML(false) - err := enc.Encode(&v) - if err != nil { - return "", err - } - return strings.TrimSuffix(buf.String(), "\n"), nil -} - -// ternary returns the first value if the last value is true, otherwise returns the second value. -func ternary(vt interface{}, vf interface{}, v bool) interface{} { - if v { - return vt - } - - return vf -} diff --git a/defaults_test.go b/defaults_test.go deleted file mode 100644 index d0a2c77..0000000 --- a/defaults_test.go +++ /dev/null @@ -1,196 +0,0 @@ -package sprout - -import ( - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestDefault(t *testing.T) { - tpl := `{{"" | default "foo"}}` - if err := runt(tpl, "foo"); err != nil { - t.Error(err) - } - tpl = `{{default "foo" 234}}` - if err := runt(tpl, "234"); err != nil { - t.Error(err) - } - tpl = `{{default "foo" 2.34}}` - if err := runt(tpl, "2.34"); err != nil { - t.Error(err) - } - - tpl = `{{ .Nothing | default "123" }}` - if err := runt(tpl, "123"); err != nil { - t.Error(err) - } - tpl = `{{ default "123" }}` - if err := runt(tpl, "123"); err != nil { - t.Error(err) - } -} - -func TestEmpty(t *testing.T) { - tpl := `{{if empty 1}}1{{else}}0{{end}}` - if err := runt(tpl, "0"); err != nil { - t.Error(err) - } - - tpl = `{{if empty 0}}1{{else}}0{{end}}` - if err := runt(tpl, "1"); err != nil { - t.Error(err) - } - tpl = `{{if empty ""}}1{{else}}0{{end}}` - if err := runt(tpl, "1"); err != nil { - t.Error(err) - } - tpl = `{{if empty 0.0}}1{{else}}0{{end}}` - if err := runt(tpl, "1"); err != nil { - t.Error(err) - } - tpl = `{{if empty false}}1{{else}}0{{end}}` - if err := runt(tpl, "1"); err != nil { - t.Error(err) - } - - dict := map[string]interface{}{"top": map[string]interface{}{}} - tpl = `{{if empty .top.NoSuchThing}}1{{else}}0{{end}}` - if err := runtv(tpl, "1", dict); err != nil { - t.Error(err) - } - tpl = `{{if empty .bottom.NoSuchThing}}1{{else}}0{{end}}` - if err := runtv(tpl, "1", dict); err != nil { - t.Error(err) - } -} - -func TestCoalesce(t *testing.T) { - tests := map[string]string{ - `{{ coalesce 1 }}`: "1", - `{{ coalesce "" 0 nil 2 }}`: "2", - `{{ $two := 2 }}{{ coalesce "" 0 nil $two }}`: "2", - `{{ $two := 2 }}{{ coalesce "" $two 0 0 0 }}`: "2", - `{{ $two := 2 }}{{ coalesce "" $two 3 4 5 }}`: "2", - `{{ coalesce }}`: "", - } - for tpl, expect := range tests { - assert.NoError(t, runt(tpl, expect)) - } - - dict := map[string]interface{}{"top": map[string]interface{}{}} - tpl := `{{ coalesce .top.NoSuchThing .bottom .bottom.dollar "airplane"}}` - if err := runtv(tpl, "airplane", dict); err != nil { - t.Error(err) - } -} - -func TestAll(t *testing.T) { - tests := map[string]string{ - `{{ all 1 }}`: "true", - `{{ all "" 0 nil 2 }}`: "false", - `{{ $two := 2 }}{{ all "" 0 nil $two }}`: "false", - `{{ $two := 2 }}{{ all "" $two 0 0 0 }}`: "false", - `{{ $two := 2 }}{{ all "" $two 3 4 5 }}`: "false", - `{{ all }}`: "true", - } - for tpl, expect := range tests { - assert.NoError(t, runt(tpl, expect)) - } - - dict := map[string]interface{}{"top": map[string]interface{}{}} - tpl := `{{ all .top.NoSuchThing .bottom .bottom.dollar "airplane"}}` - if err := runtv(tpl, "false", dict); err != nil { - t.Error(err) - } -} - -func TestAny(t *testing.T) { - tests := map[string]string{ - `{{ any 1 }}`: "true", - `{{ any "" 0 nil 2 }}`: "true", - `{{ $two := 2 }}{{ any "" 0 nil $two }}`: "true", - `{{ $two := 2 }}{{ any "" $two 3 4 5 }}`: "true", - `{{ $zero := 0 }}{{ any "" $zero 0 0 0 }}`: "false", - `{{ any }}`: "false", - } - for tpl, expect := range tests { - assert.NoError(t, runt(tpl, expect)) - } - - dict := map[string]interface{}{"top": map[string]interface{}{}} - tpl := `{{ any .top.NoSuchThing .bottom .bottom.dollar "airplane"}}` - if err := runtv(tpl, "true", dict); err != nil { - t.Error(err) - } -} - -func TestFromJson(t *testing.T) { - dict := map[string]interface{}{"Input": `{"foo": 55}`} - - tpl := `{{.Input | fromJson}}` - expected := `map[foo:55]` - if err := runtv(tpl, expected, dict); err != nil { - t.Error(err) - } - - tpl = `{{(.Input | fromJson).foo}}` - expected = `55` - if err := runtv(tpl, expected, dict); err != nil { - t.Error(err) - } -} - -func TestToJson(t *testing.T) { - dict := map[string]interface{}{"Top": map[string]interface{}{"bool": true, "string": "test", "number": 42}} - - tpl := `{{.Top | toJson}}` - expected := `{"bool":true,"number":42,"string":"test"}` - if err := runtv(tpl, expected, dict); err != nil { - t.Error(err) - } -} - -func TestToPrettyJson(t *testing.T) { - dict := map[string]interface{}{"Top": map[string]interface{}{"bool": true, "string": "test", "number": 42}} - tpl := `{{.Top | toPrettyJson}}` - expected := `{ - "bool": true, - "number": 42, - "string": "test" -}` - if err := runtv(tpl, expected, dict); err != nil { - t.Error(err) - } -} - -func TestToRawJson(t *testing.T) { - dict := map[string]interface{}{"Top": map[string]interface{}{"bool": true, "string": "test", "number": 42, "html": ""}} - tpl := `{{.Top | toRawJson}}` - expected := `{"bool":true,"html":"","number":42,"string":"test"}` - - if err := runtv(tpl, expected, dict); err != nil { - t.Error(err) - } -} - -func TestTernary(t *testing.T) { - tpl := `{{true | ternary "foo" "bar"}}` - if err := runt(tpl, "foo"); err != nil { - t.Error(err) - } - - tpl = `{{ternary "foo" "bar" true}}` - if err := runt(tpl, "foo"); err != nil { - t.Error(err) - } - - tpl = `{{false | ternary "foo" "bar"}}` - if err := runt(tpl, "bar"); err != nil { - t.Error(err) - } - - tpl = `{{ternary "foo" "bar" false}}` - if err := runt(tpl, "bar"); err != nil { - t.Error(err) - } -} diff --git a/dict.go b/dict.go deleted file mode 100644 index 5f33aa4..0000000 --- a/dict.go +++ /dev/null @@ -1,174 +0,0 @@ -package sprout - -import ( - "dario.cat/mergo" - "github.com/mitchellh/copystructure" -) - -func get(d map[string]interface{}, key string) interface{} { - if val, ok := d[key]; ok { - return val - } - return "" -} - -func set(d map[string]interface{}, key string, value interface{}) map[string]interface{} { - d[key] = value - return d -} - -func unset(d map[string]interface{}, key string) map[string]interface{} { - delete(d, key) - return d -} - -func hasKey(d map[string]interface{}, key string) bool { - _, ok := d[key] - return ok -} - -func pluck(key string, d ...map[string]interface{}) []interface{} { - res := []interface{}{} - for _, dict := range d { - if val, ok := dict[key]; ok { - res = append(res, val) - } - } - return res -} - -func keys(dicts ...map[string]interface{}) []string { - k := []string{} - for _, dict := range dicts { - for key := range dict { - k = append(k, key) - } - } - return k -} - -func pick(dict map[string]interface{}, keys ...string) map[string]interface{} { - res := map[string]interface{}{} - for _, k := range keys { - if v, ok := dict[k]; ok { - res[k] = v - } - } - return res -} - -func omit(dict map[string]interface{}, keys ...string) map[string]interface{} { - res := map[string]interface{}{} - - omit := make(map[string]bool, len(keys)) - for _, k := range keys { - omit[k] = true - } - - for k, v := range dict { - if _, ok := omit[k]; !ok { - res[k] = v - } - } - return res -} - -func dict(v ...interface{}) map[string]interface{} { - dict := map[string]interface{}{} - lenv := len(v) - for i := 0; i < lenv; i += 2 { - key := strval(v[i]) - if i+1 >= lenv { - dict[key] = "" - continue - } - dict[key] = v[i+1] - } - return dict -} - -func merge(dst map[string]interface{}, srcs ...map[string]interface{}) interface{} { - for _, src := range srcs { - if err := mergo.Merge(&dst, src); err != nil { - // Swallow errors inside of a template. - return "" - } - } - return dst -} - -func mustMerge(dst map[string]interface{}, srcs ...map[string]interface{}) (interface{}, error) { - for _, src := range srcs { - if err := mergo.Merge(&dst, src); err != nil { - return nil, err - } - } - return dst, nil -} - -func mergeOverwrite(dst map[string]interface{}, srcs ...map[string]interface{}) interface{} { - for _, src := range srcs { - if err := mergo.MergeWithOverwrite(&dst, src); err != nil { - // Swallow errors inside of a template. - return "" - } - } - return dst -} - -func mustMergeOverwrite(dst map[string]interface{}, srcs ...map[string]interface{}) (interface{}, error) { - for _, src := range srcs { - if err := mergo.MergeWithOverwrite(&dst, src); err != nil { - return nil, err - } - } - return dst, nil -} - -func values(dict map[string]interface{}) []interface{} { - values := []interface{}{} - for _, value := range dict { - values = append(values, value) - } - - return values -} - -func deepCopy(i interface{}) interface{} { - c, err := mustDeepCopy(i) - if err != nil { - panic("deepCopy error: " + err.Error()) - } - - return c -} - -func mustDeepCopy(i interface{}) (interface{}, error) { - return copystructure.Copy(i) -} - -func dig(ps ...interface{}) (interface{}, error) { - if len(ps) < 3 { - panic("dig needs at least three arguments") - } - dict := ps[len(ps)-1].(map[string]interface{}) - def := ps[len(ps)-2] - ks := make([]string, len(ps)-2) - for i := 0; i < len(ks); i++ { - ks[i] = ps[i].(string) - } - - return digFromDict(dict, def, ks) -} - -func digFromDict(dict map[string]interface{}, d interface{}, ks []string) (interface{}, error) { - k, ns := ks[0], ks[1:len(ks)] - step, has := dict[k] - if !has { - return d, nil - } - if len(ns) == 0 { - return step, nil - } - return digFromDict(step.(map[string]interface{}), d, ns) -} diff --git a/dict_test.go b/dict_test.go deleted file mode 100644 index 96d9c06..0000000 --- a/dict_test.go +++ /dev/null @@ -1,310 +0,0 @@ -package sprout - -import ( - "strings" - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestDict(t *testing.T) { - tpl := `{{$d := dict 1 2 "three" "four" 5}}{{range $k, $v := $d}}{{$k}}{{$v}}{{end}}` - out, err := runRaw(tpl, nil) - if err != nil { - t.Error(err) - } - if len(out) != 12 { - t.Errorf("Expected length 12, got %d", len(out)) - } - // dict does not guarantee ordering because it is backed by a map. - if !strings.Contains(out, "12") { - t.Error("Expected grouping 12") - } - if !strings.Contains(out, "threefour") { - t.Error("Expected grouping threefour") - } - if !strings.Contains(out, "5") { - t.Error("Expected 5") - } - tpl = `{{$t := dict "I" "shot" "the" "albatross"}}{{$t.the}} {{$t.I}}` - if err := runt(tpl, "albatross shot"); err != nil { - t.Error(err) - } -} - -func TestUnset(t *testing.T) { - tpl := `{{- $d := dict "one" 1 "two" 222222 -}} - {{- $_ := unset $d "two" -}} - {{- range $k, $v := $d}}{{$k}}{{$v}}{{- end -}} - ` - - expect := "one1" - if err := runt(tpl, expect); err != nil { - t.Error(err) - } -} -func TestHasKey(t *testing.T) { - tpl := `{{- $d := dict "one" 1 "two" 222222 -}} - {{- if hasKey $d "one" -}}1{{- end -}} - ` - - expect := "1" - if err := runt(tpl, expect); err != nil { - t.Error(err) - } -} - -func TestPluck(t *testing.T) { - tpl := ` - {{- $d := dict "one" 1 "two" 222222 -}} - {{- $d2 := dict "one" 1 "two" 33333 -}} - {{- $d3 := dict "one" 1 -}} - {{- $d4 := dict "one" 1 "two" 4444 -}} - {{- pluck "two" $d $d2 $d3 $d4 -}} - ` - - expect := "[222222 33333 4444]" - if err := runt(tpl, expect); err != nil { - t.Error(err) - } -} - -func TestKeys(t *testing.T) { - tests := map[string]string{ - `{{ dict "foo" 1 "bar" 2 | keys | sortAlpha }}`: "[bar foo]", - `{{ dict | keys }}`: "[]", - `{{ keys (dict "foo" 1) (dict "bar" 2) (dict "bar" 3) | uniq | sortAlpha }}`: "[bar foo]", - } - for tpl, expect := range tests { - if err := runt(tpl, expect); err != nil { - t.Error(err) - } - } -} - -func TestPick(t *testing.T) { - tests := map[string]string{ - `{{- $d := dict "one" 1 "two" 222222 }}{{ pick $d "two" | len -}}`: "1", - `{{- $d := dict "one" 1 "two" 222222 }}{{ pick $d "two" -}}`: "map[two:222222]", - `{{- $d := dict "one" 1 "two" 222222 }}{{ pick $d "one" "two" | len -}}`: "2", - `{{- $d := dict "one" 1 "two" 222222 }}{{ pick $d "one" "two" "three" | len -}}`: "2", - `{{- $d := dict }}{{ pick $d "two" | len -}}`: "0", - } - for tpl, expect := range tests { - if err := runt(tpl, expect); err != nil { - t.Error(err) - } - } -} -func TestOmit(t *testing.T) { - tests := map[string]string{ - `{{- $d := dict "one" 1 "two" 222222 }}{{ omit $d "one" | len -}}`: "1", - `{{- $d := dict "one" 1 "two" 222222 }}{{ omit $d "one" -}}`: "map[two:222222]", - `{{- $d := dict "one" 1 "two" 222222 }}{{ omit $d "one" "two" | len -}}`: "0", - `{{- $d := dict "one" 1 "two" 222222 }}{{ omit $d "two" "three" | len -}}`: "1", - `{{- $d := dict }}{{ omit $d "two" | len -}}`: "0", - } - for tpl, expect := range tests { - if err := runt(tpl, expect); err != nil { - t.Error(err) - } - } -} - -func TestGet(t *testing.T) { - tests := map[string]string{ - `{{- $d := dict "one" 1 }}{{ get $d "one" -}}`: "1", - `{{- $d := dict "one" 1 "two" "2" }}{{ get $d "two" -}}`: "2", - `{{- $d := dict }}{{ get $d "two" -}}`: "", - } - for tpl, expect := range tests { - if err := runt(tpl, expect); err != nil { - t.Error(err) - } - } -} - -func TestSet(t *testing.T) { - tpl := `{{- $d := dict "one" 1 "two" 222222 -}} - {{- $_ := set $d "two" 2 -}} - {{- $_ := set $d "three" 3 -}} - {{- if hasKey $d "one" -}}{{$d.one}}{{- end -}} - {{- if hasKey $d "two" -}}{{$d.two}}{{- end -}} - {{- if hasKey $d "three" -}}{{$d.three}}{{- end -}} - ` - - expect := "123" - if err := runt(tpl, expect); err != nil { - t.Error(err) - } -} - -func TestMerge(t *testing.T) { - dict := map[string]interface{}{ - "src2": map[string]interface{}{ - "h": 10, - "i": "i", - "j": "j", - }, - "src1": map[string]interface{}{ - "a": 1, - "b": 2, - "d": map[string]interface{}{ - "e": "four", - }, - "g": []int{6, 7}, - "i": "aye", - "j": "jay", - "k": map[string]interface{}{ - "l": false, - }, - }, - "dst": map[string]interface{}{ - "a": "one", - "c": 3, - "d": map[string]interface{}{ - "f": 5, - }, - "g": []int{8, 9}, - "i": "eye", - "k": map[string]interface{}{ - "l": true, - }, - }, - } - tpl := `{{merge .dst .src1 .src2}}` - _, err := runRaw(tpl, dict) - if err != nil { - t.Error(err) - } - expected := map[string]interface{}{ - "a": "one", // key overridden - "b": 2, // merged from src1 - "c": 3, // merged from dst - "d": map[string]interface{}{ // deep merge - "e": "four", - "f": 5, - }, - "g": []int{8, 9}, // overridden - arrays are not merged - "h": 10, // merged from src2 - "i": "eye", // overridden twice - "j": "jay", // overridden and merged - "k": map[string]interface{}{ - "l": true, // overridden - }, - } - assert.Equal(t, expected, dict["dst"]) -} - -func TestMergeOverwrite(t *testing.T) { - dict := map[string]interface{}{ - "src2": map[string]interface{}{ - "h": 10, - "i": "i", - "j": "j", - }, - "src1": map[string]interface{}{ - "a": 1, - "b": 2, - "d": map[string]interface{}{ - "e": "four", - }, - "g": []int{6, 7}, - "i": "aye", - "j": "jay", - "k": map[string]interface{}{ - "l": false, - }, - }, - "dst": map[string]interface{}{ - "a": "one", - "c": 3, - "d": map[string]interface{}{ - "f": 5, - }, - "g": []int{8, 9}, - "i": "eye", - "k": map[string]interface{}{ - "l": true, - }, - }, - } - tpl := `{{mergeOverwrite .dst .src1 .src2}}` - _, err := runRaw(tpl, dict) - if err != nil { - t.Error(err) - } - expected := map[string]interface{}{ - "a": 1, // key overwritten from src1 - "b": 2, // merged from src1 - "c": 3, // merged from dst - "d": map[string]interface{}{ // deep merge - "e": "four", - "f": 5, - }, - "g": []int{6, 7}, // overwritten src1 wins - "h": 10, // merged from src2 - "i": "i", // overwritten twice src2 wins - "j": "j", // overwritten twice src2 wins - "k": map[string]interface{}{ // deep merge - "l": false, // overwritten src1 wins - }, - } - assert.Equal(t, expected, dict["dst"]) -} - -func TestValues(t *testing.T) { - tests := map[string]string{ - `{{- $d := dict "a" 1 "b" 2 }}{{ values $d | sortAlpha | join "," }}`: "1,2", - `{{- $d := dict "a" "first" "b" 2 }}{{ values $d | sortAlpha | join "," }}`: "2,first", - } - - for tpl, expect := range tests { - if err := runt(tpl, expect); err != nil { - t.Error(err) - } - } -} - -func TestDeepCopy(t *testing.T) { - tests := map[string]string{ - `{{- $d := dict "a" 1 "b" 2 | deepCopy }}{{ values $d | sortAlpha | join "," }}`: "1,2", - `{{- $d := dict "a" 1 "b" 2 | deepCopy }}{{ keys $d | sortAlpha | join "," }}`: "a,b", - `{{- $one := dict "foo" (dict "bar" "baz") "qux" true -}}{{ deepCopy $one }}`: "map[foo:map[bar:baz] qux:true]", - } - - for tpl, expect := range tests { - if err := runt(tpl, expect); err != nil { - t.Error(err) - } - } -} - -func TestMustDeepCopy(t *testing.T) { - tests := map[string]string{ - `{{- $d := dict "a" 1 "b" 2 | mustDeepCopy }}{{ values $d | sortAlpha | join "," }}`: "1,2", - `{{- $d := dict "a" 1 "b" 2 | mustDeepCopy }}{{ keys $d | sortAlpha | join "," }}`: "a,b", - `{{- $one := dict "foo" (dict "bar" "baz") "qux" true -}}{{ mustDeepCopy $one }}`: "map[foo:map[bar:baz] qux:true]", - } - - for tpl, expect := range tests { - if err := runt(tpl, expect); err != nil { - t.Error(err) - } - } -} - -func TestDig(t *testing.T) { - tests := map[string]string{ - `{{- $d := dict "a" (dict "b" (dict "c" 1)) }}{{ dig "a" "b" "c" "" $d }}`: "1", - `{{- $d := dict "a" (dict "b" (dict "c" 1)) }}{{ dig "a" "b" "z" "2" $d }}`: "2", - `{{ dict "a" 1 | dig "a" "" }}`: "1", - `{{ dict "a" 1 | dig "z" "2" }}`: "2", - } - - for tpl, expect := range tests { - if err := runt(tpl, expect); err != nil { - t.Error(err) - } - } -} diff --git a/doc.go b/doc.go deleted file mode 100644 index 2d54b52..0000000 --- a/doc.go +++ /dev/null @@ -1,19 +0,0 @@ -/* -package sprout provides template functions for Go. - -This package contains a number of utility functions for working with data -inside of Go `html/template` and `text/template` files. - -To add these functions, use the `template.Funcs()` method: - - t := templates.New("foo").Funcs(sprout.FuncMap()) - -Note that you should add the function map before you parse any template files. - - In several cases, sprout reverses the order of arguments from the way they - appear in the standard library. This is to make it easier to pipe - arguments into functions. - -See http://masterminds.github.io/sprout/ for more detailed documentation on each of the available functions. -*/ -package sprout diff --git a/encoding_functions.go b/encoding_functions.go new file mode 100644 index 0000000..1e95372 --- /dev/null +++ b/encoding_functions.go @@ -0,0 +1,259 @@ +package sprout + +import ( + "bytes" + "encoding/base32" + "encoding/base64" + "encoding/json" + "strings" +) + +// Base64Encode encodes a string into its Base64 representation. +// +// Parameters: +// +// s string - the string to encode. +// +// Returns: +// +// string - the Base64 encoded string. +// +// Example: +// +// {{ "Hello World" | base64Encode }} // Output: "SGVsbG8gV29ybGQ=" +func (fh *FunctionHandler) Base64Encode(s string) string { + return base64.StdEncoding.EncodeToString([]byte(s)) +} + +// Base64Decode decodes a Base64 encoded string back to its original form. +// Returns an error message if the input is not valid Base64. +// +// Parameters: +// +// s string - the Base64 encoded string to decode. +// +// Returns: +// +// string - the decoded string, or an error message if the decoding fails. +// +// Example: +// +// {{ "SGVsbG8gV29ybGQ=" | base64Decode }} // Output: "Hello World" +func (fh *FunctionHandler) Base64Decode(s string) string { + bytes, err := base64.StdEncoding.DecodeString(s) + if err != nil { + return "" + } + return string(bytes) +} + +// Base32Encode encodes a string into its Base32 representation. +// +// Parameters: +// +// s string - the string to encode. +// +// Returns: +// +// string - the Base32 encoded string. +// +// Example: +// +// {{ "Hello World" | base32Encode }} // Output: "JBSWY3DPEBLW64TMMQQQ====" +func (fh *FunctionHandler) Base32Encode(s string) string { + return base32.StdEncoding.EncodeToString([]byte(s)) +} + +// Base32Decode decodes a Base32 encoded string back to its original form. +// Returns an error message if the input is not valid Base32. +// +// Parameters: +// +// s string - the Base32 encoded string to decode. +// +// Returns: +// +// string - the decoded string, or an error message if the decoding fails. +// +// Example: +// +// {{ "JBSWY3DPEBLW64TMMQQQ====" | base32Decode }} // Output: "Hello World" +func (fh *FunctionHandler) Base32Decode(s string) string { + bytes, err := base32.StdEncoding.DecodeString(s) + if err != nil { + return "" + } + return string(bytes) +} + +// FromJson converts a JSON string into a corresponding Go data structure. +// +// Parameters: +// +// v string - the JSON string to decode. +// +// Returns: +// +// any - the decoded Go data structure, or nil if the decoding fails. +// +// Example: +// +// result := fh.FromJson(`{"name":"John", "age":30}`) +// fmt.Printf("%v\n", result) // Output: map[name:John age:30] +func (fh *FunctionHandler) FromJson(v string) any { + output, _ := fh.MustFromJson(v) + return output +} + +// ToJson converts a Go data structure into a JSON string. +// +// Parameters: +// +// v any - the Go data structure to encode. +// +// Returns: +// +// string - the encoded JSON string. +// +// Example: +// +// jsonStr := fh.ToJson(map[string]interface{}{"name": "John", "age": 30}) +// fmt.Println(jsonStr) // Output: {"age":30,"name":"John"} +func (fh *FunctionHandler) ToJson(v any) string { + output, _ := fh.MustToJson(v) + return output +} + +// ToPrettyJson converts a Go data structure into a pretty-printed JSON string. +// +// Parameters: +// +// v any - the Go data structure to encode. +// +// Returns: +// +// string - the pretty-printed JSON string. +// +// Example: +// +// prettyJson := fh.ToPrettyJson(map[string]interface{}{"name": "John", "age": 30}) +// fmt.Println(prettyJson) // Output: { +// // "age": 30, +// // "name": "John" +// // } +func (fh *FunctionHandler) ToPrettyJson(v any) string { + output, _ := fh.MustToPrettyJson(v) + return output +} + +// ToRawJson converts a Go data structure into a JSON string without escaping HTML. +// +// Parameters: +// +// v any - the Go data structure to encode. +// +// Returns: +// +// string - the raw JSON string. +// +// Example: +// +// rawJson := fh.ToRawJson(map[string]interface{}{"content": "
Hello World!
"}) +// fmt.Println(rawJson) // Output: {"content":"
Hello World!
"} +func (fh *FunctionHandler) ToRawJson(v any) string { + output, _ := fh.MustToRawJson(v) + return output +} + +// MustFromJson decodes a JSON string into a Go data structure, returning an +// error if decoding fails. +// +// Parameters: +// +// v string - the JSON string to decode. +// +// Returns: +// +// any - the decoded Go data structure. +// error - error encountered during decoding, if any. +// +// Example: +// +// {{ `{"name":"John", "age":30}` | mustFromJson }} // Output: map[name:John age:30], nil +func (fh *FunctionHandler) MustFromJson(v string) (any, error) { + var output any + err := json.Unmarshal([]byte(v), &output) + return output, err +} + +// MustToJson encodes a Go data structure into a JSON string, returning an error +// if encoding fails. +// +// Parameters: +// +// v any - the Go data structure to encode. +// +// Returns: +// +// string - the JSON-encoded string. +// error - error encountered during encoding, if any. +// +// Example: +// +// {{ {"name": "John", "age": 30} | mustToJson }} // Output: "{"age":30,"name":"John"}", nil +func (fh *FunctionHandler) MustToJson(v any) (string, error) { + output, err := json.Marshal(v) + if err != nil { + return "", err + } + return string(output), nil +} + +// MustToPrettyJson encodes a Go data structure into a pretty-printed JSON +// string, returning an error if encoding fails. +// +// Parameters: +// +// v any - the Go data structure to encode. +// +// Returns: +// +// string - the pretty-printed JSON string. +// error - error encountered during encoding, if any. +// +// Example: +// +// {{ {"name": "John", "age": 30} | mustToPrettyJson }} // Output: "{\n \"age\": 30,\n \"name\": \"John\"\n}", nil +func (fh *FunctionHandler) MustToPrettyJson(v any) (string, error) { + output, err := json.MarshalIndent(v, "", " ") + if err != nil { + return "", err + } + return string(output), nil +} + +// MustToRawJson encodes a Go data structure into a JSON string without escaping +// HTML, returning an error if encoding fails. +// +// Parameters: +// +// v any - the Go data structure to encode. +// +// Returns: +// +// string - the raw JSON string. +// error - error encountered during encoding, if any. +// +// Example: +// +// {{ {"content": "
Hello World!
"} | mustToRawJson }} // Output: "{\"content\":\"
Hello World!
\"}", nil +func (fh *FunctionHandler) MustToRawJson(v any) (string, error) { + buf := new(bytes.Buffer) + enc := json.NewEncoder(buf) + enc.SetEscapeHTML(false) + err := enc.Encode(&v) + if err != nil { + return "", err + } + return strings.TrimSuffix(buf.String(), "\n"), nil +} diff --git a/encoding_functions_test.go b/encoding_functions_test.go new file mode 100644 index 0000000..a2be72c --- /dev/null +++ b/encoding_functions_test.go @@ -0,0 +1,122 @@ +package sprout + +import "testing" + +func TestBase64Encode(t *testing.T) { + var tests = testCases{ + {"TestWithoutInput", `{{ "" | base64Encode }}`, "", nil}, + {"TestHelloWorldInput", `{{ "Hello World" | base64Encode }}`, "SGVsbG8gV29ybGQ=", nil}, + {"TestFromVariableInput", `{{ .V | base64Encode }}`, "SGVsbG8gV29ybGQ=", map[string]any{"V": "Hello World"}}, + } + + runTestCases(t, tests) +} + +func TestBase64Decode(t *testing.T) { + var tests = testCases{ + {"TestWithoutInput", `{{ "" | base64Decode }}`, "", nil}, + {"TestHelloWorldInput", `{{ "SGVsbG8gV29ybGQ=" | base64Decode }}`, "Hello World", nil}, + {"TestFromVariableInput", `{{ .V | base64Decode }}`, "Hello World", map[string]any{"V": "SGVsbG8gV29ybGQ="}}, + {"TestInvalidInput", `{{ "SGVsbG8gV29ybGQ" | base64Decode }}`, "", nil}, + } + + runTestCases(t, tests) +} + +func TestBase32Encode(t *testing.T) { + var tests = testCases{ + {"TestWithoutInput", `{{ "" | base32Encode }}`, "", nil}, + {"TestHelloWorldInput", `{{ "Hello World" | base32Encode }}`, "JBSWY3DPEBLW64TMMQ======", nil}, + {"TestFromVariableInput", `{{ .V | base32Encode }}`, "JBSWY3DPEBLW64TMMQ======", map[string]any{"V": "Hello World"}}, + } + + runTestCases(t, tests) +} + +func TestBase32Decode(t *testing.T) { + var tests = testCases{ + {"TestWithoutInput", `{{ "" | base32Decode }}`, "", nil}, + {"TestHelloWorldInput", `{{ "JBSWY3DPEBLW64TMMQ======" | base32Decode }}`, "Hello World", nil}, + {"TestFromVariableInput", `{{ .V | base32Decode }}`, "Hello World", map[string]any{"V": "JBSWY3DPEBLW64TMMQ======"}}, + {"TestInvalidInput", `{{ "JBSWY3DPEBLW64TMMQ" | base32Decode }}`, "", nil}, + } + + runTestCases(t, tests) +} + +func TestFromJson(t *testing.T) { + var tests = testCases{ + {"TestEmptyInput", `{{ "" | fromJson }}`, "", nil}, + {"TestVariableInput", `{{ .V | fromJson }}`, "map[foo:55]", map[string]any{"V": `{"foo": 55}`}}, + {"TestAccessField", `{{ (.V | fromJson).foo }}`, "55", map[string]any{"V": `{"foo": 55}`}}, + } + + runTestCases(t, tests) +} + +func TestToJson(t *testing.T) { + var tests = testCases{ + {"TestEmptyInput", `{{ "" | toJson }}`, "\"\"", nil}, + {"TestVariableInput", `{{ .V | toJson }}`, "{\"bar\":\"baz\",\"foo\":55}", map[string]any{"V": map[string]any{"foo": 55, "bar": "baz"}}}, + } + + runTestCases(t, tests) +} + +func TestToPrettyJson(t *testing.T) { + var tests = testCases{ + {"TestEmptyInput", `{{ "" | toPrettyJson }}`, "\"\"", nil}, + {"TestVariableInput", `{{ .V | toPrettyJson }}`, "{\n \"bar\": \"baz\",\n \"foo\": 55\n}", map[string]any{"V": map[string]any{"foo": 55, "bar": "baz"}}}, + } + + runTestCases(t, tests) +} + +func TestToRawJson(t *testing.T) { + var tests = testCases{ + {"TestEmptyInput", `{{ "" | toRawJson }}`, "\"\"", nil}, + {"TestVariableInput", `{{ .V | toRawJson }}`, "{\"bar\":\"baz\",\"foo\":55}", map[string]any{"V": map[string]any{"foo": 55, "bar": "baz"}}}, + } + + runTestCases(t, tests) +} + +func TestMustFromJson(t *testing.T) { + var tests = mustTestCases{ + {testCase{"TestEmptyInput", `{{ "" | mustFromJson }}`, "", nil}, "unexpected end"}, + {testCase{"TestVariableInput", `{{ .V | mustFromJson }}`, "map[foo:55]", map[string]any{"V": `{"foo": 55}`}}, ""}, + {testCase{"TestInvalidInput", `{{ .V | mustFromJson }}`, "", map[string]any{"V": "{3}"}}, "invalid character '3'"}, + } + + runMustTestCases(t, tests) +} + +func TestMustToJson(t *testing.T) { + var tests = mustTestCases{ + {testCase{"TestEmptyInput", `{{ "" | mustToJson }}`, "\"\"", nil}, ""}, + {testCase{"TestVariableInput", `{{ .V | mustToJson }}`, "{\"bar\":\"baz\",\"foo\":55}", map[string]any{"V": map[string]any{"foo": 55, "bar": "baz"}}}, ""}, + {testCase{"TestInvalidInput", `{{ .V | mustToJson }}`, "", map[string]any{"V": make(chan int)}}, "json: unsupported type: chan int"}, + } + + runMustTestCases(t, tests) +} + +func TestMustToPrettyJson(t *testing.T) { + var tests = mustTestCases{ + {testCase{"TestEmptyInput", `{{ "" | mustToPrettyJson }}`, "\"\"", nil}, ""}, + {testCase{"TestVariableInput", `{{ .V | mustToPrettyJson }}`, "{\n \"bar\": \"baz\",\n \"foo\": 55\n}", map[string]any{"V": map[string]any{"foo": 55, "bar": "baz"}}}, ""}, + {testCase{"TestInvalidInput", `{{ .V | mustToPrettyJson }}`, "", map[string]any{"V": make(chan int)}}, "json: unsupported type: chan int"}, + } + + runMustTestCases(t, tests) +} + +func TestMustToRawJson(t *testing.T) { + var tests = mustTestCases{ + {testCase{"TestEmptyInput", `{{ "" | mustToRawJson }}`, "\"\"", nil}, ""}, + {testCase{"TestVariableInput", `{{ .V | mustToRawJson }}`, "{\"bar\":\"baz\",\"foo\":55}", map[string]any{"V": map[string]any{"foo": 55, "bar": "baz"}}}, ""}, + {testCase{"TestInvalidInput", `{{ .V | mustToRawJson }}`, "", map[string]any{"V": make(chan int)}}, "json: unsupported type: chan int"}, + } + + runMustTestCases(t, tests) +} diff --git a/example_test.go b/example_test.go deleted file mode 100644 index 343e34a..0000000 --- a/example_test.go +++ /dev/null @@ -1,25 +0,0 @@ -package sprout - -import ( - "fmt" - "os" - "text/template" -) - -func Example() { - // Set up variables and template. - vars := map[string]interface{}{"Name": " John Jacob Jingleheimer Schmidt "} - tpl := `Hello {{.Name | trim | lower}}` - - // Get the sprout function map. - fmap := TxtFuncMap() - t := template.Must(template.New("test").Funcs(fmap).Parse(tpl)) - - err := t.Execute(os.Stdout, vars) - if err != nil { - fmt.Printf("Error during template execution: %s", err) - return - } - // Output: - // Hello john jacob jingleheimer schmidt -} diff --git a/filesystem_functions.go b/filesystem_functions.go new file mode 100644 index 0000000..87a5ca3 --- /dev/null +++ b/filesystem_functions.go @@ -0,0 +1,217 @@ +package sprout + +import ( + "os" + "path" + "path/filepath" +) + +// PathBase returns the last element of the path. +// +// Parameters: +// +// str string - the path string. +// +// Returns: +// +// string - the base element of the path. +// +// Example: +// +// {{ "/path/to/file.txt" | pathBase }} // Output: "file.txt" +func (fh *FunctionHandler) PathBase(str string) string { + return path.Base(str) +} + +// PathDir returns all but the last element of the path, effectively the path's +// directory. +// +// Parameters: +// +// str string - the path string. +// +// Returns: +// +// string - the directory part of the path. +// +// Example: +// +// {{ "/path/to/file.txt" | pathDir }} // Output: "/path/to" +func (fh *FunctionHandler) PathDir(str string) string { + return path.Dir(str) +} + +// PathExt returns the file extension of the path. +// +// Parameters: +// +// str string - the path string. +// +// Returns: +// +// string - the extension of the file in the path. +// +// Example: +// +// {{ "/path/to/file.txt" | pathExt }} // Output: ".txt" +func (fh *FunctionHandler) PathExt(str string) string { + return path.Ext(str) +} + +// PathClean cleans up the path, simplifying any redundancies like double slashes. +// +// Parameters: +// +// str string - the path string. +// +// Returns: +// +// string - the cleaned path. +// +// Example: +// +// {{ "/path//to/file.txt" | pathClean }} // Output: "/path/to/file.txt" +func (fh *FunctionHandler) PathClean(str string) string { + return path.Clean(str) +} + +// PathIsAbs checks if the path is absolute. +// +// Parameters: +// +// str string - the path string. +// +// Returns: +// +// bool - true if the path is absolute, otherwise false. +// +// Example: +// +// {{ "/path/to/file.txt" | pathIsAbs }} // Output: true +func (fh *FunctionHandler) PathIsAbs(str string) bool { + return path.IsAbs(str) +} + +// OsBase returns the last element of the path, using the OS-specific path +// separator. +// +// Parameters: +// +// str string - the path string. +// +// Returns: +// +// string - the base element of the path. +// +// Example: +// +// {{ "C:\\path\\to\\file.txt" | osBase }} // Output: "file.txt" +func (fh *FunctionHandler) OsBase(str string) string { + return filepath.Base(str) +} + +// OsDir returns all but the last element of the path, using the OS-specific +// path separator. +// +// Parameters: +// +// str string - the path string. +// +// Returns: +// +// string - the directory part of the path. +// +// Example: +// +// {{ "C:\\path\\to\\file.txt" | osDir }} // Output: "C:\\path\\to" +func (fh *FunctionHandler) OsDir(str string) string { + return filepath.Dir(str) +} + +// OsExt returns the file extension of the path, using the OS-specific path +// separator. +// +// Parameters: +// +// str string - the path string. +// +// Returns: +// +// string - the extension of the file in the path. +// +// Example: +// +// {{ "C:\\path\\to\\file.txt" | osExt }} // Output: ".txt" +func (fh *FunctionHandler) OsExt(str string) string { + return filepath.Ext(str) +} + +// OsClean cleans up the path, using the OS-specific path separator and +// simplifying redundancies. +// +// Parameters: +// +// str string - the path string. +// +// Returns: +// +// string - the cleaned path. +// +// Example: +// +// {{ "C:\\path\\\\to\\file.txt" | osClean }} // Output: "C:\\path\\to\\file.txt" +func (fh *FunctionHandler) OsClean(str string) string { + return filepath.Clean(str) +} + +// OsIsAbs checks if the path is absolute, using the OS-specific path separator. +// +// Parameters: +// +// str string - the path string. +// +// Returns: +// +// bool - true if the path is absolute, otherwise false. +// +// Example: +// +// {{ "C:\\path\\to\\file.txt" | osIsAbs }} // Output: true +func (fh *FunctionHandler) OsIsAbs(str string) bool { + return filepath.IsAbs(str) +} + +// Env retrieves the value of an environment variable. +// +// Parameters: +// +// key string - the name of the environment variable. +// +// Returns: +// +// string - the value of the environment variable. +// +// Example: +// +// {{ "PATH" | env }} // Output: "/usr/bin:/bin:/usr/sbin:/sbin" +func (fh *FunctionHandler) Env(key string) string { + return os.Getenv(key) +} + +// ExpandEnv replaces ${var} or $var in the string based on the values of the +// current environment variables. +// +// Parameters: +// +// str string - the string with environment variables to expand. +// +// Returns: +// +// string - the expanded string. +// +// Example: +// +// {{ "Path is $PATH" | expandEnv }} // Output: "Path is /usr/bin:/bin:/usr/sbin:/sbin" +func (fh *FunctionHandler) ExpandEnv(str string) string { + return os.ExpandEnv(str) +} diff --git a/filesystem_functions_linux_test.go b/filesystem_functions_linux_test.go new file mode 100644 index 0000000..ac30bea --- /dev/null +++ b/filesystem_functions_linux_test.go @@ -0,0 +1,65 @@ +package sprout + +import "testing" + +func TestOsBase(t *testing.T) { + var tests = testCases{ + {"TestEmptyPath", `{{ osBase "" }}`, ".", nil}, + {"TestRootPath", `{{ osBase "/" }}`, "/", nil}, + {"TestWithoutExtension", `{{ osBase "/path/to/file" }}`, "file", nil}, + {"TestWithFileInput", `{{ osBase "/path/to/file.txt" }}`, "file.txt", nil}, + {"TestPipeSyntax", `{{ "/path/to/file.txt" | osBase }}`, "file.txt", nil}, + {"TestVariableInput", `{{ .V | osBase }}`, "file", map[string]any{"V": "/path/to/file"}}, + } + + runTestCases(t, tests) +} + +func TestOsDir(t *testing.T) { + var tests = testCases{ + {"TestEmptyPath", `{{ osDir "" }}`, ".", nil}, + {"TestRootPath", `{{ osDir "/" }}`, "/", nil}, + {"TestWithoutExtension", `{{ osDir "/path/to/file" }}`, "/path/to", nil}, + {"TestWithFileInput", `{{ osDir "/path/to/file.txt" }}`, "/path/to", nil}, + {"TestPipeSyntax", `{{ "/path/to/file.txt" | osDir }}`, "/path/to", nil}, + {"TestVariableInput", `{{ .V | osDir }}`, "/path/to", map[string]any{"V": "/path/to/file"}}, + } + + runTestCases(t, tests) +} + +func TestOsExt(t *testing.T) { + var tests = testCases{ + {"TestEmptyPath", `{{ osExt "" }}`, "", nil}, + {"TestRootPath", `{{ osExt "/" }}`, "", nil}, + {"TestWithoutExtension", `{{ osExt "/path/to/file" }}`, "", nil}, + {"TestWithFileInput", `{{ osExt "/path/to/file.txt" }}`, ".txt", nil}, + {"TestPipeSyntax", `{{ "/path/to/file.txt" | osExt }}`, ".txt", nil}, + {"TestVariableInput", `{{ .V | osExt }}`, ".txt", map[string]any{"V": "/path/to/file.txt"}}, + } + + runTestCases(t, tests) +} + +func TestOsClean(t *testing.T) { + var tests = testCases{ + {"TestEmptyPath", `{{ osClean "" }}`, ".", nil}, + {"TestRootPath", `{{ osClean "/" }}`, "/", nil}, + {"TestWithoutExtension", `{{ osClean "/path///to/file" }}`, "/path/to/file", nil}, + } + + runTestCases(t, tests) +} + +func TestOsIsAbs(t *testing.T) { + var tests = testCases{ + {"TestEmptyPath", `{{ osIsAbs "" }}`, "false", nil}, + {"TestRootPath", `{{ osIsAbs "/" }}`, "true", nil}, + {"TestRelativePath", `{{ osIsAbs "path/to/file" }}`, "false", nil}, + {"TestAbsolutePath", `{{ osIsAbs "/path/to/file.txt" }}`, "true", nil}, + {"TestPipeSyntax", `{{ "file.txt" | osIsAbs }}`, "false", nil}, + {"TestVariableInput", `{{ osIsAbs .V }}`, "true", map[string]any{"V": "/path/to/file"}}, + } + + runTestCases(t, tests) +} diff --git a/filesystem_functions_test.go b/filesystem_functions_test.go new file mode 100644 index 0000000..9307077 --- /dev/null +++ b/filesystem_functions_test.go @@ -0,0 +1,101 @@ +package sprout + +import ( + "os" + "testing" +) + +func TestPathBase(t *testing.T) { + var tests = testCases{ + {"TestEmptyPath", `{{ pathBase "" }}`, ".", nil}, + {"TestRootPath", `{{ pathBase "/" }}`, "/", nil}, + {"TestWithoutExtension", `{{ pathBase "/path/to/file" }}`, "file", nil}, + {"TestWithFileInput", `{{ pathBase "/path/to/file.txt" }}`, "file.txt", nil}, + {"TestPipeSyntax", `{{ "/path/to/file.txt" | pathBase }}`, "file.txt", nil}, + {"TestVariableInput", `{{ .V | pathBase }}`, "file", map[string]any{"V": "/path/to/file"}}, + } + + runTestCases(t, tests) +} + +func TestPathDir(t *testing.T) { + var tests = testCases{ + {"TestEmptyPath", `{{ pathDir "" }}`, ".", nil}, + {"TestRootPath", `{{ pathDir "/" }}`, "/", nil}, + {"TestWithoutExtension", `{{ pathDir "/path/to/file" }}`, "/path/to", nil}, + {"TestWithFileInput", `{{ pathDir "/path/to/file.txt" }}`, "/path/to", nil}, + {"TestPipeSyntax", `{{ "/path/to/file.txt" | pathDir }}`, "/path/to", nil}, + {"TestVariableInput", `{{ .V | pathDir }}`, "/path/to", map[string]any{"V": "/path/to/file"}}, + } + + runTestCases(t, tests) +} + +func TestPathExt(t *testing.T) { + var tests = testCases{ + {"TestEmptyPath", `{{ pathExt "" }}`, "", nil}, + {"TestRootPath", `{{ pathExt "/" }}`, "", nil}, + {"TestWithoutExtension", `{{ pathExt "/path/to/file" }}`, "", nil}, + {"TestWithFileInput", `{{ pathExt "/path/to/file.txt" }}`, ".txt", nil}, + {"TestPipeSyntax", `{{ "/path/to/file.txt" | pathExt }}`, ".txt", nil}, + {"TestVariableInput", `{{ .V | pathExt }}`, ".txt", map[string]any{"V": "/path/to/file.txt"}}, + } + + runTestCases(t, tests) +} + +func TestPathClean(t *testing.T) { + var tests = testCases{ + {"TestEmptyPath", `{{ pathClean "" }}`, ".", nil}, + {"TestRootPath", `{{ pathClean "/" }}`, "/", nil}, + {"TestWithoutExtension", `{{ pathClean "/path/to/file" }}`, "/path/to/file", nil}, + {"TestWithFileInput", `{{ pathClean "/path/to/file.txt" }}`, "/path/to/file.txt", nil}, + {"TestPipeSyntax", `{{ "/path/to/file.txt" | pathClean }}`, "/path/to/file.txt", nil}, + {"TestVariableInput", `{{ .V | pathClean }}`, "/path/to/file", map[string]any{"V": "/path/to/file"}}, + {"TestDoubleSlash", `{{ pathClean "/path//to/file" }}`, "/path/to/file", nil}, + {"TestDotSlash", `{{ pathClean "/path/./to/file" }}`, "/path/to/file", nil}, + {"TestDotDotSlash", `{{ pathClean "/path/../to/file" }}`, "/to/file", nil}, + } + + runTestCases(t, tests) +} + +func TestPathIsAbs(t *testing.T) { + var tests = testCases{ + {"TestEmptyPath", `{{ pathIsAbs "" }}`, "false", nil}, + {"TestRootPath", `{{ pathIsAbs "/" }}`, "true", nil}, + {"TestRelativePath", `{{ pathIsAbs "path/to/file" }}`, "false", nil}, + {"TestAbsolutePath", `{{ pathIsAbs "/path/to/file.txt" }}`, "true", nil}, + {"TestPipeSyntax", `{{ "file.txt" | pathIsAbs }}`, "false", nil}, + {"TestVariableInput", `{{ pathIsAbs .V }}`, "true", map[string]any{"V": "/path/to/file"}}, + } + + runTestCases(t, tests) +} + +func TestEnv(t *testing.T) { + os.Setenv("__SPROUT_TEST_ENV_KEY", "sprout will grow!") + var tests = testCases{ + {"TestEmpty", `{{ env "" }}`, "", nil}, + {"TestNonExistent", `{{ env "NON_EXISTENT_ENV_VAR" }}`, "", nil}, + {"TestExisting", `{{ env "__SPROUT_TEST_ENV_KEY" }}`, "sprout will grow!", nil}, + {"TestPipeSyntax", `{{ "__SPROUT_TEST_ENV_KEY" | env }}`, "sprout will grow!", nil}, + {"TestVariableInput", `{{ .V | env }}`, "sprout will grow!", map[string]any{"V": "__SPROUT_TEST_ENV_KEY"}}, + } + + runTestCases(t, tests) +} + +func TestExpandEnv(t *testing.T) { + os.Setenv("__SPROUT_TEST_ENV_KEY", "sprout will grow!") + var tests = testCases{ + {"TestEmpty", `{{ expandEnv "" }}`, "", nil}, + {"TestNonExistent", `{{ expandEnv "Hey" }}`, "Hey", nil}, + {"TestNonExistent", `{{ expandEnv "$NON_EXISTENT_ENV_VAR" }}`, "", nil}, + {"TestExisting", `{{ expandEnv "Hey $__SPROUT_TEST_ENV_KEY" }}`, "Hey sprout will grow!", nil}, + {"TestPipeSyntax", `{{ "Hey $__SPROUT_TEST_ENV_KEY" | expandEnv }}`, "Hey sprout will grow!", nil}, + {"TestVariableInput", `{{ .V | expandEnv }}`, "Hey sprout will grow!", map[string]any{"V": "Hey $__SPROUT_TEST_ENV_KEY"}}, + } + + runTestCases(t, tests) +} diff --git a/filesystem_functions_windows_test.go b/filesystem_functions_windows_test.go new file mode 100644 index 0000000..36fc5b4 --- /dev/null +++ b/filesystem_functions_windows_test.go @@ -0,0 +1,67 @@ +package sprout + +import ( + "testing" +) + +func TestOsBase(t *testing.T) { + var tests = testCases{ + {"TestEmptyPath", `{{ osBase "" }}`, ".", nil}, + {"TestRootPath", `{{ osBase "D:\\" }}`, "\\", nil}, + {"TestWithoutExtension", `{{ osBase "D:\\path\\to\\file" }}`, "file", nil}, + {"TestWithFileInput", `{{ osBase "D:\\path\\to\\file.txt" }}`, "file.txt", nil}, + {"TestPipeSyntax", `{{ "D:\\path\\to\\file.txt" | osBase }}`, "file.txt", nil}, + {"TestVariableInput", `{{ .V | osBase }}`, "file", map[string]any{"V": "\\path\\to\\file"}}, + } + + runTestCases(t, tests) +} + +func TestOsDir(t *testing.T) { + var tests = testCases{ + {"TestEmptyPath", `{{ osDir "" }}`, ".", nil}, + {"TestRootPath", `{{ osDir "D:\\" }}`, "D:\\", nil}, + {"TestWithoutExtension", `{{ osDir "D:\\path\\to\\file" }}`, "D:\\path\\to", nil}, + {"TestWithFileInput", `{{ osDir "D:\\path\\to\\file.txt" }}`, "D:\\path\\to", nil}, + {"TestPipeSyntax", `{{ "D:\\path\\to\\file.txt" | osDir }}`, "D:\\path\\to", nil}, + {"TestVariableInput", `{{ .V | osDir }}`, "\\path\\to", map[string]any{"V": "\\path\\to\\file"}}, + } + + runTestCases(t, tests) +} + +func TestOsExt(t *testing.T) { + var tests = testCases{ + {"TestEmptyPath", `{{ osExt "" }}`, "", nil}, + {"TestRootPath", `{{ osExt "\\" }}`, "", nil}, + {"TestWithoutExtension", `{{ osExt "D:\\path\\to\\file" }}`, "", nil}, + {"TestWithFileInput", `{{ osExt "D:\\path\\to\\file.txt" }}`, ".txt", nil}, + {"TestPipeSyntax", `{{ "D:\\path\\to\\file.txt" | osExt }}`, ".txt", nil}, + {"TestVariableInput", `{{ .V | osExt }}`, ".txt", map[string]any{"V": "D:\\path\\to\\file.txt"}}, + } + + runTestCases(t, tests) +} + +func TestOsClean(t *testing.T) { + var tests = testCases{ + {"TestEmptyPath", `{{ osClean "" }}`, ".", nil}, + {"TestRootPath", `{{ osClean "D:\\" }}`, "D:\\", nil}, + {"TestWithoutExtension", `{{ osClean "D:\\path\\\\to\\file" }}`, "D:\\path\\to\\file", nil}, + } + + runTestCases(t, tests) +} + +func TestOsIsAbs(t *testing.T) { + var tests = testCases{ + {"TestEmptyPath", `{{ osIsAbs "" }}`, "false", nil}, + {"TestRootPath", `{{ osIsAbs "D:\\" }}`, "true", nil}, + {"TestRelativePath", `{{ osIsAbs "path\\to\\file" }}`, "false", nil}, + {"TestAbsolutePath", `{{ osIsAbs "D:\\path\\to\\file.txt" }}`, "true", nil}, + {"TestPipeSyntax", `{{ "file.txt" | osIsAbs }}`, "false", nil}, + {"TestVariableInput", `{{ osIsAbs .V }}`, "true", map[string]any{"V": "D:\\path\\to\\file"}}, + } + + runTestCases(t, tests) +} diff --git a/flow_control_test.go b/flow_control_test.go deleted file mode 100644 index c7c282b..0000000 --- a/flow_control_test.go +++ /dev/null @@ -1,16 +0,0 @@ -package sprout - -import ( - "fmt" - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestFail(t *testing.T) { - const msg = "This is an error!" - tpl := fmt.Sprintf(`{{fail "%s"}}`, msg) - _, err := runRaw(tpl, nil) - assert.Error(t, err) - assert.Contains(t, err.Error(), msg) -} diff --git a/functions.go b/functions.go deleted file mode 100644 index 46183c7..0000000 --- a/functions.go +++ /dev/null @@ -1,362 +0,0 @@ -package sprout - -import ( - "errors" - "html/template" - "math/rand" - "os" - "path" - "path/filepath" - "reflect" - "strings" - ttemplate "text/template" - "time" - - "github.com/huandu/xstrings" - "github.com/shopspring/decimal" -) - -// These functions are not guaranteed to evaluate to the same result for given input, because they -// refer to the environment or global state. -var nonhermeticFunctions = []string{ - // Date functions - "date", - "date_in_zone", - "dateInZone", - "date_modify", - "dateModify", - "now", - "htmlDate", - "htmlDateInZone", - - // Strings - "randAlphaNum", - "randAlpha", - "randAscii", - "randNumeric", - "randBytes", - "uuidv4", - - // OS - "env", - "expandenv", - - // Network - "getHostByName", -} - -var genericMap = map[string]interface{}{ - "hello": func() string { return "Hello!" }, - - // Date functions - "ago": dateAgo, - "date": date, - "dateModify": dateModify, - "dateInZone": dateInZone, - "duration": duration, - "durationRound": durationRound, - "htmlDate": htmlDate, - "htmlDateInZone": htmlDateInZone, - "mustDateModify": mustDateModify, - "mustToDate": mustToDate, - "now": time.Now, - "toDate": toDate, - "unixEpoch": unixEpoch, - - // Strings - "ellipsis": func(width int, str string) string { return ellipsis(str, 0, width) }, - "ellipsisBoth": func(left, right int, str string) string { return ellipsis(str, left, right) }, - "trunc": trunc, - "trim": strings.TrimSpace, - "upper": strings.ToUpper, - "lower": strings.ToLower, - "title": toTitleCase, - "untitle": untitle, - "substr": substring, - // Switch order so that "foo" | repeat 5 - "repeat": func(count int, str string) string { return strings.Repeat(str, count) }, - // Switch order so that "$foo" | trimall "$" - "trimAll": func(a, b string) string { return strings.Trim(b, a) }, - "trimSuffix": func(a, b string) string { return strings.TrimSuffix(b, a) }, - "trimPrefix": func(a, b string) string { return strings.TrimPrefix(b, a) }, - "nospace": nospace, - "initials": func(a string) string { return initials(a, "") }, - "randAlphaNum": randAlphaNumeric, - "randAlpha": randAlpha, - "randAscii": randAscii, - "randNumeric": randNumeric, - "swapcase": swapCase, - "shuffle": shuffle, - "snakecase": xstrings.ToSnakeCase, - "camelcase": xstrings.ToCamelCase, - "kebabcase": xstrings.ToKebabCase, - "wrap": func(l int, s string) string { return wordWrap(s, l, "", false) }, - "wrapWith": func(l int, sep, str string) string { return wordWrap(str, l, sep, true) }, - // Switch order so that "foobar" | contains "foo" - "contains": func(substr string, str string) bool { return strings.Contains(str, substr) }, - "hasPrefix": func(substr string, str string) bool { return strings.HasPrefix(str, substr) }, - "hasSuffix": func(substr string, str string) bool { return strings.HasSuffix(str, substr) }, - "quote": quote, - "squote": squote, - "cat": cat, - "indent": indent, - "nindent": nindent, - "replace": replace, - "plural": plural, - "sha1sum": sha1sum, - "sha256sum": sha256sum, - "adler32sum": adler32sum, - "toString": strval, - - // Wrap Atoi to stop errors. - "int64": toInt64, - "int": toInt, - "float64": toFloat64, - "seq": seq, - "toDecimal": toDecimal, - - //"gt": func(a, b int) bool {return a > b}, - //"gte": func(a, b int) bool {return a >= b}, - //"lt": func(a, b int) bool {return a < b}, - //"lte": func(a, b int) bool {return a <= b}, - - // split "/" foo/bar returns map[int]string{0: foo, 1: bar} - "split": split, - "splitList": func(sep, orig string) []string { return strings.Split(orig, sep) }, - // splitn "/" foo/bar/fuu returns map[int]string{0: foo, 1: bar/fuu} - "splitn": splitn, - "toStrings": strslice, - - "until": until, - "untilStep": untilStep, - - // VERY basic arithmetic. - "add1": func(i interface{}) int64 { return toInt64(i) + 1 }, - "add": func(i ...interface{}) int64 { - var a int64 = 0 - for _, b := range i { - a += toInt64(b) - } - return a - }, - "sub": func(a, b interface{}) int64 { return toInt64(a) - toInt64(b) }, - "div": func(a, b interface{}) int64 { return toInt64(a) / toInt64(b) }, - "mod": func(a, b interface{}) int64 { return toInt64(a) % toInt64(b) }, - "mul": func(a interface{}, v ...interface{}) int64 { - val := toInt64(a) - for _, b := range v { - val = val * toInt64(b) - } - return val - }, - "randInt": func(min, max int) int { return rand.Intn(max-min) + min }, - "add1f": func(i interface{}) float64 { - return execDecimalOp(i, []interface{}{1}, func(d1, d2 decimal.Decimal) decimal.Decimal { return d1.Add(d2) }) - }, - "addf": func(i ...interface{}) float64 { - a := interface{}(float64(0)) - return execDecimalOp(a, i, func(d1, d2 decimal.Decimal) decimal.Decimal { return d1.Add(d2) }) - }, - "subf": func(a interface{}, v ...interface{}) float64 { - return execDecimalOp(a, v, func(d1, d2 decimal.Decimal) decimal.Decimal { return d1.Sub(d2) }) - }, - "divf": func(a interface{}, v ...interface{}) float64 { - return execDecimalOp(a, v, func(d1, d2 decimal.Decimal) decimal.Decimal { return d1.Div(d2) }) - }, - "mulf": func(a interface{}, v ...interface{}) float64 { - return execDecimalOp(a, v, func(d1, d2 decimal.Decimal) decimal.Decimal { return d1.Mul(d2) }) - }, - "max": max, - "min": min, - "maxf": maxf, - "minf": minf, - "ceil": ceil, - "floor": floor, - "round": round, - - // string slices. Note that we reverse the order b/c that's better - // for template processing. - "join": join, - "sortAlpha": sortAlpha, - - // Defaults - "default": dfault, - "empty": empty, - "coalesce": coalesce, - "all": all, - "any": any, - "compact": compact, - "mustCompact": mustCompact, - "fromJson": fromJson, - "toJson": toJson, - "toPrettyJson": toPrettyJson, - "toRawJson": toRawJson, - "mustFromJson": mustFromJson, - "mustToJson": mustToJson, - "mustToPrettyJson": mustToPrettyJson, - "mustToRawJson": mustToRawJson, - "ternary": ternary, - "deepCopy": deepCopy, - "mustDeepCopy": mustDeepCopy, - - // Reflection - "typeOf": typeOf, - "typeIs": typeIs, - "typeIsLike": typeIsLike, - "kindOf": kindOf, - "kindIs": kindIs, - "deepEqual": reflect.DeepEqual, - - // OS: - "env": os.Getenv, - "expandenv": os.ExpandEnv, - - // Network: - "getHostByName": getHostByName, - - // Paths: - "base": path.Base, - "dir": path.Dir, - "clean": path.Clean, - "ext": path.Ext, - "isAbs": path.IsAbs, - - // Filepaths: - "osBase": filepath.Base, - "osClean": filepath.Clean, - "osDir": filepath.Dir, - "osExt": filepath.Ext, - "osIsAbs": filepath.IsAbs, - - // Encoding: - "b64enc": base64encode, - "b64dec": base64decode, - "b32enc": base32encode, - "b32dec": base32decode, - - // Data Structures: - "list": list, - "dict": dict, - "get": get, - "set": set, - "unset": unset, - "hasKey": hasKey, - "pluck": pluck, - "keys": keys, - "pick": pick, - "omit": omit, - "merge": merge, - "mergeOverwrite": mergeOverwrite, - "mustMerge": mustMerge, - "mustMergeOverwrite": mustMergeOverwrite, - "values": values, - - "append": push, - "mustAppend": mustPush, - "prepend": prepend, - "mustPrepend": mustPrepend, - "first": first, - "mustFirst": mustFirst, - "rest": rest, - "mustRest": mustRest, - "last": last, - "mustLast": mustLast, - "initial": initial, - "mustInitial": mustInitial, - "reverse": reverse, - "mustReverse": mustReverse, - "uniq": uniq, - "mustUniq": mustUniq, - "without": without, - "mustWithout": mustWithout, - "has": has, - "mustHas": mustHas, - "slice": slice, - "mustSlice": mustSlice, - "concat": concat, - "dig": dig, - "chunk": chunk, - "mustChunk": mustChunk, - - // Crypto: - "bcrypt": bcrypt, - "htpasswd": htpasswd, - "genPrivateKey": generatePrivateKey, - "derivePassword": derivePassword, - "buildCustomCert": buildCustomCertificate, - "genCA": generateCertificateAuthority, - "genCAWithKey": generateCertificateAuthorityWithPEMKey, - "genSelfSignedCert": generateSelfSignedCertificate, - "genSelfSignedCertWithKey": generateSelfSignedCertificateWithPEMKey, - "genSignedCert": generateSignedCertificate, - "genSignedCertWithKey": generateSignedCertificateWithPEMKey, - "encryptAES": encryptAES, - "decryptAES": decryptAES, - "randBytes": randBytes, - - // UUIDs: - "uuidv4": uuidv4, - - // SemVer: - "semver": semver, - "semverCompare": semverCompare, - - // Flow Control: - "fail": func(msg string) (string, error) { return "", errors.New(msg) }, - - // Regex - "regexMatch": regexMatch, - "mustRegexMatch": mustRegexMatch, - "regexFindAll": regexFindAll, - "mustRegexFindAll": mustRegexFindAll, - "regexFind": regexFind, - "mustRegexFind": mustRegexFind, - "regexReplaceAll": regexReplaceAll, - "mustRegexReplaceAll": mustRegexReplaceAll, - "regexReplaceAllLiteral": regexReplaceAllLiteral, - "mustRegexReplaceAllLiteral": mustRegexReplaceAllLiteral, - "regexSplit": regexSplit, - "mustRegexSplit": mustRegexSplit, - "regexQuoteMeta": regexQuoteMeta, - - // URLs: - "urlParse": urlParse, - "urlJoin": urlJoin, -} - -// HermeticTxtFuncMap returns a 'text/template'.FuncMap with only repeatable functions. -func HermeticTxtFuncMap() ttemplate.FuncMap { - r := TxtFuncMap() - for _, name := range nonhermeticFunctions { - delete(r, name) - } - return r -} - -// HermeticHtmlFuncMap returns an 'html/template'.Funcmap with only repeatable functions. -func HermeticHtmlFuncMap() template.FuncMap { - r := HtmlFuncMap() - for _, name := range nonhermeticFunctions { - delete(r, name) - } - return r -} - -// TxtFuncMap returns a 'text/template'.FuncMap -func TxtFuncMap() ttemplate.FuncMap { - return ttemplate.FuncMap(GenericFuncMap()) -} - -// HtmlFuncMap returns an 'html/template'.Funcmap -func HtmlFuncMap() template.FuncMap { - return template.FuncMap(GenericFuncMap()) -} - -// GenericFuncMap returns a copy of the basic function map as a map[string]interface{}. -func GenericFuncMap() map[string]interface{} { - gfm := make(map[string]interface{}, len(genericMap)) - for k, v := range genericMap { - gfm[k] = v - } - return gfm -} diff --git a/functions_linux_test.go b/functions_linux_test.go deleted file mode 100644 index 49d2477..0000000 --- a/functions_linux_test.go +++ /dev/null @@ -1,28 +0,0 @@ -package sprout - -import ( - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestOsBase(t *testing.T) { - assert.NoError(t, runt(`{{ osBase "foo/bar" }}`, "bar")) -} - -func TestOsDir(t *testing.T) { - assert.NoError(t, runt(`{{ osDir "foo/bar/baz" }}`, "foo/bar")) -} - -func TestOsIsAbs(t *testing.T) { - assert.NoError(t, runt(`{{ osIsAbs "/foo" }}`, "true")) - assert.NoError(t, runt(`{{ osIsAbs "foo" }}`, "false")) -} - -func TestOsClean(t *testing.T) { - assert.NoError(t, runt(`{{ osClean "/foo/../foo/../bar" }}`, "/bar")) -} - -func TestOsExt(t *testing.T) { - assert.NoError(t, runt(`{{ osExt "/foo/bar/baz.txt" }}`, ".txt")) -} diff --git a/functions_test.go b/functions_test.go deleted file mode 100644 index 6dbd82d..0000000 --- a/functions_test.go +++ /dev/null @@ -1,128 +0,0 @@ -package sprout - -import ( - "bytes" - "fmt" - "math/rand" - "os" - "testing" - "text/template" - - "github.com/stretchr/testify/assert" -) - -func TestEnv(t *testing.T) { - os.Setenv("FOO", "bar") - tpl := `{{env "FOO"}}` - if err := runt(tpl, "bar"); err != nil { - t.Error(err) - } -} - -func TestExpandEnv(t *testing.T) { - os.Setenv("FOO", "bar") - tpl := `{{expandenv "Hello $FOO"}}` - if err := runt(tpl, "Hello bar"); err != nil { - t.Error(err) - } -} - -func TestBase(t *testing.T) { - assert.NoError(t, runt(`{{ base "foo/bar" }}`, "bar")) -} - -func TestDir(t *testing.T) { - assert.NoError(t, runt(`{{ dir "foo/bar/baz" }}`, "foo/bar")) -} - -func TestIsAbs(t *testing.T) { - assert.NoError(t, runt(`{{ isAbs "/foo" }}`, "true")) - assert.NoError(t, runt(`{{ isAbs "foo" }}`, "false")) -} - -func TestClean(t *testing.T) { - assert.NoError(t, runt(`{{ clean "/foo/../foo/../bar" }}`, "/bar")) -} - -func TestExt(t *testing.T) { - assert.NoError(t, runt(`{{ ext "/foo/bar/baz.txt" }}`, ".txt")) -} - -func TestSnakeCase(t *testing.T) { - assert.NoError(t, runt(`{{ snakecase "FirstName" }}`, "first_name")) - assert.NoError(t, runt(`{{ snakecase "HTTPServer" }}`, "http_server")) - assert.NoError(t, runt(`{{ snakecase "NoHTTPS" }}`, "no_https")) - assert.NoError(t, runt(`{{ snakecase "GO_PATH" }}`, "go_path")) - assert.NoError(t, runt(`{{ snakecase "GO PATH" }}`, "go_path")) - assert.NoError(t, runt(`{{ snakecase "GO-PATH" }}`, "go_path")) - assert.NoError(t, runt(`{{ snakecase "http2xx" }}`, "http_2xx")) - assert.NoError(t, runt(`{{ snakecase "HTTP20xOK" }}`, "http_20x_ok")) - assert.NoError(t, runt(`{{ snakecase "Duration2m3s" }}`, "duration_2m3s")) - assert.NoError(t, runt(`{{ snakecase "Bld4Floor3rd" }}`, "bld4_floor_3rd")) -} - -func TestCamelCase(t *testing.T) { - assert.NoError(t, runt(`{{ camelcase "http_server" }}`, "HttpServer")) - assert.NoError(t, runt(`{{ camelcase "_camel_case" }}`, "_CamelCase")) - assert.NoError(t, runt(`{{ camelcase "no_https" }}`, "NoHttps")) - assert.NoError(t, runt(`{{ camelcase "_complex__case_" }}`, "_Complex_Case_")) - assert.NoError(t, runt(`{{ camelcase "all" }}`, "All")) -} - -func TestKebabCase(t *testing.T) { - assert.NoError(t, runt(`{{ kebabcase "FirstName" }}`, "first-name")) - assert.NoError(t, runt(`{{ kebabcase "HTTPServer" }}`, "http-server")) - assert.NoError(t, runt(`{{ kebabcase "NoHTTPS" }}`, "no-https")) - assert.NoError(t, runt(`{{ kebabcase "GO_PATH" }}`, "go-path")) - assert.NoError(t, runt(`{{ kebabcase "GO PATH" }}`, "go-path")) - assert.NoError(t, runt(`{{ kebabcase "GO-PATH" }}`, "go-path")) -} - -func TestShuffle(t *testing.T) { - originalRand := randSource - defer func() { - randSource = originalRand - }() - - randSource = rand.NewSource(42) - // Because we're using a random number generator, we need these to go in - // a predictable sequence: - assert.NoError(t, runt(`{{ shuffle "Hello World" }}`, "Wrlodlle Ho")) -} - -func TestRegex(t *testing.T) { - assert.NoError(t, runt(`{{ regexQuoteMeta "1.2.3" }}`, "1\\.2\\.3")) - assert.NoError(t, runt(`{{ regexQuoteMeta "pretzel" }}`, "pretzel")) -} - -// runt runs a template and checks that the output exactly matches the expected string. -func runt(tpl, expect string) error { - return runtv(tpl, expect, map[string]string{}) -} - -// runtv takes a template, and expected return, and values for substitution. -// -// It runs the template and verifies that the output is an exact match. -func runtv(tpl, expect string, vars interface{}) error { - t := template.Must(template.New("test").Funcs(FuncMap()).Parse(tpl)) - var b bytes.Buffer - err := t.Execute(&b, vars) - if err != nil { - return err - } - if expect != b.String() { - return fmt.Errorf("Expected '%v', got '%v'", expect, b.String()) - } - return nil -} - -// runRaw runs a template with the given variables and returns the result. -func runRaw(tpl string, vars interface{}) (string, error) { - t := template.Must(template.New("test").Funcs(FuncMap()).Parse(tpl)) - var b bytes.Buffer - err := t.Execute(&b, vars) - if err != nil { - return "", err - } - return b.String(), nil -} diff --git a/functions_windows_test.go b/functions_windows_test.go deleted file mode 100644 index 9e1dfd2..0000000 --- a/functions_windows_test.go +++ /dev/null @@ -1,28 +0,0 @@ -package sprout - -import ( - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestOsBase(t *testing.T) { - assert.NoError(t, runt(`{{ osBase "C:\\foo\\bar" }}`, "bar")) -} - -func TestOsDir(t *testing.T) { - assert.NoError(t, runt(`{{ osDir "C:\\foo\\bar\\baz" }}`, "C:\\foo\\bar")) -} - -func TestOsIsAbs(t *testing.T) { - assert.NoError(t, runt(`{{ osIsAbs "C:\\foo" }}`, "true")) - assert.NoError(t, runt(`{{ osIsAbs "foo" }}`, "false")) -} - -func TestOsClean(t *testing.T) { - assert.NoError(t, runt(`{{ osClean "C:\\foo\\..\\foo\\..\\bar" }}`, "C:\\bar")) -} - -func TestOsExt(t *testing.T) { - assert.NoError(t, runt(`{{ osExt "C:\\foo\\bar\\baz.txt" }}`, ".txt")) -} diff --git a/issue_188_test.go b/issue_188_test.go deleted file mode 100644 index b04f38b..0000000 --- a/issue_188_test.go +++ /dev/null @@ -1,33 +0,0 @@ -package sprout - -import ( - "testing" -) - -func TestIssue188(t *testing.T) { - tests := map[string]string{ - - // This first test shows two merges and the merge is NOT A DEEP COPY MERGE. - // The first merge puts $one on to $target. When the second merge of $two - // on to $target the nested dict brought over from $one is changed on - // $one as well as $target. - `{{- $target := dict -}} - {{- $one := dict "foo" (dict "bar" "baz") "qux" true -}} - {{- $two := dict "foo" (dict "bar" "baz2") "qux" false -}} - {{- mergeOverwrite $target $one | toString | trunc 0 }}{{ $__ := mergeOverwrite $target $two }}{{ $one }}`: "map[foo:map[bar:baz2] qux:true]", - - // This test uses deepCopy on $one to create a deep copy and then merge - // that. In this case the merge of $two on to $target does not affect - // $one because a deep copy was used for that merge. - `{{- $target := dict -}} - {{- $one := dict "foo" (dict "bar" "baz") "qux" true -}} - {{- $two := dict "foo" (dict "bar" "baz2") "qux" false -}} - {{- deepCopy $one | mergeOverwrite $target | toString | trunc 0 }}{{ $__ := mergeOverwrite $target $two }}{{ $one }}`: "map[foo:map[bar:baz] qux:true]", - } - - for tpl, expect := range tests { - if err := runt(tpl, expect); err != nil { - t.Error(err) - } - } -} diff --git a/list.go b/list.go deleted file mode 100644 index 6f95cda..0000000 --- a/list.go +++ /dev/null @@ -1,464 +0,0 @@ -package sprout - -import ( - "fmt" - "math" - "reflect" - "sort" -) - -// Reflection is used in these functions so that slices and arrays of strings, -// ints, and other types not implementing []interface{} can be worked with. -// For example, this is useful if you need to work on the output of regexs. - -func list(v ...interface{}) []interface{} { - return v -} - -func push(list interface{}, v interface{}) []interface{} { - l, err := mustPush(list, v) - if err != nil { - panic(err) - } - - return l -} - -func mustPush(list interface{}, v interface{}) ([]interface{}, error) { - tp := reflect.TypeOf(list).Kind() - switch tp { - case reflect.Slice, reflect.Array: - l2 := reflect.ValueOf(list) - - l := l2.Len() - nl := make([]interface{}, l) - for i := 0; i < l; i++ { - nl[i] = l2.Index(i).Interface() - } - - return append(nl, v), nil - - default: - return nil, fmt.Errorf("Cannot push on type %s", tp) - } -} - -func prepend(list interface{}, v interface{}) []interface{} { - l, err := mustPrepend(list, v) - if err != nil { - panic(err) - } - - return l -} - -func mustPrepend(list interface{}, v interface{}) ([]interface{}, error) { - //return append([]interface{}{v}, list...) - - tp := reflect.TypeOf(list).Kind() - switch tp { - case reflect.Slice, reflect.Array: - l2 := reflect.ValueOf(list) - - l := l2.Len() - nl := make([]interface{}, l) - for i := 0; i < l; i++ { - nl[i] = l2.Index(i).Interface() - } - - return append([]interface{}{v}, nl...), nil - - default: - return nil, fmt.Errorf("Cannot prepend on type %s", tp) - } -} - -func chunk(size int, list interface{}) [][]interface{} { - l, err := mustChunk(size, list) - if err != nil { - panic(err) - } - - return l -} - -func mustChunk(size int, list interface{}) ([][]interface{}, error) { - tp := reflect.TypeOf(list).Kind() - switch tp { - case reflect.Slice, reflect.Array: - l2 := reflect.ValueOf(list) - - l := l2.Len() - - cs := int(math.Floor(float64(l-1)/float64(size)) + 1) - nl := make([][]interface{}, cs) - - for i := 0; i < cs; i++ { - clen := size - if i == cs-1 { - clen = int(math.Floor(math.Mod(float64(l), float64(size)))) - if clen == 0 { - clen = size - } - } - - nl[i] = make([]interface{}, clen) - - for j := 0; j < clen; j++ { - ix := i*size + j - nl[i][j] = l2.Index(ix).Interface() - } - } - - return nl, nil - - default: - return nil, fmt.Errorf("Cannot chunk type %s", tp) - } -} - -func last(list interface{}) interface{} { - l, err := mustLast(list) - if err != nil { - panic(err) - } - - return l -} - -func mustLast(list interface{}) (interface{}, error) { - tp := reflect.TypeOf(list).Kind() - switch tp { - case reflect.Slice, reflect.Array: - l2 := reflect.ValueOf(list) - - l := l2.Len() - if l == 0 { - return nil, nil - } - - return l2.Index(l - 1).Interface(), nil - default: - return nil, fmt.Errorf("Cannot find last on type %s", tp) - } -} - -func first(list interface{}) interface{} { - l, err := mustFirst(list) - if err != nil { - panic(err) - } - - return l -} - -func mustFirst(list interface{}) (interface{}, error) { - tp := reflect.TypeOf(list).Kind() - switch tp { - case reflect.Slice, reflect.Array: - l2 := reflect.ValueOf(list) - - l := l2.Len() - if l == 0 { - return nil, nil - } - - return l2.Index(0).Interface(), nil - default: - return nil, fmt.Errorf("Cannot find first on type %s", tp) - } -} - -func rest(list interface{}) []interface{} { - l, err := mustRest(list) - if err != nil { - panic(err) - } - - return l -} - -func mustRest(list interface{}) ([]interface{}, error) { - tp := reflect.TypeOf(list).Kind() - switch tp { - case reflect.Slice, reflect.Array: - l2 := reflect.ValueOf(list) - - l := l2.Len() - if l == 0 { - return nil, nil - } - - nl := make([]interface{}, l-1) - for i := 1; i < l; i++ { - nl[i-1] = l2.Index(i).Interface() - } - - return nl, nil - default: - return nil, fmt.Errorf("Cannot find rest on type %s", tp) - } -} - -func initial(list interface{}) []interface{} { - l, err := mustInitial(list) - if err != nil { - panic(err) - } - - return l -} - -func mustInitial(list interface{}) ([]interface{}, error) { - tp := reflect.TypeOf(list).Kind() - switch tp { - case reflect.Slice, reflect.Array: - l2 := reflect.ValueOf(list) - - l := l2.Len() - if l == 0 { - return nil, nil - } - - nl := make([]interface{}, l-1) - for i := 0; i < l-1; i++ { - nl[i] = l2.Index(i).Interface() - } - - return nl, nil - default: - return nil, fmt.Errorf("Cannot find initial on type %s", tp) - } -} - -func sortAlpha(list interface{}) []string { - k := reflect.Indirect(reflect.ValueOf(list)).Kind() - switch k { - case reflect.Slice, reflect.Array: - a := strslice(list) - s := sort.StringSlice(a) - s.Sort() - return s - } - return []string{strval(list)} -} - -func reverse(v interface{}) []interface{} { - l, err := mustReverse(v) - if err != nil { - panic(err) - } - - return l -} - -func mustReverse(v interface{}) ([]interface{}, error) { - tp := reflect.TypeOf(v).Kind() - switch tp { - case reflect.Slice, reflect.Array: - l2 := reflect.ValueOf(v) - - l := l2.Len() - // We do not sort in place because the incoming array should not be altered. - nl := make([]interface{}, l) - for i := 0; i < l; i++ { - nl[l-i-1] = l2.Index(i).Interface() - } - - return nl, nil - default: - return nil, fmt.Errorf("Cannot find reverse on type %s", tp) - } -} - -func compact(list interface{}) []interface{} { - l, err := mustCompact(list) - if err != nil { - panic(err) - } - - return l -} - -func mustCompact(list interface{}) ([]interface{}, error) { - tp := reflect.TypeOf(list).Kind() - switch tp { - case reflect.Slice, reflect.Array: - l2 := reflect.ValueOf(list) - - l := l2.Len() - nl := []interface{}{} - var item interface{} - for i := 0; i < l; i++ { - item = l2.Index(i).Interface() - if !empty(item) { - nl = append(nl, item) - } - } - - return nl, nil - default: - return nil, fmt.Errorf("Cannot compact on type %s", tp) - } -} - -func uniq(list interface{}) []interface{} { - l, err := mustUniq(list) - if err != nil { - panic(err) - } - - return l -} - -func mustUniq(list interface{}) ([]interface{}, error) { - tp := reflect.TypeOf(list).Kind() - switch tp { - case reflect.Slice, reflect.Array: - l2 := reflect.ValueOf(list) - - l := l2.Len() - dest := []interface{}{} - var item interface{} - for i := 0; i < l; i++ { - item = l2.Index(i).Interface() - if !inList(dest, item) { - dest = append(dest, item) - } - } - - return dest, nil - default: - return nil, fmt.Errorf("Cannot find uniq on type %s", tp) - } -} - -func inList(haystack []interface{}, needle interface{}) bool { - for _, h := range haystack { - if reflect.DeepEqual(needle, h) { - return true - } - } - return false -} - -func without(list interface{}, omit ...interface{}) []interface{} { - l, err := mustWithout(list, omit...) - if err != nil { - panic(err) - } - - return l -} - -func mustWithout(list interface{}, omit ...interface{}) ([]interface{}, error) { - tp := reflect.TypeOf(list).Kind() - switch tp { - case reflect.Slice, reflect.Array: - l2 := reflect.ValueOf(list) - - l := l2.Len() - res := []interface{}{} - var item interface{} - for i := 0; i < l; i++ { - item = l2.Index(i).Interface() - if !inList(omit, item) { - res = append(res, item) - } - } - - return res, nil - default: - return nil, fmt.Errorf("Cannot find without on type %s", tp) - } -} - -func has(needle interface{}, haystack interface{}) bool { - l, err := mustHas(needle, haystack) - if err != nil { - panic(err) - } - - return l -} - -func mustHas(needle interface{}, haystack interface{}) (bool, error) { - if haystack == nil { - return false, nil - } - tp := reflect.TypeOf(haystack).Kind() - switch tp { - case reflect.Slice, reflect.Array: - l2 := reflect.ValueOf(haystack) - var item interface{} - l := l2.Len() - for i := 0; i < l; i++ { - item = l2.Index(i).Interface() - if reflect.DeepEqual(needle, item) { - return true, nil - } - } - - return false, nil - default: - return false, fmt.Errorf("Cannot find has on type %s", tp) - } -} - -// $list := [1, 2, 3, 4, 5] -// slice $list -> list[0:5] = list[:] -// slice $list 0 3 -> list[0:3] = list[:3] -// slice $list 3 5 -> list[3:5] -// slice $list 3 -> list[3:5] = list[3:] -func slice(list interface{}, indices ...interface{}) interface{} { - l, err := mustSlice(list, indices...) - if err != nil { - panic(err) - } - - return l -} - -func mustSlice(list interface{}, indices ...interface{}) (interface{}, error) { - tp := reflect.TypeOf(list).Kind() - switch tp { - case reflect.Slice, reflect.Array: - l2 := reflect.ValueOf(list) - - l := l2.Len() - if l == 0 { - return nil, nil - } - - var start, end int - if len(indices) > 0 { - start = toInt(indices[0]) - } - if len(indices) < 2 { - end = l - } else { - end = toInt(indices[1]) - } - - return l2.Slice(start, end).Interface(), nil - default: - return nil, fmt.Errorf("list should be type of slice or array but %s", tp) - } -} - -func concat(lists ...interface{}) interface{} { - var res []interface{} - for _, list := range lists { - tp := reflect.TypeOf(list).Kind() - switch tp { - case reflect.Slice, reflect.Array: - l2 := reflect.ValueOf(list) - for i := 0; i < l2.Len(); i++ { - res = append(res, l2.Index(i).Interface()) - } - default: - panic(fmt.Sprintf("Cannot concat type %s as list", tp)) - } - } - return res -} diff --git a/list_test.go b/list_test.go deleted file mode 100644 index fc28c16..0000000 --- a/list_test.go +++ /dev/null @@ -1,364 +0,0 @@ -package sprout - -import ( - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestTuple(t *testing.T) { - tpl := `{{$t := tuple 1 "a" "foo"}}{{index $t 2}}{{index $t 0 }}{{index $t 1}}` - if err := runt(tpl, "foo1a"); err != nil { - t.Error(err) - } -} - -func TestList(t *testing.T) { - tpl := `{{$t := list 1 "a" "foo"}}{{index $t 2}}{{index $t 0 }}{{index $t 1}}` - if err := runt(tpl, "foo1a"); err != nil { - t.Error(err) - } -} - -func TestPush(t *testing.T) { - // Named `append` in the function map - tests := map[string]string{ - `{{ $t := tuple 1 2 3 }}{{ append $t 4 | len }}`: "4", - `{{ $t := tuple 1 2 3 4 }}{{ append $t 5 | join "-" }}`: "1-2-3-4-5", - `{{ $t := regexSplit "/" "foo/bar/baz" -1 }}{{ append $t "qux" | join "-" }}`: "foo-bar-baz-qux", - } - for tpl, expect := range tests { - assert.NoError(t, runt(tpl, expect)) - } -} - -func TestMustPush(t *testing.T) { - // Named `append` in the function map - tests := map[string]string{ - `{{ $t := tuple 1 2 3 }}{{ mustAppend $t 4 | len }}`: "4", - `{{ $t := tuple 1 2 3 4 }}{{ mustAppend $t 5 | join "-" }}`: "1-2-3-4-5", - `{{ $t := regexSplit "/" "foo/bar/baz" -1 }}{{ mustPush $t "qux" | join "-" }}`: "foo-bar-baz-qux", - } - for tpl, expect := range tests { - assert.NoError(t, runt(tpl, expect)) - } -} - -func TestChunk(t *testing.T) { - tests := map[string]string{ - `{{ tuple 1 2 3 4 5 6 7 | chunk 3 | len }}`: "3", - `{{ tuple | chunk 3 | len }}`: "0", - `{{ range ( tuple 1 2 3 4 5 6 7 8 9 | chunk 3 ) }}{{. | join "-"}}|{{end}}`: "1-2-3|4-5-6|7-8-9|", - `{{ range ( tuple 1 2 3 4 5 6 7 8 | chunk 3 ) }}{{. | join "-"}}|{{end}}`: "1-2-3|4-5-6|7-8|", - `{{ range ( tuple 1 2 | chunk 3 ) }}{{. | join "-"}}|{{end}}`: "1-2|", - } - for tpl, expect := range tests { - assert.NoError(t, runt(tpl, expect)) - } -} - -func TestMustChunk(t *testing.T) { - tests := map[string]string{ - `{{ tuple 1 2 3 4 5 6 7 | mustChunk 3 | len }}`: "3", - `{{ tuple | mustChunk 3 | len }}`: "0", - `{{ range ( tuple 1 2 3 4 5 6 7 8 9 | mustChunk 3 ) }}{{. | join "-"}}|{{end}}`: "1-2-3|4-5-6|7-8-9|", - `{{ range ( tuple 1 2 3 4 5 6 7 8 | mustChunk 3 ) }}{{. | join "-"}}|{{end}}`: "1-2-3|4-5-6|7-8|", - `{{ range ( tuple 1 2 | mustChunk 3 ) }}{{. | join "-"}}|{{end}}`: "1-2|", - } - for tpl, expect := range tests { - assert.NoError(t, runt(tpl, expect)) - } -} - -func TestPrepend(t *testing.T) { - tests := map[string]string{ - `{{ $t := tuple 1 2 3 }}{{ prepend $t 0 | len }}`: "4", - `{{ $t := tuple 1 2 3 4 }}{{ prepend $t 0 | join "-" }}`: "0-1-2-3-4", - `{{ $t := regexSplit "/" "foo/bar/baz" -1 }}{{ prepend $t "qux" | join "-" }}`: "qux-foo-bar-baz", - } - for tpl, expect := range tests { - assert.NoError(t, runt(tpl, expect)) - } -} - -func TestMustPrepend(t *testing.T) { - tests := map[string]string{ - `{{ $t := tuple 1 2 3 }}{{ mustPrepend $t 0 | len }}`: "4", - `{{ $t := tuple 1 2 3 4 }}{{ mustPrepend $t 0 | join "-" }}`: "0-1-2-3-4", - `{{ $t := regexSplit "/" "foo/bar/baz" -1 }}{{ mustPrepend $t "qux" | join "-" }}`: "qux-foo-bar-baz", - } - for tpl, expect := range tests { - assert.NoError(t, runt(tpl, expect)) - } -} - -func TestFirst(t *testing.T) { - tests := map[string]string{ - `{{ list 1 2 3 | first }}`: "1", - `{{ list | first }}`: "", - `{{ regexSplit "/src/" "foo/src/bar" -1 | first }}`: "foo", - } - for tpl, expect := range tests { - assert.NoError(t, runt(tpl, expect)) - } -} - -func TestMustFirst(t *testing.T) { - tests := map[string]string{ - `{{ list 1 2 3 | mustFirst }}`: "1", - `{{ list | mustFirst }}`: "", - `{{ regexSplit "/src/" "foo/src/bar" -1 | mustFirst }}`: "foo", - } - for tpl, expect := range tests { - assert.NoError(t, runt(tpl, expect)) - } -} - -func TestLast(t *testing.T) { - tests := map[string]string{ - `{{ list 1 2 3 | last }}`: "3", - `{{ list | last }}`: "", - `{{ regexSplit "/src/" "foo/src/bar" -1 | last }}`: "bar", - } - for tpl, expect := range tests { - assert.NoError(t, runt(tpl, expect)) - } -} - -func TestMustLast(t *testing.T) { - tests := map[string]string{ - `{{ list 1 2 3 | mustLast }}`: "3", - `{{ list | mustLast }}`: "", - `{{ regexSplit "/src/" "foo/src/bar" -1 | mustLast }}`: "bar", - } - for tpl, expect := range tests { - assert.NoError(t, runt(tpl, expect)) - } -} - -func TestInitial(t *testing.T) { - tests := map[string]string{ - `{{ list 1 2 3 | initial | len }}`: "2", - `{{ list 1 2 3 | initial | last }}`: "2", - `{{ list 1 2 3 | initial | first }}`: "1", - `{{ list | initial }}`: "[]", - `{{ regexSplit "/" "foo/bar/baz" -1 | initial }}`: "[foo bar]", - } - for tpl, expect := range tests { - assert.NoError(t, runt(tpl, expect)) - } -} - -func TestMustInitial(t *testing.T) { - tests := map[string]string{ - `{{ list 1 2 3 | mustInitial | len }}`: "2", - `{{ list 1 2 3 | mustInitial | last }}`: "2", - `{{ list 1 2 3 | mustInitial | first }}`: "1", - `{{ list | mustInitial }}`: "[]", - `{{ regexSplit "/" "foo/bar/baz" -1 | mustInitial }}`: "[foo bar]", - } - for tpl, expect := range tests { - assert.NoError(t, runt(tpl, expect)) - } -} - -func TestRest(t *testing.T) { - tests := map[string]string{ - `{{ list 1 2 3 | rest | len }}`: "2", - `{{ list 1 2 3 | rest | last }}`: "3", - `{{ list 1 2 3 | rest | first }}`: "2", - `{{ list | rest }}`: "[]", - `{{ regexSplit "/" "foo/bar/baz" -1 | rest }}`: "[bar baz]", - } - for tpl, expect := range tests { - assert.NoError(t, runt(tpl, expect)) - } -} - -func TestMustRest(t *testing.T) { - tests := map[string]string{ - `{{ list 1 2 3 | mustRest | len }}`: "2", - `{{ list 1 2 3 | mustRest | last }}`: "3", - `{{ list 1 2 3 | mustRest | first }}`: "2", - `{{ list | mustRest }}`: "[]", - `{{ regexSplit "/" "foo/bar/baz" -1 | mustRest }}`: "[bar baz]", - } - for tpl, expect := range tests { - assert.NoError(t, runt(tpl, expect)) - } -} - -func TestReverse(t *testing.T) { - tests := map[string]string{ - `{{ list 1 2 3 | reverse | first }}`: "3", - `{{ list 1 2 3 | reverse | rest | first }}`: "2", - `{{ list 1 2 3 | reverse | last }}`: "1", - `{{ list 1 2 3 4 | reverse }}`: "[4 3 2 1]", - `{{ list 1 | reverse }}`: "[1]", - `{{ list | reverse }}`: "[]", - `{{ regexSplit "/" "foo/bar/baz" -1 | reverse }}`: "[baz bar foo]", - } - for tpl, expect := range tests { - assert.NoError(t, runt(tpl, expect)) - } -} - -func TestMustReverse(t *testing.T) { - tests := map[string]string{ - `{{ list 1 2 3 | mustReverse | first }}`: "3", - `{{ list 1 2 3 | mustReverse | rest | first }}`: "2", - `{{ list 1 2 3 | mustReverse | last }}`: "1", - `{{ list 1 2 3 4 | mustReverse }}`: "[4 3 2 1]", - `{{ list 1 | mustReverse }}`: "[1]", - `{{ list | mustReverse }}`: "[]", - `{{ regexSplit "/" "foo/bar/baz" -1 | mustReverse }}`: "[baz bar foo]", - } - for tpl, expect := range tests { - assert.NoError(t, runt(tpl, expect)) - } -} - -func TestCompact(t *testing.T) { - tests := map[string]string{ - `{{ list 1 0 "" "hello" | compact }}`: `[1 hello]`, - `{{ list "" "" | compact }}`: `[]`, - `{{ list | compact }}`: `[]`, - `{{ regexSplit "/" "foo//bar" -1 | compact }}`: "[foo bar]", - } - for tpl, expect := range tests { - assert.NoError(t, runt(tpl, expect)) - } -} - -func TestMustCompact(t *testing.T) { - tests := map[string]string{ - `{{ list 1 0 "" "hello" | mustCompact }}`: `[1 hello]`, - `{{ list "" "" | mustCompact }}`: `[]`, - `{{ list | mustCompact }}`: `[]`, - `{{ regexSplit "/" "foo//bar" -1 | mustCompact }}`: "[foo bar]", - } - for tpl, expect := range tests { - assert.NoError(t, runt(tpl, expect)) - } -} - -func TestUniq(t *testing.T) { - tests := map[string]string{ - `{{ list 1 2 3 4 | uniq }}`: `[1 2 3 4]`, - `{{ list "a" "b" "c" "d" | uniq }}`: `[a b c d]`, - `{{ list 1 1 1 1 2 2 2 2 | uniq }}`: `[1 2]`, - `{{ list "foo" 1 1 1 1 "foo" "foo" | uniq }}`: `[foo 1]`, - `{{ list | uniq }}`: `[]`, - `{{ regexSplit "/" "foo/foo/bar" -1 | uniq }}`: "[foo bar]", - } - for tpl, expect := range tests { - assert.NoError(t, runt(tpl, expect)) - } -} - -func TestMustUniq(t *testing.T) { - tests := map[string]string{ - `{{ list 1 2 3 4 | mustUniq }}`: `[1 2 3 4]`, - `{{ list "a" "b" "c" "d" | mustUniq }}`: `[a b c d]`, - `{{ list 1 1 1 1 2 2 2 2 | mustUniq }}`: `[1 2]`, - `{{ list "foo" 1 1 1 1 "foo" "foo" | mustUniq }}`: `[foo 1]`, - `{{ list | mustUniq }}`: `[]`, - `{{ regexSplit "/" "foo/foo/bar" -1 | mustUniq }}`: "[foo bar]", - } - for tpl, expect := range tests { - assert.NoError(t, runt(tpl, expect)) - } -} - -func TestWithout(t *testing.T) { - tests := map[string]string{ - `{{ without (list 1 2 3 4) 1 }}`: `[2 3 4]`, - `{{ without (list "a" "b" "c" "d") "a" }}`: `[b c d]`, - `{{ without (list 1 1 1 1 2) 1 }}`: `[2]`, - `{{ without (list) 1 }}`: `[]`, - `{{ without (list 1 2 3) }}`: `[1 2 3]`, - `{{ without list }}`: `[]`, - `{{ without (regexSplit "/" "foo/bar/baz" -1 ) "foo" }}`: "[bar baz]", - } - for tpl, expect := range tests { - assert.NoError(t, runt(tpl, expect)) - } -} - -func TestMustWithout(t *testing.T) { - tests := map[string]string{ - `{{ mustWithout (list 1 2 3 4) 1 }}`: `[2 3 4]`, - `{{ mustWithout (list "a" "b" "c" "d") "a" }}`: `[b c d]`, - `{{ mustWithout (list 1 1 1 1 2) 1 }}`: `[2]`, - `{{ mustWithout (list) 1 }}`: `[]`, - `{{ mustWithout (list 1 2 3) }}`: `[1 2 3]`, - `{{ mustWithout list }}`: `[]`, - `{{ mustWithout (regexSplit "/" "foo/bar/baz" -1 ) "foo" }}`: "[bar baz]", - } - for tpl, expect := range tests { - assert.NoError(t, runt(tpl, expect)) - } -} - -func TestHas(t *testing.T) { - tests := map[string]string{ - `{{ list 1 2 3 | has 1 }}`: `true`, - `{{ list 1 2 3 | has 4 }}`: `false`, - `{{ regexSplit "/" "foo/bar/baz" -1 | has "bar" }}`: `true`, - `{{ has "bar" nil }}`: `false`, - } - for tpl, expect := range tests { - assert.NoError(t, runt(tpl, expect)) - } -} - -func TestMustHas(t *testing.T) { - tests := map[string]string{ - `{{ list 1 2 3 | mustHas 1 }}`: `true`, - `{{ list 1 2 3 | mustHas 4 }}`: `false`, - `{{ regexSplit "/" "foo/bar/baz" -1 | mustHas "bar" }}`: `true`, - `{{ mustHas "bar" nil }}`: `false`, - } - for tpl, expect := range tests { - assert.NoError(t, runt(tpl, expect)) - } -} - -func TestSlice(t *testing.T) { - tests := map[string]string{ - `{{ slice (list 1 2 3) }}`: "[1 2 3]", - `{{ slice (list 1 2 3) 0 1 }}`: "[1]", - `{{ slice (list 1 2 3) 1 3 }}`: "[2 3]", - `{{ slice (list 1 2 3) 1 }}`: "[2 3]", - `{{ slice (regexSplit "/" "foo/bar/baz" -1) 1 2 }}`: "[bar]", - } - for tpl, expect := range tests { - assert.NoError(t, runt(tpl, expect)) - } -} - -func TestMustSlice(t *testing.T) { - tests := map[string]string{ - `{{ mustSlice (list 1 2 3) }}`: "[1 2 3]", - `{{ mustSlice (list 1 2 3) 0 1 }}`: "[1]", - `{{ mustSlice (list 1 2 3) 1 3 }}`: "[2 3]", - `{{ mustSlice (list 1 2 3) 1 }}`: "[2 3]", - `{{ mustSlice (regexSplit "/" "foo/bar/baz" -1) 1 2 }}`: "[bar]", - } - for tpl, expect := range tests { - assert.NoError(t, runt(tpl, expect)) - } -} - -func TestConcat(t *testing.T) { - tests := map[string]string{ - `{{ concat (list 1 2 3) }}`: "[1 2 3]", - `{{ concat (list 1 2 3) (list 4 5) }}`: "[1 2 3 4 5]", - `{{ concat (list 1 2 3) (list 4 5) (list) }}`: "[1 2 3 4 5]", - `{{ concat (list 1 2 3) (list 4 5) (list nil) }}`: "[1 2 3 4 5 ]", - `{{ concat (list 1 2 3) (list 4 5) (list ( list "foo" ) ) }}`: "[1 2 3 4 5 [foo]]", - } - for tpl, expect := range tests { - assert.NoError(t, runt(tpl, expect)) - } -} diff --git a/maps_functions.go b/maps_functions.go new file mode 100644 index 0000000..fb6613a --- /dev/null +++ b/maps_functions.go @@ -0,0 +1,436 @@ +package sprout + +import ( + "fmt" + + "dario.cat/mergo" +) + +// Dict creates a dictionary from a list of keys and values. +// +// Parameters: +// +// values ...any - alternating keys and values. +// +// Returns: +// +// map[string]any - the created dictionary. +// +// Example: +// +// {{ dict "key1", "value1", "key2", "value2" }} // Output: {"key1": "value1", "key2": "value2"} +func (fh *FunctionHandler) Dict(values ...any) map[string]any { + // Ensure even number of values for key-value pairs + if len(values)%2 != 0 { + values = append(values, "") + } + + // Pre-allocate the map based on half the number of total elements, + // since we expect every two elements to form a key-value pair. + dict := make(map[string]any, len(values)/2) + + for i := 0; i < len(values); i += 2 { + dict[fh.ToString(values[i])] = values[i+1] + } + + return dict +} + +// Get retrieves the value associated with the specified key from the dictionary. +// +// Parameters: +// +// dict map[string]any - the dictionary. +// key string - the key to look up. +// +// Returns: +// +// any - the value associated with the key, or an empty string if the key does not exist. +// +// Example: +// +// {{ get {"key": "value"}, "key" }} // Output: "value" +func (fh *FunctionHandler) Get(dict map[string]any, key string) any { + if value, ok := dict[key]; ok { + return value + } + return "" +} + +// Set adds or updates a key with a specified value in the dictionary. +// +// Parameters: +// +// dict map[string]any - the dictionary. +// key string - the key to set. +// value any - the value to associate with the key. +// +// Returns: +// +// map[string]any - the updated dictionary. +// +// Example: +// +// {{ set {"key": "oldValue"}, "key", "newValue" }} // Output: {"key": "newValue"} +func (fh *FunctionHandler) Set(dict map[string]any, key string, value any) map[string]any { + dict[key] = value + return dict +} + +// Unset removes a key from the dictionary. +// +// Parameters: +// +// dict map[string]any - the dictionary. +// key string - the key to remove. +// +// Returns: +// +// map[string]any - the dictionary after removing the key. +// +// Example: +// +// {{ {"key": "value"}, "key" | unset }} // Output: {} +func (fh *FunctionHandler) Unset(dict map[string]any, key string) map[string]any { + delete(dict, key) + return dict +} + +// Keys retrieves all keys from one or more dictionaries. +// +// Parameters: +// +// dicts ...map[string]any - one or more dictionaries. +// +// Returns: +// +// []string - a list of all keys from the dictionaries. +// +// Example: +// +// {{ keys {"key1": "value1", "key2": "value2"} }} // Output: ["key1", "key2"] +func (fh *FunctionHandler) Keys(dicts ...map[string]any) []string { + var keyCount int + for i := range dicts { + keyCount += len(dicts[i]) + } + + keys := make([]string, 0, keyCount) + + for _, dict := range dicts { + for key := range dict { + keys = append(keys, key) + } + } + + return keys +} + +// Values retrieves all values from a dictionary. +// +// Parameters: +// +// dict map[string]any - the dictionary. +// +// Returns: +// +// []any - a list of all values from the dictionary. +// +// Example: +// +// {{ values {"key1": "value1", "key2": "value2"} }} // Output: ["value1", "value2"] +func (fh *FunctionHandler) Values(dict map[string]any) []any { + var values = make([]any, 0, len(dict)) + for _, value := range dict { + values = append(values, value) + } + + return values +} + +// Pluck extracts values associated with a specified key from a list of dictionaries. +// +// Parameters: +// +// key string - the key to pluck values for. +// dicts ...map[string]any - one or more dictionaries. +// +// Returns: +// +// []any - a list of values associated with the key from each dictionary. +// +// Example: +// +// {{ [{"key": "value1"}, {"key": "value2"}] | pluck "key" }} // Output: ["value1", "value2"] +func (fh *FunctionHandler) Pluck(key string, dicts ...map[string]any) []any { + result := []any{} + for _, dict := range dicts { + if val, ok := dict[key]; ok { + result = append(result, val) + } + } + return result +} + +// Pick creates a new dictionary containing only the specified keys from the original dictionary. +// +// Parameters: +// +// dict map[string]any - the source dictionary. +// keys ...string - the keys to include in the new dictionary. +// +// Returns: +// +// map[string]any - a dictionary containing only the picked keys and their values. +// +// Example: +// +// {{ pick {"key1": "value1", "key2": "value2", "key3": "value3"}, "key1", "key3" }} // Output: {"key1": "value1", "key3": "value3"} +func (fh *FunctionHandler) Pick(dict map[string]any, keys ...string) map[string]any { + result := map[string]any{} + for _, k := range keys { + if v, ok := dict[k]; ok { + result[k] = v + } + } + return result +} + +// Omit creates a new dictionary by excluding specified keys from the original dictionary. +// +// Parameters: +// +// dict map[string]any - the source dictionary. +// keys ...string - the keys to exclude from the new dictionary. +// +// Returns: +// +// map[string]any - a dictionary without the omitted keys. +// +// Example: +// +// {{ omit {"key1": "value1", "key2": "value2", "key3": "value3"}, "key2" }} // Output: {"key1": "value1", "key3": "value3"} +func (fh *FunctionHandler) Omit(dict map[string]any, keys ...string) map[string]any { + result := map[string]any{} + + omit := make(map[string]bool, len(keys)) + for _, key := range keys { + omit[key] = true + } + + for key, value := range dict { + if _, ok := omit[key]; !ok { + result[key] = value + } + } + return result +} + +// Dig navigates through a nested dictionary structure using a sequence of keys +// and returns the value found at the specified path. +// +// Parameters: +// +// args ...any - a sequence of keys followed by a dictionary as the last argument. +// +// Returns: +// +// any - the value found at the nested key path or nil if any key in the path is not found. +// error - an error if there are fewer than three arguments, if the last argument is not a dictionary, or if any key is not a string. +// +// Example: +// +// {{ dig "user", "profile", "name", {"user": {"profile": {"name": "John Doe"}}} }} // Output: "John Doe", nil +func (fh *FunctionHandler) Dig(args ...any) (any, error) { + if len(args) < 2 { + return nil, fmt.Errorf("dig requires at least two arguments: a sequence of keys and a dictionary") + } + + dict, ok := args[len(args)-1].(map[string]any) + if !ok { + return nil, fmt.Errorf("last argument must be a map[string]any") + } + + keys, err := fh.parseKeys(args[:len(args)-1]) + if err != nil { + return nil, err + } + + return fh.digIntoDict(dict, keys) +} + +// parseKeys converts a slice of any type to a slice of strings, ensuring all elements are strings. +// +// Parameters: +// +// keySet []any - a slice containing potential keys. +// +// Returns: +// +// []string - a slice of strings if all elements in the original slice are strings. +// error - an error if any element of the original slice is not a string. +// +// Example: +// +// keys, _ := fh.parseKeys([]any{"key1", "key2"}) +// fmt.Println(keys) // Output: ["key1", "key2"] +// +// keys, err := fh.parseKeys([]any{"key1", 2}) +// fmt.Println(err) // Output: all keys must be strings, got int at position 1 +func (fh *FunctionHandler) parseKeys(keySet []any) ([]string, error) { + keys := make([]string, len(keySet)) + for i, element := range keySet { + key, ok := element.(string) + if !ok { + return nil, fmt.Errorf("all keys must be strings, got %T at position %d", element, i) + } + keys[i] = key + } + return keys, nil +} + +// digIntoDict navigates through a nested dictionary using a sequence of keys and returns the value found. +// +// Parameters: +// +// dict map[string]any - the starting dictionary. +// keys []string - a slice of keys to navigate through the dictionary. +// +// Returns: +// +// any - the value found at the last key in the sequence. +// error - an error if a key is not found or if the value at a key is not a dictionary when expected. +func (fh *FunctionHandler) digIntoDict(dict map[string]any, keys []string) (any, error) { + current := dict + for i, key := range keys { + value, exists := current[key] + if !exists { + return nil, nil + } + if i == len(keys)-1 { + return value, nil + } + + nextDict, ok := value.(map[string]any) + if !ok { + return nil, fmt.Errorf("value at key %q is not a nested dictionary but %T", key, value) + } + current = nextDict + } + return nil, fmt.Errorf("unexpected termination of key traversal") +} + +// HasKey checks if the specified key exists in the dictionary. +// +// Parameters: +// +// dict map[string]any - the dictionary to check. +// key string - the key to look for. +// +// Returns: +// +// bool - true if the key exists, otherwise false. +// +// Example: +// +// {{ hasKey {"key": "value"}, "key" }} // Output: true +func (fh *FunctionHandler) HasKey(dict map[string]any, key string) bool { + _, ok := dict[key] + return ok +} + +// Merge combines multiple source maps into a destination map without +// overwriting existing keys. +// +// Parameters: +// +// dest map[string]any - the destination map. +// srcs ...map[string]any - one or more source maps to merge into the destination. +// +// Returns: +// +// any - the merged destination map. +// +// Example: +// +// {{ merge {}, {"a": 1}, {"b": 2} }} // Output: {"a": 1, "b": 2} +func (fh *FunctionHandler) Merge(dest map[string]any, srcs ...map[string]any) any { + result, _ := fh.MustMerge(dest, srcs...) + return result +} + +// MergeOverwrite combines multiple source maps into a destination map, +// overwriting existing keys. +// +// Parameters: +// +// dest map[string]any - the destination map. +// srcs ...map[string]any - one or more source maps to merge into the destination, with overwriting. +// +// Returns: +// +// any - the merged destination map with overwritten values where applicable. +// +// Example: +// +// {{ mergeOverwrite {}, {"a": 1}, {"a": 2, "b": 3} }} // Output: {"a": 2, "b": 3} +func (fh *FunctionHandler) MergeOverwrite(dest map[string]any, srcs ...map[string]any) any { + result, _ := fh.MustMergeOverwrite(dest, srcs...) + return result +} + +// MustMerge merges multiple source maps into a destination map without +// overwriting existing keys in the destination. +// If an error occurs during merging, it returns nil and the error. +// +// Parameters: +// +// dest map[string]any - the destination map to which all source map key-values are added. +// srcs ...map[string]any - one or more source maps whose key-values are added to the destination. +// +// Returns: +// +// any - the merged destination map. +// error - error if the merge fails. +// +// Example: +// +// {{ mustMerge {}, {"a": 1, "b": 2}, {"b": 3, "c": 4} }} // Output: {"a": 1, "b": 2, "c": 4}, nil +func (fh *FunctionHandler) MustMerge(dest map[string]any, srcs ...map[string]any) (any, error) { + for _, src := range srcs { + if err := mergo.Merge(&dest, src); err != nil { + // This error is not expected to occur, as we ensure types are correct in + // the function signature. If it does, it is a bug in the function implementation. + return nil, err + } + } + return dest, nil +} + +// MustMergeOverwrite merges multiple source maps into a destination map, +// overwriting existing keys in the destination. +// If an error occurs during merging, it returns nil and the error. +// +// Parameters: +// +// dest map[string]any - the destination map to which all source map key-values are added. +// srcs ...map[string]any - one or more source maps whose key-values are added to the destination, potentially overwriting existing keys. +// +// Returns: +// +// any - the merged destination map with overwritten values where applicable. +// error - error if the merge fails. +// +// Example: +// +// {{ mustMergeOverwrite {}, {"a": 1, "b": 2}, {"b": 3, "c": 4} }} // Output: {"a": 1, "b": 3, "c": 4}, nil +func (fh *FunctionHandler) MustMergeOverwrite(dest map[string]any, srcs ...map[string]any) (any, error) { + for _, src := range srcs { + if err := mergo.Merge(&dest, src, mergo.WithOverride); err != nil { + // This error is not expected to occur, as we ensure types are correct in + // the function signature. If it does, it is a bug in the function implementation. + return nil, err + } + } + return dest, nil +} diff --git a/maps_functions_test.go b/maps_functions_test.go new file mode 100644 index 0000000..e027d03 --- /dev/null +++ b/maps_functions_test.go @@ -0,0 +1,171 @@ +package sprout + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestDict(t *testing.T) { + var tests = testCases{ + {"TestEmpty", `{{dict}}`, "map[]", nil}, + {"TestWithEvenKeyPair", `{{dict "a" 1 "b" 2}}`, "map[a:1 b:2]", nil}, + {"TestWithOddKeyPair", `{{dict "a" 1 "b" 2 "c" 3 "d"}}`, "map[a:1 b:2 c:3 d:]", nil}, + {"TestWithANilKey", `{{dict "a" 1 "b" 2 "c" 3 .Nil 4}}`, "map[:4 a:1 b:2 c:3]", map[string]any{"Nil": nil}}, + } + + runTestCases(t, tests) +} + +func TestGet(t *testing.T) { + var tests = testCases{ + {"TestEmpty", `{{get . "a"}}`, "", nil}, + {"TestWithKey", `{{get . "a"}}`, "1", map[string]any{"a": 1}}, + {"TestWithNestedKeyNotFound", `{{get . "b"}}`, "", map[string]any{"a": 1}}, + } + + runTestCases(t, tests) +} + +func TestSet(t *testing.T) { + var tests = testCases{ + {"TestWithKey", `{{$d := set . "a" 2}}{{$d}}`, "map[a:2]", map[string]any{"a": 1}}, + {"TestWithNewKey", `{{$d := set . "b" 3}}{{$d}}`, "map[a:1 b:3]", map[string]any{"a": 1}}, + {"TestWithNilValue", `{{$d := set .V "a" .Nil}}{{$d}}`, "map[a:]", map[string]any{"V": map[string]any{"a": 1}, "Nil": nil}}, + } + + runTestCases(t, tests) +} + +func TestUnset(t *testing.T) { + var tests = testCases{ + {"TestWithKey", `{{$d := unset . "a"}}{{$d}}`, "map[]", map[string]any{"a": 1}}, + {"TestWithNestedKeyNotFound", `{{$d := unset . "b"}}{{$d}}`, "map[a:1]", map[string]any{"a": 1}}, + } + + runTestCases(t, tests) +} + +func TestKeys(t *testing.T) { + var tests = testCases{ + {"TestEmpty", `{{keys .}}`, "[]", nil}, + {"TestWithKeys", `{{keys . | sortAlpha}}`, `[a b]`, map[string]any{"a": 1, "b": 2}}, + {"TestWithMultiplesMaps", `{{keys .A .B | sortAlpha}}`, `[a b c d]`, map[string]any{"A": map[string]any{"a": 1, "b": 2}, "B": map[string]any{"c": 3, "d": 4}}}, + } + + runTestCases(t, tests) +} + +func TestValues(t *testing.T) { + var tests = testCases{ + {"TestEmpty", `{{values .}}`, "[]", nil}, + {"TestWithValues", `{{values . | sortAlpha}}`, "[1 foo]", map[string]any{"a": 1, "b": "foo"}}, + } + + runTestCases(t, tests) +} + +func TestPluck(t *testing.T) { + var tests = testCases{ + {"TestEmpty", `{{pluck "a" .}}`, "[]", nil}, + {"TestWithOneMap", `{{. | pluck "a"}}`, "[1]", map[string]any{"a": 1, "b": 2}}, + {"TestWithTwoMaps", `{{pluck "a" .A .B }}`, "[1 3]", map[string]any{"A": map[string]any{"a": 1, "b": 2}, "B": map[string]any{"a": 3, "b": 4}}}, + } + + runTestCases(t, tests) +} + +func TestPick(t *testing.T) { + var tests = testCases{ + {"TestEmpty", `{{pick . "a"}}`, "map[]", nil}, + {"TestWithOneValue", `{{pick . "a"}}`, "map[a:1]", map[string]any{"a": 1, "b": 2}}, + {"TestWithTwoValues", `{{pick . "a" "b"}}`, "map[a:1 b:2]", map[string]any{"a": 1, "b": 2}}, + {"TestWithNestedKeyNotFound", `{{pick . "nope"}}`, "map[]", map[string]any{"a": 1}}, + } + + runTestCases(t, tests) +} + +func TestOmit(t *testing.T) { + var tests = testCases{ + {"TestEmpty", `{{omit . "a"}}`, "map[]", nil}, + {"TestWithOneValue", `{{omit . "a"}}`, "map[b:2]", map[string]any{"a": 1, "b": 2}}, + {"TestWithTwoValues", `{{omit . "a" "b"}}`, "map[]", map[string]any{"a": 1, "b": 2}}, + {"TestWithNestedKeyNotFound", `{{omit . "nope"}}`, "map[a:1]", map[string]any{"a": 1}}, + } + + runTestCases(t, tests) +} + +func TestDig(t *testing.T) { + var tests = mustTestCases{ + {testCase{"TestEmpty", `{{dig "a" .}}`, "", nil}, ""}, + {testCase{"TestWithOneValue", `{{dig "a" .}}`, "1", map[string]any{"a": 1, "b": 2}}, ""}, + {testCase{"TestWithNestedKey", `{{dig "a" "b" .}}`, "2", map[string]any{"a": map[string]any{"b": 2}}}, ""}, + {testCase{"TestWithNestedKeyNotFound", `{{dig "b" .}}`, "", map[string]any{"a": 1}}, ""}, + {testCase{"TestWithNotEnoughArgs", `{{dig "a"}}`, "", nil}, "dig requires at least two arguments"}, + {testCase{"TestWithInvalidKey", `{{dig 1 .}}`, "", nil}, "all keys must be strings, got int at position 0"}, + {testCase{"TestWithInvalidMap", `{{dig "a" 1}}`, "", nil}, "last argument must be a map[string]any"}, + {testCase{"TestToAccessNotMapNestedKey", `{{dig "a" "b" .}}`, "", map[string]any{"a": 1}}, "value at key \"a\" is not a nested dictionary but int"}, + } + + runMustTestCases(t, tests) +} + +func TestDigIntoDictWithNoKeys(t *testing.T) { + _, err := NewFunctionHandler().digIntoDict(map[string]any{}, []string{}) + assert.ErrorContains(t, err, "unexpected termination of key traversal") +} + +func TestHasKey(t *testing.T) { + var tests = testCases{ + {"TestEmpty", `{{hasKey . "a"}}`, "false", nil}, + {"TestWithKey", `{{hasKey . "a"}}`, "true", map[string]any{"a": 1}}, + {"TestWithNestedKeyNotFound", `{{hasKey . "b"}}`, "false", map[string]any{"a": 1}}, + } + + runTestCases(t, tests) +} + +func TestMerge(t *testing.T) { + var tests = testCases{ + {"TestEmpty", `{{merge .}}`, "map[]", nil}, + {"TestWithOneMap", `{{merge .}}`, "map[a:1 b:2]", map[string]any{"a": 1, "b": 2}}, + {"TestWithTwoMaps", `{{merge .A .B}}`, "map[a:1 b:2 c:3 d:4]", map[string]any{"A": map[string]any{"a": 1, "b": 2}, "B": map[string]any{"c": 3, "d": 4}}}, + } + + runTestCases(t, tests) +} + +func TestMergeOverwrite(t *testing.T) { + var tests = testCases{ + {"TestEmpty", `{{mergeOverwrite .}}`, "map[]", nil}, + {"TestWithOneMap", `{{mergeOverwrite .}}`, "map[a:1 b:2]", map[string]any{"a": 1, "b": 2}}, + {"TestWithTwoMaps", `{{mergeOverwrite .A .B}}`, "map[a:1 b:2 c:3 d:4]", map[string]any{"A": map[string]any{"a": 1, "b": 2}, "B": map[string]any{"c": 3, "d": 4}}}, + {"TestWithOverwrite", `{{mergeOverwrite .A .B}}`, "map[a:3 b:2 d:4]", map[string]any{"A": map[string]any{"a": 1, "b": 2}, "B": map[string]any{"a": 3, "d": 4}}}, + } + + runTestCases(t, tests) +} + +func TestMustMerge(t *testing.T) { + var dest map[string]any + var tests = mustTestCases{ + {testCase{"TestWithNotEnoughArgs", `{{mustMerge .}}`, "map[a:1]", map[string]any{"a": 1}}, ""}, + {testCase{"TestWithDestNonInitialized", `{{mustMerge .A .B}}`, "map[b:2]", map[string]any{"A": dest, "B": map[string]any{"b": 2}}}, ""}, + {testCase{"TestWithDestNotMap", `{{mustMerge .A .B}}`, "", map[string]any{"A": 1, "B": map[string]any{"b": 2}}}, "wrong type for value"}, + } + + runMustTestCases(t, tests) +} + +func TestMustMergeOverwrite(t *testing.T) { + var dest map[string]any + var tests = mustTestCases{ + {testCase{"TestWithNotEnoughArgs", `{{mustMergeOverwrite .}}`, "map[a:1]", map[string]any{"a": 1}}, ""}, + {testCase{"TestWithDestNonInitialized", `{{mustMergeOverwrite .A .B}}`, "map[b:2]", map[string]any{"A": dest, "B": map[string]any{"b": 2}}}, ""}, + {testCase{"TestWithDestNotMap", `{{mustMergeOverwrite .A .B}}`, "", map[string]any{"A": 1, "B": map[string]any{"b": 2}}}, "wrong type for value"}, + } + + runMustTestCases(t, tests) +} diff --git a/misc_functions.go b/misc_functions.go index e993315..83f73fa 100644 --- a/misc_functions.go +++ b/misc_functions.go @@ -1,6 +1,476 @@ package sprout +import ( + "errors" + "fmt" + "reflect" + "strings" + + "github.com/google/uuid" + "github.com/mitchellh/copystructure" +) + // Hello returns a greeting string. -func (p *FunctionHandler) Hello() string { - return "Hello, World!" +// It simply returns the string "Hello!" to be used as a test function. +func (fh *FunctionHandler) Hello() string { + return "Hello!" +} + +// Default returns the first non-empty value from the given arguments or a +// default value if the argument list is empty or the first element is empty. +// It accepts a default value `defaultValue` of any type and a variadic slice +// `given` of any type. If `given` is not provided or the first element in +// `given` is empty, it returns `defaultValue`. +// Otherwise, it returns the first element of `given`. +// If you want to catch the first non-empty value from a list of values, use +// the `Coalesce` function instead. +// +// Parameters: +// +// defaultValue any - the default value to return if no valid argument is +// provided or if the first argument is empty. +// given ...any - a variadic slice of any type to check the first +// element of it for emptiness. +// +// Returns: +// +// any - the first element of `given`, or `defaultValue` if `given` is empty +// or all values are empty. +// +// Example: +// +// {{ nil | default "default" }} // Output: "default" +// {{ "" | default "default" }} // Output: "default" +// {{ "first" | default "default" }} // Output: "first" +// {{ "first" | default "default" "second" }} // Output: "second" +func (fh *FunctionHandler) Default(defaultValue any, given ...any) any { + if fh.Empty(given) || fh.Empty(given[0]) { + return defaultValue + } + return given[0] +} + +// Empty evaluates the emptiness of the provided value 'given'. It returns +// true if 'given' is considered empty based on its type. This method is +// essential for determining the presence or absence of meaningful value +// across various data types. +// +// Parameters: +// +// given any - the value to be evaluated for emptiness. +// +// Returns: +// +// bool - true if 'given' is empty, false otherwise. +// +// This method utilizes the reflect package to inspect the type and value of +// 'given'. Depending on the type, it checks for nil pointers, zero-length +// collections (arrays, slices, maps, and strings), zero values of numeric +// types (integers, floats, complex numbers, unsigned ints), and false for +// booleans. +// +// Example: +// +// {{ nil | empty }} // Output: true +// {{ "" | empty }} // Output: true +// {{ 0 | empty }} // Output: true +// {{ false | empty }} // Output: true +// {{ struct{}{} | empty }} // Output: false +func (fh *FunctionHandler) Empty(given any) bool { + g := reflect.ValueOf(given) + if !g.IsValid() { + return true + } + + // Basically adapted from text/template.isTrue + switch g.Kind() { + case reflect.Array, reflect.Slice, reflect.Map, reflect.String: + return g.Len() == 0 + case reflect.Bool: + return !g.Bool() + case reflect.Complex64, reflect.Complex128: + return g.Complex() == 0 + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + return g.Int() == 0 + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr: + return g.Uint() == 0 + case reflect.Float32, reflect.Float64: + return g.Float() == 0 + case reflect.Struct: + return false + default: + return g.IsNil() + } +} + +// All checks if all values in the provided variadic slice are non-empty. +// It returns true only if none of the values are considered empty by the Empty method. +// +// Parameters: +// +// values ...any - a variadic parameter list of values to be checked. +// +// Returns: +// +// bool - true if all values are non-empty, false otherwise. +// +// Example: +// +// {{ 1, "hello", true | all }} // Output: true +// {{ 1, "", true | all }} // Output: false +func (fh *FunctionHandler) All(values ...any) bool { + for _, val := range values { + if fh.Empty(val) { + return false + } + } + return true +} + +// Any checks if any of the provided values are non-empty. +// It returns true if at least one value is non-empty. +// +// Parameters: +// values ...any - a variadic parameter list of values to be checked. +// +// Returns: +// bool - true if any value is non-empty, false if all are empty. +// +// Example: +// {{ "", 0, false | any }} // Output: false +// {{ "", 0, "text" | any }} // Output: true + +func (fh *FunctionHandler) Any(values ...any) bool { + for _, val := range values { + if !fh.Empty(val) { + return true + } + } + return false +} + +// Coalesce returns the first non-empty value from the given list. +// If all values are empty, it returns nil. +// +// Parameters: +// values ...any - a variadic parameter list of values from which the first +// non-empty value should be selected. +// +// Returns: +// any - the first non-empty value, or nil if all values are empty. +// +// Example: +// {{ nil, "", "first", "second" | coalesce }} // Output: "first" + +func (fh *FunctionHandler) Coalesce(values ...any) any { + for _, val := range values { + if !fh.Empty(val) { + return val + } + } + return nil +} + +// Ternary mimics the ternary conditional operator found in many programming languages. +// It returns 'trueValue' if 'condition' is true, otherwise 'falseValue'. +// +// Parameters: +// +// trueValue any - the value to return if 'condition' is true. +// falseValue any - the value to return if 'condition' is false. +// condition bool - the condition to evaluate. +// +// Returns: +// +// any - the result based on the evaluated condition. +// +// Example: +// +// {{ "yes", "no", true | ternary }} // Output: "yes" +// {{ "yes", "no", false | ternary }} // Output: "no" +func (fh *FunctionHandler) Ternary(trueValue any, falseValue any, condition bool) any { + if condition { + return trueValue + } + + return falseValue +} + +// Uuidv4 generates a new random UUID (Universally Unique Identifier) version 4. +// This function does not take parameters and returns a string representation +// of a UUID. +// +// Returns: +// +// string - a new UUID string. +// +// Example: +// +// {{ uuidv4 }} // Output: "3f0c463e-53f5-4f05-a2ec-3c083aa8f937" +func (fh *FunctionHandler) Uuidv4() string { + return uuid.New().String() +} + +// Cat concatenates a series of values into a single string. Each value is +// converted to its string representation and separated by a space. Nil +// values are skipped, and no trailing spaces are added. +// +// Parameters: +// +// values ...any - a variadic parameter list of values to be concatenated. +// +// Returns: +// +// string - a single string composed of all non-nil input values separated +// by spaces. +// +// Example: +// +// {{ "Hello", nil, 123, true | cat }} // Output: "Hello 123 true" +func (fh *FunctionHandler) Cat(values ...any) string { + var builder strings.Builder + for i, item := range values { + if item == nil { + continue // Skip nil elements + } + if i > 0 { + builder.WriteRune(' ') // Add space between elements + } + // Append the string representation of the item + builder.WriteString(fmt.Sprint(item)) + } + // Return the concatenated string without trailing spaces + return builder.String() +} + +// Until generates a slice of integers from 0 up to but not including 'count'. +// If 'count' is negative, it produces a descending slice from 0 down to 'count', +// inclusive, with a step of -1. The function leverages UntilStep to specify +// the range and step dynamically. +// +// Parameters: +// count int - the endpoint (exclusive) of the range to generate. +// +// Returns: +// []int - a slice of integers from 0 to 'count' with the appropriate step +// depending on whether 'count' is positive or negative. +// +// Example: +// {{ 5 | until }} // Output: [0 1 2 3 4] +// {{ -3 | until }} // Output: [0 -1 -2] + +func (fh *FunctionHandler) Until(count int) []int { + step := 1 + if count < 0 { + step = -1 + } + return fh.UntilStep(0, count, step) +} + +// UntilStep generates a slice of integers from 'start' to 'stop' (exclusive), +// incrementing by 'step'. If 'step' is positive, the sequence increases; if +// negative, it decreases. The function returns an empty slice if the sequence +// does not make logical sense (e.g., positive step when start is greater than +// stop or vice versa). +// +// Parameters: +// +// start int - the starting point of the sequence. +// stop int - the endpoint (exclusive) of the sequence. +// step int - the increment between elements in the sequence. +// +// Returns: +// +// []int - a dynamically generated slice of integers based on the input +// parameters, or an empty slice if the parameters are inconsistent +// with the desired range and step. +// +// Example: +// +// {{ 0, 10, 2 | untilStep }} // Output: [0 2 4 6 8] +// {{ 10, 0, -2 | untilStep }} // Output: [10 8 6 4 2] +func (fh *FunctionHandler) UntilStep(start, stop, step int) []int { + v := []int{} + + if stop < start { + if step >= 0 { + return v + } + for i := start; i > stop; i += step { + v = append(v, i) + } + return v + } + + if step <= 0 { + return v + } + for i := start; i < stop; i += step { + v = append(v, i) + } + return v +} + +// TypeIs compares the type of 'src' to a target type string 'target'. +// It returns true if the type of 'src' matches the 'target'. +// +// Parameters: +// +// target string - the string representation of the type to check against. +// src any - the variable whose type is being checked. +// +// Returns: +// +// bool - true if 'src' is of type 'target', false otherwise. +// +// Example: +// +// {{ "int", 42 | typeIs }} // Output: true +func (fh *FunctionHandler) TypeIs(target string, src any) bool { + return target == fh.TypeOf(src) +} + +// TypeIsLike compares the type of 'src' to a target type string 'target', +// including a wildcard '*' prefix option. It returns true if 'src' matches +// 'target' or '*target'. Useful for checking if a variable is of a specific +// type or a pointer to that type. +// +// Parameters: +// +// target string - the string representation of the type or its wildcard version. +// src any - the variable whose type is being checked. +// +// Returns: +// +// bool - true if the type of 'src' matches 'target' or '*'+target, false otherwise. +// +// Example: +// +// {{ "*int", 42 | typeIsLike }} // Output: true +func (fh *FunctionHandler) TypeIsLike(target string, src any) bool { + t := fh.TypeOf(src) + return target == t || "*"+target == t +} + +// TypeOf returns the type of 'src' as a string. +// +// Parameters: +// +// src any - the variable whose type is being determined. +// +// Returns: +// +// string - the string representation of 'src's type. +// +// Example: +// +// {{ 42 | typeOf }} // Output: "int" +func (fh *FunctionHandler) TypeOf(src any) string { + return fmt.Sprintf("%T", src) +} + +// KindIs compares the kind of 'src' to a target kind string 'target'. +// It returns true if the kind of 'src' matches the 'target'. +// +// Parameters: +// +// target string - the string representation of the kind to check against. +// src any - the variable whose kind is being checked. +// +// Returns: +// +// bool - true if 'src's kind is 'target', false otherwise. +// +// Example: +// +// {{ "int", 42 | kindIs }} // Output: true +func (fh *FunctionHandler) KindIs(target string, src any) bool { + return target == fh.KindOf(src) +} + +// KindOf returns the kind of 'src' as a string. +// +// Parameters: +// +// src any - the variable whose kind is being determined. +// +// Returns: +// +// string - the string representation of 'src's kind. +// +// Example: +// +// {{ 42 | kindOf }} // Output: "int" +func (fh *FunctionHandler) KindOf(src any) string { + return reflect.ValueOf(src).Kind().String() +} + +// DeepEqual determines if two variables, 'x' and 'y', are deeply equal. +// It uses reflect.DeepEqual to evaluate equality. +// +// Parameters: +// +// x, y any - the variables to be compared. +// +// Returns: +// +// bool - true if 'x' and 'y' are deeply equal, false otherwise. +// +// Example: +// +// {{ {"a":1}, {"a":1} | deepEqual }} // Output: true +func (fh *FunctionHandler) DeepEqual(x, y any) bool { + return reflect.DeepEqual(y, x) +} + +// DeepCopy performs a deep copy of 'element' and panics if copying fails. +// It relies on MustDeepCopy to perform the copy and handle errors internally. +// +// Parameters: +// +// element any - the element to be deeply copied. +// +// Returns: +// +// any - a deep copy of 'element'. +// +// Example: +// +// {{ {"name":"John"} | deepCopy }} // Output: {"name":"John"} +func (fh *FunctionHandler) DeepCopy(element any) any { + c, err := fh.MustDeepCopy(element) + if err != nil { + return nil + } + + return c +} + +func (fh *FunctionHandler) MustDeepCopy(element any) (any, error) { + if element == nil { + return nil, errors.New("element cannot be nil") + } + return copystructure.Copy(element) +} + +// ! DEPRECATED: This should be removed in the next major version. +// +// Fail creates an error with a specified message and returns a nil pointer +// alongside the created error. This function is typically used to indicate +// failure conditions in functions that return a pointer and an error. +// +// Parameters: +// +// message string - the error message to be associated with the returned error. +// +// Returns: +// +// *uint - always returns nil, indicating no value is associated with the failure. +// error - the error object containing the provided message. +// +// Example: +// +// {{ "Operation failed" | fail }} // Output: nil, error with "Operation failed" +func (fh *FunctionHandler) Fail(message string) (*uint, error) { + return nil, errors.New(message) } diff --git a/misc_functions_test.go b/misc_functions_test.go index 9c3993c..36dc54f 100644 --- a/misc_functions_test.go +++ b/misc_functions_test.go @@ -1,34 +1,287 @@ package sprout import ( - "bytes" "testing" - "text/template" "github.com/stretchr/testify/assert" ) -// TestHelper is a helper function that performs common setup tasks for tests. -func runTemplate(t *testing.T, handler *FunctionHandler, tmplString string) (string, error) { - tmpl, err := template.New("test").Funcs(FuncMap(WithFunctionHandler(handler))).Parse(tmplString) - if err != nil { - assert.FailNow(t, "Failed to parse template", err) - return "", err - } - - var buf bytes.Buffer - err = tmpl.ExecuteTemplate(&buf, "test", nil) - return buf.String(), err -} - // TestHello asserts the Hello method returns the expected greeting. func TestHello(t *testing.T) { handler := NewFunctionHandler() - expected := "Hello, World!" + expected := "Hello!" assert.Equal(t, expected, handler.Hello()) - tmplResponse, err := runTemplate(t, handler, `{{hello}}`) - assert.Nil(t, err) + tmplResponse, err := runTemplate(t, handler, `{{hello}}`, nil) + assert.NoError(t, err) assert.Equal(t, expected, tmplResponse) } + +func TestDefault(t *testing.T) { + var tests = testCases{ + {"TestDefaultEmptyInput", `{{default "default" ""}}`, "default", nil}, + {"TestDefaultGivenInput", `{{default "default" "given"}}`, "given", nil}, + {"TestDefaultIntInput", `{{default "default" 42}}`, "42", nil}, + {"TestDefaultFloatInput", `{{default "default" 2.42}}`, "2.42", nil}, + {"TestDefaultTrueInput", `{{default "default" true}}`, "true", nil}, + {"TestDefaultFalseInput", `{{default "default" false}}`, "default", nil}, + {"TestDefaultNilInput", `{{default "default" nil}}`, "default", nil}, + {"TestDefaultNothingInput", `{{default "default" .Nothing}}`, "default", nil}, + {"TestDefaultMultipleNothingInput", `{{default "default" .Nothing}}`, "default", nil}, + {"TestDefaultMultipleArgument", `{{"first" | default "default" "second"}}`, "second", nil}, + } + + runTestCases(t, tests) +} + +func TestEmpty(t *testing.T) { + var tests = testCases{ + {"TestEmptyEmptyInput", `{{if empty ""}}1{{else}}0{{end}}`, "1", nil}, + {"TestEmptyGivenInput", `{{if empty "given"}}1{{else}}0{{end}}`, "0", nil}, + {"TestEmptyIntInput", `{{if empty 42}}1{{else}}0{{end}}`, "0", nil}, + {"TestEmptyUintInput", `{{if empty .i}}1{{else}}0{{end}}`, "0", map[string]any{"i": uint(42)}}, + {"TestEmptyComplexInput", `{{if empty .c}}1{{else}}0{{end}}`, "0", map[string]any{"c": complex(42, 42)}}, + {"TestEmptyFloatInput", `{{if empty 2.42}}1{{else}}0{{end}}`, "0", nil}, + {"TestEmptyTrueInput", `{{if empty true}}1{{else}}0{{end}}`, "0", nil}, + {"TestEmptyFalseInput", `{{if empty false}}1{{else}}0{{end}}`, "1", nil}, + {"TestEmptyStructInput", `{{if empty .s}}1{{else}}0{{end}}`, "0", map[string]any{"s": struct{}{}}}, + {"TestEmptyNilInput", `{{if empty nil}}1{{else}}0{{end}}`, "1", nil}, + {"TestEmptyNothingInput", `{{if empty .Nothing}}1{{else}}0{{end}}`, "1", nil}, + {"TestEmptyNestedInput", `{{if empty .top.NoSuchThing}}1{{else}}0{{end}}`, "1", map[string]any{"top": map[string]interface{}{}}}, + {"TestEmptyNestedNoDataInput", `{{if empty .bottom.NoSuchThing}}1{{else}}0{{end}}`, "1", nil}, + {"TestEmptyNimPointerInput", `{{if empty .nilPtr}}1{{else}}0{{end}}`, "1", map[string]any{"nilPtr": (*int)(nil)}}, + } + + runTestCases(t, tests) +} + +func TestAll(t *testing.T) { + var tests = testCases{ + {"TestAllEmptyInput", `{{if all ""}}1{{else}}0{{end}}`, "0", nil}, + {"TestAllGivenInput", `{{if all "given"}}1{{else}}0{{end}}`, "1", nil}, + {"TestAllIntInput", `{{if all 42 0 1}}1{{else}}0{{end}}`, "0", nil}, + {"TestAllVariableInput1", `{{ $two := 2 }}{{if all "" 0 nil $two }}1{{else}}0{{end}}`, "0", nil}, + {"TestAllVariableInput2", `{{ $two := 2 }}{{if all "" $two 0 0 0 }}1{{else}}0{{end}}`, "0", nil}, + {"TestAllVariableInput3", `{{ $two := 2 }}{{if all "" $two 3 4 5 }}1{{else}}0{{end}}`, "0", nil}, + {"TestAllNoInput", `{{if all }}1{{else}}0{{end}}`, "1", nil}, + } + + runTestCases(t, tests) +} + +func TestAny(t *testing.T) { + var tests = testCases{ + {"TestAnyEmptyInput", `{{if any ""}}1{{else}}0{{end}}`, "0", nil}, + {"TestAnyGivenInput", `{{if any "given"}}1{{else}}0{{end}}`, "1", nil}, + {"TestAnyIntInput", `{{if any 42 0 1}}1{{else}}0{{end}}`, "1", nil}, + {"TestAnyVariableInput1", `{{ $two := 2 }}{{if any "" 0 nil $two }}1{{else}}0{{end}}`, "1", nil}, + {"TestAnyVariableInput2", `{{ $two := 2 }}{{if any "" $two 3 4 5 }}1{{else}}0{{end}}`, "1", nil}, + {"TestAnyVariableInput3", `{{ $zero := 0 }}{{if any "" $zero 0 0 0 }}1{{else}}0{{end}}`, "0", nil}, + {"TestAnyNoInput", `{{if any }}1{{else}}0{{end}}`, "0", nil}, + } + + runTestCases(t, tests) +} + +func TestCoalesce(t *testing.T) { + var tests = testCases{ + {"TestCoalesceEmptyInput", `{{coalesce ""}}`, "", nil}, + {"TestCoalesceGivenInput", `{{coalesce "given"}}`, "given", nil}, + {"TestCoalesceIntInput", `{{ coalesce "" 0 nil 42 }}`, "42", nil}, + {"TestCoalesceVariableInput1", `{{ $two := 2 }}{{ coalesce "" 0 nil $two }}`, "2", nil}, + {"TestCoalesceVariableInput2", `{{ $two := 2 }}{{ coalesce "" $two 0 0 0 }}`, "2", nil}, + {"TestCoalesceVariableInput3", `{{ $two := 2 }}{{ coalesce "" $two 3 4 5 }}`, "2", nil}, + {"TestCoalesceNoInput", `{{ coalesce }}`, "", nil}, + } + + runTestCases(t, tests) +} + +func TestTernary(t *testing.T) { + var tests = testCases{ + {"", `{{true | ternary "foo" "bar"}}`, "foo", nil}, + {"", `{{ternary "foo" "bar" true}}`, "foo", nil}, + {"", `{{false | ternary "foo" "bar"}}`, "bar", nil}, + {"", `{{ternary "foo" "bar" false}}`, "bar", nil}, + } + + runTestCases(t, tests) +} + +func TestUuidv4(t *testing.T) { + handler := NewFunctionHandler() + + tmplResponse, err := runTemplate(t, handler, `{{uuidv4}}`, nil) + assert.NoError(t, err) + assert.Regexp(t, `^[\da-f]{8}-[\da-f]{4}-4[\da-f]{3}-[\da-f]{4}-[\da-f]{12}$`, tmplResponse) +} + +func TestCat(t *testing.T) { + var tests = testCases{ + {"TestCatEmptyInput", `{{cat ""}}`, "", nil}, + {"TestCatGivenInput", `{{cat "given"}}`, "given", nil}, + {"TestCatIntInput", `{{cat 42}}`, "42", nil}, + {"TestCatFloatInput", `{{cat 2.42}}`, "2.42", nil}, + {"TestCatTrueInput", `{{cat true}}`, "true", nil}, + {"TestCatFalseInput", `{{cat false}}`, "false", nil}, + {"TestCatNilInput", `{{cat nil}}`, "", nil}, + {"TestCatNothingInput", `{{cat .Nothing}}`, "", nil}, + {"TestCatMultipleInput", `{{cat "first" "second"}}`, "first second", nil}, + {"TestCatMultipleArgument", `{{"first" | cat "second"}}`, "second first", nil}, + {"TestCatVariableInput", `{{$b := "b"}}{{"c" | cat "a" $b}}`, "a b c", nil}, + {"TestCatDataInput", `{{.text | cat "a" "b"}}`, "a b cd", map[string]any{"text": "cd"}}, + } + + runTestCases(t, tests) +} + +func TestUntil(t *testing.T) { + var tests = testCases{ + {"", `{{range $i, $e := until 5}}({{$i}}{{$e}}){{end}}`, "(00)(11)(22)(33)(44)", nil}, + {"", `{{range $i, $e := until -5}}({{$i}}{{$e}}){{end}}`, "(00)(1-1)(2-2)(3-3)(4-4)", nil}, + } + + runTestCases(t, tests) +} + +func TestUntilStep(t *testing.T) { + var tests = testCases{ + {"", `{{range $i, $e := untilStep 0 5 1}}({{$i}}{{$e}}){{end}}`, "(00)(11)(22)(33)(44)", nil}, + {"", `{{range $i, $e := untilStep 3 6 1}}({{$i}}{{$e}}){{end}}`, "(03)(14)(25)", nil}, + {"", `{{range $i, $e := untilStep 0 -10 -2}}({{$i}}{{$e}}){{end}}`, "(00)(1-2)(2-4)(3-6)(4-8)", nil}, + {"", `{{range $i, $e := untilStep 3 0 1}}({{$i}}{{$e}}){{end}}`, "", nil}, + {"", `{{range $i, $e := untilStep 3 99 0}}({{$i}}{{$e}}){{end}}`, "", nil}, + {"", `{{range $i, $e := untilStep 3 99 -1}}({{$i}}{{$e}}){{end}}`, "", nil}, + {"", `{{range $i, $e := untilStep 3 0 0}}({{$i}}{{$e}}){{end}}`, "", nil}, + } + + runTestCases(t, tests) +} + +func TestTypeIs(t *testing.T) { + type testStruct struct{} + + var tests = testCases{ + {"TestTypeIsInt", `{{typeIs "int" 42}}`, "true", nil}, + {"TestTypeIsString", `{{42 | typeIs "string"}}`, "false", nil}, + {"TestTypeIsVariable", `{{$var := 42}}{{typeIs "string" $var}}`, "false", nil}, + {"TestTypeIsStruct", `{{.var | typeIs "*sprout.testStruct"}}`, "true", map[string]any{"var": &testStruct{}}}, + } + + runTestCases(t, tests) +} + +func TestTypeIsLike(t *testing.T) { + type testStruct struct{} + + var tests = testCases{ + {"TestTypeIsLikeInt", `{{typeIsLike "int" 42}}`, "true", nil}, + {"TestTypeIsLikeString", `{{42 | typeIsLike "string"}}`, "false", nil}, + {"TestTypeIsLikeVariable", `{{$var := 42}}{{typeIsLike "string" $var}}`, "false", nil}, + {"TestTypeIsLikeStruct", `{{.var | typeIsLike "*sprout.testStruct"}}`, "true", map[string]any{"var": &testStruct{}}}, + {"TestTypeIsLikeStructWithoutPointerMark", `{{.var | typeIsLike "sprout.testStruct"}}`, "true", map[string]any{"var": &testStruct{}}}, + } + + runTestCases(t, tests) +} + +func TestTypeOf(t *testing.T) { + type testStruct struct{} + + var tests = testCases{ + {"TestTypeOfInt", `{{typeOf 42}}`, "int", nil}, + {"TestTypeOfString", `{{typeOf "42"}}`, "string", nil}, + {"TestTypeOfVariable", `{{$var := 42}}{{typeOf $var}}`, "int", nil}, + {"TestTypeOfStruct", `{{typeOf .var}}`, "*sprout.testStruct", map[string]any{"var": &testStruct{}}}, + } + + runTestCases(t, tests) +} + +func TestKindIs(t *testing.T) { + type testStruct struct{} + + var tests = testCases{ + {"TestKindIsInt", `{{kindIs "int" 42}}`, "true", nil}, + {"TestKindIsString", `{{42 | kindIs "string"}}`, "false", nil}, + {"TestKindIsVariable", `{{$var := 42}}{{kindIs "string" $var}}`, "false", nil}, + {"TestKindIsStruct", `{{.var | kindIs "ptr"}}`, "true", map[string]any{"var": &testStruct{}}}, + } + + runTestCases(t, tests) +} + +func TestKindOf(t *testing.T) { + type testStruct struct{} + + var tests = testCases{ + {"TestKindOfInt", `{{kindOf 42}}`, "int", nil}, + {"TestKindOfString", `{{kindOf "42"}}`, "string", nil}, + {"TestKindOfSlice", `{{kindOf .var}}`, "slice", map[string]any{"var": []int{}}}, + {"TestKindOfVariable", `{{$var := 42}}{{kindOf $var}}`, "int", nil}, + {"TestKindOfStruct", `{{kindOf .var}}`, "ptr", map[string]any{"var": &testStruct{}}}, + {"TestKindOfStructWithoutPointerMark", `{{kindOf .var}}`, "struct", map[string]any{"var": testStruct{}}}, + } + + runTestCases(t, tests) +} + +func TestDeepEqual(t *testing.T) { + var tests = testCases{ + {"TestDeepEqualInt", `{{deepEqual 42 42}}`, "true", nil}, + {"TestDeepEqualString", `{{deepEqual "42" "42"}}`, "true", nil}, + {"TestDeepEqualSlice", `{{deepEqual .a .b}}`, "true", map[string]any{"a": []int{1, 2, 3}, "b": []int{1, 2, 3}}}, + {"TestDeepEqualMap", `{{deepEqual .a .b}}`, "true", map[string]any{"a": map[string]int{"a": 1, "b": 2}, "b": map[string]int{"a": 1, "b": 2}}}, + {"TestDeepEqualStruct", `{{deepEqual .a .b}}`, "true", map[string]any{"a": struct{ A int }{A: 1}, "b": struct{ A int }{A: 1}}}, + {"TestDeepEqualVariable", `{{$a := 42}}{{$b := 42}}{{deepEqual $a $b}}`, "true", nil}, + {"TestDeepEqualDifferent", `{{deepEqual 42 32}}`, "false", nil}, + {"TestDeepEqualDifferentType", `{{deepEqual 42 "42"}}`, "false", nil}, + } + + runTestCases(t, tests) +} + +func TestDeepCopy(t *testing.T) { + handler := NewFunctionHandler() + + type testStruct struct { + A int + } + + var tests = testCases{ + {"TestDeepCopyInt", `{{$a := 42}}{{$b := deepCopy $a}}{{$b}}`, "42", nil}, + {"TestDeepCopyString", `{{$a := "42"}}{{$b := deepCopy $a}}{{$b}}`, "42", nil}, + {"TestDeepCopySlice", `{{$a := .a}}{{$b := deepCopy $a}}{{$b}}`, "[1 2 3]", map[string]any{"a": []int{1, 2, 3}}}, + {"TestDeepCopyMap", `{{$a := .a}}{{$b := deepCopy $a}}{{$b}}`, `map[a:1 b:2]`, map[string]any{"a": map[string]int{"a": 1, "b": 2}}}, + {"TestDeepCopyStruct", `{{$a := .a}}{{$b := deepCopy $a}}{{$b}}`, "{1}", map[string]any{"a": testStruct{A: 1}}}, + {"TestDeepCopyVariable", `{{$a := 42}}{{$b := deepCopy $a}}{{$b}}`, "42", nil}, + {"TestDeepCopyDifferent", `{{$a := 42}}{{$b := deepCopy "42"}}{{$b}}`, "42", nil}, + {"TestDeepCopyDifferentType", `{{$a := 42}}{{$b := deepCopy 42.0}}{{$b}}`, "42", nil}, + {"TestDeepCopyNil", `{{$b := deepCopy .a}}`, "", map[string]any{"a": nil}}, + {"", `{{- $d := dict "a" 1 "b" 2 | deepCopy }}{{ values $d | sortAlpha | join "," }}`, "1,2", nil}, + {"", `{{- $d := dict "a" 1 "b" 2 | deepCopy }}{{ keys $d | sortAlpha | join "," }}`, "a,b", nil}, + {"", `{{- $one := dict "foo" (dict "bar" "baz") "qux" true -}}{{ deepCopy $one }}`, "map[foo:map[bar:baz] qux:true]", nil}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + tmplResponse, err := runTemplate(t, handler, test.input, test.data) + assert.NoError(t, err) + assert.Equal(t, test.expected, tmplResponse) + + if test.data != nil { + assert.NotEqual(t, test.data["a"], test.expected) + } + }) + } +} + +func TestFail(t *testing.T) { + handler := NewFunctionHandler() + + tmplResponse, err := runTemplate(t, handler, `{{fail "This is an error"}}`, nil) + assert.Error(t, err) + assert.Contains(t, err.Error(), "This is an error") + assert.Empty(t, tmplResponse) +} diff --git a/network.go b/network.go deleted file mode 100644 index 3423686..0000000 --- a/network.go +++ /dev/null @@ -1,12 +0,0 @@ -package sprout - -import ( - "math/rand" - "net" -) - -func getHostByName(name string) string { - addrs, _ := net.LookupHost(name) - //TODO: add error handing when release v3 comes out - return addrs[rand.Intn(len(addrs))] -} diff --git a/network_test.go b/network_test.go deleted file mode 100644 index 232814c..0000000 --- a/network_test.go +++ /dev/null @@ -1,18 +0,0 @@ -package sprout - -import ( - "net" - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestGetHostByName(t *testing.T) { - tpl := `{{"www.google.com" | getHostByName}}` - - resolvedIP, _ := runRaw(tpl, nil) - - ip := net.ParseIP(resolvedIP) - assert.NotNil(t, ip) - assert.NotEmpty(t, ip) -} diff --git a/numeric.go b/numeric.go deleted file mode 100644 index d8c0243..0000000 --- a/numeric.go +++ /dev/null @@ -1,186 +0,0 @@ -package sprout - -import ( - "fmt" - "math" - "strconv" - "strings" - - "github.com/shopspring/decimal" - "github.com/spf13/cast" -) - -// toFloat64 converts 64-bit floats -func toFloat64(v interface{}) float64 { - return cast.ToFloat64(v) -} - -func toInt(v interface{}) int { - return cast.ToInt(v) -} - -// toInt64 converts integer types to 64-bit integers -func toInt64(v interface{}) int64 { - return cast.ToInt64(v) -} - -func max(a interface{}, i ...interface{}) int64 { - aa := toInt64(a) - for _, b := range i { - bb := toInt64(b) - if bb > aa { - aa = bb - } - } - return aa -} - -func maxf(a interface{}, i ...interface{}) float64 { - aa := toFloat64(a) - for _, b := range i { - bb := toFloat64(b) - aa = math.Max(aa, bb) - } - return aa -} - -func min(a interface{}, i ...interface{}) int64 { - aa := toInt64(a) - for _, b := range i { - bb := toInt64(b) - if bb < aa { - aa = bb - } - } - return aa -} - -func minf(a interface{}, i ...interface{}) float64 { - aa := toFloat64(a) - for _, b := range i { - bb := toFloat64(b) - aa = math.Min(aa, bb) - } - return aa -} - -func until(count int) []int { - step := 1 - if count < 0 { - step = -1 - } - return untilStep(0, count, step) -} - -func untilStep(start, stop, step int) []int { - v := []int{} - - if stop < start { - if step >= 0 { - return v - } - for i := start; i > stop; i += step { - v = append(v, i) - } - return v - } - - if step <= 0 { - return v - } - for i := start; i < stop; i += step { - v = append(v, i) - } - return v -} - -func floor(a interface{}) float64 { - aa := toFloat64(a) - return math.Floor(aa) -} - -func ceil(a interface{}) float64 { - aa := toFloat64(a) - return math.Ceil(aa) -} - -func round(a interface{}, p int, rOpt ...float64) float64 { - roundOn := .5 - if len(rOpt) > 0 { - roundOn = rOpt[0] - } - val := toFloat64(a) - places := toFloat64(p) - - var round float64 - pow := math.Pow(10, places) - digit := pow * val - _, div := math.Modf(digit) - if div >= roundOn { - round = math.Ceil(digit) - } else { - round = math.Floor(digit) - } - return round / pow -} - -// converts unix octal to decimal -func toDecimal(v interface{}) int64 { - result, err := strconv.ParseInt(fmt.Sprint(v), 8, 64) - if err != nil { - return 0 - } - return result -} - -func seq(params ...int) string { - increment := 1 - switch len(params) { - case 0: - return "" - case 1: - start := 1 - end := params[0] - if end < start { - increment = -1 - } - return intArrayToString(untilStep(start, end+increment, increment), " ") - case 3: - start := params[0] - end := params[2] - step := params[1] - if end < start { - increment = -1 - if step > 0 { - return "" - } - } - return intArrayToString(untilStep(start, end+increment, step), " ") - case 2: - start := params[0] - end := params[1] - step := 1 - if end < start { - step = -1 - } - return intArrayToString(untilStep(start, end+step, step), " ") - default: - return "" - } -} - -func intArrayToString(slice []int, delimeter string) string { - return strings.Trim(strings.Join(strings.Fields(fmt.Sprint(slice)), delimeter), "[]") -} - -// performs a float and subsequent decimal.Decimal conversion on inputs, -// and iterates through a and b executing the mathmetical operation f -func execDecimalOp(a interface{}, b []interface{}, f func(d1, d2 decimal.Decimal) decimal.Decimal) float64 { - prt := decimal.NewFromFloat(toFloat64(a)) - for _, x := range b { - dx := decimal.NewFromFloat(toFloat64(x)) - prt = f(prt, dx) - } - rslt, _ := prt.Float64() - return rslt -} diff --git a/numeric_functions.go b/numeric_functions.go new file mode 100644 index 0000000..b06ad8e --- /dev/null +++ b/numeric_functions.go @@ -0,0 +1,364 @@ +package sprout + +import ( + "math" + "reflect" + + "github.com/spf13/cast" +) + +// numericOperation defines a function type that performs a binary operation on +// two float64 values. It is used to abstract arithmetic operations like +// addition, subtraction, multiplication, or division so that these can be +// applied in a generic function that processes lists of numbers. +// +// Example Usage: +// +// add := func(a, b float64) float64 { return a + b } +// result := operateNumeric([]any{1.0, 2.0}, add, 0.0) +// fmt.Println(result) // Output: 3.0 +type numericOperation func(float64, float64) float64 + +// operateNumeric applies a numericOperation to a slice of any type, converting +// to and from float64. The result is converted back to the type of the first +// element in the slice. +// +// Parameters: +// +// values []any - Slice of numeric values. +// op numericOperation - Function to apply. +// initial float64 - Starting value for the operation. +// +// Returns: +// +// any - Result of the operation, converted to the type of the first slice element. +// +// Example: +// +// add := func(a, b float64) float64 { return a + b } +// result := operateNumeric([]any{1.5, 2.5}, add, 0) +// fmt.Println(result) // Output: 4.0 (type float64 if first element is float64) +func operateNumeric(values []any, op numericOperation, initial any) any { + if len(values) == 0 { + return initial + } + + result := cast.ToFloat64(values[0]) + for _, value := range values[1:] { + result = op(result, cast.ToFloat64(value)) + } + + initialType := reflect.TypeOf(values[0]) + return reflect.ValueOf(result).Convert(initialType).Interface() +} + +// Floor returns the largest integer less than or equal to the provided number. +// +// Parameters: +// +// num any - the number to floor, expected to be numeric or convertible to float64. +// +// Returns: +// +// float64 - the floored value. +// +// Example: +// +// {{ 3.7 | floor }} // Output: 3 +func (fh *FunctionHandler) Floor(num any) float64 { + return math.Floor(cast.ToFloat64(num)) +} + +// Ceil returns the smallest integer greater than or equal to the provided number. +// +// Parameters: +// +// num any - the number to ceil, expected to be numeric or convertible to float64. +// +// Returns: +// +// float64 - the ceiled value. +// +// Example: +// +// {{ 3.1 | ceil }} // Output: 4 +func (fh *FunctionHandler) Ceil(num any) float64 { + return math.Ceil(cast.ToFloat64(num)) +} + +// Round rounds a number to a specified precision and rounding threshold. +// +// Parameters: +// +// num any - the number to round. +// poww int - the power of ten to which to round. +// roundOpts ...float64 - optional threshold for rounding up (default is 0.5). +// +// Returns: +// +// float64 - the rounded number. +// +// Example: +// +// {{ 3.746, 2, 0.5 | round }} // Output: 3.75 +func (fh *FunctionHandler) Round(num any, poww int, roundOpts ...float64) float64 { + roundOn := 0.5 + if len(roundOpts) > 0 { + roundOn = roundOpts[0] + } + + pow := math.Pow(10, float64(poww)) + digit := cast.ToFloat64(num) * pow + _, div := math.Modf(digit) + if div >= roundOn { + return math.Ceil(digit) / pow + } + return math.Floor(digit) / pow +} + +// Add performs addition on a slice of values. +// +// Parameters: +// +// values ...any - numbers to add. +// +// Returns: +// +// any - the sum of the values, converted to the type of the first value. +// +// Example: +// +// {{ 5, 3.5, 2 | add }} // Output: 10.5 +func (fh *FunctionHandler) Add(values ...any) any { + return operateNumeric(values, func(a, b float64) float64 { return a + b }, 0.0) +} + +// Add performs a unary addition operation on a single value. +// +// Parameters: +// +// x any - the number to add. +// +// Returns: +// +// any - the sum of the value and 1, converted to the type of the input. +// +// Example: +// +// {{ 5 | add1 }} // Output: 6 +func (fh *FunctionHandler) Add1(x any) any { + one := reflect.ValueOf(1).Convert(reflect.TypeOf(x)).Interface() + return fh.Add(x, one) +} + +// Sub performs subtraction on a slice of values, starting with the first value. +// +// Parameters: +// +// values ...any - numbers to subtract from the first number. +// +// Returns: +// +// any - the result of the subtraction, converted to the type of the first value. +// +// Example: +// +// {{ 10, 3, 2 | sub }} // Output: 5 +func (fh *FunctionHandler) Sub(values ...any) any { + return operateNumeric(values, func(a, b float64) float64 { return a - b }, 0.0) +} + +// MulInt multiplies a sequence of values and returns the result as int64. +// +// Parameters: +// +// values ...any - numbers to multiply, expected to be numeric or convertible to float64. +// +// Returns: +// +// int64 - the product of the values. +// +// Example: +// +// {{ 5, 3, 2 | mulInt }} // Output: 30 +func (fh *FunctionHandler) MulInt(values ...any) int64 { + return cast.ToInt64( + operateNumeric(values, func(a, b float64) float64 { return a * b }, 1), + ) +} + +// Mulf multiplies a sequence of values and returns the result as float64. +// +// Parameters: +// +// values ...any - numbers to multiply. +// +// Returns: +// +// any - the product of the values, converted to the type of the first value. +// +// Example: +// +// {{ 5.5, 2.0, 2.0 | mulf }} // Output: 22.0 +func (fh *FunctionHandler) Mulf(values ...any) any { + return operateNumeric(values, func(a, b float64) float64 { return a * b }, 1.0) +} + +// DivInt divides a sequence of values and returns the result as int64. +// +// Parameters: +// +// values ...any - numbers to divide. +// +// Returns: +// +// int64 - the quotient of the division. +// +// Example: +// +// {{ 30, 3, 2 | divInt }} // Output: 5 +func (fh *FunctionHandler) DivInt(values ...any) int64 { + return fh.ToInt64(fh.Divf(values...)) +} + +// Divf divides a sequence of values, starting with the first value, and returns the result. +// +// Parameters: +// +// values ...any - numbers to divide. +// +// Returns: +// +// any - the quotient of the division, converted to the type of the first value. +// +// Example: +// +// {{ 30.0, 3.0, 2.0 | divf }} // Output: 5.0 +func (fh *FunctionHandler) Divf(values ...any) any { + //FIXME: Special manipulation to force float operation + // This is a workaround to ensure that the result is a float to allow + // BACKWARD COMPATIBILITY with previous versions of Sprig. + if len(values) > 0 { + if _, ok := values[0].(float64); !ok { + values[0] = cast.ToFloat64(values[0]) + } + } + + return operateNumeric(values, func(a, b float64) float64 { return a / b }, 0.0) +} + +// Mod returns the remainder of division of 'x' by 'y'. +// +// Parameters: +// +// x any, y any - numbers to divide, expected to be numeric or convertible to float64. +// +// Returns: +// +// any - the remainder, converted to the type of 'x'. +// +// Example: +// +// {{ 10, 4 | mod }} // Output: 2 +func (fh *FunctionHandler) Mod(x, y any) any { + result := math.Mod(cast.ToFloat64(x), cast.ToFloat64(y)) + + // Convert the result to the same type as the input + return reflect.ValueOf(result).Convert(reflect.TypeOf(x)).Interface() +} + +// Min returns the minimum value among the provided arguments. +// +// Parameters: +// +// a any - the first number to compare. +// i ...any - additional numbers to compare. +// +// Returns: +// +// int64 - the smallest number among the inputs. +// +// Example: +// +// {{ 5, 3, 8, 2 | min }} // Output: 2 +func (fh *FunctionHandler) Min(a any, i ...any) int64 { + aa := fh.ToInt64(a) + for _, b := range i { + bb := fh.ToInt64(b) + if bb < aa { + aa = bb + } + } + return aa +} + +// Minf returns the minimum value among the provided floating-point arguments. +// +// Parameters: +// +// a any - the first number to compare, expected to be numeric or convertible to float64. +// i ...any - additional numbers to compare. +// +// Returns: +// +// float64 - the smallest number among the inputs. +// +// Example: +// +// {{ 5.2, 3.8, 8.1, 2.6 | minf }} // Output: 2.6 +func (fh *FunctionHandler) Minf(a any, i ...any) float64 { + aa := cast.ToFloat64(a) + for _, b := range i { + bb := cast.ToFloat64(b) + aa = math.Min(aa, bb) + } + return aa +} + +// Max returns the maximum value among the provided arguments. +// +// Parameters: +// +// a any - the first number to compare. +// i ...any - additional numbers to compare. +// +// Returns: +// +// int64 - the largest number among the inputs. +// +// Example: +// +// {{ 5, 3, 8, 2 | max }} // Output: 8 +func (fh *FunctionHandler) Max(a any, i ...any) int64 { + aa := fh.ToInt64(a) + for _, b := range i { + bb := fh.ToInt64(b) + if bb > aa { + aa = bb + } + } + return aa +} + +// Maxf returns the maximum value among the provided floating-point arguments. +// +// Parameters: +// +// a any - the first number to compare, expected to be numeric or convertible to float64. +// i ...any - additional numbers to compare. +// +// Returns: +// +// float64 - the largest number among the inputs. +// +// Example: +// +// {{ 5.2, 3.8, 8.1, 2.6 | maxf }} // Output: 8.1 +func (fh *FunctionHandler) Maxf(a any, i ...any) float64 { + aa := cast.ToFloat64(a) + for _, b := range i { + bb := cast.ToFloat64(b) + aa = math.Max(aa, bb) + } + return aa +} diff --git a/numeric_functions_test.go b/numeric_functions_test.go new file mode 100644 index 0000000..e0dcfd0 --- /dev/null +++ b/numeric_functions_test.go @@ -0,0 +1,222 @@ +package sprout + +import ( + "math" + "strconv" + "testing" +) + +func TestFloor(t *testing.T) { + var tests = testCases{ + {"", `{{ floor 1.5 }}`, "1", nil}, + {"", `{{ floor 1 }}`, "1", nil}, + {"", `{{ floor -1.5 }}`, "-2", nil}, + {"", `{{ floor -1 }}`, "-1", nil}, + {"", `{{ floor 0 }}`, "0", nil}, + {"", `{{ floor 123 }}`, "123", nil}, + {"", `{{ floor "123" }}`, "123", nil}, + {"", `{{ floor 123.9999 }}`, "123", nil}, + {"", `{{ floor 123.0001 }}`, "123", nil}, + } + + runTestCases(t, tests) +} + +func TestCeil(t *testing.T) { + var tests = testCases{ + {"", `{{ ceil 1.5 }}`, "2", nil}, + {"", `{{ ceil 1 }}`, "1", nil}, + {"", `{{ ceil -1.5 }}`, "-1", nil}, + {"", `{{ ceil -1 }}`, "-1", nil}, + {"", `{{ ceil 0 }}`, "0", nil}, + {"", `{{ ceil 123 }}`, "123", nil}, + {"", `{{ ceil "123" }}`, "123", nil}, + {"", `{{ ceil 123.9999 }}`, "124", nil}, + {"", `{{ ceil 123.0001 }}`, "124", nil}, + } + + runTestCases(t, tests) +} + +func TestRound(t *testing.T) { + var tests = testCases{ + {"", `{{ round 3.746 2 }}`, "3.75", nil}, + {"", `{{ round 3.746 2 0.5 }}`, "3.75", nil}, + {"", `{{ round 123.5555 3 }}`, "123.556", nil}, + {"", `{{ round "123.5555" 3 }}`, "123.556", nil}, + {"", `{{ round 123.500001 0 }}`, "124", nil}, + {"", `{{ round 123.49999999 0 }}`, "123", nil}, + {"", `{{ round 123.2329999 2 .3 }}`, "123.23", nil}, + {"", `{{ round 123.233 2 .3 }}`, "123.24", nil}, + } + + runTestCases(t, tests) +} + +func TestAdd(t *testing.T) { + var tests = testCases{ + {"", `{{ add }}`, "0", nil}, + {"", `{{ add 1 }}`, "1", nil}, + {"", `{{ add 1 2 3 4 5 6 7 8 9 10 }}`, "55", nil}, + {"", `{{ 10.1 | add 1.1 2.2 3.3 4.4 5.5 6.6 7.7 8.8 9.9 }}`, "59.6", nil}, + } + + runTestCases(t, tests) +} + +func TestAddf(t *testing.T) { + var tests = testCases{ + {"", `{{ addf }}`, "0", nil}, + {"", `{{ addf 1 }}`, "1", nil}, + {"", `{{ addf 1 2 3 4 5 6 7 8 9 10 }}`, "55", nil}, + {"", `{{ 10.1 | addf 1.1 2.2 3.3 4.4 5.5 6.6 7.7 8.8 9.9 }}`, "59.6", nil}, + } + + runTestCases(t, tests) +} + +func TestAdd1(t *testing.T) { + var tests = testCases{ + {"", `{{ add1 -1 }}`, "0", nil}, + {"", `{{ add1f -1.0}}`, "0", nil}, + {"", `{{ add1 1 }}`, "2", nil}, + {"", `{{ add1 1.1 }}`, "2.1", nil}, + } + + runTestCases(t, tests) +} + +func TestAdd1f(t *testing.T) { + var tests = testCases{ + {"", `{{ add1f -1 }}`, "0", nil}, + {"", `{{ add1f -1.0}}`, "0", nil}, + {"", `{{ add1f 1 }}`, "2", nil}, + {"", `{{ add1f 1.1 }}`, "2.1", nil}, + } + + runTestCases(t, tests) +} + +func TestSub(t *testing.T) { + var tests = testCases{ + {"", `{{ sub 1 1 }}`, "0", nil}, + {"", `{{ sub 1 2 }}`, "-1", nil}, + {"", `{{ sub 1.1 1.1 }}`, "0", nil}, + {"", `{{ sub 1.1 2.2 }}`, "-1.1", nil}, + {"", `{{ 3 | sub 14 }}`, "11", nil}, + } + + runTestCases(t, tests) +} + +func TestSubf(t *testing.T) { + var tests = testCases{ + {"", `{{ subf 1.1 1.1 }}`, "0", nil}, + {"", `{{ subf 1.1 2.2 }}`, "-1.1", nil}, + {"", `{{ round (3 | subf 4.5 1) 1 }}`, "0.5", nil}, + } + + runTestCases(t, tests) +} + +func TestMulInt(t *testing.T) { + var tests = testCases{ + {"", `{{ mul 1 1 }}`, "1", nil}, + {"", `{{ mul 1 2 }}`, "2", nil}, + {"", `{{ mul 1.1 1.1 }}`, "1", nil}, + {"", `{{ mul 1.1 2.2 }}`, "2", nil}, + {"", `{{ 3 | mul 14 }}`, "42", nil}, + } + + runTestCases(t, tests) +} + +func TestMulFloat(t *testing.T) { + var tests = testCases{ + {"", `{{ round (mulf 1.1 1.1) 2 }}`, "1.21", nil}, + {"", `{{ round (mulf 1.1 2.2) 2 }}`, "2.42", nil}, + {"", `{{ round (3.3 | mulf 14.4) 2 }}`, "47.52", nil}, + } + + runTestCases(t, tests) +} + +func TestDivInt(t *testing.T) { + var tests = testCases{ + {"", `{{ div 1 1 }}`, "1", nil}, + {"", `{{ div 1 2 }}`, "0", nil}, + {"", `{{ div 1.1 1.1 }}`, "1", nil}, + {"", `{{ div 1.1 2.2 }}`, "0", nil}, + {"", `{{ 4 | div 5 }}`, "1", nil}, + } + + runTestCases(t, tests) +} + +func TestDivFloat(t *testing.T) { + var tests = testCases{ + {"", `{{ round (divf 1.1 1.1) 2 }}`, "1", nil}, + {"", `{{ round (divf 1.1 2.2) 2 }}`, "0.5", nil}, + {"", `{{ 2 | divf 5 4 }}`, "0.625", nil}, + } + + runTestCases(t, tests) +} + +func TestMod(t *testing.T) { + var tests = testCases{ + {"", `{{ mod 10 4 }}`, "2", nil}, + {"", `{{ mod 10 3 }}`, "1", nil}, + {"", `{{ mod 10 2 }}`, "0", nil}, + {"", `{{ mod 10 1 }}`, "0", nil}, + // In case of division by zero, the result is NaN defined by the + // IEEE 754 " not-a-number" value. + {"", `{{ mod 10 0 }}`, strconv.Itoa(int(math.NaN())), nil}, + } + + runTestCases(t, tests) +} + +func TestMin(t *testing.T) { + var tests = testCases{ + {"", `{{ min 1 }}`, "1", nil}, + {"", `{{ min 1 "1" }}`, "1", nil}, + {"", `{{ min -1 0 1 }}`, "-1", nil}, + {"", `{{ min 1 2 3 4 5 6 7 8 9 10 1 2 3 4 5 6 7 8 9 10 0 }}`, "0", nil}, + } + + runTestCases(t, tests) +} + +func TestMinf(t *testing.T) { + var tests = testCases{ + {"", `{{ minf 1 }}`, "1", nil}, + {"", `{{ minf 1 "1.1" }}`, "1", nil}, + {"", `{{ minf -1.4 .0 2.1 }}`, "-1.4", nil}, + {"", `{{ minf .1 .2 .3 .4 .5 .6 .7 .8 .9 .10 .1 .2 .3 .4 .5 .6 .7 .8 .9 .10}}`, "0.1", nil}, + } + + runTestCases(t, tests) +} + +func TestMax(t *testing.T) { + var tests = testCases{ + {"", `{{ max 1 }}`, "1", nil}, + {"", `{{ max 1 "1" }}`, "1", nil}, + {"", `{{ max -1 0 1 }}`, "1", nil}, + {"", `{{ max 1 2 3 4 5 6 7 8 9 10 1 2 3 4 5 6 7 8 9 10 0 }}`, "10", nil}, + } + + runTestCases(t, tests) +} + +func TestMaxf(t *testing.T) { + var tests = testCases{ + {"", `{{ maxf 1 }}`, "1", nil}, + {"", `{{ maxf 1.0 "1.1" }}`, "1.1", nil}, + {"", `{{ maxf -1.5 0 1.4 }}`, "1.4", nil}, + {"", `{{ maxf .1 .2 .3 .4 .5 .6 .7 .8 .9 .10 .1 .2 .3 .4 .5 .6 .7 .8 .9 .10 }}`, "0.9", nil}, + } + + runTestCases(t, tests) +} diff --git a/numeric_test.go b/numeric_test.go deleted file mode 100644 index f398e45..0000000 --- a/numeric_test.go +++ /dev/null @@ -1,343 +0,0 @@ -package sprout - -import ( - "fmt" - "strconv" - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestUntil(t *testing.T) { - tests := map[string]string{ - `{{range $i, $e := until 5}}{{$i}}{{$e}}{{end}}`: "0011223344", - `{{range $i, $e := until -5}}{{$i}}{{$e}} {{end}}`: "00 1-1 2-2 3-3 4-4 ", - } - for tpl, expect := range tests { - if err := runt(tpl, expect); err != nil { - t.Error(err) - } - } -} -func TestUntilStep(t *testing.T) { - tests := map[string]string{ - `{{range $i, $e := untilStep 0 5 1}}{{$i}}{{$e}}{{end}}`: "0011223344", - `{{range $i, $e := untilStep 3 6 1}}{{$i}}{{$e}}{{end}}`: "031425", - `{{range $i, $e := untilStep 0 -10 -2}}{{$i}}{{$e}} {{end}}`: "00 1-2 2-4 3-6 4-8 ", - `{{range $i, $e := untilStep 3 0 1}}{{$i}}{{$e}}{{end}}`: "", - `{{range $i, $e := untilStep 3 99 0}}{{$i}}{{$e}}{{end}}`: "", - `{{range $i, $e := untilStep 3 99 -1}}{{$i}}{{$e}}{{end}}`: "", - `{{range $i, $e := untilStep 3 0 0}}{{$i}}{{$e}}{{end}}`: "", - } - for tpl, expect := range tests { - if err := runt(tpl, expect); err != nil { - t.Error(err) - } - } - -} -func TestBiggest(t *testing.T) { - tpl := `{{ biggest 1 2 3 345 5 6 7}}` - if err := runt(tpl, `345`); err != nil { - t.Error(err) - } - - tpl = `{{ max 345}}` - if err := runt(tpl, `345`); err != nil { - t.Error(err) - } -} -func TestMaxf(t *testing.T) { - tpl := `{{ maxf 1 2 3 345.7 5 6 7}}` - if err := runt(tpl, `345.7`); err != nil { - t.Error(err) - } - - tpl = `{{ max 345 }}` - if err := runt(tpl, `345`); err != nil { - t.Error(err) - } -} -func TestMin(t *testing.T) { - tpl := `{{ min 1 2 3 345 5 6 7}}` - if err := runt(tpl, `1`); err != nil { - t.Error(err) - } - - tpl = `{{ min 345}}` - if err := runt(tpl, `345`); err != nil { - t.Error(err) - } -} - -func TestMinf(t *testing.T) { - tpl := `{{ minf 1.4 2 3 345.6 5 6 7}}` - if err := runt(tpl, `1.4`); err != nil { - t.Error(err) - } - - tpl = `{{ minf 345 }}` - if err := runt(tpl, `345`); err != nil { - t.Error(err) - } -} - -func TestToFloat64(t *testing.T) { - target := float64(102) - if target != toFloat64(int8(102)) { - t.Errorf("Expected 102") - } - if target != toFloat64(int(102)) { - t.Errorf("Expected 102") - } - if target != toFloat64(int32(102)) { - t.Errorf("Expected 102") - } - if target != toFloat64(int16(102)) { - t.Errorf("Expected 102") - } - if target != toFloat64(int64(102)) { - t.Errorf("Expected 102") - } - if target != toFloat64("102") { - t.Errorf("Expected 102") - } - if 0 != toFloat64("frankie") { - t.Errorf("Expected 0") - } - if target != toFloat64(uint16(102)) { - t.Errorf("Expected 102") - } - if target != toFloat64(uint64(102)) { - t.Errorf("Expected 102") - } - if 102.1234 != toFloat64(float64(102.1234)) { - t.Errorf("Expected 102.1234") - } - if 1 != toFloat64(true) { - t.Errorf("Expected 102") - } -} -func TestToInt64(t *testing.T) { - target := int64(102) - if target != toInt64(int8(102)) { - t.Errorf("Expected 102") - } - if target != toInt64(int(102)) { - t.Errorf("Expected 102") - } - if target != toInt64(int32(102)) { - t.Errorf("Expected 102") - } - if target != toInt64(int16(102)) { - t.Errorf("Expected 102") - } - if target != toInt64(int64(102)) { - t.Errorf("Expected 102") - } - if target != toInt64("102") { - t.Errorf("Expected 102") - } - if 0 != toInt64("frankie") { - t.Errorf("Expected 0") - } - if target != toInt64(uint16(102)) { - t.Errorf("Expected 102") - } - if target != toInt64(uint64(102)) { - t.Errorf("Expected 102") - } - if target != toInt64(float64(102.1234)) { - t.Errorf("Expected 102") - } - if 1 != toInt64(true) { - t.Errorf("Expected 102") - } -} - -func TestToInt(t *testing.T) { - target := int(102) - if target != toInt(int8(102)) { - t.Errorf("Expected 102") - } - if target != toInt(int(102)) { - t.Errorf("Expected 102") - } - if target != toInt(int32(102)) { - t.Errorf("Expected 102") - } - if target != toInt(int16(102)) { - t.Errorf("Expected 102") - } - if target != toInt(int64(102)) { - t.Errorf("Expected 102") - } - if target != toInt("102") { - t.Errorf("Expected 102") - } - if 0 != toInt("frankie") { - t.Errorf("Expected 0") - } - if target != toInt(uint16(102)) { - t.Errorf("Expected 102") - } - if target != toInt(uint64(102)) { - t.Errorf("Expected 102") - } - if target != toInt(float64(102.1234)) { - t.Errorf("Expected 102") - } - if 1 != toInt(true) { - t.Errorf("Expected 102") - } -} - -func TestToDecimal(t *testing.T) { - tests := map[interface{}]int64{ - "777": 511, - 777: 511, - 770: 504, - 755: 493, - } - - for input, expectedResult := range tests { - result := toDecimal(input) - if result != expectedResult { - t.Errorf("Expected %v but got %v", expectedResult, result) - } - } -} - -func TestAdd1(t *testing.T) { - tpl := `{{ 3 | add1 }}` - if err := runt(tpl, `4`); err != nil { - t.Error(err) - } -} - -func TestAdd1f(t *testing.T) { - tpl := `{{ 3.4 | add1f }}` - if err := runt(tpl, `4.4`); err != nil { - t.Error(err) - } -} - -func TestAdd(t *testing.T) { - tpl := `{{ 3 | add 1 2}}` - if err := runt(tpl, `6`); err != nil { - t.Error(err) - } -} - -func TestAddf(t *testing.T) { - tpl := `{{ 3 | addf 1.5 2.2}}` - if err := runt(tpl, `6.7`); err != nil { - t.Error(err) - } -} - -func TestDiv(t *testing.T) { - tpl := `{{ 4 | div 5 }}` - if err := runt(tpl, `1`); err != nil { - t.Error(err) - } -} - -func TestDivf(t *testing.T) { - tpl := `{{ 2 | divf 5 4 }}` - if err := runt(tpl, `0.625`); err != nil { - t.Error(err) - } -} - -func TestMul(t *testing.T) { - tpl := `{{ 1 | mul "2" 3 "4"}}` - if err := runt(tpl, `24`); err != nil { - t.Error(err) - } -} - -func TestMulf(t *testing.T) { - tpl := `{{ 1.2 | mulf "2.4" 10 "4"}}` - if err := runt(tpl, `115.2`); err != nil { - t.Error(err) - } -} - -func TestSub(t *testing.T) { - tpl := `{{ 3 | sub 14 }}` - if err := runt(tpl, `11`); err != nil { - t.Error(err) - } -} - -func TestSubf(t *testing.T) { - tpl := `{{ 3 | subf 4.5 1 }}` - if err := runt(tpl, `0.5`); err != nil { - t.Error(err) - } -} - -func TestCeil(t *testing.T) { - assert.Equal(t, 123.0, ceil(123)) - assert.Equal(t, 123.0, ceil("123")) - assert.Equal(t, 124.0, ceil(123.01)) - assert.Equal(t, 124.0, ceil("123.01")) -} - -func TestFloor(t *testing.T) { - assert.Equal(t, 123.0, floor(123)) - assert.Equal(t, 123.0, floor("123")) - assert.Equal(t, 123.0, floor(123.9999)) - assert.Equal(t, 123.0, floor("123.9999")) -} - -func TestRound(t *testing.T) { - assert.Equal(t, 123.556, round(123.5555, 3)) - assert.Equal(t, 123.556, round("123.55555", 3)) - assert.Equal(t, 124.0, round(123.500001, 0)) - assert.Equal(t, 123.0, round(123.49999999, 0)) - assert.Equal(t, 123.23, round(123.2329999, 2, .3)) - assert.Equal(t, 123.24, round(123.233, 2, .3)) -} - -func TestRandomInt(t *testing.T) { - var tests = []struct { - min int - max int - }{ - {10, 11}, - {10, 13}, - {0, 1}, - {5, 50}, - } - for _, v := range tests { - x, _ := runRaw(fmt.Sprintf(`{{ randInt %d %d }}`, v.min, v.max), nil) - r, err := strconv.Atoi(x) - assert.NoError(t, err) - assert.True(t, func(min, max, r int) bool { - return r >= v.min && r < v.max - }(v.min, v.max, r)) - } -} - -func TestSeq(t *testing.T) { - tests := map[string]string{ - `{{seq 0 1 3}}`: "0 1 2 3", - `{{seq 0 3 10}}`: "0 3 6 9", - `{{seq 3 3 2}}`: "", - `{{seq 3 -3 2}}`: "3", - `{{seq}}`: "", - `{{seq 0 4}}`: "0 1 2 3 4", - `{{seq 5}}`: "1 2 3 4 5", - `{{seq -5}}`: "1 0 -1 -2 -3 -4 -5", - `{{seq 0}}`: "1 0", - `{{seq 0 1 2 3}}`: "", - `{{seq 0 -4}}`: "0 -1 -2 -3 -4", - } - for tpl, expect := range tests { - if err := runt(tpl, expect); err != nil { - t.Error(err) - } - } -} diff --git a/random_functions.go b/random_functions.go new file mode 100644 index 0000000..69dd33c --- /dev/null +++ b/random_functions.go @@ -0,0 +1,208 @@ +package sprout + +import ( + "crypto/rand" + cryptorand "crypto/rand" + "encoding/base64" + "math/big" + mathrand "math/rand" + "strings" + "time" +) + +// randSource is a global variable that provides a source of randomness seeded with +// a cryptographically secure random number. This source is used throughout various +// random generation functions to ensure that randomness is both fast and non-repetitive. +var randSource mathrand.Source + +// randomOpts defines options for generating random strings. These options specify +// which character sets to include in the random generation process. When you provide +// a set of chars with `withChars`, the other options are ignored. +// +// Fields: +// +// withLetters bool - Includes lowercase and uppercase alphabetic characters if set to true. +// withNumbers bool - Includes numeric characters (0-9) if set to true. +// withAscii bool - Includes all printable ASCII characters (from space to tilde) if set to true. +// withChars []rune - Allows for specifying an explicit list of characters to include in the generation. +// +// Usage: +// +// opts := randomOpts{ +// withLetters: true, +// withNumbers: true, +// withAscii: false, +// withChars: nil, +// } +// +// Use these options in a random string generation function to produce a string +// consisting only of alphabetic and numeric characters. +type randomOpts struct { + withLetters bool + withNumbers bool + withAscii bool + withChars []rune +} + +// init is an initialization function that seeds the global random source used +// in random string generation. It retrieves a secure timestamp-based seed from +// crypto/rand and uses it to initialize math/rand's source, ensuring that random +// values are not predictable across program restarts. +func init() { + index, _ := cryptorand.Int(rand.Reader, big.NewInt(time.Now().UnixNano())) + randSource = mathrand.NewSource(index.Int64()) +} + +// randomString generates a random string of a given length using specified options. +// It supports a flexible character set based on the provided options. +// +// Parameters: +// +// count int - the length of the string to generate. +// opts *randomOpts - options specifying character sets to include in the string. +// +// Returns: +// +// string - the randomly generated string. +// +// Usage: +// +// opts := &randomOpts{withLetters: true, withNumbers: true} +// randomStr := fh.randomString(10, opts) // Generates a 10-character alphanumeric string. +func (fh *FunctionHandler) randomString(count int, opts *randomOpts) string { + if count <= 0 { + return "" + } + + if len(opts.withChars) > 0 { + goto GENERATE + } + + if opts.withAscii { + for i := 32; i <= 126; i++ { + opts.withChars = append(opts.withChars, rune(i)) + } + } + + if opts.withLetters { + for i := 'a'; i <= 'z'; i++ { + opts.withChars = append(opts.withChars, i) + } + for i := 'A'; i <= 'Z'; i++ { + opts.withChars = append(opts.withChars, i) + } + } + + if opts.withNumbers { + for i := '0'; i <= '9'; i++ { + opts.withChars = append(opts.withChars, i) + } + } + +GENERATE: + var builder strings.Builder + builder.Grow(count) + + for i := 0; i < count; i++ { + index, _ := rand.Int(rand.Reader, big.NewInt(int64(len(opts.withChars)))) + builder.WriteRune(opts.withChars[index.Int64()]) + } + + return builder.String() +} + +// RandAlphaNumeric generates a random alphanumeric string of specified length. +// +// Parameters: +// +// count int - the length of the string to generate. +// +// Returns: +// +// string - the randomly generated alphanumeric string. +// +// Example: +// +// {{ 10 | randAlphaNumeric }} // Output: "a1b2c3d4e5" (output will vary) +func (fh *FunctionHandler) RandAlphaNumeric(count int) string { + return fh.randomString(count, &randomOpts{withLetters: true, withNumbers: true}) +} + +// RandAlpha generates a random alphabetic string of specified length. +// +// Parameters: +// +// count int - the length of the string to generate. +// +// Returns: +// +// string - the randomly generated alphabetic string. +// +// Example: +// +// {{ 10 | randAlpha }} // Output: "abcdefghij" (output will vary) +func (fh *FunctionHandler) RandAlpha(count int) string { + return fh.randomString(count, &randomOpts{withLetters: true}) +} + +// RandAscii generates a random ASCII string (character codes 32 to 126) of specified length. +// +// Parameters: +// +// count int - the length of the string to generate. +// +// Returns: +// +// string - the randomly generated ASCII string. +// +// Example: +// +// {{ 10 | randAscii }} // Output: "}]~>_<:^%" (output will vary) +func (fh *FunctionHandler) RandAscii(count int) string { + return fh.randomString(count, &randomOpts{withAscii: true}) +} + +// RandNumeric generates a random numeric string of specified length. +// +// Parameters: +// +// count int - the length of the string to generate. +// +// Returns: +// +// string - the randomly generated numeric string. +// +// Example: +// +// {{ 10 | randNumeric }} // Output: "0123456789" (output will vary) +func (fh *FunctionHandler) RandNumeric(count int) string { + return fh.randomString(count, &randomOpts{withNumbers: true}) +} + +// RandBytes generates a random byte array of specified length and returns it as a base64 encoded string. +// +// Parameters: +// +// count int - the number of bytes to generate. +// +// Returns: +// +// string - the base64 encoded string of the randomly generated bytes. +// error - error if the random byte generation fails. +// +// Example: +// +// {{ 16 | randBytes }} // Output: "c3RhY2thYnVzZSByb2NrcyE=" (output will vary) +func (fh *FunctionHandler) RandBytes(count int) (string, error) { + if count <= 0 { + return "", nil + } + + buf := make([]byte, count) + _, _ = cryptorand.Read(buf) + return base64.StdEncoding.EncodeToString(buf), nil +} + +func (fh *FunctionHandler) RandInt(min, max int) int { + return mathrand.Intn(max-min) + min +} diff --git a/random_functions_test.go b/random_functions_test.go new file mode 100644 index 0000000..5fbe019 --- /dev/null +++ b/random_functions_test.go @@ -0,0 +1,114 @@ +package sprout + +import ( + "encoding/base64" + "testing" + + "github.com/stretchr/testify/assert" +) + +type randTestCase struct { + name string + template string + regexp string + length int +} + +func testRandHelper(t *testing.T, tcs []randTestCase) { + t.Helper() + + for _, test := range tcs { + t.Run(test.name, func(t *testing.T) { + t.Helper() + + result, err := runTemplate(t, NewFunctionHandler(), test.template, nil) + assert.NoError(t, err) + + assert.Regexp(t, test.regexp, result) + if test.length != -1 { + assert.Len(t, result, test.length) + } + }) + } +} + +func TestRandAlphaNumeric(t *testing.T) { + var tests = []randTestCase{ + {"TestRandAlphaNumWithNegativeValue", `{{ randAlphaNum -1 }}`, "", 0}, + {"TestRandAlphaNumWithZero", `{{ randAlphaNum 0 }}`, "", 0}, + {"TestRandAlphaNum", `{{ randAlphaNum 100 }}`, `^[a-zA-Z0-9]{100}$`, 100}, + } + + testRandHelper(t, tests) +} + +func TestRandAlpha(t *testing.T) { + var tests = []randTestCase{ + {"TestRandAlphaWithNegativeValue", `{{ randAlpha -1 }}`, "", 0}, + {"TestRandAlphaWithZero", `{{ randAlpha 0 }}`, "", 0}, + {"TestRandAlpha", `{{ randAlpha 100 }}`, `^[a-zA-Z]{100}$`, 100}, + } + + testRandHelper(t, tests) +} + +func TestRandAscii(t *testing.T) { + var tests = []randTestCase{ + {"TestRandAsciiWithNegativeValue", `{{ randAscii -1 }}`, "", 0}, + {"TestRandAsciiWithZero", `{{ randAscii 0 }}`, "", 0}, + {"TestRandAscii", `{{ randAscii 100 }}`, "^[[:ascii:]]{100}$", 100}, + } + + testRandHelper(t, tests) +} + +func TestRandNumeric(t *testing.T) { + var tests = []randTestCase{ + {"TestRandNumericWithNegativeValue", `{{ randNumeric -1 }}`, "", 0}, + {"TestRandNumericWithZero", `{{ randNumeric 0 }}`, "", 0}, + {"TestRandNumeric", `{{ randNumeric 100 }}`, `^[0-9]{100}$`, 100}, + } + + testRandHelper(t, tests) +} + +func TestRandBytes(t *testing.T) { + var tests = []randTestCase{ + {"TestRandBytesWithNegativeValue", `{{ randBytes -1 }}`, "", 0}, + {"TestRandBytesWithZero", `{{ randBytes 0 }}`, "", 0}, + {"TestRandBytes", `{{ randBytes 100 }}`, "", 100}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + result, err := runTemplate(t, NewFunctionHandler(), test.template, nil) + assert.NoError(t, err) + + assert.Regexp(t, test.regexp, result) + + b, err := base64.StdEncoding.DecodeString(result) + assert.NoError(t, err) + assert.Len(t, b, test.length) + }) + } +} + +func TestRandInt(t *testing.T) { + var tests = []randTestCase{ + {"TestRandIntWithNegativeValue", `{{ randInt -1 10 }}`, "", -1}, + {"BetweenZeroAndTen", `{{ randInt 0 10 }}`, `^[0-9]{1,2}$`, -1}, + {"BetweenTenAndTwenty", `{{ randInt 10 20 }}`, `^[0-9]{1,2}$`, -1}, + {"BetweenNegativeTenAndTwenty", `{{ randInt -10 20 }}`, `^-?[0-9]{1,2}$`, -1}, + } + + testRandHelper(t, tests) +} + +func TestRandomString(t *testing.T) { + fh := NewFunctionHandler() + assert.Regexp(t, "^[0-9]{100}$", fh.randomString(100, &randomOpts{withNumbers: true})) + assert.Regexp(t, "^[a-zA-Z]{100}$", fh.randomString(100, &randomOpts{withLetters: true})) + assert.Regexp(t, "^[a-zA-Z0-9]{100}$", fh.randomString(100, &randomOpts{withLetters: true, withNumbers: true})) + assert.Regexp(t, "^([a-zA-Z0-9]|[[:ascii:]]){100}$", fh.randomString(100, &randomOpts{withLetters: true, withAscii: true})) + assert.Regexp(t, "^[42@]{100}$", fh.randomString(100, &randomOpts{withChars: []rune{'4', '2', '@'}})) +} diff --git a/reflect.go b/reflect.go deleted file mode 100644 index adffb58..0000000 --- a/reflect.go +++ /dev/null @@ -1,28 +0,0 @@ -package sprout - -import ( - "fmt" - "reflect" -) - -// typeIs returns true if the src is the type named in target. -func typeIs(target string, src interface{}) bool { - return target == typeOf(src) -} - -func typeIsLike(target string, src interface{}) bool { - t := typeOf(src) - return target == t || "*"+target == t -} - -func typeOf(src interface{}) string { - return fmt.Sprintf("%T", src) -} - -func kindIs(target string, src interface{}) bool { - return target == kindOf(src) -} - -func kindOf(src interface{}) string { - return reflect.ValueOf(src).Kind().String() -} diff --git a/reflect_test.go b/reflect_test.go deleted file mode 100644 index 80f13bf..0000000 --- a/reflect_test.go +++ /dev/null @@ -1,73 +0,0 @@ -package sprout - -import ( - "testing" -) - -type fixtureTO struct { - Name, Value string -} - -func TestTypeOf(t *testing.T) { - f := &fixtureTO{"hello", "world"} - tpl := `{{typeOf .}}` - if err := runtv(tpl, "*sprout.fixtureTO", f); err != nil { - t.Error(err) - } -} - -func TestKindOf(t *testing.T) { - tpl := `{{kindOf .}}` - - f := fixtureTO{"hello", "world"} - if err := runtv(tpl, "struct", f); err != nil { - t.Error(err) - } - - f2 := []string{"hello"} - if err := runtv(tpl, "slice", f2); err != nil { - t.Error(err) - } - - var f3 *fixtureTO - if err := runtv(tpl, "ptr", f3); err != nil { - t.Error(err) - } -} - -func TestTypeIs(t *testing.T) { - f := &fixtureTO{"hello", "world"} - tpl := `{{if typeIs "*sprout.fixtureTO" .}}t{{else}}f{{end}}` - if err := runtv(tpl, "t", f); err != nil { - t.Error(err) - } - - f2 := "hello" - if err := runtv(tpl, "f", f2); err != nil { - t.Error(err) - } -} -func TestTypeIsLike(t *testing.T) { - f := "foo" - tpl := `{{if typeIsLike "string" .}}t{{else}}f{{end}}` - if err := runtv(tpl, "t", f); err != nil { - t.Error(err) - } - - // Now make a pointer. Should still match. - f2 := &f - if err := runtv(tpl, "t", f2); err != nil { - t.Error(err) - } -} -func TestKindIs(t *testing.T) { - f := &fixtureTO{"hello", "world"} - tpl := `{{if kindIs "ptr" .}}t{{else}}f{{end}}` - if err := runtv(tpl, "t", f); err != nil { - t.Error(err) - } - f2 := "hello" - if err := runtv(tpl, "f", f2); err != nil { - t.Error(err) - } -} diff --git a/regex.go b/regex.go deleted file mode 100644 index 9c4b8db..0000000 --- a/regex.go +++ /dev/null @@ -1,83 +0,0 @@ -package sprout - -import ( - "regexp" -) - -func regexMatch(regex string, s string) bool { - match, _ := regexp.MatchString(regex, s) - return match -} - -func mustRegexMatch(regex string, s string) (bool, error) { - return regexp.MatchString(regex, s) -} - -func regexFindAll(regex string, s string, n int) []string { - r := regexp.MustCompile(regex) - return r.FindAllString(s, n) -} - -func mustRegexFindAll(regex string, s string, n int) ([]string, error) { - r, err := regexp.Compile(regex) - if err != nil { - return []string{}, err - } - return r.FindAllString(s, n), nil -} - -func regexFind(regex string, s string) string { - r := regexp.MustCompile(regex) - return r.FindString(s) -} - -func mustRegexFind(regex string, s string) (string, error) { - r, err := regexp.Compile(regex) - if err != nil { - return "", err - } - return r.FindString(s), nil -} - -func regexReplaceAll(regex string, s string, repl string) string { - r := regexp.MustCompile(regex) - return r.ReplaceAllString(s, repl) -} - -func mustRegexReplaceAll(regex string, s string, repl string) (string, error) { - r, err := regexp.Compile(regex) - if err != nil { - return "", err - } - return r.ReplaceAllString(s, repl), nil -} - -func regexReplaceAllLiteral(regex string, s string, repl string) string { - r := regexp.MustCompile(regex) - return r.ReplaceAllLiteralString(s, repl) -} - -func mustRegexReplaceAllLiteral(regex string, s string, repl string) (string, error) { - r, err := regexp.Compile(regex) - if err != nil { - return "", err - } - return r.ReplaceAllLiteralString(s, repl), nil -} - -func regexSplit(regex string, s string, n int) []string { - r := regexp.MustCompile(regex) - return r.Split(s, n) -} - -func mustRegexSplit(regex string, s string, n int) ([]string, error) { - r, err := regexp.Compile(regex) - if err != nil { - return []string{}, err - } - return r.Split(s, n), nil -} - -func regexQuoteMeta(s string) string { - return regexp.QuoteMeta(s) -} diff --git a/regex_test.go b/regex_test.go deleted file mode 100644 index 6783909..0000000 --- a/regex_test.go +++ /dev/null @@ -1,203 +0,0 @@ -package sprout - -import ( - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestRegexMatch(t *testing.T) { - regex := "[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}" - - assert.True(t, regexMatch(regex, "test@acme.com")) - assert.True(t, regexMatch(regex, "Test@Acme.Com")) - assert.False(t, regexMatch(regex, "test")) - assert.False(t, regexMatch(regex, "test.com")) - assert.False(t, regexMatch(regex, "test@acme")) -} - -func TestMustRegexMatch(t *testing.T) { - regex := "[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}" - - o, err := mustRegexMatch(regex, "test@acme.com") - assert.True(t, o) - assert.Nil(t, err) - - o, err = mustRegexMatch(regex, "Test@Acme.Com") - assert.True(t, o) - assert.Nil(t, err) - - o, err = mustRegexMatch(regex, "test") - assert.False(t, o) - assert.Nil(t, err) - - o, err = mustRegexMatch(regex, "test.com") - assert.False(t, o) - assert.Nil(t, err) - - o, err = mustRegexMatch(regex, "test@acme") - assert.False(t, o) - assert.Nil(t, err) -} - -func TestRegexFindAll(t *testing.T) { - regex := "a{2}" - assert.Equal(t, 1, len(regexFindAll(regex, "aa", -1))) - assert.Equal(t, 1, len(regexFindAll(regex, "aaaaaaaa", 1))) - assert.Equal(t, 2, len(regexFindAll(regex, "aaaa", -1))) - assert.Equal(t, 0, len(regexFindAll(regex, "none", -1))) -} - -func TestMustRegexFindAll(t *testing.T) { - type args struct { - regex, s string - n int - } - cases := []struct { - expected int - args args - }{ - {1, args{"a{2}", "aa", -1}}, - {1, args{"a{2}", "aaaaaaaa", 1}}, - {2, args{"a{2}", "aaaa", -1}}, - {0, args{"a{2}", "none", -1}}, - } - - for _, c := range cases { - res, err := mustRegexFindAll(c.args.regex, c.args.s, c.args.n) - if err != nil { - t.Errorf("regexFindAll test case %v failed with err %s", c, err) - } - assert.Equal(t, c.expected, len(res), "case %#v", c.args) - } -} - -func TestRegexFindl(t *testing.T) { - regex := "fo.?" - assert.Equal(t, "foo", regexFind(regex, "foorbar")) - assert.Equal(t, "foo", regexFind(regex, "foo foe fome")) - assert.Equal(t, "", regexFind(regex, "none")) -} - -func TestMustRegexFindl(t *testing.T) { - type args struct{ regex, s string } - cases := []struct { - expected string - args args - }{ - {"foo", args{"fo.?", "foorbar"}}, - {"foo", args{"fo.?", "foo foe fome"}}, - {"", args{"fo.?", "none"}}, - } - - for _, c := range cases { - res, err := mustRegexFind(c.args.regex, c.args.s) - if err != nil { - t.Errorf("regexFind test case %v failed with err %s", c, err) - } - assert.Equal(t, c.expected, res, "case %#v", c.args) - } -} - -func TestRegexReplaceAll(t *testing.T) { - regex := "a(x*)b" - assert.Equal(t, "-T-T-", regexReplaceAll(regex, "-ab-axxb-", "T")) - assert.Equal(t, "--xx-", regexReplaceAll(regex, "-ab-axxb-", "$1")) - assert.Equal(t, "---", regexReplaceAll(regex, "-ab-axxb-", "$1W")) - assert.Equal(t, "-W-xxW-", regexReplaceAll(regex, "-ab-axxb-", "${1}W")) -} - -func TestMustRegexReplaceAll(t *testing.T) { - type args struct{ regex, s, repl string } - cases := []struct { - expected string - args args - }{ - {"-T-T-", args{"a(x*)b", "-ab-axxb-", "T"}}, - {"--xx-", args{"a(x*)b", "-ab-axxb-", "$1"}}, - {"---", args{"a(x*)b", "-ab-axxb-", "$1W"}}, - {"-W-xxW-", args{"a(x*)b", "-ab-axxb-", "${1}W"}}, - } - - for _, c := range cases { - res, err := mustRegexReplaceAll(c.args.regex, c.args.s, c.args.repl) - if err != nil { - t.Errorf("regexReplaceAll test case %v failed with err %s", c, err) - } - assert.Equal(t, c.expected, res, "case %#v", c.args) - } -} - -func TestRegexReplaceAllLiteral(t *testing.T) { - regex := "a(x*)b" - assert.Equal(t, "-T-T-", regexReplaceAllLiteral(regex, "-ab-axxb-", "T")) - assert.Equal(t, "-$1-$1-", regexReplaceAllLiteral(regex, "-ab-axxb-", "$1")) - assert.Equal(t, "-${1}-${1}-", regexReplaceAllLiteral(regex, "-ab-axxb-", "${1}")) -} - -func TestMustRegexReplaceAllLiteral(t *testing.T) { - type args struct{ regex, s, repl string } - cases := []struct { - expected string - args args - }{ - {"-T-T-", args{"a(x*)b", "-ab-axxb-", "T"}}, - {"-$1-$1-", args{"a(x*)b", "-ab-axxb-", "$1"}}, - {"-${1}-${1}-", args{"a(x*)b", "-ab-axxb-", "${1}"}}, - } - - for _, c := range cases { - res, err := mustRegexReplaceAllLiteral(c.args.regex, c.args.s, c.args.repl) - if err != nil { - t.Errorf("regexReplaceAllLiteral test case %v failed with err %s", c, err) - } - assert.Equal(t, c.expected, res, "case %#v", c.args) - } -} - -func TestRegexSplit(t *testing.T) { - regex := "a" - assert.Equal(t, 4, len(regexSplit(regex, "banana", -1))) - assert.Equal(t, 0, len(regexSplit(regex, "banana", 0))) - assert.Equal(t, 1, len(regexSplit(regex, "banana", 1))) - assert.Equal(t, 2, len(regexSplit(regex, "banana", 2))) - - regex = "z+" - assert.Equal(t, 2, len(regexSplit(regex, "pizza", -1))) - assert.Equal(t, 0, len(regexSplit(regex, "pizza", 0))) - assert.Equal(t, 1, len(regexSplit(regex, "pizza", 1))) - assert.Equal(t, 2, len(regexSplit(regex, "pizza", 2))) -} - -func TestMustRegexSplit(t *testing.T) { - type args struct { - regex, s string - n int - } - cases := []struct { - expected int - args args - }{ - {4, args{"a", "banana", -1}}, - {0, args{"a", "banana", 0}}, - {1, args{"a", "banana", 1}}, - {2, args{"a", "banana", 2}}, - {2, args{"z+", "pizza", -1}}, - {0, args{"z+", "pizza", 0}}, - {1, args{"z+", "pizza", 1}}, - {2, args{"z+", "pizza", 2}}, - } - - for _, c := range cases { - res, err := mustRegexSplit(c.args.regex, c.args.s, c.args.n) - if err != nil { - t.Errorf("regexSplit test case %v failed with err %s", c, err) - } - assert.Equal(t, c.expected, len(res), "case %#v", c.args) - } -} - -func TestRegexQuoteMeta(t *testing.T) { - assert.Equal(t, "1\\.2\\.3", regexQuoteMeta("1.2.3")) - assert.Equal(t, "pretzel", regexQuoteMeta("pretzel")) -} diff --git a/regexp_functions.go b/regexp_functions.go new file mode 100644 index 0000000..292e2aa --- /dev/null +++ b/regexp_functions.go @@ -0,0 +1,284 @@ +package sprout + +import "regexp" + +// RegexFind returns the first match of the regex pattern in the string. +// +// Parameters: +// +// regex string - the regular expression pattern to search for. +// s string - the string to search. +// +// Returns: +// +// string - the first matching string. +// +// Example: +// +// {{ regexFind "a(b+)" "aaabbb" }} // Output: "abbb" +func (fh *FunctionHandler) RegexFind(regex string, s string) string { + result, _ := fh.MustRegexFind(regex, s) + return result +} + +// RegexFindAll returns all matches of the regex pattern in the string up to n +// matches. +// +// Parameters: +// +// regex string - the regular expression pattern to search for. +// s string - the string to search. +// n int - the maximum number of matches to return. +// +// Returns: +// +// []string - a slice of all matches. +// +// Example: +// +// {{ regexFindAll "a(b+)" "aaabbb" 2 }} // Output: ["abbb"] +func (fh *FunctionHandler) RegexFindAll(regex string, s string, n int) []string { + result, _ := fh.MustRegexFindAll(regex, s, n) + return result +} + +// RegexMatch checks if the string matches the regex pattern. +// +// Parameters: +// +// regex string - the regular expression pattern to match against. +// s string - the string to check. +// +// Returns: +// +// bool - true if the string matches the regex pattern, otherwise false. +// +// Example: +// +// {{ regexMatch "^[a-zA-Z]+$" "Hello" }} // Output: true +func (fh *FunctionHandler) RegexMatch(regex string, s string) bool { + result, _ := fh.MustRegexMatch(regex, s) + return result +} + +// RegexSplit splits the string by the regex pattern up to n times. +// +// Parameters: +// +// regex string - the regular expression pattern to split by. +// s string - the string to split. +// n int - the number of times to split. +// +// Returns: +// +// []string - a slice of the substrings split by the regex. +// +// Example: +// +// {{regexSplit "\\s+" "hello world" -1 }} // Output: ["hello", "world"] +func (fh *FunctionHandler) RegexSplit(regex string, s string, n int) []string { + result, _ := fh.MustRegexSplit(regex, s, n) + return result +} + +// RegexReplaceAll replaces all occurrences of the regex pattern in the string +// with the replacement string. +// +// Parameters: +// +// regex string - the regular expression pattern to replace. +// s string - the string to perform replacements on. +// repl string - the replacement string. +// +// Returns: +// +// string - the string with all replacements made. +// +// Example: +// +// {{ regexReplaceAll "[aeiou]" "hello" "i" }} // Output: "hillo" +func (fh *FunctionHandler) RegexReplaceAll(regex string, s string, repl string) string { + result, _ := fh.MustRegexReplaceAll(regex, s, repl) + return result +} + +// RegexReplaceAllLiteral replaces all occurrences of the regex pattern in the +// string with the literal replacement string. +// +// Parameters: +// +// regex string - the regular expression pattern to replace. +// s string - the string to perform replacements on. +// repl string - the replacement string, inserted literally. +// +// Returns: +// +// string - the string with all replacements made, without treating the replacement string as a regex replacement pattern. +// +// Example: +// +// {{ regexReplaceAllLiteral "[aeiou]" "hello" "$&" }} // Output: "h$&ll$&" +func (fh *FunctionHandler) RegexReplaceAllLiteral(regex string, s string, repl string) string { + result, _ := fh.MustRegexReplaceAllLiteral(regex, s, repl) + return result +} + +// RegexQuoteMeta returns a literal pattern string for the provided string. +// +// Parameters: +// +// s string - the string to be escaped. +// +// Returns: +// +// string - the escaped regex pattern. +// +// Example: +// +// {{ regexQuoteMeta ".+*?^$()[]{}|" }} // Output: "\.\+\*\?\^\$\(\)\[\]\{\}\|" +func (fh *FunctionHandler) RegexQuoteMeta(s string) string { + return regexp.QuoteMeta(s) +} + +// MustRegexFind searches for the first match of a regex pattern in a string +// and returns it, with error handling. +// +// Parameters: +// +// regex string - the regular expression to search with. +// s string - the string to search within. +// +// Returns: +// +// string - the first regex match found. +// error - error if the regex fails to compile. +// +// Example: +// +// {{ "hello world" | mustRegexFind "hello" }} // Output: "hello", nil +func (fh *FunctionHandler) MustRegexFind(regex string, s string) (string, error) { + r, err := regexp.Compile(regex) + if err != nil { + return "", err + } + return r.FindString(s), nil +} + +// MustRegexFindAll finds all matches of a regex pattern in a string up to a +// specified limit, with error handling. +// +// Parameters: +// +// regex string - the regular expression to search with. +// s string - the string to search within. +// n int - the maximum number of matches to return; use -1 for no limit. +// +// Returns: +// +// []string - all regex matches found. +// error - error if the regex fails to compile. +// +// Example: +// +// {{ mustRegexFindAll "a.", "aba acada afa", 3 }} // Output: ["ab", "ac", "af"], nil +func (fh *FunctionHandler) MustRegexFindAll(regex string, s string, n int) ([]string, error) { + r, err := regexp.Compile(regex) + if err != nil { + return []string{}, err + } + return r.FindAllString(s, n), nil +} + +// MustRegexMatch checks if a string matches a regex pattern, with error handling. +// +// Parameters: +// +// regex string - the regular expression to match against. +// s string - the string to check. +// +// Returns: +// +// bool - true if the string matches the regex pattern, otherwise false. +// error - error if the regex fails to compile. +// +// Example: +// +// {{ mustRegexMatch "^[a-zA-Z]+$", "Hello" }} // Output: true, nil +func (fh *FunctionHandler) MustRegexMatch(regex string, s string) (bool, error) { + return regexp.MatchString(regex, s) +} + +// MustRegexSplit splits a string by a regex pattern up to a specified number of +// substrings, with error handling. +// +// Parameters: +// +// regex string - the regular expression to split by. +// s string - the string to split. +// n int - the maximum number of substrings to return; use -1 for no limit. +// +// Returns: +// +// []string - the substrings resulting from the split. +// error - error if the regex fails to compile. +// +// Example: +// +// {{ mustRegexSplit "\\s+", "hello world from Go", 2 }} // Output: ["hello", "world from Go"], nil +func (fh *FunctionHandler) MustRegexSplit(regex string, s string, n int) ([]string, error) { + r, err := regexp.Compile(regex) + if err != nil { + return []string{}, err + } + return r.Split(s, n), nil +} + +// MustRegexReplaceAll replaces all occurrences of a regex pattern in a string +// with a replacement string, with error handling. +// +// Parameters: +// +// regex string - the regular expression to replace. +// s string - the string containing the original text. +// repl string - the replacement text. +// +// Returns: +// +// string - the modified string after all replacements. +// error - error if the regex fails to compile. +// +// Example: +// +// {{ mustRegexReplaceAll "\\d", "R2D2 C3PO", "X" }} // Output: "RXDX CXPO", nil +func (fh *FunctionHandler) MustRegexReplaceAll(regex string, s string, repl string) (string, error) { + r, err := regexp.Compile(regex) + if err != nil { + return "", err + } + return r.ReplaceAllString(s, repl), nil +} + +// MustRegexReplaceAllLiteral replaces all occurrences of a regex pattern in a +// string with a literal replacement string, with error handling. +// +// Parameters: +// +// regex string - the regular expression to replace. +// s string - the string containing the original text. +// repl string - the literal replacement text. +// +// Returns: +// +// string - the modified string after all replacements, treating the replacement text as literal text. +// error - error if the regex fails to compile. +// +// Example: +// +// {{ mustRegexReplaceAllLiteral "world", "hello world", "$1" }} // Output: "hello $1", nil +func (fh *FunctionHandler) MustRegexReplaceAllLiteral(regex string, s string, repl string) (string, error) { + r, err := regexp.Compile(regex) + if err != nil { + return "", err + } + return r.ReplaceAllLiteralString(s, repl), nil +} diff --git a/regexp_functions_test.go b/regexp_functions_test.go new file mode 100644 index 0000000..bfdbe65 --- /dev/null +++ b/regexp_functions_test.go @@ -0,0 +1,145 @@ +package sprout + +import "testing" + +func TestRegexpFind(t *testing.T) { + var tests = testCases{ + {"TestRegexpFind", `{{ regexFind "a(b+)" "aaabbb" }}`, "abbb", nil}, + {"TestRegexpFindError", `{{ regexFind "a(b+" "aaabbb" }}`, "", nil}, + } + + runTestCases(t, tests) +} + +func TestRegexpFindAll(t *testing.T) { + var tests = testCases{ + {"TestRegexpFindAllWithoutLimit", `{{ regexFindAll "a(b+)" "aaabbb" -1 }}`, "[abbb]", nil}, + {"TestRegexpFindAllWithLimit", `{{ regexFindAll "a{2}" "aaaabbb" -1 }}`, "[aa aa]", nil}, + {"TestRegexpFindAllWithNoMatch", `{{ regexFindAll "a{2}" "none" -1 }}`, "[]", nil}, + {"TestRegexpFindAllWithInvalidPattern", `{{ regexFindAll "a(b+" "aaabbb" -1 }}`, "[]", nil}, + } + + runTestCases(t, tests) +} + +func TestRegexMatch(t *testing.T) { + var tests = testCases{ + {"TestRegexMatchValid", `{{ regexMatch "^[a-zA-Z]+$" "Hello" }}`, "true", nil}, + {"TestRegexMatchInvalidAlphaNumeric", `{{ regexMatch "^[a-zA-Z]+$" "Hello123" }}`, "false", nil}, + {"TestRegexMatchInvalidNumeric", `{{ regexMatch "^[a-zA-Z]+$" "123" }}`, "false", nil}, + } + + runTestCases(t, tests) +} + +func TestRegexSplit(t *testing.T) { + var tests = testCases{ + {"TestRegexpFindAllWithoutLimit", `{{ regexSplit "a" "banana" -1 }}`, "[b n n ]", nil}, + {"TestRegexpSplitZeroLimit", `{{ regexSplit "a" "banana" 0 }}`, "[]", nil}, + {"TestRegexpSplitOneLimit", `{{ regexSplit "a" "banana" 1 }}`, "[banana]", nil}, + {"TestRegexpSplitTwoLimit", `{{ regexSplit "a" "banana" 2 }}`, "[b nana]", nil}, + {"TestRegexpSplitRepetitionLimit", `{{ regexSplit "a+" "banana" 1 }}`, "[banana]", nil}, + } + + runTestCases(t, tests) +} + +func TestRegexReplaceAll(t *testing.T) { + var tests = testCases{ + {"TestRegexReplaceAllValid", `{{ regexReplaceAll "a(x*)b" "-ab-axxb-" "T" }}`, "-T-T-", nil}, + {"TestRegexReplaceAllWithDollarSign", `{{ regexReplaceAll "a(x*)b" "-ab-axxb-" "$1" }}`, "--xx-", nil}, + {"TestRegexReplaceAllWithDollarSignAndLetter", `{{ regexReplaceAll "a(x*)b" "-ab-axxb-" "$1W" }}`, "---", nil}, + {"TestRegexReplaceAllWithDollarSignAndCurlyBraces", `{{ regexReplaceAll "a(x*)b" "-ab-axxb-" "${1}W" }}`, "-W-xxW-", nil}, + } + + runTestCases(t, tests) +} + +func TestRegexReplaceAllLiteral(t *testing.T) { + var tests = testCases{ + {"TestRegexReplaceAllLiteralValid", `{{ regexReplaceAllLiteral "a(x*)b" "-ab-axxb-" "T" }}`, "-T-T-", nil}, + {"TestRegexReplaceAllLiteralWithDollarSign", `{{ regexReplaceAllLiteral "a(x*)b" "-ab-axxb-" "$1" }}`, "-$1-$1-", nil}, + {"TestRegexReplaceAllLiteralWithDollarSignAndLetter", `{{ regexReplaceAllLiteral "a(x*)b" "-ab-axxb-" "$1W" }}`, "-$1W-$1W-", nil}, + {"TestRegexReplaceAllLiteralWithDollarSignAndCurlyBraces", `{{ regexReplaceAllLiteral "a(x*)b" "-ab-axxb-" "${1}W" }}`, "-${1}W-${1}W-", nil}, + } + + runTestCases(t, tests) +} + +func TestRegexQuoteMeta(t *testing.T) { + var tests = testCases{ + {"TestRegexQuoteMetaALongLine", `{{ regexQuoteMeta "Escaping $100? That's a lot." }}`, "Escaping \\$100\\? That's a lot\\.", nil}, + {"TestRegexQuoteMetaASemVer", `{{ regexQuoteMeta "1.2.3" }}`, "1\\.2\\.3", nil}, + {"TestRegexQuoteMetaNothing", `{{ regexQuoteMeta "golang" }}`, "golang", nil}, + } + + runTestCases(t, tests) +} + +func TestMustRegexFind(t *testing.T) { + var tests = mustTestCases{ + {testCase{"TestMustRegexFindValid", `{{ mustRegexFind "a(b+)" "aaabbb" }}`, "abbb", nil}, ""}, + {testCase{"TestMustRegexFindInvalid", `{{ mustRegexFind "a(b+" "aaabbb" }}`, "", nil}, "error parsing regexp: missing closing ): `a(b+`"}, + } + + runMustTestCases(t, tests) +} + +func TestMustRegexFindAll(t *testing.T) { + var tests = mustTestCases{ + {testCase{"TestMustRegexFindAllValid", `{{ mustRegexFindAll "a(b+)" "aaabbb" -1 }}`, "[abbb]", nil}, ""}, + {testCase{"TestMustRegexFindAllWithLimit", `{{ mustRegexFindAll "a{2}" "aaaabbb" -1 }}`, "[aa aa]", nil}, ""}, + {testCase{"TestMustRegexFindAllWithNoMatch", `{{ mustRegexFindAll "a{2}" "none" -1 }}`, "[]", nil}, ""}, + {testCase{"TestMustRegexFindAllWithInvalidPattern", `{{ mustRegexFindAll "a(b+" "aaabbb" -1 }}`, "", nil}, "error parsing regexp: missing closing ): `a(b+`"}, + } + + runMustTestCases(t, tests) +} + +func TestMustRegexMatch(t *testing.T) { + var tests = mustTestCases{ + {testCase{"TestMustRegexMatchValid", `{{ mustRegexMatch "^[a-zA-Z]+$" "Hello" }}`, "true", nil}, ""}, + {testCase{"TestMustRegexMatchInvalidAlphaNumeric", `{{ mustRegexMatch "^[a-zA-Z]+$" "Hello123" }}`, "false", nil}, ""}, + {testCase{"TestMustRegexMatchInvalidNumeric", `{{ mustRegexMatch "^[a-zA-Z]+$" "123" }}`, "false", nil}, ""}, + {testCase{"TestMustRegexMatchInvalidPattern", `{{ mustRegexMatch "^[a-zA+$" "Hello" }}`, "", nil}, "error parsing regexp: missing closing ]: `[a-zA+$`"}, + } + + runMustTestCases(t, tests) +} + +func TestMustRegexSplit(t *testing.T) { + var tests = mustTestCases{ + {testCase{"TestMustRegexSplitWithoutLimit", `{{ mustRegexSplit "a" "banana" -1 }}`, "[b n n ]", nil}, ""}, + {testCase{"TestMustRegexSplitZeroLimit", `{{ mustRegexSplit "a" "banana" 0 }}`, "[]", nil}, ""}, + {testCase{"TestMustRegexSplitOneLimit", `{{ mustRegexSplit "a" "banana" 1 }}`, "[banana]", nil}, ""}, + {testCase{"TestMustRegexSplitTwoLimit", `{{ mustRegexSplit "a" "banana" 2 }}`, "[b nana]", nil}, ""}, + {testCase{"TestMustRegexSplitRepetitionLimit", `{{ mustRegexSplit "a+" "banana" 1 }}`, "[banana]", nil}, ""}, + {testCase{"TestMustRegexSplitInvalidPattern", `{{ mustRegexSplit "+" "banana" 0 }}`, "", nil}, "error parsing regexp: missing argument to repetition operator: `+`"}, + } + + runMustTestCases(t, tests) +} + +func TestMustRegexReplaceAll(t *testing.T) { + var tests = mustTestCases{ + {testCase{"TestMustRegexReplaceAllValid", `{{ mustRegexReplaceAll "a(x*)b" "-ab-axxb-" "T" }}`, "-T-T-", nil}, ""}, + {testCase{"TestMustRegexReplaceAllWithDollarSign", `{{ mustRegexReplaceAll "a(x*)b" "-ab-axxb-" "$1" }}`, "--xx-", nil}, ""}, + {testCase{"TestMustRegexReplaceAllWithDollarSignAndLetter", `{{ mustRegexReplaceAll "a(x*)b" "-ab-axxb-" "$1W" }}`, "---", nil}, ""}, + {testCase{"TestMustRegexReplaceAllWithDollarSignAndCurlyBraces", `{{ mustRegexReplaceAll "a(x*)b" "-ab-axxb-" "${1}W" }}`, "-W-xxW-", nil}, ""}, + {testCase{"TestMustRegexReplaceAllWithInvalidPattern", `{{ mustRegexReplaceAll "a(x*}" "-ab-axxb-" "T" }}`, "", nil}, "error parsing regexp: missing closing ): `a(x*}`"}, + } + + runMustTestCases(t, tests) +} + +func TestMustRegexReplaceAllLiteral(t *testing.T) { + var tests = mustTestCases{ + {testCase{"TestMustRegexReplaceAllLiteralValid", `{{ mustRegexReplaceAllLiteral "a(x*)b" "-ab-axxb-" "T" }}`, "-T-T-", nil}, ""}, + {testCase{"TestMustRegexReplaceAllLiteralWithDollarSign", `{{ mustRegexReplaceAllLiteral "a(x*)b" "-ab-axxb-" "$1" }}`, "-$1-$1-", nil}, ""}, + {testCase{"TestMustRegexReplaceAllLiteralWithDollarSignAndLetter", `{{ mustRegexReplaceAllLiteral "a(x*)b" "-ab-axxb-" "$1W" }}`, "-$1W-$1W-", nil}, ""}, + {testCase{"TestMustRegexReplaceAllLiteralWithDollarSignAndCurlyBraces", `{{ mustRegexReplaceAllLiteral "a(x*)b" "-ab-axxb-" "${1}W" }}`, "-${1}W-${1}W-", nil}, ""}, + {testCase{"TestMustRegexReplaceAllLiteralWithInvalidPattern", `{{ mustRegexReplaceAllLiteral "a(x*}" "-ab-axxb-" "T" }}`, "", nil}, "error parsing regexp: missing closing ): `a(x*}`"}, + } + + runMustTestCases(t, tests) +} diff --git a/semver.go b/semver.go deleted file mode 100644 index 97245ce..0000000 --- a/semver.go +++ /dev/null @@ -1,23 +0,0 @@ -package sprout - -import ( - sv2 "github.com/Masterminds/semver/v3" -) - -func semverCompare(constraint, version string) (bool, error) { - c, err := sv2.NewConstraint(constraint) - if err != nil { - return false, err - } - - v, err := sv2.NewVersion(version) - if err != nil { - return false, err - } - - return c.Check(v), nil -} - -func semver(version string) (*sv2.Version, error) { - return sv2.NewVersion(version) -} diff --git a/semver_test.go b/semver_test.go deleted file mode 100644 index d2f2245..0000000 --- a/semver_test.go +++ /dev/null @@ -1,31 +0,0 @@ -package sprout - -import ( - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestSemverCompare(t *testing.T) { - tests := map[string]string{ - `{{ semverCompare "1.2.3" "1.2.3" }}`: `true`, - `{{ semverCompare "^1.2.0" "1.2.3" }}`: `true`, - `{{ semverCompare "^1.2.0" "2.2.3" }}`: `false`, - } - for tpl, expect := range tests { - assert.NoError(t, runt(tpl, expect)) - } -} - -func TestSemver(t *testing.T) { - tests := map[string]string{ - `{{ $s := semver "1.2.3-beta.1+c0ff33" }}{{ $s.Prerelease }}`: "beta.1", - `{{ $s := semver "1.2.3-beta.1+c0ff33" }}{{ $s.Major}}`: "1", - `{{ semver "1.2.3" | (semver "1.2.3").Compare }}`: `0`, - `{{ semver "1.2.3" | (semver "1.3.3").Compare }}`: `1`, - `{{ semver "1.4.3" | (semver "1.2.3").Compare }}`: `-1`, - } - for tpl, expect := range tests { - assert.NoError(t, runt(tpl, expect)) - } -} diff --git a/slices_functions.go b/slices_functions.go new file mode 100644 index 0000000..487e48b --- /dev/null +++ b/slices_functions.go @@ -0,0 +1,975 @@ +package sprout + +import ( + "fmt" + "math" + "reflect" + "sort" + "strings" +) + +// List creates a list from the provided elements. +// +// Parameters: +// +// values ...any - the elements to include in the list. +// +// Returns: +// +// []any - the created list containing the provided elements. +// +// Example: +// +// {{ 1, 2, 3 | list }} // Output: [1, 2, 3] +func (fh *FunctionHandler) List(values ...any) []any { + return values +} + +// Append adds an element to the end of the list. +// +// Parameters: +// +// list any - the original list to append to. +// v any - the element to append. +// +// Returns: +// +// []any - the new list with the element appended. +// +// Example: +// +// {{ append ["a", "b"], "c" }} // Output: ["a", "b", "c"] +func (fh *FunctionHandler) Append(list any, v any) []any { + result, err := fh.MustAppend(list, v) + if err != nil { + return []any{} + // panic(err) + } + + return result +} + +// Prepend adds an element to the beginning of the list. +// +// Parameters: +// +// list any - the original list to prepend to. +// v any - the element to prepend. +// +// Returns: +// +// []any - the new list with the element prepended. +// +// Example: +// +// {{ prepend ["b", "c"], "a" }} // Output: ["a", "b", "c"] +func (fh *FunctionHandler) Prepend(list any, v any) []any { + result, err := fh.MustPrepend(list, v) + if err != nil { + return []any{} + // panic(err) + } + + return result +} + +// Concat merges multiple lists into a single list. +// +// Parameters: +// +// lists ...any - the lists to concatenate. +// +// Returns: +// +// any - a single concatenated list containing elements from all provided lists. +// +// Example: +// +// {{ ["c", "d"] | concat ["a", "b"] }} // Output: ["a", "b", "c", "d"] +func (fh *FunctionHandler) Concat(lists ...any) any { + var res []any + for _, list := range lists { + if list == nil { + continue + } + + tp := reflect.TypeOf(list).Kind() + switch tp { + case reflect.Slice, reflect.Array: + valueOfList := reflect.ValueOf(list) + for i := 0; i < valueOfList.Len(); i++ { + res = append(res, valueOfList.Index(i).Interface()) + } + default: + continue + // panic(fmt.Sprintf("cannot concat type %s as list", tp)) + } + } + return res +} + +// Chunk divides a list into chunks of specified size. +// +// Parameters: +// +// size int - the size of each chunk. +// list any - the list to divide. +// +// Returns: +// +// [][]any - a list of chunks. +// +// Example: +// +// {{ chunk 2, ["a", "b", "c", "d"] }} // Output: [["a", "b"], ["c", "d"]] +func (fh *FunctionHandler) Chunk(size int, list any) [][]any { + result, err := fh.MustChunk(size, list) + if err != nil { + return [][]any{} + // panic(err) + } + + return result +} + +// Uniq removes duplicate elements from a list. +// +// Parameters: +// +// list any - the list from which to remove duplicates. +// +// Returns: +// +// []any - a list containing only unique elements. +// +// Example: +// +// {{ ["a", "b", "a", "c"] | uniq }} // Output: ["a", "b", "c"] +func (fh *FunctionHandler) Uniq(list any) []any { + result, err := fh.MustUniq(list) + if err != nil { + return []any{} + // panic(err) + } + + return result +} + +// Compact removes nil and zero-value elements from a list. +// +// Parameters: +// +// list any - the list to compact. +// +// Returns: +// +// []any - the list without nil or zero-value elements. +// +// Example: +// +// {{ [0, 1, nil, 2, "", 3] | compact }} // Output: [1, 2, 3] +func (fh *FunctionHandler) Compact(list any) []any { + result, err := fh.MustCompact(list) + if err != nil { + return []any{} + // panic(err) + } + + return result +} + +// Slice extracts a slice from a list between two indices. +// +// Parameters: +// +// list any - the list to slice. +// indices ...any - the start and optional end indices; if end is omitted, +// +// slices to the end. +// +// Returns: +// +// any - the sliced part of the list. +// +// Example: +// +// {{ slice [1, 2, 3, 4, 5], 1, 3 }} // Output: [2, 3] +func (fh *FunctionHandler) Slice(list any, indices ...any) any { + result, err := fh.MustSlice(list, indices...) + if err != nil { + return []any{} + // panic(err) + } + + return result +} + +// Has checks if the specified element is present in the collection. +// +// Parameters: +// +// element any - the element to search for. +// list any - the collection to search. +// +// Returns: +// +// bool - true if the element is found, otherwise false. +// +// Example: +// +// {{ ["value", "other"] | has "value" }} // Output: true +func (fh *FunctionHandler) Has(element any, list any) bool { + result, _ := fh.MustHas(element, list) + return result +} + +// Without returns a new list excluding specified elements. +// +// Parameters: +// +// list any - the original list. +// omit ...any - elements to exclude from the new list. +// +// Returns: +// +// []any - the list excluding the specified elements. +// +// Example: +// +// {{ without [1, 2, 3, 4], 2, 4 }} // Output: [1, 3] +func (fh *FunctionHandler) Without(list any, omit ...any) []any { + result, err := fh.MustWithout(list, omit...) + if err != nil { + return []any{} + // panic(err) + } + + return result +} + +// Rest returns all elements of a list except the first. +// +// Parameters: +// +// list any - the list to process. +// +// Returns: +// +// []any - the list without the first element. +// +// Example: +// +// {{ [1, 2, 3, 4] | rest }} // Output: [2, 3, 4] +func (fh *FunctionHandler) Rest(list any) []any { + result, err := fh.MustRest(list) + if err != nil { + return []any{} + // panic(err) + } + + return result +} + +// Initial returns all elements of a list except the last. +// +// Parameters: +// +// list any - the list to process. +// +// Returns: +// +// []any - the list without the last element. +// +// Example: +// +// {{ [1, 2, 3, 4] | initial }} // Output: [1, 2, 3] +func (fh *FunctionHandler) Initial(list any) []any { + result, err := fh.MustInitial(list) + if err != nil { + return []any{} + // panic(err) + } + + return result +} + +// First returns the first element of a list. +// +// Parameters: +// +// list any - the list from which to take the first element. +// +// Returns: +// +// any - the first element of the list. +// +// Example: +// +// {{ [1, 2, 3, 4] | first }} // Output: 1 +func (fh *FunctionHandler) First(list any) any { + result, err := fh.MustFirst(list) + if err != nil { + return nil + // panic(err) + } + + return result +} + +// Last returns the last element of a list. +// +// Parameters: +// +// list any - the list from which to take the last element. +// +// Returns: +// +// any - the last element of the list. +// +// Example: +// +// {{ [1, 2, 3, 4] | last }} // Output: 4 +func (fh *FunctionHandler) Last(list any) any { + result, err := fh.MustLast(list) + if err != nil { + return nil + // panic(err) + } + + return result +} + +// Reverse returns a new list with the elements in reverse order. +// +// Parameters: +// +// list any - the list to reverse. +// +// Returns: +// +// []any - the list in reverse order. +// +// Example: +// +// {{ [1, 2, 3, 4] | reverse }} // Output: [4, 3, 2, 1] +func (fh *FunctionHandler) Reverse(list any) []any { + result, err := fh.MustReverse(list) + if err != nil { + return []any{} + // panic(err) + } + + return result +} + +// SortAlpha sorts a list of strings in alphabetical order. +// +// Parameters: +// +// list any - the list of strings to sort. +// +// Returns: +// +// []string - the sorted list. +// +// Example: +// +// {{ ["d", "b", "a", "c"] | sortAlpha }} // Output: ["a", "b", "c", "d"] +func (fh *FunctionHandler) SortAlpha(list any) []string { + kind := reflect.Indirect(reflect.ValueOf(list)).Kind() + switch kind { + case reflect.Slice, reflect.Array: + sortedList := sort.StringSlice(fh.StrSlice(list)) + sortedList.Sort() + return sortedList + } + return []string{fh.ToString(list)} +} + +// SplitList divides a string into a slice of substrings separated by the +// specified separator. +// +// ! FUTURE: Rename this function to be more explicit +// +// Parameters: +// +// sep string - the delimiter used to split the string. +// str string - the string to split. +// +// Returns: +// +// []string - a slice containing the substrings obtained from splitting the input string. +// +// Example: +// +// {{ ", ", "one, two, three" | splitList }} // Output: ["one", "two", "three"] +func (fh *FunctionHandler) SplitList(sep string, str string) []string { + return strings.Split(str, sep) +} + +func (fh *FunctionHandler) StrSlice(value any) []string { + if value == nil { + return []string{} + } + + // Handle []string type efficiently without reflection. + if strs, ok := value.([]string); ok { + return strs + } + + // For slices of any, convert each element to a string, skipping nil values. + if interfaces, ok := value.([]any); ok { + var result []string + for _, s := range interfaces { + if s != nil { + result = append(result, fh.ToString(s)) + } + } + return result + } + + // Use reflection for other slice types to convert them to []string. + reflectedValue := reflect.ValueOf(value) + if reflectedValue.Kind() == reflect.Slice || reflectedValue.Kind() == reflect.Array { + var result []string + for i := 0; i < reflectedValue.Len(); i++ { + value := reflectedValue.Index(i).Interface() + if value != nil { + result = append(result, fh.ToString(value)) + } + } + return result + } + + // If it's not a slice, array, or nil, return a slice with the string representation of v. + return []string{fh.ToString(value)} +} + +// MustAppend appends an element to a slice or array, returning an error if the +// operation isn't applicable. +// +// Parameters: +// +// list any - the original list to append to. +// v any - the element to append. +// +// Returns: +// +// []any - the new list with the element appended. +// error - error if the list is nil or not a slice/array. +// +// Example: +// +// {{ mustAppend ["a", "b"], "c" }} // Output: ["a", "b", "c"], nil +func (fh *FunctionHandler) MustAppend(list any, v any) ([]any, error) { + if list == nil { + return nil, fmt.Errorf("cannot append to nil") + } + + tp := reflect.TypeOf(list).Kind() + switch tp { + case reflect.Slice, reflect.Array: + valueOfList := reflect.ValueOf(list) + + length := valueOfList.Len() + result := make([]any, length) + for i := 0; i < length; i++ { + result[i] = valueOfList.Index(i).Interface() + } + + return append(result, v), nil + + default: + return nil, fmt.Errorf("cannot append on type %s", tp) + } +} + +// MustPrepend prepends an element to a slice or array, returning an error if +// the operation isn't applicable. +// +// Parameters: +// +// list any - the original list to prepend to. +// v any - the element to prepend. +// +// Returns: +// +// []any - the new list with the element prepended. +// error - error if the list is nil or not a slice/array. +// +// Example: +// +// {{ mustPrepend ["b", "c"], "a" }} // Output: ["a", "b", "c"], nil +func (fh *FunctionHandler) MustPrepend(list any, v any) ([]any, error) { + if list == nil { + return nil, fmt.Errorf("cannot prepend to nil") + } + + tp := reflect.TypeOf(list).Kind() + switch tp { + case reflect.Slice, reflect.Array: + valueOfList := reflect.ValueOf(list) + + length := valueOfList.Len() + result := make([]any, length) + for i := 0; i < length; i++ { + result[i] = valueOfList.Index(i).Interface() + } + + return append([]any{v}, result...), nil + + default: + return nil, fmt.Errorf("cannot prepend on type %s", tp) + } +} + +// MustChunk divides a list into chunks of specified size, returning an error +// if the list is nil or not a slice/array. +// +// Parameters: +// +// size int - the maximum size of each chunk. +// list any - the list to chunk. +// +// Returns: +// +// [][]any - a list of chunks. +// error - error if the list is nil or not a slice/array. +// +// Example: +// +// {{ ["a", "b", "c", "d"] | mustChunk 2 }} // Output: [["a", "b"], ["c", "d"]], nil +func (fh *FunctionHandler) MustChunk(size int, list any) ([][]any, error) { + if list == nil { + return nil, fmt.Errorf("cannot chunk nil") + } + + tp := reflect.TypeOf(list).Kind() + switch tp { + case reflect.Slice, reflect.Array: + valueOfList := reflect.ValueOf(list) + + length := valueOfList.Len() + + chunkCount := int(math.Floor(float64(length-1)/float64(size)) + 1) + result := make([][]any, chunkCount) + + for i := 0; i < chunkCount; i++ { + chunkLength := size + if i == chunkCount-1 { + chunkLength = int(math.Floor(math.Mod(float64(length), float64(size)))) + if chunkLength == 0 { + chunkLength = size + } + } + + result[i] = make([]any, chunkLength) + + for j := 0; j < chunkLength; j++ { + ix := i*size + j + result[i][j] = valueOfList.Index(ix).Interface() + } + } + + return result, nil + + default: + return nil, fmt.Errorf("cannot chunk type %s", tp) + } +} + +// MustUniq returns a new slice containing unique elements of the given list, +// preserving order. +// +// Parameters: +// +// list any - the list from which to remove duplicates. +// +// Returns: +// +// []any - a list containing only the unique elements. +// error - error if the list is nil or not a slice/array. +// +// Example: +// +// {{ ["a", "b", "a", "c"] | mustUniq }} // Output: ["a", "b", "c"], nil +func (fh *FunctionHandler) MustUniq(list any) ([]any, error) { + if list == nil { + return nil, fmt.Errorf("cannot uniq nil") + } + + tp := reflect.TypeOf(list).Kind() + switch tp { + case reflect.Slice, reflect.Array: + valueOfList := reflect.ValueOf(list) + + length := valueOfList.Len() + result := []any{} + var item any + for i := 0; i < length; i++ { + item = valueOfList.Index(i).Interface() + if !fh.InList(result, item) { + result = append(result, item) + } + } + + return result, nil + default: + return nil, fmt.Errorf("cannot find uniq on type %s", tp) + } +} + +// MustCompact removes nil or zero-value elements from a list. +// +// Parameters: +// +// list any - the list to compact. +// +// Returns: +// +// []any - the list without nil or zero-value elements. +// error - error if the list is nil or not a slice/array. +// +// Example: +// +// {{ [0, 1, nil, 2, "", 3] | mustCompact }} // Output: [1, 2, 3], nil +func (fh *FunctionHandler) MustCompact(list any) ([]any, error) { + if list == nil { + return nil, fmt.Errorf("cannot compact nil") + } + + tp := reflect.TypeOf(list).Kind() + switch tp { + case reflect.Slice, reflect.Array: + valueOfList := reflect.ValueOf(list) + + length := valueOfList.Len() + result := []any{} + var item any + for i := 0; i < length; i++ { + item = valueOfList.Index(i).Interface() + if !fh.Empty(item) { + result = append(result, item) + } + } + + return result, nil + default: + return nil, fmt.Errorf("cannot compact on type %s", tp) + } +} + +// MustSlice extracts a slice from a list between two indices. +// +// Parameters: +// +// list any - the list to slice. +// indices ...any - the start and optional end indices; if end is omitted, +// +// slices to the end. +// +// Returns: +// +// any - the sliced part of the list. +// error - error if the list is nil or not a slice/array. +// +// Example: +// +// {{ mustSlice [1, 2, 3, 4, 5], 1, 3 }} // Output: [2, 3], nil +func (fh *FunctionHandler) MustSlice(list any, indices ...any) (any, error) { + if list == nil { + return nil, fmt.Errorf("cannot slice nil") + } + + tp := reflect.TypeOf(list).Kind() + switch tp { + case reflect.Slice, reflect.Array: + valueOfList := reflect.ValueOf(list) + + length := valueOfList.Len() + if length == 0 { + return nil, nil + } + + var start, end int + if len(indices) > 0 { + start = fh.ToInt(indices[0]) + } + if len(indices) < 2 { + end = length + } else { + end = fh.ToInt(indices[1]) + } + + return valueOfList.Slice(start, end).Interface(), nil + default: + return nil, fmt.Errorf("list should be type of slice or array but %s", tp) + } +} + +// MustHas checks if a specified element is present in a collection and handles +// type errors. +// +// Parameters: +// +// element any - the element to search for in the collection. +// list any - the collection in which to search for the element. +// +// Returns: +// +// bool - true if the element is found, otherwise false. +// error - error if the list is not a type that can be searched (not a slice or array). +// +// Example: +// +// {{ [1, 2, 3, 4] | mustHas 3 }} // Output: true, nil +func (fh *FunctionHandler) MustHas(element any, list any) (bool, error) { + if list == nil { + return false, nil + } + typeOfList := reflect.TypeOf(list).Kind() + switch typeOfList { + case reflect.Slice, reflect.Array: + valueOfList := reflect.ValueOf(list) + var item any + length := valueOfList.Len() + for i := 0; i < length; i++ { + item = valueOfList.Index(i).Interface() + if reflect.DeepEqual(element, item) { + return true, nil + } + } + + return false, nil + default: + return false, fmt.Errorf("cannot find has on type %s", typeOfList) + } +} + +// MustWithout returns a new list excluding specified elements. +// +// Parameters: +// +// list any - the original list. +// omit ...any - elements to exclude from the new list. +// +// Returns: +// +// []any - the list excluding the specified elements. +// error - error if the list is nil or not a slice/array. +// +// Example: +// +// {{ mustWithout [1, 2, 3, 4], 2, 4 }} // Output: [1, 3], nil +func (fh *FunctionHandler) MustWithout(list any, omit ...any) ([]any, error) { + if list == nil { + return nil, fmt.Errorf("cannot without nil") + } + + tp := reflect.TypeOf(list).Kind() + switch tp { + case reflect.Slice, reflect.Array: + valueOfList := reflect.ValueOf(list) + + length := valueOfList.Len() + result := []any{} + var item any + for i := 0; i < length; i++ { + item = valueOfList.Index(i).Interface() + if !fh.InList(omit, item) { + result = append(result, item) + } + } + + return result, nil + default: + return nil, fmt.Errorf("cannot find without on type %s", tp) + } +} + +// MustRest returns all elements of a list except the first. +// +// Parameters: +// +// list any - the list to process. +// +// Returns: +// +// []any - the list without the first element. +// error - error if the list is nil or not a slice/array. +// +// Example: +// +// {{ [1, 2, 3, 4] | mustRest }} // Output: [2, 3, 4], nil +func (fh *FunctionHandler) MustRest(list any) ([]any, error) { + if list == nil { + return nil, fmt.Errorf("cannot rest nil") + } + + tp := reflect.TypeOf(list).Kind() + switch tp { + case reflect.Slice, reflect.Array: + valueOfList := reflect.ValueOf(list) + + length := valueOfList.Len() + if length == 0 { + return nil, nil + } + + result := make([]any, length-1) + for i := 1; i < length; i++ { + result[i-1] = valueOfList.Index(i).Interface() + } + + return result, nil + default: + return nil, fmt.Errorf("cannot find rest on type %s", tp) + } +} + +// MustInitial returns all elements of a list except the last. +// +// Parameters: +// +// list any - the list to process. +// +// Returns: +// +// []any - the list without the last element. +// error - error if the list is nil or not a slice/array. +// +// Example: +// +// {{ [1, 2, 3, 4] | mustInitial }} // Output: [1, 2, 3], nil +func (fh *FunctionHandler) MustInitial(list any) ([]any, error) { + if list == nil { + return nil, fmt.Errorf("cannot initial nil") + } + + tp := reflect.TypeOf(list).Kind() + switch tp { + case reflect.Slice, reflect.Array: + valueOfList := reflect.ValueOf(list) + + length := valueOfList.Len() + if length == 0 { + return nil, nil + } + + result := make([]any, length-1) + for i := 0; i < length-1; i++ { + result[i] = valueOfList.Index(i).Interface() + } + + return result, nil + default: + return nil, fmt.Errorf("cannot find initial on type %s", tp) + } +} + +// MustFirst returns the first element of a list. +// +// Parameters: +// +// list any - the list from which to take the first element. +// +// Returns: +// +// any - the first element of the list. +// error - error if the list is nil, empty, or not a slice/array. +// +// Example: +// +// {{ [1, 2, 3, 4] | mustFirst }} // Output: 1, nil +func (fh *FunctionHandler) MustFirst(list any) (any, error) { + if list == nil { + return nil, fmt.Errorf("cannot first nil") + } + + tp := reflect.TypeOf(list).Kind() + switch tp { + case reflect.Slice, reflect.Array: + valueOfList := reflect.ValueOf(list) + + length := valueOfList.Len() + if length == 0 { + return nil, nil + } + + return valueOfList.Index(0).Interface(), nil + default: + return nil, fmt.Errorf("cannot find first on type %s", tp) + } +} + +// MustLast returns the last element of a list. +// +// Parameters: +// +// list any - the list from which to take the last element. +// +// Returns: +// +// any - the last element of the list. +// error - error if the list is nil, empty, or not a slice/array. +// +// Example: +// +// {{ [1, 2, 3, 4] | mustLast }} // Output: 4, nil +func (fh *FunctionHandler) MustLast(list any) (any, error) { + if list == nil { + return nil, fmt.Errorf("cannot last nil") + } + + tp := reflect.TypeOf(list).Kind() + switch tp { + case reflect.Slice, reflect.Array: + valueOfList := reflect.ValueOf(list) + + length := valueOfList.Len() + if length == 0 { + return nil, nil + } + + return valueOfList.Index(length - 1).Interface(), nil + default: + return nil, fmt.Errorf("cannot find last on type %s", tp) + } +} + +// MustReverse returns a new list with the elements in reverse order. +// +// Parameters: +// +// list any - the list to reverse. +// +// Returns: +// +// []any - the list in reverse order. +// error - error if the list is nil or not a slice/array. +// +// Example: +// +// {{ [1, 2, 3, 4] | mustReverse }} // Output: [4, 3, 2, 1], nil +func (fh *FunctionHandler) MustReverse(list any) ([]any, error) { + if list == nil { + return nil, fmt.Errorf("cannot reverse nil") + } + + tp := reflect.TypeOf(list).Kind() + switch tp { + case reflect.Slice, reflect.Array: + valueOfList := reflect.ValueOf(list) + + length := valueOfList.Len() + // We do not sort in place because the incoming array should not be altered. + nl := make([]any, length) + for i := 0; i < length; i++ { + nl[length-i-1] = valueOfList.Index(i).Interface() + } + + return nl, nil + default: + return nil, fmt.Errorf("cannot find reverse on type %s", tp) + } +} diff --git a/slices_functions_test.go b/slices_functions_test.go new file mode 100644 index 0000000..23fc840 --- /dev/null +++ b/slices_functions_test.go @@ -0,0 +1,377 @@ +package sprout + +import "testing" + +func TestList(t *testing.T) { + var tests = testCases{ + {"", `{{ list }}`, "[]", nil}, + {"", `{{ .V | list "ab" true 4 5 }}`, "[ab true 4 5 ]", map[string]any{"V": nil}}, + } + + runTestCases(t, tests) +} + +func TestAppend(t *testing.T) { + var tests = testCases{ + {"", `{{ append .V "a" }}`, "[a]", map[string]any{"V": []string{}}}, + {"", `{{ append .V "a" }}`, "[a]", map[string]any{"V": []string(nil)}}, + {"", `{{ append .V "a" }}`, "[]", map[string]any{"V": nil}}, + {"", `{{ append .V "a" }}`, "[x a]", map[string]any{"V": []string{"x"}}}, + } + + runTestCases(t, tests) +} + +func TestPrepend(t *testing.T) { + var tests = testCases{ + {"", `{{ prepend .V "a" }}`, "[a]", map[string]any{"V": []string{}}}, + {"", `{{ prepend .V "a" }}`, "[a]", map[string]any{"V": []string(nil)}}, + {"", `{{ prepend .V "a" }}`, "[]", map[string]any{"V": nil}}, + {"", `{{ prepend .V "a" }}`, "[a x]", map[string]any{"V": []string{"x"}}}, + } + + runTestCases(t, tests) +} + +func TestConcat(t *testing.T) { + var tests = testCases{ + {"", `{{ concat .V (list 1 2 3) }}`, "[a 1 2 3]", map[string]any{"V": []string{"a"}}}, + {"", `{{ list 4 5 | concat (list 1 2 3) }}`, "[1 2 3 4 5]", nil}, + {"", `{{ concat .V (list 1 2 3) }}`, "[1 2 3]", map[string]any{"V": nil}}, + {"", `{{ concat .V (list 1 2 3) }}`, "[1 2 3]", map[string]any{"V": "a"}}, + {"", `{{ concat .V (list 1 2 3) }}`, "[x 1 2 3]", map[string]any{"V": []string{"x"}}}, + {"", `{{ concat .V (list 1 2 3) }}`, "[[x] 1 2 3]", map[string]any{"V": [][]string{{"x"}}}}, + } + + runTestCases(t, tests) +} + +func TestChunk(t *testing.T) { + var tests = testCases{ + {"", `{{ chunk 2 .V }}`, "[[a b] [c d] [e]]", map[string]any{"V": []string{"a", "b", "c", "d", "e"}}}, + {"", `{{ chunk 2 .V }}`, "[[a b] [c d]]", map[string]any{"V": []string{"a", "b", "c", "d"}}}, + {"", `{{ chunk 2 .V }}`, "[[a b]]", map[string]any{"V": []string{"a", "b"}}}, + {"", `{{ chunk 2 .V }}`, "[]", map[string]any{"V": []string{}}}, + {"", `{{ chunk 2 .V }}`, "[]", map[string]any{"V": nil}}, + } + + runTestCases(t, tests) +} + +func TestUniq(t *testing.T) { + var tests = testCases{ + {"", `{{ uniq .V }}`, "[a b c]", map[string]any{"V": []string{"a", "b", "c", "a", "b", "c"}}}, + {"", `{{ uniq .V }}`, "[a b c]", map[string]any{"V": []string{"a", "b", "c"}}}, + {"", `{{ uniq .V }}`, "[a]", map[string]any{"V": []string{"a", "a", "a"}}}, + {"", `{{ uniq .V }}`, "[a]", map[string]any{"V": []string{"a"}}}, + {"", `{{ uniq .V }}`, "[]", map[string]any{"V": []string{}}}, + {"", `{{ uniq .V }}`, "[]", map[string]any{"V": nil}}, + } + + runTestCases(t, tests) +} + +func TestCompact(t *testing.T) { + var tests = testCases{ + {"", `{{ compact .V }}`, "[a b c]", map[string]any{"V": []string{"a", "", "b", "", "c"}}}, + {"", `{{ compact .V }}`, "[a a]", map[string]any{"V": []string{"a", "", "a"}}}, + {"", `{{ compact .V }}`, "[a]", map[string]any{"V": []string{"a"}}}, + {"", `{{ compact .V }}`, "[]", map[string]any{"V": []string{}}}, + {"", `{{ compact .V }}`, "[]", map[string]any{"V": nil}}, + {"", `{{ list 1 0 "" "hello" | compact }}`, "[1 hello]", nil}, + {"", `{{ list "" "" | compact }}`, "[]", nil}, + {"", `{{ list | compact }}`, "[]", nil}, + } + + runTestCases(t, tests) +} + +func TestSlice(t *testing.T) { + var tests = testCases{ + {"", `{{ slice .V }}`, "[a b c d e]", map[string]any{"V": []string{"a", "b", "c", "d", "e"}}}, + {"", `{{ slice .V 1 }}`, "[b c d e]", map[string]any{"V": []string{"a", "b", "c", "d", "e"}}}, + {"", `{{ slice .V 1 3 }}`, "[b c]", map[string]any{"V": []string{"a", "b", "c", "d", "e"}}}, + {"", `{{ slice .V 0 1 }}`, "[a]", map[string]any{"V": []string{"a", "b", "c", "d", "e"}}}, + {"", `{{ slice .V 0 1 }}`, "[a]", map[string]any{"V": []string{"a"}}}, + {"", `{{ slice .V 0 1 }}`, "", map[string]any{"V": []string{}}}, + {"", `{{ slice .V 0 1 }}`, "[]", map[string]any{"V": nil}}, + } + + runTestCases(t, tests) +} + +func TestHas(t *testing.T) { + var tests = testCases{ + {"", `{{ .V | has "a" }}`, "true", map[string]any{"V": []string{"a", "b", "c", "d", "e"}}}, + {"", `{{ .V | has "a" }}`, "false", map[string]any{"V": []string{"b", "c", "d", "e"}}}, + {"", `{{ .V | has 1 }}`, "true", map[string]any{"V": []any{"b", 1, nil, struct{}{}}}}, + {"", `{{ .V | has .Nil }}`, "true", map[string]any{"Nil": nil, "V": []any{"b", 1, nil, struct{}{}}}}, + {"", `{{ .V | has "nope" }}`, "false", map[string]any{"V": []any{"b", 1, nil, struct{}{}}}}, + {"", `{{ .V | has 1 }}`, "true", map[string]any{"V": []int{1}}}, + {"", `{{ .V | has "a" }}`, "false", map[string]any{"V": nil}}, + {"", `{{ .V | has "a" }}`, "false", map[string]any{"V": 1}}, + } + + runTestCases(t, tests) +} + +func TestWithout(t *testing.T) { + var tests = testCases{ + {"", `{{ without .V "a" }}`, "[b c d e]", map[string]any{"V": []string{"a", "b", "c", "d", "e"}}}, + {"", `{{ without .V "a" }}`, "[b c d e]", map[string]any{"V": []string{"b", "c", "d", "e"}}}, + {"", `{{ without .V "a" }}`, "[b c d e]", map[string]any{"V": []string{"b", "c", "d", "e", "a"}}}, + {"", `{{ without .V "a" }}`, "[]", map[string]any{"V": []string{"a"}}}, + {"", `{{ without .V "a" }}`, "[]", map[string]any{"V": []string{}}}, + {"", `{{ without .V "a" }}`, "[]", map[string]any{"V": nil}}, + } + + runTestCases(t, tests) +} + +func TestRest(t *testing.T) { + var tests = testCases{ + {"", `{{ rest .V }}`, "[b c d e]", map[string]any{"V": []string{"a", "b", "c", "d", "e"}}}, + {"", `{{ rest .V }}`, "[c d e]", map[string]any{"V": []string{"b", "c", "d", "e"}}}, + {"", `{{ rest .V }}`, "[c d e a]", map[string]any{"V": []string{"b", "c", "d", "e", "a"}}}, + {"", `{{ rest .V }}`, "[]", map[string]any{"V": []string{"a"}}}, + {"", `{{ rest .V }}`, "[]", map[string]any{"V": []string{}}}, + {"", `{{ rest .V }}`, "[]", map[string]any{"V": nil}}, + } + + runTestCases(t, tests) +} + +func TestInitial(t *testing.T) { + var tests = testCases{ + {"", `{{ initial .V }}`, "[a b c d]", map[string]any{"V": []string{"a", "b", "c", "d", "e"}}}, + {"", `{{ initial .V }}`, "[a b c]", map[string]any{"V": []string{"a", "b", "c", "d"}}}, + {"", `{{ initial .V }}`, "[]", map[string]any{"V": []string{"a"}}}, + {"", `{{ initial .V }}`, "[]", map[string]any{"V": []string{}}}, + {"", `{{ initial .V }}`, "[]", map[string]any{"V": nil}}, + } + + runTestCases(t, tests) +} + +func TestFirst(t *testing.T) { + var tests = testCases{ + {"", `{{ first .V }}`, "a", map[string]any{"V": []string{"a", "b", "c", "d", "e"}}}, + {"", `{{ first .V }}`, "", map[string]any{"V": []string{}}}, + {"", `{{ first .V }}`, "", map[string]any{"V": nil}}, + } + + runTestCases(t, tests) +} + +func TestLast(t *testing.T) { + var tests = testCases{ + {"", `{{ last .V }}`, "e", map[string]any{"V": []string{"a", "b", "c", "d", "e"}}}, + {"", `{{ last .V }}`, "", map[string]any{"V": []string{}}}, + {"", `{{ last .V }}`, "", map[string]any{"V": nil}}, + } + + runTestCases(t, tests) +} + +func TestReverse(t *testing.T) { + var tests = testCases{ + {"", `{{ reverse .V }}`, "[e d c b a]", map[string]any{"V": []string{"a", "b", "c", "d", "e"}}}, + {"", `{{ reverse .V }}`, "[a b c d e]", map[string]any{"V": []string{"e", "d", "c", "b", "a"}}}, + {"", `{{ reverse .V }}`, "[]", map[string]any{"V": []string{}}}, + {"", `{{ reverse .V }}`, "[]", map[string]any{"V": nil}}, + } + + runTestCases(t, tests) +} + +func TestSortAlpha(t *testing.T) { + var tests = testCases{ + {"", `{{ sortAlpha .V }}`, "[a b c d e]", map[string]any{"V": []string{"e", "d", "c", "b", "a"}}}, + {"", `{{ sortAlpha .V }}`, "[1 2 3 4 5 a]", map[string]any{"V": []any{5, 4, 3, 2, 1, "a"}}}, + {"", `{{ sortAlpha .V }}`, "[]", map[string]any{"V": []string{}}}, + {"", `{{ sortAlpha .V }}`, "[]", map[string]any{"V": nil}}, + } + + runTestCases(t, tests) +} + +func TestSplitList(t *testing.T) { + var tests = testCases{ + {"", `{{ .V | splitList "," }}`, "[a b c d e]", map[string]any{"V": "a,b,c,d,e"}}, + {"", `{{ .V | splitList "," }}`, "[a b c d e ]", map[string]any{"V": "a,b,c,d,e,"}}, + {"", `{{ .V | splitList "," }}`, "[ a b c d e]", map[string]any{"V": ",a,b,c,d,e"}}, + {"", `{{ .V | splitList "," }}`, "[ a b c d e ]", map[string]any{"V": ",a,b,c,d,e,"}}, + {"", `{{ .V | splitList "," }}`, "[]", map[string]any{"V": ""}}, + } + + runTestCases(t, tests) +} + +func TestStrSlice(t *testing.T) { + var tests = testCases{ + {"", `{{ strSlice .V }}`, "[a b c d e]", map[string]any{"V": []string{"a", "b", "c", "d", "e"}}}, + {"", `{{ strSlice .V }}`, "[5 4 3 2 1]", map[string]any{"V": []int{5, 4, 3, 2, 1}}}, + {"", `{{ strSlice .V }}`, "[5 a true 1]", map[string]any{"V": []any{5, "a", true, nil, 1}}}, + {"", `{{ strSlice .V }}`, "[]", map[string]any{"V": ""}}, + } + + runTestCases(t, tests) +} + +func TestMustAppend(t *testing.T) { + var tests = mustTestCases{ + {testCase{"", `{{ mustAppend .V "a" }}`, "[a]", map[string]any{"V": []string{}}}, ""}, + {testCase{"", `{{ mustAppend .V "a" }}`, "[a]", map[string]any{"V": []string(nil)}}, ""}, + {testCase{"", `{{ mustAppend .V "a" }}`, "[x a]", map[string]any{"V": []string{"x"}}}, ""}, + {testCase{"", `{{ mustAppend .V "a" }}`, "", map[string]any{"V": nil}}, "cannot append to nil"}, + {testCase{"", `{{ mustAppend .V "a" }}`, "", map[string]any{"V": 1}}, "cannot append on type int"}, + } + + runMustTestCases(t, tests) +} + +func TestMustPrepend(t *testing.T) { + var tests = mustTestCases{ + {testCase{"", `{{ mustPrepend .V "a" }}`, "[a]", map[string]any{"V": []string{}}}, ""}, + {testCase{"", `{{ mustPrepend .V "a" }}`, "[a]", map[string]any{"V": []string(nil)}}, ""}, + {testCase{"", `{{ mustPrepend .V "a" }}`, "[a x]", map[string]any{"V": []string{"x"}}}, ""}, + {testCase{"", `{{ mustPrepend .V "a" }}`, "", map[string]any{"V": nil}}, "cannot prepend to nil"}, + {testCase{"", `{{ mustPrepend .V "a" }}`, "", map[string]any{"V": 1}}, "cannot prepend on type int"}, + } + + runMustTestCases(t, tests) +} + +func TestMustChunk(t *testing.T) { + var tests = mustTestCases{ + {testCase{"", `{{ mustChunk 2 .V }}`, "[[a b] [c d] [e]]", map[string]any{"V": []string{"a", "b", "c", "d", "e"}}}, ""}, + {testCase{"", `{{ mustChunk 2 .V }}`, "[[a b] [c d]]", map[string]any{"V": []string{"a", "b", "c", "d"}}}, ""}, + {testCase{"", `{{ mustChunk 2 .V }}`, "[[a b]]", map[string]any{"V": []string{"a", "b"}}}, ""}, + {testCase{"", `{{ mustChunk 2 .V }}`, "", map[string]any{"V": nil}}, "cannot chunk nil"}, + {testCase{"", `{{ mustChunk 2 .V }}`, "", map[string]any{"V": 1}}, "cannot chunk type int"}, + } + + runMustTestCases(t, tests) +} + +func TestMustUniq(t *testing.T) { + var tests = mustTestCases{ + {testCase{"", `{{ mustUniq .V }}`, "[a b c]", map[string]any{"V": []string{"a", "b", "c", "a", "b", "c"}}}, ""}, + {testCase{"", `{{ mustUniq .V }}`, "[a b c]", map[string]any{"V": []string{"a", "b", "c"}}}, ""}, + {testCase{"", `{{ mustUniq .V }}`, "[a]", map[string]any{"V": []string{"a", "a", "a"}}}, ""}, + {testCase{"", `{{ mustUniq .V }}`, "[a]", map[string]any{"V": []string{"a"}}}, ""}, + {testCase{"", `{{ mustUniq .V }}`, "", map[string]any{"V": nil}}, "cannot uniq nil"}, + {testCase{"", `{{ mustUniq .V }}`, "", map[string]any{"V": 1}}, "cannot find uniq on type int"}, + } + + runMustTestCases(t, tests) +} + +func TestMustCompact(t *testing.T) { + var tests = mustTestCases{ + {testCase{"", `{{ mustCompact .V }}`, "[a b c]", map[string]any{"V": []string{"a", "", "b", "", "c"}}}, ""}, + {testCase{"", `{{ mustCompact .V }}`, "[a a]", map[string]any{"V": []string{"a", "", "a"}}}, ""}, + {testCase{"", `{{ mustCompact .V }}`, "[a]", map[string]any{"V": []string{"a"}}}, ""}, + {testCase{"", `{{ mustCompact .V }}`, "", map[string]any{"V": nil}}, "cannot compact nil"}, + {testCase{"", `{{ mustCompact .V }}`, "", map[string]any{"V": 1}}, "cannot compact on type int"}, + } + + runMustTestCases(t, tests) +} + +func TestMustSlice(t *testing.T) { + var tests = mustTestCases{ + {testCase{"", `{{ mustSlice .V }}`, "[a b c d e]", map[string]any{"V": []string{"a", "b", "c", "d", "e"}}}, ""}, + {testCase{"", `{{ mustSlice .V 1 }}`, "[b c d e]", map[string]any{"V": []string{"a", "b", "c", "d", "e"}}}, ""}, + {testCase{"", `{{ mustSlice .V 1 3 }}`, "[b c]", map[string]any{"V": []string{"a", "b", "c", "d", "e"}}}, ""}, + {testCase{"", `{{ mustSlice .V 0 1 }}`, "[a]", map[string]any{"V": []string{"a", "b", "c", "d", "e"}}}, ""}, + {testCase{"", `{{ mustSlice .V 0 1 }}`, "", map[string]any{"V": nil}}, "cannot slice nil"}, + {testCase{"", `{{ mustSlice .V 0 1 }}`, "", map[string]any{"V": 1}}, "list should be type of slice or array but int"}, + } + + runMustTestCases(t, tests) +} + +func TestMustHas(t *testing.T) { + var tests = mustTestCases{ + {testCase{"", `{{ .V | mustHas "a" }}`, "true", map[string]any{"V": []string{"a", "b", "c", "d", "e"}}}, ""}, + {testCase{"", `{{ .V | mustHas "a" }}`, "false", map[string]any{"V": []string{"b", "c", "d", "e"}}}, ""}, + {testCase{"", `{{ .V | mustHas 1 }}`, "true", map[string]any{"V": []any{"b", 1, nil, struct{}{}}}}, ""}, + {testCase{"", `{{ .V | mustHas .Nil }}`, "true", map[string]any{"Nil": nil, "V": []any{"b", 1, nil, struct{}{}}}}, ""}, + {testCase{"", `{{ .V | mustHas "nope" }}`, "false", map[string]any{"V": []any{"b", 1, nil, struct{}{}}}}, ""}, + {testCase{"", `{{ .V | mustHas 1 }}`, "true", map[string]any{"V": []int{1}}}, ""}, + {testCase{"", `{{ .V | mustHas "a" }}`, "false", map[string]any{"V": nil}}, ""}, + {testCase{"", `{{ .V | mustHas "a" }}`, "", map[string]any{"V": 1}}, "cannot find has on type int"}, + } + + runMustTestCases(t, tests) +} + +func TestMustWithout(t *testing.T) { + var tests = mustTestCases{ + {testCase{"", `{{ mustWithout .V "a" }}`, "[b c d e]", map[string]any{"V": []string{"a", "b", "c", "d", "e"}}}, ""}, + {testCase{"", `{{ mustWithout .V "a" }}`, "[b c d e]", map[string]any{"V": []string{"b", "c", "d", "e"}}}, ""}, + {testCase{"", `{{ mustWithout .V "a" }}`, "[b c d e]", map[string]any{"V": []string{"b", "c", "d", "e", "a"}}}, ""}, + {testCase{"", `{{ mustWithout .V "a" }}`, "[]", map[string]any{"V": []string{"a"}}}, ""}, + {testCase{"", `{{ mustWithout .V "a" }}`, "", map[string]any{"V": nil}}, "cannot without nil"}, + {testCase{"", `{{ mustWithout .V "a" }}`, "", map[string]any{"V": 1}}, "cannot find without on type int"}, + } + + runMustTestCases(t, tests) +} + +func TestMustRest(t *testing.T) { + var tests = mustTestCases{ + {testCase{"", `{{ mustRest .V }}`, "[b c d e]", map[string]any{"V": []string{"a", "b", "c", "d", "e"}}}, ""}, + {testCase{"", `{{ mustRest .V }}`, "[c d e]", map[string]any{"V": []string{"b", "c", "d", "e"}}}, ""}, + {testCase{"", `{{ mustRest .V }}`, "[c d e a]", map[string]any{"V": []string{"b", "c", "d", "e", "a"}}}, ""}, + {testCase{"", `{{ mustRest .V }}`, "[]", map[string]any{"V": []string{"a"}}}, ""}, + {testCase{"", `{{ mustRest .V }}`, "", map[string]any{"V": nil}}, "cannot rest nil"}, + {testCase{"", `{{ mustRest .V }}`, "", map[string]any{"V": 1}}, "cannot find rest on type int"}, + } + + runMustTestCases(t, tests) +} + +func TestMustInitial(t *testing.T) { + var tests = mustTestCases{ + {testCase{"", `{{ mustInitial .V }}`, "[a b c d]", map[string]any{"V": []string{"a", "b", "c", "d", "e"}}}, ""}, + {testCase{"", `{{ mustInitial .V }}`, "[a b c]", map[string]any{"V": []string{"a", "b", "c", "d"}}}, ""}, + {testCase{"", `{{ mustInitial .V }}`, "[]", map[string]any{"V": []string{"a"}}}, ""}, + {testCase{"", `{{ mustInitial .V }}`, "", map[string]any{"V": nil}}, "cannot initial nil"}, + {testCase{"", `{{ mustInitial .V }}`, "", map[string]any{"V": 1}}, "cannot find initial on type int"}, + } + + runMustTestCases(t, tests) +} + +func TestMustFirst(t *testing.T) { + var tests = mustTestCases{ + {testCase{"", `{{ mustFirst .V }}`, "a", map[string]any{"V": []string{"a", "b", "c", "d", "e"}}}, ""}, + {testCase{"", `{{ mustFirst .V }}`, "", map[string]any{"V": nil}}, "cannot first nil"}, + {testCase{"", `{{ mustFirst .V }}`, "", map[string]any{"V": 1}}, "cannot find first on type int"}, + } + + runMustTestCases(t, tests) +} + +func TestMustLast(t *testing.T) { + var tests = mustTestCases{ + {testCase{"", `{{ mustLast .V }}`, "e", map[string]any{"V": []string{"a", "b", "c", "d", "e"}}}, ""}, + {testCase{"", `{{ mustLast .V }}`, "", map[string]any{"V": nil}}, "cannot last nil"}, + {testCase{"", `{{ mustLast .V }}`, "", map[string]any{"V": 1}}, "cannot find last on type int"}, + } + + runMustTestCases(t, tests) +} + +func TestMustReverse(t *testing.T) { + var tests = mustTestCases{ + {testCase{"", `{{ mustReverse .V }}`, "[e d c b a]", map[string]any{"V": []string{"a", "b", "c", "d", "e"}}}, ""}, + {testCase{"", `{{ mustReverse .V }}`, "[a b c d e]", map[string]any{"V": []string{"e", "d", "c", "b", "a"}}}, ""}, + {testCase{"", `{{ mustReverse .V }}`, "", map[string]any{"V": nil}}, "cannot reverse nil"}, + {testCase{"", `{{ mustReverse .V }}`, "", map[string]any{"V": 1}}, "cannot find reverse on type int"}, + } + + runMustTestCases(t, tests) +} diff --git a/crypto.go b/sprig_functions_not_included_in_sprout.go similarity index 63% rename from crypto.go rename to sprig_functions_not_included_in_sprout.go index b1f15d5..237a429 100644 --- a/crypto.go +++ b/sprig_functions_not_included_in_sprout.go @@ -1,3 +1,24 @@ +/** + * This file lists the functions originally part of the Sprig library that are + * intentionally excluded from the Sprout library. The exclusions are based on\ + * community decisions and technical evaluations aimed at enhancing security, + * relevance, and performance in the context of Go templates. + * Each exclusion is supported by rational and further community discussions + * can be found on our GitHub issues page. + * + * Exclusion Criteria: + * 1. Crypto functions: Deemed inappropriate for Go templates due to inherent security risks. + * 2. Irrelevant functions: Omitted because they do not provide utility in the context of Go templates. + * 3. Deprecated/Insecure: Functions using outdated or insecure standards are excluded. + * 4. Temporary exclusions: Certain functions are temporarily excluded to prevent breaking changes, + * pending the implementation of the new loader feature. + * 5. Community decision: Choices made by the community are documented and can be discussed at + * https://github.com/42atomys/sprout/issues/1. + * + * The Sprout library is an open-source project and welcomes contributions from the community. + * To discuss existing exclusions or propose new ones, please contribute to the discussions on + * our GitHub repository. + */ package sprout import ( @@ -10,7 +31,7 @@ import ( "crypto/ed25519" "crypto/elliptic" "crypto/hmac" - "crypto/rand" + cryptorand "crypto/rand" "crypto/rsa" "crypto/sha1" "crypto/sha256" @@ -26,72 +47,115 @@ import ( "hash/adler32" "io" "math/big" + mathrand "math/rand" "net" - "time" - + "net/url" + "reflect" "strings" + "time" - "github.com/google/uuid" + sv2 "github.com/Masterminds/semver/v3" bcrypt_lib "golang.org/x/crypto/bcrypt" "golang.org/x/crypto/scrypt" ) -type cryptoRandomStringOpts struct { - letters bool - numbers bool - ascii bool - chars []rune +func (fh *FunctionHandler) UrlParse(v string) map[string]any { + dict := map[string]any{} + parsedURL, err := url.Parse(v) + if err != nil { + panic(fmt.Sprintf("unable to parse url: %s", err)) + } + dict["scheme"] = parsedURL.Scheme + dict["host"] = parsedURL.Host + dict["hostname"] = parsedURL.Hostname() + dict["path"] = parsedURL.Path + dict["query"] = parsedURL.RawQuery + dict["opaque"] = parsedURL.Opaque + dict["fragment"] = parsedURL.Fragment + if parsedURL.User != nil { + dict["userinfo"] = parsedURL.User.String() + } else { + dict["userinfo"] = "" + } + + return dict +} + +func (fh *FunctionHandler) UrlJoin(d map[string]any) string { + resURL := url.URL{ + Scheme: fh.Get(d, "scheme").(string), + Host: fh.Get(d, "host").(string), + Path: fh.Get(d, "path").(string), + RawQuery: fh.Get(d, "query").(string), + Opaque: fh.Get(d, "opaque").(string), + Fragment: fh.Get(d, "fragment").(string), + } + userinfo := fh.Get(d, "userinfo").(string) + var user *url.Userinfo + if userinfo != "" { + tempURL, err := url.Parse(fmt.Sprintf("proto://%s@host", userinfo)) + if err != nil { + panic(fmt.Sprintf("unable to parse userinfo in dict: %s", err)) + } + user = tempURL.User + } + + resURL.User = user + return resURL.String() } -func cryptoRandomString(count int, opts cryptoRandomStringOpts) string { - source := []rune{} - if opts.chars == nil { - if opts.ascii { - for i := 32; i <= 126; i++ { - source = append(source, rune(i)) - } - } +func (fh *FunctionHandler) GetHostByName(name string) string { + addrs, _ := net.LookupHost(name) + //TODO: add error handing when release v3 comes out + return addrs[mathrand.Intn(len(addrs))] +} - if opts.letters { - for i := 'a'; i <= 'z'; i++ { - source = append(source, i) - } - for i := 'A'; i <= 'Z'; i++ { - source = append(source, i) - } - } - if opts.numbers { - for i := '0'; i <= '9'; i++ { - source = append(source, i) - } +func (fh *FunctionHandler) InList(haystack []any, needle any) bool { + for _, h := range haystack { + if reflect.DeepEqual(needle, h) { + return true } } + return false +} - // Generate random string - return strings.Map(func(r rune) rune { - if bigInt, err := rand.Int(rand.Reader, big.NewInt(int64(len(source)))); err == nil { - return source[bigInt.Int64()] - } - return rune(-1) // Should not happen, indicates an error - }, strings.Repeat(" ", count)) +func (fh *FunctionHandler) SemverCompare(constraint, version string) (bool, error) { + c, err := sv2.NewConstraint(constraint) + if err != nil { + return false, err + } + + v, err := sv2.NewVersion(version) + if err != nil { + return false, err + } + + return c.Check(v), nil +} + +func (fh *FunctionHandler) Semver(version string) (*sv2.Version, error) { + return sv2.NewVersion(version) } -func sha256sum(input string) string { +// ////////// +// CRYPTO // +// ////////// +func (fh *FunctionHandler) Sha256sum(input string) string { hash := sha256.Sum256([]byte(input)) return hex.EncodeToString(hash[:]) } -func sha1sum(input string) string { +func (fh *FunctionHandler) Sha1sum(input string) string { hash := sha1.Sum([]byte(input)) return hex.EncodeToString(hash[:]) } -func adler32sum(input string) string { +func (fh *FunctionHandler) Adler32sum(input string) string { hash := adler32.Checksum([]byte(input)) return fmt.Sprintf("%d", hash) } -func bcrypt(input string) string { +func (fh *FunctionHandler) Bcrypt(input string) string { hash, err := bcrypt_lib.GenerateFromPassword([]byte(input), bcrypt_lib.DefaultCost) if err != nil { return fmt.Sprintf("failed to encrypt string with bcrypt: %s", err) @@ -100,24 +164,11 @@ func bcrypt(input string) string { return string(hash) } -func htpasswd(username string, password string) string { +func (fh *FunctionHandler) Htpasswd(username string, password string) string { if strings.Contains(username, ":") { return fmt.Sprintf("invalid username: %s", username) } - return fmt.Sprintf("%s:%s", username, bcrypt(password)) -} - -func randBytes(count int) (string, error) { - buf := make([]byte, count) - if _, err := rand.Read(buf); err != nil { - return "", err - } - return base64.StdEncoding.EncodeToString(buf), nil -} - -// uuidv4 provides a safe and secure UUID v4 implementation -func uuidv4() string { - return uuid.New().String() + return fmt.Sprintf("%s:%s", username, fh.Bcrypt(password)) } var masterPasswordSeed = "com.lyndir.masterpassword" @@ -147,7 +198,7 @@ var templateCharacters = map[byte]string{ 'x': "AEIOUaeiouBCDFGHJKLMNPQRSTVWXYZbcdfghjklmnpqrstvwxyz0123456789!@#$%^&*()", } -func derivePassword(counter uint32, passwordType, password, user, site string) string { +func (fh *FunctionHandler) DerivePassword(counter uint32, passwordType, password, user, site string) string { var templates = passwordTypeTemplates[passwordType] if templates == nil { return fmt.Sprintf("cannot find password template %s", passwordType) @@ -184,26 +235,26 @@ func derivePassword(counter uint32, passwordType, password, user, site string) s return buffer.String() } -func generatePrivateKey(typ string) string { +func (fh *FunctionHandler) GeneratePrivateKey(typ string) string { var priv interface{} var err error switch typ { case "", "rsa": // good enough for government work - priv, err = rsa.GenerateKey(rand.Reader, 4096) + priv, err = rsa.GenerateKey(cryptorand.Reader, 4096) case "dsa": key := new(dsa.PrivateKey) // again, good enough for government work - if err = dsa.GenerateParameters(&key.Parameters, rand.Reader, dsa.L2048N256); err != nil { + if err = dsa.GenerateParameters(&key.Parameters, cryptorand.Reader, dsa.L2048N256); err != nil { return fmt.Sprintf("failed to generate dsa params: %s", err) } - err = dsa.GenerateKey(key, rand.Reader) + err = dsa.GenerateKey(key, cryptorand.Reader) priv = key case "ecdsa": // again, good enough for government work - priv, err = ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + priv, err = ecdsa.GenerateKey(elliptic.P256(), cryptorand.Reader) case "ed25519": - _, priv, err = ed25519.GenerateKey(rand.Reader) + _, priv, err = ed25519.GenerateKey(cryptorand.Reader) default: return "Unknown type " + typ } @@ -211,7 +262,7 @@ func generatePrivateKey(typ string) string { return fmt.Sprintf("failed to generate private key: %s", err) } - return string(pem.EncodeToMemory(pemBlockForKey(priv))) + return string(pem.EncodeToMemory(fh.PemBlockForKey(priv))) } // DSAKeyFormat stores the format for DSA keys. @@ -221,7 +272,7 @@ type DSAKeyFormat struct { P, Q, G, Y, X *big.Int } -func pemBlockForKey(priv interface{}) *pem.Block { +func (fh *FunctionHandler) PemBlockForKey(priv interface{}) *pem.Block { switch k := priv.(type) { case *rsa.PrivateKey: return &pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(k)} @@ -245,7 +296,7 @@ func pemBlockForKey(priv interface{}) *pem.Block { } } -func parsePrivateKeyPEM(pemBlock string) (crypto.PrivateKey, error) { +func (fh *FunctionHandler) ParsePrivateKeyPEM(pemBlock string) (crypto.PrivateKey, error) { block, _ := pem.Decode([]byte(pemBlock)) if block == nil { return nil, errors.New("no PEM data in input") @@ -295,7 +346,7 @@ func parsePrivateKeyPEM(pemBlock string) (crypto.PrivateKey, error) { } } -func getPublicKey(priv crypto.PrivateKey) (crypto.PublicKey, error) { +func (fh *FunctionHandler) GetPublicKey(priv crypto.PrivateKey) (crypto.PublicKey, error) { switch k := priv.(type) { case interface{ Public() crypto.PublicKey }: return k.Public(), nil @@ -311,7 +362,7 @@ type certificate struct { Key string } -func buildCustomCertificate(b64cert string, b64key string) (certificate, error) { +func (fh *FunctionHandler) BuildCustomCertificate(b64cert string, b64key string) (certificate, error) { crt := certificate{} cert, err := base64.StdEncoding.DecodeString(b64cert) @@ -336,7 +387,7 @@ func buildCustomCertificate(b64cert string, b64key string) (certificate, error) ) } - _, err = parsePrivateKeyPEM(string(key)) + _, err = fh.ParsePrivateKeyPEM(string(key)) if err != nil { return crt, fmt.Errorf( "error parsing private key: %s", @@ -350,38 +401,38 @@ func buildCustomCertificate(b64cert string, b64key string) (certificate, error) return crt, nil } -func generateCertificateAuthority( +func (fh *FunctionHandler) GenerateCertificateAuthority( cn string, daysValid int, ) (certificate, error) { - priv, err := rsa.GenerateKey(rand.Reader, 2048) + priv, err := rsa.GenerateKey(cryptorand.Reader, 2048) if err != nil { return certificate{}, fmt.Errorf("error generating rsa key: %s", err) } - return generateCertificateAuthorityWithKeyInternal(cn, daysValid, priv) + return fh.GenerateCertificateAuthorityWithKeyInternal(cn, daysValid, priv) } -func generateCertificateAuthorityWithPEMKey( +func (fh *FunctionHandler) GenerateCertificateAuthorityWithPEMKey( cn string, daysValid int, privPEM string, ) (certificate, error) { - priv, err := parsePrivateKeyPEM(privPEM) + priv, err := fh.ParsePrivateKeyPEM(privPEM) if err != nil { return certificate{}, fmt.Errorf("parsing private key: %s", err) } - return generateCertificateAuthorityWithKeyInternal(cn, daysValid, priv) + return fh.GenerateCertificateAuthorityWithKeyInternal(cn, daysValid, priv) } -func generateCertificateAuthorityWithKeyInternal( +func (fh *FunctionHandler) GenerateCertificateAuthorityWithKeyInternal( cn string, daysValid int, priv crypto.PrivateKey, ) (certificate, error) { ca := certificate{} - template, err := getBaseCertTemplate(cn, nil, nil, daysValid) + template, err := fh.GetBaseCertTemplate(cn, nil, nil, daysValid) if err != nil { return ca, err } @@ -391,39 +442,39 @@ func generateCertificateAuthorityWithKeyInternal( x509.KeyUsageCertSign template.IsCA = true - ca.Cert, ca.Key, err = getCertAndKey(template, priv, template, priv) + ca.Cert, ca.Key, err = fh.GetCertAndKey(template, priv, template, priv) return ca, err } -func generateSelfSignedCertificate( +func (fh *FunctionHandler) GenerateSelfSignedCertificate( cn string, ips []interface{}, alternateDNS []interface{}, daysValid int, ) (certificate, error) { - priv, err := rsa.GenerateKey(rand.Reader, 2048) + priv, err := rsa.GenerateKey(cryptorand.Reader, 2048) if err != nil { return certificate{}, fmt.Errorf("error generating rsa key: %s", err) } - return generateSelfSignedCertificateWithKeyInternal(cn, ips, alternateDNS, daysValid, priv) + return fh.GenerateSelfSignedCertificateWithKeyInternal(cn, ips, alternateDNS, daysValid, priv) } -func generateSelfSignedCertificateWithPEMKey( +func (fh *FunctionHandler) GenerateSelfSignedCertificateWithPEMKey( cn string, ips []interface{}, alternateDNS []interface{}, daysValid int, privPEM string, ) (certificate, error) { - priv, err := parsePrivateKeyPEM(privPEM) + priv, err := fh.ParsePrivateKeyPEM(privPEM) if err != nil { return certificate{}, fmt.Errorf("parsing private key: %s", err) } - return generateSelfSignedCertificateWithKeyInternal(cn, ips, alternateDNS, daysValid, priv) + return fh.GenerateSelfSignedCertificateWithKeyInternal(cn, ips, alternateDNS, daysValid, priv) } -func generateSelfSignedCertificateWithKeyInternal( +func (fh *FunctionHandler) GenerateSelfSignedCertificateWithKeyInternal( cn string, ips []interface{}, alternateDNS []interface{}, @@ -432,31 +483,31 @@ func generateSelfSignedCertificateWithKeyInternal( ) (certificate, error) { cert := certificate{} - template, err := getBaseCertTemplate(cn, ips, alternateDNS, daysValid) + template, err := fh.GetBaseCertTemplate(cn, ips, alternateDNS, daysValid) if err != nil { return cert, err } - cert.Cert, cert.Key, err = getCertAndKey(template, priv, template, priv) + cert.Cert, cert.Key, err = fh.GetCertAndKey(template, priv, template, priv) return cert, err } -func generateSignedCertificate( +func (fh *FunctionHandler) GenerateSignedCertificate( cn string, ips []interface{}, alternateDNS []interface{}, daysValid int, ca certificate, ) (certificate, error) { - priv, err := rsa.GenerateKey(rand.Reader, 2048) + priv, err := rsa.GenerateKey(cryptorand.Reader, 2048) if err != nil { return certificate{}, fmt.Errorf("error generating rsa key: %s", err) } - return generateSignedCertificateWithKeyInternal(cn, ips, alternateDNS, daysValid, ca, priv) + return fh.GenerateSignedCertificateWithKeyInternal(cn, ips, alternateDNS, daysValid, ca, priv) } -func generateSignedCertificateWithPEMKey( +func (fh *FunctionHandler) GenerateSignedCertificateWithPEMKey( cn string, ips []interface{}, alternateDNS []interface{}, @@ -464,14 +515,14 @@ func generateSignedCertificateWithPEMKey( ca certificate, privPEM string, ) (certificate, error) { - priv, err := parsePrivateKeyPEM(privPEM) + priv, err := fh.ParsePrivateKeyPEM(privPEM) if err != nil { return certificate{}, fmt.Errorf("parsing private key: %s", err) } - return generateSignedCertificateWithKeyInternal(cn, ips, alternateDNS, daysValid, ca, priv) + return fh.GenerateSignedCertificateWithKeyInternal(cn, ips, alternateDNS, daysValid, ca, priv) } -func generateSignedCertificateWithKeyInternal( +func (fh *FunctionHandler) GenerateSignedCertificateWithKeyInternal( cn string, ips []interface{}, alternateDNS []interface{}, @@ -492,7 +543,7 @@ func generateSignedCertificateWithKeyInternal( err, ) } - signerKey, err := parsePrivateKeyPEM(ca.Key) + signerKey, err := fh.ParsePrivateKeyPEM(ca.Key) if err != nil { return cert, fmt.Errorf( "error parsing private key: %s", @@ -500,12 +551,12 @@ func generateSignedCertificateWithKeyInternal( ) } - template, err := getBaseCertTemplate(cn, ips, alternateDNS, daysValid) + template, err := fh.GetBaseCertTemplate(cn, ips, alternateDNS, daysValid) if err != nil { return cert, err } - cert.Cert, cert.Key, err = getCertAndKey( + cert.Cert, cert.Key, err = fh.GetCertAndKey( template, priv, signerCert, @@ -515,18 +566,18 @@ func generateSignedCertificateWithKeyInternal( return cert, err } -func getCertAndKey( +func (fh *FunctionHandler) GetCertAndKey( template *x509.Certificate, signeeKey crypto.PrivateKey, parent *x509.Certificate, signingKey crypto.PrivateKey, ) (string, string, error) { - signeePubKey, err := getPublicKey(signeeKey) + signeePubKey, err := fh.GetPublicKey(signeeKey) if err != nil { return "", "", fmt.Errorf("error retrieving public key from signee key: %s", err) } derBytes, err := x509.CreateCertificate( - rand.Reader, + cryptorand.Reader, template, parent, signeePubKey, @@ -547,7 +598,7 @@ func getCertAndKey( keyBuffer := bytes.Buffer{} if err := pem.Encode( &keyBuffer, - pemBlockForKey(signeeKey), + fh.PemBlockForKey(signeeKey), ); err != nil { return "", "", fmt.Errorf("error pem-encoding key: %s", err) } @@ -555,22 +606,22 @@ func getCertAndKey( return certBuffer.String(), keyBuffer.String(), nil } -func getBaseCertTemplate( +func (fh *FunctionHandler) GetBaseCertTemplate( cn string, ips []interface{}, alternateDNS []interface{}, daysValid int, ) (*x509.Certificate, error) { - ipAddresses, err := getNetIPs(ips) + ipAddresses, err := fh.GetNetIPs(ips) if err != nil { return nil, err } - dnsNames, err := getAlternateDNSStrs(alternateDNS) + dnsNames, err := fh.GetAlternateDNSStrs(alternateDNS) if err != nil { return nil, err } serialNumberUpperBound := new(big.Int).Lsh(big.NewInt(1), 128) - serialNumber, err := rand.Int(rand.Reader, serialNumberUpperBound) + serialNumber, err := cryptorand.Int(cryptorand.Reader, serialNumberUpperBound) if err != nil { return nil, err } @@ -592,7 +643,7 @@ func getBaseCertTemplate( }, nil } -func getNetIPs(ips []interface{}) ([]net.IP, error) { +func (fh *FunctionHandler) GetNetIPs(ips []interface{}) ([]net.IP, error) { if ips == nil { return []net.IP{}, nil } @@ -614,7 +665,7 @@ func getNetIPs(ips []interface{}) ([]net.IP, error) { return netIPs, nil } -func getAlternateDNSStrs(alternateDNS []interface{}) ([]string, error) { +func (fh *FunctionHandler) GetAlternateDNSStrs(alternateDNS []interface{}) ([]string, error) { if alternateDNS == nil { return []string{}, nil } @@ -634,7 +685,7 @@ func getAlternateDNSStrs(alternateDNS []interface{}) ([]string, error) { return alternateDNSStrs, nil } -func encryptAES(password string, plaintext string) (string, error) { +func (fh *FunctionHandler) EncryptAES(password string, plaintext string) (string, error) { if plaintext == "" { return "", nil } @@ -655,7 +706,7 @@ func encryptAES(password string, plaintext string) (string, error) { ciphertext := make([]byte, aes.BlockSize+len(content)) iv := ciphertext[:aes.BlockSize] - if _, err := io.ReadFull(rand.Reader, iv); err != nil { + if _, err := io.ReadFull(cryptorand.Reader, iv); err != nil { return "", err } @@ -665,7 +716,7 @@ func encryptAES(password string, plaintext string) (string, error) { return base64.StdEncoding.EncodeToString(ciphertext), nil } -func decryptAES(password string, crypt64 string) (string, error) { +func (fh *FunctionHandler) DecryptAES(password string, crypt64 string) (string, error) { if crypt64 == "" { return "", nil } diff --git a/sprout.go b/sprout.go index 6a3966e..8cad5a2 100644 --- a/sprout.go +++ b/sprout.go @@ -88,15 +88,212 @@ func WithFunctionHandler(new *FunctionHandler) FunctionHandlerOption { func FuncMap(opts ...FunctionHandlerOption) template.FuncMap { fnHandler := NewFunctionHandler(opts...) - // BACKWARD COMPATIBILITY - // Fallback to FuncMap() to get the unmigrated functions - for k, v := range TxtFuncMap() { - fnHandler.funcMap[k] = v - } - // Added migrated functions fnHandler.funcMap["hello"] = fnHandler.Hello + // Added functions not migrated totally yet + fnHandler.funcMap["dateAgo"] = fnHandler.DateAgo + fnHandler.funcMap["date"] = fnHandler.Date + fnHandler.funcMap["dateModify"] = fnHandler.DateModify + fnHandler.funcMap["dateInZone"] = fnHandler.DateInZone + fnHandler.funcMap["duration"] = fnHandler.Duration + fnHandler.funcMap["durationRound"] = fnHandler.DurationRound + fnHandler.funcMap["htmlDate"] = fnHandler.HtmlDate + fnHandler.funcMap["htmlDateInZone"] = fnHandler.HtmlDateInZone + fnHandler.funcMap["mustDateModify"] = fnHandler.MustDateModify + fnHandler.funcMap["mustToDate"] = fnHandler.MustToDate + fnHandler.funcMap["now"] = fnHandler.Now + fnHandler.funcMap["toDate"] = fnHandler.ToDate + fnHandler.funcMap["unixEpoch"] = fnHandler.UnixEpoch + fnHandler.funcMap["ellipsis"] = fnHandler.Ellipsis + fnHandler.funcMap["ellipsisBoth"] = fnHandler.EllipsisBoth + fnHandler.funcMap["toUpper"] = fnHandler.ToUpper + fnHandler.funcMap["toLower"] = fnHandler.ToLower + fnHandler.funcMap["untitle"] = fnHandler.Untitle + fnHandler.funcMap["substr"] = fnHandler.Substring + fnHandler.funcMap["repeat"] = fnHandler.Repeat + fnHandler.funcMap["trunc"] = fnHandler.Trunc + fnHandler.funcMap["trim"] = fnHandler.Trim + fnHandler.funcMap["trimAll"] = fnHandler.TrimAll + fnHandler.funcMap["trimPrefix"] = fnHandler.TrimPrefix + fnHandler.funcMap["trimSuffix"] = fnHandler.TrimSuffix + fnHandler.funcMap["nospace"] = fnHandler.Nospace + fnHandler.funcMap["initials"] = fnHandler.Initials + fnHandler.funcMap["randAlphaNum"] = fnHandler.RandAlphaNumeric + fnHandler.funcMap["randAlpha"] = fnHandler.RandAlpha + fnHandler.funcMap["randAscii"] = fnHandler.RandAscii + fnHandler.funcMap["randNumeric"] = fnHandler.RandNumeric + fnHandler.funcMap["swapCase"] = fnHandler.SwapCase + fnHandler.funcMap["shuffle"] = fnHandler.Shuffle + fnHandler.funcMap["toSnakeCase"] = fnHandler.ToSnakeCase + fnHandler.funcMap["toCamelCase"] = fnHandler.ToCamelCase + fnHandler.funcMap["toKebabCase"] = fnHandler.ToKebabCase + fnHandler.funcMap["toPascalCase"] = fnHandler.ToPascalCase + fnHandler.funcMap["toTitleCase"] = fnHandler.ToTitleCase + fnHandler.funcMap["toDotCase"] = fnHandler.ToDotCase + fnHandler.funcMap["toPathCase"] = fnHandler.ToPathCase + fnHandler.funcMap["toConstantCase"] = fnHandler.ToConstantCase + fnHandler.funcMap["wrap"] = fnHandler.Wrap + fnHandler.funcMap["wrapWith"] = fnHandler.WrapWith + fnHandler.funcMap["contains"] = fnHandler.Contains + fnHandler.funcMap["hasPrefix"] = fnHandler.HasPrefix + fnHandler.funcMap["hasSuffix"] = fnHandler.HasSuffix + fnHandler.funcMap["quote"] = fnHandler.Quote + fnHandler.funcMap["squote"] = fnHandler.Squote + fnHandler.funcMap["cat"] = fnHandler.Cat + fnHandler.funcMap["indent"] = fnHandler.Indent + fnHandler.funcMap["nindent"] = fnHandler.Nindent + fnHandler.funcMap["replace"] = fnHandler.Replace + fnHandler.funcMap["plural"] = fnHandler.Plural + fnHandler.funcMap["sha1sum"] = fnHandler.Sha1sum + fnHandler.funcMap["sha256sum"] = fnHandler.Sha256sum + fnHandler.funcMap["adler32sum"] = fnHandler.Adler32sum + fnHandler.funcMap["toString"] = fnHandler.ToString + fnHandler.funcMap["toInt64"] = fnHandler.ToInt64 + fnHandler.funcMap["toInt"] = fnHandler.ToInt + fnHandler.funcMap["toFloat64"] = fnHandler.ToFloat64 + fnHandler.funcMap["seq"] = fnHandler.Seq + fnHandler.funcMap["toOctal"] = fnHandler.ToOctal + fnHandler.funcMap["split"] = fnHandler.Split + fnHandler.funcMap["splitList"] = fnHandler.SplitList + fnHandler.funcMap["splitn"] = fnHandler.Splitn + fnHandler.funcMap["strSlice"] = fnHandler.StrSlice + fnHandler.funcMap["until"] = fnHandler.Until + fnHandler.funcMap["untilStep"] = fnHandler.UntilStep + fnHandler.funcMap["add1"] = fnHandler.Add1 + fnHandler.funcMap["add"] = fnHandler.Add + fnHandler.funcMap["sub"] = fnHandler.Sub + fnHandler.funcMap["div"] = fnHandler.DivInt + fnHandler.funcMap["divf"] = fnHandler.Divf + fnHandler.funcMap["mod"] = fnHandler.Mod + fnHandler.funcMap["mul"] = fnHandler.MulInt + fnHandler.funcMap["mulf"] = fnHandler.Mulf + fnHandler.funcMap["randInt"] = fnHandler.RandInt + fnHandler.funcMap["max"] = fnHandler.Max + fnHandler.funcMap["min"] = fnHandler.Min + fnHandler.funcMap["maxf"] = fnHandler.Maxf + fnHandler.funcMap["minf"] = fnHandler.Minf + fnHandler.funcMap["ceil"] = fnHandler.Ceil + fnHandler.funcMap["floor"] = fnHandler.Floor + fnHandler.funcMap["round"] = fnHandler.Round + fnHandler.funcMap["join"] = fnHandler.Join + fnHandler.funcMap["sortAlpha"] = fnHandler.SortAlpha + fnHandler.funcMap["default"] = fnHandler.Default + fnHandler.funcMap["empty"] = fnHandler.Empty + fnHandler.funcMap["coalesce"] = fnHandler.Coalesce + fnHandler.funcMap["all"] = fnHandler.All + fnHandler.funcMap["any"] = fnHandler.Any + fnHandler.funcMap["compact"] = fnHandler.Compact + fnHandler.funcMap["mustCompact"] = fnHandler.MustCompact + fnHandler.funcMap["fromJson"] = fnHandler.FromJson + fnHandler.funcMap["toJson"] = fnHandler.ToJson + fnHandler.funcMap["toPrettyJson"] = fnHandler.ToPrettyJson + fnHandler.funcMap["toRawJson"] = fnHandler.ToRawJson + fnHandler.funcMap["mustFromJson"] = fnHandler.MustFromJson + fnHandler.funcMap["mustToJson"] = fnHandler.MustToJson + fnHandler.funcMap["mustToPrettyJson"] = fnHandler.MustToPrettyJson + fnHandler.funcMap["mustToRawJson"] = fnHandler.MustToRawJson + fnHandler.funcMap["ternary"] = fnHandler.Ternary + fnHandler.funcMap["deepCopy"] = fnHandler.DeepCopy + fnHandler.funcMap["mustDeepCopy"] = fnHandler.MustDeepCopy + fnHandler.funcMap["typeOf"] = fnHandler.TypeOf + fnHandler.funcMap["typeIs"] = fnHandler.TypeIs + fnHandler.funcMap["typeIsLike"] = fnHandler.TypeIsLike + fnHandler.funcMap["kindOf"] = fnHandler.KindOf + fnHandler.funcMap["kindIs"] = fnHandler.KindIs + fnHandler.funcMap["deepEqual"] = fnHandler.DeepEqual + fnHandler.funcMap["regexMatch"] = fnHandler.RegexMatch + fnHandler.funcMap["mustRegexMatch"] = fnHandler.MustRegexMatch + fnHandler.funcMap["regexFindAll"] = fnHandler.RegexFindAll + fnHandler.funcMap["mustRegexFindAll"] = fnHandler.MustRegexFindAll + fnHandler.funcMap["regexFind"] = fnHandler.RegexFind + fnHandler.funcMap["mustRegexFind"] = fnHandler.MustRegexFind + fnHandler.funcMap["regexReplaceAll"] = fnHandler.RegexReplaceAll + fnHandler.funcMap["mustRegexReplaceAll"] = fnHandler.MustRegexReplaceAll + fnHandler.funcMap["regexReplaceAllLiteral"] = fnHandler.RegexReplaceAllLiteral + fnHandler.funcMap["mustRegexReplaceAllLiteral"] = fnHandler.MustRegexReplaceAllLiteral + fnHandler.funcMap["regexSplit"] = fnHandler.RegexSplit + fnHandler.funcMap["mustRegexSplit"] = fnHandler.MustRegexSplit + fnHandler.funcMap["regexQuoteMeta"] = fnHandler.RegexQuoteMeta + fnHandler.funcMap["append"] = fnHandler.Append + fnHandler.funcMap["mustAppend"] = fnHandler.MustAppend + fnHandler.funcMap["prepend"] = fnHandler.Prepend + fnHandler.funcMap["mustPrepend"] = fnHandler.MustPrepend + fnHandler.funcMap["first"] = fnHandler.First + fnHandler.funcMap["mustFirst"] = fnHandler.MustFirst + fnHandler.funcMap["rest"] = fnHandler.Rest + fnHandler.funcMap["mustRest"] = fnHandler.MustRest + fnHandler.funcMap["last"] = fnHandler.Last + fnHandler.funcMap["mustLast"] = fnHandler.MustLast + fnHandler.funcMap["initial"] = fnHandler.Initial + fnHandler.funcMap["mustInitial"] = fnHandler.MustInitial + fnHandler.funcMap["reverse"] = fnHandler.Reverse + fnHandler.funcMap["mustReverse"] = fnHandler.MustReverse + fnHandler.funcMap["uniq"] = fnHandler.Uniq + fnHandler.funcMap["mustUniq"] = fnHandler.MustUniq + fnHandler.funcMap["without"] = fnHandler.Without + fnHandler.funcMap["mustWithout"] = fnHandler.MustWithout + fnHandler.funcMap["has"] = fnHandler.Has + fnHandler.funcMap["mustHas"] = fnHandler.MustHas + fnHandler.funcMap["slice"] = fnHandler.Slice + fnHandler.funcMap["mustSlice"] = fnHandler.MustSlice + fnHandler.funcMap["concat"] = fnHandler.Concat + fnHandler.funcMap["dig"] = fnHandler.Dig + fnHandler.funcMap["chunk"] = fnHandler.Chunk + fnHandler.funcMap["mustChunk"] = fnHandler.MustChunk + fnHandler.funcMap["list"] = fnHandler.List + fnHandler.funcMap["dict"] = fnHandler.Dict + fnHandler.funcMap["get"] = fnHandler.Get + fnHandler.funcMap["set"] = fnHandler.Set + fnHandler.funcMap["unset"] = fnHandler.Unset + fnHandler.funcMap["hasKey"] = fnHandler.HasKey + fnHandler.funcMap["pluck"] = fnHandler.Pluck + fnHandler.funcMap["keys"] = fnHandler.Keys + fnHandler.funcMap["pick"] = fnHandler.Pick + fnHandler.funcMap["omit"] = fnHandler.Omit + fnHandler.funcMap["merge"] = fnHandler.Merge + fnHandler.funcMap["mergeOverwrite"] = fnHandler.MergeOverwrite + fnHandler.funcMap["mustMerge"] = fnHandler.MustMerge + fnHandler.funcMap["mustMergeOverwrite"] = fnHandler.MustMergeOverwrite + fnHandler.funcMap["values"] = fnHandler.Values + fnHandler.funcMap["bcrypt"] = fnHandler.Bcrypt + fnHandler.funcMap["htpasswd"] = fnHandler.Htpasswd + fnHandler.funcMap["genPrivateKey"] = fnHandler.GeneratePrivateKey + fnHandler.funcMap["derivePassword"] = fnHandler.DerivePassword + fnHandler.funcMap["buildCustomCert"] = fnHandler.BuildCustomCertificate + fnHandler.funcMap["genCA"] = fnHandler.GenerateCertificateAuthority + fnHandler.funcMap["genCAWithKey"] = fnHandler.GenerateCertificateAuthorityWithPEMKey + fnHandler.funcMap["genSelfSignedCert"] = fnHandler.GenerateSelfSignedCertificate + fnHandler.funcMap["genSelfSignedCertWithKey"] = fnHandler.GenerateSelfSignedCertificateWithPEMKey + fnHandler.funcMap["genSignedCert"] = fnHandler.GenerateSignedCertificate + fnHandler.funcMap["genSignedCertWithKey"] = fnHandler.GenerateSignedCertificateWithPEMKey + fnHandler.funcMap["encryptAES"] = fnHandler.EncryptAES + fnHandler.funcMap["decryptAES"] = fnHandler.DecryptAES + fnHandler.funcMap["randBytes"] = fnHandler.RandBytes + fnHandler.funcMap["pathBase"] = fnHandler.PathBase + fnHandler.funcMap["pathDir"] = fnHandler.PathDir + fnHandler.funcMap["pathClean"] = fnHandler.PathClean + fnHandler.funcMap["pathExt"] = fnHandler.PathExt + fnHandler.funcMap["pathIsAbs"] = fnHandler.PathIsAbs + fnHandler.funcMap["osBase"] = fnHandler.OsBase + fnHandler.funcMap["osClean"] = fnHandler.OsClean + fnHandler.funcMap["osDir"] = fnHandler.OsDir + fnHandler.funcMap["osExt"] = fnHandler.OsExt + fnHandler.funcMap["osIsAbs"] = fnHandler.OsIsAbs + fnHandler.funcMap["env"] = fnHandler.Env + fnHandler.funcMap["expandEnv"] = fnHandler.ExpandEnv + fnHandler.funcMap["getHostByName"] = fnHandler.GetHostByName + fnHandler.funcMap["uuidv4"] = fnHandler.Uuidv4 + fnHandler.funcMap["semver"] = fnHandler.Semver + fnHandler.funcMap["semverCompare"] = fnHandler.SemverCompare + fnHandler.funcMap["fail"] = fnHandler.Fail + fnHandler.funcMap["urlParse"] = fnHandler.UrlParse + fnHandler.funcMap["urlJoin"] = fnHandler.UrlJoin + fnHandler.funcMap["base64Encode"] = fnHandler.Base64Encode + fnHandler.funcMap["base64Decode"] = fnHandler.Base64Decode + fnHandler.funcMap["base32Encode"] = fnHandler.Base32Encode + fnHandler.funcMap["base32Decode"] = fnHandler.Base32Decode + // Register aliases for functions fnHandler.registerAliases() return fnHandler.funcMap diff --git a/sprout_test.go b/sprout_test.go index f06a3a9..43b8425 100644 --- a/sprout_test.go +++ b/sprout_test.go @@ -83,26 +83,5 @@ func TestFuncMap_IncludesHello(t *testing.T) { helloFunc, ok := funcMap["hello"].(func() string) assert.True(t, ok) - assert.Equal(t, "Hello, World!", helloFunc()) -} - -// This test ensures backward compatibility by checking if FuncMap (the function mentioned in the comment) exists or needs to be implemented for the test. -func TestFuncMap_BackwardCompatibility(t *testing.T) { - // Assuming FuncMap() is implemented and returns a template.FuncMap - // Replace the implementation details as per actual FuncMap function. - genericMap["TestFuncMap_BackwardCompatibility"] = func() string { - return "example" - } - - funcMap := FuncMap() - exampleFunc, exists := funcMap["TestFuncMap_BackwardCompatibility"] - assert.True(t, exists) - - result, ok := exampleFunc.(func() string) - assert.True(t, ok) - assert.Equal(t, "example", result()) - - helloFunc, ok := funcMap["hello"].(func() string) - assert.True(t, ok) - assert.Equal(t, "Hello, World!", helloFunc()) + assert.Equal(t, "Hello!", helloFunc()) } diff --git a/strings.go b/strings.go deleted file mode 100644 index 87c11e6..0000000 --- a/strings.go +++ /dev/null @@ -1,441 +0,0 @@ -package sprout - -import ( - "encoding/base32" - "encoding/base64" - "fmt" - "math/rand" - "reflect" - "strings" - "time" - "unicode" - "unicode/utf8" - - "golang.org/x/text/cases" - "golang.org/x/text/language" -) - -var randSource rand.Source - -func init() { - randSource = rand.NewSource(time.Now().UnixNano()) -} - -func nospace(str string) string { - return strings.Map(func(r rune) rune { - if unicode.IsSpace(r) { - return -1 - } - return r - }, str) -} - -func swapCase(s string) string { - return strings.Map(func(r rune) rune { - switch { - case unicode.IsLower(r): - return unicode.ToUpper(r) - case unicode.IsUpper(r): - return unicode.ToLower(r) - default: - return r - } - }, s) -} - -func wordWrap(str string, wrapLength int, newLineCharacter string, wrapLongWords bool) string { - if wrapLength < 1 { - wrapLength = 1 - } - if newLineCharacter == "" { - newLineCharacter = "\n" - } - - var resultBuilder strings.Builder - var currentLineLength int - - for _, word := range strings.Fields(str) { - wordLength := utf8.RuneCountInString(word) - - // If the word is too long and should be wrapped, or it fits in the remaining line length - if currentLineLength > 0 && (currentLineLength+1+wordLength > wrapLength && !wrapLongWords || wordLength > wrapLength) { - resultBuilder.WriteString(newLineCharacter) - currentLineLength = 0 - } - - if wrapLongWords && wordLength > wrapLength { - for i, r := range word { - if currentLineLength == wrapLength { - resultBuilder.WriteString(newLineCharacter) - currentLineLength = 0 - } - resultBuilder.WriteRune(r) - currentLineLength++ - // Avoid adding a new line immediately after wrapping a long word - if i < len(word)-1 && currentLineLength == wrapLength { - resultBuilder.WriteString(newLineCharacter) - currentLineLength = 0 - } - } - } else { - if currentLineLength > 0 { - resultBuilder.WriteString(newLineCharacter) - currentLineLength++ - } - resultBuilder.WriteString(word) - currentLineLength += wordLength - } - } - - return resultBuilder.String() -} - -func toTitleCase(s string) string { - return cases.Title(language.English).String(s) -} - -// shuffle shuffles a string in a random manner. -func shuffle(str string) string { - r := []rune(str) - rand.New(randSource).Shuffle(len(r), func(i, j int) { - r[i], r[j] = r[j], r[i] - }) - return string(r) -} - -// ellipsis adds an ellipsis to the string `str` starting at `offset` if the length exceeds `maxWidth`. -// `maxWidth` must be at least 4, to accommodate the ellipsis and at least one character. -func ellipsis(str string, offset, maxWidth int) string { - ellipsis := "..." - // Return the original string if maxWidth is less than 4, or the offset - // create exclusive dot string, it's not possible to add an ellipsis. - if maxWidth < 4 || offset > 0 && maxWidth < 7 { - return str - } - - runeCount := utf8.RuneCountInString(str) - - // If the string doesn't need trimming, return it as is. - if runeCount <= maxWidth || runeCount <= offset { - return str[offset:] - } - - // Determine end position for the substring, ensuring room for the ellipsis. - endPos := offset + maxWidth - 3 // 3 is for the length of the ellipsis - if offset > 0 { - endPos -= 3 // remove the left ellipsis - } - - // Convert the string to a slice of runes to properly handle multi-byte characters. - runes := []rune(str) - - // Return the substring with an ellipsis, directly constructing the string in the return statement. - if offset > 0 { - return ellipsis + string(runes[offset:endPos]) + ellipsis - } - return string(runes[offset:endPos]) + ellipsis -} - -// initials extracts the initials from the given string using the specified delimiters. -// If delimiters are empty, it defaults to using whitespace. -func initials(str string, delimiters string) string { - // Define a function to determine if a rune is a delimiter. - isDelimiter := func(r rune) bool { - if delimiters == "" { - return unicode.IsSpace(r) - } - return strings.ContainsRune(delimiters, r) - } - - words := strings.FieldsFunc(str, isDelimiter) - var runes = make([]rune, len(words)) - for i, word := range strings.FieldsFunc(str, isDelimiter) { - if i == 0 || unicode.IsLetter(rune(word[0])) { - runes[i] = rune(word[0]) - } - } - - return string(runes) -} - -// uncapitalize transforms the first letter of each word in the string to lowercase. -// It uses specified delimiters or whitespace to determine word boundaries. -func uncapitalize(str string, delimiters string) string { - var result strings.Builder - // Convert delimiters to a map for efficient checking - delimMap := make(map[rune]bool) - for _, d := range delimiters { - delimMap[d] = true - } - - // Helper function to check if a rune is a delimiter - isDelim := func(r rune) bool { - if delimiters == "" { - return unicode.IsSpace(r) - } - return delimMap[r] - } - - // Process each rune in the input string - startOfWord := true - for _, r := range str { - if isDelim(r) { - startOfWord = true - result.WriteRune(r) - } else { - if startOfWord { - result.WriteRune(unicode.ToLower(r)) - startOfWord = false - } else { - result.WriteRune(r) - } - } - } - - return result.String() -} - -// base64encode encodes a string to Base64. -func base64encode(s string) string { - return base64.StdEncoding.EncodeToString([]byte(s)) -} - -// base64decode decodes a Base64 encoded string. -func base64decode(s string) string { - bytes, err := base64.StdEncoding.DecodeString(s) - if err != nil { - return err.Error() - } - return string(bytes) -} - -// base32encode encodes a string to Base32. -func base32encode(s string) string { - return base32.StdEncoding.EncodeToString([]byte(s)) -} - -// base32decode decodes a Base32 encoded string. -func base32decode(s string) string { - bytes, err := base32.StdEncoding.DecodeString(s) - if err != nil { - return err.Error() - } - return string(bytes) -} - -func randAlphaNumeric(count int) string { - return cryptoRandomString(count, cryptoRandomStringOpts{letters: true, numbers: true}) -} - -func randAlpha(count int) string { - return cryptoRandomString(count, cryptoRandomStringOpts{letters: true}) -} - -func randAscii(count int) string { - return cryptoRandomString(count, cryptoRandomStringOpts{ascii: true}) -} - -func randNumeric(count int) string { - return cryptoRandomString(count, cryptoRandomStringOpts{numbers: true}) -} - -func untitle(str string) string { - return uncapitalize(str, "") -} - -func quote(str ...interface{}) string { - var build strings.Builder - for i, s := range str { - if s == nil { - continue - } - if i > 0 { - build.WriteRune(' ') - } - build.WriteString(fmt.Sprintf("%q", fmt.Sprint(s))) - } - return build.String() -} - -func squote(str ...interface{}) string { - var builder strings.Builder - for i, s := range str { - if s == nil { - continue - } - if i > 0 { - builder.WriteRune(' ') - } - // Use fmt.Sprint to convert interface{} to string, then quote it. - builder.WriteRune('\'') - builder.WriteString(fmt.Sprint(s)) - builder.WriteRune('\'') - } - return builder.String() -} - -// Efficiently concatenates non-nil elements of v, separated by spaces. -func cat(v ...interface{}) string { - var builder strings.Builder - for i, item := range v { - if item == nil { - continue // Skip nil elements - } - if i > 0 { - builder.WriteRune(' ') // Add space between elements - } - // Append the string representation of the item - builder.WriteString(fmt.Sprint(item)) - } - // Return the concatenated string without trailing spaces - return builder.String() -} - -// Efficiently indents each line of the input string `v` with `spaces` number of spaces. -func indent(spaces int, v string) string { - var builder strings.Builder - pad := strings.Repeat(" ", spaces) - lines := strings.Split(v, "\n") - - for i, line := range lines { - if i > 0 { - builder.WriteString("\n" + pad) - } else { - builder.WriteString(pad) - } - builder.WriteString(line) - } - - return builder.String() -} - -// Adds a newline at the start and then indents each line of `v` with `spaces` number of spaces. -func nindent(spaces int, v string) string { - return "\n" + indent(spaces, v) -} - -func replace(old, new, src string) string { - return strings.Replace(src, old, new, -1) -} - -func plural(one, many string, count int) string { - if count == 1 { - return one - } - return many -} - -func join(sep string, v interface{}) string { - return strings.Join(strslice(v), sep) -} - -func strval(v interface{}) string { - switch v := v.(type) { - case string: - return v - case []byte: - return string(v) - case error: - return v.Error() - case fmt.Stringer: - return v.String() - default: - // Handles any other types by leveraging fmt.Sprintf for a string representation. - return fmt.Sprintf("%v", v) - } -} - -// strslice attempts to convert a variety of slice types to a slice of strings, optimizing performance and minimizing assignments. -func strslice(v interface{}) []string { - if v == nil { - return []string{} - } - - // Handle []string type efficiently without reflection. - if strs, ok := v.([]string); ok { - return strs - } - - // For slices of interface{}, convert each element to a string, skipping nil values. - if interfaces, ok := v.([]interface{}); ok { - var result []string - for _, s := range interfaces { - if s != nil { - result = append(result, strval(s)) - } - } - return result - } - - // Use reflection for other slice types to convert them to []string. - val := reflect.ValueOf(v) - if val.Kind() == reflect.Slice || val.Kind() == reflect.Array { - var result []string - for i := 0; i < val.Len(); i++ { - value := val.Index(i).Interface() - if value != nil { - result = append(result, strval(value)) - } - } - return result - } - - // If it's not a slice, array, or nil, return a slice with the string representation of v. - return []string{strval(v)} -} - -func trunc(c int, s string) string { - length := len(s) - - if c < 0 && length+c > 0 { - return s[length+c:] - } - - if c >= 0 && length > c { - return s[:c] - } - - return s -} - -// Splits `orig` string by `sep` and returns a map of the resulting parts. -// Each key is prefixed with an underscore followed by the part's index. -func split(sep, orig string) map[string]string { - parts := strings.Split(orig, sep) - return fillMapWithParts(parts) -} - -// Splits `orig` string by `sep` into at most `n` parts and returns a map of the parts. -// Each key is prefixed with an underscore followed by the part's index. -func splitn(sep string, n int, orig string) map[string]string { - parts := strings.SplitN(orig, sep, n) - return fillMapWithParts(parts) -} - -// fillMapWithParts fills a map with the provided parts, using a key format. -func fillMapWithParts(parts []string) map[string]string { - res := make(map[string]string, len(parts)) - for i, v := range parts { - res[fmt.Sprintf("_%d", i)] = v - } - return res -} - -func substring(start, end int, s string) string { - if start < 0 { - start = len(s) + start - } - if end < 0 { - end = len(s) + end - } - if start < 0 { - start = 0 - } - if end > len(s) { - end = len(s) - } - if start > end { - return "" - } - return s[start:end] -} diff --git a/strings_functions.go b/strings_functions.go new file mode 100644 index 0000000..4f66ae0 --- /dev/null +++ b/strings_functions.go @@ -0,0 +1,1159 @@ +package sprout + +import ( + "fmt" + mathrand "math/rand" + "strings" + "unicode" + "unicode/utf8" + + "golang.org/x/text/cases" + "golang.org/x/text/language" +) + +// caseStyle defines the rules for transforming strings based on capitalization, +// separator insertion, and case enforcement. This struct is typically used to +// configure functions that modify the case and formatting of strings to match +// specific coding or display conventions. +// +// Fields: +// +// Separator rune - The character used to separate words in the transformed string. +// For example, underscores (_) or hyphens (-). +// +// CapitalizeNext bool - Determines if the first character of each word should be +// capitalized in the output. Useful for TitleCase or CamelCase. +// +// ForceLowercase bool - If set to true, all characters in the output are converted +// to lowercase, overriding any capitalization rules. +// +// Usage: +// +// This struct can be used to configure string transformation functions, allowing for +// flexible adaptation to various text formatting needs, such as generating identifiers +// or user-friendly display text. +// +// Example: +// +// style := caseStyle{ +// Separator: '_', +// CapitalizeNext: false, +// ForceLowercase: true, +// InsertSeparator: true, +// } +// Use `style` to transform "ExampleText" to "example_text" +type caseStyle struct { + Separator rune // Character that separates words. + CapitalizeNext bool // Whether to capitalize the first character of each word. + ForceLowercase bool // Whether to force all characters to lowercase. + ForceUppercase bool // Whether to force all characters to uppercase. +} + +var ( + camelCaseStyle = caseStyle{Separator: -1, CapitalizeNext: true, ForceLowercase: true} + kebabCaseStyle = caseStyle{Separator: '-', ForceLowercase: true} + pascalCaseStyle = caseStyle{Separator: -1, CapitalizeNext: true} + snakeCaseStyle = caseStyle{Separator: '_', ForceLowercase: true} + dotCaseStyle = caseStyle{Separator: '.', ForceLowercase: true} + pathCaseStyle = caseStyle{Separator: '/', ForceLowercase: true} + constantCaseStyle = caseStyle{Separator: '_', ForceUppercase: true} +) + +// Nospace removes all whitespace characters from the provided string. +// It uses the unicode package to identify whitespace runes and removes them. +// +// Parameters: +// +// str string - the string from which to remove whitespace. +// +// Returns: +// +// string - the modified string with all whitespace characters removed. +// +// Example: +// +// {{ "Hello World" | nospace }} // Output: "HelloWorld" +func (fh *FunctionHandler) Nospace(str string) string { + return strings.Map(func(r rune) rune { + if unicode.IsSpace(r) { + return -1 + } + return r + }, str) +} + +// Trim removes leading and trailing whitespace from the string. +// +// Parameters: +// +// str string - the string to trim. +// +// Returns: +// +// string - the trimmed string. +// +// Example: +// +// {{ " Hello World " | trim }} // Output: "Hello World" +func (fh *FunctionHandler) Trim(str string) string { + return strings.TrimSpace(str) +} + +// TrimAll removes all occurrences of any characters in 'cutset' from both the +// beginning and the end of 'str'. +// +// Parameters: +// +// cutset string - a string of characters to remove from the string. +// str string - the string to trim. +// +// Returns: +// +// string - the string with specified characters removed. +// +// Example: +// +// {{ "xyzHelloxyz" | trimAll "xyz" }} // Output: "Hello" +func (fh *FunctionHandler) TrimAll(cutset string, str string) string { + return strings.Trim(str, cutset) +} + +// TrimPrefix removes the 'prefix' from the start of 'str' if present. +// +// Parameters: +// +// prefix string - the prefix to remove. +// str string - the string to trim. +// +// Returns: +// +// string - the string with the prefix removed if it was present. +// +// Example: +// +// {{ "HelloWorld" | trimPrefix "Hello" }} // Output: "World" +func (fh *FunctionHandler) TrimPrefix(prefix string, str string) string { + return strings.TrimPrefix(str, prefix) +} + +// TrimSuffix removes the 'suffix' from the end of 'str' if present. +// +// Parameters: +// +// suffix string - the suffix to remove. +// str string - the string to trim. +// +// Returns: +// +// string - the string with the suffix removed if it was present. +// +// Example: +// +// {{ "HelloWorld" | trimSuffix "World" }} // Output: "Hello" +func (fh *FunctionHandler) TrimSuffix(suffix string, str string) string { + return strings.TrimSuffix(str, suffix) +} + +// Contains checks if 'str' contains the 'substring'. +// +// Parameters: +// +// substring string - the substring to search for. +// str string - the string to search within. +// +// Returns: +// +// bool - true if 'str' contains 'substring', false otherwise. +// +// Example: +// +// {{ "Hello" | contains "ell" }} // Output: true +func (fh *FunctionHandler) Contains(substring string, str string) bool { + return strings.Contains(str, substring) +} + +// HasPrefix checks if 'str' starts with the specified 'prefix'. +// +// Parameters: +// +// prefix string - the prefix to check. +// str string - the string to check. +// +// Returns: +// +// bool - true if 'str' starts with 'prefix', false otherwise. +// +// Example: +// +// {{ "HelloWorld" | hasPrefix "Hello" }} // Output: true +func (fh *FunctionHandler) HasPrefix(prefix string, str string) bool { + return strings.HasPrefix(str, prefix) +} + +// HasSuffix checks if 'str' ends with the specified 'suffix'. +// +// Parameters: +// +// suffix string - the suffix to check. +// str string - the string to check. +// +// Returns: +// +// bool - true if 'str' ends with 'suffix', false otherwise. +// +// Example: +// +// {{ "HelloWorld" | hasSuffix "World" }} // Output: true +func (fh *FunctionHandler) HasSuffix(suffix string, str string) bool { + return strings.HasSuffix(str, suffix) +} + +// ToLower converts all characters in the provided string to lowercase. +// +// Parameters: +// +// str string - the string to convert. +// +// Returns: +// +// string - the lowercase version of the input string. +// +// Example: +// +// {{ "HELLO WORLD" | toLower }} // Output: "hello world" +func (fh *FunctionHandler) ToLower(str string) string { + return strings.ToLower(str) +} + +// ToUpper converts all characters in the provided string to uppercase. +// +// Parameters: +// +// str string - the string to convert. +// +// Returns: +// +// string - the uppercase version of the input string. +// +// Example: +// +// {{ "hello world" | toUpper }} // Output: "HELLO WORLD" +func (fh *FunctionHandler) ToUpper(str string) string { + return strings.ToUpper(str) +} + +// Replace replaces all occurrences of 'old' in 'src' with 'new'. +// +// Parameters: +// +// old string - the substring to be replaced. +// new string - the substring to replace with. +// src string - the source string where replacements take place. +// +// Returns: +// +// string - the modified string after all replacements. +// +// Example: +// +// {{ "banana" | replace "a", "o" }} // Output: "bonono" +func (fh *FunctionHandler) Replace(old, new, src string) string { + return strings.Replace(src, old, new, -1) +} + +// Repeat repeats the string 'str' for 'count' times. +// +// Parameters: +// +// count int - the number of times to repeat. +// str string - the string to repeat. +// +// Returns: +// +// string - the repeated string. +// +// Example: +// +// {{ "ha" | repeat 3 }} // Output: "hahaha" +func (fh *FunctionHandler) Repeat(count int, str string) string { + return strings.Repeat(str, count) +} + +// Join concatenates the elements of a slice into a single string separated by 'sep'. +// The slice is extracted from 'v', which can be any slice input. The function +// uses 'Strslice' to convert 'v' to a slice of strings if necessary. +// +// Parameters: +// +// sep string - the separator string. +// v any - the slice to join, can be of any slice type. +// +// Returns: +// +// string - the concatenated string. +// +// Example: +// +// {{ $list := slice "apple" "banana" "cherry" }} +// {{ $list | join ", " }} // Output: "apple, banana, cherry" +func (fh *FunctionHandler) Join(sep string, v any) string { + return strings.Join(fh.StrSlice(v), sep) +} + +// Trunc truncates 's' to a maximum length 'count'. If 'count' is negative, it removes +// '-count' characters from the beginning of the string. +// +// Parameters: +// +// count int - the number of characters to keep. Negative values indicate truncation +// from the beginning. +// str string - the string to truncate. +// +// Returns: +// +// string - the truncated string. +// +// Example: +// +// {{ "Hello World" | trunc 5 }} // Output: "Hello" +// {{ "Hello World" | trunc -1 }} // Output: "World" +func (fh *FunctionHandler) Trunc(count int, str string) string { + length := len(str) + + if count < 0 && length+count > 0 { + return str[length+count:] + } + + if count >= 0 && length > count { + return str[:count] + } + + return str +} + +// Shuffle randomly rearranges the characters in 'str'. +// +// Parameters: +// +// str string - the string to shuffle. +// +// Returns: +// +// string - the shuffled string. +// +// Example: +// +// {{ "hello" | shuffle }} // Output: "loleh" (output may vary due to randomness) +func (fh *FunctionHandler) Shuffle(str string) string { + r := []rune(str) + mathrand.New(randSource).Shuffle(len(r), func(i, j int) { + r[i], r[j] = r[j], r[i] + }) + return string(r) +} + +// ellipsis truncates 'str' from both ends, preserving the middle part of +// the string and appending ellipses to both ends if needed. +// +// Parameters: +// +// offset int - starting position for preserving text. +// maxWidth int - the maximum width of the string including the ellipsis. +// str string - the string to truncate. +// +// Returns: +// +// string - the possibly truncated string with an ellipsis. +func (fh *FunctionHandler) ellipsis(str string, offset int, maxWidth int) string { + ellipsis := "..." + // Return the original string if maxWidth is less than 4, or the offset + // create exclusive dot string, it's not possible to add an ellipsis. + if maxWidth < 4 || offset > 0 && maxWidth < 7 { + return str + } + + runeCount := utf8.RuneCountInString(str) + + // If the string doesn't need trimming, return it as is. + if runeCount <= maxWidth || runeCount <= offset { + return str[offset:] + } + + // Determine end position for the substring, ensuring room for the ellipsis. + endPos := offset + maxWidth - 3 // 3 is for the length of the ellipsis + if offset > 0 { + endPos -= 3 // remove the left ellipsis + } + + // Convert the string to a slice of runes to properly handle multi-byte characters. + runes := []rune(str) + + // Return the substring with an ellipsis, directly constructing the string in the return statement. + if offset > 0 { + return ellipsis + string(runes[offset:endPos]) + ellipsis + } + return string(runes[offset:endPos]) + ellipsis +} + +// Ellipsis truncates 'str' to 'maxWidth' and appends an ellipsis if the string +// is longer than 'maxWidth'. +// +// Parameters: +// +// maxWidth int - the maximum width of the string including the ellipsis. +// str string - the string to truncate. +// +// Returns: +// +// string - the possibly truncated string with an ellipsis. +// +// Example: +// +// {{ "Hello World" | ellipsis 10 }} // Output: "Hello W..." +func (fh *FunctionHandler) Ellipsis(maxWidth int, str string) string { + return fh.ellipsis(str, 0, maxWidth) +} + +// EllipsisBoth truncates 'str' from both ends, preserving the middle part of +// the string and appending ellipses to both ends if needed. +// +// Parameters: +// +// offset int - starting position for preserving text. +// maxWidth int - the total maximum width including ellipses. +// str string - the string to truncate. +// +// Returns: +// +// string - the truncated string with ellipses on both ends. +// +// Example: +// +// {{ "Hello World" | ellipsisBoth 1 10 }} // Output: "...lo Wor..." +func (fh *FunctionHandler) EllipsisBoth(offset int, maxWidth int, str string) string { + return fh.ellipsis(str, offset, maxWidth) +} + +// Initials extracts the initials from 'str', using optional 'delimiters' to +// determine word boundaries. +// +// Parameters: +// +// str string - the string from which to extract initials. +// delimiters string - optional string containing delimiter characters. +// +// Returns: +// +// string - the initials of the words in 'str'. +// +// Example: +// +// {{ "John Doe" | initials }} // Output: "JD" +func (fh *FunctionHandler) Initials(str string) string { + return fh.initials(str, " ") +} + +// initials extracts the initials from 'str', using 'delimiters' to determine +// word boundaries. +// +// Parameters: +// +// str string - the string from which to extract initials. +// delimiters string - the string containing delimiter characters. +// +// Returns: +// +// string - the initials of the words in 'str'. +func (fh *FunctionHandler) initials(str string, delimiters string) string { + // Define a function to determine if a rune is a delimiter. + isDelimiter := func(r rune) bool { + return strings.ContainsRune(delimiters, r) + } + + words := strings.FieldsFunc(str, isDelimiter) + var runes = make([]rune, len(words)) + for i, word := range strings.FieldsFunc(str, isDelimiter) { + if i == 0 || unicode.IsLetter(rune(word[0])) { + runes[i] = rune(word[0]) + } + } + + return string(runes) +} + +// Plural returns 'one' if 'count' is 1, otherwise it returns 'many'. +// +// Parameters: +// +// one string - the string to return if 'count' is 1. +// many string - the string to return if 'count' is not 1. +// count int - the number used to determine which string to return. +// +// Returns: +// +// string - either 'one' or 'many' based on 'count'. +// +// Example: +// +// {{ 1 | plural "apple" "apples" }} // Output: "apple" +// {{ 2 | plural "apple" "apples" }} // Output: "apples" +func (fh *FunctionHandler) Plural(one, many string, count int) string { + if count == 1 { + return one + } + return many +} + +// Wrap breaks 'str' into lines with a maximum length of 'length'. +// It ensures that words are not split across lines unless necessary. +// +// Parameters: +// +// length int - the maximum length of each line. +// str string - the string to be wrapped. +// +// Returns: +// +// string - the wrapped string using newline characters to separate lines. +// +// Example: +// +// {{ "This is a long string that needs to be wrapped." | wrap 10 }} +// Output: "This is a\nlong\nstring\nthat needs\nto be\nwrapped." +func (fh *FunctionHandler) Wrap(length int, str string) string { + return fh.WordWrap(length, "", false, str) +} + +// WrapWith breaks 'str' into lines of maximum 'length', using 'newLineCharacter' +// to separate lines. It wraps words only when they exceed the line length. +// +// Parameters: +// +// length int - the maximum line length. +// newLineCharacter string - the character(s) used to denote new lines. +// str string - the string to wrap. +// +// Returns: +// +// string - the wrapped string. +// +// Example: +// +// {{ "This is a long string that needs to be wrapped." | wrapWith 10 "
" }} +// Output: "This is a
long
string
that needs
to be
wrapped." +func (fh *FunctionHandler) WrapWith(length int, newLineCharacter string, str string) string { + return fh.WordWrap(length, newLineCharacter, true, str) +} + +// WordWrap formats 'str' into lines of maximum 'wrapLength', optionally wrapping +// long words and using 'newLineCharacter' for line breaks. +// +// Parameters: +// +// str string - the string to wrap. +// wrapLength int - the maximum length of each line. +// newLineCharacter string - the string used to denote new lines. +// wrapLongWords bool - true to wrap long words that exceed the line length. +// +// Returns: +// +// string - the wrapped string. +// +// Example: +// +// {{ "A very longwordindeed that cannot fit on one line." | wordWrap 10 "\n" true }} +// Output: "A very\nlongwordin\ndeed that\ncannot fit\non one\nline." +func (fh *FunctionHandler) WordWrap(wrapLength int, newLineCharacter string, wrapLongWords bool, str string) string { + if wrapLength < 1 { + wrapLength = 1 + } + if newLineCharacter == "" { + newLineCharacter = "\n" + } + + var resultBuilder strings.Builder + var currentLineLength int + + for _, word := range strings.Fields(str) { + wordLength := utf8.RuneCountInString(word) + + // If the word is too long and should be wrapped, or it fits in the remaining line length + if currentLineLength > 0 && (currentLineLength+1+wordLength > wrapLength && !wrapLongWords || currentLineLength+1+wordLength > wrapLength) { + resultBuilder.WriteString(newLineCharacter) + currentLineLength = 0 + } + + if wrapLongWords && wordLength > wrapLength { + for i, r := range word { + resultBuilder.WriteRune(r) + currentLineLength++ + // Avoid adding a new line immediately after wrapping a long word + if i < len(word)-1 && currentLineLength == wrapLength { + resultBuilder.WriteString(newLineCharacter) + currentLineLength = 0 + } + } + } else { + if currentLineLength > 0 { + resultBuilder.WriteRune(' ') + currentLineLength++ + } + resultBuilder.WriteString(word) + currentLineLength += wordLength + } + } + + return resultBuilder.String() +} + +// Quote wraps each element in 'elements' with double quotes and separates them with spaces. +// +// Parameters: +// +// elements ...any - the elements to be quoted. +// +// Returns: +// +// string - a single string with each element double quoted. +// +// Example: +// +// {{ $list := slice "hello" "world" 123 }} +// {{ $list | quote }} +// Output: "hello" "world" "123" +func (fh *FunctionHandler) Quote(elements ...any) string { + var build strings.Builder + + for i, elem := range elements { + if elem == nil { + continue + } + if i > 0 { + build.WriteRune(' ') + } + build.WriteString(fmt.Sprintf("%q", fmt.Sprint(elem))) + } + return build.String() +} + +// Squote wraps each element in 'elements' with single quotes and separates them with spaces. +// +// Parameters: +// +// elements ...any - the elements to be single quoted. +// +// Returns: +// +// string - a single string with each element single quoted. +// +// Example: +// +// {{ $list := slice "hello" "world" 123 }} +// {{ $list | squote }} +// Output: 'hello' 'world' '123' +func (fh *FunctionHandler) Squote(elements ...any) string { + var builder strings.Builder + for i, elem := range elements { + if elem == nil { + continue + } + if i > 0 { + builder.WriteRune(' ') + } + // Use fmt.Sprint to convert any to string, then quote it. + builder.WriteRune('\'') + builder.WriteString(fmt.Sprint(elem)) + builder.WriteRune('\'') + } + return builder.String() +} + +// transformString modifies the string 'str' based on various case styling rules +// specified in the 'style' parameter. It can capitalize, lowercase, and insert +// separators according to the rules provided. +// +// Parameters: +// +// style caseStyle - a struct specifying how to transform the string, including +// capitalization rules, insertion of separators, and whether +// to force lowercase. +// str string - the string to transform. +// +// Returns: +// +// string - the transformed string. +// +// Example: +// +// style := caseStyle{ +// Separator: '_', +// CapitalizeNext: true, +// ForceLowercase: false, +// InsertSeparator: true, +// } +// transformed := fh.transformString(style, "hello world") +// Output: "Hello_World" +// +// Note: +// +// This example demonstrates how to use the function to capitalize the first letter of +// each word and insert underscores between words, which is common in identifiers like +// variable names in programming. +func (fh *FunctionHandler) transformString(style caseStyle, str string) string { + var result strings.Builder + result.Grow(len(str) + 10) // Allocate a bit more for potential separators + capitalizeNext := style.CapitalizeNext + var lastRune, nextRune rune = 0, 0 + + for i, r := range str { + if i+1 < len(str) { + nextRune = rune(str[i+1]) + } + + if r == ' ' || r == '-' || r == '_' { + if style.Separator != -1 && (lastRune != style.Separator) { + result.WriteRune(style.Separator) + } + capitalizeNext = true + lastRune = style.Separator + continue + } + + if unicode.IsUpper(r) && style.Separator != -1 && result.Len() > 0 && lastRune != style.Separator { + if (unicode.IsUpper(lastRune) && unicode.IsUpper(r) && unicode.IsLower(nextRune)) || (unicode.IsUpper(r) && unicode.IsLower(lastRune)) { + result.WriteRune(style.Separator) + } + } + + if style.Separator != -1 && lastRune != style.Separator && (unicode.IsDigit(r) && !unicode.IsDigit(lastRune)) { + result.WriteRune(style.Separator) + } + + if capitalizeNext && style.CapitalizeNext { + result.WriteRune(unicode.ToUpper(r)) + capitalizeNext = false + } else if style.ForceLowercase { + result.WriteRune(unicode.ToLower(r)) + } else if style.ForceUppercase { + result.WriteRune(unicode.ToUpper(r)) + } else { + result.WriteRune(r) + } + lastRune = r // Update lastRune to the current rune + } + + return result.String() +} + +// ToCamelCase converts a string to camelCase. +// +// Parameters: +// +// str string - the string to convert. +// +// Returns: +// +// string - the string converted to camelCase. +// +// Example: +// +// {{ "hello world" | toCamelCase }} // Output: "helloWorld" +func (fh *FunctionHandler) ToCamelCase(str string) string { + return fh.transformString(camelCaseStyle, str) +} + +// ToKebabCase converts a string to kebab-case. +// +// Parameters: +// +// str string - the string to convert. +// +// Returns: +// +// string - the string converted to kebab-case. +// +// Example: +// +// {{ "hello world" | toKebabCase }} // Output: "hello-world" +func (fh *FunctionHandler) ToKebabCase(str string) string { + return fh.transformString(kebabCaseStyle, str) +} + +// ToPascalCase converts a string to PascalCase. +// +// Parameters: +// +// str string - the string to convert. +// +// Returns: +// +// string - the string converted to PascalCase. +// +// Example: +// +// {{ "hello world" | toPascalCase }} // Output: "HelloWorld" +func (fh *FunctionHandler) ToPascalCase(str string) string { + return fh.transformString(pascalCaseStyle, str) +} + +// ToDotCase converts a string to dot.case. +// +// Parameters: +// +// str string - the string to convert. +// +// Returns: +// +// string - the string converted to dot.case. +// +// Example: +// +// {{ "hello world" | toDotCase }} // Output: "hello.world" +func (fh *FunctionHandler) ToDotCase(str string) string { + return fh.transformString(dotCaseStyle, str) +} + +// ToPathCase converts a string to path/case. +// +// Parameters: +// +// str string - the string to convert. +// +// Returns: +// +// string - the string converted to path/case. +// +// Example: +// +// {{ "hello world" | toPathCase }} // Output: "hello/world" +func (fh *FunctionHandler) ToPathCase(str string) string { + return fh.transformString(pathCaseStyle, str) +} + +// ToConstantCase converts a string to CONSTANT_CASE. +// +// Parameters: +// +// str string - the string to convert. +// +// Returns: +// +// string - the string converted to CONSTANT_CASE. +// +// Example: +// +// {{ "hello world" | toConstantCase }} // Output: "HELLO_WORLD" +func (fh *FunctionHandler) ToConstantCase(str string) string { + return fh.transformString(constantCaseStyle, str) +} + +// ToSnakeCase converts a string to snake_case. +// +// Parameters: +// +// str string - the string to convert. +// +// Returns: +// +// string - the string converted to snake_case. +// +// Example: +// +// {{ "hello world" | toSnakeCase }} // Output: "hello_world" +func (fh *FunctionHandler) ToSnakeCase(str string) string { + return fh.transformString(snakeCaseStyle, str) +} + +// ToTitleCase converts a string to Title Case. +// +// Parameters: +// +// str string - the string to convert. +// +// Returns: +// +// string - the string converted to Title Case. +// +// Example: +// +// {{ "hello world" | toTitleCase }} // Output: "Hello World" +func (fh *FunctionHandler) ToTitleCase(str string) string { + return cases.Title(language.English).String(str) +} + +// Untitle converts the first letter of each word in 'str' to lowercase. +// +// Parameters: +// +// str string - the string to be converted. +// +// Returns: +// +// string - the converted string with each word starting in lowercase. +// +// Example: +// +// {{ "Hello World" | untitle }} // Output: "hello world" +func (fh *FunctionHandler) Untitle(str string) string { + var result strings.Builder + + // Process each rune in the input string + startOfWord := true + for _, r := range str { + if r == ' ' { + startOfWord = true + result.WriteRune(r) + } else { + if startOfWord { + result.WriteRune(unicode.ToLower(r)) + startOfWord = false + } else { + result.WriteRune(r) + } + } + } + + return result.String() +} + +// SwapCase switches the case of each letter in 'str'. Lowercase letters become +// uppercase and vice versa. +// +// Parameters: +// +// str string - the string to convert. +// +// Returns: +// +// string - the string with each character's case switched. +// +// Example: +// +// {{ "Hello World" | swapCase }} // Output: "hELLO wORLD" +func (fh *FunctionHandler) SwapCase(str string) string { + return strings.Map(func(r rune) rune { + if unicode.IsLower(r) { + return unicode.ToUpper(r) + } + return unicode.ToLower(r) + }, str) +} + +// Split divides 'orig' into a map of string parts using 'sep' as the separator. +// +// Parameters: +// +// sep string - the separator string. +// orig string - the original string to split. +// +// Returns: +// +// map[string]string - a map of the split parts. +// +// Example: +// +// {{ "apple,banana,cherry" | split "," }} // Output: { "_0":"apple", "_1":"banana", "_2":"cherry" } +func (fh *FunctionHandler) Split(sep, orig string) map[string]string { + parts := strings.Split(orig, sep) + return fh.populateMapWithParts(parts) +} + +// Splitn divides 'orig' into a map of string parts using 'sep' as the separator +// up to 'n' parts. +// +// Parameters: +// +// sep string - the separator string. +// n int - the maximum number of substrings to return. +// orig string - the original string to split. +// +// Returns: +// +// map[string]string - a map of the split parts. +// +// Example: +// +// {{ "apple,banana,cherry" | split "," 2 }} // Output: { "_0":"apple", "_1":"banana,cherry" } +func (fh *FunctionHandler) Splitn(sep string, n int, orig string) map[string]string { + parts := strings.SplitN(orig, sep, n) + return fh.populateMapWithParts(parts) +} + +// populateMapWithParts converts an array of strings into a map with keys based +// on the index of each string. +// +// Parameters: +// +// parts []string - the array of strings to be converted into a map. +// +// Returns: +// +// map[string]string - a map where each key corresponds to an index (with an underscore prefix) of the string in the input array. +// +// Example: +// +// parts := []string{"apple", "banana", "cherry"} +// result := fh.populateMapWithParts(parts) +// fmt.Println(result) // Output: {"_0": "apple", "_1": "banana", "_2": "cherry"} +func (fh *FunctionHandler) populateMapWithParts(parts []string) map[string]string { + res := make(map[string]string, len(parts)) + for i, v := range parts { + res[fmt.Sprintf("_%d", i)] = v + } + return res +} + +// Substring extracts a substring from 's' starting at 'start' and ending at 'end'. +// Negative values for 'start' or 'end' are interpreted as positions from the end +// of the string. +// +// Parameters: +// +// start int - the starting index. +// end int - the ending index, exclusive. +// str string - the source string. +// +// Returns: +// +// string - the extracted substring. +// +// Example: +// +// {{ "Hello World" | substring 0 5 }} // Output: "Hello" +func (fh *FunctionHandler) Substring(start, end int, str string) string { + if start < 0 { + start = len(str) + start + } + if end < 0 { + end = len(str) + end + } + if start < 0 { + start = 0 + } + if end > len(str) { + end = len(str) + } + if start > end { + return "" + } + return str[start:end] +} + +// Indent adds spaces to the beginning of each line in 'str'. +// +// Parameters: +// +// spaces int - the number of spaces to add. +// str string - the string to indent. +// +// Returns: +// +// string - the indented string. +// +// Example: +// +// {{ "Hello\nWorld" | indent 4 }} // Output: " Hello\n World" +func (fh *FunctionHandler) Indent(spaces int, str string) string { + var builder strings.Builder + pad := strings.Repeat(" ", spaces) + lines := strings.Split(str, "\n") + + for i, line := range lines { + if i > 0 { + builder.WriteString("\n" + pad) + } else { + builder.WriteString(pad) + } + builder.WriteString(line) + } + + return builder.String() +} + +// Nindent is similar to Indent, but it adds a newline at the start. +// +// Parameters: +// spaces int - the number of spaces to add after the newline. +// str string - the string to indent. +// +// Returns: +// string - the indented string with a newline at the start. +// +// Example: +// {{ "Hello\nWorld" | nindent 4 }} // Output: "\n Hello\n World" + +func (fh *FunctionHandler) Nindent(spaces int, str string) string { + return "\n" + fh.Indent(spaces, str) +} + +// Seq generates a sequence of numbers as a string. It can take 0, 1, 2, or 3 +// integers as parameters defining the start, end, and step of the sequence. +// NOTE: This function works similarly to the seq command in Unix systems. +// +// Parameters: +// +// params ...int - sequence parameters (start, step, end). +// +// Returns: +// +// string - a space-separated string of numbers in the sequence. +// +// Example: +// +// {{ seq 1, 2, 10 }} // Output: "1 3 5 7 9" +func (fh *FunctionHandler) Seq(params ...int) string { + increment := 1 + switch len(params) { + case 0: + return "" + case 1: + start := 1 + end := params[0] + if end < start { + increment = -1 + } + return fh.convertIntArrayToString(fh.UntilStep(start, end+increment, increment), " ") + case 3: + start := params[0] + end := params[2] + step := params[1] + if end < start { + increment = -1 + if step > 0 { + return "" + } + } + return fh.convertIntArrayToString(fh.UntilStep(start, end+increment, step), " ") + case 2: + start := params[0] + end := params[1] + step := 1 + if end < start { + step = -1 + } + return fh.convertIntArrayToString(fh.UntilStep(start, end+step, step), " ") + default: + return "" + } +} + +// convertIntArrayToString converts an array of integers into a single string +// with elements separated by a given delimiter. +// +// Parameters: +// +// slice []int - the array of integers to convert. +// delimiter string - the string to use as a delimiter between the integers in the output string. +// +// Returns: +// +// string - the resulting string that concatenates all the integers in the array separated by the specified delimiter. +// +// Example: +// +// slice := []int{1, 2, 3, 4, 5} +// result := fh.convertIntArrayToString(slice, ", ") +// fmt.Println(result) // Output: "1, 2, 3, 4, 5" +func (fh *FunctionHandler) convertIntArrayToString(slice []int, delimeter string) string { + return strings.Trim(strings.Join(strings.Fields(fmt.Sprint(slice)), delimeter), "[]") +} diff --git a/strings_functions_test.go b/strings_functions_test.go new file mode 100644 index 0000000..a46f2f3 --- /dev/null +++ b/strings_functions_test.go @@ -0,0 +1,502 @@ +package sprout + +import ( + mathrand "math/rand" + "testing" +) + +func TestNoSpace(t *testing.T) { + var tests = testCases{ + {"TestEmpty", `{{ "" | nospace }}`, "", nil}, + {"TestSpaceOnly", `{{ " " | nospace }}`, "", nil}, + {"TestLeadingSpace", `{{ " foo" | nospace }}`, "foo", nil}, + {"TestTrailingSpace", `{{ "foo " | nospace }}`, "foo", nil}, + {"TestLeadingAndTrailingSpace", `{{ " foo " | nospace }}`, "foo", nil}, + {"TestMultipleSpaces", `{{ " foo bar " | nospace }}`, "foobar", nil}, + } + + runTestCases(t, tests) +} + +func TestTrim(t *testing.T) { + var tests = testCases{ + {"TestEmpty", `{{ "" | trim }}`, "", nil}, + {"TestSpaceOnly", `{{ " " | trim }}`, "", nil}, + {"TestLeadingSpace", `{{ " foo" | trim }}`, "foo", nil}, + {"TestTrailingSpace", `{{ "foo " | trim }}`, "foo", nil}, + {"TestLeadingAndTrailingSpace", `{{ " foo " | trim }}`, "foo", nil}, + {"TestMultipleSpaces", `{{ " foo bar " | trim }}`, "foo bar", nil}, + } + + runTestCases(t, tests) +} + +func TestTrimAll(t *testing.T) { + var tests = testCases{ + {"TestEmpty", `{{ "" | trimAll "-" }}`, "", nil}, + {"TestAllDashes", `{{ "---------" | trimAll "-" }}`, "", nil}, + {"TestNoDashes", `{{ "foo" | trimAll "-" }}`, "foo", nil}, + {"TestSomeDashes", `{{ "-f--o-o-" | trimAll "-" }}`, "f--o-o", nil}, + {"TestOtherDashes", `{{ "-f--o-o-" | trimAll "-o" }}`, "f", nil}, + } + + runTestCases(t, tests) +} + +func TestTrimPrefix(t *testing.T) { + var tests = testCases{ + {"TestEmpty", `{{ "" | trimPrefix "-" }}`, "", nil}, + {"TestDoubleDash", `{{ "--" | trimPrefix "-" }}`, "-", nil}, + {"TestNoPrefix", `{{ "foo" | trimPrefix "-" }}`, "foo", nil}, + {"TestSinglePrefix", `{{ "-foo-" | trimPrefix "-" }}`, "foo-", nil}, + {"TestMultiplePrefix", `{{ "-foo-" | trimPrefix "-f" }}`, "oo-", nil}, + } + + runTestCases(t, tests) +} + +func TestTrimSuffix(t *testing.T) { + var tests = testCases{ + {"TestEmpty", `{{ "" | trimSuffix "-" }}`, "", nil}, + {"TestDoubleDash", `{{ "--" | trimSuffix "-" }}`, "-", nil}, + {"TestNoSuffix", `{{ "foo" | trimSuffix "-" }}`, "foo", nil}, + {"TestSingleSuffix", `{{ "-foo-" | trimSuffix "-" }}`, "-foo", nil}, + {"TestMultipleSuffix", `{{ "-foo-" | trimSuffix "o-" }}`, "-fo", nil}, + } + + runTestCases(t, tests) +} + +func TestContains(t *testing.T) { + var tests = testCases{ + {"TestEmpty", `{{ "" | contains "-" }}`, "false", nil}, + {"TestContains", `{{ "foo" | contains "o" }}`, "true", nil}, + {"TestNotContains", `{{ "foo" | contains "x" }}`, "false", nil}, + } + + runTestCases(t, tests) +} + +func TestHasPrefix(t *testing.T) { + var tests = testCases{ + {"TestEmpty", `{{ "" | hasPrefix "-" }}`, "false", nil}, + {"TestHasPrefix", `{{ "foo" | hasPrefix "f" }}`, "true", nil}, + {"TestNotHasPrefix", `{{ "foo" | hasPrefix "o" }}`, "false", nil}, + } + + runTestCases(t, tests) +} + +func TestHasSuffix(t *testing.T) { + var tests = testCases{ + {"TestEmpty", `{{ "" | hasSuffix "-" }}`, "false", nil}, + {"TestHasSuffix", `{{ "foo" | hasSuffix "o" }}`, "true", nil}, + {"TestNotHasSuffix", `{{ "foo" | hasSuffix "f" }}`, "false", nil}, + } + + runTestCases(t, tests) +} + +func TestToLower(t *testing.T) { + var tests = testCases{ + {"TestEmpty", `{{ "" | toLower }}`, "", nil}, + {"TestLower", `{{ "foo" | toLower }}`, "foo", nil}, + {"TestUpper", `{{ "FOO" | toLower }}`, "foo", nil}, + } + + runTestCases(t, tests) +} + +func TestToUpper(t *testing.T) { + var tests = testCases{ + {"TestEmpty", `{{ "" | toUpper }}`, "", nil}, + {"TestLower", `{{ "foo" | toUpper }}`, "FOO", nil}, + {"TestUpper", `{{ "FOO" | toUpper }}`, "FOO", nil}, + } + + runTestCases(t, tests) +} + +func TestReplace(t *testing.T) { + var tests = testCases{ + {"TestEmpty", `{{ "" | replace "-" "x" }}`, "", nil}, + {"TestReplace", `{{ "foo" | replace "o" "x" }}`, "fxx", nil}, + {"TestNotReplace", `{{ "foo" | replace "x" "y" }}`, "foo", nil}, + {"TestMultipleReplace", `{{ "foo" | replace "o" "x" | replace "f" "y" }}`, "yxx", nil}, + } + + runTestCases(t, tests) +} + +func TestRepeat(t *testing.T) { + var tests = testCases{ + {"TestEmpty", `{{ "" | repeat 3 }}`, "", nil}, + {"TestRepeat", `{{ "foo" | repeat 3 }}`, "foofoofoo", nil}, + {"TestRepeatZero", `{{ "foo" | repeat 0 }}`, "", nil}, + } + + runTestCases(t, tests) +} + +func TestJoin(t *testing.T) { + var tests = testCases{ + {"TestNil", `{{ .nil | join "-" }}`, "", map[string]any{"nil": nil}}, + {"TestIntSlice", `{{ .test | join "-" }}`, "1-2-3", map[string]any{"test": []int{1, 2, 3}}}, + {"TestStringSlice", `{{ .test | join "-" }}`, "a-b-c", map[string]any{"test": []string{"a", "b", "c"}}}, + {"TestString", `{{ .test | join "-" }}`, "abc", map[string]any{"test": "abc"}}, + {"TestMixedSlice", `{{ .test | join "-" }}`, "a-1-true", map[string]any{"test": []any{"a", nil, 1, true}}}, + } + + runTestCases(t, tests) +} + +func TestTrunc(t *testing.T) { + var tests = testCases{ + {"TestEmpty", `{{ "" | trunc 3 }}`, "", nil}, + {"TestTruncate", `{{ "foooooo" | trunc 3 }}`, "foo", nil}, + {"TestNegativeTruncate", `{{ "foobar" | trunc -3 }}`, "bar", nil}, + {"TestNegativeLargeTruncate", `{{ "foobar" | trunc -999 }}`, "foobar", nil}, + {"TestZeroTruncate", `{{ "foobar" | trunc 0 }}`, "", nil}, + } + + runTestCases(t, tests) +} + +func TestShuffle(t *testing.T) { + originalRandSource := randSource + defer func() { + randSource = originalRandSource + }() + + randSource = mathrand.NewSource(0) + + var tests = testCases{ + {"TestEmpty", `{{ "" | shuffle }}`, "", nil}, + {"TestShuffle", `{{ "foobar" | shuffle }}`, "abfoor", nil}, + } + + runTestCases(t, tests) +} + +func TestEllipsis(t *testing.T) { + var tests = testCases{ + {"TestEmpty", `{{ "" | ellipsis 3 }}`, "", nil}, + {"TestShort", `{{ "foo" | ellipsis 5 }}`, "foo", nil}, + {"TestTruncate", `{{ "foooooo" | ellipsis 6 }}`, "foo...", nil}, + {"TestZeroTruncate", `{{ "foobar" | ellipsis 0 }}`, "foobar", nil}, + } + + runTestCases(t, tests) +} + +func TestEllipsisBoth(t *testing.T) { + var tests = testCases{ + {"TestEmpty", `{{ "" | ellipsisBoth 3 5 }}`, "", nil}, + {"TestShort", `{{ "foo" | ellipsisBoth 5 4 }}`, "foo", nil}, + {"TestTruncate", `{{ "foooboooooo" | ellipsisBoth 4 9 }}`, "...boo...", nil}, + {"TestZeroTruncate", `{{ "foobar" | ellipsisBoth 0 0 }}`, "foobar", nil}, + } + + runTestCases(t, tests) +} + +func TestInitials(t *testing.T) { + var tests = testCases{ + {"TestEmpty", `{{ "" | initials }}`, "", nil}, + {"TestSingle", `{{ "f" | initials }}`, "f", nil}, + {"TestTwo", `{{ "foo" | initials }}`, "f", nil}, + {"TestThree", `{{ "foo bar" | initials }}`, "fb", nil}, + {"TestMultipleSpaces", `{{ "foo bar" | initials }}`, "fb", nil}, + {"TestWithUppercased", `{{ " Foo bar" | initials }}`, "Fb", nil}, + } + + runTestCases(t, tests) +} + +func TestPlural(t *testing.T) { + var tests = testCases{ + {"TestZero", `{{ 0 | plural "single" "many" }}`, "many", nil}, + {"TestSingle", `{{ 1 | plural "single" "many" }}`, "single", nil}, + {"TestMultiple", `{{ 2 | plural "single" "many" }}`, "many", nil}, + {"TestNegative", `{{ -1 | plural "single" "many" }}`, "many", nil}, + } + + runTestCases(t, tests) +} + +func TestWrap(t *testing.T) { + var tests = testCases{ + {"TestEmpty", `{{ "" | wrap 10 }}`, "", nil}, + {"TestNegativeWrap", `{{ wrap -1 "With a negative wrap." }}`, "With\na\nnegative\nwrap.", nil}, + {"TestWrap", `{{ "This is a long string that needs to be wrapped." | wrap 10 }}`, "This is a\nlong\nstring\nthat needs\nto be\nwrapped.", nil}, + } + + runTestCases(t, tests) +} + +func TestWrapWith(t *testing.T) { + var tests = testCases{ + {"TestEmpty", `{{ "" | wrapWith 10 "\t" }}`, "", nil}, + {"TestWrap", `{{ "This is a long string that needs to be wrapped." | wrapWith 10 "\t" }}`, "This is a\tlong\tstring\tthat needs\tto be\twrapped.", nil}, + {"TestWrapWithLongWord", `{{ "This is a long string that needs to be wrapped with a looooooooooooooooooooooooooooooooooooong word." | wrapWith 10 "\t" }}`, "This is a\tlong\tstring\tthat needs\tto be\twrapped\twith a\tlooooooooo\toooooooooo\toooooooooo\toooooooong\tword.", nil}, + } + + runTestCases(t, tests) +} + +func TestQuote(t *testing.T) { + var tests = testCases{ + {"TestEmpty", `{{ "" | quote }}`, `""`, nil}, + {"TestNil", `{{ quote .nil }}`, ``, map[string]any{"nil": nil}}, + {"TestQuote", `{{ "foo" | quote }}`, `"foo"`, nil}, + {"TestSpace", `{{ "foo bar" | quote }}`, `"foo bar"`, nil}, + {"TestQuote", `{{ "foo \"bar\"" | quote }}`, `"foo \"bar\""`, nil}, + {"TestNewline", `{{ "foo\nbar" | quote }}`, `"foo\nbar"`, nil}, + {"TestBackslash", `{{ "foo\\bar" | quote }}`, `"foo\\bar"`, nil}, + {"TestBackslashAndQuote", `{{ "foo\\\"bar" | quote }}`, `"foo\\\"bar"`, nil}, + {"TestUnicode", `{{ quote "foo" "👍" }}`, `"foo" "👍"`, nil}, + } + + runTestCases(t, tests) +} + +func TestSquote(t *testing.T) { + var tests = testCases{ + {"TestEmpty", `{{ "" | squote }}`, "''", nil}, + {"TestNil", `{{ squote .nil }}`, "", map[string]any{"nil": nil}}, + {"TestQuote", `{{ "foo" | squote }}`, "'foo'", nil}, + {"TestSpace", `{{ "foo bar" | squote }}`, "'foo bar'", nil}, + {"TestQuote", `{{ "foo 'bar'" | squote }}`, "'foo 'bar''", nil}, + {"TestNewline", `{{ "foo\nbar" | squote }}`, "'foo\nbar'", nil}, + {"TestBackslash", `{{ "foo\\bar" | squote }}`, "'foo\\bar'", nil}, + {"TestBackslashAndQuote", `{{ "foo\\'bar" | squote }}`, "'foo\\'bar'", nil}, + {"TestUnicode", `{{ squote "foo" "👍" }}`, "'foo' '👍'", nil}, + } + + runTestCases(t, tests) +} + +func TestToCamelCase(t *testing.T) { + var tests = testCases{ + {"TestEmpty", `{{ "" | toCamelCase }}`, "", nil}, + {"TestCamelCase", `{{ "foo bar" | toCamelCase }}`, "FooBar", nil}, + {"TestCamelCaseWithSpace", `{{ "foo bar" | toCamelCase }}`, "FooBar", nil}, + {"TestCamelCaseWithUnderscore", `{{ "foo_bar" | toCamelCase }}`, "FooBar", nil}, + {"TestCamelCaseWithHyphen", `{{ "foo-bar" | toCamelCase }}`, "FooBar", nil}, + {"TestCamelCaseWithMixed", `{{ "foo-bar_baz" | toCamelCase }}`, "FooBarBaz", nil}, + {"", `{{ toCamelCase "_complex__case_" }}`, "ComplexCase", nil}, + {"", `{{ toCamelCase "_camel_case" }}`, "CamelCase", nil}, + {"", `{{ toCamelCase "http_server" }}`, "HttpServer", nil}, + {"", `{{ toCamelCase "no_https" }}`, "NoHttps", nil}, + {"", `{{ toCamelCase "all" }}`, "All", nil}, + } + + runTestCases(t, tests) +} + +func TestToKebakCase(t *testing.T) { + var tests = testCases{ + {"TestEmpty", `{{ "" | toKebabCase }}`, "", nil}, + {"TestKebabCase", `{{ "foo bar" | toKebabCase }}`, "foo-bar", nil}, + {"TestKebabCaseWithSpace", `{{ "foo bar" | toKebabCase }}`, "foo-bar", nil}, + {"TestKebabCaseWithUnderscore", `{{ "foo_bar" | toKebabCase }}`, "foo-bar", nil}, + {"TestKebabCaseWithHyphen", `{{ "foo-bar" | toKebabCase }}`, "foo-bar", nil}, + {"TestKebabCaseWithMixed", `{{ "foo-bar_baz" | toKebabCase }}`, "foo-bar-baz", nil}, + {"", `{{ toKebabCase "HTTPServer" }}`, "http-server", nil}, + {"", `{{ toKebabCase "FirstName" }}`, "first-name", nil}, + {"", `{{ toKebabCase "NoHTTPS" }}`, "no-https", nil}, + {"", `{{ toKebabCase "GO_PATH" }}`, "go-path", nil}, + {"", `{{ toKebabCase "GO PATH" }}`, "go-path", nil}, + {"", `{{ toKebabCase "GO-PATH" }}`, "go-path", nil}, + } + + runTestCases(t, tests) +} + +func TestToPascalCase(t *testing.T) { + var tests = testCases{ + {"TestEmpty", `{{ "" | toPascalCase }}`, "", nil}, + {"TestPascalCase", `{{ "foo bar" | toPascalCase }}`, "FooBar", nil}, + {"TestPascalCaseWithSpace", `{{ "foo bar" | toPascalCase }}`, "FooBar", nil}, + {"TestPascalCaseWithUnderscore", `{{ "foo_bar" | toPascalCase }}`, "FooBar", nil}, + {"TestPascalCaseWithHyphen", `{{ "foo-bar" | toPascalCase }}`, "FooBar", nil}, + {"TestPascalCaseWithMixed", `{{ "foo-bar_baz" | toPascalCase }}`, "FooBarBaz", nil}, + } + + runTestCases(t, tests) +} + +func TestToDotCase(t *testing.T) { + var tests = testCases{ + {"TestEmpty", `{{ "" | toDotCase }}`, "", nil}, + {"TestDotCase", `{{ "foo bar" | toDotCase }}`, "foo.bar", nil}, + {"TestDotCaseWithSpace", `{{ "foo bar" | toDotCase }}`, "foo.bar", nil}, + {"TestDotCaseWithUnderscore", `{{ "foo_bar" | toDotCase }}`, "foo.bar", nil}, + {"TestDotCaseWithHyphen", `{{ "foo-bar" | toDotCase }}`, "foo.bar", nil}, + {"TestDotCaseWithMixed", `{{ "foo-bar_baz" | toDotCase }}`, "foo.bar.baz", nil}, + } + + runTestCases(t, tests) +} + +func TestToPathCase(t *testing.T) { + var tests = testCases{ + {"TestEmpty", `{{ "" | toPathCase }}`, "", nil}, + {"TestPathCase", `{{ "foo bar" | toPathCase }}`, "foo/bar", nil}, + {"TestPathCaseWithSpace", `{{ "foo bar" | toPathCase }}`, "foo/bar", nil}, + {"TestPathCaseWithUnderscore", `{{ "foo_bar" | toPathCase }}`, "foo/bar", nil}, + {"TestPathCaseWithHyphen", `{{ "foo-bar" | toPathCase }}`, "foo/bar", nil}, + {"TestPathCaseWithMixed", `{{ "foo-bar_baz" | toPathCase }}`, "foo/bar/baz", nil}, + } + + runTestCases(t, tests) +} + +func TestToConstantCase(t *testing.T) { + var tests = testCases{ + {"TestEmpty", `{{ "" | toConstantCase }}`, "", nil}, + {"TestConstantCase", `{{ "foo bar" | toConstantCase }}`, "FOO_BAR", nil}, + {"TestConstantCaseWithSpace", `{{ "foo bar" | toConstantCase }}`, "FOO_BAR", nil}, + {"TestConstantCaseWithUnderscore", `{{ "foo_bar" | toConstantCase }}`, "FOO_BAR", nil}, + {"TestConstantCaseWithHyphen", `{{ "foo-bar" | toConstantCase }}`, "FOO_BAR", nil}, + {"TestConstantCaseWithMixed", `{{ "foo-bar_baz" | toConstantCase }}`, "FOO_BAR_BAZ", nil}, + } + + runTestCases(t, tests) +} + +func TestToSnakeCase(t *testing.T) { + var tests = testCases{ + {"TestEmpty", `{{ "" | toSnakeCase }}`, "", nil}, + {"TestSnakeCase", `{{ "foo bar" | toSnakeCase }}`, "foo_bar", nil}, + {"TestSnakeCaseWithSpace", `{{ "foo bar" | toSnakeCase }}`, "foo_bar", nil}, + {"TestSnakeCaseWithUnderscore", `{{ "foo_bar" | toSnakeCase }}`, "foo_bar", nil}, + {"TestSnakeCaseWithHyphen", `{{ "foo-bar" | toSnakeCase }}`, "foo_bar", nil}, + {"TestSnakeCaseWithMixed", `{{ "foo-bar_baz" | toSnakeCase }}`, "foo_bar_baz", nil}, + {"", `{{ toSnakeCase "http2xx" }}`, "http_2xx", nil}, + {"", `{{ toSnakeCase "HTTP20xOK" }}`, "http_20x_ok", nil}, + {"", `{{ toSnakeCase "Duration2m3s" }}`, "duration_2m_3s", nil}, + {"", `{{ toSnakeCase "Bld4Floor3rd" }}`, "bld_4floor_3rd", nil}, + {"", `{{ toSnakeCase "FirstName" }}`, "first_name", nil}, + {"", `{{ toSnakeCase "HTTPServer" }}`, "http_server", nil}, + {"", `{{ toSnakeCase "NoHTTPS" }}`, "no_https", nil}, + {"", `{{ toSnakeCase "GO_PATH" }}`, "go_path", nil}, + {"", `{{ toSnakeCase "GO PATH" }}`, "go_path", nil}, + {"", `{{ toSnakeCase "GO-PATH" }}`, "go_path", nil}, + } + + runTestCases(t, tests) +} + +func TestToTitleCase(t *testing.T) { + var tests = testCases{ + {"TestEmpty", `{{ "" | toTitleCase }}`, "", nil}, + {"TestTitleCase", `{{ "foo bar" | toTitleCase }}`, "Foo Bar", nil}, + {"TestTitleCaseWithSpace", `{{ "foo bar" | toTitleCase }}`, "Foo Bar", nil}, + {"TestTitleCaseWithUnderscore", `{{ "foo_bar" | toTitleCase }}`, "Foo_bar", nil}, + {"TestTitleCaseWithHyphen", `{{ "foo-bar" | toTitleCase }}`, "Foo-Bar", nil}, + {"TestTitleCaseWithMixed", `{{ "foo-bar_baz" | toTitleCase }}`, "Foo-Bar_baz", nil}, + } + + runTestCases(t, tests) +} + +func TestUntitle(t *testing.T) { + var tests = testCases{ + {"TestEmpty", `{{ "" | untitle }}`, "", nil}, + {"TestUnTitle", `{{ "Foo Bar" | untitle }}`, "foo bar", nil}, + {"TestUnTitleWithSpace", `{{ "Foo Bar" | untitle }}`, "foo bar", nil}, + {"TestUnTitleWithUnderscore", `{{ "Foo_bar" | untitle }}`, "foo_bar", nil}, + {"TestUnTitleWithHyphen", `{{ "Foo-Bar" | untitle }}`, "foo-Bar", nil}, + {"TestUnTitleWithMixed", `{{ "Foo-Bar_baz" | untitle }}`, "foo-Bar_baz", nil}, + } + + runTestCases(t, tests) +} + +func TestSwapCase(t *testing.T) { + var tests = testCases{ + {"TestEmpty", `{{ "" | swapCase }}`, "", nil}, + {"TestSwapCase", `{{ "Foo Bar" | swapCase }}`, "fOO bAR", nil}, + {"TestSwapCaseWithSpace", `{{ "Foo Bar" | swapCase }}`, "fOO bAR", nil}, + {"TestSwapCaseWithUnderscore", `{{ "Foo_bar" | swapCase }}`, "fOO_BAR", nil}, + {"TestSwapCaseWithHyphen", `{{ "Foo-Bar" | swapCase }}`, "fOO-bAR", nil}, + {"TestSwapCaseWithMixed", `{{ "Foo-Bar_baz" | swapCase }}`, "fOO-bAR_BAZ", nil}, + } + + runTestCases(t, tests) +} + +func TestSplit(t *testing.T) { + var tests = testCases{ + {"TestEmpty", `{{ $v := ("" | split "-") }}{{$v._0}}`, "", nil}, + {"TestSplit", `{{ $v := ("foo$bar$baz" | split "$") }}{{$v._0}} {{$v._1}} {{$v._2}}`, "foo bar baz", nil}, + {"TestSplitWithEmpty", `{{ $v := ("foo$bar$" | split "$") }}{{$v._0}} {{$v._1}} {{$v._2}}`, "foo bar ", nil}, + } + + runTestCases(t, tests) +} + +func TestSplitn(t *testing.T) { + var tests = testCases{ + {"TestEmpty", `{{ $v := ("" | splitn "-" 3) }}{{$v._0}}`, "", nil}, + {"TestSplit", `{{ $v := ("foo$bar$baz" | splitn "$" 2) }}{{$v._0}} {{$v._1}}`, "foo bar$baz", nil}, + {"TestSplitWithEmpty", `{{ $v := ("foo$bar$" | splitn "$" 2) }}{{$v._0}} {{$v._1}}`, "foo bar$", nil}, + } + + runTestCases(t, tests) +} + +func TestSubstring(t *testing.T) { + var tests = testCases{ + {"TestEmpty", `{{ "" | substr 0 3 }}`, "", nil}, + {"TestEmptyWithNegativeValue", `{{ "" | substr -1 -4 }}`, "", nil}, + {"TestSubstring", `{{ "foobar" | substr 0 3 }}`, "foo", nil}, + {"TestSubstringNegativeEnd", `{{ "foobar" | substr 0 -3 }}`, "foo", nil}, + {"TestSubstringNegativeStart", `{{ "foobar" | substr -3 6 }}`, "bar", nil}, + {"TestSubstringNegativeStartAndEnd", `{{ "foobar" | substr -3 -1 }}`, "ba", nil}, + {"TestSubstringInvalidRange", `{{ "foobar" | substr -3 -3 }}`, "", nil}, + } + + runTestCases(t, tests) +} + +func TestIndent(t *testing.T) { + var tests = testCases{ + {"TestEmpty", `{{ "" | indent 3 }}`, " ", nil}, + {"TestIndent", `{{ "foo\nbar" | indent 3 }}`, " foo\n bar", nil}, + {"TestIndentWithSpace", `{{ "foo\n bar" | indent 3 }}`, " foo\n bar", nil}, + {"TestIndentWithTab", `{{ "foo\n\tbar" | indent 3 }}`, " foo\n \tbar", nil}, + } + + runTestCases(t, tests) +} + +func TestNindent(t *testing.T) { + var tests = testCases{ + {"TestEmpty", `{{ "" | nindent 3 }}`, "\n ", nil}, + {"TestIndent", `{{ "foo\nbar" | nindent 3 }}`, "\n foo\n bar", nil}, + {"TestIndentWithSpace", `{{ "foo\n bar" | nindent 3 }}`, "\n foo\n bar", nil}, + {"TestIndentWithTab", `{{ "foo\n\tbar" | nindent 3 }}`, "\n foo\n \tbar", nil}, + } + + runTestCases(t, tests) +} + +func TestSeq(t *testing.T) { + var tests = testCases{ + {"", `{{ seq 0 1 3 }}`, "0 1 2 3", nil}, + {"", `{{ seq 0 3 10 }}`, "0 3 6 9", nil}, + {"", `{{ seq 3 3 2 }}`, "", nil}, + {"", `{{ seq 3 -3 2 }}`, "3", nil}, + {"", `{{ seq }}`, "", nil}, + {"", `{{ seq 0 4 }}`, "0 1 2 3 4", nil}, + {"", `{{ seq 5 }}`, "1 2 3 4 5", nil}, + {"", `{{ seq -5 }}`, "1 0 -1 -2 -3 -4 -5", nil}, + {"", `{{ seq 0 }}`, "1 0", nil}, + {"", `{{ seq 0 1 2 3 }}`, "", nil}, + {"", `{{ seq 0 -4 }}`, "0 -1 -2 -3 -4", nil}, + } + + runTestCases(t, tests) +} diff --git a/strings_test.go b/strings_test.go deleted file mode 100644 index 49eee4a..0000000 --- a/strings_test.go +++ /dev/null @@ -1,278 +0,0 @@ -package sprout - -import ( - "encoding/base32" - "encoding/base64" - "fmt" - "testing" - "unicode/utf8" - - "github.com/stretchr/testify/assert" -) - -func TestSubstr(t *testing.T) { - tpl := `{{"fooo" | substr 0 3 }}` - if err := runt(tpl, "foo"); err != nil { - t.Error(err) - } -} - -func TestSubstr_shorterString(t *testing.T) { - tpl := `{{"foo" | substr 0 10 }}` - if err := runt(tpl, "foo"); err != nil { - t.Error(err) - } -} - -func TestTrunc(t *testing.T) { - tpl := `{{ "foooooo" | trunc 3 }}` - if err := runt(tpl, "foo"); err != nil { - t.Error(err) - } - tpl = `{{ "baaaaaar" | trunc -3 }}` - if err := runt(tpl, "aar"); err != nil { - t.Error(err) - } - tpl = `{{ "baaaaaar" | trunc -999 }}` - if err := runt(tpl, "baaaaaar"); err != nil { - t.Error(err) - } - tpl = `{{ "baaaaaz" | trunc 0 }}` - if err := runt(tpl, ""); err != nil { - t.Error(err) - } -} - -func TestQuote(t *testing.T) { - tpl := `{{quote "a" "b" "c"}}` - if err := runt(tpl, `"a" "b" "c"`); err != nil { - t.Error(err) - } - tpl = `{{quote "\"a\"" "b" "c"}}` - if err := runt(tpl, `"\"a\"" "b" "c"`); err != nil { - t.Error(err) - } - tpl = `{{quote 1 2 3 }}` - if err := runt(tpl, `"1" "2" "3"`); err != nil { - t.Error(err) - } - tpl = `{{ .value | quote }}` - values := map[string]interface{}{"value": nil} - if err := runtv(tpl, ``, values); err != nil { - t.Error(err) - } -} -func TestSquote(t *testing.T) { - tpl := `{{squote "a" "b" "c"}}` - if err := runt(tpl, `'a' 'b' 'c'`); err != nil { - t.Error(err) - } - tpl = `{{squote 1 2 3 }}` - if err := runt(tpl, `'1' '2' '3'`); err != nil { - t.Error(err) - } - tpl = `{{ .value | squote }}` - values := map[string]interface{}{"value": nil} - if err := runtv(tpl, ``, values); err != nil { - t.Error(err) - } -} - -func TestContains(t *testing.T) { - // Mainly, we're just verifying the paramater order swap. - tests := []string{ - `{{if contains "cat" "fair catch"}}1{{end}}`, - `{{if hasPrefix "cat" "catch"}}1{{end}}`, - `{{if hasSuffix "cat" "ducat"}}1{{end}}`, - } - for _, tt := range tests { - if err := runt(tt, "1"); err != nil { - t.Error(err) - } - } -} - -func TestTrim(t *testing.T) { - tests := []string{ - `{{trim " 5.00 "}}`, - `{{trimAll "$" "$5.00$"}}`, - `{{trimPrefix "$" "$5.00"}}`, - `{{trimSuffix "$" "5.00$"}}`, - } - for _, tt := range tests { - if err := runt(tt, "5.00"); err != nil { - t.Error(err) - } - } -} - -func TestSplit(t *testing.T) { - tpl := `{{$v := "foo$bar$baz" | split "$"}}{{$v._0}}` - if err := runt(tpl, "foo"); err != nil { - t.Error(err) - } -} - -func TestSplitn(t *testing.T) { - tpl := `{{$v := "foo$bar$baz" | splitn "$" 2}}{{$v._0}}` - if err := runt(tpl, "foo"); err != nil { - t.Error(err) - } -} - -func TestToString(t *testing.T) { - tpl := `{{ toString 1 | kindOf }}` - assert.NoError(t, runt(tpl, "string")) -} - -func TestToStrings(t *testing.T) { - tpl := `{{ $s := list 1 2 3 | toStrings }}{{ index $s 1 | kindOf }}` - assert.NoError(t, runt(tpl, "string")) - tpl = `{{ list 1 .value 2 | toStrings }}` - values := map[string]interface{}{"value": nil} - if err := runtv(tpl, `[1 2]`, values); err != nil { - t.Error(err) - } -} - -func TestJoin(t *testing.T) { - assert.NoError(t, runt(`{{ tuple "a" "b" "c" | join "-" }}`, "a-b-c")) - assert.NoError(t, runt(`{{ tuple 1 2 3 | join "-" }}`, "1-2-3")) - assert.NoError(t, runtv(`{{ join "-" .V }}`, "a-b-c", map[string]interface{}{"V": []string{"a", "b", "c"}})) - assert.NoError(t, runtv(`{{ join "-" .V }}`, "abc", map[string]interface{}{"V": "abc"})) - assert.NoError(t, runtv(`{{ join "-" .V }}`, "1-2-3", map[string]interface{}{"V": []int{1, 2, 3}})) - assert.NoError(t, runtv(`{{ join "-" .value }}`, "1-2", map[string]interface{}{"value": []interface{}{"1", nil, "2"}})) -} - -func TestSortAlpha(t *testing.T) { - // Named `append` in the function map - tests := map[string]string{ - `{{ list "c" "a" "b" | sortAlpha | join "" }}`: "abc", - `{{ list 2 1 4 3 | sortAlpha | join "" }}`: "1234", - } - for tpl, expect := range tests { - assert.NoError(t, runt(tpl, expect)) - } -} -func TestBase64EncodeDecode(t *testing.T) { - magicWord := "coffee" - expect := base64.StdEncoding.EncodeToString([]byte(magicWord)) - - if expect == magicWord { - t.Fatal("Encoder doesn't work.") - } - - tpl := `{{b64enc "coffee"}}` - if err := runt(tpl, expect); err != nil { - t.Error(err) - } - tpl = fmt.Sprintf("{{b64dec %q}}", expect) - if err := runt(tpl, magicWord); err != nil { - t.Error(err) - } -} -func TestBase32EncodeDecode(t *testing.T) { - magicWord := "coffee" - expect := base32.StdEncoding.EncodeToString([]byte(magicWord)) - - if expect == magicWord { - t.Fatal("Encoder doesn't work.") - } - - tpl := `{{b32enc "coffee"}}` - if err := runt(tpl, expect); err != nil { - t.Error(err) - } - tpl = fmt.Sprintf("{{b32dec %q}}", expect) - if err := runt(tpl, magicWord); err != nil { - t.Error(err) - } -} - -func TestGoutils(t *testing.T) { - tests := map[string]string{ - `{{abbrev 5 "hello world"}}`: "he...", - `{{abbrevboth 5 10 "1234 5678 9123"}}`: "...5678...", - `{{nospace "h e l l o "}}`: "hello", - `{{untitle "First Try"}}`: "first try", //https://youtu.be/44-RsrF_V_w - `{{initials "First Try"}}`: "FT", - `{{wrap 5 "Hello World"}}`: "Hello\nWorld", - `{{wrapWith 5 "\t" "Hello World"}}`: "Hello\tWorld", - } - for k, v := range tests { - t.Log(k) - if err := runt(k, v); err != nil { - t.Errorf("Error on tpl %q: %s", k, err) - } - } -} - -func TestRandomString(t *testing.T) { - // Random strings are now using Masterminds/goutils's cryptographically secure random string functions - // by default. Consequently, these tests now have no predictable character sequence. No checks for exact - // string output are necessary. - - // {{randAlphaNum 5}} should yield five random characters - if x, _ := runRaw(`{{randAlphaNum 5}}`, nil); utf8.RuneCountInString(x) != 5 { - t.Errorf("String should be 5 characters; string was %v characters", utf8.RuneCountInString(x)) - } - - // {{randAlpha 5}} should yield five random characters - if x, _ := runRaw(`{{randAlpha 5}}`, nil); utf8.RuneCountInString(x) != 5 { - t.Errorf("String should be 5 characters; string was %v characters", utf8.RuneCountInString(x)) - } - - // {{randAscii 5}} should yield five random characters - if x, _ := runRaw(`{{randAscii 5}}`, nil); utf8.RuneCountInString(x) != 5 { - t.Errorf("String should be 5 characters; string was %v characters", utf8.RuneCountInString(x)) - } - - // {{randNumeric 5}} should yield five random characters - if x, _ := runRaw(`{{randNumeric 5}}`, nil); utf8.RuneCountInString(x) != 5 { - t.Errorf("String should be 5 characters; string was %v characters", utf8.RuneCountInString(x)) - } -} - -func TestCat(t *testing.T) { - tpl := `{{$b := "b"}}{{"c" | cat "a" $b}}` - if err := runt(tpl, "a b c"); err != nil { - t.Error(err) - } - tpl = `{{ .value | cat "a" "b"}}` - values := map[string]interface{}{"value": nil} - if err := runtv(tpl, "a b", values); err != nil { - t.Error(err) - } -} - -func TestIndent(t *testing.T) { - tpl := `{{indent 4 "a\nb\nc"}}` - if err := runt(tpl, " a\n b\n c"); err != nil { - t.Error(err) - } -} - -func TestNindent(t *testing.T) { - tpl := `{{nindent 4 "a\nb\nc"}}` - if err := runt(tpl, "\n a\n b\n c"); err != nil { - t.Error(err) - } -} - -func TestReplace(t *testing.T) { - tpl := `{{"I Am Henry VIII" | replace " " "-"}}` - if err := runt(tpl, "I-Am-Henry-VIII"); err != nil { - t.Error(err) - } -} - -func TestPlural(t *testing.T) { - tpl := `{{$num := len "two"}}{{$num}} {{$num | plural "1 char" "chars"}}` - if err := runt(tpl, "3 chars"); err != nil { - t.Error(err) - } - tpl = `{{len "t" | plural "cheese" "%d chars"}}` - if err := runt(tpl, "cheese"); err != nil { - t.Error(err) - } -} diff --git a/test_helpers_test.go b/test_helpers_test.go new file mode 100644 index 0000000..33e3697 --- /dev/null +++ b/test_helpers_test.go @@ -0,0 +1,74 @@ +package sprout + +import ( + "bytes" + "testing" + "text/template" + + "github.com/stretchr/testify/assert" +) + +type testCase struct { + name string + input string + expected string + data map[string]any +} + +type mustTestCase struct { + testCase + expectedErr string +} + +type testCases []testCase +type mustTestCases []mustTestCase + +func runTestCases(t *testing.T, tc testCases) { + t.Helper() + handler := NewFunctionHandler() + + for _, test := range tc { + t.Run(test.name, func(t *testing.T) { + t.Helper() + + tmplResponse, err := runTemplate(t, handler, test.input, test.data) + assert.NoError(t, err) + assert.Equal(t, test.expected, tmplResponse) + }) + } +} + +func runMustTestCases(t *testing.T, tc mustTestCases) { + t.Helper() + handler := NewFunctionHandler() + + for _, test := range tc { + t.Run(test.name, func(t *testing.T) { + t.Helper() + + tmplResponse, err := runTemplate(t, handler, test.input, test.data) + if test.expectedErr != "" { + if assert.Error(t, err) { + assert.Contains(t, err.Error(), test.expectedErr) + } + } else { + assert.NoError(t, err) + } + assert.Equal(t, test.expected, tmplResponse) + }) + } +} + +func runTemplate(t *testing.T, handler *FunctionHandler, tmplString string, data any) (string, error) { + t.Helper() + + tmpl, err := template.New("test").Funcs(FuncMap(WithFunctionHandler(handler))).Parse(tmplString) + if err != nil { + assert.FailNow(t, "Failed to parse template", err) + return "", err + } + + var buf bytes.Buffer + err = tmpl.ExecuteTemplate(&buf, "test", data) + return buf.String(), err +} diff --git a/time_functions.go b/time_functions.go new file mode 100644 index 0000000..83c6239 --- /dev/null +++ b/time_functions.go @@ -0,0 +1,310 @@ +package sprout + +import ( + "strconv" + "strings" + "time" +) + +// Date formats a given date or current time into a specified format string. +// +// Parameters: +// +// fmt string - the format string. +// date any - the date to format or the current time if not a date type. +// +// Returns: +// +// string - the formatted date. +// +// Example: +// +// {{ "2023-05-04T15:04:05Z" | date "Jan 2, 2006" }} // Output: "May 4, 2023" +func (fh *FunctionHandler) Date(fmt string, date any) string { + return fh.DateInZone(fmt, date, "Local") +} + +// DateInZone formats a given date or current time into a specified format string in a specified timezone. +// +// Parameters: +// +// fmt string - the format string. +// date any - the date to format, in various acceptable formats. +// zone string - the timezone name. +// +// Returns: +// +// string - the formatted date. +// +// Example: +// +// {{ dateInZone "Jan 2, 2006", "2023-05-04T15:04:05Z", "UTC" }} // Output: "May 4, 2023" +func (fh *FunctionHandler) DateInZone(fmt string, date any, zone string) string { + var t time.Time + switch date := date.(type) { + default: + t = time.Now() + case time.Time: + t = date + case *time.Time: + t = *date + case int64: + t = time.Unix(date, 0) + case int: + t = time.Unix(int64(date), 0) + case int32: + t = time.Unix(int64(date), 0) + } + + loc, err := time.LoadLocation(zone) + if err != nil { + loc, _ = time.LoadLocation("UTC") + } + + return t.In(loc).Format(fmt) +} + +// Duration converts seconds into a human-readable duration string. +// +// Parameters: +// +// sec any - the duration in seconds. +// +// Returns: +// +// string - the human-readable duration. +// +// Example: +// +// {{ 3661 | duration }} // Output: "1h1m1s" +func (fh *FunctionHandler) Duration(sec any) string { + var n int64 + switch value := sec.(type) { + default: + n = 0 + case string: + n, _ = strconv.ParseInt(value, 10, 64) + case int64: + n = value + } + return (time.Duration(n) * time.Second).String() +} + +// DateAgo calculates how much time has passed since the given date. +// +// Parameters: +// +// date any - the starting date for the calculation. +// +// Returns: +// +// string - a human-readable string describing how long ago the date was. +// +// Example: +// +// {{ "2023-05-04T15:04:05Z" | dateAgo }} // Output: "4m" +func (fh *FunctionHandler) DateAgo(date any) string { + var t time.Time + + switch date := date.(type) { + default: + t = time.Now() + case time.Time: + t = date + case *time.Time: + t = *date + case int64: + t = time.Unix(date, 0) + case int32: + t = time.Unix(int64(date), 0) + case int: + t = time.Unix(int64(date), 0) + } + // Drop resolution to seconds + duration := time.Since(t).Round(time.Second) + return duration.String() +} + +// Now returns the current time. +// +// Returns: +// +// time.Time - the current time. +// +// Example: +// +// {{ now }} // Output: "2023-05-07T15:04:05Z" +func (fh *FunctionHandler) Now() time.Time { + return time.Now() +} + +// UnixEpoch returns the Unix epoch timestamp of a given date. +// +// Parameters: +// +// date time.Time - the date to convert to a Unix timestamp. +// +// Returns: +// +// string - the Unix timestamp as a string. +// +// Example: +// +// {{ now | unixEpoch }} // Output: "1683306245" +func (fh *FunctionHandler) UnixEpoch(date time.Time) string { + return strconv.FormatInt(date.Unix(), 10) +} + +// DateModify adjusts a given date by a specified duration. If the duration +// format is incorrect, it returns the original date without any modification. +// +// Parameters: +// fmt string - the duration string to add to the date, such as "2h" for two hours. +// date time.Time - the date to modify. +// +// Returns: +// time.Time - the modified date after adding the duration +// +// Example: +// {{ "2024-05-04T15:04:05Z" | dateModify "48h" }} // Outputs the date two days later + +func (fh *FunctionHandler) DateModify(fmt string, date time.Time) time.Time { + d, err := time.ParseDuration(fmt) + if err != nil { + return date + } + return date.Add(d) +} + +// DurationRound rounds a duration to the nearest significant unit, such as years or seconds. +// +// Parameters: +// +// duration any - the duration to round. +// +// Returns: +// +// string - the rounded duration. +// +// Example: +// +// {{ "3600s" | durationRound }} // Output: "1h" +func (fh *FunctionHandler) DurationRound(duration any) string { + var d time.Duration + switch duration := duration.(type) { + case string: + d, _ = time.ParseDuration(duration) + case int64: + d = time.Duration(duration) + case time.Time: + d = time.Since(duration) + default: + d = 0 + } + + u := uint64(d) + neg := d < 0 + if neg { + u = -u + } + + if u == 0 { + return "0s" + } + + var ( + year = uint64(time.Hour) * 24 * 365 + month = uint64(time.Hour) * 24 * 30 + day = uint64(time.Hour) * 24 + hour = uint64(time.Hour) + minute = uint64(time.Minute) + second = uint64(time.Second) + ) + + var b strings.Builder + b.Grow(3) + + if neg { + b.WriteByte('-') + } + switch { + case u > year: + b.WriteString(strconv.FormatUint(u/year, 10)) + b.WriteRune('y') + case u > month: + b.WriteString(strconv.FormatUint(u/month, 10)) + b.WriteString("mo") + case u > day: + b.WriteString(strconv.FormatUint(u/day, 10)) + b.WriteRune('d') + case u > hour: + b.WriteString(strconv.FormatUint(u/hour, 10)) + b.WriteRune('h') + case u > minute: + b.WriteString(strconv.FormatUint(u/minute, 10)) + b.WriteRune('m') + case u > second: + b.WriteString(strconv.FormatUint(u/second, 10)) + b.WriteRune('s') + } + return b.String() +} + +// HtmlDate formats a date into a standard HTML date format (YYYY-MM-DD). +// +// Parameters: +// +// date any - the date to format. +// +// Returns: +// +// string - the formatted date in HTML format. +// +// Example: +// +// {{ "2023-05-04T15:04:05Z" | htmlDate }} // Output: "2023-05-04" +func (fh *FunctionHandler) HtmlDate(date any) string { + return fh.DateInZone("2006-01-02", date, "Local") +} + +// HtmlDateInZone formats a date into a standard HTML date format (YYYY-MM-DD) in a specified timezone. +// +// Parameters: +// +// date any - the date to format. +// zone string - the timezone name. +// +// Returns: +// +// string - the formatted date in HTML format. +// +// Example: +// +// {{ "2023-05-04T15:04:05Z", "UTC" | htmlDateInZone }} // Output: "2023-05-04" +func (fh *FunctionHandler) HtmlDateInZone(date any, zone string) string { + return fh.DateInZone("2006-01-02", date, zone) +} + +// MustDateModify calculates a new date by adding a specified duration to a given date. +// It returns an error if the duration format is incorrect. +// +// Parameters: +// +// fmt string - the duration string to be added to the date (e.g., "2h", "1m30s"). +// date time.Time - the initial date to which the duration is added. +// +// Returns: +// +// time.Time - the modified date after adding the duration. +// error - error if the duration format is invalid. +// +// Example: +// +// {{ "2024-05-04T15:04:05Z" | mustDateModify "48h" }} // Output: "2024-05-06T15:04:05Z", nil +func (fh *FunctionHandler) MustDateModify(fmt string, date time.Time) (time.Time, error) { + d, err := time.ParseDuration(fmt) + if err != nil { + return time.Time{}, err + } + return date.Add(d), nil +} diff --git a/time_functions_test.go b/time_functions_test.go new file mode 100644 index 0000000..1946056 --- /dev/null +++ b/time_functions_test.go @@ -0,0 +1,163 @@ +package sprout + +import ( + "testing" + "time" +) + +func TestDate(t *testing.T) { + timeTest := time.Date(2024, 5, 7, 15, 4, 5, 0, time.UTC) + + var tests = testCases{ + {"TestTimeObject", `{{ .V | date "02 Jan 06 15:04 -0700" }}`, "07 May 24 15:04 +0000", map[string]any{"V": timeTest}}, + {"TestTimeObjectPointer", `{{ .V | date "02 Jan 06 15:04 -0700" }}`, "07 May 24 15:04 +0000", map[string]any{"V": &timeTest}}, + {"TestTimeObjectUnix", `{{ .V | date "02 Jan 06 15:04 -0700" }}`, "07 May 24 15:04 +0000", map[string]any{"V": timeTest.Unix()}}, + {"TestTimeObjectUnixInt", `{{ .V | date "02 Jan 06 15:04 -0700" }}`, "07 May 24 15:04 +0000", map[string]any{"V": int(timeTest.Unix())}}, + } + + runTestCases(t, tests) +} + +func TestDateInZone(t *testing.T) { + timeTest := time.Date(2024, 5, 7, 15, 4, 5, 0, time.UTC) + + var tests = testCases{ + {"TestTimeObject", `{{ dateInZone "02 Jan 06 15:04 -0700" .V "UTC" }}`, "07 May 24 15:04 +0000", map[string]any{"V": timeTest}}, + {"TestTimeObjectPointer", `{{ dateInZone "02 Jan 06 15:04 -0700" .V "UTC" }}`, "07 May 24 15:04 +0000", map[string]any{"V": &timeTest}}, + {"TestTimeObjectUnix", `{{ dateInZone "02 Jan 06 15:04 -0700" .V "UTC" }}`, "07 May 24 15:04 +0000", map[string]any{"V": timeTest.Unix()}}, + {"TestTimeObjectUnixInt", `{{ dateInZone "02 Jan 06 15:04 -0700" .V "UTC" }}`, "07 May 24 15:04 +0000", map[string]any{"V": int(timeTest.Unix())}}, + {"TestTimeObjectUnixInt", `{{ dateInZone "02 Jan 06 15:04 -0700" .V "UTC" }}`, "07 May 24 15:04 +0000", map[string]any{"V": int32(timeTest.Unix())}}, + {"TestWithInvalidInput", `{{ dateInZone "02 Jan 06 15:04 -0700" .V "UTC" }}`, time.Now().Format("02 Jan 06 15:04 -0700"), map[string]any{"V": "invalid"}}, + {"TestWithInvalidZone", `{{ dateInZone "02 Jan 06 15:04 -0700" .V "invalid" }}`, "07 May 24 15:04 +0000", map[string]any{"V": timeTest}}, + } + + runTestCases(t, tests) +} + +func TestDuration(t *testing.T) { + var tests = testCases{ + {"InvalidInput", `{{ .V | duration }}`, "0s", map[string]any{"V": "1h"}}, + {"TestDurationWithInt64", `{{ .V | duration }}`, "10s", map[string]any{"V": int64(10)}}, + {"TestDurationWithString", `{{ .V | duration }}`, "26h3m4s", map[string]any{"V": "93784"}}, + {"TestDurationWithInvalidType", `{{ .V | duration }}`, "0s", map[string]any{"V": make(chan int)}}, + } + + runTestCases(t, tests) +} + +func TestDateAgo(t *testing.T) { + timeTest := time.Now().Add(-time.Hour * 24) + + var tests = testCases{ + {"TestTimeObject", `{{ .V | dateAgo | substr 0 5 }}`, "24h0m", map[string]any{"V": timeTest}}, + {"TestTimeObjectPointer", `{{ .V | dateAgo | substr 0 5 }}`, "24h0m", map[string]any{"V": &timeTest}}, + {"TestTimeObjectUnix", `{{ .V | dateAgo | substr 0 5 }}`, "24h0m", map[string]any{"V": timeTest.Unix()}}, + {"TestTimeObjectUnixInt", `{{ .V | dateAgo | substr 0 5 }}`, "24h0m", map[string]any{"V": int(timeTest.Unix())}}, + {"TestTimeObjectUnixInt32", `{{ .V | dateAgo | substr 0 5 }}`, "24h0m", map[string]any{"V": int32(timeTest.Unix())}}, + {"TestWithInvalidInput", `{{ .V | dateAgo }}`, "0s", map[string]any{"V": "invalid"}}, + } + + runTestCases(t, tests) +} + +func TestNow(t *testing.T) { + var tests = testCases{ + {"TestNow", `{{ now | date "02 Jan 06 15:04 -0700" }}`, time.Now().Format("02 Jan 06 15:04 -0700"), nil}, + } + + runTestCases(t, tests) +} + +func TestUnixEpoch(t *testing.T) { + timeTest := time.Date(2024, 5, 7, 15, 4, 5, 0, time.UTC) + + var tests = testCases{ + {"TestUnixEpoch", `{{ .V | unixEpoch }}`, "1715094245", map[string]any{"V": timeTest}}, + } + + runTestCases(t, tests) +} + +func TestDateModify(t *testing.T) { + timeTest := time.Date(2024, 5, 7, 15, 4, 5, 0, time.UTC) + + var tests = testCases{ + {"AddOneHour", `{{ .V | dateModify "1h" | date "02 Jan 06 15:04 -0700" }}`, "07 May 24 16:04 +0000", map[string]any{"V": timeTest}}, + {"AddOneHourWithPlusSign", `{{ .V | dateModify "+1h" | date "02 Jan 06 15:04 -0700" }}`, "07 May 24 16:04 +0000", map[string]any{"V": timeTest}}, + {"SubtractOneHour", `{{ .V | dateModify "-1h" | date "02 Jan 06 15:04 -0700" }}`, "07 May 24 14:04 +0000", map[string]any{"V": timeTest}}, + {"AddTenMinutes", `{{ .V | dateModify "10m" | date "02 Jan 06 15:04 -0700" }}`, "07 May 24 15:14 +0000", map[string]any{"V": timeTest}}, + {"SubtractTenSeconds", `{{ .V | dateModify "-10s" | date "02 Jan 06 15:04 -0700" }}`, "07 May 24 15:03 +0000", map[string]any{"V": timeTest}}, + {"WithInvalidInput", `{{ .V | dateModify "zz" | date "02 Jan 06 15:04 -0700" }}`, "07 May 24 15:04 +0000", map[string]any{"V": timeTest}}, + } + + runTestCases(t, tests) +} + +func TestDurationRound(t *testing.T) { + var tests = testCases{ + {"", `{{ .V | durationRound }}`, "0s", map[string]any{"V": ""}}, + {"", `{{ .V | durationRound }}`, "2h", map[string]any{"V": "2h5s"}}, + {"", `{{ .V | durationRound }}`, "1d", map[string]any{"V": "24h5s"}}, + {"", `{{ .V | durationRound }}`, "3mo", map[string]any{"V": "2400h5s"}}, + {"", `{{ .V | durationRound }}`, "45m", map[string]any{"V": int64(45*time.Minute + 30*time.Second)}}, + {"", `{{ .V | durationRound }}`, "1s", map[string]any{"V": int64(1*time.Second + 500*time.Millisecond)}}, + {"", `{{ .V | durationRound }}`, "1y", map[string]any{"V": int64(365*24*time.Hour + 12*time.Hour)}}, + {"", `{{ .V | durationRound }}`, "1y", map[string]any{"V": time.Now().Add(-365*24*time.Hour - 72*time.Hour)}}, + {"", `{{ .V | durationRound }}`, "0s", map[string]any{"V": make(chan int)}}, + {"", `{{ .V | durationRound }}`, "-1h", map[string]any{"V": "-1h01s"}}, + } + + runTestCases(t, tests) +} + +func TestHtmlDate(t *testing.T) { + timeTest := time.Date(2024, 5, 7, 15, 4, 5, 0, time.UTC) + + var tests = testCases{ + {"TestTimeObject", `{{ .V | htmlDate }}`, "2024-05-07", map[string]any{"V": timeTest}}, + {"TestTimeObjectPointer", `{{ .V | htmlDate }}`, "2024-05-07", map[string]any{"V": &timeTest}}, + {"TestTimeObjectUnix", `{{ .V | htmlDate }}`, "2024-05-07", map[string]any{"V": timeTest.Unix()}}, + {"TestTimeObjectUnixInt", `{{ .V | htmlDate }}`, "2024-05-07", map[string]any{"V": int(timeTest.Unix())}}, + {"TestTimeObjectUnixInt32", `{{ .V | htmlDate }}`, "2024-05-07", map[string]any{"V": int32(timeTest.Unix())}}, + {"TestZeroValue", `{{ .V | htmlDate }}`, "1970-01-01", map[string]any{"V": 0}}, + {"TestWithInvalidInput", `{{ .V | htmlDate }}`, time.Now().Format("2006-01-02"), map[string]any{"V": make(chan int)}}, + } + + runTestCases(t, tests) +} + +func TestHtmlDateInZone(t *testing.T) { + timeTest := time.Date(2024, 5, 7, 15, 4, 5, 0, time.UTC) + + var tests = testCases{ + {"TestTimeObject", `{{ htmlDateInZone .V "UTC" }}`, "2024-05-07", map[string]any{"V": timeTest}}, + {"TestTimeObjectPointer", `{{ htmlDateInZone .V "UTC" }}`, "2024-05-07", map[string]any{"V": &timeTest}}, + {"TestTimeObjectUnix", `{{ htmlDateInZone .V "UTC" }}`, "2024-05-07", map[string]any{"V": timeTest.Unix()}}, + {"TestTimeObjectUnixInt", `{{ htmlDateInZone .V "UTC" }}`, "2024-05-07", map[string]any{"V": int(timeTest.Unix())}}, + {"TestTimeObjectUnixInt32", `{{ htmlDateInZone .V "UTC" }}`, "2024-05-07", map[string]any{"V": int32(timeTest.Unix())}}, + {"TestWithInvalidInput", `{{ htmlDateInZone .V "UTC" }}`, time.Now().Format("2006-01-02"), map[string]any{"V": make(chan int)}}, + } + + runTestCases(t, tests) +} + +func TestMustDateModify(t *testing.T) { + timeTest := time.Date(2024, 5, 7, 15, 4, 5, 0, time.UTC) + + var tests = testCases{ + {"AddOneHour", `{{ .V | mustDateModify "1h" | date "02 Jan 06 15:04 -0700" }}`, "07 May 24 16:04 +0000", map[string]any{"V": timeTest}}, + {"AddOneHourWithPlusSign", `{{ .V | mustDateModify "+1h" | date "02 Jan 06 15:04 -0700" }}`, "07 May 24 16:04 +0000", map[string]any{"V": timeTest}}, + {"SubtractOneHour", `{{ .V | mustDateModify "-1h" | date "02 Jan 06 15:04 -0700" }}`, "07 May 24 14:04 +0000", map[string]any{"V": timeTest}}, + {"AddTenMinutes", `{{ .V | mustDateModify "10m" | date "02 Jan 06 15:04 -0700" }}`, "07 May 24 15:14 +0000", map[string]any{"V": timeTest}}, + {"SubtractTenSeconds", `{{ .V | mustDateModify "-10s" | date "02 Jan 06 15:04 -0700" }}`, "07 May 24 15:03 +0000", map[string]any{"V": timeTest}}, + } + + runTestCases(t, tests) + + var mustTests = mustTestCases{ + {testCase{"WithEmptyInput", `{{ .V | mustDateModify "" }}`, "", map[string]any{"V": timeTest}}, "invalid duration"}, + {testCase{"WithInvalidInput", `{{ .V | mustDateModify "zz" }}`, "", map[string]any{"V": timeTest}}, "invalid duration"}, + } + + runMustTestCases(t, mustTests) +} diff --git a/url.go b/url.go deleted file mode 100644 index 5a176c4..0000000 --- a/url.go +++ /dev/null @@ -1,66 +0,0 @@ -package sprout - -import ( - "fmt" - "net/url" - "reflect" -) - -func dictGetOrEmpty(dict map[string]interface{}, key string) string { - value, ok := dict[key] - if !ok { - return "" - } - tp := reflect.TypeOf(value).Kind() - if tp != reflect.String { - panic(fmt.Sprintf("unable to parse %s key, must be of type string, but %s found", key, tp.String())) - } - return reflect.ValueOf(value).String() -} - -// parses given URL to return dict object -func urlParse(v string) map[string]interface{} { - dict := map[string]interface{}{} - parsedURL, err := url.Parse(v) - if err != nil { - panic(fmt.Sprintf("unable to parse url: %s", err)) - } - dict["scheme"] = parsedURL.Scheme - dict["host"] = parsedURL.Host - dict["hostname"] = parsedURL.Hostname() - dict["path"] = parsedURL.Path - dict["query"] = parsedURL.RawQuery - dict["opaque"] = parsedURL.Opaque - dict["fragment"] = parsedURL.Fragment - if parsedURL.User != nil { - dict["userinfo"] = parsedURL.User.String() - } else { - dict["userinfo"] = "" - } - - return dict -} - -// join given dict to URL string -func urlJoin(d map[string]interface{}) string { - resURL := url.URL{ - Scheme: dictGetOrEmpty(d, "scheme"), - Host: dictGetOrEmpty(d, "host"), - Path: dictGetOrEmpty(d, "path"), - RawQuery: dictGetOrEmpty(d, "query"), - Opaque: dictGetOrEmpty(d, "opaque"), - Fragment: dictGetOrEmpty(d, "fragment"), - } - userinfo := dictGetOrEmpty(d, "userinfo") - var user *url.Userinfo - if userinfo != "" { - tempURL, err := url.Parse(fmt.Sprintf("proto://%s@host", userinfo)) - if err != nil { - panic(fmt.Sprintf("unable to parse userinfo in dict: %s", err)) - } - user = tempURL.User - } - - resURL.User = user - return resURL.String() -} diff --git a/url_test.go b/url_test.go deleted file mode 100644 index c091488..0000000 --- a/url_test.go +++ /dev/null @@ -1,87 +0,0 @@ -package sprout - -import ( - "testing" - - "github.com/stretchr/testify/assert" -) - -var urlTests = map[string]map[string]interface{}{ - "proto://auth@host:80/path?query#fragment": { - "fragment": "fragment", - "host": "host:80", - "hostname": "host", - "opaque": "", - "path": "/path", - "query": "query", - "scheme": "proto", - "userinfo": "auth", - }, - "proto://host:80/path": { - "fragment": "", - "host": "host:80", - "hostname": "host", - "opaque": "", - "path": "/path", - "query": "", - "scheme": "proto", - "userinfo": "", - }, - "something": { - "fragment": "", - "host": "", - "hostname": "", - "opaque": "", - "path": "something", - "query": "", - "scheme": "", - "userinfo": "", - }, - "proto://user:passwor%20d@host:80/path": { - "fragment": "", - "host": "host:80", - "hostname": "host", - "opaque": "", - "path": "/path", - "query": "", - "scheme": "proto", - "userinfo": "user:passwor%20d", - }, - "proto://host:80/pa%20th?key=val%20ue": { - "fragment": "", - "host": "host:80", - "hostname": "host", - "opaque": "", - "path": "/pa th", - "query": "key=val%20ue", - "scheme": "proto", - "userinfo": "", - }, -} - -func TestUrlParse(t *testing.T) { - // testing that function is exported and working properly - assert.NoError(t, runt( - `{{ index ( urlParse "proto://auth@host:80/path?query#fragment" ) "host" }}`, - "host:80")) - - // testing scenarios - for url, expected := range urlTests { - assert.EqualValues(t, expected, urlParse(url)) - } -} - -func TestUrlJoin(t *testing.T) { - tests := map[string]string{ - `{{ urlJoin (dict "fragment" "fragment" "host" "host:80" "path" "/path" "query" "query" "scheme" "proto") }}`: "proto://host:80/path?query#fragment", - `{{ urlJoin (dict "fragment" "fragment" "host" "host:80" "path" "/path" "scheme" "proto" "userinfo" "ASDJKJSD") }}`: "proto://ASDJKJSD@host:80/path#fragment", - } - for tpl, expected := range tests { - assert.NoError(t, runt(tpl, expected)) - } - - for expected, urlMap := range urlTests { - assert.EqualValues(t, expected, urlJoin(urlMap)) - } - -}