From aba688f2d2f9020404f6075fc17641c8b9daeb92 Mon Sep 17 00:00:00 2001 From: Sasha Koshka Date: Sat, 27 Apr 2024 16:03:23 -0400 Subject: [PATCH] Add simple parsing for key/value files --- key-value/key-value.go | 218 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 218 insertions(+) create mode 100644 key-value/key-value.go diff --git a/key-value/key-value.go b/key-value/key-value.go new file mode 100644 index 0000000..3616eba --- /dev/null +++ b/key-value/key-value.go @@ -0,0 +1,218 @@ +// Package keyValue implements the basic key/value file format as described in +// desktop-entry-spec version 1.5. At the moment, these files can only be read +// from and not written to. +// The specification can be read at: +// https://specifications.freedesktop.org/desktop-entry-spec/1.5/ +package keyValue + +// This implementation does not support writing files, because, and I quote: +// Lines beginning with a # and blank lines are considered comments and will +// be ignored, however they should be preserved across reads and writes of the +// desktop entry file. +// MISS ME WITH THAT SHIT! + +import "io" +import "bufio" +import "strings" +import "git.tebibyte.media/tomo/xdg/locale" + +type keyValueError string +func (err keyValueError) Error () string { return string(err) } + +const ( + // ErrUnexpectedRune indicates an unexpected rune was encountered. + ErrUnexpectedRune = keyValueError("unexpected rune") + // ErrInvalidGroupHeader indicates a wrongly formatted group header was + // encountered. + ErrInvalidGroupHeader = keyValueError("invalid group header") + // ErrInvalidEntry indicates a wrongly formatted key/value entry was + // encountered. + ErrInvalidEntry = keyValueError("invalid entry") + // ErrDuplicateGroup indicates that two or more groups with the same + // name were found. + ErrDuplicateGroup = keyValueError("duplicate group name") + // ErrDuplicateEntry indicates that two or more keys with the same name + // were found. + ErrDuplicateEntry = keyValueError("duplicate entry name") + // ErrDuplicateLocalization indicates that two or more localized values + // with the same locale were provided for the same entry. + ErrDuplicateLocalization = keyValueError("duplicate localization") + // ErrNoDefaultValue indicates that localized values were provided for + // an entry with no default value. + ErrNoDefaultValue = keyValueError("no default value") + // ErrEntryOutsideGroup indicates that an entry was found. + ErrEntryOutsideGroup = keyValueError("entry outside group") +) + +// Parse parses a key/value file from a Reader. +func Parse (reader io.Reader) (File, error) { + buffer := bufio.NewReader(reader) + var file = File { } + var group Group + var specifiedValues map[string] struct { } + for { + line, err := buffer.ReadString('\n') + if err != nil { return nil, err } + line = strings.TrimSpace(line) + + switch { + // comment + case line == "", strings.HasPrefix(line, "#"): + + // group header + case strings.HasPrefix(line, "["): + // check integrity of prev. group + if group != nil { + for key := range group { + if _, ok := specifiedValues[key]; !ok { + return nil, ErrNoDefaultValue + } + } + } + + // create new group + name, err := parseGroupHeader(line) + if err != nil { return nil, err } + group = Group { } + specifiedValues = map[string] struct { } { } + _, exists := file[name] + if exists { return nil, ErrDuplicateGroup } + file[name] = group + + // key/value pair + default: + if group == nil { return nil, ErrEntryOutsideGroup } + key, value, loc, err := parseEntry(line) + if err != nil { return nil, err } + + if loc != (locale.Locale { }) { + // localized value + entry, ok := group[key] + if !ok { entry = newEntry() } + entry.Localized[loc] = value + group[key] = entry + } else { + // default value + _, exists := group[key] + if exists { return nil, ErrDuplicateEntry } + entry := newEntry() + entry.Value = value + group[key] = entry + specifiedValues[key] = struct { } { } + } + } + } +} + +func newEntry () Entry { + return Entry { + Localized: map[locale.Locale] string { }, + } +} + +func parseGroupHeader (line string) (string, error) { + line = strings.TrimPrefix(line, "[") + if !strings.HasSuffix(line, "]") { + return "", ErrInvalidGroupHeader + } + line = strings.TrimSuffix(line, "]") + if strings.ContainsAny(line, "[]") { + return "", ErrInvalidGroupHeader + } + return line, nil +} + +func parseEntry (line string) (key, val string, loc locale.Locale, err error) { + keyb := strings.Builder { } + locb := strings.Builder { } + valb := strings.Builder { } + + state := 0 + const getKey = 0 + const getLoc = 1 + const waitVal = 2 + const getVal = 3 + + for _, char := range line { + switch state { + case getKey: + switch char { + case '[': state = getLoc + case '=': state = getVal + default: keyb.WriteRune(char) + } + case waitVal: + if char == '=' { state = getVal } + case getLoc: + switch char { + case ']': state = waitVal + default: locb.WriteRune(char) + } + case getVal: + valb.WriteRune(char) + } + } + if state != getVal { err = ErrInvalidEntry; return } + + loc, err = locale.Parse(locb.String()) + if err != nil { return} + key = strings.TrimSpace(keyb.String()) + val = strings.TrimSpace(valb.String()) + + if !isKeyValid(key) { err = ErrInvalidEntry; return } + + return +} + +func isKeyValid (key string) bool { + for _, char := range key { + if char == '-' { continue } + if 'A' <= char && char <= 'Z' { continue } + if 'a' <= char && char <= 'z' { continue } + return false + } + return true +} + +// File represents a key/value file. +type File map[string] Group + +// Group represents a group of entries. +type Group map[string] Entry + +// Entry represents an entry in a group. +type Entry struct { + Value string + Localized map[locale.Locale] string +} + +// Localize returns a localized value matching the specified locale, which +// should be LC_MESSAGES for a system-localized value. +// +// The matching is done as follows. If locale is of the form +// lang_COUNTRY.ENCODING@MODIFIER, then it will match a key of the form +// lang_COUNTRY@MODIFIER. If such a key does not exist, it will attempt to match +// lang_COUNTRY followed by lang@MODIFIER. Then, a match against lang by itself +// will be attempted. Finally, if no matching key is found the required key +// without a locale specified is used. The encoding from the locale value is +// ignored when matching. +func (entry Entry) Localize (locale locale.Locale) string { + withoutEncoding := locale + withoutEncoding.Encoding = "" + value, ok := entry.Localized[withoutEncoding] + if ok { return value } + + withoutModifier := withoutEncoding + withoutModifier.Modifier = "" + value, ok = entry.Localized[withoutModifier] + if ok { return value } + + withoutCountry := withoutEncoding + withoutCountry.Country = "" + value, ok = entry.Localized[withoutCountry] + if ok { return value } + + return entry.Value +} + +// TODO have functions to parse/validate all data types