From de63cc9e0405933e92a26f066178aee42951aae8 Mon Sep 17 00:00:00 2001 From: Dmitry Date: Sun, 20 Apr 2025 21:05:14 +0300 Subject: [PATCH] v1 --- LICENSE | 2 +- README.md | 210 ++++++------------------- conf_test.go | 106 ------------- config.go | 109 ------------- example/main.go | 123 ++++++++------- map.go | 183 ++++++++++++++++++++++ map_test.go | 162 ++++++++++++++++++++ parser.go | 67 -------- settings.go | 157 ------------------- testdata/test.conf | 5 + util.go | 31 ++++ util_test.go | 209 +++++++++++++++++++++++++ value.go | 127 ++++++++++++++++ value_test.go | 370 +++++++++++++++++++++++++++++++++++++++++++++ 14 files changed, 1191 insertions(+), 670 deletions(-) delete mode 100644 conf_test.go delete mode 100644 config.go create mode 100644 map.go create mode 100644 map_test.go delete mode 100644 parser.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 create mode 100644 value_test.go diff --git a/LICENSE b/LICENSE index 4d84e88..a9ab5ed 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2021 Dmitry Fedotov +Copyright (c) 2025 Dmitry Fedotov Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal 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_test.go b/conf_test.go deleted file mode 100644 index 33364ed..0000000 --- a/conf_test.go +++ /dev/null @@ -1,106 +0,0 @@ -package conf - -import ( - "bytes" - "testing" -) - -var testConf = []byte(`port=10000 -# removed -two = two words -commas = abc, def, ghi -token=test -bool1=1 -bool2=true - -editor = vim -distance=13.42 -floats=0.5,2.37,6 -floatswithstring = 0.5, hello, 0.9 -no missedme -color`) - -func TestPackage(t *testing.T) { - r := bytes.NewReader(testConf) - c := parseReader(r) - 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.Value != "myvalue" { - t.Log("GetDefault fails to apply default value") - t.Fail() - } -} diff --git a/config.go b/config.go deleted file mode 100644 index 97b3c7c..0000000 --- a/config.go +++ /dev/null @@ -1,109 +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. Settings and Options can be -// accessed with Config.Settings and Config.Options maps directly. -type Config 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 - // Options map stores single word options ("option" in config file) - Options map[string]struct{} -} - -// 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 *Config) Find(key string) (s Setting, err error) { - v, ok := c.Settings[key] - if !ok { - err = ErrNotFound - } - s.Value = v - return -} - -// Get returns a Setting. If key was not found the returned Setting Value -// will be empty string. -func (c *Config) Get(key string) (s Setting) { - s.Value = c.Settings[key] - return -} - -// GetDefault looks up a Setting with requested key. -// If lookup fails it returns Setting with Value field set to def. -func (c *Config) GetDefault(key, def string) (s Setting) { - v, ok := c.Settings[key] - switch ok { - case true: - s.Value = v - default: - s.Value = def - } - return -} - -// HasOption returns true if line: -// -// "key" -// -// was found in the parsed file -func (c *Config) HasOption(option string) (exists bool) { - _, exists = c.Options[option] - return -} diff --git a/example/main.go b/example/main.go index 5ee17e1..1381c5c 100644 --- a/example/main.go +++ b/example/main.go @@ -11,81 +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 -color`) +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.ParseReader(r) // we could call conf.ParseFile("filename") here + config, _ := conf.Read(r) // we could call conf.Open("filename") here - // First of all we can access parsed values directly: - for key, value := range config.Settings { - fmt.Println(key, value) + // 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") } - 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) + // 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 - // 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 + // 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] - // 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) + // 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 "с" 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/map_test.go b/map_test.go new file mode 100644 index 0000000..649bb74 --- /dev/null +++ b/map_test.go @@ -0,0 +1,162 @@ +package conf + +import ( + "bytes" + "errors" + "fmt" + "os" + "reflect" + "slices" + "testing" +) + +func TestRead(t *testing.T) { + tc := []struct { + name string + in []byte + want Map + err error + }{ + { + name: "empty", + in: []byte{}, + 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"), + want: Map{ + "key": Value("value"), + }, + err: nil, + }, + { + name: "single key no spaces", + in: []byte("key=value"), + want: Map{ + "key": Value("value"), + }, + err: nil, + }, + { + name: "single key with newline", + in: []byte("key=value\n"), + want: Map{ + "key": Value("value"), + }, + err: nil, + }, + { + name: "single key with comment and newline", + 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"), + want: nil, + err: ErrFormat, + }, + { + name: "duplicate key", + in: []byte("key=value1\nkey=value2\n"), + want: nil, + err: ErrDuplicateKey, + }, + } + + for _, c := range tc { + t.Run(c.name, func(t *testing.T) { + r := bytes.NewReader(c.in) + + have, err := Read(r) + if !errors.Is(err, c.err) { + t.Fatalf("want error: %v have: %v", c.err, err) + } + + if !reflect.DeepEqual(c.want, have) { + t.Fatalf("want: %+v, have: %+v", c.want, have) + } + }) + } +} + +func TestOpen(t *testing.T) { + conf, err := Open("./testdata/test.conf") + if err != nil { + t.Fatal(err) + } + + 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 TestMainMethods(t *testing.T) { + key := "key" + value := "value" + b := []byte(fmt.Sprintf("%s = %s", key, value)) + r := bytes.NewReader(b) + + m := make(Map) + if err := m.Read(r); err != nil { + t.Fatal(err) + } + + if m.Get(key).String() != value { + t.Error("Get fails for existing key") + } + + if v, ok := m.Find("key"); !ok || v.String() != value { + t.Error("Find fails for existing key") + } + + if m.GetDefault(key, "none").String() != value { + t.Error("GetDafault fails on existing value") + } + + if m.GetDefault("unknown", value).String() != value { + t.Error("GetDafault fails to supply default") + } +} + +func TestReadReadFile(t *testing.T) { + m := make(Map) + if err := m.ReadFile("./testdata/test.conf"); err != nil { + t.Fatal(err) + } + + f, err := os.Open("./testdata/test.conf") + if err != nil { + t.Fatal(err) + } + defer f.Close() + + m = make(Map) + if err := m.Read(f); err != nil { + t.Fatal(err) + } +} + +func TestKeys(t *testing.T) { + m := Map{ + "1": Value(""), + "2": Value(""), + "3": Value(""), + } + + if !slices.Equal([]string{"1", "2", "3"}, m.Keys()) { + t.Fatal("Keys method returns incorrect values") + } +} diff --git a/parser.go b/parser.go deleted file mode 100644 index 75c7799..0000000 --- a/parser.go +++ /dev/null @@ -1,67 +0,0 @@ -package conf - -import ( - "bufio" - "io" - "os" - "regexp" - "strings" -) - -var ( - configKeyValueRe = regexp.MustCompile(`(\w+) *= *(.+)\b[\t| |\n]*`) - configOptionRe = regexp.MustCompile(`(\w+)`) - configCommentedOut = "#" -) - -// ParseFile 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 ParseFile(filename string) (*Config, error) { - file, err := os.Open(filename) - if err != nil { - return nil, err - } - defer file.Close() - return parseReader(file), nil -} - -// ParseReader reads from r and returns Config. See also ParseFile. -func ParseReader(r io.Reader) *Config { - return parseReader(r) -} - -// ParseReadCloser reads from r, returns Config and calls r.Close(). -// See also ParseFile. -func ParseReadCloser(r io.ReadCloser) *Config { - defer r.Close() - return parseReader(r) -} - -func parseReader(r io.Reader) *Config { - var c Config - c.Settings = make(map[string]string) - c.Options = make(map[string]struct{}) - scanner := bufio.NewScanner(r) - for scanner.Scan() { - line := scanner.Text() - if strings.HasPrefix(line, configCommentedOut) { - continue - } - switch { - case configKeyValueRe.MatchString(line): - kvpair := configKeyValueRe.FindStringSubmatch(line) - c.Settings[kvpair[1]] = kvpair[2] - case configOptionRe.MatchString(line): - opt := configOptionRe.FindString(line) - c.Options[opt] = struct{}{} - } - } - return &c -} diff --git a/settings.go b/settings.go deleted file mode 100644 index f734e6d..0000000 --- a/settings.go +++ /dev/null @@ -1,157 +0,0 @@ -package conf - -import ( - "errors" - "strconv" - "strings" -) - -var ( - ErrNotFound = errors.New("key not found") - ErrParsingBool = errors.New("value can not be interpreted as bool") - ErrCouldNotConvert = errors.New("could not cast one or more values to required type") -) - -var valuesSeparator = "," - -var boolMap = map[string]bool{ - // what evaluates to true - "true": true, - "t": true, - "1": true, - "yes": true, - "on": true, - // what evaluates to false - "false": false, - "f": false, - "0": false, - "no": false, - "off": false, -} - -// 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 { - Value string -} - -// Int converts Setting Value to int. Returned error -// will be non nil if convesion failed. -func (st Setting) Int() (int, error) { - return parseInt(st.Value) -} - -// 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 (st Setting) IntSlice() ([]int, error) { - return parseIntSlice(st.Value, valuesSeparator) -} - -/* func (st Setting) split(sep string) Setting { - st.sep = sep //Choose separator to split values ? - return st -} */ - -// Float64 converts Setting Value to float64. Returned error -// will be non nil if convesion failed. -func (st Setting) Float64() (float64, error) { - return parseFloat64(st.Value) -} - -// 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 (st Setting) Float64Slice() ([]float64, error) { - return parseFloat64Slice(st.Value, valuesSeparator) -} - -// String returns option Value as string -// This method also implements Stringer interface from fmt module -func (st Setting) String() string { - return st.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 (st Setting) StringSlice() []string { - return tidySplit(st.Value, valuesSeparator) -} - -// Bool tries to interpret Setting's Value as bool -// "1", "true", "on", "yes" (case insensitive) yields true -// "0", "false", "off", "no" (case insensitive) yields false -// If nothing matches will return false and conf.ErrParsingBool -func (st Setting) Bool() (bool, error) { - return parseBool(st.Value) -} - -func parseInt(s string) (n int, err error) { - n, err = strconv.Atoi(s) - return -} - -func parseIntSlice(s, sep string) ([]int, error) { - var ( - n int - err error - slice []int - digits []string - ) - digits = tidySplit(s, sep) - for _, d := range digits { - n, err = strconv.Atoi(d) - if err != nil { - err = ErrCouldNotConvert - break - } - slice = append(slice, n) - } - return slice, err -} - -func parseFloat64(s string) (n float64, err error) { - n, err = strconv.ParseFloat(s, 64) - return -} - -func parseFloat64Slice(s, sep string) ([]float64, error) { - var ( - n float64 - err error - slice []float64 - digits []string - ) - digits = tidySplit(s, sep) - for _, d := range digits { - n, err = strconv.ParseFloat(d, 64) - if err != nil { - err = ErrCouldNotConvert - break - } - slice = append(slice, n) - } - return slice, err -} - -func parseBool(s string) (value bool, err error) { - s = strings.ToLower(s) - value, ok := boolMap[s] - if !ok { - err = ErrParsingBool - } - return -} - -func tidySplit(s, sep string) []string { - splitted := strings.Split(s, sep) - for i, str := range splitted { - splitted[i] = strings.Trim(str, " ") - } - return splitted -} diff --git a/testdata/test.conf b/testdata/test.conf new file mode 100644 index 0000000..3266892 --- /dev/null +++ b/testdata/test.conf @@ -0,0 +1,5 @@ +# comment +key1 = value1 +key2=value2 +key3 = value3 +key4 = value4 \ No newline at end of file diff --git a/util.go b/util.go new file mode 100644 index 0000000..5658760 --- /dev/null +++ b/util.go @@ -0,0 +1,31 @@ +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 { + if strings.HasPrefix(s, separatorComment) { + return "" + } + + return s +} diff --git a/util_test.go b/util_test.go new file mode 100644 index 0000000..e24d8d7 --- /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", + want: "text#some", + }, + { + name: "comment", + in: "text #some", + want: "text #some", + }, + { + name: "comment", + in: " text #some ", + want: " text #some ", + }, + } + + 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..5265788 --- /dev/null +++ b/value.go @@ -0,0 +1,127 @@ +package conf + +import ( + "errors" + "net/url" + "strconv" + "strings" +) + +var ( + ErrCouldNotConvert = errors.New("conf: could not cast one or more values to required type") +) + +const ( + separatorSlice = "," + separatorMap = ":" +) + +type Value string + +// 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, len(split)) + + for i := range split { + k, v, ok := toKV(split[i], separatorMap) + if !ok { + return nil, ErrCouldNotConvert + } + + 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) // 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 nil and non-nil error on first failure to convert. +func (v Value) IntSlice() ([]int, error) { + return parseSlice(v, strconv.Atoi) +} + +// 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 Value (separator is ",") and adds +// each of resulting values to []float64 if possible. +// Returns nil and non-nil error on first failure to convert. +func (v Value) Float64Slice() ([]float64, error) { + return parseSlice(v, parseFloat64) +} + +// 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 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) +} + +// 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) + + list := make([]T, 0, len(split)) + for _, str := range split { + v, err := parseValue(str, f) + if err != nil { + return nil, 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] = trim(str) + } + return splitted +} + +func parseFloat64(s string) (float64, error) { + return strconv.ParseFloat(s, 64) +} diff --git a/value_test.go b/value_test.go new file mode 100644 index 0000000..1a56279 --- /dev/null +++ b/value_test.go @@ -0,0 +1,370 @@ +package conf + +import ( + "net/url" + "reflect" + "slices" + "testing" +) + +func TestValueMethods(t *testing.T) { + tc := []struct { + name string + f func(t *testing.T) + }{ + { + name: "string", + f: func(t *testing.T) { + s := Value("test").String() + if s != "test" { + t.Fatalf("want: %s, have: %s", "test", s) + } + }, + }, + { + name: "int", + f: func(t *testing.T) { + x, err := Value("10").Int() + if err != nil { + t.Fatal(err) + } + + if x != 10 { + t.Fatalf("want: %v, have: %v", 10, x) + } + }, + }, + { + 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) { + x, err := Value("-10").Int() + if err != nil { + t.Fatal(err) + } + + if x != -10 { + t.Fatalf("want: %v, have: %v", -10, x) + } + }, + }, + { + name: "bad int", + f: func(t *testing.T) { + x, err := Value("1a").Int() + if err == nil { + t.Fatal("int does not fail on incorrect input") + } + + if x != 0 { + t.Fatalf("want: %v, have: %v", 0, x) + } + }, + }, + { + name: "bad int", + f: func(t *testing.T) { + x, err := Value("NaN").Int() + if err == nil { + t.Fatal("int does not fail on incorrect input") + } + + if x != 0 { + t.Fatalf("want: %v, have: %v", 0, x) + } + }, + }, + { + name: "float64", + f: func(t *testing.T) { + x, err := Value("0.05").Float64() + if err != nil { + t.Fatal(err) + } + + if x != 0.05 { + t.Fatalf("want: %v, have: %v", 0.05, x) + } + }, + }, + { + name: "negative float64", + f: func(t *testing.T) { + x, err := Value("-0.05").Float64() + if err != nil { + t.Fatal(err) + } + + if x != -0.05 { + t.Fatalf("want: %v, have: %v", -0.05, x) + } + }, + }, + { + name: "bad float64", + f: func(t *testing.T) { + x, err := Value("0,05").Float64() + if err == nil { + t.Fatal("float does not fail on incorrect input") + } + + if x != 0 { + t.Fatalf("want: %v, have: %v", 0, x) + } + }, + }, + { + name: "bad float64", + f: func(t *testing.T) { + x, err := Value("0,05").Float64() + if err == nil { + t.Fatal("float does not fail on incorrect input") + } + + if x != 0 { + t.Fatalf("want: %v, have: %v", 0, x) + } + }, + }, + { + name: "bool", + f: func(t *testing.T) { + x, err := Value("t").Bool() + if err != nil { + t.Fatal(err) + } + + if x != true { + t.Fatalf("want: %v, have: %v", true, x) + } + }, + }, + { + name: "bool", + f: func(t *testing.T) { + x, err := Value("true").Bool() + if err != nil { + t.Fatal(err) + } + + if x != true { + t.Fatalf("want: %v, have: %v", true, x) + } + }, + }, + { + name: "bool", + f: func(t *testing.T) { + x, err := Value("T").Bool() + if err != nil { + t.Fatal(err) + } + + if x != true { + t.Fatalf("want: %v, have: %v", true, x) + } + }, + }, + { + name: "bool", + f: func(t *testing.T) { + x, err := Value("True").Bool() + if err != nil { + t.Fatal(err) + } + + if x != true { + t.Fatalf("want: %v, have: %v", true, x) + } + }, + }, + { + name: "bool", + f: func(t *testing.T) { + x, err := Value("TRUE").Bool() + if err != nil { + t.Fatal(err) + } + + if x != true { + t.Fatalf("want: %v, have: %v", true, x) + } + }, + }, + { + name: "bad bool", + f: func(t *testing.T) { + x, err := Value("unknown").Bool() + if err == nil { + t.Fatal("bool does not fail on incorrect values") + } + + if x != false { + t.Fatalf("want: %v, have: %v", false, x) + } + }, + }, + { + name: "string slice", + f: func(t *testing.T) { + s := Value("a, b, c").StringSlice() + want := []string{"a", "b", "c"} + if !slices.Equal(s, want) { + t.Fatalf("want: %v, have: %v", want, s) + } + }, + }, + { + name: "int slice", + f: func(t *testing.T) { + x, err := Value("1, 2, 3").IntSlice() + if err != nil { + t.Fatal(err) + } + + want := []int{1, 2, 3} + if !slices.Equal(x, want) { + t.Fatalf("want: %v, have: %v", want, x) + } + }, + }, + { + name: "int slice", + f: func(t *testing.T) { + x, err := Value("1,2, 3").IntSlice() + if err != nil { + t.Fatal(err) + } + + want := []int{1, 2, 3} + if !slices.Equal(x, want) { + t.Fatalf("want: %v, have: %v", want, x) + } + }, + }, + { + name: "bad int slice", + f: func(t *testing.T) { + x, err := Value("1, a, 3").IntSlice() + if err == nil { + t.Fatal("int slice does not fail on incorrect value") + } + + var want []int + if !slices.Equal(x, want) { + t.Fatalf("want: %v, have: %v", want, x) + } + }, + }, + { + name: "float64 slice", + f: func(t *testing.T) { + x, err := Value("0.1, 0.2, 0.3").Float64Slice() + if err != nil { + t.Fatal(err) + } + + want := []float64{0.1, 0.2, 0.3} + if !slices.Equal(x, want) { + t.Fatalf("want: %v, have: %v", want, x) + } + }, + }, + { + name: "bad float64 slice", + f: func(t *testing.T) { + x, err := Value("0.1, a, 0.3").Float64Slice() + if err == nil { + t.Fatal("float slice does not fail on incorrect value") + } + + var want []float64 + if !slices.Equal(x, want) { + t.Fatalf("want: %v, have: %v", want, x) + } + }, + }, + { + 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 { + t.Run(c.name, func(t *testing.T) { + c.f(t) + }) + } +}