Skip to content

Commit

Permalink
chore: add miniparse pkg for future .mini config files (#49)
Browse files Browse the repository at this point in the history
* chore: add miniparse pkg for future .mini config files

* chore: refactor to func state machine

* chore: support reflection for one struct

* test: add parse errors test

* test: more corner cases; fix: panic without root section

* chore: minor reflect patch

* test: add reflection tests

* feat: support section arrays; test: more reflection

* feat: add mini-required tag

* feat: add duration reflect type

* feat: add mini-default tag

* docs: add docs to Decode func
  • Loading branch information
Gornak40 authored Nov 9, 2024
1 parent 1eb6583 commit 40a924a
Show file tree
Hide file tree
Showing 5 changed files with 656 additions and 0 deletions.
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

0 comments on commit 40a924a

Please sign in to comment.