-
Notifications
You must be signed in to change notification settings - Fork 1
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
chore: add miniparse pkg for future .mini config files (#49)
* 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
Showing
5 changed files
with
656 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
} |
Oops, something went wrong.