269 lines
7.5 KiB
Go
269 lines
7.5 KiB
Go
package config
|
|
|
|
import "io"
|
|
import "fmt"
|
|
import "math"
|
|
import "bufio"
|
|
import "errors"
|
|
import "strconv"
|
|
import "strings"
|
|
import "unicode"
|
|
|
|
type line any
|
|
type comment string
|
|
type entry struct { key string; value Value }
|
|
|
|
// File represents a config file. It preserves the order of the lines, as well
|
|
// as blank lines and comments.
|
|
type File struct {
|
|
lines []line
|
|
keys map[string] int
|
|
}
|
|
|
|
// NewFile creates a blank file with nothing in it.
|
|
func NewFile () *File {
|
|
return &File {
|
|
keys: make(map[string] int),
|
|
}
|
|
}
|
|
|
|
// Parse parses a config file from a reader. This function operates on a
|
|
// best-effort basis: A file will always be returned, and any errors encountered
|
|
// will be joined together.
|
|
//
|
|
// The general format of the file is as follows:
|
|
// - Encoded in UTF-8
|
|
// - Consists of lines, separated by \n, or \r\n
|
|
// - Lines can be any of these:
|
|
// - Blank line: has only whitespace
|
|
// - Comment: begins with a '#'
|
|
// - Entry: a key/value pair separated by an '=' sign
|
|
//
|
|
// For entries, all whitespace on either side of the '=' sign, the key, or the
|
|
// value is ignored. The key may contain any letter or digit, as well as '-'
|
|
// and '.'. The value is always identified by its first rune (after the
|
|
// preliminary whitespace of course) and can be one of:
|
|
// - String: either a double-quoted string, or any string of runes not
|
|
// identifiable as any other kind of value. The quoted string is always
|
|
// unquoted when it is read. Either way, these escape sequences are
|
|
// supported, and resolved when they are read:
|
|
// - '\\': a literal backslash
|
|
// - '\a': alert, bell
|
|
// - '\b': backspace
|
|
// - '\t': horizontal tab
|
|
// - '\n': line feed
|
|
// - '\v': vertical tab
|
|
// - '\f': form feed
|
|
// - '\r': carriage return
|
|
// - '\"': double quote
|
|
// - Number: a floating point value. It can be of the form:
|
|
// - Inf
|
|
// - -Inf
|
|
// - NaN
|
|
// - [0-9]+
|
|
// - [0-9]+\.[0-9]*
|
|
// - Bool: a boolean value. It can be one of:
|
|
// - true
|
|
// - false
|
|
//
|
|
// Be aware that some unquoted strings, within reason, are subject to being read
|
|
// as some other value in the future. For example, if there were suddenly a
|
|
// third boolean value called glorble, the unquoted string glorble would be read
|
|
// as a boolean value instead of a string.
|
|
func Parse (reader io.Reader) (*File, error) {
|
|
file := &File {
|
|
keys: make(map[string] int),
|
|
}
|
|
errs := []error { }
|
|
scanner := bufio.NewScanner(reader)
|
|
|
|
for scanner.Scan() {
|
|
text := strings.TrimSpace(scanner.Text())
|
|
switch {
|
|
case text == "", strings.HasPrefix(text, "#"):
|
|
file.lines = append(file.lines, comment(text))
|
|
default:
|
|
entry, err := parseEntry(text)
|
|
if err == nil {
|
|
file.keys[entry.key] = len(file.lines)
|
|
file.lines = append(file.lines, entry)
|
|
} else {
|
|
errs = append (errs, err )
|
|
}
|
|
}
|
|
}
|
|
errs = append(errs, scanner.Err())
|
|
return file, errors.Join(errs...)
|
|
}
|
|
|
|
func parseEntry (str string) (entry, error) {
|
|
key, value, ok := strings.Cut(str, "=")
|
|
if !ok { return entry { }, ErrMalformedEntry }
|
|
|
|
key = strings.TrimSpace(key)
|
|
if !KeyValid(key) { return entry { }, ErrMalformedKey }
|
|
|
|
value = strings.TrimSpace(value)
|
|
parsedValue, err := ParseValue(value)
|
|
if err != nil { return entry { }, err }
|
|
|
|
return entry { key: key, value: parsedValue }, nil
|
|
}
|
|
|
|
// ParseValue parses a value from a string. For any Value v,
|
|
// ParseValue(v.String()) should hold data exactly equal to v. This function
|
|
// does not trim whitespace.
|
|
func ParseValue (str string) (Value, error) {
|
|
first := firstRune(str)
|
|
switch {
|
|
case str == "":
|
|
return ValueString(""), nil
|
|
case first == '"':
|
|
value, ok := unescape(str)
|
|
if !ok { return nil, ErrMalformedEscapeSequence }
|
|
value, ok = unquote(value)
|
|
if !ok { return nil, ErrMalformedStringValue }
|
|
return ValueString(value), nil
|
|
case first == '-' || (first >= '0' && first <= '9'):
|
|
value, err := strconv.ParseFloat(str, 64)
|
|
if err != nil { return nil, ErrMalformedNumberValue }
|
|
return ValueNumber(value), nil
|
|
case str == "false":
|
|
return ValueBool(false), nil
|
|
case str == "true":
|
|
return ValueBool(true), nil
|
|
case str == "Inf":
|
|
return ValueNumber(math.Inf(1)), nil
|
|
case str == "NaN":
|
|
return ValueNumber(math.NaN()), nil
|
|
default:
|
|
value, ok := unescape(str)
|
|
if !ok { return nil, ErrMalformedEscapeSequence }
|
|
return ValueString(value), nil
|
|
}
|
|
}
|
|
|
|
// Has returns whether the key exists. If the key is invalid, it returns false,
|
|
// ErrMalformedKey.
|
|
func (this *File) Has (key string) (bool, error) {
|
|
if !KeyValid(key) { return false, ErrMalformedKey }
|
|
_, ok := this.keys[key]
|
|
return ok, nil
|
|
}
|
|
|
|
// Get gets the keyed value. If the value is unspecified, it returns nil,
|
|
// ErrNonexistentEntry. If the key is invalid, it returns nil, ErrMalformedKey.
|
|
func (this *File) Get (key string) (Value, error) {
|
|
if !KeyValid(key) { return nil, ErrMalformedKey }
|
|
if index, ok := this.keys[key]; ok {
|
|
if lin, ok := this.lines[index].(entry); ok {
|
|
return lin.value, nil
|
|
}
|
|
}
|
|
return nil, ErrNonexistentEntry
|
|
}
|
|
|
|
// Set sets a value. If the key is invalid, it returns ErrMalformedKey.
|
|
func (this *File) Set (key string, value Value) error {
|
|
if !KeyValid(key) { return ErrMalformedKey }
|
|
ent := entry {
|
|
key: key,
|
|
value: value,
|
|
}
|
|
if index, ok := this.keys[key]; ok {
|
|
this.lines[index] = ent
|
|
return nil
|
|
}
|
|
this.keys[key] = len(this.lines)
|
|
this.lines = append(this.lines, ent)
|
|
return nil
|
|
}
|
|
|
|
// Reset removes the value from the file. If the value is set again, it will be
|
|
// added back at the same location. Note that because of this, the positions of
|
|
// lines are not forgotten until the file is written and reloaded. This is why
|
|
// the method is called Reset and not Remove. If the key is invalid, it returns
|
|
// ErrMalformedKey.
|
|
func (this *File) Reset (key string) error {
|
|
if !KeyValid(key) { return ErrMalformedKey }
|
|
for index, lin := range this.lines {
|
|
if lin, ok := lin.(entry); ok {
|
|
if lin.key == key {
|
|
this.lines[index] = nil
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Map creates and returns a map of keys to values.
|
|
func (this *File) Map () map[string] Value {
|
|
mp := make(map[string] Value)
|
|
for key, index := range this.keys {
|
|
if lin, ok := this.lines[index].(entry); ok {
|
|
mp[key] = lin.value
|
|
}
|
|
}
|
|
return mp
|
|
}
|
|
|
|
// Diff returns a set of keys that are different from the other file.
|
|
func (this *File) Diff (other *File) map[string] struct { } {
|
|
diff := make(map[string] struct { })
|
|
|
|
// - keys only we have
|
|
// - keys we both have, but are different
|
|
for key, index := range this.keys {
|
|
otherIndex, ok := other.keys[key]
|
|
if !ok {
|
|
diff[key] = struct { } { }
|
|
continue
|
|
}
|
|
if !this.lines[index].(entry).value.Equals(other.lines[otherIndex].(entry).value) {
|
|
diff[key] = struct { } { }
|
|
}
|
|
}
|
|
|
|
// - keys only they have
|
|
for key := range other.keys {
|
|
if _, has := this.keys[key]; !has {
|
|
diff[key] = struct { } { }
|
|
}
|
|
}
|
|
|
|
return diff
|
|
}
|
|
|
|
// WriteTo writes the data in this file to an io.Writer.
|
|
func (file *File) WriteTo (writer io.Writer) (n int64, err error) {
|
|
for _, lin := range file.lines {
|
|
nint := 0
|
|
switch lin := lin.(type) {
|
|
case comment:
|
|
nint, err = fmt.Fprintln(writer, string(lin))
|
|
case entry:
|
|
nint, err = fmt.Fprintf(writer, "%s=%v\n", lin.key, lin.value)
|
|
}
|
|
n += int64(nint)
|
|
if err != nil { return n, err }
|
|
}
|
|
return n, nil
|
|
}
|
|
|
|
// KeyValid returns whether a key contains only valid runes. They are:
|
|
// - Letters
|
|
// - Digits
|
|
// - '-'
|
|
// - '.'
|
|
func KeyValid (key string) bool {
|
|
for _, char := range key {
|
|
valid :=
|
|
char == '.' ||
|
|
char == '-' ||
|
|
unicode.IsLetter(char) ||
|
|
unicode.IsDigit(char)
|
|
if !valid { return false }
|
|
}
|
|
return true
|
|
}
|