From 85452e3dea6cbbb14bfeadc55247b2a1b0ed0ca9 Mon Sep 17 00:00:00 2001 From: Dmitry Fedotov Date: Sun, 13 Apr 2025 22:46:22 +0300 Subject: [PATCH] tidy up, add docs --- README.md | 210 ++++++++---------------------------- conf.go | 210 ------------------------------------ example/main.go | 114 +++++++++++--------- map.go | 183 +++++++++++++++++++++++++++++++ conf_test.go => map_test.go | 97 ++++++----------- testdata/test.conf | 2 +- util.go | 5 +- util_test.go | 12 +-- value.go | 32 +++--- value_test.go | 85 ++++++++++++++- 10 files changed, 433 insertions(+), 517 deletions(-) delete mode 100644 conf.go create mode 100644 map.go rename conf_test.go => map_test.go (58%) diff --git a/README.md b/README.md index 9844781..a049355 100644 --- a/README.md +++ b/README.md @@ -1,180 +1,56 @@ -# module conf +# package conf **go get code.uint32.ru/tiny/conf** to download. **import "code.uint32.ru/tiny/conf"** to use in your code. +conf implements a very simple config parser with permissive input syntax. -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 -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. +It parses input file or io.Reader looking for key value pairs separated by equal +sign into conf.Map which is a map[string]conf.Value under the hood. -Typical use case would look like this: +Input syntax rules are as follows: + +1. Lines starting with "#" are considered to be comments and are discarded. If a line starts with any number of whitespace characters followed by "#" it is also discarded as comment. + +2. If a line is not a comment it must follow the "key = value" pattern. Any of the following is valid: "key=value", "key= value", "key =value". Leading and trailing whitespace characters are trimmed before parsing line. Whitespaces surrounding +key and value strings are also trimmed. + +3. Keys in the input must be unique, ohterwise Read / ReadFile methods will return an error. + +Map object has Find, Get and GetDefault methods used to retrieve Value from Map by corresponding key. + +Value is essentially a string which can be converted into several types using Value's methods: + +**String()** returns Value as string + +**StringSlice()** interprets Value as a comma-separated list (whitespace around elements is trimmed) + +**Int()** converts Value to int + +**IntSlice()** tries to convert Value to []int + +**Float64()** converts Value to float64 + +**Float64Slice()** tries to convert to []float64 + +**Map()** tries to interpret Value as comma-separated list of key-value pairs like in "key1: value1, key2: value2, key3: value3" + +**URL()** calls url.Parse to convert Value to *url.URL + +See documentation for description of all available methods. + +Example usage: ```go -config, err := conf.ParseFile("filename") +m, _ := conf.ReadFile("myfile") +retries, err := m.Get("retries").Int() if err != nil { - // Means we failed to read from file - // config variable is now nil and unusable + // handle err } -value, err := config.Find("mykey") + +addr, err := m.Get("addr").URL() if err != nil { - // Means that key has not been found -} -// value now holds conf.Setting type which can be accessed directly. -fmt.Println(value.Value) -n, err := value.Float64() // tries to parse float from Setting.Value field. -// if err is nil then n holds float64. -``` -Shorter syntax is also available with Get() method which drops errors if -key was not found and simply returns Setting with empty Value field: -```go -listnumbers, err := config.Get("numbers").IntSlice() -// listnumbers holds slice of ints. If err is nil all of found values -// have converted successfully. -``` -Note that we'd still like to check for errors in above example even if -we're sure the key exists. This way we'll configrm that all values have been converted. - -There is also a GetDefault() method: -```go -def := config.GetDefault("non-existant-key", "myvalue") -fmt.Println(def.Value) // "myvalue" -``` -To check whether single-word options were found use: -```go -if config.HasOption("wordoption") { - // do something + // handle err } ``` -See description of module's types and methods which are -quite self-explanatory. -See also **[https://pkg.go.dev/code.uint32.ru/tiny/conf](https://pkg.go.dev/code.uint32.ru/tiny/conf)** for a complete description of module's -functions. - -Below is listing of a working example of a program parsing config (also found **example/main.go** in the repository). -```go -package main - -import ( - "bytes" - "fmt" - - "code.uint32.ru/tiny/conf" -) - -var testConf = []byte(` -# commented -port=10000 -servers = 10.0.0.1, 10.0.0.2, 10.0.0.3 -bool0=0 -booltrue = true -distance=13.42 -iamsure = hopefully you're not mistaken -float = 13.1984 -color`) - -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() - - // Find( key string) returns instance of Setting and an - // error if key was not found - // - // type Setting struct { - // Value string // value if found - // } - // - // 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) - - // We can cast Setting.Value to a desired type including int, float64, - // bool and string. Method will return an error if Setting Value field - // can not be interpreted as desired type. - n, err := port.Int() - fmt.Printf("n has value: %v, type: %T, err: %v\n", n, n, err) - - // Another syntax for getting Setting instance is to use Get() method - // which never returns errors. Get will return Setting with empty string - // in Value filed if requested key was now found. - d := config.Get("distance") - distance, err := d.Float64() - fmt.Printf("var distance has value: %v, type: %T, error value: %v\n", distance, distance, err) - - // Get() syntax can be is slightly shorter if we're "sure" that key exists in - // the config. - sure := config.Get("iamsure").String() // String method never returns errors - // or simply sure := config.Get("iamsure").Value: - fmt.Println(sure) - - // or like this: - fl, _ := config.Get("float").Float64() // we're dropping error here. Bad if value fails to convert. - fmt.Println("fl, _ := config.Get(\"float\").Float64() Value of var fl is:", fl) - - // We can access comma-separated values of key like this: - servers := config.Get("servers").StringSlice() - fmt.Println("Found servers:") - for i, s := range servers { - fmt.Println(i+1, "\t", s) - } - // There is also a GetDefault() method which sets output Setting value - // to requested default if key was not found. - 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 - t, _ = config.Get("booltrue").Bool() - f, _ = config.Get("bool0").Bool() - fmt.Printf("t's type is: %T, value: %v, f's type is: %T, value: %v\n\n", t, t, f, f) -} -``` -Here is the full output of the above code: -``` -distance 13.42 -iamsure hopefully you're not mistaken -float 13.1984 -port 10000 -servers 10.0.0.1, 10.0.0.2, 10.0.0.3 -bool0 0 -booltrue true -color - -variable port has type: conf.Setting, port.Value == 10000, type of port.Value is: string, error returned: -n has value: 10000, type: int, err: -var distance has value: 13.42, type: float64, error value: -hopefully you're not mistaken -fl, _ := config.Get("float").Float64() Value of var fl is: 13.1984 -Found servers: -1 10.0.0.1 -2 10.0.0.2 -3 10.0.0.3 -Hooray, we've found option "color"! -t's type is: bool, value: true, f's type is: bool, value: false - -``` +See example folder. \ No newline at end of file diff --git a/conf.go b/conf.go deleted file mode 100644 index 3b8fbae..0000000 --- a/conf.go +++ /dev/null @@ -1,210 +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 - -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/example/main.go b/example/main.go index 312d3ba..1abf3c3 100644 --- a/example/main.go +++ b/example/main.go @@ -11,64 +11,78 @@ var testConf = []byte(` # commented port=10000 servers = 10.0.0.1, 10.0.0.2, 10.0.0.3 -bool0=0 -booltrue = true -distance=13.42 -iamsure = hopefully you're not mistaken -float = 13.1984`) +some_ints = 1, 2, 3, 4 +map = a: 1, b: 2, c:3 +`) func main() { r := bytes.NewReader(testConf) // creating io.Reader from []byte() - config, _ := conf.Read(r) // we could call conf.ParseFile("filename") here + config, _ := conf.Read(r) // we could call conf.Open("filename") here - // Find( key string) returns instance of Setting and an - // error if key was not found - // - // type Setting struct { - // Value string // value if found - // } - // - // You can access Setting's Value field (type string) directly. - 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) + // main methods if conf.Map are Find, Get, and GetDefault + // Find returns conf.Value and a bool indicating whether value has been found + _, ok := config.Find("port") + if !ok { + // key "port" was not found + panic("not found") + } + + // GetDefault will return Value set to provided default + // if value is not in the map. In this example the returned + // value wiill be Value("vyvalue"). + def := config.GetDefault("non-existant-key", "myvalue") + + fmt.Println(def.String()) // "myvalue" + + // Get will return empty conf.Value if it was not found. + // get values from line "servers = 10.0.0.1, 10.0.0.2, 10.0.0.3". + servers := config.Get("servers") + + // the returned Value has many methods to convert the underlying string + for i, s := range servers.StringSlice() { + fmt.Println(i+1, "\t", s) // 10.0.0.1 10.0.0.2 10.0.0.3 + } // We can cast Setting.Value to a desired type including int, float64, // bool and string. Method will return an error if Setting Value field // can not be interpreted as desired type. - n, err := port.Int() - fmt.Printf("n has value: %v, type: %T, err: %v\n", n, n, err) - - // Another syntax for getting Setting instance is to use Get() method - // which never returns errors. Get will return Setting with empty string - // in Value filed if requested key was now found. - d := config.Get("distance") - distance, err := d.Float64() - fmt.Printf("var distance has value: %v, type: %T, error value: %v\n", distance, distance, err) - - // Get() syntax can be is slightly shorter if we're "sure" that key exists in - // the config. - sure := config.Get("iamsure").String() // String method never returns errors - // or simply sure := config.Get("iamsure").Value: - fmt.Println(sure) - - // or like this: - fl, _ := config.Get("float").Float64() // we're dropping error here. Bad if value fails to convert. - fmt.Println("fl, _ := config.Get(\"float\").Float64() Value of var fl is:", fl) - - // We can access comma-separated values of key like this: - servers := config.Get("servers").StringSlice() - fmt.Println("Found servers:") - for i, s := range servers { - fmt.Println(i+1, "\t", s) + n, err := config.Get("port").Int() + if err != nil { + // n is zero value for int and we failed to convert + // value from our file to int. + panic(err) } - // There is also a GetDefault() method - def := config.GetDefault("non-existant-key", "myvalue") - fmt.Println(def.Value) // "myvalue" + fmt.Println(n) // 10000 - // Below code finds two keys with bool values in the Config - // and outputs those. - var t, f bool - t, _ = config.Get("booltrue").Bool() - f, _ = config.Get("bool0").Bool() - fmt.Printf("t's type is: %T, value: %v, f's type is: %T, value: %v\n\n", t, t, f, f) + // The value methods are as follows: + v := config.Get("some_ints") // the config line is "some_ints = 1, 2, 3, 4" + + // String returns string as is (only trimmed from whitespace) + s := v.String() + fmt.Printf("%T, %v\n", s, s) // string, 1, 2, 3, 4 + + // StringSlice tries to interpret value as comma-separated list, trims each element from whitespace + ss := v.StringSlice() + fmt.Printf("%T, %v\n", ss, ss) // []string, [1 2 3 4] + + // IntSlice parses value in the same way as StringSlice then tries to convert each element + // to int. Failure to convert any element is an error. + is, err := v.IntSlice() + if err != nil { + panic(err) + } + fmt.Printf("%T, %v\n", is, is) // []int, [1 2 3 4] + + // there are similar methods for bool and float64 types. + + // Map method of Value tries to interpret value as mapping where + // keys are sepearated from values with ":". + // This example parses line for the example config + // "map = a: 1, b: 2, c: 3" + m, _ := config.Get("map").Map() // return conf.Map and error if any + fmt.Printf("%T, %v\n", m, m) // conf.Map, map[a:1 b:2 c:3] + + // key "a" can be interpreted as int + x, _ := m.Get("c").Int() + fmt.Println(x) // prints 3 } diff --git a/map.go b/map.go new file mode 100644 index 0000000..3eef800 --- /dev/null +++ b/map.go @@ -0,0 +1,183 @@ +// conf implements a very simple config parser with permissive input syntax. +// +// It parses input file or io.Reader looking for key value pairs separated by equal +// sign into conf.Map which is a map[string]conf.Value under the hood. +// +// Input syntax rules are as follows: +// +// 1. Lines starting with "#" are considered to be comments and are discarded. If a line starts with any number of whitespace characters followed by "#" it is also discarded as comment. +// +// 2. If a line is not a comment it must follow the "key = value" pattern. Any of the following is valid: +// +// key=value +// key= value +// key =value +// +// Leading and trailing whitespace characters are trimmed before parsing line. Whitespaces surrounding +// key and value strings are also trimmed. +// +// 3. Keys in the input must be unique. +// +// Map object has Find, Get and GetDefault mathod used to retrieve Value from +// Map by corresponding key. +// +// Value is essentially a string which can be converted into several types using +// Value's methods: +// +// String() returns Value as string +// StringSlice() interprets Value as a comma-separated list (whitespace around elements is trimmed) +// Int() converts Value to int +// IntSlice() tries to convert Value to []int +// Float64() converts Value to float64 +// Float64Slice() tries to convert to []float64 +// Map() tries to interpret Value as comma-separated list of key-value pairs like in "key1: value1, key2: value2, key3: value3" +// URL() calls url.Parse to convert Value to *url.URL +// +// See documentation for description of all available methods. +// +// Example usage: +// +// m, _ := conf.ReadFile("myfile") +// retries, err := m.Get("retries").Int() +// if err != nil { +// // handle err +// } +// addr, err := m.Get("addr").URL() +// if err != nil { +// // handle err +// } +// +// See example folder. +package conf + +import ( + "bufio" + "errors" + "fmt" + "io" + "os" + "slices" +) + +var ( + ErrFormat = errors.New("conf: line does not match \"key = value\" pattern") + ErrDuplicateKey = errors.New("conf: duplicate key found") +) + +// Map holds key value pairs. +type Map map[string]Value + +// Open reads values from file. +func Open(filename string) (Map, error) { + m := make(Map) + if err := m.readFile(filename); err != nil { + return nil, err + } + + return m, nil +} + +// Read reads from r and returns Conf. +func Read(r io.Reader) (Map, error) { + m := make(Map) + if err := m.read(r); err != nil { + return nil, err + } + + return m, nil +} + +func (m Map) Read(r io.Reader) error { + return m.read(r) +} + +func (m Map) ReadFile(name string) error { + return m.readFile(name) +} + +// Find looks up a the key and returns Value associated with it. +// If bool is false then the returned Value would be zero. +func (m Map) Find(key string) (Value, bool) { + return m.get(key, "") +} + +// Get returns a Value. If key was not found the returned Value +// will be empty. +func (m Map) Get(key string) Value { + v, _ := m.get(key, "") + return v +} + +// GetDefault looks up a Value for requested key. +// If lookup fails it returns Value set to def. +func (m Map) GetDefault(key, def string) Value { + v, _ := m.get(key, def) + return v +} + +func (m Map) Keys() []string { + list := make([]string, 0, len(m)) + + for k := range m { + list = append(list, k) + } + + slices.Sort(list) + + return list +} + +func (m Map) get(key string, def string) (Value, bool) { + v, ok := m[key] + if def != "" && !ok { + v = Value(def) + } + + return v, ok +} + +func (m Map) readFile(name string) error { + file, err := os.Open(name) + if err != nil { + return err + } + + if err := m.read(file); err != nil { + return err + } + + return file.Close() +} + +func (m Map) read(r io.Reader) error { + scanner := bufio.NewScanner(r) + + lineN := 0 + for scanner.Scan() { + lineN++ + + line := scanner.Text() + + line = stripComment(trim(line)) + if line == "" { + continue + } + + key, value, ok := toKV(line, separatorKV) + if !ok { + return fmt.Errorf("line: %d, err: %w", lineN, ErrFormat) + } + + if _, ok := m[key]; ok { + return fmt.Errorf("line: %d, err: %w %s", lineN, ErrDuplicateKey, key) + } + + m[key] = Value(value) + } + + if err := scanner.Err(); err != nil { + return err + } + + return nil +} diff --git a/conf_test.go b/map_test.go similarity index 58% rename from conf_test.go rename to map_test.go index 75bb4e7..649bb74 100644 --- a/conf_test.go +++ b/map_test.go @@ -14,79 +14,57 @@ func TestRead(t *testing.T) { tc := []struct { name string in []byte - res *Conf + want Map 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 + want: Map{}, + 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"), - }, - }, + want: Map{ + "key": Value("value"), }, err: nil, }, { name: "single key no spaces", in: []byte("key=value"), - res: &Conf{ - values: []*kv{ - { - k: "key", - v: Value("value"), - }, - }, + want: Map{ + "key": Value("value"), }, err: nil, }, { name: "single key with newline", in: []byte("key=value\n"), - res: &Conf{ - values: []*kv{ - { - k: "key", - v: Value("value"), - }, - }, + want: Map{ + "key": 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"), - }, - }, + in: []byte("key =value # something else \n"), + want: Map{ + "key": Value("value # something else"), }, err: nil, }, { name: "incorrect k/v separator", in: []byte("key ! value \n"), - res: nil, + want: nil, err: ErrFormat, }, { name: "duplicate key", in: []byte("key=value1\nkey=value2\n"), - res: nil, + want: nil, err: ErrDuplicateKey, }, } @@ -95,13 +73,13 @@ func TestRead(t *testing.T) { t.Run(c.name, func(t *testing.T) { r := bytes.NewReader(c.in) - conf, err := Read(r) + have, 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) + if !reflect.DeepEqual(c.want, have) { + t.Fatalf("want: %+v, have: %+v", c.want, have) } }) } @@ -131,31 +109,31 @@ func TestMainMethods(t *testing.T) { b := []byte(fmt.Sprintf("%s = %s", key, value)) r := bytes.NewReader(b) - c := new(Conf) - if err := c.Read(r); err != nil { + m := make(Map) + if err := m.Read(r); err != nil { t.Fatal(err) } - if c.Get(key).String() != value { + if m.Get(key).String() != value { t.Error("Get fails for existing key") } - if v, ok := c.Find("key"); !ok || v.String() != value { + if v, ok := m.Find("key"); !ok || v.String() != value { t.Error("Find fails for existing key") } - if c.GetDefault(key, "none").String() != value { + if m.GetDefault(key, "none").String() != value { t.Error("GetDafault fails on existing value") } - if c.GetDefault("unknown", value).String() != value { + if m.GetDefault("unknown", value).String() != value { t.Error("GetDafault fails to supply default") } } func TestReadReadFile(t *testing.T) { - conf := new(Conf) - if err := conf.ReadFile("./testdata/test.conf"); err != nil { + m := make(Map) + if err := m.ReadFile("./testdata/test.conf"); err != nil { t.Fatal(err) } @@ -165,31 +143,20 @@ func TestReadReadFile(t *testing.T) { } defer f.Close() - conf = new(Conf) - if err := conf.Read(f); err != nil { + m = make(Map) + if err := m.Read(f); err != nil { t.Fatal(err) } } func TestKeys(t *testing.T) { - c := Conf{ - values: []*kv{ - { - k: "1", - v: Value(""), - }, - { - k: "2", - v: Value(""), - }, - { - k: "3", - v: Value(""), - }, - }, + m := Map{ + "1": Value(""), + "2": Value(""), + "3": Value(""), } - if !slices.Equal([]string{"1", "2", "3"}, c.Keys()) { + if !slices.Equal([]string{"1", "2", "3"}, m.Keys()) { t.Fatal("Keys method returns incorrect values") } } diff --git a/testdata/test.conf b/testdata/test.conf index c32cb72..3266892 100644 --- a/testdata/test.conf +++ b/testdata/test.conf @@ -2,4 +2,4 @@ key1 = value1 key2=value2 key3 = value3 -key4 = value4 # comment \ No newline at end of file +key4 = value4 \ No newline at end of file diff --git a/util.go b/util.go index 4ca686c..5658760 100644 --- a/util.go +++ b/util.go @@ -23,9 +23,8 @@ func trim(s string) string { } func stripComment(s string) string { - idx := strings.Index(s, separatorComment) - if idx > -1 { - s = s[:idx] + if strings.HasPrefix(s, separatorComment) { + return "" } return s diff --git a/util_test.go b/util_test.go index 8155e62..e24d8d7 100644 --- a/util_test.go +++ b/util_test.go @@ -78,18 +78,18 @@ func Test_stripComment(t *testing.T) { }, { name: "comment", - in: "text#some comment", - want: "text", + in: "text#some", + want: "text#some", }, { name: "comment", - in: "text #some comment", - want: "text ", + in: "text #some", + want: "text #some", }, { name: "comment", - in: " text #some comment", - want: " text ", + in: " text #some ", + want: " text #some ", }, } diff --git a/value.go b/value.go index 2606b40..5265788 100644 --- a/value.go +++ b/value.go @@ -2,7 +2,7 @@ package conf import ( "errors" - "fmt" + "net/url" "strconv" "strings" ) @@ -18,20 +18,23 @@ const ( type Value string -func (v Value) Map() (map[string]Value, error) { +// Map tries to interpret value as "key: value" pairs +// separated by comma like in the following string: +// "key1: value1, key2: value2, key3: value3". +func (v Value) Map() (Map, error) { split := tidySplit(v, separatorSlice) - m := make(map[string]Value, len(split)) + m := make(Map, 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") + return nil, ErrCouldNotConvert } m[k] = Value(v) } + return m, nil } @@ -43,37 +46,37 @@ func (v Value) String() string { // 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) + return parseValue(v, strconv.Atoi) // Atoi in fact checks int size, no need to use ParseInt } // IntSlice splits Value (separator is ",") and adds // each of resulting values to []int if possible. -// Returns non-nil error on first failure to convert. +// Returns nil and 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 +// Float64 converts 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 +// Float64Slice splits Value (separator is ",") and adds // each of resulting values to []float64 if possible. -// Returns non-nil error on first failure to convert. +// Returns nil and 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 +// StringSlice splits 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 +// Bool tries to interpret 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. @@ -81,6 +84,11 @@ func (v Value) Bool() (bool, error) { return parseValue(v, strconv.ParseBool) } +// URL tries to interpret value as url.URL +func (v Value) URL() (*url.URL, error) { + return parseValue(v, url.Parse) +} + func parseSlice[T any, S ~string](s S, f func(string) (T, error)) ([]T, error) { split := tidySplit(s, separatorSlice) diff --git a/value_test.go b/value_test.go index 5c9aeeb..1a56279 100644 --- a/value_test.go +++ b/value_test.go @@ -1,6 +1,8 @@ package conf import ( + "net/url" + "reflect" "slices" "testing" ) @@ -32,6 +34,19 @@ func TestValueMethods(t *testing.T) { } }, }, + { + name: "int", + f: func(t *testing.T) { + x, err := Value("010").Int() + if err != nil { + t.Fatal(err) + } + + if x != 10 { + t.Fatalf("want: %v, have: %v", 10, x) + } + }, + }, { name: "negative int", f: func(t *testing.T) { @@ -189,11 +204,11 @@ func TestValueMethods(t *testing.T) { }, }, { - name: "bool", + name: "bad bool", f: func(t *testing.T) { x, err := Value("unknown").Bool() if err == nil { - t.Fatal("bool doe not fail on incorrect values") + t.Fatal("bool does not fail on incorrect values") } if x != false { @@ -272,7 +287,7 @@ func TestValueMethods(t *testing.T) { f: func(t *testing.T) { x, err := Value("0.1, a, 0.3").Float64Slice() if err == nil { - t.Fatal("float slice doe not fail on incorrect value") + t.Fatal("float slice does not fail on incorrect value") } var want []float64 @@ -281,6 +296,70 @@ func TestValueMethods(t *testing.T) { } }, }, + { + name: "map", + f: func(t *testing.T) { + x, err := Value("a: 1, b : 2, c :3").Map() + if err != nil { + t.Fatal("map fails on correct values") + } + + want := Map{ + "a": Value("1"), + "b": Value("2"), + "c": Value("3"), + } + if !reflect.DeepEqual(x, want) { + t.Fatalf("want: %v, have: %v", want, x) + } + }, + }, + { + name: "map invalid", + f: func(t *testing.T) { + x, err := Value("a:1, b-2, c: 3").Map() + if err == nil { + t.Fatal("map does not fail on incorrect value") + } + + var want Map + + if !reflect.DeepEqual(x, want) { + t.Fatalf("want: %v, have: %v", want, x) + } + }, + }, + { + name: "URL", + f: func(t *testing.T) { + x, err := Value("https://example.com:80").URL() + if err != nil { + t.Fatal(err) + } + + want := &url.URL{ + Scheme: "http", + Host: "example.com:80", + } + + if reflect.DeepEqual(want, x) { + t.Fatalf("want: %v, have: %v", want, x) + } + }, + }, + { + name: "bad URL", + f: func(t *testing.T) { + x, err := Value(":malformed://url/").URL() + if err == nil { + t.Fatal("url does not fail on incorrect input") + } + + if x != nil { + t.Fatalf("want: %v, have: %v", nil, x) + } + }, + }, } for _, c := range tc {