From 6d0e13f05e2c001d69cb3714fdc0447e9cd3ec20 Mon Sep 17 00:00:00 2001 From: Dmitry Fedotov Date: Wed, 9 Apr 2025 04:57:40 +0300 Subject: [PATCH] rewrite --- conf.go | 210 +++++++++++++++++++++++++++++++++++++++++++ conf_test.go | 216 +++++++++++++++++++++++++-------------------- config.go | 112 ----------------------- example/main.go | 25 +----- parser.go | 108 ----------------------- parser_test.go | 159 --------------------------------- settings.go | 109 ----------------------- testdata/test.conf | 5 ++ util.go | 32 +++++++ util_test.go | 209 +++++++++++++++++++++++++++++++++++++++++++ value.go | 119 +++++++++++++++++++++++++ 11 files changed, 700 insertions(+), 604 deletions(-) create mode 100644 conf.go delete mode 100644 config.go delete mode 100644 parser.go delete mode 100644 parser_test.go delete mode 100644 settings.go create mode 100644 testdata/test.conf create mode 100644 util.go create mode 100644 util_test.go create mode 100644 value.go diff --git a/conf.go b/conf.go new file mode 100644 index 0000000..3b8fbae --- /dev/null +++ b/conf.go @@ -0,0 +1,210 @@ +// module conf +// +// import "code.uint32.ru/tiny/conf" +// +// Module conf implements a very simple config parser with two types of +// values: key value pairs and single word options. Each of these must be +// put on separate line in a file like this: +// +// key1 = value +// key2 = value1, value2, value3 +// key3=v1,v2,v3 +// option1 +// option2 +// +// Values can also be read from any io.Reader or io.ReadCloser. Note that +// values in key value pairs mey either be separated with spaces or not. +// Typical use case would look like this: +// +// config, err := conf.ParseFile("filename") +// if err != nil { +// // Means we failed to read from file +// // config variable is now nil and unusable +// } +// value, err := config.Find("mykey") +// if err != nil { +// // Means that key has not been found +// } +// // value now holds conf.Setting. +// n, err := value.Float64() // tries to parse float from Setting.Value field. +// //if err is nil then n holds float64. +// +// There is also a quicker Get() method which returns no errors +// ("i'm feeling lucky" way to lookup values). If it does not find +// requested key, the returned Setting has empty string in Value field. +// +// value2 := config.Get("otherkey") +// mybool, err := value2.Bool() // tries to interpret Setting.Value field as bool +// // mybool now holds boolean if "otherkey" was found and error returned +// // by Bool() method is nil. +// +// Even shorter syntax would be: +// +// listnumbers, err := config.Get("numbers").IntSlice() +// // Note that we'd still like to check for errors even if +// // we're sure the key exists to make sure all values are converted. +// // listnumbers holds slice of ints. If err is nil all of found values +// // have been converted successfully. +// +// To check whether single-word options were found use: +// +// if config.HasOption("wordoption") { +// // do something +// } +// +// See description of module's other methods which are +// quite self-explanatory. +package conf + +import ( + "bufio" + "errors" + "fmt" + "io" + "os" +) + +var ( + ErrFormat = errors.New("conf: line does not match \"key = value\" pattern") + ErrDuplicateKey = errors.New("conf: duplicate key found") +) + +// Conf holds key value pairs. +type Conf struct { + values []*kv // values store key value pairs ("key = value" in config file) +} + +type kv struct { + k string + v Value +} + +// Open reads values from file. +func Open(filename string) (*Conf, error) { + c := new(Conf) + if err := c.readFile(filename); err != nil { + return nil, err + } + + return c, nil +} + +// Read reads from r and returns Conf. +func Read(r io.Reader) (*Conf, error) { + c := new(Conf) + if err := c.read(r); err != nil { + return nil, err + } + + return c, nil +} + +// Find looks up a the key and returns Value associated with it. +// If bool is false then the returned Value would be zero. +func (c *Conf) Find(key string) (Value, bool) { + return c.get(key, "") +} + +// Get returns a Value. If key was not found the returned Value +// will be empty. +func (c *Conf) Get(key string) Value { + v, _ := c.get(key, "") + return v +} + +// GetDefault looks up a Value for requested key. +// If lookup fails it returns Value set to def. +func (c *Conf) GetDefault(key, def string) Value { + v, _ := c.get(key, def) + return v +} + +func (c *Conf) Keys() []string { + list := make([]string, 0, len(c.values)) + + for i := range c.values { + list = append(list, c.values[i].k) + } + + return list +} + +func (c *Conf) Read(r io.Reader) error { + return c.read(r) +} + +func (c *Conf) ReadFile(name string) error { + return c.readFile(name) +} + +func (c *Conf) get(key string, def string) (Value, bool) { + var ( + v Value + found bool + ) + for i := range c.values { + if c.values[i].k == key { + v = c.values[i].v + found = true + break + } + } + + if def != "" && !found { + v = Value(def) + } + + return v, found +} + +func (c *Conf) readFile(name string) error { + file, err := os.Open(name) + if err != nil { + return err + } + + if err := c.read(file); err != nil { + return err + } + + return file.Close() +} + +func (c *Conf) read(r io.Reader) error { + uniq := make(map[string]struct{}) + + scanner := bufio.NewScanner(r) + + for scanner.Scan() { + line := scanner.Text() + + line = trim(stripComment(line)) + if line == "" { + continue + } + + key, value, ok := toKV(line, separatorKV) + if !ok { + return ErrFormat + } + + if _, ok := uniq[key]; ok { + return fmt.Errorf("%w: %s", ErrDuplicateKey, key) + } + + uniq[key] = struct{}{} + + kv := &kv{ + k: key, + v: Value(value), + } + + c.values = append(c.values, kv) + } + + if err := scanner.Err(); err != nil { + return err + } + + return nil +} diff --git a/conf_test.go b/conf_test.go index 7dfa68d..d4b48b2 100644 --- a/conf_test.go +++ b/conf_test.go @@ -2,108 +2,134 @@ package conf import ( "bytes" + "errors" + "reflect" + "slices" "testing" ) -var testConf = []byte(`port=10000 -# removed -two = two words -commas = abc, def, ghi -token=test -bool1=1 -bool2=true +func TestRead(t *testing.T) { + tc := []struct { + name string + in []byte + res *Conf + err error + }{ + { + name: "empty", + in: []byte{}, + res: &Conf{ + values: nil, // no reads, so the slice has not been initialized + }, + err: nil, // if the reader just returned io.EOF, which isn't an error for us + }, + { + name: "single key with spaces", + in: []byte("key = value"), + res: &Conf{ + values: []*kv{ + { + k: "key", + v: Value("value"), + }, + }, + }, + err: nil, + }, + { + name: "single key no spaces", + in: []byte("key=value"), + res: &Conf{ + values: []*kv{ + { + k: "key", + v: Value("value"), + }, + }, + }, + err: nil, + }, + { + name: "single key with newline", + in: []byte("key=value\n"), + res: &Conf{ + values: []*kv{ + { + k: "key", + v: Value("value"), + }, + }, + }, + err: nil, + }, + { + name: "single key with comment and newline", + in: []byte("key =value # this is a comment\n"), + res: &Conf{ + values: []*kv{ + { + k: "key", + v: Value("value"), + }, + }, + }, + err: nil, + }, + } -editor = vim -distance=13.42 -floats=0.5,2.37,6 -floatswithstring = 0.5, hello, 0.9 -false -color`) + for _, c := range tc { + t.Run(c.name, func(t *testing.T) { + r := bytes.NewReader(c.in) -func TestPackage(t *testing.T) { - r := bytes.NewReader(testConf) - c, err := parseReader(r) + conf, err := Read(r) + if !errors.Is(err, c.err) { + t.Fatalf("want error: %v have: %v", c.err, err) + } + + if !reflect.DeepEqual(c.res, conf) { + t.Fatalf("want: %+v, have: %+v", c.res, conf) + } + }) + } +} + +func TestOpen(t *testing.T) { + conf, err := Open("./testdata/test.conf") if err != nil { t.Fatal(err) } - if _, err := c.Get("floatswithstring").Float64Slice(); err == nil { - t.Log("Float64Slice accepting incorrect values") - t.Fail() - } - if _, err := c.Get("floats").Float64Slice(); err != nil { - t.Log("Float64Slice failing on correct values") - t.Fail() - } - if v := c.Get("token").String(); v != "test" { - t.Log("failed finding key value") - t.Fail() - } - if v := c.Get("editor").String(); v != "vim" { - t.Log("failed finding key value") - t.Fail() - } - if v, _ := c.Get("port").Int(); v != 10000 { - t.Log("failed finding int") - t.Fail() - } - if v, _ := c.Get("distance").Float64(); v != 13.42 { - t.Log("failed finding key value") - t.Fail() - } - if c.HasOption("color") != true { - t.Log("failed finding option") - t.Fail() - } - if v, _ := c.Get("bool1").Bool(); v != true { - t.Log("failed finding bool1 value") - t.Fail() - } - if v, _ := c.Get("bool2").Bool(); v != true { - t.Log("failed finding bool2 value") - t.Fail() - } - if v := c.Get("two").String(); v != "two words" { - t.Log("failed finding key value") - t.Fail() - } - if v := c.Get("commas").String(); v != "abc, def, ghi" { - t.Log("failed finding key value") - t.Fail() - } - if v := c.Get("nonexistent").String(); v != "" { - t.Log("returned non-empty string for nonexistent key") - t.Fail() - } - if c.HasOption("removed") { - t.Log("commented out line shows up in config") - t.Fail() - } - splitted := c.Get("commas").StringSlice() - if len(splitted) != 3 { - t.Log("could not split string") - t.Fail() - } - abc := splitted[0] - ghi := splitted[2] - if abc != "abc" || ghi != "ghi" { - t.Log("Split() returned incorrect values") - t.Fail() - } - if c.HasOption("no") != true { - t.Log("should capture one option per line even if line holds two") - t.Fail() - } - if c.HasOption("missedme") == true { - t.Log("should only capture one option per line") - t.Fail() - } - st := Setting{} - if v, err := st.Float64(); v != 0.0 || err == nil { - t.Log("empty string erroneously converts to float") - t.Fail() - } - if def := c.GetDefault("non-existant-key", "myvalue"); def.String() != "myvalue" { - t.Log("GetDefault fails to apply default value") - t.Fail() + + const ( + key = "key" + value = "value" + ) + for _, n := range []string{"1", "2", "3", "4"} { + v := conf.Get(key + n) + if v.String() != value+n { + t.Errorf("want: %s got: %s", value+n, v) + } + } +} + +func TestKeys(t *testing.T) { + c := Conf{ + values: []*kv{ + { + k: "1", + v: Value(""), + }, + { + k: "2", + v: Value(""), + }, + { + k: "3", + v: Value(""), + }, + }, + } + + if !slices.Equal([]string{"1", "2", "3"}, c.Keys()) { + t.Fatal("Keys method returns incorrect values") } } diff --git a/config.go b/config.go deleted file mode 100644 index 2a1995f..0000000 --- a/config.go +++ /dev/null @@ -1,112 +0,0 @@ -// module conf -// -// import "code.uint32.ru/tiny/conf" -// -// Module conf implements a very simple config parser with two types of -// values: key value pairs and single word options. Each of these must be -// put on separate line in a file like this: -// -// key1 = value -// key2 = value1, value2, value3 -// key3=v1,v2,v3 -// option1 -// option2 -// -// Values can also be read from any io.Reader or io.ReadCloser. Note that -// values in key value pairs mey either be separated with spaces or not. -// Typical use case would look like this: -// -// config, err := conf.ParseFile("filename") -// if err != nil { -// // Means we failed to read from file -// // config variable is now nil and unusable -// } -// value, err := config.Find("mykey") -// if err != nil { -// // Means that key has not been found -// } -// // value now holds conf.Setting. -// n, err := value.Float64() // tries to parse float from Setting.Value field. -// //if err is nil then n holds float64. -// -// There is also a quicker Get() method which returns no errors -// ("i'm feeling lucky" way to lookup values). If it does not find -// requested key, the returned Setting has empty string in Value field. -// -// value2 := config.Get("otherkey") -// mybool, err := value2.Bool() // tries to interpret Setting.Value field as bool -// // mybool now holds boolean if "otherkey" was found and error returned -// // by Bool() method is nil. -// -// Even shorter syntax would be: -// -// listnumbers, err := config.Get("numbers").IntSlice() -// // Note that we'd still like to check for errors even if -// // we're sure the key exists to make sure all values are converted. -// // listnumbers holds slice of ints. If err is nil all of found values -// // have been converted successfully. -// -// To check whether single-word options were found use: -// -// if config.HasOption("wordoption") { -// // do something -// } -// -// See description of module's other methods which are -// quite self-explanatory. -package conf - -// Config holds parsed keys and values. -type Conf struct { - // Settings store key value pairs ("key = value" in config file) - // all key value pairs found when parsing input are accumulated in this map. - settings map[string]string -} - -// Find looks up a Setting and returns it. If returned error is not nil -// the requested key was not found and returned Setting has empty string in Value -// field. -func (c *Conf) Find(key string) (Setting, bool) { - return c.get(key, "") -} - -// Get returns a Setting. If key was not found the returned Setting Value -// will be empty string. -func (c *Conf) Get(key string) Setting { - s, _ := c.get(key, "") - return s -} - -// GetDefault looks up a Setting with requested key. -// If lookup fails it returns Setting with Value field set to def. -func (c *Conf) GetDefault(key, def string) Setting { - s, _ := c.get(key, def) - return s -} - -func (c *Conf) Settings() []Setting { - list := make([]Setting, 0, len(c.settings)) - - for k, v := range c.settings { - s := Setting{ - Name: k, - Value: v, - } - - list = append(list, s) - } - - return list -} - -func (c *Conf) get(key string, def string) (Setting, bool) { - v, ok := c.settings[key] - if def != "" && !ok { - v = def - } - - return Setting{ - Name: key, - Value: v, - }, ok -} diff --git a/example/main.go b/example/main.go index 5ee17e1..312d3ba 100644 --- a/example/main.go +++ b/example/main.go @@ -15,21 +15,11 @@ bool0=0 booltrue = true distance=13.42 iamsure = hopefully you're not mistaken -float = 13.1984 -color`) +float = 13.1984`) func main() { r := bytes.NewReader(testConf) // creating io.Reader from []byte() - config := conf.ParseReader(r) // we could call conf.ParseFile("filename") here - - // First of all we can access parsed values directly: - for key, value := range config.Settings { - fmt.Println(key, value) - } - for opt := range config.Options { - fmt.Println(opt) - } - fmt.Println() + config, _ := conf.Read(r) // we could call conf.ParseFile("filename") here // Find( key string) returns instance of Setting and an // error if key was not found @@ -39,8 +29,8 @@ func main() { // } // // You can access Setting's Value field (type string) directly. - port, err := config.Find("port") - fmt.Printf("variable port has type: %T, port.Value == %v, type of port.Value is: %T, error returned: %v\n", port, port.Value, port.Value, err) + port, ok := config.Find("port") + fmt.Printf("variable port has type: %T, port.Value == %v, type of port.Value is: %T, error returned: %v\n", port, port.Value, port.Value, ok) // We can cast Setting.Value to a desired type including int, float64, // bool and string. Method will return an error if Setting Value field @@ -75,13 +65,6 @@ func main() { def := config.GetDefault("non-existant-key", "myvalue") fmt.Println(def.Value) // "myvalue" - // You can use HasOption method to find whether single-word options were - // present in the the config - if config.HasOption("color") { - fmt.Println("Hooray, we've found option \"color\"!") - // do something useful - } - // Below code finds two keys with bool values in the Config // and outputs those. var t, f bool diff --git a/parser.go b/parser.go deleted file mode 100644 index 1880f13..0000000 --- a/parser.go +++ /dev/null @@ -1,108 +0,0 @@ -package conf - -import ( - "bufio" - "errors" - "fmt" - "io" - "os" - "strings" -) - -const ( - strComment = "#" - strEqSign = "=" -) - -var ( - ErrFormat = errors.New("conf: line does not match \"key = value\" pattern") - ErrDuplicateKey = errors.New("conf: duplicate key found") -) - -// Open reads values from file. It returns nil and error if os.Open(filename) fails. -// It would be wise to always check returned error. -// ParseFile captures two types of values: "key = value" and "option". Either key value pair or -// option must be put in its own line in the file. -// Key or option must be a single word. In example line: -// -// "option1 option2" -// -// only option1 will be captured. option2 needs to be in a separate line in the file to take effect. -// In line "key = value1,value2,value3" all of value1, value2, and value3 will be -// captured. They can be later accesed separately with Setting's Split() method. -func Open(filename string) (*Conf, error) { - file, err := os.Open(filename) - if err != nil { - return nil, err - } - - c, err := parseReader(file) - if err != nil { - return nil, err - } - - return c, file.Close() -} - -// Read reads from r and returns Config. -func Read(r io.Reader) (*Conf, error) { - return parseReader(r) -} - -func parseReader(r io.Reader) (*Conf, error) { - settings := make(map[string]string) - - scanner := bufio.NewScanner(r) - - for scanner.Scan() { - line := scanner.Text() - - line = trim(stripComment(line)) - if line == "" { - continue - } - - key, value, ok := toKV(line) - if !ok { - return nil, ErrFormat - } - - if _, ok := settings[key]; ok { - return nil, fmt.Errorf("%w: %s", ErrDuplicateKey, key) - } - - settings[key] = value - } - - if err := scanner.Err(); err != nil { - return nil, err - } - - return &Conf{ - settings: settings, - }, nil -} - -func toKV(s string) (string, string, bool) { - k, v, ok := strings.Cut(s, strEqSign) - if !ok || k == "" || v == "" { - return "", "", false - } - - return trim(k), trim(v), true -} - -func trim(s string) string { - s = strings.TrimSpace(s) - - return s -} - -func stripComment(s string) string { - idx := strings.Index(s, strComment) - if idx > -1 { - s = s[:idx] - } - - return s -} diff --git a/parser_test.go b/parser_test.go deleted file mode 100644 index 2c53dd6..0000000 --- a/parser_test.go +++ /dev/null @@ -1,159 +0,0 @@ -package conf - -import ( - "bytes" - "errors" - "reflect" - "testing" -) - -func Test_trim(t *testing.T) { - tc := []struct { - name string - in string - want string - }{ - { - name: "empty", - in: "", - want: "", - }, - { - name: "comment", - in: "# some comment", - want: "", - }, - { - name: "comment", - in: "#some comment", - want: "", - }, - { - name: "comment", - in: " #some comment", - want: "", - }, - { - name: "key value", - in: "key = value", - want: "key = value", - }, - { - name: "key value with whitespace", - in: " key = value ", - want: "key = value", - }, - { - name: "key value with whitespace", - in: "\tkey = value \t", - want: "key = value", - }, - { - name: "key value with whitespace", - in: "\tkey = value \t\n", - want: "key = value", - }, - { - name: "key value with comment", - in: "key = value #comment", - want: "key = value", - }, - { - name: "option", - in: "option \n", - want: "option", - }, - { - name: "option with comment", - in: "option # comment \n", - want: "option", - }, - } - - for _, c := range tc { - t.Run(c.name, func(t *testing.T) { - have := trim(c.in) - if have != c.want { - t.Errorf("want: %s, have: %s", have, c.want) - } - }) - } -} - -func TestRead(t *testing.T) { - tc := []struct { - name string - in []byte - res *Conf - err error - }{ - { - name: "empty", - in: []byte{}, - res: &Conf{ - settings: make(map[string]string), - options: map[string]struct{}{}, - }, - err: nil, // if the reader is empty its not out fault - }, - { - name: "single key with spaces", - in: []byte("key = value"), - res: &Conf{ - settings: map[string]string{ - "key": "value", - }, - options: make(map[string]struct{}), - }, - err: nil, - }, - { - name: "single key no spaces", - in: []byte("key=value"), - res: &Conf{ - settings: map[string]string{ - "key": "value", - }, - options: make(map[string]struct{}), - }, - err: nil, - }, - { - name: "single key with newline", - in: []byte("key=value\n"), - res: &Conf{ - settings: map[string]string{ - "key": "value", - }, - options: make(map[string]struct{}), - }, - err: nil, - }, - { - name: "single key with comment and newline", - in: []byte("key =value # this is a comment\n"), - res: &Conf{ - settings: map[string]string{ - "key": "value", - }, - options: make(map[string]struct{}), - }, - err: nil, - }, - } - - for _, c := range tc { - t.Run(c.name, func(t *testing.T) { - r := bytes.NewReader(c.in) - - conf, err := Read(r) - if !errors.Is(err, c.err) { - t.Fatalf("want error: %v have: %v", c.err, err) - } - - if !reflect.DeepEqual(c.res, conf) { - t.Fatalf("want: %+v, have: %+v", c.res, conf) - } - }) - } -} diff --git a/settings.go b/settings.go deleted file mode 100644 index ae1dc9a..0000000 --- a/settings.go +++ /dev/null @@ -1,109 +0,0 @@ -package conf - -import ( - "errors" - "strconv" - "strings" -) - -var ( - ErrNotFound = errors.New("conf: key not found") - ErrCouldNotConvert = errors.New("conf: could not cast one or more values to required type") -) - -const valuesSeparator = "," - -// Setting represents key-value pair read from config file. -// It's Value field holds the value of key parsed from the configuration -type Setting struct { - Name string - Value string -} - -// Int converts Setting Value to int. Returned error -// will be non nil if convesion failed. -func (s Setting) Int() (int, error) { - return parseValue(s.Value, strconv.Atoi) -} - -// IntSlice splits Setting Value (separator is ",") and adds -// each of resulting values to []int if possible. -// If one or more values can not be converted to float64 those will be dropped -// and method will return conf.ErrCouldNotConvert. -// Check error to be sure that all required values were parsed. -func (s Setting) IntSlice() ([]int, error) { - return parseSlice(s.Value, strconv.Atoi) -} - -// Float64 converts Setting Value to float64. Returned error -// will be non nil if convesion failed. -func (s Setting) Float64() (float64, error) { - return parseValue(s.Value, parseFloat64) -} - -// Float64Slice splits Setting Value (separator is ",") and adds -// each of resulting values to []float64 if possible. -// If one or more values can not be converted to float64 those will be dropped -// and method will return conf.ErrCouldNotConvert. -// Check error to be sure that all required values were parsed. -func (s Setting) Float64Slice() ([]float64, error) { - return parseSlice(s.Value, parseFloat64) -} - -// String returns option Value as string -// This method also implements Stringer interface from fmt module -func (s Setting) String() string { - return s.Value -} - -// StringSlice splits Setting's Value (separator is ",") and adds -// each of resulting values to []string trimming leading and trailing spaces -// from each string. -func (s Setting) StringSlice() []string { - return tidySplit(s.Value) -} - -// Bool tries to interpret Setting's Value as bool -// "1", "t", "T", true", "True", "TRUE" yields true -// "0", "f", "F, "false", "False", "FALSE" yields false -// If nothing matches will return false and conf.ErrCouldNotConvert. -func (s Setting) Bool() (bool, error) { - return parseValue(s.Value, strconv.ParseBool) -} - -func parseSlice[T any](s string, f func(string) (T, error)) ([]T, error) { - split := tidySplit(s) - - list := make([]T, 0, len(split)) - for _, str := range split { - v, err := parseValue(str, f) - if err != nil { - return list, err - } - - list = append(list, v) - } - - return list, nil -} - -func parseValue[T any](s string, f func(string) (T, error)) (T, error) { - v, err := f(s) - if err != nil { - return v, errors.Join(ErrCouldNotConvert, err) - } - - return v, err -} - -func tidySplit(s string) []string { - splitted := strings.Split(s, valuesSeparator) - for i, str := range splitted { - splitted[i] = strings.TrimSpace(str) - } - return splitted -} - -func parseFloat64(s string) (float64, error) { - return strconv.ParseFloat(s, 64) -} diff --git a/testdata/test.conf b/testdata/test.conf new file mode 100644 index 0000000..c32cb72 --- /dev/null +++ b/testdata/test.conf @@ -0,0 +1,5 @@ +# comment +key1 = value1 +key2=value2 +key3 = value3 +key4 = value4 # comment \ No newline at end of file diff --git a/util.go b/util.go new file mode 100644 index 0000000..4ca686c --- /dev/null +++ b/util.go @@ -0,0 +1,32 @@ +package conf + +import "strings" + +const ( + separatorComment = "#" + separatorKV = "=" +) + +func toKV(s string, sep string) (string, string, bool) { + k, v, ok := strings.Cut(s, sep) + if !ok || k == "" || v == "" { + return "", "", false + } + + return trim(k), trim(v), true +} + +func trim(s string) string { + s = strings.TrimSpace(s) + + return s +} + +func stripComment(s string) string { + idx := strings.Index(s, separatorComment) + if idx > -1 { + s = s[:idx] + } + + return s +} diff --git a/util_test.go b/util_test.go new file mode 100644 index 0000000..8155e62 --- /dev/null +++ b/util_test.go @@ -0,0 +1,209 @@ +package conf + +import "testing" + +func Test_trim(t *testing.T) { + tc := []struct { + name string + in string + want string + }{ + { + name: "empty", + in: "", + want: "", + }, + { + name: "spaces", + in: " text ", + want: "text", + }, + { + name: "tabs", + in: "\ttext\t", + want: "text", + }, + { + name: "newline", + in: "text\n", + want: "text", + }, + { + name: "newline", + in: "text\r", + want: "text", + }, + { + name: "newline", + in: "text\r\n", + want: "text", + }, + { + name: "mixed", + in: " \t text\n\r", + want: "text", + }, + } + + for _, c := range tc { + t.Run(c.name, func(t *testing.T) { + have := trim(c.in) + if have != c.want { + t.Errorf("want: %s, have: %s", have, c.want) + } + }) + } +} + +func Test_stripComment(t *testing.T) { + tc := []struct { + name string + in string + want string + }{ + { + name: "empty", + in: "", + want: "", + }, + { + name: "comment", + in: "# some comment", + want: "", + }, + { + name: "comment", + in: "#some comment", + want: "", + }, + { + name: "comment", + in: "text#some comment", + want: "text", + }, + { + name: "comment", + in: "text #some comment", + want: "text ", + }, + { + name: "comment", + in: " text #some comment", + want: " text ", + }, + } + + for _, c := range tc { + t.Run(c.name, func(t *testing.T) { + have := stripComment(c.in) + if have != c.want { + t.Errorf("want: %s, have: %s", have, c.want) + } + }) + } +} + +func Test_toKV(t *testing.T) { + tc := []struct { + name string + in string + k string + v string + ok bool + }{ + { + name: "empty", + in: "", + k: "", + v: "", + ok: false, + }, + { + name: "empty value", + in: "key=", + k: "", + v: "", + ok: false, + }, + { + name: "empty key", + in: "=value", + k: "", + v: "", + ok: false, + }, + { + name: "incorrect separator", + in: "key/value", + k: "", + v: "", + ok: false, + }, + { + name: "incorrect separator", + in: "key / value", + k: "", + v: "", + ok: false, + }, + { + name: "incorrect separator", + in: "key value", + k: "", + v: "", + ok: false, + }, + { + name: "no spaces", + in: "key=value", + k: "key", + v: "value", + ok: true, + }, + { + name: "with spaces", + in: "key = value", + k: "key", + v: "value", + ok: true, + }, + + { + name: "with spaces", + in: " key = value ", + k: "key", + v: "value", + ok: true, + }, + { + name: "with tabs", + in: "key\t=\tvalue", + k: "key", + v: "value", + ok: true, + }, + { + name: "with spaces and tabs", + in: " key\t=\tvalue ", + k: "key", + v: "value", + ok: true, + }, + { + name: "more tabs", + in: "\tkey\t=\tvalue\t", + k: "key", + v: "value", + ok: true, + }, + } + + for _, c := range tc { + t.Run(c.name, func(t *testing.T) { + k, v, ok := toKV(c.in, separatorKV) + if k != c.k || v != c.v || ok != c.ok { + t.Errorf("want: %s %s %t, have: %s %s %t", c.k, c.v, c.ok, k, v, ok) + } + }) + } +} diff --git a/value.go b/value.go new file mode 100644 index 0000000..a9f813a --- /dev/null +++ b/value.go @@ -0,0 +1,119 @@ +package conf + +import ( + "errors" + "fmt" + "strconv" + "strings" +) + +var ( + ErrCouldNotConvert = errors.New("conf: could not cast one or more values to required type") +) + +const ( + separatorSlice = "," + separatorMap = ":" +) + +type Value string + +func (v Value) Map() (map[string]Value, error) { + split := tidySplit(v, separatorSlice) + + m := make(map[string]Value, len(split)) + + for i := range split { + k, v, ok := toKV(split[i], separatorMap) + if !ok { + // TODO + return nil, fmt.Errorf("could not convert to map") + } + + m[k] = Value(v) + } + return m, nil +} + +// String returns Value as string +func (v Value) String() string { + return string(v) +} + +// Int converts Value to int. Returned error +// will be non nil if convesion failed. +func (v Value) Int() (int, error) { + return parseValue(v, strconv.Atoi) +} + +// IntSlice splits Value (separator is ",") and adds +// each of resulting values to []int if possible. +// Returns non-nil error on first failure to convert. +func (v Value) IntSlice() ([]int, error) { + return parseSlice(v, strconv.Atoi) +} + +// Float64 converts Setting Value to float64. Returned error +// will be non nil if convesion failed. +func (v Value) Float64() (float64, error) { + return parseValue(v, parseFloat64) +} + +// Float64Slice splits Setting Value (separator is ",") and adds +// each of resulting values to []float64 if possible. +// Returns non-nil error on first failure to convert. +func (v Value) Float64Slice() ([]float64, error) { + return parseSlice(v, parseFloat64) +} + +// StringSlice splits Setting's Value (separator is ",") and adds +// each of resulting values to []string trimming leading and trailing spaces +// from each string. +func (v Value) StringSlice() []string { + return tidySplit(v, separatorSlice) +} + +// Bool tries to interpret Setting's Value as bool +// "1", "t", "T", true", "True", "TRUE" yields true +// "0", "f", "F, "false", "False", "FALSE" yields false +// If nothing matches will return false and conf.ErrCouldNotConvert. +func (v Value) Bool() (bool, error) { + return parseValue(v, strconv.ParseBool) +} + +func parseSlice[T any, S ~string](s S, f func(string) (T, error)) ([]T, error) { + split := tidySplit(s, separatorSlice) + + list := make([]T, 0, len(split)) + for _, str := range split { + v, err := parseValue(str, f) + if err != nil { + return list, err + } + + list = append(list, v) + } + + return list, nil +} + +func parseValue[T any, S ~string](s S, f func(string) (T, error)) (T, error) { + v, err := f(string(s)) + if err != nil { + return v, errors.Join(ErrCouldNotConvert, err) + } + + return v, err +} + +func tidySplit[S ~string](s S, sep string) []string { + splitted := strings.Split(string(s), sep) + for i, str := range splitted { + splitted[i] = strings.TrimSpace(str) + } + return splitted +} + +func parseFloat64(s string) (float64, error) { + return strconv.ParseFloat(s, 64) +}