2024-04-27 14:03:23 -06:00
|
|
|
// 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"
|
2024-04-27 17:43:26 -06:00
|
|
|
import "fmt"
|
2024-04-27 14:03:23 -06:00
|
|
|
import "bufio"
|
2024-04-27 18:16:46 -06:00
|
|
|
import "errors"
|
2024-04-27 14:03:23 -06:00
|
|
|
import "strings"
|
2024-04-27 15:07:41 -06:00
|
|
|
import "strconv"
|
|
|
|
import "unicode"
|
2024-04-27 14:03:23 -06:00
|
|
|
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")
|
2024-04-27 15:07:41 -06:00
|
|
|
// 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")
|
2024-04-27 14:03:23 -06:00
|
|
|
)
|
|
|
|
|
|
|
|
// 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 { }
|
2024-04-28 17:34:59 -06:00
|
|
|
|
|
|
|
checkGroupIntegrity := func () error {
|
|
|
|
if group != nil {
|
|
|
|
for key := range group {
|
|
|
|
if _, ok := specifiedValues[key]; !ok {
|
|
|
|
return ErrNoDefaultValue
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
2024-04-27 14:03:23 -06:00
|
|
|
for {
|
|
|
|
line, err := buffer.ReadString('\n')
|
2024-04-28 17:27:07 -06:00
|
|
|
if errors.Is(err, io.EOF) { break }
|
2024-04-27 18:16:46 -06:00
|
|
|
if err != nil { return nil, err }
|
|
|
|
|
2024-04-27 14:03:23 -06:00
|
|
|
line = strings.TrimSpace(line)
|
|
|
|
|
|
|
|
switch {
|
|
|
|
// comment
|
|
|
|
case line == "", strings.HasPrefix(line, "#"):
|
|
|
|
|
|
|
|
// group header
|
|
|
|
case strings.HasPrefix(line, "["):
|
|
|
|
// check integrity of prev. group
|
2024-04-28 17:34:59 -06:00
|
|
|
err := checkGroupIntegrity()
|
|
|
|
if err != nil { return File { }, err }
|
2024-04-27 14:03:23 -06:00
|
|
|
|
|
|
|
// 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
|
2024-04-28 17:27:07 -06:00
|
|
|
_, specified := specifiedValues[key]
|
|
|
|
if specified { return nil, ErrDuplicateEntry }
|
|
|
|
|
|
|
|
entry, exists := group[key]
|
|
|
|
if !exists { entry = newEntry() }
|
|
|
|
|
2024-04-27 14:03:23 -06:00
|
|
|
entry.Value = value
|
|
|
|
group[key] = entry
|
|
|
|
specifiedValues[key] = struct { } { }
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
2024-04-28 17:27:07 -06:00
|
|
|
|
2024-04-28 17:34:59 -06:00
|
|
|
err := checkGroupIntegrity()
|
|
|
|
if err != nil { return File { }, err }
|
|
|
|
|
2024-04-28 17:27:07 -06:00
|
|
|
return file, nil
|
2024-04-27 14:03:23 -06:00
|
|
|
}
|
|
|
|
|
|
|
|
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 }
|
|
|
|
|
2024-04-27 18:16:46 -06:00
|
|
|
if locb.Len() > 0 {
|
|
|
|
loc, err = locale.Parse(locb.String())
|
|
|
|
if err != nil { return}
|
|
|
|
}
|
2024-04-27 14:03:23 -06:00
|
|
|
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
|
|
|
|
|
2024-04-27 17:43:26 -06:00
|
|
|
// 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
|
|
|
|
}
|
|
|
|
|
2024-04-27 14:03:23 -06:00
|
|
|
// 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
|
|
|
|
}
|
|
|
|
|
2024-04-28 10:46:22 -06:00
|
|
|
// 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
|
|
|
|
}
|
|
|
|
|
2024-04-27 14:03:23 -06:00
|
|
|
// TODO have functions to parse/validate all data types
|
2024-04-27 15:07:41 -06:00
|
|
|
|
|
|
|
// 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) {
|
2024-04-28 10:46:22 -06:00
|
|
|
value, err := escapeString(value)
|
|
|
|
if err != nil { return "", err }
|
2024-04-27 15:07:41 -06:00
|
|
|
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
|
|
|
|
}
|
|
|
|
|
2024-04-28 15:42:19 -06:00
|
|
|
// 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
|
|
|
|
}
|
|
|
|
|
2024-04-27 15:07:41 -06:00
|
|
|
// 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) {
|
2024-04-28 15:42:19 -06:00
|
|
|
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) {
|
2024-04-27 15:07:41 -06:00
|
|
|
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
|
2024-04-28 15:42:19 -06:00
|
|
|
case sep:
|
2024-04-27 15:07:41 -06:00
|
|
|
if backslash {
|
2024-04-28 15:42:19 -06:00
|
|
|
builder.WriteRune(sep)
|
2024-04-27 15:07:41 -06:00
|
|
|
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
|
|
|
|
}
|