Add simple parsing for key/value files

This commit is contained in:
Sasha Koshka 2024-04-27 16:03:23 -04:00
parent 25edf46db3
commit aba688f2d2

218
key-value/key-value.go Normal file
View File

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