diff --git a/CHANGES.md b/CHANGES.md index e2d271b..736103e 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,5 +1,10 @@ # **ANGoLS** Changes +## 0.10.0-beta3 - 26th February 2026 + +* added `ParseKeyValuePairsList()`; + + ## 0.10.0-beta2 - 15th February 2026 * added `GenerateSlice[T]()`; diff --git a/NEWS.md b/NEWS.md index 358a2e7..10a5e7e 100644 --- a/NEWS.md +++ b/NEWS.md @@ -3,6 +3,7 @@ | Date | News Item | | ------------------- | ---------------------------------------------------------------------------------------------------------- | +| 26th February 2026 | Release of [**ANGoLS** 0.10.0-beta3](https://github.com/synesissoftware/ANGoLS/releases/tag/0.10.0-beta3) | | 15th February 2026 | Release of [**ANGoLS** 0.10.0-beta2](https://github.com/synesissoftware/ANGoLS/releases/tag/0.10.0-beta2) | | 7th February 2026 | Release of [**ANGoLS** 0.10.0-beta1](https://github.com/synesissoftware/ANGoLS/releases/tag/0.10.0-beta1) | | 3rd January 2026 | Release of [**ANGoLS** 0.9.0](https://github.com/synesissoftware/ANGoLS/releases/tag/0.9.0) | diff --git a/strings/parse.go b/strings/parse.go new file mode 100644 index 0000000..d1ae6b2 --- /dev/null +++ b/strings/parse.go @@ -0,0 +1,256 @@ +// Copyright 2019-2026 Matthew Wilson and Synesis Information Systems. All +// rights reserved. Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package strings + +import ( + "errors" + "strings" + // d "github.com/synesissoftware/Diagnosticism.Go" +) + +type ParseKeyValuePairsListOption int64 + +// NOTE: do not ever insert into this list, only ever append to it + +const ( + ParseKeyValuePairsListOption_None ParseKeyValuePairsListOption = 0 + ParseKeyValuePairsListOption_IgnoreAnonymousValue ParseKeyValuePairsListOption = 1 << iota // causes an anonymous value - e.g. ",=val1" - to be omitted from the results; otherwise this causes parse failure + ParseKeyValuePairsListOption_PermitAnonymousValue // causes an anonymous value - e.g. ",=val1" - to be included in the results, with the key `""`; otherwise this causes parse failure + ParseKeyValuePairsListOption_IgnoreValuelessKey // causes a valueless key - e.g. ",key1," or ",key1=," - to be omitted from the results; otherwise this causes parse failure + ParseKeyValuePairsListOption_PermitValuelessKey // causes a valueless key - e.g. ",key1," or ",key1=," - to be included in the results, with the value `""`; otherwise this causes parse failure + ParseKeyValuePairsListOption_PermitRepeatedKeys // causes repeated key(s) to be included in the results; otherwise this will cause parse failure + ParseKeyValuePairsListOption_TakeFirstRepeatedKey // causes the first repeated key only to be obtained in the results; otherwise this will cause parse failure + ParseKeyValuePairsListOption_TakeLastRepeatedKey // causes the last repeated key only to be obtained in the results; otherwise this will cause parse failure + ParseKeyValuePairsListOption_PreserveOrder // causes key-value pairs order to be preserved in the results, which may cause more processing cost; otherwise arbitrary order will obtain +) + +// anonymous value, +// valueless key ",key1," or ",key1=," +// repeated key + +var ( + errAnonymousValueNotPermitted = errors.New("missing key") + errInvalidSeparators = errors.New("invalid separator(s): neither `pairSeparator` nor `keyValueSeparator` may be `nil`, nor may they be equal") + errRepeatedKeys = errors.New("repeated keys") + errValuelessKeyNotPermitted = errors.New("missing value") +) + +// Result type for `ParseKeyValuePairsList` +type KeyValuePair struct { + Key string // The key, or an empty string for an anonymous value + Value string // The value, or an empty string for a valueless key +} + +// Parses a string that contains a list of key-value pairs +// +// Parameters: +// - input - The input string; +// - pairSeparator - The pair separator, which may not be `nil` and may +// not equal `keyValueSeparator`; +// - keyValueSeparator - The key/value separator, which may not be `nil` +// and may not equal `pairSeparator`; +// - parseOptions - Options that control the parsing; +// +// Preconditions: +// - `pairSeparator != ""`; +// - `keyValueSeparator != ""`; +// - `pairSeparator != keyValueSeparator; +// +// Note: +// Whitespace is elided from around key-value pairs, and from around keys, +// but is preserved from values. Thus, if the only/last pair is to contain +// trailing whitespace, it must be followed by a pairSeparator, as in the +// following example " key1 = a really spacey value ," that would +// obtain a key of "key1" and a value of " a really spacey value ". +// +// Note: +// If input contains either/both of the separate sequences in the keys or +// values they are recognised as separators nonetheless - no escaping is +// supported in the current implementation. The exception to this is that a +// value may be obtained with a prefix of `keyValueSeparator` by specifying +// it twice (or more) in the pair, as in "key1==value1==," would obtain the +// value "=value1==". +func ParseKeyValuePairsList( + input string, + pairSeparator string, // e.g. "," + keyValueSeparator string, // e.g. "=" + parseOptions ParseKeyValuePairsListOption, +) ( + pairs []KeyValuePair, + err error, +) { + // fmt.Fprintf(os.Stderr, "%s(input=%s, pairSeparator=%s, keyValueSeparator=%s, parseOptions=%x)\n", d.FileLineFunction(), input, pairSeparator, keyValueSeparator, parseOptions) + + // precondition enforcement + + if pairSeparator == "" || keyValueSeparator == "" || pairSeparator == keyValueSeparator { + panic(errInvalidSeparators) + } + + pairs = make([]KeyValuePair, 0, len(input)/10) // 10 be a guess ... + + input = strings.TrimSpace(input) + + splits0 := strings.Split(input, pairSeparator) + + for i, s0 := range splits0 { + + splits1 := strings.SplitN(s0, keyValueSeparator, 2) + + var k string + var v string + + switch len(splits1) { + case 0: // e.g. 2nd from "key1=val1,,key3=val3" + + // ignore + continue + case 1: // e.g. 2nd from "key1=val1, ,key3=val3" + + k = strings.TrimSpace(splits1[0]) + + if k == "" { + + // ignore + continue + } else { + + if _hasFlag(parseOptions, ParseKeyValuePairsListOption_IgnoreValuelessKey) { + + // ignore + continue + } + + if !_hasFlag(parseOptions, ParseKeyValuePairsListOption_PermitValuelessKey) { + + err = errValuelessKeyNotPermitted + + return + } + + pairs = append(pairs, KeyValuePair{ + Key: k, + }) + } + case 2: + + k = strings.TrimSpace(splits1[0]) + v = splits1[1] + + if k == "" { + + if _hasFlag(parseOptions, ParseKeyValuePairsListOption_IgnoreAnonymousValue) { + + // ignore + continue + } + + if !_hasFlag(parseOptions, ParseKeyValuePairsListOption_PermitAnonymousValue) { + + err = errAnonymousValueNotPermitted + + return + } + } + + if v == "" { + + if _hasFlag(parseOptions, ParseKeyValuePairsListOption_IgnoreValuelessKey) { + + // ignore + continue + } + + if !_hasFlag(parseOptions, ParseKeyValuePairsListOption_PermitValuelessKey) { + + err = errValuelessKeyNotPermitted + + return + } + + _ = i // TODO: report error, including pair index + } + + pairs = append(pairs, KeyValuePair{ + Key: k, + Value: v, + }) + } + } + + // now deal with repeated keys + + if !_hasFlag(parseOptions, ParseKeyValuePairsListOption_PermitRepeatedKeys) { + + // need to + + switch { + case _hasFlag(parseOptions, ParseKeyValuePairsListOption_TakeLastRepeatedKey): + + pairs2 := make([]KeyValuePair, 0, len(pairs)) + + // map of string => int, where value is "index of first (i.e. only)" + + m := make(map[string]int, len(pairs)) + + for _, kv := range pairs { + + if ix, exists := m[kv.Key]; exists { + + if _hasFlag(parseOptions, ParseKeyValuePairsListOption_PreserveOrder) { + + } else { + + pairs2[ix] = kv + } + } else { + + pairs2 = append(pairs2, kv) + + m[kv.Key] = len(pairs2) - 1 + } + } + + pairs = pairs2 + default: + + pairs2 := make([]KeyValuePair, 0, len(pairs)) + + // map of string => bool, where true is "seen before" + + m := make(map[string]bool, len(pairs)) + + for _, kv := range pairs { + + if m[kv.Key] { + + if !_hasFlag(parseOptions, ParseKeyValuePairsListOption_TakeFirstRepeatedKey) { + + err = errRepeatedKeys + + return + } + } else { + + m[kv.Key] = true + + pairs2 = append(pairs2, kv) + } + } + + pairs = pairs2 + } + } + + return +} + +func _hasFlag( + parseOptions ParseKeyValuePairsListOption, + flag ParseKeyValuePairsListOption, +) bool { + + return (parseOptions & flag) == flag +} diff --git a/strings/parse_test.go b/strings/parse_test.go new file mode 100644 index 0000000..7b4ea4e --- /dev/null +++ b/strings/parse_test.go @@ -0,0 +1,287 @@ +package strings_test + +import ( + strings "github.com/synesissoftware/ANGoLS/strings" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "testing" +) + +func Test_ParseKeyValuePairsList(t *testing.T) { + + tests := []struct { + name string + input string + pairSeparator string + keyValueSeparator string + options strings.ParseKeyValuePairsListOption + pairs []strings.KeyValuePair + shouldFail bool + expectedErrStringFragment string + }{ + { + name: "empty input", + input: "", + pairSeparator: ",", + keyValueSeparator: "=", + pairs: []strings.KeyValuePair{}, + }, + { + name: "whitespace-only input", + input: " \t ", + pairSeparator: ",", + keyValueSeparator: "=", + pairs: []strings.KeyValuePair{}, + }, + { + name: "1-pair", + input: "k1=v1", + pairSeparator: ",", + keyValueSeparator: "=", + pairs: []strings.KeyValuePair{ + { + Key: "k1", + Value: "v1", + }, + }, + }, + { + name: "1-pair with trailing pair-separator", + input: "k1=v1,", + pairSeparator: ",", + keyValueSeparator: "=", + pairs: []strings.KeyValuePair{ + { + Key: "k1", + Value: "v1", + }, + }, + }, + { + name: "1-pair with trailing pair-separator and leading and trailing whitespace", + input: " k1=v1, ", + pairSeparator: ",", + keyValueSeparator: "=", + pairs: []strings.KeyValuePair{ + { + Key: "k1", + Value: "v1", + }, + }, + }, + { + name: "2-pairs", + input: "k1=v1,k2=val2", + pairSeparator: ",", + keyValueSeparator: "=", + pairs: []strings.KeyValuePair{ + { + Key: "k1", + Value: "v1", + }, + { + Key: "k2", + Value: "val2", + }, + }, + }, + { + name: "2-pairs with leading and trailing pair-separators", + input: ",,,,,,k1=v1,k2=val2,,", + pairSeparator: ",", + keyValueSeparator: "=", + pairs: []strings.KeyValuePair{ + { + Key: "k1", + Value: "v1", + }, + { + Key: "k2", + Value: "val2", + }, + }, + }, + // anonymous value(s) + { + name: "1-pair with anonymous value, which fails", + input: "=v1", + pairSeparator: ",", + keyValueSeparator: "=", + shouldFail: true, + expectedErrStringFragment: "missing key", + }, + { + name: "1-pair with anonymous value, which is ignored", + input: "=v1", + pairSeparator: ",", + keyValueSeparator: "=", + pairs: []strings.KeyValuePair{}, + options: strings.ParseKeyValuePairsListOption_IgnoreAnonymousValue, + }, + { + name: "1-pair with anonymous value, which is permitted", + input: "=v1", + pairSeparator: ",", + keyValueSeparator: "=", + pairs: []strings.KeyValuePair{ + { + Key: "", + Value: "v1", + }, + }, + options: strings.ParseKeyValuePairsListOption_PermitAnonymousValue, + }, + // valueless key(s) + { + name: "1-pair with valueless key, which fails", + input: "k1", + pairSeparator: ",", + keyValueSeparator: "=", + shouldFail: true, + expectedErrStringFragment: "missing value", + }, + { + name: "1-pair with valueless key, which is ignored", + input: "k1", + pairSeparator: ",", + keyValueSeparator: "=", + pairs: []strings.KeyValuePair{}, + options: strings.ParseKeyValuePairsListOption_IgnoreValuelessKey, + }, + { + name: "1-pair with valueless key, which is permitted", + input: "k1", + pairSeparator: ",", + keyValueSeparator: "=", + pairs: []strings.KeyValuePair{ + { + Key: "k1", + Value: "", + }, + }, + options: strings.ParseKeyValuePairsListOption_PermitValuelessKey, + }, + { + name: "1-pair with valueless key (and separator), which fails", + input: "k1=", + pairSeparator: ",", + keyValueSeparator: "=", + shouldFail: true, + expectedErrStringFragment: "missing value", + }, + { + name: "1-pair with valueless key (and separator), which is ignored", + input: "k1=", + pairSeparator: ",", + keyValueSeparator: "=", + pairs: []strings.KeyValuePair{}, + options: strings.ParseKeyValuePairsListOption_IgnoreValuelessKey, + }, + { + name: "1-pair with valueless key (and separator), which is permitted", + input: "k1=", + pairSeparator: ",", + keyValueSeparator: "=", + pairs: []strings.KeyValuePair{ + { + Key: "k1", + Value: "", + }, + }, + options: strings.ParseKeyValuePairsListOption_PermitValuelessKey, + }, + // repeated key(s) + { + name: "2-pair with identical keys, which fails", + input: "k1=v1,k2=v2,k1=v3", + pairSeparator: ",", + keyValueSeparator: "=", + shouldFail: true, + expectedErrStringFragment: "repeated keys", + }, + { + name: "2-pair with identical keys, which is permitted", + input: "k1=v1,k2=v2,k1=v3", + pairSeparator: ",", + keyValueSeparator: "=", + pairs: []strings.KeyValuePair{ + { + Key: "k1", + Value: "v1", + }, + { + Key: "k2", + Value: "v2", + }, + { + Key: "k1", + Value: "v3", + }, + }, + options: strings.ParseKeyValuePairsListOption_PermitRepeatedKeys, + }, + { + name: "2-pair with identical keys, keeping the first", + input: "k1=v1,k2=v2,k1=v3", + pairSeparator: ",", + keyValueSeparator: "=", + pairs: []strings.KeyValuePair{ + { + Key: "k1", + Value: "v1", + }, + { + Key: "k2", + Value: "v2", + }, + }, + options: strings.ParseKeyValuePairsListOption_TakeFirstRepeatedKey, + }, + { + name: "2-pair with identical keys, keeping the last", + input: "k1=v1,k2=v2,k1=v3", + pairSeparator: ",", + keyValueSeparator: "=", + pairs: []strings.KeyValuePair{ + { + Key: "k1", + Value: "v3", + }, + { + Key: "k2", + Value: "v2", + }, + }, + options: strings.ParseKeyValuePairsListOption_TakeLastRepeatedKey, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + + if pairs, err := strings.ParseKeyValuePairsList( + tt.input, + tt.pairSeparator, + tt.keyValueSeparator, + tt.options, + ); err != nil { + + require.True(t, tt.shouldFail) + + assert.Contains(t, tt.expectedErrStringFragment, err.Error()) + } else { + + require.False(t, tt.shouldFail) + + require.Equal(t, len(tt.pairs), len(pairs), "expected %d pair(s) but obtained %d", len(tt.pairs), len(pairs)) + + for i := 0; i != len(tt.pairs); i++ { + + assert.Equal(t, tt.pairs[i], pairs[i]) + } + } + }) + } +} diff --git a/version.go b/version.go index 8c780c1..4e6f859 100644 --- a/version.go +++ b/version.go @@ -15,7 +15,7 @@ const ( VersionMajor uint16 = 0 VersionMinor uint16 = 10 VersionPatch uint16 = 0 - VersionAB uint16 = 0x8002 + VersionAB uint16 = 0x8003 Version uint64 = (uint64(VersionMajor) << 48) + (uint64(VersionMinor) << 32) + (uint64(VersionPatch) << 16) + (uint64(VersionAB) << 0) ) diff --git a/version_test.go b/version_test.go index 40721e7..f935878 100644 --- a/version_test.go +++ b/version_test.go @@ -12,7 +12,7 @@ const ( Expected_VersionMajor uint16 = 0 Expected_VersionMinor uint16 = 10 Expected_VersionPatch uint16 = 0 - Expected_VersionAB uint16 = 0x8002 + Expected_VersionAB uint16 = 0x8003 ) func Test_Version_Elements(t *testing.T) { @@ -23,9 +23,9 @@ func Test_Version_Elements(t *testing.T) { } func Test_Version(t *testing.T) { - require.Equal(t, uint64(0x0000_000A_0000_8002), angols.Version) + require.Equal(t, uint64(0x0000_000A_0000_8003), angols.Version) } func Test_Version_String(t *testing.T) { - require.Equal(t, "0.10.0-beta2", angols.VersionString()) + require.Equal(t, "0.10.0-beta3", angols.VersionString()) }