Restructure the code a bit.

Add a more complete wrapper function called Configure and ConfigureWithDefaults.
Add ability to write a configmap back to a new file.
Changed my mind about multiple equals signs because some data like base64 uses equals signs.
Added more tests.
Bump to version 0.1.0 to signal closer to completeness/stability.
Changed domain name from gitea.wisellama.rocks to git.wisellama.rocks.
main v0.1.0
Sean Hickey 2022-11-09 00:16:36 -08:00
parent 5cc5aa419e
commit 4481060613
12 changed files with 429 additions and 245 deletions

View File

@ -1,8 +1,9 @@
all: linter test all: linter test
test: test:
go test ./ go test
lint: linter
linter: linter:
golangci-lint run golangci-lint run

View File

@ -1,16 +1,18 @@
# gosimpleconf # gosimpleconf
```sh ```sh
go get gitea.wisellama.rocks/Wisellama/gosimpleconf@v0.0.4 go get git.wisellama.rocks/Wisellama/gosimpleconf@v0.1.0
``` ```
This is a small library for parsing super simple configuration files. This is a small library for parsing super simple configuration files
similar to Unix conf files.
It expects a file with the following format: It expects a file with the following format:
* Lines with key-values pairs separated by `=` (e.g. `foo = bar`) * Lines with key-values pairs separated by `=` (e.g. `foo = bar`)
* Lines that start with `#` are ignored (used for comments) * Lines that start with `#` are ignored (used for comments)
* Empty and whitespace-only lines are ignore * Empty and whitespace-only lines are ignored
* Lines with data that don't have an `=` will cause an error. * Lines with some data but no `=` will cause an error.
* Lines with multiple `=` are split on the literal first `=` seen, any remaining are part of the value.
The key-values pairs are simply parsed as strings and plopped into a The key-values pairs are simply parsed as strings and plopped into a
map for you to do whatever your program wants with them. If you need a map for you to do whatever your program wants with them. If you need a
@ -25,15 +27,21 @@ url = www.wisellama.rocks
number_of_widgets = 1337 number_of_widgets = 1337
pi = 3.141592653589793238462643383 pi = 3.141592653589793238462643383
environment = production environment = production
# Quotes are optional # Quotes are optional
name = Dude guy name = Dude guy
description = "Just some dude it was" description = "Just some dude it was"
# Additional equals are just part of the value
base64_tacos = dGFjb3M=
a=b=c
# key: 'a', value: 'b=c'
``` ```
Then to load that config file into a map: Then to load that config file into a map:
```go ```go
configMap, err := gosimpleconf.Load("file.conf") configMap, err := gosimpleconf.Configure("file.conf")
``` ```
Then everything is a string. You can parse it as needed. Then everything is a string. You can parse it as needed.
@ -42,19 +50,43 @@ var piStr string = configMap["pi"]
pi, err := strconv.ParseFloat(value, 64) pi, err := strconv.ParseFloat(value, 64)
``` ```
To override values with cli flags, after parsing the conf file you can ### Command-Line Flag Overrides
use these functions to also parse the flags:
```go
flagMap := SetupFlagOverrides(configMap)
configMap = ParseFlags(configMap, flagmap)
```
Then you could, for example, override the url value without modifying By default, the `Configure()` function will figure out which keys
the config file: exist in the config map and set those as cli flags that you can
```go override. For example, with the config file above, you could override
the `url` key with a cli flag:
```sh
./program --url=newthing.wisellama.rocks ./program --url=newthing.wisellama.rocks
``` ```
If you don't want to support cli flag overrides, you can directly call
`ReadFile("file.conf")` instead.
If you want to support other cli flags that don't map to a config
setting, they must be set up before calling `Configure()`. This is
because the `ParseFlags()` function calls `flag.Parse()` to get all
the values for the config map overrides. You could also work around
this by calling `ReadFile` and `ParseFlags` yourself and add your
other logic in between the two calls.
### Default Config
You can specify a default configuration to return if the given config
file doesn't exist. Here's an example:
```go
defaultConfig := gosimpleconf.ConfigMap{
"game.title": "Some Game",
"log.writeToFile": "false",
}
configMap, err := gosimpleconf.ConfigureWithDefaults("file_that_doesnt_exist.conf", defaultConfig)
```
Then if the config file can't be found, the default config map values
will be used instead. You can still override the default values with
cli flags.
## Why? ## Why?
I've always seen configuration files similar to this show up in I've always seen configuration files similar to this show up in

