Added simple INI file serializer (utini).

This commit is contained in:
Sander Schobers 2021-10-09 19:53:07 +02:00
parent 942bf31ab6
commit 2f614c777d
7 changed files with 717 additions and 0 deletions

56
utini/file.go Normal file
View File

@ -0,0 +1,56 @@
package utini
import (
"sort"
"strings"
)
type Comment string
type File struct {
Sections map[string]*Section
}
func (f *File) CreateIfNotExists(name string) *Section {
if f.Sections == nil {
f.Sections = map[string]*Section{}
}
section := f.Sections[name]
if section == nil {
section = &Section{}
f.Sections[name] = section
}
return section
}
type Property struct {
Key, Value string
}
type Section struct {
Properties []*Property
}
func (s Section) PropertyByKey(key string) *Property {
key = strings.ToLower(key)
for _, p := range s.Properties {
if strings.ToLower(p.Key) == key {
return p
}
}
return nil
}
func (s Section) Keys() []string {
keys := map[string]struct{}{}
for _, property := range s.Properties {
keys[property.Key] = struct{}{}
}
var sorted []string
for key := range keys {
sorted = append(sorted, key)
}
sort.Strings(sorted)
return sorted
}

214
utini/load.go Normal file
View File

@ -0,0 +1,214 @@
package utini
import (
"errors"
"fmt"
"io"
"reflect"
"regexp"
"strconv"
"strings"
"opslag.de/schobers/ut/utio"
)
var errStructPtrExpected = errors.New(`expected pointer to struct`)
var keyValuePairRE = regexp.MustCompile(`^\s*(\w+)\s*=\s*(.*)\s*$`)
var sectionRE = regexp.MustCompile(`\[((?:\w+\.)*(\w+))\]`)
var commentRE = regexp.MustCompile(`^\s*[;#].*$`)
func appendToPrefix(prefix, name string) string {
if prefix == "" {
return name
}
return fmt.Sprintf(`%s.%s`, prefix, name)
}
// func fmtErrNoSection(name string) error { return fmt.Errorf(`no section with name %s in file`, name) }
func load(lines []string) (*File, error) {
file := &File{}
var section *Section
for _, line := range lines {
if commentRE.MatchString(line) {
continue
}
match := sectionRE.FindStringSubmatch(line)
if match != nil {
section = file.CreateIfNotExists(match[1])
continue
}
match = keyValuePairRE.FindStringSubmatch(line)
if match != nil {
if section == nil {
section = file.CreateIfNotExists("")
}
section.Properties = append(section.Properties, &Property{
Key: match[1],
Value: match[2],
})
}
}
return file, nil
}
func Load(r io.Reader) (*File, error) {
lines := utio.Lines()
if err := lines.Decode(r); err != nil {
return nil, err
}
return load(lines.Lines)
}
func LoadFile(path string) (*File, error) {
var err error
var file *File
utio.ReadFile(path, func(r io.Reader) error {
file, err = Load(r)
return err
})
return file, err
}
func loadMap(file *File, err error, v interface{}) error {
if err != nil {
return err
}
return MapFromFile(file, v)
}
func LoadMap(r io.Reader, v interface{}) error {
file, err := Load(r)
return loadMap(file, err, v)
}
func LoadMapFile(path string, v interface{}) error {
file, err := LoadFile(path)
return loadMap(file, err, v)
}
func mapFileToStruct(ptr reflect.Value, file *File, prefix string) error {
if ptr.Type().Kind() != reflect.Ptr {
return errStructPtrExpected
}
value := ptr.Elem()
if value.Type().Kind() != reflect.Struct {
return errStructPtrExpected
}
valueTyp := value.Type()
n := valueTyp.NumField()
for i := 0; i < n; i++ {
field := valueTyp.Field(i)
name := appendToPrefix(prefix, field.Name)
kind := field.Type.Kind()
fieldValue := value.FieldByName(field.Name)
switch {
case kind == reflect.Struct:
if err := mapFileToStruct(fieldValue.Addr(), file, name); err != nil {
return err
}
case kind == reflect.Bool:
if err := mapPropertyToField(file, prefix, field, fieldValue, setBoolValue); err != nil {
return err
}
case kind == reflect.Int || kind == reflect.Int8 || kind == reflect.Int16 || kind == reflect.Int32 || kind == reflect.Int64:
if err := mapPropertyToField(file, prefix, field, fieldValue, setIntValue); err != nil {
return err
}
case kind == reflect.Uint || kind == reflect.Uint8 || kind == reflect.Uint16 || kind == reflect.Uint32 || kind == reflect.Uint64:
if err := mapPropertyToField(file, prefix, field, fieldValue, setUintValue); err != nil {
return err
}
case kind == reflect.String:
if err := mapPropertyToField(file, prefix, field, fieldValue, setStringValue); err != nil {
return err
}
}
}
return nil
}
func MapFromFile(file *File, v interface{}) error {
ptr := reflect.ValueOf(v)
return mapFileToStruct(ptr, file, "")
}
func mapPropertyToField(file *File, prefix string, field reflect.StructField, value reflect.Value, set setValueFn) error {
property := propertyValueForField(file, prefix, field)
if property == "" {
return nil
}
err := set(value, iniTag(field), property)
if err != nil {
return fmt.Errorf(`failed to map property "%s"; error: %v`, appendToPrefix(prefix, field.Name), err)
}
return nil
}
func propertyValueForField(file *File, prefix string, field reflect.StructField) string {
sectionKey := camelCaseToSnakeCase(prefix)
section := file.Sections[sectionKey]
if section == nil {
return defaultTagValue(iniTag(field))
}
key := camelCaseToSnakeCase(field.Name)
property := section.PropertyByKey(key)
if property == nil {
return defaultTagValue(iniTag(field))
}
return property.Value
}
func setBoolValue(value reflect.Value, tag string, property string) error {
states := statesTagValue(tag)
p := strings.ToLower(property)
if p == states[0] {
value.SetBool(false)
} else if p == states[1] {
value.SetBool(true)
} else {
return fmt.Errorf(`failed to convert value "%s" to its boolean value, expected either "%s" or "%s"`, property, states[0], states[1])
}
return nil
}
func setIntValue(value reflect.Value, _ string, property string) error {
i, err := strconv.ParseInt(property, 10, 64)
if err != nil {
return fmt.Errorf(`failed to convert value "%s" to an integer`, property)
}
value.SetInt(i)
return nil
}
func setStringValue(value reflect.Value, _ string, property string) error {
n := len(property)
if n > 1 {
delimiter := property[0]
if (delimiter == '"' || delimiter == '\'' || delimiter == '`') && property[n-1] == delimiter {
property = property[1 : n-1]
}
}
value.SetString(property)
return nil
}
func setUintValue(value reflect.Value, _ string, property string) error {
i, err := strconv.ParseUint(property, 10, 64)
if err != nil {
return fmt.Errorf(`failed to convert value "%s" to an unsigned integer`, property)
}
value.SetUint(i)
return nil
}
type setValueFn func(value reflect.Value, tag, property string) error

