Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

chore: add miniparse pkg for future .mini config files #49

Merged
merged 12 commits into from
Nov 9, 2024
1 change: 1 addition & 0 deletions .golangci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ linters:
- varnamelen
- godox
- exportloopref
- tagalign # alignment looks awful

linters-settings:
cyclop:
Expand Down
72 changes: 72 additions & 0 deletions pkg/miniparse/miniparse.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
package miniparse

import (
"bufio"
"errors"
"io"
"reflect"
)

var (
ErrLeadingSpace = errors.New("leading spaces are not allowed")
ErrInvalidChar = errors.New("invalid leading char")
ErrExpectedNewLine = errors.New("expected new line")
ErrInvalidSection = errors.New("invalid section name")
ErrRootSection = errors.New("expected root section")
ErrInvalidKey = errors.New("invalid key name")
ErrExpectedEqual = errors.New("expected equal sign")
ErrExpectedSpace = errors.New("expected space")
ErrUnexpectedEOF = errors.New("unexpected end of file")

ErrExpectedPointer = errors.New("expected not nil pointer")
ErrExpectedStruct = errors.New("expected struct")
ErrBadSectionType = errors.New("bad section type")
ErrBadRecordType = errors.New("bad record type")
ErrExpectedArray = errors.New("expected array")
ErrRequiredField = errors.New("field marked required")
)

// Decode .mini config file into value using reflect.
// The mini format is similar to ini, but very strict.
//
// Each line of the config must be one of the following: blank, comment, record title, record field.
// Leading spaces are not allowed. End-of-line spaces are only allowed in record field lines.
// A non-empty .mini config file must end with a blank string.
// A comment must begin with a '#' character. All comments will be ignored by the parser.
// The record title must be "[title]", where title is the non-empty name of the varname.
// Varnames contain only lowercase ascii letters, digits and the '_' character.
// The first letter of the varname must not be a digit.
// The record field must have the form “key = value”, where key is a non-empty varname.
// The value contains any valid utf-8 sequence.
// Record names and keys can be non-unique. Then they will be interpreted as arrays.
//
// The mini format does not specify data types of values.
// But this decoder works only with string, int, bool and time.Duration.
// You should use `mini:"name"` tag to designate a structure field.
// You can use the `mini-required:"true"` tag for mandatory fields.
// You can use the `mini-default:"value"` tag for default values.
func Decode(r io.Reader, v any) error {
rb := bufio.NewReader(r)
m := newMachine()
nxt := m.stateInit

for {
c, _, err := rb.ReadRune()
if errors.Is(err, io.EOF) {
break
}
if err != nil {
return err
}
nxt, err = nxt(c)
if err != nil {
return err
}
}
// TODO: find more Go-like solution
if reflect.ValueOf(nxt).Pointer() != reflect.ValueOf(m.stateInit).Pointer() {
return ErrUnexpectedEOF
}

return m.feed(v)
}
246 changes: 246 additions & 0 deletions pkg/miniparse/miniparse_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,246 @@
package miniparse_test

import (
"strings"
"testing"
"time"

"github.com/Gornak40/algolymp/pkg/miniparse"
"github.com/stretchr/testify/require"
)

const coreMini = `[core]
# year is just for fun
id = avx2024
name = AVX инструкции с нуля 🦍

number_id = 2812
number_id = 1233
public = 1
contest_id = 33
magic = -1

flags = false
flags = true
flags = T

contest_id = 12312
contest_id = 9012

[standings]
id = avx2024_fall
type = acm
# visible names
public = true

[standings]
id = avx2024_aesc
type = acm
refresh_delay = 60s

[penalty]
id = avx2024_pen
ban = 1440m
ban = 72h

[penalty]
id = avx2024_aesc_pen
value = 100
`

type core struct {
ID string `mini:"id" mini-required:"true"`
Name string `mini:"name"`
NumberID []int `mini:"number_id"`
Public bool `mini:"public"`
Empty string
Flags []bool `mini:"flags"`
ContestID []string `mini:"contest_id"`
Magic int `mini:"magic"`
}

type standings struct {
ID string `mini:"id" mini-required:"true"`
Type string `mini:"type"`
Public bool `mini:"public"`
RefreshDelay time.Duration `mini:"refresh_delay"`
}

type penalty struct {
ID string `mini:"id" mini-required:"true"`
Ban []time.Duration `mini:"ban" mini-default:"3h30m"`
Value int `mini:"value" mini-default:"50"`
}

type config struct {
Core core `mini:"core"`
Standings []standings `mini:"standings"`
Penalty []penalty `mini:"penalty"`
}

func TestReflect(t *testing.T) {
t.Parallel()
var ss config

r := strings.NewReader(coreMini)
require.NoError(t, miniparse.Decode(r, &ss))
require.Equal(t, config{
Core: core{
ID: "avx2024",
Name: "AVX инструкции с нуля 🦍",
NumberID: []int{2812, 1233},
Public: true,
Flags: []bool{false, true, true},
ContestID: []string{"33", "12312", "9012"},
Magic: -1,
},
Standings: []standings{
{
ID: "avx2024_fall",
Type: "acm",
Public: true,
},
{
ID: "avx2024_aesc",
Type: "acm",
RefreshDelay: time.Minute,
},
},
Penalty: []penalty{
{
ID: "avx2024_pen",
Ban: []time.Duration{24 * time.Hour, 72 * time.Hour},
Value: 50,
},
{
ID: "avx2024_aesc_pen",
Ban: []time.Duration{210 * time.Minute},
Value: 100,
},
},
}, ss)
}

