xdg/key-value/key-value.go
2024-04-28 22:59:20 -04:00

437 lines
13 KiB
Go

// 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 "fmt"
import "bufio"
import "errors"
import "strings"
import "strconv"
import "unicode"
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")
// ErrUnsupportedEscape indicates that an unsupported escape sequence
// was found.
ErrUnsupportedEscape = keyValueError("unsupported escape")
// ErrStringNotASCII indicates that a string or iconstring value was not
// found to contain valid ASCII text.
ErrStringNotASCII = keyValueError("string not ascii")
// ErrBooleanNotTrueOrFalse indicates that a boolean value was not found
// to equal "true" or "false".
ErrBooleanNotTrueOrFalse = keyValueError("boolean not true or false")
)
// 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 { }
checkGroupIntegrity := func () error {
if group != nil {
for key := range group {
if _, ok := specifiedValues[key]; !ok {
return ErrNoDefaultValue
}
}
}
return nil
}
for {
line, err := buffer.ReadString('\n')
if errors.Is(err, io.EOF) { break }
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
err := checkGroupIntegrity()
if err != nil { return File { }, err }
// 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
_, specified := specifiedValues[key]
if specified { return nil, ErrDuplicateEntry }
entry, exists := group[key]
if !exists { entry = newEntry() }
entry.Value = value
group[key] = entry
specifiedValues[key] = struct { } { }
}
}
}
err := checkGroupIntegrity()
if err != nil { return File { }, err }
return file, nil
}
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 }
if locb.Len() > 0 {
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
// String returns a string representation of the file.
func (file File) String () string {
outb := strings.Builder { }
for name, group := range file {
fmt.Fprintf(&outb, "[%s]\n", name)
for key, entry := range group {
fmt.Fprintf(&outb, "%s=%s\n", key, entry.Value)
for loc, localized := range entry.Localized {
fmt.Fprintf(&outb, "%s[%v]=%s\n", key, loc, localized)
}
}
}
out := outb.String()
if len(out) > 0 { out = out[:len(out) - 2] }
return out
}
// 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
}
// Unescape returns a new copy of this entry with its main value parsed and
// unescaped as a string, and its localized values parsed and unescaped as
// localestrings.
func (entry Entry) Unescape () (Entry, error) {
value, err := ParseString(entry.Value)
if err != nil { return Entry { }, err }
localizedValue := Entry {
Value: value,
Localized: make(map[locale.Locale] string),
}
for name, localized := range entry.Localized {
unescaped, err := ParseLocaleString(localized)
if err != nil { return Entry { }, err }
localizedValue.Localized[name] = unescaped
}
return localizedValue, nil
}
// ParseString parses a value of type string.
// Values of type string may contain all ASCII characters except for control
// characters.
// The escape sequences \s, \n, \t, \r, and \\ are supported, meaning ASCII
// space, newline, tab, carriage return, and backslash, respectively.
func ParseString (value string) (string, error) {
value, err := escapeString(value)
if err != nil { return "", err }
if !isAsciiText(value) { return "", ErrStringNotASCII }
return value, nil
}
// ParseLocaleString parses a value of type localestring.
// Values of type localestring are user displayable, and are encoded in UTF-8.
// The escape sequences \s, \n, \t, \r, and \\ are supported, meaning ASCII
// space, newline, tab, carriage return, and backslash, respectively.
func ParseLocaleString (value string) (string, error) {
value, err := escapeString(value)
if err != nil { return "", err }
return value, nil
}
// ParseIconString parses a value of type iconstring.
// Values of type iconstring are the names of icons; these may be absolute
// paths, or symbolic names for icons located using the algorithm described in
// the Icon Theme Specification. Such values are not user-displayable, and are
// encoded in UTF-8.
// The escape sequences \s, \n, \t, \r, and \\ are supported, meaning ASCII
// space, newline, tab, carriage return, and backslash, respectively.
func ParseIconString (value string) (string, error) {
value, err := escapeString(value)
if err != nil { return "", err }
if !isAsciiText(value) { return "", ErrStringNotASCII }
return value, nil
}
// ParseBoolean parses a value of type boolean.
// Values of type boolean must either be the string true or false.
func ParseBoolean (value string) (bool, error) {
if value == "true" { return true, nil }
if value == "false" { return false, nil }
return false, ErrBooleanNotTrueOrFalse
}
// ParseInteger parses a value of type integer.
// The geniuses at freedesktop never explained this type at all, or how it
// should be parsed.
func ParseInteger (value string) (int, error) {
// TODO ensure this is compliant
integer, err := strconv.ParseInt(value, 10, 64)
return int(integer), err
}
// ParseNumeric parses a value of type numeric.
// Values of type numeric must be a valid floating point number as recognized by
// the %f specifier for scanf in the C locale.
func ParseNumeric (value string) (float64, error) {
// TODO ensure this is compliant
return strconv.ParseFloat(value, 64)
}
// ParseMultiple parses multiple of a value type. Any value parsing function can
// be specified. The multiple values should be separated by a semicolon and the
// input string may be optionally terminated by a semicolon. Trailing empty
// strings must always be terminated with a semicolon. Semicolons in these
// values need to be escaped using \;.
func ParseMultiple[T any] (parser func (string) (T, error), value string) ([]T, error) {
return parseMultiple(parser, value, ';')
}
// ParseMultipleComma is like ParseMultiple, but uses a comma as a separator
// instead of a semicolon. This is used to parse icon theme files because the
// freedesktop people haven't yet learned the word "consistency".
func ParseMultipleComma[T any] (parser func (string) (T, error), value string) ([]T, error) {
return parseMultiple(parser, value, ',')
}
func parseMultiple[T any] (parser func (string) (T, error), value string, sep rune) ([]T, error) {
values := []T { }
builder := strings.Builder { }
newValue := func () error {
value, err := parser(builder.String())
if err != nil { return err }
values = append(values, value)
builder.Reset()
return nil
}
backslash := false
for _, char := range value {
switch char {
case '\\':
backslash = true
case sep:
if backslash {
builder.WriteRune(sep)
backslash = false
} else {
err := newValue()
if err != nil { return nil, err }
}
default:
if backslash {
builder.WriteRune('\\')
backslash = false
}
builder.WriteRune(char)
}
}
if backslash {
builder.WriteRune('\\')
}
if builder.Len() > 0 {
err := newValue()
if err != nil { return nil, err }
}
return values, nil
}
func isAsciiText (value string) bool {
for _, char := range value {
// must be an ascii character that isn't a control character.
if char <= 0x1F || char > unicode.MaxASCII {
return false
}
}
return true
}
func escapeString (value string) (string, error) {
builder := strings.Builder { }
backslash := false
for _, char := range value {
if char == '\\' { backslash = true; continue }
if backslash {
switch char {
case 's': builder.WriteRune(' ')
case 'n': builder.WriteRune('\n')
case 't': builder.WriteRune('\t')
case 'r': builder.WriteRune('\r')
case '\\': builder.WriteRune('\\')
default: return "", ErrUnsupportedEscape
}
} else {
builder.WriteRune(char)
}
}
return builder.String(), nil
}