feat: TOML 1.0 parser and encoder
Assisted-by: GLM 5.3 Flash
This commit is contained in:
+133
@@ -0,0 +1,133 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package interpres
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TOML distinguishes four date-time kinds. interpres decodes an offset
|
||||
// date-time to a plain time.Time (it carries a zone), and uses the wrapper
|
||||
// types below for the local variants so callers can tell them apart.
|
||||
|
||||
// LocalDateTime is a TOML local date-time with no offset, e.g.
|
||||
// 1979-05-27T07:32:00. The embedded time.Time is in UTC.
|
||||
type LocalDateTime struct{ time.Time }
|
||||
|
||||
// LocalDate is a TOML local date with no time or offset, e.g. 1979-05-27.
|
||||
// The embedded time.Time is at midnight UTC.
|
||||
type LocalDate struct{ time.Time }
|
||||
|
||||
// LocalTime is a TOML local time with no date or offset, e.g. 07:32:00.999999.
|
||||
// The embedded time.Time uses the zero date.
|
||||
type LocalTime struct{ time.Time }
|
||||
|
||||
// String returns the TOML-canonical rendering of the local date-time, e.g.
|
||||
// "1979-05-27T07:32:00" or "...:00.000000123" when the time has a fractional
|
||||
// second. The fractional component is zero-padded to nanosecond precision.
|
||||
func (ldt LocalDateTime) String() string {
|
||||
base := ldt.Format("2006-01-02T15:04:05")
|
||||
if ns := ldt.Nanosecond(); ns > 0 {
|
||||
return base + "." + fmt.Sprintf("%09d", ns)
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
// String returns the TOML-canonical rendering of the local date, e.g.
|
||||
// "1979-05-27".
|
||||
func (ld LocalDate) String() string { return ld.Format("2006-01-02") }
|
||||
|
||||
// String returns the TOML-canonical rendering of the local time, e.g.
|
||||
// "07:32:00" or "...:00.000000123" when the time has a fractional second.
|
||||
// The fractional component is zero-padded to nanosecond precision.
|
||||
func (lt LocalTime) String() string {
|
||||
base := lt.Format("15:04:05")
|
||||
if ns := lt.Nanosecond(); ns > 0 {
|
||||
return base + "." + fmt.Sprintf("%09d", ns)
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
var (
|
||||
offsetDateTimeLayouts = []string{
|
||||
"2006-01-02T15:04:05.999999999Z07:00",
|
||||
"2006-01-02T15:04:05Z07:00",
|
||||
"2006-01-02 15:04:05.999999999Z07:00",
|
||||
"2006-01-02 15:04:05Z07:00",
|
||||
}
|
||||
localDateTimeLayouts = []string{
|
||||
"2006-01-02T15:04:05.999999999",
|
||||
"2006-01-02T15:04:05",
|
||||
"2006-01-02 15:04:05.999999999",
|
||||
"2006-01-02 15:04:05",
|
||||
}
|
||||
localTimeLayouts = []string{
|
||||
"15:04:05.999999999",
|
||||
"15:04:05",
|
||||
}
|
||||
)
|
||||
|
||||
// dateTimeShape enforces the strict TOML grammar (two-digit components) that
|
||||
// time.Parse would otherwise accept loosely (e.g. a single-digit hour).
|
||||
var dateTimeShape = regexp.MustCompile(
|
||||
`^\d{4}-\d{2}-\d{2}([Tt ]\d{2}:\d{2}:\d{2}(\.\d+)?([Zz]|[+-]\d{2}:\d{2})?)?$` +
|
||||
`|^\d{2}:\d{2}:\d{2}(\.\d+)?$`,
|
||||
)
|
||||
|
||||
// parseDateTime classifies and parses a bare token as a TOML date-time value.
|
||||
// It returns the decoded value (time.Time, LocalDateTime, LocalDate, or
|
||||
// LocalTime) and whether the token was a date-time at all.
|
||||
func parseDateTime(tok string) (any, bool) {
|
||||
if tok == "" || tok[0] < '0' || tok[0] > '9' {
|
||||
return nil, false
|
||||
}
|
||||
if !strings.ContainsAny(tok, "-:") {
|
||||
return nil, false
|
||||
}
|
||||
if !dateTimeShape.MatchString(tok) {
|
||||
return nil, false
|
||||
}
|
||||
// The ABNF accepts lowercase "t"/"z"; time.Parse only matches uppercase.
|
||||
norm := strings.ToUpper(tok)
|
||||
for _, layout := range offsetDateTimeLayouts {
|
||||
if t, err := time.Parse(layout, norm); err == nil {
|
||||
return t, true
|
||||
}
|
||||
}
|
||||
for _, layout := range localDateTimeLayouts {
|
||||
if t, err := time.Parse(layout, norm); err == nil {
|
||||
return LocalDateTime{t}, true
|
||||
}
|
||||
}
|
||||
if t, err := time.Parse("2006-01-02", norm); err == nil {
|
||||
return LocalDate{t}, true
|
||||
}
|
||||
for _, layout := range localTimeLayouts {
|
||||
if t, err := time.Parse(layout, norm); err == nil {
|
||||
return LocalTime{t}, true
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// isDateToken reports whether s is exactly a YYYY-MM-DD date, used to detect a
|
||||
// space-separated date-time written as "date<space>time".
|
||||
func isDateToken(s string) bool {
|
||||
if len(s) != 10 {
|
||||
return false
|
||||
}
|
||||
for i := range len(s) {
|
||||
if i == 4 || i == 7 {
|
||||
if s[i] != '-' {
|
||||
return false
|
||||
}
|
||||
} else if !isDecDigit(s[i]) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package interpres
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"reflect"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// decoder maps a parsed TOML tree onto Go values via reflection.
|
||||
type decoder struct {
|
||||
disallowUnknown bool
|
||||
}
|
||||
|
||||
func newDecoder() *decoder { return &decoder{} }
|
||||
|
||||
var timeType = reflect.TypeFor[time.Time]()
|
||||
|
||||
func (d *decoder) decode(tree map[string]any, v any) error {
|
||||
rv := reflect.ValueOf(v)
|
||||
if rv.Kind() != reflect.Pointer || rv.IsNil() {
|
||||
return fmt.Errorf("interpres: decode target must be a non-nil pointer")
|
||||
}
|
||||
return d.assign(tree, rv.Elem())
|
||||
}
|
||||
|
||||
// assign stores data into dst, converting between the TOML value kinds and the
|
||||
// destination's Go type.
|
||||
func (d *decoder) assign(data any, dst reflect.Value) error {
|
||||
if dst.Kind() == reflect.Pointer {
|
||||
if dst.IsNil() {
|
||||
dst.Set(reflect.New(dst.Type().Elem()))
|
||||
}
|
||||
return d.assign(data, dst.Elem())
|
||||
}
|
||||
|
||||
// An any destination takes the value as-is.
|
||||
if dst.Kind() == reflect.Interface && dst.NumMethod() == 0 {
|
||||
dst.Set(reflect.ValueOf(data))
|
||||
return nil
|
||||
}
|
||||
|
||||
// Types implementing Unmarshaler get the parsed data wholesale and are
|
||||
// responsible for setting their own state. The decoder does not consult
|
||||
// any return value; whatever the receiver stores is kept. The lookup
|
||||
// covers both T and *T so a pointer-receiver UnmarshalTOML method is
|
||||
// invoked on an addressable struct field.
|
||||
if dst.CanInterface() {
|
||||
u, ok := dst.Interface().(Unmarshaler)
|
||||
if !ok && dst.CanAddr() {
|
||||
u, ok = dst.Addr().Interface().(Unmarshaler)
|
||||
}
|
||||
if ok {
|
||||
if err := u.UnmarshalTOML(data); err != nil {
|
||||
return fmt.Errorf("unmarshal: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
switch v := data.(type) {
|
||||
case map[string]any:
|
||||
return d.assignTable(v, dst)
|
||||
case []map[string]any:
|
||||
return d.assignTableSlice(v, dst)
|
||||
case []any:
|
||||
return d.assignSlice(v, dst)
|
||||
case string:
|
||||
return setBasic(dst, reflect.ValueOf(v), "string")
|
||||
case bool:
|
||||
return setBasic(dst, reflect.ValueOf(v), "bool")
|
||||
case int64:
|
||||
return setInt(dst, v)
|
||||
case float64:
|
||||
return setFloat(dst, v)
|
||||
case time.Time:
|
||||
if dst.Type() != timeType {
|
||||
return fmt.Errorf("interpres: cannot assign datetime to %s", dst.Type())
|
||||
}
|
||||
dst.Set(reflect.ValueOf(v))
|
||||
return nil
|
||||
default:
|
||||
rv := reflect.ValueOf(data)
|
||||
if rv.IsValid() && dst.Type() == rv.Type() {
|
||||
dst.Set(rv)
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("interpres: cannot assign %T to %s", data, dst.Type())
|
||||
}
|
||||
}
|
||||
|
||||
func (d *decoder) assignTable(tbl map[string]any, dst reflect.Value) error {
|
||||
switch dst.Kind() {
|
||||
case reflect.Struct:
|
||||
return d.assignStruct(tbl, dst)
|
||||
case reflect.Map:
|
||||
return d.assignMap(tbl, dst)
|
||||
default:
|
||||
return fmt.Errorf("interpres: cannot assign table to %s", dst.Type())
|
||||
}
|
||||
}
|
||||
|
||||
func (d *decoder) assignStruct(tbl map[string]any, dst reflect.Value) error {
|
||||
fields := structFields(dst.Type())
|
||||
for key, val := range tbl {
|
||||
field, ok := fields[strings.ToLower(key)]
|
||||
if !ok {
|
||||
if d.disallowUnknown {
|
||||
return fmt.Errorf("interpres: unknown field %q for %s", key, dst.Type())
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err := d.assign(val, dst.Field(field)); err != nil {
|
||||
return fmt.Errorf("%s: %w", key, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *decoder) assignMap(tbl map[string]any, dst reflect.Value) error {
|
||||
if dst.Type().Key().Kind() != reflect.String {
|
||||
return fmt.Errorf("interpres: map key must be a string, got %s", dst.Type().Key())
|
||||
}
|
||||
if dst.IsNil() {
|
||||
dst.Set(reflect.MakeMap(dst.Type()))
|
||||
}
|
||||
elemType := dst.Type().Elem()
|
||||
for key, val := range tbl {
|
||||
elem := reflect.New(elemType).Elem()
|
||||
if err := d.assign(val, elem); err != nil {
|
||||
return fmt.Errorf("%s: %w", key, err)
|
||||
}
|
||||
dst.SetMapIndex(reflect.ValueOf(key), elem)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *decoder) assignSlice(items []any, dst reflect.Value) error {
|
||||
if dst.Kind() != reflect.Slice {
|
||||
return fmt.Errorf("interpres: cannot assign array to %s", dst.Type())
|
||||
}
|
||||
out := reflect.MakeSlice(dst.Type(), len(items), len(items))
|
||||
for i, item := range items {
|
||||
if err := d.assign(item, out.Index(i)); err != nil {
|
||||
return fmt.Errorf("[%d]: %w", i, err)
|
||||
}
|
||||
}
|
||||
dst.Set(out)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *decoder) assignTableSlice(items []map[string]any, dst reflect.Value) error {
|
||||
if dst.Kind() != reflect.Slice {
|
||||
return fmt.Errorf("interpres: cannot assign array of tables to %s", dst.Type())
|
||||
}
|
||||
out := reflect.MakeSlice(dst.Type(), len(items), len(items))
|
||||
for i, item := range items {
|
||||
if err := d.assign(item, out.Index(i)); err != nil {
|
||||
return fmt.Errorf("[%d]: %w", i, err)
|
||||
}
|
||||
}
|
||||
dst.Set(out)
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- low-level setters -----------------------------------------------------
|
||||
|
||||
func setBasic(dst, val reflect.Value, kind string) error {
|
||||
if dst.Kind() != val.Kind() {
|
||||
return fmt.Errorf("interpres: cannot assign %s to %s", kind, dst.Type())
|
||||
}
|
||||
dst.Set(val)
|
||||
return nil
|
||||
}
|
||||
|
||||
func setInt(dst reflect.Value, v int64) error {
|
||||
switch dst.Kind() {
|
||||
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
|
||||
if dst.OverflowInt(v) {
|
||||
return fmt.Errorf("interpres: integer %d overflows %s", v, dst.Type())
|
||||
}
|
||||
dst.SetInt(v)
|
||||
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
|
||||
if v < 0 {
|
||||
return fmt.Errorf("interpres: cannot assign negative %d to %s", v, dst.Type())
|
||||
}
|
||||
var max uint64
|
||||
switch dst.Kind() {
|
||||
case reflect.Uint8:
|
||||
max = math.MaxUint8
|
||||
case reflect.Uint16:
|
||||
max = math.MaxUint16
|
||||
case reflect.Uint32:
|
||||
max = math.MaxUint32
|
||||
}
|
||||
if max != 0 && uint64(v) > max {
|
||||
return fmt.Errorf("interpres: integer %d overflows %s", v, dst.Type())
|
||||
}
|
||||
dst.SetUint(uint64(v))
|
||||
case reflect.Float32, reflect.Float64:
|
||||
dst.SetFloat(float64(v))
|
||||
default:
|
||||
return fmt.Errorf("interpres: cannot assign integer to %s", dst.Type())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func setFloat(dst reflect.Value, v float64) error {
|
||||
switch dst.Kind() {
|
||||
case reflect.Float32, reflect.Float64:
|
||||
dst.SetFloat(v)
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("interpres: cannot assign float to %s", dst.Type())
|
||||
}
|
||||
}
|
||||
|
||||
// structFields builds a lower-cased lookup of field name → field index for the
|
||||
// exported fields of t, honouring `toml:"name"` tags.
|
||||
func structFields(t reflect.Type) map[string]int {
|
||||
fields := make(map[string]int, t.NumField())
|
||||
for i := range t.NumField() {
|
||||
f := t.Field(i)
|
||||
if f.PkgPath != "" { // unexported
|
||||
continue
|
||||
}
|
||||
name := f.Name
|
||||
if tag, ok := f.Tag.Lookup("toml"); ok {
|
||||
tag = strings.Split(tag, ",")[0]
|
||||
if tag == "-" {
|
||||
continue
|
||||
}
|
||||
if tag != "" {
|
||||
name = tag
|
||||
}
|
||||
}
|
||||
fields[strings.ToLower(name)] = i
|
||||
}
|
||||
return fields
|
||||
}
|
||||
+496
@@ -0,0 +1,496 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package interpres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSyntaxErrorMessage(t *testing.T) {
|
||||
err := &SyntaxError{Line: 7, Msg: "expected '=' after key"}
|
||||
want := "interpres: line 7: expected '=' after key"
|
||||
if got := err.Error(); got != want {
|
||||
t.Errorf("Error() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRejectsInvalidUTF8(t *testing.T) {
|
||||
_, err := Parse([]byte("v = \"\xff\"\n"))
|
||||
if err == nil {
|
||||
t.Fatal("expected a UTF-8 validation error")
|
||||
}
|
||||
se, ok := err.(*SyntaxError)
|
||||
if !ok {
|
||||
t.Fatalf("err is %T, want *SyntaxError", err)
|
||||
}
|
||||
if !strings.Contains(se.Msg, "UTF-8") {
|
||||
t.Errorf("Msg = %q, want it to mention UTF-8", se.Msg)
|
||||
}
|
||||
if se.Line != 1 {
|
||||
t.Errorf("Line = %d, want 1", se.Line)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnmarshalIntoMap(t *testing.T) {
|
||||
var m map[string]any
|
||||
if err := Unmarshal([]byte(`name = "x"
|
||||
count = 3
|
||||
`), &m); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if m["name"] != "x" {
|
||||
t.Errorf("name = %#v", m["name"])
|
||||
}
|
||||
if m["count"] != int64(3) {
|
||||
t.Errorf("count = %#v (%T)", m["count"], m["count"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnmarshalIntoMapNested(t *testing.T) {
|
||||
var m map[string]any
|
||||
if err := Unmarshal([]byte("[a]\nb = 2\n"), &m); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
a, ok := m["a"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("a = %T, want map[string]any", m["a"])
|
||||
}
|
||||
if a["b"] != int64(2) {
|
||||
t.Errorf("a.b = %#v", a["b"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnmarshalPrefilledMap(t *testing.T) {
|
||||
m := map[string]any{"keep": "yes"}
|
||||
if err := Unmarshal([]byte(`name = "x"`), &m); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if m["keep"] != "yes" {
|
||||
t.Errorf("keep = %#v", m["keep"])
|
||||
}
|
||||
if m["name"] != "x" {
|
||||
t.Errorf("name = %#v", m["name"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnmarshalTableToNonStructOrMap(t *testing.T) {
|
||||
var s string
|
||||
if err := Unmarshal([]byte("v = 1\n"), &s); err == nil {
|
||||
t.Fatal("expected an error for table-to-scalar")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnmarshalIntoAny(t *testing.T) {
|
||||
// A non-nil any destination must accept the parsed tree.
|
||||
var x any
|
||||
if err := Unmarshal([]byte("[a]\nb = 2\n"), &x); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
tree, ok := x.(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("x is %T, want map[string]any", x)
|
||||
}
|
||||
a, ok := tree["a"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("a is %T, want map[string]any", tree["a"])
|
||||
}
|
||||
if a["b"] != int64(2) {
|
||||
t.Errorf("a.b = %#v", a["b"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnmarshalIntoNilAny(t *testing.T) {
|
||||
// A nil any target must still receive the parsed tree without
|
||||
// panicking.
|
||||
var x any
|
||||
if err := Unmarshal([]byte("v = 1\n"), &x); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if x == nil {
|
||||
t.Fatal("x is still nil after Unmarshal")
|
||||
}
|
||||
tree, ok := x.(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("x is %T, want map[string]any", x)
|
||||
}
|
||||
if tree["v"] != int64(1) {
|
||||
t.Errorf("v = %#v", tree["v"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseContextHonoursCancellation(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
if _, err := ParseContext(ctx, []byte("a = 1\n")); !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("ParseContext returned %v, want context.Canceled", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnmarshalContextHonoursCancellation(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
var cfg map[string]any
|
||||
if err := UnmarshalContext(ctx, []byte("a = 1\n"), &cfg); !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("UnmarshalContext returned %v, want context.Canceled", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecoderDecodeContextHonoursCancellation(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
var cfg map[string]any
|
||||
err := NewDecoder().DecodeContext(ctx, []byte("a = 1\n"), &cfg)
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("DecodeContext returned %v, want context.Canceled", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContextRoundTrip(t *testing.T) {
|
||||
// The *Context variants with a Background context must produce the same
|
||||
// result as the non-context variants for ordinary inputs.
|
||||
in := []byte(`title = "x"
|
||||
count = 3
|
||||
`)
|
||||
if _, err := ParseContext(context.Background(), in); err != nil {
|
||||
t.Fatalf("ParseContext: %v", err)
|
||||
}
|
||||
var out struct {
|
||||
Title string `toml:"title"`
|
||||
Count int `toml:"count"`
|
||||
}
|
||||
if err := UnmarshalContext(context.Background(), in, &out); err != nil {
|
||||
t.Fatalf("UnmarshalContext: %v", err)
|
||||
}
|
||||
if out.Title != "x" || out.Count != 3 {
|
||||
t.Errorf("out = %#v", out)
|
||||
}
|
||||
if err := NewDecoder().DecodeContext(context.Background(), in, &map[string]any{}); err != nil {
|
||||
t.Fatalf("Decoder.DecodeContext: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnmarshalIntToUintOverflow(t *testing.T) {
|
||||
type C struct {
|
||||
X uint8 `toml:"x"`
|
||||
}
|
||||
var c C
|
||||
err := Unmarshal([]byte("x = 300\n"), &c)
|
||||
if err == nil {
|
||||
t.Fatal("expected overflow error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "overflow") {
|
||||
t.Errorf("err = %v, want substring 'overflow'", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnmarshalIntToUint16Boundary(t *testing.T) {
|
||||
type C struct {
|
||||
X uint16 `toml:"x"`
|
||||
}
|
||||
// Exactly 65535 fits, 65536 does not.
|
||||
var ok C
|
||||
if err := Unmarshal([]byte("x = 65535\n"), &ok); err != nil {
|
||||
t.Fatalf("65535 should fit uint16, got %v", err)
|
||||
}
|
||||
if ok.X != 65535 {
|
||||
t.Errorf("X = %d, want 65535", ok.X)
|
||||
}
|
||||
|
||||
var bad C
|
||||
if err := Unmarshal([]byte("x = 65536\n"), &bad); err == nil {
|
||||
t.Fatal("65536 must not fit uint16")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnmarshalIntToUint64FitsMaxInt64(t *testing.T) {
|
||||
type C struct {
|
||||
X uint64 `toml:"x"`
|
||||
}
|
||||
var c C
|
||||
tok := "x = 9223372036854775807\n" // math.MaxInt64
|
||||
if err := Unmarshal([]byte(tok), &c); err != nil {
|
||||
t.Fatalf("MaxInt64 should fit uint64, got %v", err)
|
||||
}
|
||||
if c.X != math.MaxInt64 {
|
||||
t.Errorf("X = %d, want %d", uint64(c.X), uint64(math.MaxInt64))
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnmarshalNegativeIntToUint(t *testing.T) {
|
||||
type C struct {
|
||||
X uint8 `toml:"x"`
|
||||
}
|
||||
var c C
|
||||
err := Unmarshal([]byte("x = -1\n"), &c)
|
||||
if err == nil {
|
||||
t.Fatal("expected negative-to-uint error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "negative") {
|
||||
t.Errorf("err = %v, want substring 'negative'", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnmarshalIntToFloat(t *testing.T) {
|
||||
type C struct {
|
||||
X float64 `toml:"x"`
|
||||
}
|
||||
var c C
|
||||
if err := Unmarshal([]byte("x = 5\n"), &c); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if c.X != 5.0 {
|
||||
t.Errorf("X = %v, want 5.0", c.X)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnmarshalIntToStringFails(t *testing.T) {
|
||||
type C struct {
|
||||
X string `toml:"x"`
|
||||
}
|
||||
var c C
|
||||
if err := Unmarshal([]byte(`x = 5`), &c); err == nil {
|
||||
t.Fatal("expected type-mismatch error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnmarshalStringToIntFails(t *testing.T) {
|
||||
type C struct {
|
||||
X int `toml:"x"`
|
||||
}
|
||||
var c C
|
||||
if err := Unmarshal([]byte(`x = "hello"`), &c); err == nil {
|
||||
t.Fatal("expected type-mismatch error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnmarshalArrayToScalarFails(t *testing.T) {
|
||||
type C struct {
|
||||
X int `toml:"x"`
|
||||
}
|
||||
var c C
|
||||
if err := Unmarshal([]byte("x = [1, 2]\n"), &c); err == nil {
|
||||
t.Fatal("expected type-mismatch error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnmarshalArrayOfTablesToScalarFails(t *testing.T) {
|
||||
type Item struct {
|
||||
Name string `toml:"name"`
|
||||
}
|
||||
type C struct {
|
||||
X Item `toml:"x"`
|
||||
}
|
||||
var c C
|
||||
if err := Unmarshal([]byte("[[x]]\nname = \"a\"\n"), &c); err == nil {
|
||||
t.Fatal("expected type-mismatch error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnmarshalFloatToIntFails(t *testing.T) {
|
||||
type C struct {
|
||||
X int `toml:"x"`
|
||||
}
|
||||
var c C
|
||||
if err := Unmarshal([]byte("x = 1.5\n"), &c); err == nil {
|
||||
t.Fatal("expected type-mismatch error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnmarshalBoolToIntFails(t *testing.T) {
|
||||
type C struct {
|
||||
X int `toml:"x"`
|
||||
}
|
||||
var c C
|
||||
if err := Unmarshal([]byte("x = true\n"), &c); err == nil {
|
||||
t.Fatal("expected type-mismatch error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnmarshalNonPointerRejected(t *testing.T) {
|
||||
var v int
|
||||
if err := Unmarshal([]byte("x = 1\n"), v); err == nil {
|
||||
t.Fatal("expected error for non-pointer target")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnmarshalAssignErrorWrapped(t *testing.T) {
|
||||
// assignStruct wraps inner assignment errors with the key name.
|
||||
type C struct {
|
||||
Inner struct {
|
||||
X int `toml:"x"`
|
||||
} `toml:"inner"`
|
||||
}
|
||||
var c C
|
||||
err := Unmarshal([]byte("[inner]\nx = \"oops\"\n"), &c)
|
||||
if err == nil {
|
||||
t.Fatal("expected an assignment error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "x") {
|
||||
t.Errorf("err = %v, want it to mention key x", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnmarshalAssignMapErrorWrapped(t *testing.T) {
|
||||
// assignMap wraps inner errors with the key of the bad element.
|
||||
m := map[string]int{}
|
||||
err := Unmarshal([]byte("[s]\nx = 1\n"), &m)
|
||||
if err == nil {
|
||||
t.Fatal("expected an assignment error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "s") {
|
||||
t.Errorf("err = %v, want it to mention key 's'", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnmarshalAssignSliceErrorWrapped(t *testing.T) {
|
||||
// assignSlice wraps inner errors with the bad element's index.
|
||||
type C struct {
|
||||
Items []int `toml:"items"`
|
||||
}
|
||||
var c C
|
||||
err := Unmarshal([]byte(`items = [1, "oops"]`), &c)
|
||||
if err == nil {
|
||||
t.Fatal("expected an assignment error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "[1]") {
|
||||
t.Errorf("err = %v, want it to mention index [1]", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnmarshalAssignTimeToWrongTypeFails(t *testing.T) {
|
||||
// assign maps time.Time to time.Time only.
|
||||
type C struct {
|
||||
T string `toml:"t"`
|
||||
}
|
||||
var c C
|
||||
err := Unmarshal([]byte("t = 2026-01-01T00:00:00Z\n"), &c)
|
||||
if err == nil {
|
||||
t.Fatal("expected time-to-string assignment to fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarshalerUsesCustomEncoding(t *testing.T) {
|
||||
// A type that implements Marshaler can be encoded through a wrapping struct.
|
||||
val := inlineMarshaler(func() (any, error) {
|
||||
return map[string]any{"k": "v"}, nil
|
||||
})
|
||||
type wrap struct {
|
||||
Inner inlineMarshaler `toml:"inner"`
|
||||
}
|
||||
out, err := Marshal(wrap{Inner: val})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
got := string(out)
|
||||
if !strings.Contains(got, "[inner]") || !strings.Contains(got, "k = \"v\"") {
|
||||
t.Errorf("out = %q, want a [inner] table with k = \"v\"", got)
|
||||
}
|
||||
}
|
||||
|
||||
type inlineMarshaler func() (any, error)
|
||||
|
||||
func (i inlineMarshaler) MarshalTOML() (any, error) { return i() }
|
||||
|
||||
// --- Unmarshaler -----------------------------------------------------------
|
||||
|
||||
// receiverSetter implements *Unmarshaler by reshaping a parsed table.
|
||||
type receiverSetter struct {
|
||||
Field string
|
||||
Received any
|
||||
}
|
||||
|
||||
func (r *receiverSetter) UnmarshalTOML(data any) error {
|
||||
m, ok := data.(map[string]any)
|
||||
if !ok {
|
||||
return fmt.Errorf("interpres: receiverSetter expects table, got %T", data)
|
||||
}
|
||||
if v, ok := m["field"].(string); ok {
|
||||
r.Field = v
|
||||
}
|
||||
r.Received = data
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestUnmarshalerByPointer(t *testing.T) {
|
||||
type Cfg struct {
|
||||
R receiverSetter `toml:"r"`
|
||||
}
|
||||
var cfg Cfg
|
||||
if err := Unmarshal([]byte(`[r]
|
||||
field = "x"
|
||||
`), &cfg); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if cfg.R.Field != "x" {
|
||||
t.Errorf("Field = %q, want \"x\"", cfg.R.Field)
|
||||
}
|
||||
if cfg.R.Received == nil {
|
||||
t.Error("Receiver did not see parsed data")
|
||||
}
|
||||
}
|
||||
|
||||
type scalarUnmarshaler struct{ val string }
|
||||
|
||||
func (s *scalarUnmarshaler) UnmarshalTOML(data any) error {
|
||||
str, ok := data.(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("interpres: scalarUnmarshaler expects string, got %T", data)
|
||||
}
|
||||
s.val = str
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestUnmarshalerReceivesRawScalar(t *testing.T) {
|
||||
// Place the Unmarshaler-implementing field inside a wrapper struct so
|
||||
// the decoder dispatches the scalar value to its UnmarshalTOML.
|
||||
type Cfg struct {
|
||||
S scalarUnmarshaler `toml:"s"`
|
||||
}
|
||||
var cfg Cfg
|
||||
if err := Unmarshal([]byte(`s = "hello"`), &cfg); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if cfg.S.val != "hello" {
|
||||
t.Errorf("cfg.S.val = %q, want \"hello\"", cfg.S.val)
|
||||
}
|
||||
}
|
||||
|
||||
type failingUnmarshaler struct{}
|
||||
|
||||
func (f *failingUnmarshaler) UnmarshalTOML(_ any) error { return errors.New("boom") }
|
||||
|
||||
func TestUnmarshalerErrorPropagates(t *testing.T) {
|
||||
type Cfg struct {
|
||||
F failingUnmarshaler `toml:"f"`
|
||||
}
|
||||
var cfg Cfg
|
||||
if err := Unmarshal([]byte("f = 1"), &cfg); err == nil {
|
||||
t.Fatal("expected error from UnmarshalTOML")
|
||||
} else if !strings.Contains(err.Error(), "boom") {
|
||||
t.Errorf("err = %v, want substring \"boom\"", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnmarshalerTakesPrecedenceOverDefault(t *testing.T) {
|
||||
// Even when the field is a scalar type and the value is a table,
|
||||
// UnmarshalTOML wins, because the receiver decides.
|
||||
type Cfg struct {
|
||||
R receiverSetter `toml:"r"`
|
||||
}
|
||||
var cfg Cfg
|
||||
in := []byte(`[r]
|
||||
field = "y"
|
||||
`)
|
||||
if err := Unmarshal(in, &cfg); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if cfg.R.Field != "y" {
|
||||
t.Errorf("Field = %q, want \"y\"", cfg.R.Field)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,752 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package interpres
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"math"
|
||||
"reflect"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
var (
|
||||
localDateTimeType = reflect.TypeFor[LocalDateTime]()
|
||||
localDateType = reflect.TypeFor[LocalDate]()
|
||||
localTimeType = reflect.TypeFor[LocalTime]()
|
||||
timeGoType = reflect.TypeFor[time.Time]()
|
||||
)
|
||||
|
||||
// encoder produces a TOML document from a Go value via a small intermediate
|
||||
// representation that preserves the order in which fields were declared.
|
||||
type encoder struct {
|
||||
buf bytes.Buffer
|
||||
ctx context.Context
|
||||
opts Encoder
|
||||
}
|
||||
|
||||
func newEncoder() *encoder { return &encoder{} }
|
||||
|
||||
func (e *encoder) bytes() []byte { return e.buf.Bytes() }
|
||||
|
||||
func (e *encoder) checkCtx() error {
|
||||
if e.ctx == nil {
|
||||
return nil
|
||||
}
|
||||
return e.ctx.Err()
|
||||
}
|
||||
|
||||
// encode converts v into a TOML document. v must be a struct or a
|
||||
// map[string]V (or a non-nil pointer to one).
|
||||
func (e *encoder) encode(v any) error {
|
||||
if err := e.checkCtx(); err != nil {
|
||||
return err
|
||||
}
|
||||
rv := reflect.ValueOf(v)
|
||||
if !rv.IsValid() {
|
||||
return fmt.Errorf("interpres: cannot marshal nil value")
|
||||
}
|
||||
if rv.Kind() == reflect.Pointer {
|
||||
if rv.IsNil() {
|
||||
return fmt.Errorf("interpres: cannot marshal nil pointer")
|
||||
}
|
||||
rv = rv.Elem()
|
||||
}
|
||||
doc := &tomlDoc{ctx: e.ctx, opts: e.opts}
|
||||
switch rv.Kind() {
|
||||
case reflect.Struct:
|
||||
if err := buildStructDoc(rv, doc, ""); err != nil {
|
||||
return err
|
||||
}
|
||||
case reflect.Map:
|
||||
if err := buildMapDoc(rv, doc, ""); err != nil {
|
||||
return err
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("interpres: top-level value must be a struct or map[string]V, got %s", rv.Type())
|
||||
}
|
||||
return e.emitDoc(doc, nil)
|
||||
}
|
||||
|
||||
// --- intermediate representation -----------------------------------------
|
||||
|
||||
// entryKind discriminates the three forms an entry in a tomlDoc may take.
|
||||
type entryKind int
|
||||
|
||||
const (
|
||||
entryScalar entryKind = iota
|
||||
entryTable
|
||||
entryArray
|
||||
)
|
||||
|
||||
// entry is one binding in a tomlDoc. entries live in a single slice in the
|
||||
// order they were added; emission either walks that order directly
|
||||
// (Encoder with GroupByKind(false)) or partitions by kind first
|
||||
// (Encoder with GroupByKind(true), the default).
|
||||
type entry struct {
|
||||
kind entryKind
|
||||
key string
|
||||
val any // entryScalar
|
||||
doc *tomlDoc // entryTable
|
||||
docs []*tomlDoc
|
||||
}
|
||||
|
||||
// tomlDoc holds the entries of one TOML table in declaration order.
|
||||
type tomlDoc struct {
|
||||
entries []entry
|
||||
ctx context.Context // inherited from encoder; nil-safe
|
||||
opts Encoder // inherited from encoder; options drive emit-time behaviour
|
||||
}
|
||||
|
||||
func (d *tomlDoc) checkCtx() error {
|
||||
if d.ctx == nil {
|
||||
return nil
|
||||
}
|
||||
return d.ctx.Err()
|
||||
}
|
||||
|
||||
func (d *tomlDoc) addScalar(key string, val any) {
|
||||
d.entries = append(d.entries, entry{kind: entryScalar, key: key, val: val})
|
||||
}
|
||||
|
||||
func (d *tomlDoc) addTable(key string, sub *tomlDoc) {
|
||||
d.entries = append(d.entries, entry{kind: entryTable, key: key, doc: sub})
|
||||
}
|
||||
|
||||
func (d *tomlDoc) addArray(key string, subs []*tomlDoc) {
|
||||
d.entries = append(d.entries, entry{kind: entryArray, key: key, docs: subs})
|
||||
}
|
||||
|
||||
// partitionedEntries returns the entries grouped by kind, preserving each
|
||||
// group's relative order. The only allocation is the three slice headers.
|
||||
func (d *tomlDoc) partitionedEntries() (scalars []entry, tables []entry, arrays []entry) {
|
||||
for _, e := range d.entries {
|
||||
switch e.kind {
|
||||
case entryScalar:
|
||||
scalars = append(scalars, e)
|
||||
case entryTable:
|
||||
tables = append(tables, e)
|
||||
case entryArray:
|
||||
arrays = append(arrays, e)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// --- reflection walk: struct ---------------------------------------------
|
||||
|
||||
func buildStructDoc(v reflect.Value, doc *tomlDoc, ctx string) error {
|
||||
t := v.Type()
|
||||
for i := range t.NumField() {
|
||||
if i%ctxCheckInterval == 0 {
|
||||
if err := doc.checkCtx(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
f := t.Field(i)
|
||||
if f.PkgPath != "" {
|
||||
continue
|
||||
}
|
||||
if f.Anonymous {
|
||||
tag, _ := f.Tag.Lookup("toml")
|
||||
if tag == "-" {
|
||||
continue
|
||||
}
|
||||
if tag == "" {
|
||||
fv := followPtr(v.Field(i))
|
||||
if !fv.IsValid() {
|
||||
continue
|
||||
}
|
||||
switch fv.Kind() {
|
||||
case reflect.Struct:
|
||||
if isScalarStruct(fv.Type()) {
|
||||
name := strings.ToLower(f.Name)
|
||||
if err := doc.appendScalar(name, fv.Interface(), ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err := buildStructDoc(fv, doc, ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
case reflect.Map:
|
||||
if err := buildMapDoc(fv, doc, ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
name := fieldName(f)
|
||||
if name == "-" {
|
||||
continue
|
||||
}
|
||||
if err := addField(doc, name, v.Field(i), ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// fieldName returns the TOML key for a struct field, honouring the `toml`
|
||||
// tag (name or `-`) and falling back to a lower-cased field name.
|
||||
func fieldName(f reflect.StructField) string {
|
||||
if tag, ok := f.Tag.Lookup("toml"); ok {
|
||||
name, _, _ := strings.Cut(tag, ",")
|
||||
if name == "-" {
|
||||
return "-"
|
||||
}
|
||||
if name != "" {
|
||||
return name
|
||||
}
|
||||
}
|
||||
return strings.ToLower(f.Name)
|
||||
}
|
||||
|
||||
// --- reflection walk: map ------------------------------------------------
|
||||
|
||||
func buildMapDoc(v reflect.Value, doc *tomlDoc, ctx string) error {
|
||||
if v.Type().Key().Kind() != reflect.String {
|
||||
return fmt.Errorf("interpres: map key must be string, got %s", v.Type().Key())
|
||||
}
|
||||
keys := v.MapKeys()
|
||||
slices.SortFunc(keys, func(a, b reflect.Value) int {
|
||||
return strings.Compare(a.String(), b.String())
|
||||
})
|
||||
for i, k := range keys {
|
||||
if i%ctxCheckInterval == 0 {
|
||||
if err := doc.checkCtx(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := addField(doc, k.String(), v.MapIndex(k), ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- reflection walk: field dispatch -------------------------------------
|
||||
|
||||
func addField(doc *tomlDoc, name string, v reflect.Value, ctx string) error {
|
||||
if v.CanInterface() {
|
||||
if m, ok := v.Interface().(Marshaler); ok {
|
||||
mv, err := m.MarshalTOML()
|
||||
if err != nil {
|
||||
return fmt.Errorf("interpres: %s.%s: %w", ctx, name, err)
|
||||
}
|
||||
v = reflect.ValueOf(mv)
|
||||
}
|
||||
}
|
||||
v = followPtr(v)
|
||||
if !v.IsValid() {
|
||||
return nil
|
||||
}
|
||||
if v.Kind() == reflect.Interface {
|
||||
if v.IsNil() {
|
||||
return nil
|
||||
}
|
||||
v = v.Elem()
|
||||
}
|
||||
switch v.Kind() {
|
||||
case reflect.Struct:
|
||||
if isScalarStruct(v.Type()) {
|
||||
return doc.appendScalar(name, v.Interface(), ctx)
|
||||
}
|
||||
return addSubTable(doc, name, v, ctx)
|
||||
case reflect.Map:
|
||||
return addSubTable(doc, name, v, ctx)
|
||||
case reflect.Slice, reflect.Array:
|
||||
return addArrayValue(doc, name, v, ctx)
|
||||
default:
|
||||
val, err := normaliseValue(v)
|
||||
if err != nil {
|
||||
return fmt.Errorf("interpres: %s.%s: %w", ctx, name, err)
|
||||
}
|
||||
return doc.appendScalar(name, val, ctx)
|
||||
}
|
||||
}
|
||||
|
||||
// appendScalar wraps addScalar with a uniform error path.
|
||||
func (d *tomlDoc) appendScalar(name string, val any, ctx string) error {
|
||||
d.addScalar(name, val)
|
||||
return nil
|
||||
}
|
||||
|
||||
func addSubTable(doc *tomlDoc, name string, v reflect.Value, ctx string) error {
|
||||
sub := &tomlDoc{ctx: doc.ctx, opts: doc.opts}
|
||||
switch v.Kind() {
|
||||
case reflect.Struct:
|
||||
if err := buildStructDoc(v, sub, joinKey(ctx, name)); err != nil {
|
||||
return err
|
||||
}
|
||||
case reflect.Map:
|
||||
if err := buildMapDoc(v, sub, joinKey(ctx, name)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
doc.addTable(name, sub)
|
||||
return nil
|
||||
}
|
||||
|
||||
func addArrayValue(doc *tomlDoc, name string, v reflect.Value, ctx string) error {
|
||||
if v.Kind() == reflect.Slice && v.IsNil() {
|
||||
// A nil slice has no explicit representation in TOML, so it is skipped.
|
||||
return nil
|
||||
}
|
||||
n := v.Len()
|
||||
if n == 0 {
|
||||
if isTableElementType(v.Type().Elem()) {
|
||||
// Empty array of tables has no valid TOML form, so it is skipped.
|
||||
return nil
|
||||
}
|
||||
if doc.opts.omitEmptyArrays {
|
||||
return nil
|
||||
}
|
||||
return doc.appendScalar(name, []any{}, ctx)
|
||||
}
|
||||
|
||||
if isTableElementValue(v.Index(0)) {
|
||||
subs := make([]*tomlDoc, n)
|
||||
for i := range n {
|
||||
if i%ctxCheckInterval == 0 {
|
||||
if err := doc.checkCtx(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
ev := followPtr(v.Index(i))
|
||||
if !ev.IsValid() {
|
||||
return fmt.Errorf("interpres: %s.%s[%d]: nil element", ctx, name, i)
|
||||
}
|
||||
sub := &tomlDoc{ctx: doc.ctx, opts: doc.opts}
|
||||
switch ev.Kind() {
|
||||
case reflect.Struct:
|
||||
if isScalarStruct(ev.Type()) {
|
||||
return fmt.Errorf("interpres: %s.%s[%d]: heterogeneous array contains scalar", ctx, name, i)
|
||||
}
|
||||
if err := buildStructDoc(ev, sub, joinKey(ctx, fmt.Sprintf("%s[%d]", name, i))); err != nil {
|
||||
return err
|
||||
}
|
||||
case reflect.Map:
|
||||
if err := buildMapDoc(ev, sub, joinKey(ctx, fmt.Sprintf("%s[%d]", name, i))); err != nil {
|
||||
return err
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("interpres: %s.%s: heterogeneous array, expected table", ctx, name)
|
||||
}
|
||||
subs[i] = sub
|
||||
}
|
||||
doc.addArray(name, subs)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Regular array of scalars.
|
||||
items := make([]any, n)
|
||||
for i := range n {
|
||||
if i%ctxCheckInterval == 0 {
|
||||
if err := doc.checkCtx(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
ev := followPtr(v.Index(i))
|
||||
if !ev.IsValid() {
|
||||
return fmt.Errorf("interpres: %s.%s[%d]: nil element", ctx, name, i)
|
||||
}
|
||||
if ev.CanInterface() {
|
||||
if m, ok := ev.Interface().(Marshaler); ok {
|
||||
mv, err := m.MarshalTOML()
|
||||
if err != nil {
|
||||
return fmt.Errorf("interpres: %s.%s[%d]: %w", ctx, name, i, err)
|
||||
}
|
||||
ev = reflect.ValueOf(mv)
|
||||
ev = followPtr(ev)
|
||||
}
|
||||
}
|
||||
val, err := normaliseValue(ev)
|
||||
if err != nil {
|
||||
return fmt.Errorf("interpres: %s.%s[%d]: %w", ctx, name, i, err)
|
||||
}
|
||||
items[i] = val
|
||||
}
|
||||
return doc.appendScalar(name, items, ctx)
|
||||
}
|
||||
|
||||
// normaliseValue converts a reflect.Value into one of the canonical scalar or
|
||||
// nested-array representations the emitter understands. Slices and arrays are
|
||||
// recursively normalised so that nested arrays (e.g. [][]int) work.
|
||||
func normaliseValue(v reflect.Value) (any, error) {
|
||||
if v.CanInterface() {
|
||||
if m, ok := v.Interface().(Marshaler); ok {
|
||||
return m.MarshalTOML()
|
||||
}
|
||||
}
|
||||
switch v.Kind() {
|
||||
case reflect.String:
|
||||
return v.String(), nil
|
||||
case reflect.Bool:
|
||||
return v.Bool(), nil
|
||||
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
|
||||
return v.Int(), nil
|
||||
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
|
||||
u := v.Uint()
|
||||
if u > math.MaxInt64 {
|
||||
return nil, fmt.Errorf("unsigned value %d overflows int64", u)
|
||||
}
|
||||
return int64(u), nil
|
||||
case reflect.Float32, reflect.Float64:
|
||||
return v.Float(), nil
|
||||
case reflect.Slice, reflect.Array:
|
||||
items := make([]any, v.Len())
|
||||
for i := range v.Len() {
|
||||
val, err := normaliseValue(v.Index(i))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("[%d]: %w", i, err)
|
||||
}
|
||||
items[i] = val
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
if !v.IsValid() {
|
||||
return nil, fmt.Errorf("invalid value")
|
||||
}
|
||||
return nil, fmt.Errorf("cannot encode %s", v.Type())
|
||||
}
|
||||
|
||||
// followPtr unwraps pointer and interface layers. Returns a zero Value if a
|
||||
// nil pointer or nil interface is encountered.
|
||||
func followPtr(v reflect.Value) reflect.Value {
|
||||
for {
|
||||
switch v.Kind() {
|
||||
case reflect.Pointer, reflect.Interface:
|
||||
if v.IsNil() {
|
||||
return reflect.Value{}
|
||||
}
|
||||
v = v.Elem()
|
||||
continue
|
||||
}
|
||||
return v
|
||||
}
|
||||
}
|
||||
|
||||
// isScalarStruct reports whether t is a struct type that the encoder treats
|
||||
// as a TOML scalar (time.Time, LocalDateTime, LocalDate, LocalTime).
|
||||
func isScalarStruct(t reflect.Type) bool {
|
||||
return t == timeGoType || isLocalDateType(t)
|
||||
}
|
||||
|
||||
func isLocalDateType(t reflect.Type) bool {
|
||||
return t == localDateTimeType || t == localDateType || t == localTimeType
|
||||
}
|
||||
|
||||
func isTableElementType(t reflect.Type) bool {
|
||||
switch t.Kind() {
|
||||
case reflect.Struct:
|
||||
return !isScalarStruct(t)
|
||||
case reflect.Map:
|
||||
return t.Key().Kind() == reflect.String
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isTableElementValue(v reflect.Value) bool {
|
||||
v = followPtr(v)
|
||||
if !v.IsValid() {
|
||||
return false
|
||||
}
|
||||
return isTableElementType(v.Type())
|
||||
}
|
||||
|
||||
func joinKey(ctx, name string) string {
|
||||
if ctx == "" {
|
||||
return name
|
||||
}
|
||||
return ctx + "." + name
|
||||
}
|
||||
|
||||
// --- emission ------------------------------------------------------------
|
||||
|
||||
// writeBlankLine writes a single newline before a table or array-of-tables
|
||||
// header so the output has a blank line between sections, unless the buffer
|
||||
// is empty (i.e. this is the very first header).
|
||||
func (e *encoder) writeBlankLine() {
|
||||
if e.buf.Len() == 0 {
|
||||
return
|
||||
}
|
||||
e.buf.WriteByte('\n')
|
||||
}
|
||||
|
||||
func (e *encoder) emitDoc(doc *tomlDoc, prefix []string) error {
|
||||
if e.opts.groupByKind {
|
||||
scalars, tables, arrays := doc.partitionedEntries()
|
||||
for _, kv := range scalars {
|
||||
if err := e.writeKV(kv.key, kv.val); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, t := range tables {
|
||||
path := append(append([]string{}, prefix...), t.key)
|
||||
e.writeBlankLine()
|
||||
e.buf.WriteByte('[')
|
||||
writeKeyPath(&e.buf, path)
|
||||
e.buf.WriteString("]\n")
|
||||
if err := e.emitDoc(t.doc, path); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, a := range arrays {
|
||||
path := append(append([]string{}, prefix...), a.key)
|
||||
for _, sub := range a.docs {
|
||||
e.writeBlankLine()
|
||||
e.buf.WriteString("[[")
|
||||
writeKeyPath(&e.buf, path)
|
||||
e.buf.WriteString("]]\n")
|
||||
if err := e.emitDoc(sub, path); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Preserve declaration order. Scalars and table/array headers may now
|
||||
// interleave, which means each table/array header must include only its
|
||||
// own section content; the emitter still writes sub-documents as separate
|
||||
// nested blocks, so a "" sub-keyed scalar following a header for the same
|
||||
// section is impossible in practice (struct fields are visited in order).
|
||||
for _, ent := range doc.entries {
|
||||
switch ent.kind {
|
||||
case entryScalar:
|
||||
if err := e.writeKV(ent.key, ent.val); err != nil {
|
||||
return err
|
||||
}
|
||||
case entryTable:
|
||||
path := append(append([]string{}, prefix...), ent.key)
|
||||
e.writeBlankLine()
|
||||
e.buf.WriteByte('[')
|
||||
writeKeyPath(&e.buf, path)
|
||||
e.buf.WriteString("]\n")
|
||||
if err := e.emitDoc(ent.doc, path); err != nil {
|
||||
return err
|
||||
}
|
||||
case entryArray:
|
||||
path := append(append([]string{}, prefix...), ent.key)
|
||||
for _, sub := range ent.docs {
|
||||
e.writeBlankLine()
|
||||
e.buf.WriteString("[[")
|
||||
writeKeyPath(&e.buf, path)
|
||||
e.buf.WriteString("]]\n")
|
||||
if err := e.emitDoc(sub, path); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *encoder) writeKV(key string, val any) error {
|
||||
if !utf8.ValidString(key) {
|
||||
return fmt.Errorf("interpres: key %q is not valid UTF-8", key)
|
||||
}
|
||||
e.writeKey(key)
|
||||
e.buf.WriteString(" = ")
|
||||
if err := e.writeValue(val); err != nil {
|
||||
return err
|
||||
}
|
||||
e.buf.WriteByte('\n')
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeKeyPath(buf *bytes.Buffer, path []string) {
|
||||
for i, p := range path {
|
||||
if i > 0 {
|
||||
buf.WriteByte('.')
|
||||
}
|
||||
if isBareKey(p) {
|
||||
buf.WriteString(p)
|
||||
continue
|
||||
}
|
||||
writeQuotedString(buf, p)
|
||||
}
|
||||
}
|
||||
|
||||
func (e *encoder) writeKey(key string) {
|
||||
if isBareKey(key) {
|
||||
e.buf.WriteString(key)
|
||||
return
|
||||
}
|
||||
writeQuotedString(&e.buf, key)
|
||||
}
|
||||
|
||||
// writeQuotedString writes s as a TOML basic string (double-quoted) to buf.
|
||||
// Returns an error only if s is not valid UTF-8; invalid byte sequences
|
||||
// within a valid UTF-8 string are encoded as \ufffd replacement characters.
|
||||
func writeQuotedString(buf *bytes.Buffer, s string) error {
|
||||
if !utf8.ValidString(s) {
|
||||
return fmt.Errorf("interpres: string is not valid UTF-8")
|
||||
}
|
||||
buf.WriteByte('"')
|
||||
for i := 0; i < len(s); {
|
||||
r, size := utf8.DecodeRuneInString(s[i:])
|
||||
if r == utf8.RuneError && size == 1 {
|
||||
buf.WriteString(`\ufffd`)
|
||||
i++
|
||||
continue
|
||||
}
|
||||
i += size
|
||||
writeEscapedRune(buf, r)
|
||||
}
|
||||
buf.WriteByte('"')
|
||||
return nil
|
||||
}
|
||||
|
||||
// writeEscapedRune writes a single rune to buf, escaping it as required by
|
||||
// TOML basic-string rules.
|
||||
func writeEscapedRune(buf *bytes.Buffer, r rune) {
|
||||
switch r {
|
||||
case '\\':
|
||||
buf.WriteString(`\\`)
|
||||
case '"':
|
||||
buf.WriteString(`\"`)
|
||||
case '\b':
|
||||
buf.WriteString(`\b`)
|
||||
case '\t':
|
||||
buf.WriteString(`\t`)
|
||||
case '\n':
|
||||
buf.WriteString(`\n`)
|
||||
case '\f':
|
||||
buf.WriteString(`\f`)
|
||||
case '\r':
|
||||
buf.WriteString(`\r`)
|
||||
default:
|
||||
if r < 0x20 || r == 0x7f {
|
||||
fmt.Fprintf(buf, `\u%04X`, r)
|
||||
} else {
|
||||
buf.WriteRune(r)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func isBareKey(s string) bool {
|
||||
if s == "" {
|
||||
return false
|
||||
}
|
||||
for i := range len(s) {
|
||||
c := s[i]
|
||||
if !((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '_' || c == '-') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (e *encoder) writeValue(val any) error {
|
||||
switch v := val.(type) {
|
||||
case string:
|
||||
return e.writeStringVal(v)
|
||||
case bool:
|
||||
e.buf.WriteString(strconv.FormatBool(v))
|
||||
return nil
|
||||
case int64:
|
||||
e.buf.WriteString(strconv.FormatInt(v, 10))
|
||||
return nil
|
||||
case float64:
|
||||
return e.writeFloat(v)
|
||||
case time.Time:
|
||||
e.buf.WriteString(v.Format(time.RFC3339Nano))
|
||||
return nil
|
||||
case LocalDateTime:
|
||||
e.buf.WriteString(v.String())
|
||||
return nil
|
||||
case LocalDate:
|
||||
e.buf.WriteString(v.String())
|
||||
return nil
|
||||
case LocalTime:
|
||||
e.buf.WriteString(v.String())
|
||||
return nil
|
||||
case []any:
|
||||
e.buf.WriteByte('[')
|
||||
for i, item := range v {
|
||||
if i > 0 {
|
||||
e.buf.WriteString(", ")
|
||||
}
|
||||
if err := e.writeValue(item); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
e.buf.WriteByte(']')
|
||||
return nil
|
||||
case nil:
|
||||
return fmt.Errorf("interpres: cannot encode nil value")
|
||||
default:
|
||||
return fmt.Errorf("interpres: cannot encode %T", val)
|
||||
}
|
||||
}
|
||||
|
||||
func (e *encoder) writeStringVal(s string) error {
|
||||
if e.opts.literalMultilineAt > 0 && strings.ContainsRune(s, '\n') && len(s) >= e.opts.literalMultilineAt {
|
||||
return writeLiteralMultilineString(&e.buf, s)
|
||||
}
|
||||
return writeQuotedString(&e.buf, s)
|
||||
}
|
||||
|
||||
// writeLiteralMultilineString writes s as a TOML literal multi-line string,
|
||||
// surrounded by triple single quotes. The opening delimiter is followed by a
|
||||
// newline that the reader trims, so we always include one. The closing
|
||||
// delimiter sits on its own line; if the value does not end in a newline, one
|
||||
// is inserted before the closing delimiter.
|
||||
func writeLiteralMultilineString(buf *bytes.Buffer, s string) error {
|
||||
if !utf8.ValidString(s) {
|
||||
return fmt.Errorf("interpres: string is not valid UTF-8")
|
||||
}
|
||||
buf.WriteString("'''\n")
|
||||
buf.WriteString(s)
|
||||
if !strings.HasSuffix(s, "\n") {
|
||||
buf.WriteByte('\n')
|
||||
}
|
||||
buf.WriteString("'''")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *encoder) writeFloat(v float64) error {
|
||||
switch {
|
||||
case math.IsNaN(v):
|
||||
e.buf.WriteString("nan")
|
||||
case math.IsInf(v, 1):
|
||||
e.buf.WriteString("inf")
|
||||
case math.IsInf(v, -1):
|
||||
e.buf.WriteString("-inf")
|
||||
case v == 0:
|
||||
// Normalise negative zero to positive zero (TOML has no -0).
|
||||
e.buf.WriteString("0.0")
|
||||
default:
|
||||
s := strconv.FormatFloat(v, 'g', -1, 64)
|
||||
// TOML forbids leading zeros in the exponent digits.
|
||||
if idx := strings.LastIndexAny(s, "eE"); idx >= 0 {
|
||||
mant := s[:idx]
|
||||
exp := s[idx+1:] // e.g. "+06", "-05"
|
||||
sign := ""
|
||||
if len(exp) > 0 && (exp[0] == '+' || exp[0] == '-') {
|
||||
sign = string(exp[0])
|
||||
exp = exp[1:]
|
||||
}
|
||||
exp = strings.TrimLeft(exp, "0")
|
||||
if exp == "" {
|
||||
exp = "0"
|
||||
}
|
||||
s = mant + "e" + sign + exp
|
||||
}
|
||||
if !strings.ContainsAny(s, ".eE") {
|
||||
s += ".0"
|
||||
}
|
||||
e.buf.WriteString(s)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
+1010
File diff suppressed because it is too large
Load Diff
+252
@@ -0,0 +1,252 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package interpres is a dependency-free TOML parser for Go.
|
||||
//
|
||||
// interpres reads and writes TOML documents using only the standard library.
|
||||
// It exposes a small, encoding/json-style API:
|
||||
//
|
||||
// var cfg Config
|
||||
// err := interpres.Unmarshal(data, &cfg)
|
||||
//
|
||||
// out, err := interpres.Marshal(cfg)
|
||||
//
|
||||
// or, for an untyped tree:
|
||||
//
|
||||
// tree, err := interpres.Parse(data)
|
||||
//
|
||||
// A Decoder allows strict decoding that rejects keys without a matching
|
||||
// struct field, mirroring (*json.Decoder).DisallowUnknownFields.
|
||||
package interpres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// A SyntaxError describes a malformed TOML document, including the 1-based
|
||||
// line on which the problem was detected.
|
||||
type SyntaxError struct {
|
||||
Line int
|
||||
Msg string
|
||||
}
|
||||
|
||||
func (e *SyntaxError) Error() string {
|
||||
return fmt.Sprintf("interpres: line %d: %s", e.Line, e.Msg)
|
||||
}
|
||||
|
||||
// Parse decodes a TOML document into a nested map[string]any.
|
||||
//
|
||||
// Values are mapped to Go types as follows: strings to string, integers to
|
||||
// int64, floats to float64, booleans to bool, date-times to time.Time, arrays
|
||||
// to []any, and tables (including inline tables) to map[string]any.
|
||||
//
|
||||
// Parse is equivalent to ParseContext with context.Background.
|
||||
func Parse(data []byte) (map[string]any, error) {
|
||||
return ParseContext(context.Background(), data)
|
||||
}
|
||||
|
||||
// ParseContext decodes a TOML document into a nested map[string]any, obeying
|
||||
// ctx. The context is checked between top-level statements so cancellation is
|
||||
// honoured before the parser has done substantial work.
|
||||
func ParseContext(ctx context.Context, data []byte) (map[string]any, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !utf8.Valid(data) {
|
||||
return nil, &SyntaxError{Line: 1, Msg: "input is not valid UTF-8"}
|
||||
}
|
||||
p := &parser{src: []rune(string(data)), line: 1, ctx: ctx}
|
||||
return p.parse()
|
||||
}
|
||||
|
||||
// Unmarshal parses a TOML document and stores the result in the value pointed
|
||||
// to by v. v is typically a pointer to a struct or to a map[string]any.
|
||||
//
|
||||
// Struct fields are matched to TOML keys by the `toml:"name"` tag, or by a
|
||||
// case-insensitive match on the field name when no tag is present. A tag of
|
||||
// "-" skips the field.
|
||||
//
|
||||
// Unmarshal is equivalent to UnmarshalContext with context.Background.
|
||||
func Unmarshal(data []byte, v any) error {
|
||||
return UnmarshalContext(context.Background(), data, v)
|
||||
}
|
||||
|
||||
// UnmarshalContext is the cancellable variant of Unmarshal.
|
||||
func UnmarshalContext(ctx context.Context, data []byte, v any) error {
|
||||
tree, err := ParseContext(ctx, data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return newDecoder().decode(tree, v)
|
||||
}
|
||||
|
||||
// A Decoder decodes a TOML document into a Go value with configurable
|
||||
// strictness.
|
||||
type Decoder struct {
|
||||
disallowUnknown bool
|
||||
}
|
||||
|
||||
// NewDecoder returns a Decoder.
|
||||
func NewDecoder() *Decoder { return &Decoder{} }
|
||||
|
||||
// DisallowUnknownFields causes Decode to return an error when the document
|
||||
// contains a key with no matching destination struct field.
|
||||
func (d *Decoder) DisallowUnknownFields() *Decoder {
|
||||
d.disallowUnknown = true
|
||||
return d
|
||||
}
|
||||
|
||||
// Decode parses data and stores the result in the value pointed to by v,
|
||||
// honouring the decoder's strictness settings.
|
||||
//
|
||||
// Decode is equivalent to DecodeContext with context.Background.
|
||||
func (d *Decoder) Decode(data []byte, v any) error {
|
||||
return d.DecodeContext(context.Background(), data, v)
|
||||
}
|
||||
|
||||
// DecodeContext is the cancellable variant of Decode.
|
||||
func (d *Decoder) DecodeContext(ctx context.Context, data []byte, v any) error {
|
||||
tree, err := ParseContext(ctx, data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
dec := newDecoder()
|
||||
dec.disallowUnknown = d.disallowUnknown
|
||||
return dec.decode(tree, v)
|
||||
}
|
||||
|
||||
// Marshaler is the interface implemented by types that can produce a custom
|
||||
// TOML representation of themselves. MarshalTOML returns a value that Marshal
|
||||
// then encodes as if the returned value had been passed in its place, which
|
||||
// is useful for emitting a Go type as a different TOML shape (for example, a
|
||||
// struct as an inline table or a primitive alias as a richer value).
|
||||
type Marshaler interface {
|
||||
MarshalTOML() (any, error)
|
||||
}
|
||||
|
||||
// Unmarshaler is the inverse of Marshaler: a type that wants control over
|
||||
// how it is decoded from a TOML value may implement UnmarshalTOML. The data
|
||||
// argument is whatever the parser produced for that key: one of string,
|
||||
// bool, int64, float64, time.Time, LocalDateTime, LocalDate, LocalTime,
|
||||
// []any, or map[string]any. UnmarshalTOML may parse, inspect, or transform
|
||||
// the value however it likes, then store the result by mutating its
|
||||
// receiver through the standard pointer-indirection rules of the reflect
|
||||
// package (i.e. via reflect.Value.Set or by reassigning fields through a
|
||||
// pointer the receiver holds).
|
||||
//
|
||||
// UnmarshalTOML is invoked from (*Decoder).Decode / Unmarshal when the
|
||||
// destination type implements the interface. The decoder does not need to
|
||||
// consult the concrete return value; whatever the receiver stores is kept.
|
||||
type Unmarshaler interface {
|
||||
UnmarshalTOML(data any) error
|
||||
}
|
||||
|
||||
// Marshal returns the TOML 1.0 encoding of v.
|
||||
//
|
||||
// Marshal traverses v using reflection and applies the following rules:
|
||||
//
|
||||
// - The top-level value must be a struct or a map[string]V. Pointers are
|
||||
// followed; a nil top-level pointer is an error.
|
||||
// - Struct fields are matched by `toml:"name"` tag (case-insensitive
|
||||
// fallback to field name; `-` skips). Anonymous (embedded) fields without
|
||||
// a tag are inlined.
|
||||
// - Maps use sorted keys for deterministic output.
|
||||
// - Slices and arrays of structs or maps become TOML arrays of tables; a
|
||||
// nil or empty array of tables is omitted (TOML forbids an empty `[[a]]`),
|
||||
// while other empty arrays emit as `key = []`.
|
||||
// - Other slices and arrays become TOML arrays.
|
||||
// - Scalars encode as TOML scalars: bool, int64, float64, string, time.Time
|
||||
// (offset date-time), and LocalDateTime/LocalDate/LocalTime (local
|
||||
// variants).
|
||||
// - Values implementing Marshaler are encoded by calling MarshalTOML and
|
||||
// using its result.
|
||||
// - nil pointer fields are omitted.
|
||||
//
|
||||
// Marshal cannot encode cyclic data structures; passing one will loop until
|
||||
// the stack overflows. The output is not guaranteed to be byte-identical to
|
||||
// the input that produced v: comments, whitespace, key order (for maps),
|
||||
// string quoting style, and the choice between `[table]` headers and inline
|
||||
// tables are not preserved.
|
||||
//
|
||||
// Marshal is equivalent to MarshalContext with context.Background.
|
||||
func Marshal(v any) ([]byte, error) {
|
||||
return MarshalContext(context.Background(), v)
|
||||
}
|
||||
|
||||
// MarshalContext is the cancellable variant of Marshal.
|
||||
func MarshalContext(ctx context.Context, v any) ([]byte, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return NewEncoder().MarshalContext(ctx, v)
|
||||
}
|
||||
|
||||
// An Encoder encodes Go values into TOML.
|
||||
//
|
||||
// All options default to behaviour that preserves byte-for-byte compatibility
|
||||
// with previous releases and passes the toml-test compliance suite:
|
||||
//
|
||||
// GroupByKind: true (scalars first, then tables, then arrays of tables)
|
||||
// OmitEmptyArrays: false (a nil/empty []string slice emits [] as a value;
|
||||
// a nil/empty []Item struct slice is still skipped)
|
||||
// LiteralMultilineAt: 0 (always emit basic multi-line strings with
|
||||
// escape sequences, never literal ones)
|
||||
//
|
||||
// Use the chainable option methods to opt out. The option state is private;
|
||||
// callers that need the underlying knobs reach for the methods rather than
|
||||
// reading or mutating fields.
|
||||
type Encoder struct {
|
||||
groupByKind bool // default true; set via (*Encoder).GroupByKind
|
||||
omitEmptyArrays bool // default false; set via (*Encoder).OmitEmptyArrays
|
||||
literalMultilineAt int // default 0; set via (*Encoder).UseLiteralMultiline
|
||||
}
|
||||
|
||||
// NewEncoder returns an Encoder with default options.
|
||||
func NewEncoder() *Encoder { return &Encoder{groupByKind: true} }
|
||||
|
||||
// GroupByKind toggles whether fields at the same TOML level are reordered
|
||||
// into the group-by-kind layout (scalars first, then tables, then arrays of
|
||||
// tables). When set to false, the emitter preserves the source declaration
|
||||
// order (struct field order, or sorted key order for maps).
|
||||
func (e *Encoder) GroupByKind(v bool) *Encoder {
|
||||
e.groupByKind = v
|
||||
return e
|
||||
}
|
||||
|
||||
// OmitEmptyArrays opts in to skipping empty (non-nil, length 0) TOML arrays
|
||||
// of scalars. The default emits them as "key = []". Nil slices and empty
|
||||
// arrays of tables are already always omitted.
|
||||
func (e *Encoder) OmitEmptyArrays() *Encoder {
|
||||
e.omitEmptyArrays = true
|
||||
return e
|
||||
}
|
||||
|
||||
// UseLiteralMultiline sets the length threshold at which a multi-line string
|
||||
// is emitted as a literal triple-quoted string instead of the escaped form.
|
||||
// Use 0 or any negative value to disable (always escaped). The literal form
|
||||
// is selected only when the value contains an internal newline; otherwise the
|
||||
// single-line basic form is used regardless of this setting.
|
||||
func (e *Encoder) UseLiteralMultiline(threshold int) *Encoder {
|
||||
e.literalMultilineAt = threshold
|
||||
return e
|
||||
}
|
||||
|
||||
// Marshal encodes v to TOML bytes. It is equivalent to calling Marshal with v.
|
||||
//
|
||||
// Marshal is equivalent to MarshalContext with context.Background.
|
||||
func (e *Encoder) Marshal(v any) ([]byte, error) {
|
||||
return e.MarshalContext(context.Background(), v)
|
||||
}
|
||||
|
||||
// MarshalContext is the cancellable variant of Marshal.
|
||||
func (e *Encoder) MarshalContext(ctx context.Context, v any) ([]byte, error) {
|
||||
enc := newEncoder()
|
||||
enc.ctx = ctx
|
||||
enc.opts = *e
|
||||
if err := enc.encode(v); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return enc.bytes(), nil
|
||||
}
|
||||
@@ -0,0 +1,513 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package interpres
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestParseScalars(t *testing.T) {
|
||||
tree, err := Parse([]byte(`
|
||||
title = "interpres"
|
||||
count = 42
|
||||
ratio = 3.14
|
||||
enabled = true
|
||||
disabled = false
|
||||
hexv = 0xFF
|
||||
octv = 0o755
|
||||
binv = 0b1010
|
||||
grouped = 1_000_000
|
||||
neg = -17
|
||||
expv = 1e3
|
||||
`))
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
|
||||
cases := map[string]any{
|
||||
"title": "interpres",
|
||||
"count": int64(42),
|
||||
"ratio": 3.14,
|
||||
"enabled": true,
|
||||
"disabled": false,
|
||||
"hexv": int64(255),
|
||||
"octv": int64(493),
|
||||
"binv": int64(10),
|
||||
"grouped": int64(1000000),
|
||||
"neg": int64(-17),
|
||||
"expv": 1000.0,
|
||||
}
|
||||
for k, want := range cases {
|
||||
if got := tree[k]; got != want {
|
||||
t.Errorf("%s = %#v (%T), want %#v (%T)", k, got, got, want, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseInfNan(t *testing.T) {
|
||||
tree, err := Parse([]byte("pos = inf\nneg = -inf\nbad = nan\n"))
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
if v := tree["pos"].(float64); !math.IsInf(v, 1) {
|
||||
t.Errorf("pos = %v, want +Inf", v)
|
||||
}
|
||||
if v := tree["neg"].(float64); !math.IsInf(v, -1) {
|
||||
t.Errorf("neg = %v, want -Inf", v)
|
||||
}
|
||||
if v := tree["bad"].(float64); !math.IsNaN(v) {
|
||||
t.Errorf("bad = %v, want NaN", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseStrings(t *testing.T) {
|
||||
tree, err := Parse([]byte(`
|
||||
basic = "a\tb\nc"
|
||||
literal = 'C:\path\no\escape'
|
||||
quote = "say \"hi\""
|
||||
unicode = "\u00e9"
|
||||
`))
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
if tree["basic"] != "a\tb\nc" {
|
||||
t.Errorf("basic = %q", tree["basic"])
|
||||
}
|
||||
if tree["literal"] != `C:\path\no\escape` {
|
||||
t.Errorf("literal = %q", tree["literal"])
|
||||
}
|
||||
if tree["quote"] != `say "hi"` {
|
||||
t.Errorf("quote = %q", tree["quote"])
|
||||
}
|
||||
if tree["unicode"] != "é" {
|
||||
t.Errorf("unicode = %q", tree["unicode"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMultilineString(t *testing.T) {
|
||||
tree, err := Parse([]byte("text = \"\"\"\nfirst\nsecond\"\"\"\n"))
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
if tree["text"] != "first\nsecond" {
|
||||
t.Errorf("text = %q, want %q", tree["text"], "first\nsecond")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMultilineLineEndingBackslash(t *testing.T) {
|
||||
tree, err := Parse([]byte("text = \"\"\"\\\n one \\\n two\"\"\"\n"))
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
if tree["text"] != "one two" {
|
||||
t.Errorf("text = %q, want %q", tree["text"], "one two")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTablesAndDottedKeys(t *testing.T) {
|
||||
tree, err := Parse([]byte(`
|
||||
owner.name = "Petr"
|
||||
|
||||
[server]
|
||||
host = "localhost"
|
||||
port = 9090
|
||||
|
||||
[server.tls]
|
||||
enabled = true
|
||||
`))
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
server := tree["server"].(map[string]any)
|
||||
if server["host"] != "localhost" || server["port"] != int64(9090) {
|
||||
t.Errorf("server = %#v", server)
|
||||
}
|
||||
tls := server["tls"].(map[string]any)
|
||||
if tls["enabled"] != true {
|
||||
t.Errorf("tls = %#v", tls)
|
||||
}
|
||||
owner := tree["owner"].(map[string]any)
|
||||
if owner["name"] != "Petr" {
|
||||
t.Errorf("owner = %#v", owner)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseArrayOfTables(t *testing.T) {
|
||||
tree, err := Parse([]byte(`
|
||||
[[forms]]
|
||||
name = "contact"
|
||||
|
||||
[[forms]]
|
||||
name = "feedback"
|
||||
`))
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
forms := tree["forms"].([]map[string]any)
|
||||
if len(forms) != 2 {
|
||||
t.Fatalf("len(forms) = %d, want 2", len(forms))
|
||||
}
|
||||
if forms[0]["name"] != "contact" || forms[1]["name"] != "feedback" {
|
||||
t.Errorf("forms = %#v", forms)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseArraysAndInlineTables(t *testing.T) {
|
||||
tree, err := Parse([]byte(`
|
||||
ports = [80, 443]
|
||||
mixed = [
|
||||
"a",
|
||||
"b",
|
||||
]
|
||||
point = { x = 1, y = 2 }
|
||||
`))
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
ports := tree["ports"].([]any)
|
||||
if len(ports) != 2 || ports[0] != int64(80) || ports[1] != int64(443) {
|
||||
t.Errorf("ports = %#v", ports)
|
||||
}
|
||||
mixed := tree["mixed"].([]any)
|
||||
if len(mixed) != 2 || mixed[0] != "a" || mixed[1] != "b" {
|
||||
t.Errorf("mixed = %#v", mixed)
|
||||
}
|
||||
point := tree["point"].(map[string]any)
|
||||
if point["x"] != int64(1) || point["y"] != int64(2) {
|
||||
t.Errorf("point = %#v", point)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDateTime(t *testing.T) {
|
||||
tree, err := Parse([]byte(`
|
||||
offset = 1979-05-27T07:32:00Z
|
||||
local = 1979-05-27T07:32:00
|
||||
day = 1979-05-27
|
||||
clock = 07:32:00
|
||||
`))
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
if off, ok := tree["offset"].(time.Time); !ok || off.Year() != 1979 || off.Hour() != 7 {
|
||||
t.Errorf("offset = %#v (%T)", tree["offset"], tree["offset"])
|
||||
}
|
||||
if ldt, ok := tree["local"].(LocalDateTime); !ok || ldt.Year() != 1979 || ldt.Hour() != 7 {
|
||||
t.Errorf("local = %#v (%T)", tree["local"], tree["local"])
|
||||
}
|
||||
if d, ok := tree["day"].(LocalDate); !ok || d.Month() != time.May || d.Day() != 27 {
|
||||
t.Errorf("day = %#v (%T)", tree["day"], tree["day"])
|
||||
}
|
||||
if clk, ok := tree["clock"].(LocalTime); !ok || clk.Hour() != 7 || clk.Minute() != 32 {
|
||||
t.Errorf("clock = %#v (%T)", tree["clock"], tree["clock"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDateTimeFormats(t *testing.T) {
|
||||
tree, err := Parse([]byte("a = 1987-07-05 17:45:00Z\nb = 1987-07-05t17:45:00z\nc = 1977-12-21T10:32:00.555\n"))
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
if _, ok := tree["a"].(time.Time); !ok {
|
||||
t.Errorf("a is %T, want time.Time", tree["a"])
|
||||
}
|
||||
if _, ok := tree["b"].(time.Time); !ok {
|
||||
t.Errorf("b is %T, want time.Time", tree["b"])
|
||||
}
|
||||
if _, ok := tree["c"].(LocalDateTime); !ok {
|
||||
t.Errorf("c is %T, want LocalDateTime", tree["c"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnmarshalDateTime(t *testing.T) {
|
||||
type Doc struct {
|
||||
Created time.Time `toml:"created"`
|
||||
Day LocalDate `toml:"day"`
|
||||
}
|
||||
var d Doc
|
||||
if err := Unmarshal([]byte("created = 2026-06-20T10:00:00Z\nday = 2026-06-20\n"), &d); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if d.Created.Year() != 2026 || d.Created.Hour() != 10 {
|
||||
t.Errorf("Created = %v", d.Created)
|
||||
}
|
||||
if d.Day.Year() != 2026 || d.Day.Month() != time.June || d.Day.Day() != 20 {
|
||||
t.Errorf("Day = %v", d.Day)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnmarshalStruct(t *testing.T) {
|
||||
type SMTP struct {
|
||||
Host string `toml:"host"`
|
||||
Port int `toml:"port"`
|
||||
}
|
||||
type Form struct {
|
||||
Name string `toml:"name"`
|
||||
SMTP SMTP `toml:"smtp"`
|
||||
Origins []string `toml:"allowed_origins"`
|
||||
}
|
||||
type Config struct {
|
||||
Port int `toml:"port"`
|
||||
Forms []Form `toml:"forms"`
|
||||
}
|
||||
|
||||
data := []byte(`
|
||||
port = 8080
|
||||
|
||||
[[forms]]
|
||||
name = "contact"
|
||||
allowed_origins = ["https://example.com"]
|
||||
|
||||
[forms.smtp]
|
||||
host = "smtp.example.com"
|
||||
port = 587
|
||||
`)
|
||||
|
||||
var cfg Config
|
||||
if err := Unmarshal(data, &cfg); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if cfg.Port != 8080 {
|
||||
t.Errorf("Port = %d", cfg.Port)
|
||||
}
|
||||
if len(cfg.Forms) != 1 {
|
||||
t.Fatalf("len(Forms) = %d", len(cfg.Forms))
|
||||
}
|
||||
f := cfg.Forms[0]
|
||||
if f.Name != "contact" || f.SMTP.Host != "smtp.example.com" || f.SMTP.Port != 587 {
|
||||
t.Errorf("form = %#v", f)
|
||||
}
|
||||
if len(f.Origins) != 1 || f.Origins[0] != "https://example.com" {
|
||||
t.Errorf("origins = %#v", f.Origins)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnmarshalCaseInsensitiveAndUntagged(t *testing.T) {
|
||||
type Config struct {
|
||||
Title string
|
||||
Count int
|
||||
}
|
||||
var cfg Config
|
||||
if err := Unmarshal([]byte("title = \"x\"\ncount = 3\n"), &cfg); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if cfg.Title != "x" || cfg.Count != 3 {
|
||||
t.Errorf("cfg = %#v", cfg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDisallowUnknownFields(t *testing.T) {
|
||||
type C struct {
|
||||
Known string `toml:"known"`
|
||||
}
|
||||
data := []byte("known = \"x\"\nbogus = 1\n")
|
||||
|
||||
var lenient C
|
||||
if err := Unmarshal(data, &lenient); err != nil {
|
||||
t.Fatalf("lenient unmarshal: %v", err)
|
||||
}
|
||||
|
||||
var strict C
|
||||
err := NewDecoder().DisallowUnknownFields().Decode(data, &strict)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for unknown field, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkippedFieldTag(t *testing.T) {
|
||||
type C struct {
|
||||
Keep string `toml:"keep"`
|
||||
Skip string `toml:"-"`
|
||||
}
|
||||
var c C
|
||||
if err := Unmarshal([]byte("keep = \"y\"\n"), &c); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if c.Keep != "y" || c.Skip != "" {
|
||||
t.Errorf("c = %#v", c)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyntaxErrorReportsLine(t *testing.T) {
|
||||
_, err := Parse([]byte("a = 1\nb = \nc = 3\n"))
|
||||
if err == nil {
|
||||
t.Fatal("expected a syntax error")
|
||||
}
|
||||
se, ok := err.(*SyntaxError)
|
||||
if !ok {
|
||||
t.Fatalf("error is %T, want *SyntaxError", err)
|
||||
}
|
||||
if se.Line != 2 {
|
||||
t.Errorf("Line = %d, want 2", se.Line)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComments(t *testing.T) {
|
||||
tree, err := Parse([]byte(`
|
||||
# a leading comment
|
||||
key = "value" # trailing comment
|
||||
# another
|
||||
`))
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
if tree["key"] != "value" {
|
||||
t.Errorf("key = %q", tree["key"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDuplicateKeyRejected(t *testing.T) {
|
||||
_, err := Parse([]byte("a = 1\na = 2\n"))
|
||||
if err == nil {
|
||||
t.Fatal("expected duplicate key error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRejectsInvalidNumbers(t *testing.T) {
|
||||
for _, tok := range []string{
|
||||
"01", "-01", "00",
|
||||
"1__0", "_1", "1_", "0x_1", "1_.0",
|
||||
"1.", ".5", "1.2.3", "1.e2",
|
||||
"0x", "0o", "0b", "0b2", "0o8", "0xG",
|
||||
"+0x1",
|
||||
} {
|
||||
if _, err := Parse([]byte("v = " + tok + "\n")); err == nil {
|
||||
t.Errorf("%q: expected an error, got none", tok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAcceptsNumberEdgeCases(t *testing.T) {
|
||||
cases := map[string]any{
|
||||
"0": int64(0),
|
||||
"-0": int64(0),
|
||||
"+99": int64(99),
|
||||
"1_000": int64(1000),
|
||||
"0xDEAD_BEEF": int64(0xDEADBEEF),
|
||||
"0o755": int64(493),
|
||||
"0b1010": int64(10),
|
||||
"0.0": 0.0,
|
||||
"3.14": 3.14,
|
||||
"6.022e23": 6.022e23,
|
||||
"1e10": 1e10,
|
||||
"-2.5E-3": -2.5e-3,
|
||||
}
|
||||
for tok, want := range cases {
|
||||
tree, err := Parse([]byte("v = " + tok + "\n"))
|
||||
if err != nil {
|
||||
t.Errorf("%q: %v", tok, err)
|
||||
continue
|
||||
}
|
||||
if got := tree["v"]; got != want {
|
||||
t.Errorf("%q = %#v (%T), want %#v", tok, got, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRejectsTableRedefinition(t *testing.T) {
|
||||
_, err := Parse([]byte("[a]\nx = 1\n\n[a]\ny = 2\n"))
|
||||
if err == nil {
|
||||
t.Fatal("expected a table-redefinition error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllowsImplicitThenExplicitTable(t *testing.T) {
|
||||
tree, err := Parse([]byte("[a.b]\nx = 1\n\n[a]\ny = 2\n"))
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
a := tree["a"].(map[string]any)
|
||||
if a["y"] != int64(2) {
|
||||
t.Errorf("a.y = %#v", a["y"])
|
||||
}
|
||||
if b := a["b"].(map[string]any); b["x"] != int64(1) {
|
||||
t.Errorf("a.b.x = %#v", b["x"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestRejectsControlCharInString(t *testing.T) {
|
||||
if _, err := Parse([]byte("v = \"a\x01b\"\n")); err == nil {
|
||||
t.Fatal("expected a control-character error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllowsEscapedControlChar(t *testing.T) {
|
||||
tree, err := Parse([]byte(`v = "\u0000"`))
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
if tree["v"] != "\x00" {
|
||||
t.Errorf("v = %q", tree["v"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestMultilineQuotesAtDelimiter(t *testing.T) {
|
||||
tree, err := Parse([]byte("a = '''''two quotes'''''\n"))
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
if tree["a"] != "''two quotes''" {
|
||||
t.Errorf("a = %q", tree["a"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestRejectsInlineTableExtension(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"by header": "a = { b = 1 }\n[a.c]\nx = 2\n",
|
||||
"by dotted key": "a = { b = 1 }\na.c = 2\n",
|
||||
"header over it": "a = { b = 1 }\n[a]\nx = 2\n",
|
||||
}
|
||||
for name, doc := range cases {
|
||||
if _, err := Parse([]byte(doc)); err == nil {
|
||||
t.Errorf("%s: expected an inline-table extension error", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRejectsSpecInvalid(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"single-digit hour": "a = 2023-10-01T1:32:00Z\n",
|
||||
"inline duplicate key": "a = { b = 1, b = 2 }\n",
|
||||
"inline dotted overwrite": "a = { b = 1, b.c = 2 }\n",
|
||||
"dotted over header": "[a.b]\nx = 1\n[a]\nb.y = 2\n",
|
||||
"table over array": "[[t]]\n[t]\n",
|
||||
"truncated datetime": "a = 2026-01-02T\n",
|
||||
"datetime no seconds": "a = 2026-01-02T07:32\n",
|
||||
}
|
||||
for name, doc := range cases {
|
||||
if _, err := Parse([]byte(doc)); err == nil {
|
||||
t.Errorf("%s: expected an error", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestArrayOfTablesPerElementSubtable(t *testing.T) {
|
||||
tree, err := Parse([]byte(`
|
||||
[[forms]]
|
||||
name = "a"
|
||||
|
||||
[forms.smtp]
|
||||
host = "h1"
|
||||
|
||||
[[forms]]
|
||||
name = "b"
|
||||
|
||||
[forms.smtp]
|
||||
host = "h2"
|
||||
`))
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
forms := tree["forms"].([]map[string]any)
|
||||
if len(forms) != 2 {
|
||||
t.Fatalf("len(forms) = %d", len(forms))
|
||||
}
|
||||
if h := forms[0]["smtp"].(map[string]any)["host"]; h != "h1" {
|
||||
t.Errorf("forms[0].smtp.host = %v", h)
|
||||
}
|
||||
if h := forms[1]["smtp"].(map[string]any)["host"]; h != "h2" {
|
||||
t.Errorf("forms[1].smtp.host = %v", h)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package interpres
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// decodeNumber parses a bare numeric token under strict TOML rules: no leading
|
||||
// zeros, underscores only between digits, prefixed radixes without a sign, and
|
||||
// floats with explicit fraction/exponent digits.
|
||||
func decodeNumber(tok string) (any, error) {
|
||||
switch tok {
|
||||
case "inf", "+inf":
|
||||
return math.Inf(1), nil
|
||||
case "-inf":
|
||||
return math.Inf(-1), nil
|
||||
case "nan", "+nan", "-nan":
|
||||
return math.NaN(), nil
|
||||
}
|
||||
|
||||
if len(tok) >= 2 && tok[0] == '0' && (tok[1] == 'x' || tok[1] == 'o' || tok[1] == 'b') {
|
||||
return decodeRadix(tok)
|
||||
}
|
||||
if strings.ContainsAny(tok, ".eE") {
|
||||
return decodeFloat(tok)
|
||||
}
|
||||
return decodeDecimalInt(tok)
|
||||
}
|
||||
|
||||
func decodeDecimalInt(tok string) (any, error) {
|
||||
sign, body := splitSign(tok)
|
||||
digits, err := joinDigits(body, isDecDigit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := checkNoLeadingZero(digits); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
i, err := strconv.ParseInt(sign+digits, 10, 64)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("integer %q out of range", tok)
|
||||
}
|
||||
return i, nil
|
||||
}
|
||||
|
||||
func decodeRadix(tok string) (any, error) {
|
||||
var base int
|
||||
var isDigit func(byte) bool
|
||||
switch tok[1] {
|
||||
case 'x':
|
||||
base, isDigit = 16, isHexDigit
|
||||
case 'o':
|
||||
base, isDigit = 8, isOctDigit
|
||||
case 'b':
|
||||
base, isDigit = 2, isBinDigit
|
||||
}
|
||||
digits, err := joinDigits(tok[2:], isDigit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
i, err := strconv.ParseInt(digits, base, 64)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("integer %q out of range", tok)
|
||||
}
|
||||
return i, nil
|
||||
}
|
||||
|
||||
func decodeFloat(tok string) (any, error) {
|
||||
sign, s := splitSign(tok)
|
||||
|
||||
mantissa, exp := s, ""
|
||||
if i := strings.IndexAny(s, "eE"); i >= 0 {
|
||||
mantissa, exp = s[:i], s[i+1:]
|
||||
}
|
||||
|
||||
intPart, frac, hasDot := mantissa, "", false
|
||||
if i := strings.IndexByte(mantissa, '.'); i >= 0 {
|
||||
intPart, frac, hasDot = mantissa[:i], mantissa[i+1:], true
|
||||
}
|
||||
if !hasDot && exp == "" {
|
||||
return nil, fmt.Errorf("invalid float %q", tok)
|
||||
}
|
||||
|
||||
ip, err := joinDigits(intPart, isDecDigit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := checkNoLeadingZero(ip); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
build := sign + ip
|
||||
|
||||
if hasDot {
|
||||
fp, err := joinDigits(frac, isDecDigit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
build += "." + fp
|
||||
}
|
||||
if exp != "" {
|
||||
esign, edigits := splitSign(exp)
|
||||
ed, err := joinDigits(edigits, isDecDigit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
build += "e" + esign + ed
|
||||
}
|
||||
|
||||
f, err := strconv.ParseFloat(build, 64)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid float %q", tok)
|
||||
}
|
||||
return f, nil
|
||||
}
|
||||
|
||||
// joinDigits validates that every rune is a digit (per isDigit) and that each
|
||||
// underscore sits between two digits, returning the digits with underscores
|
||||
// removed.
|
||||
func joinDigits(s string, isDigit func(byte) bool) (string, error) {
|
||||
if s == "" {
|
||||
return "", fmt.Errorf("number is missing digits")
|
||||
}
|
||||
var b strings.Builder
|
||||
for i := range len(s) {
|
||||
c := s[i]
|
||||
if c == '_' {
|
||||
if i == 0 || i == len(s)-1 || !isDigit(s[i-1]) || !isDigit(s[i+1]) {
|
||||
return "", fmt.Errorf("misplaced underscore in number %q", s)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if !isDigit(c) {
|
||||
return "", fmt.Errorf("invalid character %q in number", string(c))
|
||||
}
|
||||
b.WriteByte(c)
|
||||
}
|
||||
return b.String(), nil
|
||||
}
|
||||
|
||||
func checkNoLeadingZero(digits string) error {
|
||||
if len(digits) > 1 && digits[0] == '0' {
|
||||
return fmt.Errorf("leading zeros are not allowed in numbers")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func splitSign(tok string) (sign, rest string) {
|
||||
if tok != "" && (tok[0] == '+' || tok[0] == '-') {
|
||||
if tok[0] == '-' {
|
||||
return "-", tok[1:]
|
||||
}
|
||||
return "", tok[1:]
|
||||
}
|
||||
return "", tok
|
||||
}
|
||||
|
||||
func isDecDigit(c byte) bool { return c >= '0' && c <= '9' }
|
||||
func isOctDigit(c byte) bool { return c >= '0' && c <= '7' }
|
||||
func isBinDigit(c byte) bool { return c == '0' || c == '1' }
|
||||
func isHexDigit(c byte) bool {
|
||||
return isDecDigit(c) || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')
|
||||
}
|
||||
@@ -0,0 +1,929 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package interpres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ctxCheckInterval is the number of top-level parser iterations between
|
||||
// context-cancellation checks. A small interval keeps the response snappy on
|
||||
// cancellation; a too-small one wastes cycles on a non-cancelled run.
|
||||
const ctxCheckInterval = 64
|
||||
|
||||
// parser is a recursive-descent TOML parser producing a map[string]any tree.
|
||||
type parser struct {
|
||||
src []rune
|
||||
pos int
|
||||
line int
|
||||
ctx context.Context
|
||||
|
||||
root map[string]any
|
||||
current map[string]any
|
||||
headers map[string]bool
|
||||
frozen map[string]bool
|
||||
dotted map[string]bool
|
||||
arrays map[string]bool
|
||||
|
||||
currentPath []string
|
||||
}
|
||||
|
||||
func (p *parser) parse() (map[string]any, error) {
|
||||
p.root = map[string]any{}
|
||||
p.current = p.root
|
||||
p.headers = map[string]bool{}
|
||||
p.frozen = map[string]bool{}
|
||||
p.dotted = map[string]bool{}
|
||||
p.arrays = map[string]bool{}
|
||||
p.currentPath = nil
|
||||
|
||||
for i := 0; ; i++ {
|
||||
if i%ctxCheckInterval == 0 {
|
||||
if err := p.checkCtx(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if err := p.skipBlank(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if p.eof() {
|
||||
break
|
||||
}
|
||||
c := p.peek()
|
||||
switch {
|
||||
case c == '[':
|
||||
if err := p.parseTableHeader(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
default:
|
||||
if err := p.parseKeyValue(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if err := p.expectLineEnd(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return p.root, nil
|
||||
}
|
||||
|
||||
// checkCtx returns ctx.Err() when the context has been cancelled, nil
|
||||
// otherwise. The call is a no-op when ctx is nil or the zero Background
|
||||
// context, both of which never cancel.
|
||||
func (p *parser) checkCtx() error {
|
||||
if p.ctx == nil {
|
||||
return nil
|
||||
}
|
||||
return p.ctx.Err()
|
||||
}
|
||||
|
||||
// --- table headers ---------------------------------------------------------
|
||||
|
||||
func (p *parser) parseTableHeader() error {
|
||||
array := false
|
||||
p.next() // consume '['
|
||||
if !p.eof() && p.peek() == '[' {
|
||||
array = true
|
||||
p.next()
|
||||
}
|
||||
|
||||
key, err := p.parseKeyPath()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
p.skipInline()
|
||||
if p.eof() || p.peek() != ']' {
|
||||
return p.errf("expected ']' to close table header")
|
||||
}
|
||||
p.next()
|
||||
if array {
|
||||
if p.eof() || p.peek() != ']' {
|
||||
return p.errf("expected ']]' to close array-of-tables header")
|
||||
}
|
||||
p.next()
|
||||
}
|
||||
|
||||
if array {
|
||||
tbl, err := p.appendArrayTable(key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// A new array-of-tables element starts a fresh scope: sub-table headers
|
||||
// and inline-table freezes from the previous element no longer apply.
|
||||
p.resetScopeUnder(key)
|
||||
p.arrays[pathKey(key)] = true
|
||||
p.current = tbl
|
||||
p.currentPath = key
|
||||
return nil
|
||||
}
|
||||
|
||||
pk := pathKey(key)
|
||||
if p.headers[pk] || p.dotted[pk] || p.arrays[pk] {
|
||||
return p.errf("table %q is defined more than once", strings.Join(key, "."))
|
||||
}
|
||||
p.headers[pk] = true
|
||||
|
||||
tbl, err := p.tableAt(key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p.current = tbl
|
||||
p.currentPath = key
|
||||
return nil
|
||||
}
|
||||
|
||||
// tableAt walks (creating intermediate tables) to the table named by key,
|
||||
// relative to the document root, rejecting any step into a frozen inline table.
|
||||
func (p *parser) tableAt(key []string) (map[string]any, error) {
|
||||
cur := p.root
|
||||
path := make([]string, 0, len(key))
|
||||
for _, k := range key {
|
||||
path = append(path, k)
|
||||
if p.frozen[pathKey(path)] {
|
||||
return nil, p.errf("cannot extend inline table %q", strings.Join(path, "."))
|
||||
}
|
||||
existing, ok := cur[k]
|
||||
if !ok {
|
||||
next := map[string]any{}
|
||||
cur[k] = next
|
||||
cur = next
|
||||
continue
|
||||
}
|
||||
switch v := existing.(type) {
|
||||
case map[string]any:
|
||||
cur = v
|
||||
case []map[string]any:
|
||||
if len(v) == 0 {
|
||||
return nil, p.errf("key %q is an empty array of tables", k)
|
||||
}
|
||||
cur = v[len(v)-1]
|
||||
default:
|
||||
return nil, p.errf("key %q is not a table", k)
|
||||
}
|
||||
}
|
||||
return cur, nil
|
||||
}
|
||||
|
||||
func (p *parser) appendArrayTable(key []string) (map[string]any, error) {
|
||||
parent := p.root
|
||||
for _, k := range key[:len(key)-1] {
|
||||
existing, ok := parent[k]
|
||||
if !ok {
|
||||
next := map[string]any{}
|
||||
parent[k] = next
|
||||
parent = next
|
||||
continue
|
||||
}
|
||||
switch v := existing.(type) {
|
||||
case map[string]any:
|
||||
parent = v
|
||||
case []map[string]any:
|
||||
parent = v[len(v)-1]
|
||||
default:
|
||||
return nil, p.errf("key %q is not a table", k)
|
||||
}
|
||||
}
|
||||
|
||||
leaf := key[len(key)-1]
|
||||
tbl := map[string]any{}
|
||||
switch existing := parent[leaf].(type) {
|
||||
case nil:
|
||||
parent[leaf] = []map[string]any{tbl}
|
||||
case []map[string]any:
|
||||
parent[leaf] = append(existing, tbl)
|
||||
default:
|
||||
return nil, p.errf("key %q is not an array of tables", leaf)
|
||||
}
|
||||
return tbl, nil
|
||||
}
|
||||
|
||||
// --- key/value -------------------------------------------------------------
|
||||
|
||||
func (p *parser) parseKeyValue() error {
|
||||
key, err := p.parseKeyPath()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p.skipInline()
|
||||
if p.eof() || p.peek() != '=' {
|
||||
return p.errf("expected '=' after key")
|
||||
}
|
||||
p.next()
|
||||
p.skipInline()
|
||||
|
||||
val, err := p.parseValue()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
dest := p.current
|
||||
abs := append([]string{}, p.currentPath...)
|
||||
for _, k := range key[:len(key)-1] {
|
||||
abs = append(abs, k)
|
||||
if p.frozen[pathKey(abs)] {
|
||||
return p.errf("cannot extend inline table %q", strings.Join(abs, "."))
|
||||
}
|
||||
if p.headers[pathKey(abs)] {
|
||||
return p.errf("cannot extend table %q with a dotted key", strings.Join(abs, "."))
|
||||
}
|
||||
p.dotted[pathKey(abs)] = true
|
||||
existing, ok := dest[k]
|
||||
if !ok {
|
||||
next := map[string]any{}
|
||||
dest[k] = next
|
||||
dest = next
|
||||
continue
|
||||
}
|
||||
m, ok := existing.(map[string]any)
|
||||
if !ok {
|
||||
return p.errf("key %q is not a table", k)
|
||||
}
|
||||
dest = m
|
||||
}
|
||||
leaf := key[len(key)-1]
|
||||
abs = append(abs, leaf)
|
||||
if _, exists := dest[leaf]; exists {
|
||||
return p.errf("duplicate key %q", leaf)
|
||||
}
|
||||
dest[leaf] = val
|
||||
p.freezeInline(abs, val)
|
||||
return nil
|
||||
}
|
||||
|
||||
// freezeInline marks the path of an inline table (and any nested inline tables)
|
||||
// as immutable, so a later header or dotted key cannot extend it.
|
||||
func (p *parser) freezeInline(path []string, val any) {
|
||||
m, ok := val.(map[string]any)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
p.frozen[pathKey(path)] = true
|
||||
for k, v := range m {
|
||||
child := append(append([]string{}, path...), k)
|
||||
p.freezeInline(child, v)
|
||||
}
|
||||
}
|
||||
|
||||
// resetScopeUnder forgets the header and freeze records nested under key, which
|
||||
// belong to the previous element of an array of tables.
|
||||
func (p *parser) resetScopeUnder(key []string) {
|
||||
prefix := pathKey(key) + "\x00"
|
||||
for k := range p.headers {
|
||||
if strings.HasPrefix(k, prefix) {
|
||||
delete(p.headers, k)
|
||||
}
|
||||
}
|
||||
for k := range p.frozen {
|
||||
if strings.HasPrefix(k, prefix) {
|
||||
delete(p.frozen, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// parseKeyPath parses a dotted key into its components.
|
||||
func (p *parser) parseKeyPath() ([]string, error) {
|
||||
var parts []string
|
||||
for {
|
||||
p.skipInline()
|
||||
part, err := p.parseKeyComponent()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
parts = append(parts, part)
|
||||
p.skipInline()
|
||||
if !p.eof() && p.peek() == '.' {
|
||||
p.next()
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
return parts, nil
|
||||
}
|
||||
|
||||
func (p *parser) parseKeyComponent() (string, error) {
|
||||
if p.eof() {
|
||||
return "", p.errf("expected a key")
|
||||
}
|
||||
switch c := p.peek(); c {
|
||||
case '"':
|
||||
if p.lookahead(`"""`) {
|
||||
return "", p.errf("multiline strings are not allowed in keys")
|
||||
}
|
||||
return p.parseBasicString()
|
||||
case '\'':
|
||||
if p.lookahead(`'''`) {
|
||||
return "", p.errf("multiline strings are not allowed in keys")
|
||||
}
|
||||
return p.parseLiteralString()
|
||||
default:
|
||||
start := p.pos
|
||||
for !p.eof() {
|
||||
c := p.peek()
|
||||
if (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') ||
|
||||
(c >= '0' && c <= '9') || c == '_' || c == '-' {
|
||||
p.next()
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
if p.pos == start {
|
||||
return "", p.errf("invalid key character %q", string(p.peek()))
|
||||
}
|
||||
return string(p.src[start:p.pos]), nil
|
||||
}
|
||||
}
|
||||
|
||||
// --- values ----------------------------------------------------------------
|
||||
|
||||
func (p *parser) parseValue() (any, error) {
|
||||
if p.eof() {
|
||||
return nil, p.errf("expected a value")
|
||||
}
|
||||
switch c := p.peek(); {
|
||||
case c == '"':
|
||||
return p.parseBasicString()
|
||||
case c == '\'':
|
||||
return p.parseLiteralString()
|
||||
case c == '[':
|
||||
return p.parseArray()
|
||||
case c == '{':
|
||||
return p.parseInlineTable()
|
||||
case c == 't' || c == 'f':
|
||||
return p.parseBool()
|
||||
default:
|
||||
return p.parseAtom()
|
||||
}
|
||||
}
|
||||
|
||||
func (p *parser) parseBool() (any, error) {
|
||||
if p.match("true") {
|
||||
return true, nil
|
||||
}
|
||||
if p.match("false") {
|
||||
return false, nil
|
||||
}
|
||||
return nil, p.errf("invalid value")
|
||||
}
|
||||
|
||||
// parseAtom handles numbers, inf/nan, and date-times.
|
||||
func (p *parser) parseAtom() (any, error) {
|
||||
start := p.pos
|
||||
p.scanBareToken()
|
||||
tok := string(p.src[start:p.pos])
|
||||
if tok == "" {
|
||||
return nil, p.errf("expected a value")
|
||||
}
|
||||
// A date may be followed by a space and a time, forming one date-time.
|
||||
if isDateToken(tok) && !p.eof() && p.peek() == ' ' {
|
||||
if next, ok := p.peekAt(1); ok && next >= '0' && next <= '9' {
|
||||
p.next() // consume the separating space
|
||||
timeStart := p.pos
|
||||
p.scanBareToken()
|
||||
tok = tok + " " + string(p.src[timeStart:p.pos])
|
||||
}
|
||||
}
|
||||
if v, ok := parseDateTime(tok); ok {
|
||||
return v, nil
|
||||
}
|
||||
v, err := decodeNumber(tok)
|
||||
if err != nil {
|
||||
return nil, p.errf("%s", err)
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// scanBareToken advances past a bare value token (number, bool, or date-time),
|
||||
// stopping at whitespace, a separator, or a comment.
|
||||
func (p *parser) scanBareToken() {
|
||||
for !p.eof() {
|
||||
c := p.peek()
|
||||
if c == ' ' || c == '\t' || c == '\n' || c == '\r' ||
|
||||
c == ',' || c == ']' || c == '}' || c == '#' {
|
||||
return
|
||||
}
|
||||
p.next()
|
||||
}
|
||||
}
|
||||
|
||||
// --- strings ---------------------------------------------------------------
|
||||
|
||||
func (p *parser) parseBasicString() (string, error) {
|
||||
if p.lookahead(`"""`) {
|
||||
return p.parseMultilineString('"', true)
|
||||
}
|
||||
p.next() // opening quote
|
||||
var b strings.Builder
|
||||
for {
|
||||
if p.eof() {
|
||||
return "", p.errf("unterminated string")
|
||||
}
|
||||
c := p.next()
|
||||
switch c {
|
||||
case '"':
|
||||
return b.String(), nil
|
||||
case '\n':
|
||||
return "", p.errf("unterminated string")
|
||||
case '\r':
|
||||
return "", p.errf("bare carriage return is not allowed in a string")
|
||||
case '\\':
|
||||
r, err := p.readEscape()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
b.WriteRune(r)
|
||||
default:
|
||||
if isControlRune(c) {
|
||||
return "", p.errf("control character U+%04X is not allowed in a string", c)
|
||||
}
|
||||
b.WriteRune(c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (p *parser) parseLiteralString() (string, error) {
|
||||
if p.lookahead(`'''`) {
|
||||
return p.parseMultilineString('\'', false)
|
||||
}
|
||||
p.next() // opening quote
|
||||
var b strings.Builder
|
||||
for {
|
||||
if p.eof() {
|
||||
return "", p.errf("unterminated literal string")
|
||||
}
|
||||
c := p.next()
|
||||
if c == '\'' {
|
||||
return b.String(), nil
|
||||
}
|
||||
if c == '\n' {
|
||||
return "", p.errf("unterminated literal string")
|
||||
}
|
||||
if c == '\r' {
|
||||
return "", p.errf("bare carriage return is not allowed in a string")
|
||||
}
|
||||
if isControlRune(c) {
|
||||
return "", p.errf("control character U+%04X is not allowed in a string", c)
|
||||
}
|
||||
b.WriteRune(c)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *parser) parseMultilineString(quote rune, escapes bool) (string, error) {
|
||||
p.skipN(3) // opening delimiter
|
||||
// A newline immediately after the opening delimiter is trimmed.
|
||||
if !p.eof() && p.peek() == '\r' {
|
||||
p.next()
|
||||
}
|
||||
if !p.eof() && p.peek() == '\n' {
|
||||
p.line++
|
||||
p.next()
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
for {
|
||||
if p.eof() {
|
||||
return "", p.errf("unterminated multiline string")
|
||||
}
|
||||
if p.peek() == quote {
|
||||
// Count the run of delimiter characters. The last three close the
|
||||
// string; up to two extra ones belong to the content.
|
||||
n := 0
|
||||
for p.pos+n < len(p.src) && p.src[p.pos+n] == quote {
|
||||
n++
|
||||
}
|
||||
if n >= 3 {
|
||||
if n > 5 {
|
||||
return "", p.errf("too many '%c' before the closing delimiter", quote)
|
||||
}
|
||||
for range n - 3 {
|
||||
b.WriteRune(quote)
|
||||
}
|
||||
p.skipN(n)
|
||||
return b.String(), nil
|
||||
}
|
||||
for range n {
|
||||
b.WriteRune(quote)
|
||||
p.next()
|
||||
}
|
||||
continue
|
||||
}
|
||||
c := p.next()
|
||||
if c == '\n' {
|
||||
p.line++
|
||||
b.WriteRune(c)
|
||||
continue
|
||||
}
|
||||
if c == '\r' {
|
||||
if !p.eof() && p.peek() == '\n' {
|
||||
b.WriteRune(c)
|
||||
continue
|
||||
}
|
||||
return "", p.errf("bare carriage return is not allowed in a string")
|
||||
}
|
||||
if escapes && c == '\\' {
|
||||
// Line-ending backslash trims the following whitespace/newlines.
|
||||
if p.trimLineEndingBackslash() {
|
||||
continue
|
||||
}
|
||||
r, err := p.readEscape()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
b.WriteRune(r)
|
||||
continue
|
||||
}
|
||||
if isControlRune(c) {
|
||||
return "", p.errf("control character U+%04X is not allowed in a string", c)
|
||||
}
|
||||
b.WriteRune(c)
|
||||
}
|
||||
}
|
||||
|
||||
// trimLineEndingBackslash consumes whitespace through the next newline (and the
|
||||
// blank lines that follow) when a backslash is the last token on a line.
|
||||
// It reports whether it did so.
|
||||
func (p *parser) trimLineEndingBackslash() bool {
|
||||
save, saveLine := p.pos, p.line
|
||||
for !p.eof() {
|
||||
c := p.peek()
|
||||
if c == ' ' || c == '\t' || c == '\r' {
|
||||
p.next()
|
||||
continue
|
||||
}
|
||||
if c == '\n' {
|
||||
break
|
||||
}
|
||||
// Not a line-ending backslash; restore.
|
||||
p.pos, p.line = save, saveLine
|
||||
return false
|
||||
}
|
||||
if p.eof() {
|
||||
p.pos, p.line = save, saveLine
|
||||
return false
|
||||
}
|
||||
// Consume the newline and all following whitespace.
|
||||
for !p.eof() {
|
||||
c := p.peek()
|
||||
if c == '\n' {
|
||||
p.line++
|
||||
p.next()
|
||||
continue
|
||||
}
|
||||
if c == ' ' || c == '\t' || c == '\r' {
|
||||
p.next()
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (p *parser) readEscape() (rune, error) {
|
||||
if p.eof() {
|
||||
return 0, p.errf("unterminated escape sequence")
|
||||
}
|
||||
c := p.next()
|
||||
switch c {
|
||||
case 'b':
|
||||
return '\b', nil
|
||||
case 't':
|
||||
return '\t', nil
|
||||
case 'n':
|
||||
return '\n', nil
|
||||
case 'f':
|
||||
return '\f', nil
|
||||
case 'r':
|
||||
return '\r', nil
|
||||
case '"':
|
||||
return '"', nil
|
||||
case '\\':
|
||||
return '\\', nil
|
||||
case 'u':
|
||||
return p.readUnicode(4)
|
||||
case 'U':
|
||||
return p.readUnicode(8)
|
||||
default:
|
||||
return 0, p.errf("invalid escape sequence \\%c", c)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *parser) readUnicode(n int) (rune, error) {
|
||||
if p.pos+n > len(p.src) {
|
||||
return 0, p.errf("invalid unicode escape")
|
||||
}
|
||||
hex := string(p.src[p.pos : p.pos+n])
|
||||
p.pos += n
|
||||
v, err := strconv.ParseInt(hex, 16, 64)
|
||||
if err != nil {
|
||||
return 0, p.errf("invalid unicode escape \\%s", hex)
|
||||
}
|
||||
if v > 0x10FFFF || (v >= 0xD800 && v <= 0xDFFF) {
|
||||
return 0, p.errf("escape \\%s is not a valid Unicode scalar value", hex)
|
||||
}
|
||||
return rune(v), nil
|
||||
}
|
||||
|
||||
// --- arrays and inline tables ---------------------------------------------
|
||||
|
||||
func (p *parser) parseArray() (any, error) {
|
||||
p.next() // '['
|
||||
arr := []any{}
|
||||
for {
|
||||
if err := p.skipArraySpace(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if p.eof() {
|
||||
return nil, p.errf("unterminated array")
|
||||
}
|
||||
if p.peek() == ']' {
|
||||
p.next()
|
||||
return arr, nil
|
||||
}
|
||||
v, err := p.parseValue()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
arr = append(arr, v)
|
||||
if err := p.skipArraySpace(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if p.eof() {
|
||||
return nil, p.errf("unterminated array")
|
||||
}
|
||||
switch p.peek() {
|
||||
case ',':
|
||||
p.next()
|
||||
case ']':
|
||||
p.next()
|
||||
return arr, nil
|
||||
default:
|
||||
return nil, p.errf("expected ',' or ']' in array")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (p *parser) parseInlineTable() (any, error) {
|
||||
p.next() // '{'
|
||||
tbl := map[string]any{}
|
||||
assigned := map[string]bool{}
|
||||
p.skipInline()
|
||||
if !p.eof() && p.peek() == '}' {
|
||||
p.next()
|
||||
return tbl, nil
|
||||
}
|
||||
for {
|
||||
p.skipInline()
|
||||
key, err := p.parseKeyPath()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p.skipInline()
|
||||
if p.eof() || p.peek() != '=' {
|
||||
return nil, p.errf("expected '=' in inline table")
|
||||
}
|
||||
p.next()
|
||||
p.skipInline()
|
||||
val, err := p.parseValue()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
dest := tbl
|
||||
path := make([]string, 0, len(key))
|
||||
for _, k := range key[:len(key)-1] {
|
||||
path = append(path, k)
|
||||
if assigned[pathKey(path)] {
|
||||
return nil, p.errf("key %q is already defined", strings.Join(path, "."))
|
||||
}
|
||||
existing, ok := dest[k]
|
||||
if !ok {
|
||||
m := map[string]any{}
|
||||
dest[k] = m
|
||||
dest = m
|
||||
continue
|
||||
}
|
||||
m, isMap := existing.(map[string]any)
|
||||
if !isMap {
|
||||
return nil, p.errf("key %q is already defined", k)
|
||||
}
|
||||
dest = m
|
||||
}
|
||||
leaf := key[len(key)-1]
|
||||
path = append(path, leaf)
|
||||
if _, exists := dest[leaf]; exists {
|
||||
return nil, p.errf("duplicate key %q in inline table", leaf)
|
||||
}
|
||||
dest[leaf] = val
|
||||
assigned[pathKey(path)] = true
|
||||
|
||||
p.skipInline()
|
||||
if p.eof() {
|
||||
return nil, p.errf("unterminated inline table")
|
||||
}
|
||||
switch p.peek() {
|
||||
case ',':
|
||||
p.next()
|
||||
case '}':
|
||||
p.next()
|
||||
return tbl, nil
|
||||
default:
|
||||
return nil, p.errf("expected ',' or '}' in inline table")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- scanning helpers ------------------------------------------------------
|
||||
|
||||
func (p *parser) eof() bool { return p.pos >= len(p.src) }
|
||||
func (p *parser) peek() rune { return p.src[p.pos] }
|
||||
|
||||
// peekAt returns the rune at offset n from the current position and whether the
|
||||
// offset is within the source. Use it instead of indexing p.src directly when
|
||||
// the offset may sit past the end.
|
||||
func (p *parser) peekAt(n int) (rune, bool) {
|
||||
i := p.pos + n
|
||||
if i < 0 || i >= len(p.src) {
|
||||
return 0, false
|
||||
}
|
||||
return p.src[i], true
|
||||
}
|
||||
|
||||
func (p *parser) next() rune {
|
||||
c := p.src[p.pos]
|
||||
p.pos++
|
||||
return c
|
||||
}
|
||||
|
||||
func (p *parser) skipN(n int) {
|
||||
for i := 0; i < n && !p.eof(); i++ {
|
||||
p.next()
|
||||
}
|
||||
}
|
||||
|
||||
func (p *parser) match(word string) bool {
|
||||
if p.lookahead(word) {
|
||||
p.skipN(len([]rune(word)))
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (p *parser) lookahead(s string) bool {
|
||||
r := []rune(s)
|
||||
if p.pos+len(r) > len(p.src) {
|
||||
return false
|
||||
}
|
||||
for i, c := range r {
|
||||
if p.src[p.pos+i] != c {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// skipInline consumes spaces and tabs only.
|
||||
func (p *parser) skipInline() {
|
||||
for !p.eof() {
|
||||
if c := p.peek(); c == ' ' || c == '\t' {
|
||||
p.next()
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// skipArraySpace consumes whitespace, newlines, and comments inside arrays.
|
||||
func (p *parser) skipArraySpace() error {
|
||||
for !p.eof() {
|
||||
switch p.peek() {
|
||||
case ' ', '\t':
|
||||
p.next()
|
||||
case '\r':
|
||||
if err := p.expectCRLF(); err != nil {
|
||||
return err
|
||||
}
|
||||
case '\n':
|
||||
p.line++
|
||||
p.next()
|
||||
case '#':
|
||||
if err := p.skipComment(); err != nil {
|
||||
return err
|
||||
}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// skipBlank consumes whitespace, blank lines, and comments between statements.
|
||||
func (p *parser) skipBlank() error {
|
||||
for !p.eof() {
|
||||
switch p.peek() {
|
||||
case ' ', '\t':
|
||||
p.next()
|
||||
case '\r':
|
||||
if err := p.expectCRLF(); err != nil {
|
||||
return err
|
||||
}
|
||||
case '\n':
|
||||
p.line++
|
||||
p.next()
|
||||
case '#':
|
||||
if err := p.skipComment(); err != nil {
|
||||
return err
|
||||
}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *parser) skipComment() error {
|
||||
p.next() // consume '#'
|
||||
for !p.eof() {
|
||||
c := p.peek()
|
||||
switch {
|
||||
case c == '\n':
|
||||
return nil
|
||||
case c == '\r':
|
||||
if p.pos+1 < len(p.src) && p.src[p.pos+1] == '\n' {
|
||||
return nil
|
||||
}
|
||||
return p.errf("bare carriage return is not allowed")
|
||||
case c == '\t':
|
||||
p.next()
|
||||
case c < 0x20 || c == 0x7f:
|
||||
return p.errf("control character U+%04X is not allowed in a comment", c)
|
||||
default:
|
||||
p.next()
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// expectCRLF consumes a carriage return that must be immediately followed by a
|
||||
// line feed; a bare CR is invalid.
|
||||
func (p *parser) expectCRLF() error {
|
||||
if p.pos+1 < len(p.src) && p.src[p.pos+1] == '\n' {
|
||||
p.next() // consume CR; the LF is handled by the caller
|
||||
return nil
|
||||
}
|
||||
return p.errf("bare carriage return is not allowed")
|
||||
}
|
||||
|
||||
// expectLineEnd consumes trailing inline whitespace and an optional comment,
|
||||
// then requires a newline or end of input.
|
||||
func (p *parser) expectLineEnd() error {
|
||||
p.skipInline()
|
||||
if p.eof() {
|
||||
return nil
|
||||
}
|
||||
if p.peek() == '#' {
|
||||
if err := p.skipComment(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if p.eof() {
|
||||
return nil
|
||||
}
|
||||
if p.peek() == '\r' {
|
||||
if err := p.expectCRLF(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if p.eof() {
|
||||
return nil
|
||||
}
|
||||
if p.peek() == '\n' {
|
||||
p.line++
|
||||
p.next()
|
||||
return nil
|
||||
}
|
||||
return p.errf("unexpected %q after value", string(p.peek()))
|
||||
}
|
||||
|
||||
func (p *parser) errf(format string, args ...any) error {
|
||||
return &SyntaxError{Line: p.line, Msg: fmt.Sprintf(format, args...)}
|
||||
}
|
||||
|
||||
// pathKey joins key components with a NUL separator so a dotted path can be
|
||||
// used as a map key for tracking defined tables.
|
||||
func pathKey(parts []string) string {
|
||||
return strings.Join(parts, "\x00")
|
||||
}
|
||||
|
||||
// isControlRune reports whether r is a control character disallowed in a string
|
||||
// literal. Tab, line feed, and carriage return are permitted (handled
|
||||
// elsewhere); everything else below U+0020, plus U+007F, is rejected.
|
||||
func isControlRune(r rune) bool {
|
||||
if r == '\t' || r == '\n' || r == '\r' {
|
||||
return false
|
||||
}
|
||||
return r < 0x20 || r == 0x7f
|
||||
}
|
||||
Reference in New Issue
Block a user