95
config_file.go Normal file
View File

@ -0,0 +1,95 @@
package gosimpleconf
import (
"bufio"
"fmt"
"io"
"os"
"strings"
)
type ConfigMap map[string]string
func Load(filename string) (ConfigMap, error) {
return ReadFile(filename)
}
func ReadFile(filename string) (ConfigMap, error) {
var err error
file, err := os.Open(filename)
if err != nil {
return nil, err
}
defer file.Close()
parsedMap, err := parseFile(file)
if err != nil {
return nil, err
}
return parsedMap, nil
}
func WriteFile(filename string, configMap ConfigMap) error {
file, err := os.Create(filename)
if err != nil {
return err
}
defer file.Close()
for k, v := range configMap {
fmt.Fprintf(file, "%s = %s\n", k, v)
}
return nil
}
func parseFile(config io.Reader) (ConfigMap, error) {
var err error
parsedMap := make(ConfigMap)
scanner := bufio.NewScanner(config)
for scanner.Scan() {
line := scanner.Text()
trimmed := strings.TrimSpace(line)
if len(trimmed) == 0 {
// ignore empty lines
continue
}
if trimmed[0] == '#' {
// ignore comments
continue
}
split := strings.Split(trimmed, "=")
if len(split) < 2 {
return nil, fmt.Errorf("failed to parse config line: %v", trimmed)
}
key := trimToken(split[0])
value := strings.Join(split[1:], "=") // Re-add any ='s that were part of the value
value = trimToken(value)
parsedMap[key] = value
}
if err = scanner.Err(); err != nil {
return nil, err
}
return parsedMap, nil
}
func trimToken(value string) string {
// Trim off whitespace
v := strings.TrimSpace(value)
// Trim off any surrounding quotes, everything is a string anyway
v = strings.TrimPrefix(v, "\"")
v = strings.TrimSuffix(v, "\"")
return v
}

104
config_file_test.go Normal file
View File

@ -0,0 +1,104 @@
package gosimpleconf
import (
"strings"
"testing"
)
func TestParseWithValidConfig(t *testing.T) {
var err error
configFileStr := `
# Some config file
foo=bar
asdf = 1234
# Things
wat.wat= thing
foo.wat =stuff
immaempty=
technically valid = haha hmm...
#Done
`
configFile := strings.NewReader(configFileStr)
expectedMap := make(ConfigMap)
expectedMap["foo"] = "bar"
expectedMap["asdf"] = "1234"
expectedMap["wat.wat"] = "thing"
expectedMap["foo.wat"] = "stuff"
expectedMap["immaempty"] = ""
expectedMap["technically valid"] = "haha hmm..."
parsedMap, err := parseFile(configFile)
if err != nil {
t.Errorf("failed while parsing the file")
}
validateMap(t, parsedMap, expectedMap)
}
func TestParseWithNoEquals(t *testing.T) {
var err error
configFileStr := `
Something here with no equals to split on
`
configFile := strings.NewReader(configFileStr)
_, err = parseFile(configFile)
if err == nil {
t.Errorf("parse did not thrown an error when it was supposed to")
}
}
func TestParseWithExtraQuotes(t *testing.T) {
var err error
configFileStr := `
some.name = "This is in quotes"
extra.quotes = ""This will keep the quotes""
internal.quotes = "Some "thing" is here"
"This should work too" = shrug I guess
`
configFile := strings.NewReader(configFileStr)
expectedMap := make(ConfigMap)
expectedMap["some.name"] = "This is in quotes"
expectedMap["extra.quotes"] = "\"This will keep the quotes\""
expectedMap["internal.quotes"] = "Some \"thing\" is here"
expectedMap["This should work too"] = "shrug I guess"
configMap, err := parseFile(configFile)
if err != nil {
t.Errorf("failed while parsing the file")
}
validateMap(t, configMap, expectedMap)
}
func TestParseExtraEquals(t *testing.T) {
configFileStr := `
foo=bar=true
base64_tacos = dGFjb3M=
"equals=splits" the key/value, quotes and other =equals= don't matter
yup=soup
`
configFile := strings.NewReader(configFileStr)
expectedMap := make(ConfigMap)
expectedMap["foo"] = "bar=true"
expectedMap["base64_tacos"] = "dGFjb3M="
expectedMap["equals"] = "splits\" the key/value, quotes and other =equals= don't matter"
expectedMap["yup"] = "soup"
configMap, err := parseFile(configFile)
if err != nil {
t.Errorf("failed while parsing the file")
}
validateMap(t, configMap, expectedMap)
}

