From b88b2fd5e6e0a7c59979579e07fefca1f81373e1 Mon Sep 17 00:00:00 2001 From: Dmitry Fedotov Date: Wed, 9 Apr 2025 02:13:47 +0300 Subject: [PATCH] WIP on v1 --- conf_test.go | 9 ++- config.go | 69 +++++++++++---------- parser.go | 103 ++++++++++++++++++++++---------- parser_test.go | 159 +++++++++++++++++++++++++++++++++++++++++++++++++ settings.go | 136 ++++++++++++++---------------------------- 5 files changed, 317 insertions(+), 159 deletions(-) create mode 100644 parser_test.go diff --git a/conf_test.go b/conf_test.go index 33364ed..7dfa68d 100644 --- a/conf_test.go +++ b/conf_test.go @@ -17,12 +17,15 @@ editor = vim distance=13.42 floats=0.5,2.37,6 floatswithstring = 0.5, hello, 0.9 -no missedme +false color`) func TestPackage(t *testing.T) { r := bytes.NewReader(testConf) - c := parseReader(r) + c, err := parseReader(r) + if err != nil { + t.Fatal(err) + } if _, err := c.Get("floatswithstring").Float64Slice(); err == nil { t.Log("Float64Slice accepting incorrect values") t.Fail() @@ -99,7 +102,7 @@ func TestPackage(t *testing.T) { t.Log("empty string erroneously converts to float") t.Fail() } - if def := c.GetDefault("non-existant-key", "myvalue"); def.Value != "myvalue" { + if def := c.GetDefault("non-existant-key", "myvalue"); def.String() != "myvalue" { t.Log("GetDefault fails to apply default value") t.Fail() } diff --git a/config.go b/config.go index 97b3c7c..2a1995f 100644 --- a/config.go +++ b/config.go @@ -56,54 +56,57 @@ // 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 { +// 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 - // Options map stores single word options ("option" in config file) - Options map[string]struct{} + 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 *Config) Find(key string) (s Setting, err error) { - v, ok := c.Settings[key] - if !ok { - err = ErrNotFound - } - s.Value = v - return +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 *Config) Get(key string) (s Setting) { - s.Value = c.Settings[key] - return +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 *Config) GetDefault(key, def string) (s Setting) { - v, ok := c.Settings[key] - switch ok { - case true: - s.Value = v - default: - s.Value = def - } - return +func (c *Conf) GetDefault(key, def string) Setting { + s, _ := c.get(key, def) + return s } -// 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 +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/parser.go b/parser.go index 75c7799..1880f13 100644 --- a/parser.go +++ b/parser.go @@ -2,66 +2,107 @@ package conf import ( "bufio" + "errors" + "fmt" "io" "os" - "regexp" "strings" ) -var ( - configKeyValueRe = regexp.MustCompile(`(\w+) *= *(.+)\b[\t| |\n]*`) - configOptionRe = regexp.MustCompile(`(\w+)`) - configCommentedOut = "#" +const ( + strComment = "#" + strEqSign = "=" ) -// ParseFile reads values from file. It returns nil and error if os.Open(filename) fails. +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" +// +// "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) { +func Open(filename string) (*Conf, error) { file, err := os.Open(filename) if err != nil { return nil, err } - defer file.Close() - return parseReader(file), nil + + c, err := parseReader(file) + if err != nil { + return nil, err + } + + return c, file.Close() } -// ParseReader reads from r and returns Config. See also ParseFile. -func ParseReader(r io.Reader) *Config { +// Read reads from r and returns Config. +func Read(r io.Reader) (*Conf, error) { 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) (*Conf, error) { + settings := make(map[string]string) -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) { + + line = trim(stripComment(line)) + if line == "" { 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{}{} + + 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 } - return &c + + 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 new file mode 100644 index 0000000..2c53dd6 --- /dev/null +++ b/parser_test.go @@ -0,0 +1,159 @@ +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 index f734e6d..ae1dc9a 100644 --- a/settings.go +++ b/settings.go @@ -7,38 +7,23 @@ import ( ) 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") + ErrNotFound = errors.New("conf: key not found") + ErrCouldNotConvert = errors.New("conf: 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, -} +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 (st Setting) Int() (int, error) { - return parseInt(st.Value) +func (s Setting) Int() (int, error) { + return parseValue(s.Value, strconv.Atoi) } // IntSlice splits Setting Value (separator is ",") and adds @@ -46,19 +31,14 @@ func (st Setting) Int() (int, error) { // 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 (s Setting) IntSlice() ([]int, error) { + return parseSlice(s.Value, strconv.Atoi) } -/* 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) +func (s Setting) Float64() (float64, error) { + return parseValue(s.Value, parseFloat64) } // Float64Slice splits Setting Value (separator is ",") and adds @@ -66,92 +46,64 @@ func (st Setting) Float64() (float64, error) { // 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) +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 (st Setting) String() string { - return st.Value +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 (st Setting) StringSlice() []string { - return tidySplit(st.Value, valuesSeparator) +func (s Setting) StringSlice() []string { + return tidySplit(s.Value) } // 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) +// "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 parseInt(s string) (n int, err error) { - n, err = strconv.Atoi(s) - return -} +func parseSlice[T any](s string, f func(string) (T, error)) ([]T, error) { + split := tidySplit(s) -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) + list := make([]T, 0, len(split)) + for _, str := range split { + v, err := parseValue(str, f) if err != nil { - err = ErrCouldNotConvert - break + return list, err } - slice = append(slice, n) + + list = append(list, v) } - return slice, err + + return list, nil } -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) +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 slice, err + + return v, 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) +func tidySplit(s string) []string { + splitted := strings.Split(s, valuesSeparator) for i, str := range splitted { - splitted[i] = strings.Trim(str, " ") + splitted[i] = strings.TrimSpace(str) } return splitted } + +func parseFloat64(s string) (float64, error) { + return strconv.ParseFloat(s, 64) +}