97
utini/load_test.go Normal file
View File

@ -0,0 +1,97 @@
package utini
import (
"testing"
"github.com/stretchr/testify/assert"
"opslag.de/schobers/ut/utio"
)
func AssertLoadMapString(t *testing.T, s string, v interface{}) {
file := AssertLoadString(t, s)
err := MapFromFile(file, v)
assert.Nil(t, err)
}
func AssertLoadString(t *testing.T, s string) *File {
lines := utio.Lines()
utio.DecodeFromString(s, lines)
file, err := load(lines.Lines)
assert.Nil(t, err)
return file
}
type testData struct {
NoSection string
Primitives testDataSectionPrimitives
OnlySub testDataSectionOnlySub
NoSection2 string
}
type testDataSectionPrimitives struct {
DelimitedString string
Integer int
SingleQuotes string
UnsignedByte uint8
}
type testDataSectionOnlySub struct {
Sub testDataSectionSub
}
type testDataSectionSub struct {
Test string
}
type testDataDefault struct {
Integer int `ini:"default=12345678"`
String string `ini:"default=default"`
}
var testData1IniFile = `no_section = "no section"
no_section2 = "no section 2"
[only_sub.sub]
test = "value"
[primitives]
delimited_string = "value"
integer = 12345678
single_quotes = 'single quote delimited"'
unsigned_byte = 101
`
var testData1Struct = testData{
NoSection: "no section",
NoSection2: "no section 2",
Primitives: testDataSectionPrimitives{
DelimitedString: "value",
Integer: 12345678,
SingleQuotes: "single quote delimited\"",
UnsignedByte: 101,
},
OnlySub: testDataSectionOnlySub{
Sub: testDataSectionSub{
Test: "value",
},
},
}
func TestMapFromFile(t *testing.T) {
file := AssertLoadString(t, testData1IniFile)
data := &testData{}
err := MapFromFile(file, data)
assert.Nil(t, err)
assert.Equal(t, testData1Struct, *data)
}
func TestLoadDefaultValues(t *testing.T) {
data := &testDataDefault{}
AssertLoadMapString(t, ``, data)
assert.Equal(t, 12345678, data.Integer)
assert.Equal(t, `default`, data.String)
}