36
conversions.go Normal file
View File

@ -0,0 +1,36 @@
package gosimpleconf
import (
"log"
"strconv"
)
func Bool(value string) bool {
v, err := strconv.ParseBool(value)
if err != nil {
log.Printf("error parsing bool %v - %v\n", v, err)
v = false
}
return v
}
func Int64(value string) int64 {
v, err := strconv.ParseInt(value, 10, 64)
if err != nil {
log.Printf("error parsing int %v - %v\n", v, err)
v = 0
}
return v
}
func Float64(value string) float64 {
v, err := strconv.ParseFloat(value, 64)
if err != nil {
log.Printf("error parsing float %v - %v\n", v, err)
v = 0.0
}
return v
}

28
flag_overrides.go Normal file
View File

@ -0,0 +1,28 @@
package gosimpleconf
import "flag"
type FlagMap map[string]*string
func SetupFlagOverrides(configMap ConfigMap) FlagMap {
flagMap := make(FlagMap)
for k, v := range configMap {
f := flag.String(k, v, "")
flagMap[k] = f
}
return flagMap
}
func ParseFlags(configMap ConfigMap, flagMap FlagMap) ConfigMap {
flag.Parse()
for k, v := range flagMap {
if v != nil && len(*v) > 0 {
configMap[k] = trimToken(*v)
}
}
return configMap
}

41
flag_overrides_test.go Normal file
View File

@ -0,0 +1,41 @@
package gosimpleconf
import (
"flag"
"strings"
"testing"
)
func TestFlagOverrides(t *testing.T) {
var err error
configFileStr := `
foo=bar
asdf = 1234
foo.wat =stuff
immaempty=
`
configFile := strings.NewReader(configFileStr)
expectedMap := make(ConfigMap)
expectedMap["foo"] = "bar"
expectedMap["asdf"] = "1234"
// expect to override the 'foo.wat' value with a cli flag
expectedMap["foo.wat"] = "iwasoverridden"
expectedMap["immaempty"] = ""
configMap, err := parseFile(configFile)
if err != nil {
t.Errorf("failed while parsing the file")
}
flagMap := SetupFlagOverrides(configMap)
err = flag.Set("foo.wat", "iwasoverridden")
if err != nil {
t.Errorf("failed to set cli flag override")
}
configMap = ParseFlags(configMap, flagMap)
validateMap(t, configMap, expectedMap)
}

2
go.mod
View File

@ -1,3 +1,3 @@
module gitea.wisellama.rocks/Wisellama/gosimpleconf module git.wisellama.rocks/Wisellama/gosimpleconf
go 1.17 go 1.17

View File

