diff --git a/utini/file.go b/utini/file.go new file mode 100644 index 0000000..30276f0 --- /dev/null +++ b/utini/file.go @@ -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 +} diff --git a/utini/load.go b/utini/load.go new file mode 100644 index 0000000..e8103eb --- /dev/null +++ b/utini/load.go @@ -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 diff --git a/utini/load_test.go b/utini/load_test.go new file mode 100644 index 0000000..b919fe2 --- /dev/null +++ b/utini/load_test.go @@ -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) +} diff --git a/utini/names.go b/utini/names.go new file mode 100644 index 0000000..2ab2fbc --- /dev/null +++ b/utini/names.go @@ -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, ``) +} diff --git a/utini/save.go b/utini/save.go new file mode 100644 index 0000000..5573ee7 --- /dev/null +++ b/utini/save.go @@ -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) +} diff --git a/utini/save_test.go b/utini/save_test.go new file mode 100644 index 0000000..872d50b --- /dev/null +++ b/utini/save_test.go @@ -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) +} diff --git a/utini/tags.go b/utini/tags.go new file mode 100644 index 0000000..1eb0300 --- /dev/null +++ b/utini/tags.go @@ -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 +}