86
utini/names.go Normal file
View File

@ -0,0 +1,86 @@
package utini
import (
"strings"
"unicode"
)
type characterCasing int
const (
undefinedCase characterCasing = iota
lowerCase
upperCase
)
func camelCaseToSnakeCase(name string) string {
segments := strings.Split(name, ".")
if len(segments) > 1 {
for i, segment := range segments {
segments[i] = camelCaseToSnakeCase(segment)
}
return strings.Join(segments, ".")
}
var words []string
runes := []rune(name)
if len(runes) == 0 {
return ""
}
n := len(runes)
end := n
casing := undefinedCase
for i := range runes {
j := n - i - 1
r := runes[j]
if unicode.IsLower(r) {
if casing == upperCase {
words = append(words, strings.ToLower(string(runes[j+1:end])))
end = j + 1
casing = undefinedCase
} else {
casing = lowerCase
}
} else if r == '_' {
if end-j > 1 {
words = append(words, strings.ToLower(string(runes[j+1:end])))
end = j
}
casing = undefinedCase
} else if unicode.IsDigit(r) { // acts like either upper or lowercase depending on which character was following.
} else {
if casing == lowerCase {
words = append(words, strings.ToLower(string(runes[j:end])))
end = j
casing = undefinedCase
} else {
casing = upperCase
}
}
}
if end > 0 {
words = append(words, strings.ToLower(string(runes[:end])))
}
// reverse order of words
last := len(words) - 1
for i := 0; i < len(words)/2; i++ {
words[i], words[last-i] = words[last-i], words[i]
}
return strings.Join(words, "_")
}
func snakeCaseToCamelCase(name string) string {
words := strings.Split(name, `_`)
for i, word := range words {
runes := []rune(word)
first := strings.IndexFunc(word, unicode.IsLetter)
if first > -1 {
runes[first] = unicode.ToUpper(runes[first])
}
words[i] = string(runes)
}
return strings.Join(words, ``)
}

173
utini/save.go Normal file
View File