@ -1,112 +1,31 @@
package gosimpleconf package gosimpleconf
import ( import "os"
"bufio"
"flag"
"fmt"
"io"
"log"
"os"
"strconv"
"strings"
)
type ConfigMap map[string]string func Configure(filename string) (ConfigMap, error) {
type FlagMap map[string]*string
func Load(filename string) (ConfigMap, error) {
var err error var err error
file, err := os.Open(filename) configMap, err := ReadFile(filename)
if err != nil {
return nil, err
}
defer file.Close()
parsedMap, err := parseFile(file)
if err != nil { if err != nil {
return nil, err return nil, err
} }
return parsedMap, nil flagMap := SetupFlagOverrides(configMap)
configMap = ParseFlags(configMap, flagMap)
return configMap, nil
} }
func SetupFlagOverrides(configMap ConfigMap) FlagMap { func ConfigureWithDefaults(filename string, defaultConf ConfigMap) (ConfigMap, error) {
flagMap := make(FlagMap)
for k, v := range configMap {
f := flag.String(k, v, "")
flagMap[k] = f
}
return flagMap
}
func ParseFlags(configMap ConfigMap, flagMap FlagMap) ConfigMap {
flag.Parse()
for k, v := range flagMap {
if v != nil && len(*v) > 0 {
configMap[k] = trimToken(*v)
}
}
return configMap
}
func Bool(value string) bool {
v, err := strconv.ParseBool(value)
if err != nil {
log.Printf("error parsing bool %v - %v\n", v, err)
v = false
}
return v
}
func parseFile(config io.Reader) (ConfigMap, error) {
var err error var err error
parsedMap := make(ConfigMap)
scanner := bufio.NewScanner(config) _, err = os.Stat(filename)
for scanner.Scan() { if os.IsNotExist(err) {
line := scanner.Text() // Config file does not exist, return a default configMap
trimmed := strings.TrimSpace(line) return defaultConf, nil
if len(trimmed) == 0 { } else if err != nil {
// ignore empty lines
continue
}
if trimmed[0] == '#' {
// ignore comments
continue
}
split := strings.Split(trimmed, "=")
if len(split) != 2 {
return nil, fmt.Errorf("failed to parse config line: %v", trimmed)
}
key := trimToken(split[0])
value := trimToken(split[1])
parsedMap[key] = value
}
if err = scanner.Err(); err != nil {
return nil, err return nil, err
} }
return parsedMap, nil return Configure(filename)
}
func trimToken(value string) string {
// Trim off whitespace
v := strings.TrimSpace(value)
// Trim off any surrounding quotes, everything is a string anyway
v = strings.TrimPrefix(v, "\"")
v = strings.TrimSuffix(v, "\"")
return v
} }

View File

