This commit is contained in:
2025-04-20 21:05:14 +03:00
parent 27cb532ec4
commit de63cc9e04
14 changed files with 1191 additions and 670 deletions

View File

@@ -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

210
README.md
View File

@@ -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: <nil>
n has value: 10000, type: int, err: <nil>
var distance has value: 13.42, type: float64, error value: <nil>
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.

View File

@@ -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()
}
}

109
config.go
View File

@@ -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
}

View File

@@ -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"
// 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)
// 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 "с" can be interpreted as int
x, _ := m.Get("c").Int()
fmt.Println(x) // prints 3
}

183
map.go Normal file
View File

@@ -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
}

162
map_test.go Normal file
View File

@@ -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")
}
}

View File

@@ -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
}

View File

@@ -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
}

5
testdata/test.conf vendored Normal file
View File

@@ -0,0 +1,5 @@
# comment
key1 = value1
key2=value2
key3 = value3
key4 = value4

31
util.go Normal file
View File

@@ -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
}

209
util_test.go Normal file
View File

@@ -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)
}
})
}
}

127
value.go Normal file
View File

@@ -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)
}

370
value_test.go Normal file
View File

@@ -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)
})
}
}