@ -0,0 +1,173 @@
package utini
import (
"fmt"
"io"
"reflect"
"sort"
"strconv"
"strings"
"opslag.de/schobers/ut/utio"
)
var stringDelimiters = []byte{'"', '\'', '`'}
func getBoolValue(value reflect.Value, tag string) string {
states := statesTagValue(tag)
if value.Bool() {
return states[1]
}
return states[0]
}
func getIntValue(value reflect.Value, _ string) string {
i := value.Int()
return strconv.FormatInt(i, 10)
}
func getStringValue(value reflect.Value, _ string) string {
s := value.String()
for _, delimiter := range stringDelimiters {
if strings.Contains(s, string([]byte{delimiter})) {
continue
}
return fmt.Sprintf("%c%s%c", delimiter, s, delimiter)
}
return s
}
func getUintValue(value reflect.Value, _ string) string {
i := value.Uint()
return strconv.FormatUint(i, 10)
}
type getValueFn func(reflect.Value, string) string
func mapFieldToProperty(file *File, prefix string, field reflect.StructField, value reflect.Value, get getValueFn) error {
sectionKey := camelCaseToSnakeCase(prefix)
section := file.CreateIfNotExists(sectionKey)
key := camelCaseToSnakeCase(field.Name)
section.Properties = append(section.Properties, &Property{
Key: key,
Value: get(value, iniTag(field)),
})
return nil
}
func mapStructToFile(file *File, ptr reflect.Value, prefix string) error {
if ptr.Type().Kind() != reflect.Ptr {
return errStructPtrExpected
}
value := ptr.Elem()
if value.Type().Kind() != reflect.Struct {
return errStructPtrExpected
}
valueTyp := value.Type()
n := valueTyp.NumField()
for i := 0; i < n; i++ {
field := valueTyp.Field(i)
name := appendToPrefix(prefix, field.Name)
kind := field.Type.Kind()
fieldValue := value.FieldByName(field.Name)
switch {
case kind == reflect.Struct:
if err := mapStructToFile(file, fieldValue.Addr(), name); err != nil {
return err
}
case kind == reflect.Bool:
if err := mapFieldToProperty(file, prefix, field, fieldValue, getBoolValue); err != nil {
return err
}
case kind == reflect.Int || kind == reflect.Int8 || kind == reflect.Int16 || kind == reflect.Int32 || kind == reflect.Int64:
if err := mapFieldToProperty(file, prefix, field, fieldValue, getIntValue); err != nil {
return err
}
case kind == reflect.Uint || kind == reflect.Uint8 || kind == reflect.Uint16 || kind == reflect.Uint32 || kind == reflect.Uint64:
if err := mapFieldToProperty(file, prefix, field, fieldValue, getUintValue); err != nil {
return err
}
case kind == reflect.String:
if err := mapFieldToProperty(file, prefix, field, fieldValue, getStringValue); err != nil {
return err
}
}
}
return nil
}
func MapToFile(v interface{}) (*File, error) {
ptr := reflect.ValueOf(v)
file := &File{}
err := mapStructToFile(file, ptr, "")
if err != nil {
return nil, err
}
return file, nil
}
func save(file *File) ([]string, error) {
var lines []string
var keys []string
for key := range file.Sections {
keys = append(keys, key)
}
sort.Strings(keys)
for i, key := range keys {
if i > 0 {
lines = append(lines, "")
}
if key != "" {
lines = append(lines, fmt.Sprintf("[%s]", key))
}
section := file.Sections[key]
propertyKeys := section.Keys()
for _, key := range propertyKeys {
for _, property := range section.Properties {
if property.Key != key {
continue
}
lines = append(lines, fmt.Sprintf("%s = %s", property.Key, property.Value))
}
}
}
return lines, nil
}
func Save(w io.Writer, file *File) error {
lines, err := save(file)
if err != nil {
return err
}
liner := &utio.Liner{Lines: lines}
return liner.Encode(w)
}
func SaveMap(w io.Writer, v interface{}) error {
file, err := MapToFile(v)
if err != nil {
return err
}
return Save(w, file)
}
func SaveFile(path string, file *File) error {
return utio.WriteFile(path, func(w io.Writer) error {
return Save(w, file)
})
}
func SaveFileMap(path string, v interface{}) error {
file, err := MapToFile(v)
if err != nil {
return err
}
return SaveFile(path, file)
}

38
utini/save_test.go Normal file
View File

@ -0,0 +1,38 @@
package utini
import (
"testing"
"github.com/stretchr/testify/assert"
"opslag.de/schobers/ut/utio"
)
func AssertSaveString(t *testing.T, file *File) string {
lines, err := save(file)
assert.Nil(t, err)
s, _ := utio.EncodeToString(&utio.Liner{Lines: lines})
return s
}
func AssertSaveMapString(t *testing.T, v interface{}) string {
file, err := MapToFile(v)
assert.Nil(t, err)
return AssertSaveString(t, file)
}
func TestMapToFile(t *testing.T) {
file, err := MapToFile(&testData1Struct)
assert.Nil(t, err)
s := AssertSaveString(t, file)
assert.Equal(t, testData1IniFile, s)
}
// TestSaveDefaultValues tests that default values are NOT applied during saving.
func TestSaveDefaultValues(t *testing.T) {
s := AssertSaveMapString(t, &testDataDefault{})
assert.Equal(t, `integer = 0
string = ""
`, s)
}

53
utini/tags.go Normal file
View File

@ -0,0 +1,53 @@
package utini
import (
"fmt"
"reflect"
"strings"
)
func defaultTagValue(tag string) string {
value, _ := tagValueByName(tag, `default`)
return value
}
func hasTag(tag, name string) bool {
nameValuePrefix := fmt.Sprintf("%s=", name)
values := strings.Split(tag, ",")
for _, value := range values {
if value == name {
return true
}
if strings.HasPrefix(value, nameValuePrefix) {
return true
}
}
return false
}
func iniTag(field reflect.StructField) string {
return field.Tag.Get(`ini`)
}
func statesTagValue(tag string) [2]string {
if statesTag, ok := tagValueByName(tag, "states"); ok {
s := strings.Split(statesTag, "|")
if len(s) == 2 {
return [2]string{s[0], s[1]}
}
}
return [2]string{"false", "true"}
}
func tagValueByName(tag, name string) (string, bool) {
nameValuePrefix := fmt.Sprintf("%s=", name)
values := strings.Split(tag, ",")
for _, value := range values {
value = strings.TrimSpace(value)
if !strings.HasPrefix(value, nameValuePrefix) {
continue
}
return value[len(nameValuePrefix):], true
}
return "", false
}