nasin/config/file.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
}