func TestCorner(t *testing.T) {
t.Parallel()
var ss struct{}
tt := []string{
"",
"[alone]\n",
"\n",
"[empty]\n[empty]\n[again]\n",
"[rune]\nkey = [section]\n[section]\nkey2 = 0\n",
}

for _, s := range tt {
r := strings.NewReader(s)
require.NoError(t, miniparse.Decode(r, &ss))
}
}

func TestParseError(t *testing.T) {
t.Parallel()
var ss struct{}
tt := map[string]error{
"[html page]\n": miniparse.ErrInvalidSection,
"[htmlPage]\n": miniparse.ErrInvalidSection,
"[1html]\n": miniparse.ErrInvalidSection,
"[html]": miniparse.ErrUnexpectedEOF,
"[html": miniparse.ErrUnexpectedEOF,
"[html]\n]": miniparse.ErrInvalidChar,
"[html]\nkey\n": miniparse.ErrInvalidKey,
"[html]\nkey=value\n": miniparse.ErrInvalidKey,
"[html]\nKEY = value\n": miniparse.ErrInvalidChar,
"[html]\n1key = value\n": miniparse.ErrInvalidChar,
"[html]\nkey= value\n": miniparse.ErrInvalidKey,
"[html]\nkey =value\n": miniparse.ErrExpectedSpace,
"[html]\nkey = value": miniparse.ErrUnexpectedEOF,
"[html]\nkey = value\n": miniparse.ErrExpectedEqual,
"[html]\nkey = value[html]\n[html]": miniparse.ErrUnexpectedEOF,
"[html] # section\n": miniparse.ErrExpectedNewLine,
"# section\n[html]\nkey = 1\nkey2\n": miniparse.ErrInvalidKey,
"[html]\nkey = \"bababababayka\nbrrry\"\n": miniparse.ErrInvalidKey,
" [html]\n": miniparse.ErrLeadingSpace,
"[html]\n key = value\n": miniparse.ErrLeadingSpace,
"[html]\n = value\n": miniparse.ErrLeadingSpace,
"key = value\n": miniparse.ErrRootSection,
}

for s, terr := range tt {
r := strings.NewReader(s)
require.ErrorIs(t, miniparse.Decode(r, &ss), terr)
}
}

func TestReflectArgError(t *testing.T) {
t.Parallel()

m := make(map[string]any)
require.ErrorIs(t, miniparse.Decode(strings.NewReader(""), m), miniparse.ErrExpectedPointer)
require.ErrorIs(t, miniparse.Decode(strings.NewReader(""), &m), miniparse.ErrExpectedStruct)
require.ErrorIs(t, miniparse.Decode(strings.NewReader(""), nil), miniparse.ErrExpectedPointer)

x := 4
require.ErrorIs(t, miniparse.Decode(strings.NewReader(""), &x), miniparse.ErrExpectedStruct)
require.ErrorIs(t, miniparse.Decode(strings.NewReader(""), "gorill"), miniparse.ErrExpectedPointer)

var ss struct{}
require.ErrorIs(t, miniparse.Decode(strings.NewReader(""), ss), miniparse.ErrExpectedPointer)
}

const htmlMini = `[html]
page_id = 14141
preload = true
# wow! a comment
page_id = 99119
name = Bob
`

func TestReflectError(t *testing.T) {
t.Parallel()
r := strings.NewReader(htmlMini)
var ss2 struct {
HTML struct {
PageID []struct {
Key string `mini:"key"`
Value string `mini:"value"`
} `mini:"page_id"`
} `mini:"html"`
}
require.ErrorIs(t, miniparse.Decode(r, &ss2), miniparse.ErrBadRecordType)

r.Reset(htmlMini)
var ss3 struct {
HTML struct {
Name string `mini:"name"`
PageID int `mini:"page_id"`
} `mini:"html"`
}
require.ErrorIs(t, miniparse.Decode(r, &ss3), miniparse.ErrExpectedArray)

r.Reset(htmlMini)
var ss4 struct {
HTML []map[string]any `mini:"html"`
}
require.ErrorIs(t, miniparse.Decode(r, &ss4), miniparse.ErrExpectedStruct)

r.Reset(htmlMini)
var ss5 struct {
HTML struct {
} `mini:"html"`
CSS []struct {
} `mini:"css" mini-required:"true"`
}
require.ErrorIs(t, miniparse.Decode(r, &ss5), miniparse.ErrRequiredField)

r.Reset(htmlMini)
var ss6 struct {
HTML struct {
Name string `mini:"name" mini-required:"true"`
ID int `mini:"id" mini-required:"true"`
} `mini:"html"`
CSS []struct {
} `mini:"css"`
}
require.ErrorIs(t, miniparse.Decode(r, &ss6), miniparse.ErrRequiredField)
}
Loading