Use better string splitting techniques where possible (#8226)

## What changed?

This PR aims to avoid usage of
[`strings.Split`](https://pkg.go.dev/strings#Split) where possible in
favor of better string splitting techniques, speficially:
[`strings.SplitN`](https://pkg.go.dev/strings#SplitN) and
[`strings.SplitSeq`](https://pkg.go.dev/strings#SplitSeq) where
appropriate.

There was also a [`strings.Fields`](https://pkg.go.dev/strings#Fields)
change I made to use
[`strings.FieldsSeq`](https://pkg.go.dev/strings#FieldsSeq) instead, and
another for S3 to use the [`path`](https://pkg.go.dev/path) package
instead of [`strings.Split`](https://pkg.go.dev/strings#Split).

## Why?

[`strings.SplitN`](https://pkg.go.dev/strings#SplitN) and
[`strings.SplitSeq`](https://pkg.go.dev/strings#SplitSeq) are often
better options in many cases, and can be _partially_ detected using
[`modernize`](https://pkg.go.dev/golang.org/x/tools/gopls/internal/analysis/modernize):
> `stringsseq`: replace Split in "for range strings.Split(...)" by
go1.24's more efficient `SplitSeq`, or `Fields` with `FieldSeq`.

## How did you test it?
- [X] built
- [X] run locally and tested manually
- [x] covered by existing tests
- [x] added new unit test(s)
- [ ] added new functional test(s)

## Potential risks

There are lots of potentially subtle behaviors from the `strings.Split`
(and `strings.Fields`) usage that should be accounted for. If our
existing tests don't cover those subtleties, there's risk for
introducing an unintended bug. More intricate handling/parsing
previously using the `strings` package should get extra attention from
reviewers. I've attempted to break up my changes into logical commit
chunks to aid in review / help spot potentially concerning changes.
This commit is contained in:
Kent Gruber
2025-08-26 13:23:40 -04:00
committed by GitHub
parent ebcc3fd9cf
commit 96c3aaef6b
22 changed files with 311 additions and 79 deletions

View File

@@ -81,17 +81,14 @@ func CamelCaseToSnakeCase(s string) string {
}
func SnakeCaseToPascalCase(s string) string {
// Split the string by underscores
words := strings.Split(s, "_")
// Capitalize the first letter of each word
for i, word := range words {
var b strings.Builder
// Capitalize the first letter of each word split by underscore
for word := range strings.SplitSeq(s, "_") {
// Convert first rune to upper and the rest to lower case
words[i] = cases.Title(language.AmericanEnglish).String(strings.ToLower(word))
b.WriteString(cases.Title(language.AmericanEnglish).String(strings.ToLower(word)))
}
// Join them back into a single string
return strings.Join(words, "")
return b.String()
}
func IsASCIIUpper(c rune) bool {

View File

@@ -0,0 +1,33 @@
package codegen
import "testing"
func TestSnakeCaseToPascalCase(t *testing.T) {
tests := []struct {
name string
in string
want string
}{
{name: "empty", in: "", want: ""},
{name: "single_lower", in: "a", want: "A"},
{name: "single_upper", in: "A", want: "A"},
{name: "simple", in: "hello", want: "Hello"},
{name: "two_words", in: "hello_world", want: "HelloWorld"},
{name: "all_caps", in: "HELLO_WORLD", want: "HelloWorld"},
{name: "leading_underscore", in: "_leading", want: "Leading"},
{name: "trailing_underscore", in: "trailing_", want: "Trailing"},
{name: "double_underscore", in: "a__b", want: "AB"},
{name: "only_underscores", in: "__", want: ""},
{name: "common_id", in: "user_id", want: "UserId"},
{name: "with_digits", in: "http_server_v2", want: "HttpServerV2"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := SnakeCaseToPascalCase(tt.in)
if got != tt.want {
t.Fatalf("SnakeCaseToPascalCase(%q) = %q, want %q", tt.in, got, tt.want)
}
})
}
}

View File

@@ -41,7 +41,7 @@ func findProtoImports() []string {
if d.Type().IsRegular() && strings.HasSuffix(path, ".proto") {
protoFile, err := os.ReadFile(path)
fatalIfErr(err)
for _, line := range strings.Split(string(protoFile), "\n") {
for line := range strings.SplitSeq(string(protoFile), "\n") {
if match := matchImport.FindStringSubmatch(line); len(match) > 0 {
i := match[1]
if strings.HasPrefix(i, "temporal/api/") ||

View File

@@ -152,7 +152,7 @@ func newRunIDPrecondition(runID string) connector.Precondition {
}
if strings.Contains(fileName, runID) {
fileNameParts := strings.Split(fileName, "_")
fileNameParts := strings.SplitN(fileName, "_", 5)
if len(fileNameParts) != 5 {
return true
}
@@ -176,7 +176,7 @@ func newWorkflowIDPrecondition(workflowID string) connector.Precondition {
}
if strings.Contains(fileName, workflowID) {
fileNameParts := strings.Split(fileName, "_")
fileNameParts := strings.SplitN(fileName, "_", 5)
if len(fileNameParts) != 5 {
return true
}
@@ -200,7 +200,7 @@ func newWorkflowTypeNamePrecondition(workflowTypeName string) connector.Precondi
}
if strings.Contains(fileName, workflowTypeName) {
fileNameParts := strings.Split(fileName, "_")
fileNameParts := strings.SplitN(fileName, "_", 5)
if len(fileNameParts) != 5 {
return true
}

View File

@@ -2,6 +2,7 @@ package s3store
import (
"context"
"path"
"strings"
"time"
@@ -223,11 +224,17 @@ func (v *visibilityArchiver) queryAll(
nextPageToken: nextPageToken,
parsedQuery: &parsedQuery{},
}, saTypeMap, searchPrefix, func(key string) bool {
// We only want to return entries for the closeTimeout secondary index, which will always be of the form:
// .../closeTimeout/<closeTimeout>/<runID>, so we split the key on "/" and check that the third-to-last
// element is "closeTimeout".
elements := strings.Split(key, "/")
return len(elements) >= 3 && elements[len(elements)-3] == secondaryIndexKeyCloseTimeout
// We only want to return entries for the closeTimeout secondary index. Keys for this
// index are always of the form:
// .../closeTimeout/<timestamp>/<runID>
// Walk from the end instead of splitting the whole string to avoid unnecessary
// allocations and to keep the logic clear:
// - drop <runID>
// - drop <timestamp>
// - check the remaining last segment equals "closeTimeout".
dir := path.Dir(key) // drop runID
dir = path.Dir(dir) // drop <timestamp>
return path.Base(dir) == secondaryIndexKeyCloseTimeout
})
if err != nil {
return nil, err

View File

@@ -125,7 +125,7 @@ func (a *defaultJWTClaimMapper) extractPermissions(permissions []interface{}, cl
}
parts = []string{match[a.matchNamespaceIndex], match[a.matchRoleIndex]}
} else {
parts = strings.Split(p, ":")
parts = strings.SplitN(p, ":", 2)
if len(parts) != 2 {
a.logger.Warn(fmt.Sprintf("ignoring permission in unexpected format: %v", permission))
continue

View File

@@ -2,7 +2,6 @@ package headers
import (
"context"
"slices"
"strings"
"github.com/blang/semver/v4"
@@ -152,8 +151,15 @@ func (vc *versionChecker) ClientSupported(ctx context.Context) error {
// given feature (which should be one of the Feature... constants above).
func (vc *versionChecker) ClientSupportsFeature(ctx context.Context, feature string) bool {
headers := GetValues(ctx, SupportedFeaturesHeaderName)
clientFeatures := strings.Split(headers[0], SupportedFeaturesHeaderDelim)
return slices.Contains(clientFeatures, feature)
if len(headers) == 0 {
return false
}
for clientFeature := range strings.SplitSeq(headers[0], SupportedFeaturesHeaderDelim) {
if clientFeature == feature {
return true
}
}
return false
}
func mustParseRanges(ranges map[string]string) map[string]semver.Range {

View File

@@ -1385,15 +1385,16 @@ func BuildHistoryGarbageCleanupInfo(namespaceID, workflowID, runID string) strin
// SplitHistoryGarbageCleanupInfo returns workflow identity information
func SplitHistoryGarbageCleanupInfo(info string) (namespaceID, workflowID, runID string, err error) {
ss := strings.Split(info, ":")
// workflowID can contain ":" so len(ss) can be greater than 3
if len(ss) < numItemsInGarbageInfo {
return "", "", "", fmt.Errorf("not able to split info for %s", info)
// Expect format: namespaceID:workflowID:runID, but workflowID may contain ':' so we
// take everything between the first and last ':' as workflowID.
first := strings.IndexByte(info, ':')
last := strings.LastIndexByte(info, ':')
if first < 0 || first == last { // need at least two ':' to have 3 parts
return "", "", "", fmt.Errorf("not able to split info for %s", info)
}
namespaceID = ss[0]
runID = ss[len(ss)-1]
workflowEnd := len(info) - len(runID) - 1
workflowID = info[len(namespaceID)+1 : workflowEnd]
namespaceID = info[:first]
workflowID = info[first+1 : last]
runID = info[last+1:]
return
}

View File

@@ -248,7 +248,7 @@ func GetHistoryTaskQueueName(
}
func GetHistoryTaskQueueCategoryID(queueName string) (int, error) {
fields := strings.Split(queueName, "_")
fields := strings.SplitN(queueName, "_", 4)
if len(fields) != 4 {
return 0, fmt.Errorf("%w: %s", ErrInvalidQueueName, queueName)
}

View File

@@ -173,7 +173,7 @@ func ConfigureCassandraCluster(cfg config.Cassandra, cluster *gocql.ClusterConfi
// parseHosts returns parses a list of hosts separated by comma
func parseHosts(input string) []string {
var hosts []string
for _, h := range strings.Split(input, ",") {
for h := range strings.SplitSeq(input, ",") {
if host := strings.TrimSpace(h); len(host) > 0 {
hosts = append(hosts, host)
}

View File

@@ -190,7 +190,7 @@ func (mdb *db) processRowFromDB(row *sqlplugin.VisibilityRow) error {
for saName, saValue := range *row.SearchAttributes {
switch typedSaValue := saValue.(type) {
case string:
if strings.Index(typedSaValue, keywordListSeparator) >= 0 {
if strings.Contains(typedSaValue, keywordListSeparator) {
// If the string contains the keywordListSeparator, then we need to split it
// into a list of keywords.
(*row.SearchAttributes)[saName] = strings.Split(typedSaValue, keywordListSeparator)

View File

@@ -122,9 +122,8 @@ func formatComparisonExprStringForError(expr sqlparser.ComparisonExpr) string {
// Simple tokenizer by spaces. It's a temporary solution as it doesn't cover tokenizer used by
// PostgreSQL or SQLite.
func tokenizeTextQueryString(s string) []string {
tokens := strings.Split(s, " ")
nonEmptyTokens := make([]string, 0, len(tokens))
for _, token := range tokens {
nonEmptyTokens := make([]string, 0, strings.Count(s, " ")+1)
for token := range strings.SplitSeq(s, " ") {
if token != "" {
nonEmptyTokens = append(nonEmptyTokens, token)
}

View File

@@ -0,0 +1,108 @@
package sql
import (
"testing"
"github.com/stretchr/testify/require"
)
func Test_tokenizeTextQueryString(t *testing.T) {
tests := []struct {
name string
input string
want []string
}{
{
name: "empty",
input: "",
want: []string{},
},
{
name: "single token",
input: "foo",
want: []string{"foo"},
},
{
name: "two tokens",
input: "foo bar",
want: []string{"foo", "bar"},
},
{
name: "multiple spaces collapsed",
input: "a b c",
want: []string{"a", "b", "c"},
},
{
name: "leading and trailing spaces",
input: " foo bar ",
want: []string{"foo", "bar"},
},
{
name: "spaces only",
input: " ",
want: []string{},
},
{
name: "tabs are not separators",
input: "foo\tbar baz",
want: []string{"foo\tbar", "baz"},
},
{
name: "newlines are not separators",
input: "foo\nbar baz",
want: []string{"foo\nbar", "baz"},
},
{
name: "simple comparison with quotes",
input: "status = \"RUNNING\"",
want: []string{"status", "=", "\"RUNNING\""},
},
{
name: "comparison without spaces around operator",
input: "status='RUNNING' AND execution_time > 2025-01-01",
want: []string{"status='RUNNING'", "AND", "execution_time", ">", "2025-01-01"},
},
{
name: "quoted string with internal space",
input: "WorkflowId = 'abc 123' AND RunId = 'xyz'",
want: []string{"WorkflowId", "=", "'abc", "123'", "AND", "RunId", "=", "'xyz'"},
},
{
name: "IS NULL and datetime literal",
input: "CloseTime IS NULL OR CloseTime > '2024-01-01T00:00:00Z'",
want: []string{"CloseTime", "IS", "NULL", "OR", "CloseTime", ">", "'2024-01-01T00:00:00Z'"},
},
{
name: "LIKE pattern",
input: "name LIKE '%abc%'",
want: []string{"name", "LIKE", "'%abc%'"},
},
{
name: "parentheses and operators",
input: "(status = 'FAILED') AND (attempts >= 3)",
want: []string{"(status", "=", "'FAILED')", "AND", "(attempts", ">=", "3)"},
},
{
name: "IN with list including spaced item",
input: "NamespaceId in ('ns1','ns 2','ns3')",
want: []string{"NamespaceId", "in", "('ns1','ns", "2','ns3')"},
},
{
name: "json containment operator",
input: "attr_json @> '{\"key\":\"value\"}'",
want: []string{"attr_json", "@>", "'{\"key\":\"value\"}'"},
},
{
name: "combined IN and comparison",
input: "execution_status IN ('RUNNING','COMPLETED') AND startTime >= 2025-01-01T00:00:00Z",
want: []string{"execution_status", "IN", "('RUNNING','COMPLETED')", "AND", "startTime", ">=", "2025-01-01T00:00:00Z"},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
got := tokenizeTextQueryString(test.input)
require.Equal(t, test.want, got)
})
}
}

View File

@@ -36,7 +36,7 @@ func SpanExportersFromEnv(
return exporters, nil
}
for _, exporterType := range strings.Split(exporterTypes, ",") {
for exporterType := range strings.SplitSeq(exporterTypes, ",") {
switch SpanExporterType(exporterType) {
case OtelTracesOtlpExporterType:
// only grpc is supported; fail if user requests a different protocol

View File

@@ -9,21 +9,27 @@ import (
)
func ConvertPathToCamel(input string) []string {
pathParts := strings.Split(input, ".")
for i, path := range pathParts {
var pathParts []string
for path := range strings.SplitSeq(input, ".") {
// Split by "_" and convert each word to Title Case (CamelCase)
words := strings.Split(path, "_")
snakeCase := len(words) > 1
for j, word := range words {
if snakeCase && j > 0 {
words[j] = cases.Title(language.Und).String(strings.ToLower(word))
var b strings.Builder
j := 0
for word := range strings.SplitSeq(path, "_") {
if j > 0 {
b.WriteString(cases.Title(language.Und).String(strings.ToLower(word)))
} else {
// lowercase the first letter
words[j] = strings.ToLower(word[:1]) + word[1:]
if len(word) > 0 {
b.WriteString(strings.ToLower(word[:1]))
if len(word) > 1 {
b.WriteString(word[1:])
}
}
}
j++
}
// Join the words into a CamelCase substring
pathParts[i] = strings.Join(words, "")
pathParts = append(pathParts, b.String())
}
// Join all CamelCase substrings back with "."
return pathParts

View File

@@ -22,12 +22,14 @@ func WildCardStringsToRegexp(patterns []string) (*regexp.Regexp, error) {
result.WriteRune('^')
for i, pattern := range patterns {
result.WriteRune('(')
for i, literal := range strings.Split(pattern, "*") {
if i > 0 {
first := true
for literal := range strings.SplitSeq(pattern, "*") {
if !first {
// Replace * with .*
result.WriteString(".*")
}
result.WriteString(regexp.QuoteMeta(literal))
first = false
}
result.WriteRune(')')
if i < len(patterns)-1 {

View File

@@ -95,12 +95,14 @@ func allowedAddressConverter(val any) ([]AddressMatchRule, error) {
func addressPatternToRegexp(pattern string) string {
var result strings.Builder
result.WriteString("^")
for i, literal := range strings.Split(pattern, "*") {
if i > 0 {
first := true
for literal := range strings.SplitSeq(pattern, "*") {
if !first {
// Replace * with .*
result.WriteString(".*")
}
result.WriteString(regexp.QuoteMeta(literal))
first = false
}
result.WriteString("$")
return result.String()

View File

@@ -0,0 +1,39 @@
package callbacks
import (
"regexp"
"testing"
"github.com/stretchr/testify/require"
)
func Test_addressPatternToRegexp(t *testing.T) {
tests := []struct {
name string
pattern string
want string
}{
{name: "empty", pattern: "", want: "^$"},
{name: "no_wildcard", pattern: "foo", want: "^foo$"},
{name: "single_wildcard_only", pattern: "*", want: "^.*$"},
{name: "leading_wildcard", pattern: "*foo", want: "^.*foo$"},
{name: "trailing_wildcard", pattern: "foo*", want: "^foo.*$"},
{name: "surrounded_wildcard", pattern: "*foo*", want: "^.*foo.*$"},
{name: "middle_wildcard", pattern: "foo*bar", want: "^foo.*bar$"},
{name: "literal_dots_around_wildcard", pattern: "foo.*bar", want: "^foo\\..*bar$"},
{name: "prefix_subdomain", pattern: "prefix.*.domain", want: "^prefix\\..*\\.domain$"},
{name: "leading_any_subdomain", pattern: "*.example.com", want: "^.*\\.example\\.com$"},
{name: "host_with_port", pattern: "api.example.com:8080", want: "^api\\.example\\.com:8080$"},
{name: "consecutive_wildcards", pattern: "a**b", want: "^a.*.*b$"},
{name: "triple_wildcards", pattern: "a***b", want: "^a.*.*.*b$"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
got := addressPatternToRegexp(test.pattern)
require.Equal(t, test.want, got)
_, err := regexp.Compile(got)
require.NoError(t, err)
})
}
}

View File

@@ -402,15 +402,18 @@ func (c *requestContext) augmentContext(ctx context.Context, header http.Header)
func() string { return methodNameForMetrics },
)
if userAgent := header.Get(http.CanonicalHeaderKey(headerUserAgent)); userAgent != "" {
parts := strings.Split(userAgent, clientNameVersionDelim)
if len(parts) == 2 {
mdIncoming, ok := metadata.FromIncomingContext(ctx)
if !ok {
mdIncoming = metadata.MD{}
// Preserve original strict behavior: only process if exactly one delimiter present.
if strings.Count(userAgent, clientNameVersionDelim) == 1 {
parts := strings.SplitN(userAgent, clientNameVersionDelim, 2)
if len(parts) == 2 { // defensive
mdIncoming, ok := metadata.FromIncomingContext(ctx)
if !ok {
mdIncoming = metadata.MD{}
}
mdIncoming.Set(headers.ClientNameHeaderName, parts[0])
mdIncoming.Set(headers.ClientVersionHeaderName, parts[1])
ctx = metadata.NewIncomingContext(ctx, mdIncoming)
}
mdIncoming.Set(headers.ClientNameHeaderName, parts[0])
mdIncoming.Set(headers.ClientVersionHeaderName, parts[1])
ctx = metadata.NewIncomingContext(ctx, mdIncoming)
}
}
return headers.Propagate(ctx)

View File

@@ -126,15 +126,20 @@ func (c *operationContext) augmentContext(ctx context.Context, header nexus.Head
func() string { return c.method },
)
if userAgent, ok := header[headerUserAgent]; ok {
parts := strings.Split(userAgent, clientNameVersionDelim)
if len(parts) == 2 {
mdIncoming, ok := metadata.FromIncomingContext(ctx)
if !ok {
mdIncoming = metadata.MD{}
// Use SplitN for efficiency but enforce exactly one delimiter to preserve the
// original (pre-SplitN) strictness where additional delimiters cause us to ignore
// the header instead of coalescing trailing data into the version string.
if strings.Count(userAgent, clientNameVersionDelim) == 1 { // exact single occurrence
parts := strings.SplitN(userAgent, clientNameVersionDelim, 2)
if len(parts) == 2 { // always true given Count==1, kept for defensive clarity
mdIncoming, ok := metadata.FromIncomingContext(ctx)
if !ok {
mdIncoming = metadata.MD{}
}
mdIncoming.Set(headers.ClientNameHeaderName, parts[0])
mdIncoming.Set(headers.ClientVersionHeaderName, parts[1])
ctx = metadata.NewIncomingContext(ctx, mdIncoming)
}
mdIncoming.Set(headers.ClientNameHeaderName, parts[0])
mdIncoming.Set(headers.ClientVersionHeaderName, parts[1])
ctx = metadata.NewIncomingContext(ctx, mdIncoming)
}
}
return headers.Propagate(ctx)

View File

@@ -270,14 +270,26 @@ func parseCronString(c string) (*schedulepb.StructuredCalendarSpec, *schedulepb.
// split fields
cal := schedulepb.CalendarSpec{Comment: comment}
fields := strings.Fields(c)
switch len(fields) {
// Use FieldsSeq to avoid building an unbounded slice; we only accept 57 fields.
const maxCronFields = 7
var toks [maxCronFields]string
n := 0
for tok := range strings.FieldsSeq(c) {
if n < maxCronFields {
toks[n] = tok
n++
continue
}
// More than 7 fields → invalid.
return nil, nil, "", errors.New("CronString does not have 5-7 fields")
}
switch n {
case 5:
cal.Minute, cal.Hour, cal.DayOfMonth, cal.Month, cal.DayOfWeek = fields[0], fields[1], fields[2], fields[3], fields[4]
cal.Minute, cal.Hour, cal.DayOfMonth, cal.Month, cal.DayOfWeek = toks[0], toks[1], toks[2], toks[3], toks[4]
case 6:
cal.Minute, cal.Hour, cal.DayOfMonth, cal.Month, cal.DayOfWeek, cal.Year = fields[0], fields[1], fields[2], fields[3], fields[4], fields[5]
cal.Minute, cal.Hour, cal.DayOfMonth, cal.Month, cal.DayOfWeek, cal.Year = toks[0], toks[1], toks[2], toks[3], toks[4], toks[5]
case 7:
cal.Second, cal.Minute, cal.Hour, cal.DayOfMonth, cal.Month, cal.DayOfWeek, cal.Year = fields[0], fields[1], fields[2], fields[3], fields[4], fields[5], fields[6]
cal.Second, cal.Minute, cal.Hour, cal.DayOfMonth, cal.Month, cal.DayOfWeek, cal.Year = toks[0], toks[1], toks[2], toks[3], toks[4], toks[5], toks[6]
default:
return nil, nil, "", errors.New("CronString does not have 5-7 fields")
}
@@ -404,14 +416,22 @@ func makeRange(s, field, def string, minVal, maxVal int, parseMode parseMode) ([
return nil, nil // special case for year: all is represented as empty range list
}
var ranges []*schedulepb.Range
for _, part := range strings.Split(s, ",") {
for part := range strings.SplitSeq(s, ",") {
var err error
step := 1
hasStep := false
if strings.Contains(part, "/") {
skipParts := strings.Split(part, "/")
if len(skipParts) != 2 {
return nil, fmt.Errorf("%s has too many slashes", field)
slashes := strings.Count(part, "/")
if slashes > 1 {
// Inputs like "3/5/7" should yield the canonical "too many slashes" error
// (instead of a later strconv parse error) so tests get consistent results.
return nil, fmt.Errorf("%s has too many slashes", field)
}
if slashes == 1 {
// A single slash introduces an integer step.
skipParts := strings.SplitN(part, "/", 2)
// Count==1 guarantees len==2; only need to ensure the right side is non-empty.
if skipParts[1] == "" { // e.g. "5/"
return nil, fmt.Errorf("%s missing step value", field)
}
part = skipParts[0]
step, err = strconv.Atoi(skipParts[1])
@@ -427,7 +447,13 @@ func makeRange(s, field, def string, minVal, maxVal int, parseMode parseMode) ([
start, end := minVal, maxVal
if part != "*" {
if strings.Contains(part, "-") {
rangeParts := strings.Split(part, "-")
// Only a single dash is allowed to denote a range (e.g. "1-5").
// Inputs with multiple dashes like "1-5-7" should raise the
// canonical "too many dashes" error expected by tests.
if strings.Count(part, "-") > 1 { // no negative numbers are expected in spec
return nil, fmt.Errorf("%s has too many dashes", field)
}
rangeParts := strings.SplitN(part, "-", 2)
if len(rangeParts) != 2 {
return nil, fmt.Errorf("%s has too many dashes", field)
}

View File

@@ -185,11 +185,9 @@ func parseOptionsMap(value string) map[string]string {
return make(map[string]string)
}
split := strings.Split(value, ",")
parsedMap := make(map[string]string)
for _, pair := range split {
for pair := range strings.SplitSeq(value, ",") {
trimmedPair := strings.ReplaceAll(pair, " ", "")
if len(trimmedPair) == 0 {
continue