From e084ac2b84fac3b5ae63de15080b863d31f5b2fc Mon Sep 17 00:00:00 2001 From: Rose Hogenson Date: Tue, 28 Oct 2025 20:32:44 -0700 Subject: Rename files to ccl --- asspb.go | 743 ---------------------------------------------------------- asspb_test.go | 610 ----------------------------------------------- ccl.go | 743 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ ccl_test.go | 610 +++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 1353 insertions(+), 1353 deletions(-) delete mode 100644 asspb.go delete mode 100644 asspb_test.go create mode 100644 ccl.go create mode 100644 ccl_test.go diff --git a/asspb.go b/asspb.go deleted file mode 100644 index 926d78b..0000000 --- a/asspb.go +++ /dev/null @@ -1,743 +0,0 @@ -// Me: Mom can we have textproto? -// Mom: no we have textproto at home -// textproto at home: -// -// The asspb language has similar semantics to JSON, the only exception being -// the lack of null. -// -// # Comments -// -// There are two types of comments, line comments and C-style comments. Line -// comments are written with # or //, and extend from there to the end of the -// line. C-style comments are written with /* and */, and like C they may not -// be nested. -// -// # Comments are important -// // in a configuration language -// /* what do I know */ -// -// # Numbers -// -// Numbers are written in base 10 and can optionally have a fractional part or -// an exponent written with "e" or "E". As a special case, a number prefixed -// with "0x" or "0X" can be written in base 16. -// -// 100 -// -30 -// 0xabc -// -0xdef -// 13.5 -// 1e100 -// -// Leading zeros are not permitted in decimal numbers, due to potential -// confusion with octal (which is not supported). -// -// As a lexical matter, numbers must be separated from subsequent field names by -// intervening whitespace or comments: -// -// # invalid -// field1:10field2:20 -// # ok -// field1:10 field2:20 -// -// # Strings -// -// Strings are written with " or ' and any sequence of intermediate bytes (with -// the exception of escape sequences which are described below). Strings must be -// valid UTF-8 after escape sequences are expanded. -// -// 'asdf' -// "that's cool" -// "\tall\n\tyour\n\tfavorite\n\tescape\n\tsequences" -// -// Note that strings can contain newline without needing an escape sequence -// -// 'a multiline -// string' -// -// Backslash characters inside a string are interpreted as an escape sequence. -// Any escape sequence not described below is an error. The escape sequences -// are identical to C11, with the exception that \x always takes exactly 2 -// hex characters. -// -// \' single quote 0x27 -// \" double quote 0x22 -// \? question mark 0x3f (why is this in C) -// \\ backslash 0x5c -// \a bell 0x07 -// \b backspace 0x07 -// \f form feed 0x0c -// \n newline 0x0a -// \r carriage return 0x0d -// \t tab 0x09 -// \v vertical tab 0x0b -// -// \nnn 3-digit octal value nnn -// \xnn 2-digit hex value nn -// \unnnn unicode code point U+nnnn -// \Unnnnnnnn unicode code point U+nnnnnnnn (UTF8) -// -// As an extension to the C11 escapes, a backslash immediately before a newline -// character (0x0a) will remove the newline character from the resulting string -// (and for you Microsoft Windows users, backslash followed by \r\n is -// also removed) -// -// 'backslash also can \ -// remove newlines' -// # equivalent to -// 'backslash also can remove newlines' -// -// If multiple string literals are written next to each other with only -// whitespace or comments in between, the result is to concatenate the strings -// -// 'multiple strings' " concatenated" -// # equivalent to -// 'multiple strings concatenated' -// -// # Bool -// -// Bool values can be true or false (classic), and are written using one of the -// below strings. -// -// true -// yes -// on -// -// false -// no -// off -// -// # Lists -// -// Lists are written with square brackets and elements are separated by comma. -// -// [1, 2, 3] -// [{nested: "messages"}, {are: "also"}, {allowed: yes}] -// -// Trailing comma is allowed -// -// [ -// "suck", -// "it", -// "JSON", -// ] -// -// # Messages -// -// Messages are an unordered set of key-value pairs: -// -// {key1: "value1" key2: "value2"} -// -// Keys can be alphanumeric or use underscore; no other characters are -// permitted. Values can be any of the value types here described. Key-value -// pairs must be written with a : between the key and value, except when the -// value is syntactically a message (in that case the colon is optional) -// -// { -// key1: "value1" -// key2 {} -// } -// -// As a special case, when a key is written more than once in a message, it's -// treated the same as if the values had been written in a list. If some of the -// values are already lists, they are appended, preserving the order in which -// the values appear in the input file. -// -// { -// key: [1, 2] -// key: 3 -// key: [4, 5, 6] -// } -// # equivalent to -// { -// key: [1, 2, 3, 4, 5, 6] -// } -// -// # Disclaimer -// -// This package is still experimental, expect breaking changes. -package asspb - -import ( - "bytes" - "encoding" - "encoding/base64" - "errors" - "fmt" - "iter" - "math" - "reflect" - "regexp" - "strconv" - "strings" - "unicode/utf8" -) - -type syntaxError struct { - line, col int - reason string -} - -func newSyntaxError(data []byte, idx int, reason string, args ...any) error { - line, col := 1, 1 - for _, b := range data[:idx] { - if b == '\n' { - line++ - col = 1 - } else { - col++ - } - } - return &syntaxError{line, col, fmt.Sprintf(reason, args...)} -} - -func (e *syntaxError) Error() string { - return fmt.Sprintf("%d:%d syntax error: %s", e.line, e.col, e.reason) -} - -type structField struct { - ty reflect.Type - name string -} - -func fieldMap(out map[structField]int, types map[reflect.Type]bool, s reflect.Type) error { - if types[s] { - // Already processed - return nil - } - types[s] = true - for i := range s.NumField() { - field := s.Field(i) - if !field.IsExported() { - continue - } - fieldName := field.Name - if tag, ok := field.Tag.Lookup("ccl"); ok { - var opts string - fieldName, opts, _ = strings.Cut(tag, ",") - if fieldName == "-" { - continue - } - for opt := range strings.FieldsFuncSeq(opts, func(r rune) bool { return r == ',' }) { - return fmt.Errorf("unknown option %q", opt) - } - } - if _, ok := out[structField{s, fieldName}]; ok { - return fmt.Errorf("multiple fields with name %q", fieldName) - } - out[structField{s, fieldName}] = i - if field.Type.Kind() == reflect.Struct { - if err := fieldMap(out, types, field.Type); err != nil { - return err - } - } else if (field.Type.Kind() == reflect.Pointer || field.Type.Kind() == reflect.Slice) && field.Type.Elem().Kind() == reflect.Struct { - if err := fieldMap(out, types, field.Type.Elem()); err != nil { - return err - } - } else if field.Type.Kind() == reflect.Slice && field.Type.Elem().Kind() == reflect.Pointer && field.Type.Elem().Elem().Kind() == reflect.Struct { - if err := fieldMap(out, types, field.Type.Elem().Elem()); err != nil { - return err - } - } - } - return nil -} - -type parser struct { - nextTok func() (token, error, bool) - tok []byte - err error - data []byte - i int -} - -func (p *parser) error(reason string, args ...any) error { - return newSyntaxError(p.data, p.i, reason, args...) -} - -var errEOF = errors.New("premature EOF") - -func (p *parser) peek() ([]byte, error) { - if p.err != nil || p.tok != nil { - return p.tok, p.err - } - tok, err, ok := p.nextTok() - if !ok { - p.err = errEOF - return nil, p.err - } - if err != nil { - p.err = err - return nil, p.err - } - p.tok = tok.b - p.i = tok.i - return p.tok, nil -} - -func (p *parser) next() ([]byte, error) { - tok, err := p.peek() - if err != nil { - return nil, err - } - p.tok = nil - return tok, nil -} - -var ( - numRE = regexp.MustCompile(`^[-+]?(0[xX][0-9a-fA-F]+|((0|[1-9][0-9]*)(\.[0-9]*)?|\.[0-9]+)([eE][-+]?[0-9]+)?)$`) - hexRE = regexp.MustCompile(`^([-+]?)0[xX]`) -) - -func (p *parser) parseNum(numBytes []byte) (any, error) { - if !numRE.Match(numBytes) { - return nil, p.error("invalid number") - } - if hex := hexRE.FindSubmatch(numBytes); hex != nil { - if string(hex[1]) == "-" { - n, err := strconv.ParseInt(string(numBytes[len(hex[0]):]), 16, 64) - if err != nil { - return nil, p.error("invalid number") - } - return -n, nil - } else { - n, err := strconv.ParseUint(string(numBytes[len(hex[0]):]), 16, 64) - if err != nil { - return nil, p.error("invalid number") - } - return n, nil - } - } - if bytes.ContainsAny(numBytes, ".eE") { - n, err := strconv.ParseFloat(string(numBytes), 64) - if err != nil { - return nil, p.error("invalid number") - } - return n, nil - } - if bytes.HasPrefix(numBytes, []byte("-")) { - n, err := strconv.ParseInt(string(numBytes), 10, 64) - if err != nil { - return nil, p.error("invalid number") - } - return n, nil - } else { - n, err := strconv.ParseUint(string(bytes.TrimPrefix(numBytes, []byte("+"))), 10, 64) - if err != nil { - return nil, p.error("invalid number") - } - return n, nil - } -} - -var escapesRE = regexp.MustCompile(`(?s)\\(.|\r\n|[0-7]{3}|x[0-9a-fA-F]{2}|u[0-9a-fA-F]{4}|U[0-9a-fA-F]{8})`) - -func init() { - escapesRE.Longest() -} - -func (p *parser) unescape(rawStr []byte) ([]byte, error) { - var err error - escaped := escapesRE.ReplaceAllFunc(rawStr, func(escape []byte) []byte { - switch string(escape) { - case `\'`: - return []byte("'") - case `\"`: - return []byte(`"`) - case `\?`: - return []byte("?") - case `\\`: - return []byte(`\`) - case `\a`: - return []byte("\a") - case `\b`: - return []byte("\b") - case `\f`: - return []byte("\f") - case `\n`: - return []byte("\n") - case `\r`: - return []byte("\r") - case `\t`: - return []byte("\t") - case `\v`: - return []byte("\v") - case "\\\n", "\\\r\n": - return nil - } - switch { - case bytes.HasPrefix(escape, []byte(`\x`)): - var n uint64 - if n, err = strconv.ParseUint(string(escape[2:]), 16, 8); err != nil { - err = p.error("invalid hex escape %q: %s", escape, err) - return nil - } - return []byte{byte(n)} - case bytes.HasPrefix(escape, []byte(`\u`)), bytes.HasPrefix(escape, []byte(`\U`)): - var n int64 - if n, err = strconv.ParseInt(string(escape[2:]), 16, 32); err != nil { - err = p.error("invalid unicode escape %q: %s", escape, err) - return nil - } - return utf8.AppendRune(nil, rune(n)) - default: - if len(escape) != 4 { - err = p.error("invalid string escape %q", escape) - return nil - } - var n int64 - if n, err = strconv.ParseInt(string(escape[1:]), 8, 32); err != nil { - err = p.error("invalid string escape %q", escape) - return nil - } - if n > 255 { - err = p.error("invalid octal escape %q %d > 255", escape, n) - return nil - } - return []byte{byte(n)} - } - }) - if err != nil { - return nil, err - } - if !utf8.Valid(escaped) { - return nil, p.error("syntax error: string %q is not UTF-8 encoded", escaped) - } - return escaped, nil -} - -func (p *parser) parseString(tok []byte) (string, error) { - s := new(strings.Builder) - for { - ss, err := p.unescape(tok[1 : len(tok)-1]) - if err != nil { - return "", err - } - s.Write(ss) - nextTok, err := p.peek() - if err != nil || nextTok[0] != '\'' && nextTok[0] != '"' { - return s.String(), nil - } - p.next() - tok = nextTok - } -} - -func (p *parser) parseMessage() (map[string][]any, error) { - m := make(map[string][]any) - for { - tok, err := p.next() - if err != nil || tok[0] == '}' { - return m, err - } - if err := p.parseFieldVal(m, tok); err != nil { - return nil, err - } - } -} - -func (p *parser) parseVal(tok []byte) ([]any, error) { - switch tok[0] { - case '{': - m, err := p.parseMessage() - if err != nil { - return nil, err - } - return []any{m}, nil - case '[': - return p.parseList() - case '\'', '"': - s, err := p.parseString(tok) - if err != nil { - return nil, err - } - return []any{s}, nil - default: - switch string(tok) { - case "true", "yes", "on": - return []any{true}, nil - case "false", "no", "off": - return []any{false}, nil - default: - n, err := p.parseNum(tok) - if err != nil { - return nil, err - } - return []any{n}, nil - } - } -} - -func (p *parser) parseList() ([]any, error) { - var l []any - for i := 0; ; i++ { - tok, err := p.next() - if err != nil || tok[0] == ']' { - return l, err - } - if i > 0 { - if tok[0] != ',' { - return nil, p.error("expecting comma") - } - tok, err = p.next() - if err != nil || tok[0] == ']' { // allow trailing comma - return l, err - } - } - vs, err := p.parseVal(tok) - if err != nil { - return nil, err - } - l = append(l, vs...) - } -} - -func (p *parser) parseFieldVal(m map[string][]any, field []byte) error { - if b := field[0]; !(b == '_' || 'a' <= b && b <= 'z' || 'A' <= b && b <= 'Z') { - return p.error("expecting field") - } - tok, err := p.next() - if err != nil { - return err - } - switch tok[0] { - case '{': - vs, err := p.parseVal(tok) - if err != nil { - return err - } - m[string(field)] = append(m[string(field)], vs...) - case ':': - tok, err := p.next() - if err != nil { - return err - } - vs, err := p.parseVal(tok) - if err != nil { - return err - } - m[string(field)] = append(m[string(field)], vs...) - default: - return p.error("expecting colon") - } - return nil -} - -func (p *parser) parse() (map[string][]any, error) { - m := make(map[string][]any) - for { - tok, err := p.next() - if err != nil { - if err == errEOF { - return m, nil - } - return nil, err - } - if err := p.parseFieldVal(m, tok); err != nil { - return nil, err - } - } -} - -func intLimits(kind reflect.Kind) (min int64, max uint64, ok bool) { - switch kind { - case reflect.Int: - return math.MinInt, math.MaxInt, true - case reflect.Int8: - return math.MinInt8, math.MaxInt8, true - case reflect.Int16: - return math.MinInt16, math.MaxInt16, true - case reflect.Int32: - return math.MinInt32, math.MaxInt32, true - case reflect.Int64: - return math.MinInt64, math.MaxInt64, true - case reflect.Uint: - return 0, math.MaxUint, true - case reflect.Uint8: - return 0, math.MaxUint8, true - case reflect.Uint16: - return 0, math.MaxUint16, true - case reflect.Uint32: - return 0, math.MaxUint32, true - case reflect.Uint64: - return 0, math.MaxUint64, true - default: - return 0, 0, false - } -} - -func unpackVal(fieldVal reflect.Value, fieldMap map[structField]int, val any, field string) error { - switch val := val.(type) { - case bool: - switch fieldVal.Kind() { - case reflect.Bool: - fieldVal.SetBool(val) - case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: - if val { - fieldVal.SetInt(1) - } else { - fieldVal.SetInt(0) - } - case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: - if val { - fieldVal.SetUint(1) - } else { - fieldVal.SetUint(0) - } - default: - return fmt.Errorf("field %q should have type bool", field) - } - case uint64: - switch fieldVal.Kind() { - case reflect.Float32, reflect.Float64: - fieldVal.SetFloat(float64(val)) - return nil - } - min, max, ok := intLimits(fieldVal.Kind()) - if !ok { - return fmt.Errorf("field %q should have type int", field) - } - if val > max { - return fmt.Errorf("number %d is out of range for %s", val, fieldVal.Kind()) - } - if min == 0 { // unsigned - fieldVal.SetUint(val) - } else { - fieldVal.SetInt(int64(val)) - } - case int64: - switch fieldVal.Kind() { - case reflect.Float32, reflect.Float64: - fieldVal.SetFloat(float64(val)) - return nil - } - min, max, ok := intLimits(fieldVal.Kind()) - if !ok { - return fmt.Errorf("field %q should have type int", field) - } - if val < min || val > 0 && uint64(val) > max { - return fmt.Errorf("number %d is out of range for %s", val, fieldVal.Kind()) - } - if min == 0 { // unsigned - fieldVal.SetUint(uint64(val)) - } else { - fieldVal.SetInt(val) - } - case float64: - switch fieldVal.Kind() { - case reflect.Float32, reflect.Float64: - fieldVal.SetFloat(float64(val)) - default: - return fmt.Errorf("field %q should have type float64 or float32", field) - } - case string: - if _, ok := fieldVal.Interface().(encoding.TextUnmarshaler); ok { - if fieldVal.Kind() == reflect.Pointer && fieldVal.IsNil() { - fieldVal.Set(reflect.New(fieldVal.Type().Elem())) - } - return fieldVal.Interface().(encoding.TextUnmarshaler).UnmarshalText([]byte(val)) - } - if unmarshaler, ok := fieldVal.Addr().Interface().(encoding.TextUnmarshaler); ok { - return unmarshaler.UnmarshalText([]byte(val)) - } - switch { - case fieldVal.Kind() == reflect.String: - fieldVal.SetString(val) - case fieldVal.Kind() == reflect.Slice && fieldVal.Type().Elem() == reflect.TypeFor[byte](): - b, err := base64.StdEncoding.DecodeString(val) - if err != nil { - return fmt.Errorf("field %q: bad base64", field) - } - fieldVal.Set(reflect.ValueOf(b)) - default: - return fmt.Errorf("field %q should have type string (got %s)", field, fieldVal.Type()) - } - case map[string][]any: - if !(fieldVal.Kind() == reflect.Struct || fieldVal.Kind() == reflect.Pointer && fieldVal.Type().Elem().Kind() == reflect.Struct) { - return fmt.Errorf("field %q should have type struct (got %s)", field, fieldVal.Type()) - } - if fieldVal.Kind() == reflect.Pointer { - if fieldVal.IsNil() { - fieldVal.Set(reflect.New(fieldVal.Type().Elem())) - } - fieldVal = fieldVal.Elem() - } - if err := unpackStruct(fieldVal, fieldMap, val); err != nil { - return err - } - } - return nil -} - -func unpackStruct(out reflect.Value, fieldMap map[structField]int, msg map[string][]any) error { - for field, vals := range msg { - fieldIdx, ok := fieldMap[structField{out.Type(), field}] - if !ok { - return fmt.Errorf("no field named %q", field) - } - fieldVal := out.Field(fieldIdx) - if fieldVal.Kind() == reflect.Slice && fieldVal.Type().Elem() != reflect.TypeFor[byte]() { - l := reflect.MakeSlice(fieldVal.Type(), len(vals), len(vals)) - for i, val := range vals { - if err := unpackVal(l.Index(i), fieldMap, val, field); err != nil { - return err - } - } - if fieldVal.IsNil() { - fieldVal.Set(reflect.MakeSlice(fieldVal.Type(), 0, 0)) - } - fieldVal.Set(reflect.AppendSlice(fieldVal, l)) - continue - } - if len(vals) != 1 { - return fmt.Errorf("field %q is not repeated", field) - } - if err := unpackVal(fieldVal, fieldMap, vals[0], field); err != nil { - return err - } - } - return nil -} - -// Unmarshal parses a asspb message and writes the result into v. v must be a -// non-nil pointer to a struct. -// -// Unmarshal accepts a top-level message, which is equivalent to the "message" -// type described above, but without the surrounding braces. For example: -// -// key1: "val1" -// key2: "val2" -// -// The exact semantics of which asspb types map to which Go types is a bit -// complicated and I don't feel like writing out all the rules, so suffice it -// to say that the usual stuff should work. As a special case, a []byte field -// expects a base64-encoded string. -// -// You can override a field's name using a struct tag "ccl", for example -// -// type message struct { -// MyField int `ccl:"my_field"` -// } -// -// This message could decode, for example `my_field:5` -// -// If a field has type T where T or *T implements [encoding.TextUnmarshaler], -// then a string value will be decoded by calling UnmarshalText. No other -// customization is supported, this isn't encoding/json. -func Unmarshal(data []byte, v any) error { - val := reflect.ValueOf(v) - if val.Kind() != reflect.Pointer || val.IsNil() || val.Type().Elem().Kind() != reflect.Struct { - return fmt.Errorf("value must be a non-nil pointer to a struct") - } - fields := make(map[structField]int) - if err := fieldMap(fields, make(map[reflect.Type]bool), val.Type().Elem()); err != nil { - return err - } - nextToken, stop := iter.Pull2(tokens(data)) - defer stop() - msg, err := (&parser{nextTok: nextToken, data: data}).parse() - if err != nil { - return err - } - return unpackStruct(val.Elem(), fields, msg) -} diff --git a/asspb_test.go b/asspb_test.go deleted file mode 100644 index fdf8b9b..0000000 --- a/asspb_test.go +++ /dev/null @@ -1,610 +0,0 @@ -package asspb - -import ( - "fmt" - "testing" - "time" - - "github.com/google/go-cmp/cmp" -) - -func TestUnmarshal(t *testing.T) { - t.Parallel() - - type nestedMessage struct { - Field int64 `ccl:"field"` - } - type message struct { - String string `ccl:"string"` - String2 string `ccl:"string2"` - Int int `ccl:"int"` - Int8 int8 `ccl:"int8"` - Int16 int16 `ccl:"int16"` - Int32 int32 `ccl:"int32"` - Int64 int64 `ccl:"int64"` - Uint uint `ccl:"uint"` - Uint8 uint8 `ccl:"uint8"` - Uint16 uint16 `ccl:"uint16"` - Uint32 uint32 `ccl:"uint32"` - Uint64 uint64 `ccl:"uint64"` - Float float64 `ccl:"float"` - Bool bool `ccl:"bool"` - Bool2 bool `ccl:"bool2"` - Message *nestedMessage `ccl:"message"` - Repeated []int64 `ccl:"repeated"` - RepeatedMessage []*nestedMessage `ccl:"repeated_message"` - Bytes []byte `ccl:"bytes"` - Time time.Time `ccl:"time"` - TimePointer *time.Time `ccl:"time_pointer"` - - Ignore map[int]int `ccl:"-,"` // unlike JSON this also means ignore - unexported int64 - } - - for _, tc := range []struct { - desc string - msg string - want message - }{{ - desc: "Complete", - msg: ` - # This is a comment - string: 'asdf\n' # comment end of line - string2: "asdf\n" - int: 10 - float: 10.5e13 - bool: true - bool2: false - message { field: 10 } - repeated: [1, 2, 3] - repeated: 4 - repeated: [5, 6] - `, - want: message{ - String: "asdf\n", - String2: "asdf\n", - Int: 10, - Float: 10.5e13, - Bool: true, - Bool2: false, - Message: &nestedMessage{Field: 10}, - Repeated: []int64{1, 2, 3, 4, 5, 6}, - }, - }, { - desc: "MultilineString", - msg: `string: "strings -can just span multiple lines"`, - want: message{String: "strings\ncan just span multiple lines"}, - }, { - desc: "Zero", - msg: `int: 0`, - want: message{Int: 0}, - }, { - desc: "Hex", - msg: `int: 0xff`, - want: message{Int: 255}, - }, { - desc: "CapitalHex", - msg: `int: 0XfF`, - want: message{Int: 255}, - }, { - desc: "HexLeadingZero", - msg: `int: 0x0f`, - want: message{Int: 15}, - }, { - desc: "NegativeHex", - msg: `int: -0x0f`, - want: message{Int: -15}, - }, { - desc: "PositiveHex", - msg: `int: +0x0f`, - want: message{Int: 15}, - }, { - desc: "Float", - msg: `float: 1.5e10`, - want: message{Float: 1.5e10}, - }, { - desc: "FloatCapitalE", - msg: `float: 1.5E10`, - want: message{Float: 1.5e10}, - }, { - desc: "NegativeFloat", - msg: `float: -1.5e-10`, - want: message{Float: -1.5e-10}, - }, { - desc: "PositiveFloat", - msg: `float: +1.5e+10`, - want: message{Float: 1.5e10}, - }, { - desc: "Int", - msg: `int: 10`, - want: message{Int: 10}, - }, { - desc: "NegativeInt", - msg: `int: -10`, - want: message{Int: -10}, - }, { - desc: "PositiveInt", - msg: `int: +10`, - want: message{Int: 10}, - }, { - desc: "Int8", - msg: `int8:1`, - want: message{Int8: 1}, - }, { - desc: "Int16", - msg: `int16:1`, - want: message{Int16: 1}, - }, { - desc: "Int32", - msg: `int32:1`, - want: message{Int32: 1}, - }, { - desc: "Int64", - msg: `int64:1`, - want: message{Int64: 1}, - }, { - desc: "Uint", - msg: `uint:1`, - want: message{Uint: 1}, - }, { - desc: "Uint8", - msg: `uint8:1`, - want: message{Uint8: 1}, - }, { - desc: "Uint16", - msg: `uint16:1`, - want: message{Uint16: 1}, - }, { - desc: "Uint32", - msg: `uint32:1`, - want: message{Uint32: 1}, - }, { - desc: "Uint64", - msg: `uint64:1`, - want: message{Uint64: 1}, - }, { - desc: "IntTrue", - msg: `int:on`, - want: message{Int: 1}, - }, { - desc: "IntFalse", - msg: `int:no`, - want: message{Int: 0}, - }, { - desc: "UintTrue", - msg: `uint:on`, - want: message{Uint: 1}, - }, { - desc: "UintFalse", - msg: `uint:no`, - want: message{Uint: 0}, - }, { - desc: "IntFloat", - msg: `float:-1`, - want: message{Float: -1}, - }, { - desc: "UintFloat", - msg: `float:1`, - want: message{Float: 1}, - }, { - desc: "String", - msg: `string: 'asdf'`, - want: message{String: "asdf"}, - }, { - desc: "DoubleString", - msg: `string: "asdf"`, - want: message{String: "asdf"}, - }, { - desc: "StringEscapeSingle", - msg: `string: 'ain\'t'`, - want: message{String: "ain't"}, - }, { - desc: "DoubleStringEscapeSingle", - msg: `string: "won\'t"`, - want: message{String: "won't"}, - }, { - desc: "StringEscapeDouble", - msg: `string: '\"'`, - want: message{String: `"`}, - }, { - desc: "DoubleStringEscapeDouble", - msg: `string: "\""`, - want: message{String: `"`}, - }, { - desc: "StringEscapeQuestionMark", - msg: `string: "\?"`, - want: message{String: "?"}, - }, { - desc: "StringEscapeBackslash", - msg: `string: '\\'`, - want: message{String: `\`}, - }, { - desc: "StringEscapeA", - msg: `string: '\a'`, - want: message{String: "\a"}, - }, { - desc: "StringEscapeB", - msg: `string: '\b'`, - want: message{String: "\b"}, - }, { - desc: "StringEscapeF", - msg: `string: '\f'`, - want: message{String: "\f"}, - }, { - desc: "StringEscapeN", - msg: `string: '\n'`, - want: message{String: "\n"}, - }, { - desc: "StringEscapeR", - msg: `string: '\r'`, - want: message{String: "\r"}, - }, { - desc: "StringEscapeT", - msg: `string: '\t'`, - want: message{String: "\t"}, - }, { - desc: "StringEscapeV", - msg: `string: '\v'`, - want: message{String: "\v"}, - }, { - desc: "StringHex", - msg: `string: '\x0a'`, - want: message{String: "\n"}, - }, { - desc: "StringHexHighByte", - msg: `string: "\xe4\xb8\x96"`, - want: message{String: "世"}, - }, { - desc: "StringUnicode", - msg: `string: '\u2014'`, - want: message{String: "—"}, - }, { - desc: "StringOctal", - msg: `string: '\033'`, - want: message{String: "\033"}, - }, { - desc: "Message", - msg: `message { field: 10 }`, - want: message{Message: &nestedMessage{Field: 10}}, - }, { - desc: "EmptyMessage", - msg: `message {}`, - want: message{Message: &nestedMessage{}}, - }, { - desc: "Repeated", - msg: ` - repeated: 1 - repeated: 2`, - want: message{Repeated: []int64{1, 2}}, - }, { - desc: "RepeatedList", - msg: `repeated: [1, 2]`, - want: message{Repeated: []int64{1, 2}}, - }, { - desc: "EmptyList", - msg: `repeated: []`, - want: message{Repeated: []int64{}}, - }, { - desc: "RepeatedListTrailingComma", - msg: `repeated: [ - 1, - 2, - ]`, - want: message{Repeated: []int64{1, 2}}, - }, { - desc: "ListOfMessage", - msg: `repeated_message: [{}]`, - want: message{RepeatedMessage: []*nestedMessage{{}}}, - }, { - desc: "CStyleComment", - msg: `message: /** inline comment **/ {}`, - want: message{Message: &nestedMessage{}}, - }, { - desc: "CStyleLineComment", - msg: `message: {} // line comment`, - want: message{Message: &nestedMessage{}}, - }, { - desc: "ConcatStrings", - msg: `string: 'that'"'"'s cool'`, - want: message{String: "that's cool"}, - }, { - desc: "RemoveNewline", - msg: `string: 'remove newline \ -from string'`, - want: message{String: "remove newline from string"}, - }, { - desc: "RemoveNewlineWindows", - msg: "string: 'remove newline \\\r\nfrom string'", - want: message{String: "remove newline from string"}, - }, { - desc: "Base64", - msg: `bytes:"dGVzdA=="`, - want: message{Bytes: []byte("test")}, - }, { - desc: "TextUnmarshaler", - msg: `time:"2025-10-28T07:41:47Z"`, - want: message{Time: time.Date(2025, time.October, 28, 7, 41, 47, 0, time.UTC)}, - }, { - desc: "TextUnmarshalerPointer", - msg: `time_pointer:"2025-10-28T07:41:47Z"`, - want: message{TimePointer: &[]time.Time{time.Date(2025, time.October, 28, 7, 41, 47, 0, time.UTC)}[0]}, - }} { - t.Run(tc.desc, func(t *testing.T) { - t.Parallel() - - var got message - if err := Unmarshal([]byte(tc.msg), &got); err != nil { - t.Fatalf("Unmarshal(%q) failed: %s\n", tc.msg, err) - } - if diff := cmp.Diff(tc.want, got, cmp.AllowUnexported(message{})); diff != "" { - t.Errorf("Unmarshal(%q) returned unexpected diff (-want +got):\n%s", tc.msg, diff) - } - }) - } -} - -func TestUnmarshal_Invalid(t *testing.T) { - t.Parallel() - - type nestedMessage struct { - Int int `ccl:"int"` - } - type message struct { - Int int64 `ccl:"int"` - Int8 int8 `ccl:"int8"` - String string `ccl:"string"` - Msg nestedMessage `ccl:"msg"` - Repeated []int64 `ccl:"repeated"` - RepeatedMsg []nestedMessage `ccl:"repeated_msg"` - Bytes []byte `ccl:"bytes"` - } - - for _, tc := range []struct { - desc string - msg string - }{{ - desc: "BadNum", - msg: `int: .`, - }, { - desc: "BadStringEscape", - msg: `string: '\g'`, - }, { - desc: "BadDoubleStringEscape", - msg: `string: "\g"`, - }, { - desc: "UnterminatedString", - msg: `string: '`, - }, { - desc: "UnterminatedDoubleString", - msg: `string: "`, - }, { - desc: "NoFieldName", - msg: `10`, - }, { - desc: "MsgNoFieldName", - msg: `msg {10}`, - }, { - desc: "ListMissingColon", - msg: `repeated []`, - }, { - desc: "ListMissingComma", - msg: `repeated: [1 2]`, - }, { - desc: "ListBadVal", - msg: `repeated: [asdf]`, - }, { - desc: "ListBadMsgVal", - msg: `repeated_msg: [{asdf}]`, - }, { - desc: "IntLeadingZero", - msg: `int: 0644`, - }, { - desc: "InvalidOctal", - msg: `string: "\777"`, - }, { - desc: "InvalidUTF8", - msg: `string: "\x80"`, - }, { - desc: "FieldMissingVal", - msg: `string`, - }, { - desc: "FieldMissingColon", - msg: `string "abc"`, - }, { - desc: "Repeated", - msg: `int:5 int:6`, - }, { - desc: "IntOutOfRange", - msg: `int8:512`, - }, { - desc: "IntOutOfRangeNegative", - msg: `int8:-512`, - }, { - desc: "Base64", - msg: `bytes:"dGVzdAo"`, - }, { - desc: "BadField", - msg: `asdfasdfasdf:"asdf"`, - }} { - t.Run(tc.desc, func(t *testing.T) { - t.Parallel() - - var got message - err := Unmarshal([]byte(tc.msg), &got) - if err == nil { - t.Errorf("Unmarshal(%q) returned success, want error", tc.msg) - } - }) - } -} - -func TestUnmarshal_InvalidType(t *testing.T) { - t.Parallel() - - for _, tc := range []struct { - desc string - msg string - out any - }{{ - desc: "Nil", - msg: `field {}`, - out: (*struct{})(nil), - }, { - desc: "NilMap", - msg: `field {}`, - out: map[string]any(nil), - }, { - desc: "Struct", - msg: `field {}`, - out: new(int), - }, { - desc: "Int", - msg: `Field: 123`, - out: new(struct{ Field string }), - }, { - desc: "NegativeInt", - msg: `Field: -123`, - out: new(struct{ Field string }), - }, { - desc: "IntHex", - msg: `Field: 0x123`, - out: new(struct{ Field string }), - }, { - desc: "Float", - msg: `Field: 123.`, - out: new(struct{ Field int64 }), - }, { - desc: "NestedMessage", - msg: `Field {Field {}}`, - out: new(struct{ Field struct{ Field int64 } }), - }, { - desc: "True", - msg: `F:on`, - out: new(struct{ F string }), - }, { - desc: "False", - msg: `F:no`, - out: new(struct{ F string }), - }, { - desc: "List", - msg: `F:[]`, - out: new(struct{ F int64 }), - }, { - desc: "RepeatedBool", - msg: `F:on F:no`, - out: new(struct{ F []string }), - }, { - desc: "String", - msg: `F:"abc"`, - out: new(struct{ F int64 }), - }, { - desc: "Option", - msg: `F:"abc"`, - out: new(struct { - F string `ccl:",asdf"` - }), - }, { - desc: "OptionNested", - msg: `F{F:"abc"}`, - out: new(struct { - F struct { - F string `ccl:",asdf"` - } - }), - }, { - desc: "OptionNestedPointer", - msg: `F{F:"abc"}`, - out: new(struct { - F *struct { - F string `ccl:",asdf"` - } - }), - }, { - desc: "OptionSlicePointer", - msg: `F{F:"abc"}`, - out: new(struct { - F []*struct { - F string `ccl:",asdf"` - } - }), - }, { - desc: "RepeatedTagName", - msg: `F:"abc"`, - out: new(struct { - F string - G string `ccl:"F"` - }), - }} { - t.Run(tc.desc, func(t *testing.T) { - t.Parallel() - - err := Unmarshal([]byte(tc.msg), tc.out) - if err == nil { - t.Errorf("Unmarshal(%q) returned %+v, want error", tc.msg, tc.out) - } - }) - } -} - -func TestUnmarshal_ErrorLineCol(t *testing.T) { - t.Parallel() - - type message struct { - Secret int64 `ccl:"secret"` - } - - msg := ` - ###### This is a very important file please do not modify - ######################################################### - ################ The more ## I put the more secure it is###### - secret:12345; # oops typo - ` - err := Unmarshal([]byte(msg), new(message)) - syntaxErr, ok := err.(*syntaxError) - if !ok { - t.Fatalf("Unmarshal(%q): expected *syntaxError, got error %T %[2]v", msg, err) - } - want := &syntaxError{line: 5, col: 15} - if syntaxErr.line != want.line || syntaxErr.col != want.col { - t.Errorf("Unmarshal(%q) returned error %+v, want line %d, col %d", msg, syntaxErr, want.line, want.col) - } -} - -func ExampleUnmarshal() { - // Pretend this was loaded from a file - msg := []byte(` - server { - listen: "0.0.0.0:80" - listen: "[::0]:80" - location { - path: "/" - return: "301 https://$host$request_uri" # redirect to https - } - location { - path: "/.well-known/acme-challenge/" - root: "/var/lib/acme/acme-challenge" - auth_basic: off - auth_request: off - } - } - `) - var serverConfig struct { - Server struct { - Listen []string `ccl:"listen"` - Location []struct { - Path string `ccl:"path"` - Root string `ccl:"root"` - AuthBasic bool `ccl:"auth_basic"` - AuthRequest bool `ccl:"auth_request"` - Return string `ccl:"return"` - } `ccl:"location"` - } `ccl:"server"` - } - if err := Unmarshal(msg, &serverConfig); err != nil { - panic(err) - } - fmt.Println(serverConfig.Server.Location[0].Return) - // Output: - // 301 https://$host$request_uri -} diff --git a/ccl.go b/ccl.go new file mode 100644 index 0000000..926d78b --- /dev/null +++ b/ccl.go @@ -0,0 +1,743 @@ +// Me: Mom can we have textproto? +// Mom: no we have textproto at home +// textproto at home: +// +// The asspb language has similar semantics to JSON, the only exception being +// the lack of null. +// +// # Comments +// +// There are two types of comments, line comments and C-style comments. Line +// comments are written with # or //, and extend from there to the end of the +// line. C-style comments are written with /* and */, and like C they may not +// be nested. +// +// # Comments are important +// // in a configuration language +// /* what do I know */ +// +// # Numbers +// +// Numbers are written in base 10 and can optionally have a fractional part or +// an exponent written with "e" or "E". As a special case, a number prefixed +// with "0x" or "0X" can be written in base 16. +// +// 100 +// -30 +// 0xabc +// -0xdef +// 13.5 +// 1e100 +// +// Leading zeros are not permitted in decimal numbers, due to potential +// confusion with octal (which is not supported). +// +// As a lexical matter, numbers must be separated from subsequent field names by +// intervening whitespace or comments: +// +// # invalid +// field1:10field2:20 +// # ok +// field1:10 field2:20 +// +// # Strings +// +// Strings are written with " or ' and any sequence of intermediate bytes (with +// the exception of escape sequences which are described below). Strings must be +// valid UTF-8 after escape sequences are expanded. +// +// 'asdf' +// "that's cool" +// "\tall\n\tyour\n\tfavorite\n\tescape\n\tsequences" +// +// Note that strings can contain newline without needing an escape sequence +// +// 'a multiline +// string' +// +// Backslash characters inside a string are interpreted as an escape sequence. +// Any escape sequence not described below is an error. The escape sequences +// are identical to C11, with the exception that \x always takes exactly 2 +// hex characters. +// +// \' single quote 0x27 +// \" double quote 0x22 +// \? question mark 0x3f (why is this in C) +// \\ backslash 0x5c +// \a bell 0x07 +// \b backspace 0x07 +// \f form feed 0x0c +// \n newline 0x0a +// \r carriage return 0x0d +// \t tab 0x09 +// \v vertical tab 0x0b +// +// \nnn 3-digit octal value nnn +// \xnn 2-digit hex value nn +// \unnnn unicode code point U+nnnn +// \Unnnnnnnn unicode code point U+nnnnnnnn (UTF8) +// +// As an extension to the C11 escapes, a backslash immediately before a newline +// character (0x0a) will remove the newline character from the resulting string +// (and for you Microsoft Windows users, backslash followed by \r\n is +// also removed) +// +// 'backslash also can \ +// remove newlines' +// # equivalent to +// 'backslash also can remove newlines' +// +// If multiple string literals are written next to each other with only +// whitespace or comments in between, the result is to concatenate the strings +// +// 'multiple strings' " concatenated" +// # equivalent to +// 'multiple strings concatenated' +// +// # Bool +// +// Bool values can be true or false (classic), and are written using one of the +// below strings. +// +// true +// yes +// on +// +// false +// no +// off +// +// # Lists +// +// Lists are written with square brackets and elements are separated by comma. +// +// [1, 2, 3] +// [{nested: "messages"}, {are: "also"}, {allowed: yes}] +// +// Trailing comma is allowed +// +// [ +// "suck", +// "it", +// "JSON", +// ] +// +// # Messages +// +// Messages are an unordered set of key-value pairs: +// +// {key1: "value1" key2: "value2"} +// +// Keys can be alphanumeric or use underscore; no other characters are +// permitted. Values can be any of the value types here described. Key-value +// pairs must be written with a : between the key and value, except when the +// value is syntactically a message (in that case the colon is optional) +// +// { +// key1: "value1" +// key2 {} +// } +// +// As a special case, when a key is written more than once in a message, it's +// treated the same as if the values had been written in a list. If some of the +// values are already lists, they are appended, preserving the order in which +// the values appear in the input file. +// +// { +// key: [1, 2] +// key: 3 +// key: [4, 5, 6] +// } +// # equivalent to +// { +// key: [1, 2, 3, 4, 5, 6] +// } +// +// # Disclaimer +// +// This package is still experimental, expect breaking changes. +package asspb + +import ( + "bytes" + "encoding" + "encoding/base64" + "errors" + "fmt" + "iter" + "math" + "reflect" + "regexp" + "strconv" + "strings" + "unicode/utf8" +) + +type syntaxError struct { + line, col int + reason string +} + +func newSyntaxError(data []byte, idx int, reason string, args ...any) error { + line, col := 1, 1 + for _, b := range data[:idx] { + if b == '\n' { + line++ + col = 1 + } else { + col++ + } + } + return &syntaxError{line, col, fmt.Sprintf(reason, args...)} +} + +func (e *syntaxError) Error() string { + return fmt.Sprintf("%d:%d syntax error: %s", e.line, e.col, e.reason) +} + +type structField struct { + ty reflect.Type + name string +} + +func fieldMap(out map[structField]int, types map[reflect.Type]bool, s reflect.Type) error { + if types[s] { + // Already processed + return nil + } + types[s] = true + for i := range s.NumField() { + field := s.Field(i) + if !field.IsExported() { + continue + } + fieldName := field.Name + if tag, ok := field.Tag.Lookup("ccl"); ok { + var opts string + fieldName, opts, _ = strings.Cut(tag, ",") + if fieldName == "-" { + continue + } + for opt := range strings.FieldsFuncSeq(opts, func(r rune) bool { return r == ',' }) { + return fmt.Errorf("unknown option %q", opt) + } + } + if _, ok := out[structField{s, fieldName}]; ok { + return fmt.Errorf("multiple fields with name %q", fieldName) + } + out[structField{s, fieldName}] = i + if field.Type.Kind() == reflect.Struct { + if err := fieldMap(out, types, field.Type); err != nil { + return err + } + } else if (field.Type.Kind() == reflect.Pointer || field.Type.Kind() == reflect.Slice) && field.Type.Elem().Kind() == reflect.Struct { + if err := fieldMap(out, types, field.Type.Elem()); err != nil { + return err + } + } else if field.Type.Kind() == reflect.Slice && field.Type.Elem().Kind() == reflect.Pointer && field.Type.Elem().Elem().Kind() == reflect.Struct { + if err := fieldMap(out, types, field.Type.Elem().Elem()); err != nil { + return err + } + } + } + return nil +} + +type parser struct { + nextTok func() (token, error, bool) + tok []byte + err error + data []byte + i int +} + +func (p *parser) error(reason string, args ...any) error { + return newSyntaxError(p.data, p.i, reason, args...) +} + +var errEOF = errors.New("premature EOF") + +func (p *parser) peek() ([]byte, error) { + if p.err != nil || p.tok != nil { + return p.tok, p.err + } + tok, err, ok := p.nextTok() + if !ok { + p.err = errEOF + return nil, p.err + } + if err != nil { + p.err = err + return nil, p.err + } + p.tok = tok.b + p.i = tok.i + return p.tok, nil +} + +func (p *parser) next() ([]byte, error) { + tok, err := p.peek() + if err != nil { + return nil, err + } + p.tok = nil + return tok, nil +} + +var ( + numRE = regexp.MustCompile(`^[-+]?(0[xX][0-9a-fA-F]+|((0|[1-9][0-9]*)(\.[0-9]*)?|\.[0-9]+)([eE][-+]?[0-9]+)?)$`) + hexRE = regexp.MustCompile(`^([-+]?)0[xX]`) +) + +func (p *parser) parseNum(numBytes []byte) (any, error) { + if !numRE.Match(numBytes) { + return nil, p.error("invalid number") + } + if hex := hexRE.FindSubmatch(numBytes); hex != nil { + if string(hex[1]) == "-" { + n, err := strconv.ParseInt(string(numBytes[len(hex[0]):]), 16, 64) + if err != nil { + return nil, p.error("invalid number") + } + return -n, nil + } else { + n, err := strconv.ParseUint(string(numBytes[len(hex[0]):]), 16, 64) + if err != nil { + return nil, p.error("invalid number") + } + return n, nil + } + } + if bytes.ContainsAny(numBytes, ".eE") { + n, err := strconv.ParseFloat(string(numBytes), 64) + if err != nil { + return nil, p.error("invalid number") + } + return n, nil + } + if bytes.HasPrefix(numBytes, []byte("-")) { + n, err := strconv.ParseInt(string(numBytes), 10, 64) + if err != nil { + return nil, p.error("invalid number") + } + return n, nil + } else { + n, err := strconv.ParseUint(string(bytes.TrimPrefix(numBytes, []byte("+"))), 10, 64) + if err != nil { + return nil, p.error("invalid number") + } + return n, nil + } +} + +var escapesRE = regexp.MustCompile(`(?s)\\(.|\r\n|[0-7]{3}|x[0-9a-fA-F]{2}|u[0-9a-fA-F]{4}|U[0-9a-fA-F]{8})`) + +func init() { + escapesRE.Longest() +} + +func (p *parser) unescape(rawStr []byte) ([]byte, error) { + var err error + escaped := escapesRE.ReplaceAllFunc(rawStr, func(escape []byte) []byte { + switch string(escape) { + case `\'`: + return []byte("'") + case `\"`: + return []byte(`"`) + case `\?`: + return []byte("?") + case `\\`: + return []byte(`\`) + case `\a`: + return []byte("\a") + case `\b`: + return []byte("\b") + case `\f`: + return []byte("\f") + case `\n`: + return []byte("\n") + case `\r`: + return []byte("\r") + case `\t`: + return []byte("\t") + case `\v`: + return []byte("\v") + case "\\\n", "\\\r\n": + return nil + } + switch { + case bytes.HasPrefix(escape, []byte(`\x`)): + var n uint64 + if n, err = strconv.ParseUint(string(escape[2:]), 16, 8); err != nil { + err = p.error("invalid hex escape %q: %s", escape, err) + return nil + } + return []byte{byte(n)} + case bytes.HasPrefix(escape, []byte(`\u`)), bytes.HasPrefix(escape, []byte(`\U`)): + var n int64 + if n, err = strconv.ParseInt(string(escape[2:]), 16, 32); err != nil { + err = p.error("invalid unicode escape %q: %s", escape, err) + return nil + } + return utf8.AppendRune(nil, rune(n)) + default: + if len(escape) != 4 { + err = p.error("invalid string escape %q", escape) + return nil + } + var n int64 + if n, err = strconv.ParseInt(string(escape[1:]), 8, 32); err != nil { + err = p.error("invalid string escape %q", escape) + return nil + } + if n > 255 { + err = p.error("invalid octal escape %q %d > 255", escape, n) + return nil + } + return []byte{byte(n)} + } + }) + if err != nil { + return nil, err + } + if !utf8.Valid(escaped) { + return nil, p.error("syntax error: string %q is not UTF-8 encoded", escaped) + } + return escaped, nil +} + +func (p *parser) parseString(tok []byte) (string, error) { + s := new(strings.Builder) + for { + ss, err := p.unescape(tok[1 : len(tok)-1]) + if err != nil { + return "", err + } + s.Write(ss) + nextTok, err := p.peek() + if err != nil || nextTok[0] != '\'' && nextTok[0] != '"' { + return s.String(), nil + } + p.next() + tok = nextTok + } +} + +func (p *parser) parseMessage() (map[string][]any, error) { + m := make(map[string][]any) + for { + tok, err := p.next() + if err != nil || tok[0] == '}' { + return m, err + } + if err := p.parseFieldVal(m, tok); err != nil { + return nil, err + } + } +} + +func (p *parser) parseVal(tok []byte) ([]any, error) { + switch tok[0] { + case '{': + m, err := p.parseMessage() + if err != nil { + return nil, err + } + return []any{m}, nil + case '[': + return p.parseList() + case '\'', '"': + s, err := p.parseString(tok) + if err != nil { + return nil, err + } + return []any{s}, nil + default: + switch string(tok) { + case "true", "yes", "on": + return []any{true}, nil + case "false", "no", "off": + return []any{false}, nil + default: + n, err := p.parseNum(tok) + if err != nil { + return nil, err + } + return []any{n}, nil + } + } +} + +func (p *parser) parseList() ([]any, error) { + var l []any + for i := 0; ; i++ { + tok, err := p.next() + if err != nil || tok[0] == ']' { + return l, err + } + if i > 0 { + if tok[0] != ',' { + return nil, p.error("expecting comma") + } + tok, err = p.next() + if err != nil || tok[0] == ']' { // allow trailing comma + return l, err + } + } + vs, err := p.parseVal(tok) + if err != nil { + return nil, err + } + l = append(l, vs...) + } +} + +func (p *parser) parseFieldVal(m map[string][]any, field []byte) error { + if b := field[0]; !(b == '_' || 'a' <= b && b <= 'z' || 'A' <= b && b <= 'Z') { + return p.error("expecting field") + } + tok, err := p.next() + if err != nil { + return err + } + switch tok[0] { + case '{': + vs, err := p.parseVal(tok) + if err != nil { + return err + } + m[string(field)] = append(m[string(field)], vs...) + case ':': + tok, err := p.next() + if err != nil { + return err + } + vs, err := p.parseVal(tok) + if err != nil { + return err + } + m[string(field)] = append(m[string(field)], vs...) + default: + return p.error("expecting colon") + } + return nil +} + +func (p *parser) parse() (map[string][]any, error) { + m := make(map[string][]any) + for { + tok, err := p.next() + if err != nil { + if err == errEOF { + return m, nil + } + return nil, err + } + if err := p.parseFieldVal(m, tok); err != nil { + return nil, err + } + } +} + +func intLimits(kind reflect.Kind) (min int64, max uint64, ok bool) { + switch kind { + case reflect.Int: + return math.MinInt, math.MaxInt, true + case reflect.Int8: + return math.MinInt8, math.MaxInt8, true + case reflect.Int16: + return math.MinInt16, math.MaxInt16, true + case reflect.Int32: + return math.MinInt32, math.MaxInt32, true + case reflect.Int64: + return math.MinInt64, math.MaxInt64, true + case reflect.Uint: + return 0, math.MaxUint, true + case reflect.Uint8: + return 0, math.MaxUint8, true + case reflect.Uint16: + return 0, math.MaxUint16, true + case reflect.Uint32: + return 0, math.MaxUint32, true + case reflect.Uint64: + return 0, math.MaxUint64, true + default: + return 0, 0, false + } +} + +func unpackVal(fieldVal reflect.Value, fieldMap map[structField]int, val any, field string) error { + switch val := val.(type) { + case bool: + switch fieldVal.Kind() { + case reflect.Bool: + fieldVal.SetBool(val) + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + if val { + fieldVal.SetInt(1) + } else { + fieldVal.SetInt(0) + } + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + if val { + fieldVal.SetUint(1) + } else { + fieldVal.SetUint(0) + } + default: + return fmt.Errorf("field %q should have type bool", field) + } + case uint64: + switch fieldVal.Kind() { + case reflect.Float32, reflect.Float64: + fieldVal.SetFloat(float64(val)) + return nil + } + min, max, ok := intLimits(fieldVal.Kind()) + if !ok { + return fmt.Errorf("field %q should have type int", field) + } + if val > max { + return fmt.Errorf("number %d is out of range for %s", val, fieldVal.Kind()) + } + if min == 0 { // unsigned + fieldVal.SetUint(val) + } else { + fieldVal.SetInt(int64(val)) + } + case int64: + switch fieldVal.Kind() { + case reflect.Float32, reflect.Float64: + fieldVal.SetFloat(float64(val)) + return nil + } + min, max, ok := intLimits(fieldVal.Kind()) + if !ok { + return fmt.Errorf("field %q should have type int", field) + } + if val < min || val > 0 && uint64(val) > max { + return fmt.Errorf("number %d is out of range for %s", val, fieldVal.Kind()) + } + if min == 0 { // unsigned + fieldVal.SetUint(uint64(val)) + } else { + fieldVal.SetInt(val) + } + case float64: + switch fieldVal.Kind() { + case reflect.Float32, reflect.Float64: + fieldVal.SetFloat(float64(val)) + default: + return fmt.Errorf("field %q should have type float64 or float32", field) + } + case string: + if _, ok := fieldVal.Interface().(encoding.TextUnmarshaler); ok { + if fieldVal.Kind() == reflect.Pointer && fieldVal.IsNil() { + fieldVal.Set(reflect.New(fieldVal.Type().Elem())) + } + return fieldVal.Interface().(encoding.TextUnmarshaler).UnmarshalText([]byte(val)) + } + if unmarshaler, ok := fieldVal.Addr().Interface().(encoding.TextUnmarshaler); ok { + return unmarshaler.UnmarshalText([]byte(val)) + } + switch { + case fieldVal.Kind() == reflect.String: + fieldVal.SetString(val) + case fieldVal.Kind() == reflect.Slice && fieldVal.Type().Elem() == reflect.TypeFor[byte](): + b, err := base64.StdEncoding.DecodeString(val) + if err != nil { + return fmt.Errorf("field %q: bad base64", field) + } + fieldVal.Set(reflect.ValueOf(b)) + default: + return fmt.Errorf("field %q should have type string (got %s)", field, fieldVal.Type()) + } + case map[string][]any: + if !(fieldVal.Kind() == reflect.Struct || fieldVal.Kind() == reflect.Pointer && fieldVal.Type().Elem().Kind() == reflect.Struct) { + return fmt.Errorf("field %q should have type struct (got %s)", field, fieldVal.Type()) + } + if fieldVal.Kind() == reflect.Pointer { + if fieldVal.IsNil() { + fieldVal.Set(reflect.New(fieldVal.Type().Elem())) + } + fieldVal = fieldVal.Elem() + } + if err := unpackStruct(fieldVal, fieldMap, val); err != nil { + return err + } + } + return nil +} + +func unpackStruct(out reflect.Value, fieldMap map[structField]int, msg map[string][]any) error { + for field, vals := range msg { + fieldIdx, ok := fieldMap[structField{out.Type(), field}] + if !ok { + return fmt.Errorf("no field named %q", field) + } + fieldVal := out.Field(fieldIdx) + if fieldVal.Kind() == reflect.Slice && fieldVal.Type().Elem() != reflect.TypeFor[byte]() { + l := reflect.MakeSlice(fieldVal.Type(), len(vals), len(vals)) + for i, val := range vals { + if err := unpackVal(l.Index(i), fieldMap, val, field); err != nil { + return err + } + } + if fieldVal.IsNil() { + fieldVal.Set(reflect.MakeSlice(fieldVal.Type(), 0, 0)) + } + fieldVal.Set(reflect.AppendSlice(fieldVal, l)) + continue + } + if len(vals) != 1 { + return fmt.Errorf("field %q is not repeated", field) + } + if err := unpackVal(fieldVal, fieldMap, vals[0], field); err != nil { + return err + } + } + return nil +} + +// Unmarshal parses a asspb message and writes the result into v. v must be a +// non-nil pointer to a struct. +// +// Unmarshal accepts a top-level message, which is equivalent to the "message" +// type described above, but without the surrounding braces. For example: +// +// key1: "val1" +// key2: "val2" +// +// The exact semantics of which asspb types map to which Go types is a bit +// complicated and I don't feel like writing out all the rules, so suffice it +// to say that the usual stuff should work. As a special case, a []byte field +// expects a base64-encoded string. +// +// You can override a field's name using a struct tag "ccl", for example +// +// type message struct { +// MyField int `ccl:"my_field"` +// } +// +// This message could decode, for example `my_field:5` +// +// If a field has type T where T or *T implements [encoding.TextUnmarshaler], +// then a string value will be decoded by calling UnmarshalText. No other +// customization is supported, this isn't encoding/json. +func Unmarshal(data []byte, v any) error { + val := reflect.ValueOf(v) + if val.Kind() != reflect.Pointer || val.IsNil() || val.Type().Elem().Kind() != reflect.Struct { + return fmt.Errorf("value must be a non-nil pointer to a struct") + } + fields := make(map[structField]int) + if err := fieldMap(fields, make(map[reflect.Type]bool), val.Type().Elem()); err != nil { + return err + } + nextToken, stop := iter.Pull2(tokens(data)) + defer stop() + msg, err := (&parser{nextTok: nextToken, data: data}).parse() + if err != nil { + return err + } + return unpackStruct(val.Elem(), fields, msg) +} diff --git a/ccl_test.go b/ccl_test.go new file mode 100644 index 0000000..fdf8b9b --- /dev/null +++ b/ccl_test.go @@ -0,0 +1,610 @@ +package asspb + +import ( + "fmt" + "testing" + "time" + + "github.com/google/go-cmp/cmp" +) + +func TestUnmarshal(t *testing.T) { + t.Parallel() + + type nestedMessage struct { + Field int64 `ccl:"field"` + } + type message struct { + String string `ccl:"string"` + String2 string `ccl:"string2"` + Int int `ccl:"int"` + Int8 int8 `ccl:"int8"` + Int16 int16 `ccl:"int16"` + Int32 int32 `ccl:"int32"` + Int64 int64 `ccl:"int64"` + Uint uint `ccl:"uint"` + Uint8 uint8 `ccl:"uint8"` + Uint16 uint16 `ccl:"uint16"` + Uint32 uint32 `ccl:"uint32"` + Uint64 uint64 `ccl:"uint64"` + Float float64 `ccl:"float"` + Bool bool `ccl:"bool"` + Bool2 bool `ccl:"bool2"` + Message *nestedMessage `ccl:"message"` + Repeated []int64 `ccl:"repeated"` + RepeatedMessage []*nestedMessage `ccl:"repeated_message"` + Bytes []byte `ccl:"bytes"` + Time time.Time `ccl:"time"` + TimePointer *time.Time `ccl:"time_pointer"` + + Ignore map[int]int `ccl:"-,"` // unlike JSON this also means ignore + unexported int64 + } + + for _, tc := range []struct { + desc string + msg string + want message + }{{ + desc: "Complete", + msg: ` + # This is a comment + string: 'asdf\n' # comment end of line + string2: "asdf\n" + int: 10 + float: 10.5e13 + bool: true + bool2: false + message { field: 10 } + repeated: [1, 2, 3] + repeated: 4 + repeated: [5, 6] + `, + want: message{ + String: "asdf\n", + String2: "asdf\n", + Int: 10, + Float: 10.5e13, + Bool: true, + Bool2: false, + Message: &nestedMessage{Field: 10}, + Repeated: []int64{1, 2, 3, 4, 5, 6}, + }, + }, { + desc: "MultilineString", + msg: `string: "strings +can just span multiple lines"`, + want: message{String: "strings\ncan just span multiple lines"}, + }, { + desc: "Zero", + msg: `int: 0`, + want: message{Int: 0}, + }, { + desc: "Hex", + msg: `int: 0xff`, + want: message{Int: 255}, + }, { + desc: "CapitalHex", + msg: `int: 0XfF`, + want: message{Int: 255}, + }, { + desc: "HexLeadingZero", + msg: `int: 0x0f`, + want: message{Int: 15}, + }, { + desc: "NegativeHex", + msg: `int: -0x0f`, + want: message{Int: -15}, + }, { + desc: "PositiveHex", + msg: `int: +0x0f`, + want: message{Int: 15}, + }, { + desc: "Float", + msg: `float: 1.5e10`, + want: message{Float: 1.5e10}, + }, { + desc: "FloatCapitalE", + msg: `float: 1.5E10`, + want: message{Float: 1.5e10}, + }, { + desc: "NegativeFloat", + msg: `float: -1.5e-10`, + want: message{Float: -1.5e-10}, + }, { + desc: "PositiveFloat", + msg: `float: +1.5e+10`, + want: message{Float: 1.5e10}, + }, { + desc: "Int", + msg: `int: 10`, + want: message{Int: 10}, + }, { + desc: "NegativeInt", + msg: `int: -10`, + want: message{Int: -10}, + }, { + desc: "PositiveInt", + msg: `int: +10`, + want: message{Int: 10}, + }, { + desc: "Int8", + msg: `int8:1`, + want: message{Int8: 1}, + }, { + desc: "Int16", + msg: `int16:1`, + want: message{Int16: 1}, + }, { + desc: "Int32", + msg: `int32:1`, + want: message{Int32: 1}, + }, { + desc: "Int64", + msg: `int64:1`, + want: message{Int64: 1}, + }, { + desc: "Uint", + msg: `uint:1`, + want: message{Uint: 1}, + }, { + desc: "Uint8", + msg: `uint8:1`, + want: message{Uint8: 1}, + }, { + desc: "Uint16", + msg: `uint16:1`, + want: message{Uint16: 1}, + }, { + desc: "Uint32", + msg: `uint32:1`, + want: message{Uint32: 1}, + }, { + desc: "Uint64", + msg: `uint64:1`, + want: message{Uint64: 1}, + }, { + desc: "IntTrue", + msg: `int:on`, + want: message{Int: 1}, + }, { + desc: "IntFalse", + msg: `int:no`, + want: message{Int: 0}, + }, { + desc: "UintTrue", + msg: `uint:on`, + want: message{Uint: 1}, + }, { + desc: "UintFalse", + msg: `uint:no`, + want: message{Uint: 0}, + }, { + desc: "IntFloat", + msg: `float:-1`, + want: message{Float: -1}, + }, { + desc: "UintFloat", + msg: `float:1`, + want: message{Float: 1}, + }, { + desc: "String", + msg: `string: 'asdf'`, + want: message{String: "asdf"}, + }, { + desc: "DoubleString", + msg: `string: "asdf"`, + want: message{String: "asdf"}, + }, { + desc: "StringEscapeSingle", + msg: `string: 'ain\'t'`, + want: message{String: "ain't"}, + }, { + desc: "DoubleStringEscapeSingle", + msg: `string: "won\'t"`, + want: message{String: "won't"}, + }, { + desc: "StringEscapeDouble", + msg: `string: '\"'`, + want: message{String: `"`}, + }, { + desc: "DoubleStringEscapeDouble", + msg: `string: "\""`, + want: message{String: `"`}, + }, { + desc: "StringEscapeQuestionMark", + msg: `string: "\?"`, + want: message{String: "?"}, + }, { + desc: "StringEscapeBackslash", + msg: `string: '\\'`, + want: message{String: `\`}, + }, { + desc: "StringEscapeA", + msg: `string: '\a'`, + want: message{String: "\a"}, + }, { + desc: "StringEscapeB", + msg: `string: '\b'`, + want: message{String: "\b"}, + }, { + desc: "StringEscapeF", + msg: `string: '\f'`, + want: message{String: "\f"}, + }, { + desc: "StringEscapeN", + msg: `string: '\n'`, + want: message{String: "\n"}, + }, { + desc: "StringEscapeR", + msg: `string: '\r'`, + want: message{String: "\r"}, + }, { + desc: "StringEscapeT", + msg: `string: '\t'`, + want: message{String: "\t"}, + }, { + desc: "StringEscapeV", + msg: `string: '\v'`, + want: message{String: "\v"}, + }, { + desc: "StringHex", + msg: `string: '\x0a'`, + want: message{String: "\n"}, + }, { + desc: "StringHexHighByte", + msg: `string: "\xe4\xb8\x96"`, + want: message{String: "世"}, + }, { + desc: "StringUnicode", + msg: `string: '\u2014'`, + want: message{String: "—"}, + }, { + desc: "StringOctal", + msg: `string: '\033'`, + want: message{String: "\033"}, + }, { + desc: "Message", + msg: `message { field: 10 }`, + want: message{Message: &nestedMessage{Field: 10}}, + }, { + desc: "EmptyMessage", + msg: `message {}`, + want: message{Message: &nestedMessage{}}, + }, { + desc: "Repeated", + msg: ` + repeated: 1 + repeated: 2`, + want: message{Repeated: []int64{1, 2}}, + }, { + desc: "RepeatedList", + msg: `repeated: [1, 2]`, + want: message{Repeated: []int64{1, 2}}, + }, { + desc: "EmptyList", + msg: `repeated: []`, + want: message{Repeated: []int64{}}, + }, { + desc: "RepeatedListTrailingComma", + msg: `repeated: [ + 1, + 2, + ]`, + want: message{Repeated: []int64{1, 2}}, + }, { + desc: "ListOfMessage", + msg: `repeated_message: [{}]`, + want: message{RepeatedMessage: []*nestedMessage{{}}}, + }, { + desc: "CStyleComment", + msg: `message: /** inline comment **/ {}`, + want: message{Message: &nestedMessage{}}, + }, { + desc: "CStyleLineComment", + msg: `message: {} // line comment`, + want: message{Message: &nestedMessage{}}, + }, { + desc: "ConcatStrings", + msg: `string: 'that'"'"'s cool'`, + want: message{String: "that's cool"}, + }, { + desc: "RemoveNewline", + msg: `string: 'remove newline \ +from string'`, + want: message{String: "remove newline from string"}, + }, { + desc: "RemoveNewlineWindows", + msg: "string: 'remove newline \\\r\nfrom string'", + want: message{String: "remove newline from string"}, + }, { + desc: "Base64", + msg: `bytes:"dGVzdA=="`, + want: message{Bytes: []byte("test")}, + }, { + desc: "TextUnmarshaler", + msg: `time:"2025-10-28T07:41:47Z"`, + want: message{Time: time.Date(2025, time.October, 28, 7, 41, 47, 0, time.UTC)}, + }, { + desc: "TextUnmarshalerPointer", + msg: `time_pointer:"2025-10-28T07:41:47Z"`, + want: message{TimePointer: &[]time.Time{time.Date(2025, time.October, 28, 7, 41, 47, 0, time.UTC)}[0]}, + }} { + t.Run(tc.desc, func(t *testing.T) { + t.Parallel() + + var got message + if err := Unmarshal([]byte(tc.msg), &got); err != nil { + t.Fatalf("Unmarshal(%q) failed: %s\n", tc.msg, err) + } + if diff := cmp.Diff(tc.want, got, cmp.AllowUnexported(message{})); diff != "" { + t.Errorf("Unmarshal(%q) returned unexpected diff (-want +got):\n%s", tc.msg, diff) + } + }) + } +} + +func TestUnmarshal_Invalid(t *testing.T) { + t.Parallel() + + type nestedMessage struct { + Int int `ccl:"int"` + } + type message struct { + Int int64 `ccl:"int"` + Int8 int8 `ccl:"int8"` + String string `ccl:"string"` + Msg nestedMessage `ccl:"msg"` + Repeated []int64 `ccl:"repeated"` + RepeatedMsg []nestedMessage `ccl:"repeated_msg"` + Bytes []byte `ccl:"bytes"` + } + + for _, tc := range []struct { + desc string + msg string + }{{ + desc: "BadNum", + msg: `int: .`, + }, { + desc: "BadStringEscape", + msg: `string: '\g'`, + }, { + desc: "BadDoubleStringEscape", + msg: `string: "\g"`, + }, { + desc: "UnterminatedString", + msg: `string: '`, + }, { + desc: "UnterminatedDoubleString", + msg: `string: "`, + }, { + desc: "NoFieldName", + msg: `10`, + }, { + desc: "MsgNoFieldName", + msg: `msg {10}`, + }, { + desc: "ListMissingColon", + msg: `repeated []`, + }, { + desc: "ListMissingComma", + msg: `repeated: [1 2]`, + }, { + desc: "ListBadVal", + msg: `repeated: [asdf]`, + }, { + desc: "ListBadMsgVal", + msg: `repeated_msg: [{asdf}]`, + }, { + desc: "IntLeadingZero", + msg: `int: 0644`, + }, { + desc: "InvalidOctal", + msg: `string: "\777"`, + }, { + desc: "InvalidUTF8", + msg: `string: "\x80"`, + }, { + desc: "FieldMissingVal", + msg: `string`, + }, { + desc: "FieldMissingColon", + msg: `string "abc"`, + }, { + desc: "Repeated", + msg: `int:5 int:6`, + }, { + desc: "IntOutOfRange", + msg: `int8:512`, + }, { + desc: "IntOutOfRangeNegative", + msg: `int8:-512`, + }, { + desc: "Base64", + msg: `bytes:"dGVzdAo"`, + }, { + desc: "BadField", + msg: `asdfasdfasdf:"asdf"`, + }} { + t.Run(tc.desc, func(t *testing.T) { + t.Parallel() + + var got message + err := Unmarshal([]byte(tc.msg), &got) + if err == nil { + t.Errorf("Unmarshal(%q) returned success, want error", tc.msg) + } + }) + } +} + +func TestUnmarshal_InvalidType(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + desc string + msg string + out any + }{{ + desc: "Nil", + msg: `field {}`, + out: (*struct{})(nil), + }, { + desc: "NilMap", + msg: `field {}`, + out: map[string]any(nil), + }, { + desc: "Struct", + msg: `field {}`, + out: new(int), + }, { + desc: "Int", + msg: `Field: 123`, + out: new(struct{ Field string }), + }, { + desc: "NegativeInt", + msg: `Field: -123`, + out: new(struct{ Field string }), + }, { + desc: "IntHex", + msg: `Field: 0x123`, + out: new(struct{ Field string }), + }, { + desc: "Float", + msg: `Field: 123.`, + out: new(struct{ Field int64 }), + }, { + desc: "NestedMessage", + msg: `Field {Field {}}`, + out: new(struct{ Field struct{ Field int64 } }), + }, { + desc: "True", + msg: `F:on`, + out: new(struct{ F string }), + }, { + desc: "False", + msg: `F:no`, + out: new(struct{ F string }), + }, { + desc: "List", + msg: `F:[]`, + out: new(struct{ F int64 }), + }, { + desc: "RepeatedBool", + msg: `F:on F:no`, + out: new(struct{ F []string }), + }, { + desc: "String", + msg: `F:"abc"`, + out: new(struct{ F int64 }), + }, { + desc: "Option", + msg: `F:"abc"`, + out: new(struct { + F string `ccl:",asdf"` + }), + }, { + desc: "OptionNested", + msg: `F{F:"abc"}`, + out: new(struct { + F struct { + F string `ccl:",asdf"` + } + }), + }, { + desc: "OptionNestedPointer", + msg: `F{F:"abc"}`, + out: new(struct { + F *struct { + F string `ccl:",asdf"` + } + }), + }, { + desc: "OptionSlicePointer", + msg: `F{F:"abc"}`, + out: new(struct { + F []*struct { + F string `ccl:",asdf"` + } + }), + }, { + desc: "RepeatedTagName", + msg: `F:"abc"`, + out: new(struct { + F string + G string `ccl:"F"` + }), + }} { + t.Run(tc.desc, func(t *testing.T) { + t.Parallel() + + err := Unmarshal([]byte(tc.msg), tc.out) + if err == nil { + t.Errorf("Unmarshal(%q) returned %+v, want error", tc.msg, tc.out) + } + }) + } +} + +func TestUnmarshal_ErrorLineCol(t *testing.T) { + t.Parallel() + + type message struct { + Secret int64 `ccl:"secret"` + } + + msg := ` + ###### This is a very important file please do not modify + ######################################################### + ################ The more ## I put the more secure it is###### + secret:12345; # oops typo + ` + err := Unmarshal([]byte(msg), new(message)) + syntaxErr, ok := err.(*syntaxError) + if !ok { + t.Fatalf("Unmarshal(%q): expected *syntaxError, got error %T %[2]v", msg, err) + } + want := &syntaxError{line: 5, col: 15} + if syntaxErr.line != want.line || syntaxErr.col != want.col { + t.Errorf("Unmarshal(%q) returned error %+v, want line %d, col %d", msg, syntaxErr, want.line, want.col) + } +} + +func ExampleUnmarshal() { + // Pretend this was loaded from a file + msg := []byte(` + server { + listen: "0.0.0.0:80" + listen: "[::0]:80" + location { + path: "/" + return: "301 https://$host$request_uri" # redirect to https + } + location { + path: "/.well-known/acme-challenge/" + root: "/var/lib/acme/acme-challenge" + auth_basic: off + auth_request: off + } + } + `) + var serverConfig struct { + Server struct { + Listen []string `ccl:"listen"` + Location []struct { + Path string `ccl:"path"` + Root string `ccl:"root"` + AuthBasic bool `ccl:"auth_basic"` + AuthRequest bool `ccl:"auth_request"` + Return string `ccl:"return"` + } `ccl:"location"` + } `ccl:"server"` + } + if err := Unmarshal(msg, &serverConfig); err != nil { + panic(err) + } + fmt.Println(serverConfig.Server.Location[0].Return) + // Output: + // 301 https://$host$request_uri +} -- cgit v1.3.1