@ -1,150 +1,40 @@
package gosimpleconf package gosimpleconf
import ( import "testing"
"flag"
"strings"
"testing"
)
func TestParseWithValidConfig(t *testing.T) { func TestConfigureWithDefaults(t *testing.T) {
var err error defaultMap := ConfigMap{
configFileStr := ` "some": "thing",
# Some config file "cool": "1337",
}
foo=bar filename := "idontexist_123456789"
asdf = 1234
# Things conf, err := ConfigureWithDefaults(filename, defaultMap)
wat.wat= thing
foo.wat =stuff
immaempty=
technically valid = haha hmm...
#Done
`
configFile := strings.NewReader(configFileStr)
expectedMap := make(ConfigMap)
expectedMap["foo"] = "bar"
expectedMap["asdf"] = "1234"
expectedMap["wat.wat"] = "thing"
expectedMap["foo.wat"] = "stuff"
expectedMap["immaempty"] = ""
expectedMap["technically valid"] = "haha hmm..."
parsedMap, err := parseFile(configFile)
if err != nil { if err != nil {
t.Errorf("failed while parsing the file") t.Errorf("unexpected error: %v", err)
} }
validateMap(t, parsedMap, expectedMap) validateMap(t, conf, defaultMap)
} }
func TestParseWithExtraEquals(t *testing.T) { func TestConfigure(t *testing.T) {
var err error filename := "test.conf"
configFileStr := ` expectedMap := ConfigMap{
foo=bar=true "url": "www.wisellama.rocks",
` "number_of_widgets": "1337",
configFile := strings.NewReader(configFileStr) "pi": "3.141592653589793238462643383",
"environment": "production",
_, err = parseFile(configFile) "name": "Dude guy",
if err == nil { "description": "Just some dude it was",
t.Errorf("parse did not thrown an error when it was supposed to") "base64_tacos": "dGFjb3M=",
"a": "b=c",
} }
}
func TestParseWithNoEquals(t *testing.T) { conf, err := Configure(filename)
var err error
configFileStr := `
Something here with no equals to split on
`
configFile := strings.NewReader(configFileStr)
_, err = parseFile(configFile)
if err == nil {
t.Errorf("parse did not thrown an error when it was supposed to")
}
}
func TestFlagOverrides(t *testing.T) {
var err error
configFileStr := `
foo=bar
asdf = 1234
foo.wat =stuff
immaempty=
`
configFile := strings.NewReader(configFileStr)
expectedMap := make(ConfigMap)
expectedMap["foo"] = "bar"
expectedMap["asdf"] = "1234"
// expect to override the 'foo.wat' value with a cli flag
expectedMap["foo.wat"] = "iwasoverridden"
expectedMap["immaempty"] = ""
configMap, err := parseFile(configFile)
if err != nil { if err != nil {
t.Errorf("failed while parsing the file") t.Errorf("unexpected error: %v", err)
} }
flagMap := SetupFlagOverrides(configMap) validateMap(t, conf, expectedMap)
err = flag.Set("foo.wat", "iwasoverridden")
if err != nil {
t.Errorf("failed to set cli flag override")
}
configMap = ParseFlags(configMap, flagMap)
validateMap(t, configMap, expectedMap)
}
func TestParseWithExtraQuotes(t *testing.T) {
var err error
configFileStr := `
some.name = "This is in quotes"
extra.quotes = ""This will keep the quotes""
internal.quotes = "Some "thing" is here"
"This should work too" = shrug I guess
`
configFile := strings.NewReader(configFileStr)
expectedMap := make(ConfigMap)
expectedMap["some.name"] = "This is in quotes"
expectedMap["extra.quotes"] = "\"This will keep the quotes\""
expectedMap["internal.quotes"] = "Some \"thing\" is here"
expectedMap["This should work too"] = "shrug I guess"
configMap, err := parseFile(configFile)
if err != nil {
t.Errorf("failed while parsing the file")
}
validateMap(t, configMap, expectedMap)
}
func validateMap(t *testing.T, given map[string]string, expected map[string]string) {
if given == nil {
t.Errorf("given map was nil")
}
if expected == nil {
t.Errorf("expected map was nil")
}
expectedLen := len(expected)
givenLen := len(given)
if expectedLen != givenLen {
t.Errorf("size mismatch on maps - expected %v, given %v", expectedLen, givenLen)
}
for k, v := range expected {
if v != given[k] {
t.Errorf("incorrect value for key %v - expected %v, given %v", k, expected[k], given[k])
}
}
} }

13
test.conf Normal file
View File

@ -0,0 +1,13 @@
url = www.wisellama.rocks
number_of_widgets = 1337
pi = 3.141592653589793238462643383
environment = production
# Quotes are optional
name = Dude guy
description = "Just some dude it was"
# Additional equals are just part of the value
base64_tacos = dGFjb3M=
a=b=c
# key: 'a', value: 'b=c'

25
test_helper.go Normal file
View File

@ -0,0 +1,25 @@
package gosimpleconf
import "testing"
func validateMap(t *testing.T, given map[string]string, expected map[string]string) {
if given == nil {
t.Errorf("given map was nil")
}
if expected == nil {
t.Errorf("expected map was nil")
}
expectedLen := len(expected)
givenLen := len(given)
if expectedLen != givenLen {
t.Errorf("size mismatch on maps - expected %v, given %v", expectedLen, givenLen)
}
for k, v := range expected {
if v != given[k] {
t.Errorf("incorrect value for key %v - expected %v, given %v", k, expected[k], given[k])
}
}
}