Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a73d3097d0 | ||
|
|
376a1120fe | ||
|
|
818d863827 | ||
|
|
eac3a60e9e |
@@ -6,13 +6,13 @@ on:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
release:
|
release:
|
||||||
runs-on: codeberg-small
|
runs-on: fedora
|
||||||
steps:
|
steps:
|
||||||
- uses: https://data.forgejo.org/actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Verify tag and extract CHANGELOG section
|
- name: Verify tag and extract CHANGELOG section
|
||||||
env:
|
env:
|
||||||
VERSION: ${{ forgejo.ref_name }}
|
VERSION: ${{ gitea.ref_name }}
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
@@ -40,7 +40,10 @@ jobs:
|
|||||||
|
|
||||||
- name: Create release
|
- name: Create release
|
||||||
env:
|
env:
|
||||||
FORGEJO_TOKEN: ${{ secrets.FORGEJO_TOKEN }}
|
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||||
|
GITEA_SERVER_URL: ${{ gitea.server_url }}
|
||||||
|
GITEA_REPOSITORY: ${{ gitea.repository }}
|
||||||
|
GITEA_REF_NAME: ${{ gitea.ref_name }}
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
@@ -56,13 +59,13 @@ jobs:
|
|||||||
BODY="\"${BODY}\""
|
BODY="\"${BODY}\""
|
||||||
|
|
||||||
response=$(curl -sS -w '\n%{http_code}' \
|
response=$(curl -sS -w '\n%{http_code}' \
|
||||||
-H "Authorization: token ${FORGEJO_TOKEN}" \
|
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-X POST \
|
-X POST \
|
||||||
"${FORGEJO_SERVER_URL}/api/v1/repos/${FORGEJO_REPOSITORY}/releases" \
|
"${GITEA_SERVER_URL}/api/v1/repos/${GITEA_REPOSITORY}/releases" \
|
||||||
-d "{
|
-d "{
|
||||||
\"tag_name\": \"${FORGEJO_REF_NAME}\",
|
\"tag_name\": \"${GITEA_REF_NAME}\",
|
||||||
\"name\": \"${FORGEJO_REF_NAME}\",
|
\"name\": \"${GITEA_REF_NAME}\",
|
||||||
\"body\": ${BODY},
|
\"body\": ${BODY},
|
||||||
\"draft\": false,
|
\"draft\": false,
|
||||||
\"prerelease\": false
|
\"prerelease\": false
|
||||||
@@ -78,4 +81,4 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
RELEASE_ID=$(echo "$payload" | grep -oE '"id"[[:space:]]*:[[:space:]]*[0-9]+' | head -1 | grep -oE '[0-9]+')
|
RELEASE_ID=$(echo "$payload" | grep -oE '"id"[[:space:]]*:[[:space:]]*[0-9]+' | head -1 | grep -oE '[0-9]+')
|
||||||
echo "Release ${FORGEJO_REF_NAME} is live (ID=${RELEASE_ID})."
|
echo "Release ${GITEA_REF_NAME} is live (ID=${RELEASE_ID})."
|
||||||
@@ -8,11 +8,11 @@ on:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
vet:
|
vet:
|
||||||
runs-on: codeberg-small
|
runs-on: fedora
|
||||||
steps:
|
steps:
|
||||||
- uses: https://data.forgejo.org/actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- uses: https://data.forgejo.org/actions/setup-go@v5
|
- uses: actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
go-version: "1.26"
|
go-version: "1.26"
|
||||||
|
|
||||||
@@ -30,28 +30,33 @@ jobs:
|
|||||||
run: go vet ./...
|
run: go vet ./...
|
||||||
|
|
||||||
test:
|
test:
|
||||||
runs-on: codeberg-small
|
runs-on: fedora
|
||||||
needs: vet
|
needs: vet
|
||||||
steps:
|
steps:
|
||||||
- uses: https://data.forgejo.org/actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- uses: https://data.forgejo.org/actions/setup-go@v5
|
- uses: actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
go-version: "1.26"
|
go-version: "1.26"
|
||||||
|
|
||||||
|
- name: Install C compiler
|
||||||
|
run: sudo dnf install -y gcc
|
||||||
|
|
||||||
- name: go test -race
|
- name: go test -race
|
||||||
|
env:
|
||||||
|
CGO_ENABLED: "1"
|
||||||
run: go test -race -count=1 ./...
|
run: go test -race -count=1 ./...
|
||||||
|
|
||||||
- name: build example
|
- name: build example
|
||||||
run: go build ./examples/...
|
run: go build ./examples/...
|
||||||
|
|
||||||
toml-test:
|
toml-test:
|
||||||
runs-on: codeberg-small
|
runs-on: fedora
|
||||||
needs: vet
|
needs: vet
|
||||||
steps:
|
steps:
|
||||||
- uses: https://data.forgejo.org/actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- uses: https://data.forgejo.org/actions/setup-go@v5
|
- uses: actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
go-version: "1.26"
|
go-version: "1.26"
|
||||||
|
|
||||||
@@ -5,6 +5,50 @@ All notable changes to **interpres** are documented in this file.
|
|||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [1.1.1] — 2026-07-26
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Project**
|
||||||
|
- Migrate from Codeberg to sourcedock.dev (module path, imports, CI).
|
||||||
|
|
||||||
|
## [1.1.0] — 2026-06-26
|
||||||
|
|
||||||
|
Adds TOML emission to the previously read-only library, completing the
|
||||||
|
`encoding/json`-style API.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
**Marshalling**
|
||||||
|
|
||||||
|
- `Marshal(v any) ([]byte, error)` — encode a struct or `map[string]V` value
|
||||||
|
to a TOML 1.0 document.
|
||||||
|
- `Encoder` and `NewEncoder`, mirroring the `Decoder` shape for symmetry with
|
||||||
|
`Unmarshal`.
|
||||||
|
- `Marshaler` interface (`MarshalTOML() (any, error)`) for types that need a
|
||||||
|
custom TOML shape; the returned value is encoded normally.
|
||||||
|
- Struct fields are matched by `toml:"name"` tag (case-insensitive fallback to
|
||||||
|
field name; `toml:"-"` skips). Anonymous (embedded) fields without a tag
|
||||||
|
are inlined.
|
||||||
|
- Slices and arrays of structs or maps become `[[a]]` arrays of tables; other
|
||||||
|
slices and arrays become TOML arrays. Empty or nil arrays of tables are
|
||||||
|
omitted; empty arrays of scalars emit as `key = []`.
|
||||||
|
- Scalars encode as bool, int64, float64, string, `time.Time` (offset
|
||||||
|
date-time), `LocalDateTime`, `LocalDate`, or `LocalTime`.
|
||||||
|
- `time.Time`, `LocalDateTime`, `LocalDate`, and `LocalTime` now have
|
||||||
|
`String()` methods that return their TOML-canonical rendering
|
||||||
|
(zero-padded to nanosecond precision when a fractional second is present).
|
||||||
|
|
||||||
|
**Encoding policy**
|
||||||
|
|
||||||
|
The emitter groups fields at every TOML level: scalars first, then
|
||||||
|
sub-tables, then arrays of tables. Within each group the order matches struct
|
||||||
|
field declaration order, or sorted key order for maps. The output is
|
||||||
|
guaranteed to re-parse to an equivalent value tree via `Parse`, but is not
|
||||||
|
byte-identical to any input that produced the value — comments, whitespace,
|
||||||
|
map key order, and basic-vs-literal string quoting are not preserved. See
|
||||||
|
the "Encoding" section of the README for the full set of rules.
|
||||||
|
|
||||||
## [1.0.0] — 2026-06-20
|
## [1.0.0] — 2026-06-20
|
||||||
|
|
||||||
First stable release: a dependency-free TOML 1.0 parser for Go that uses only
|
First stable release: a dependency-free TOML 1.0 parser for Go that uses only
|
||||||
@@ -51,3 +95,7 @@ the standard library and passes the entire
|
|||||||
JSON on stdout).
|
JSON on stdout).
|
||||||
- A runnable example, a Go test suite, and a `just` task set (`install`, `run`,
|
- A runnable example, a Go test suite, and a `just` task set (`install`, `run`,
|
||||||
`build`, `test`, `coverage`, `uninstall`).
|
`build`, `test`, `coverage`, `uninstall`).
|
||||||
|
|
||||||
|
[1.1.1]: https://sourcedock.dev/petrbalvin/interpres/releases/tag/v1.1.1
|
||||||
|
[1.1.0]: https://codeberg.org/petrbalvin/interpres/releases/tag/v1.1.0
|
||||||
|
[1.0.0]: https://codeberg.org/petrbalvin/interpres/releases/tag/v1.0.0
|
||||||
|
|||||||
+4
-2
@@ -52,12 +52,14 @@ Run this before and after any parser change; it must stay at zero failures.
|
|||||||
|
|
||||||
```
|
```
|
||||||
interpres/
|
interpres/
|
||||||
├── interpres.go # public API: Parse, Unmarshal, Decoder, SyntaxError
|
├── interpres.go # public API: Parse, Unmarshal, Marshal, Decoder, Encoder, SyntaxError
|
||||||
├── parser.go # recursive-descent parser → map[string]any
|
├── parser.go # recursive-descent parser → map[string]any
|
||||||
├── number.go # strict numeric token parsing
|
├── number.go # strict numeric token parsing
|
||||||
├── datetime.go # date-time types and parsing
|
├── datetime.go # date-time types and parsing
|
||||||
├── decode.go # reflection mapping of the tree onto Go values
|
├── decode.go # reflection mapping of the tree onto Go values
|
||||||
├── interpres_test.go # test suite
|
├── encode.go # reflection-based marshal of Go values to TOML
|
||||||
|
├── interpres_test.go # parser/decoder test suite
|
||||||
|
├── encode_test.go # encoder test suite
|
||||||
├── cmd/interpres-decode/ # toml-test harness adapter
|
├── cmd/interpres-decode/ # toml-test harness adapter
|
||||||
└── examples/basic/ # runnable usage example
|
└── examples/basic/ # runnable usage example
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ Go programs that still want their configuration in TOML.
|
|||||||
## Install
|
## Install
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
go get codeberg.org/petrbalvin/interpres
|
go get sourcedock.dev/petrbalvin/interpres
|
||||||
```
|
```
|
||||||
|
|
||||||
Requires Go 1.26 or newer. The module imports only the standard library.
|
Requires Go 1.26 or newer. The module imports only the standard library.
|
||||||
@@ -27,7 +27,7 @@ package main
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"codeberg.org/petrbalvin/interpres"
|
"sourcedock.dev/petrbalvin/interpres"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
@@ -55,12 +55,48 @@ port = 9090
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Encode from a struct
|
||||||
|
|
||||||
|
```go
|
||||||
|
out, err := interpres.Marshal(cfg)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
fmt.Println(string(out))
|
||||||
|
```
|
||||||
|
|
||||||
|
writes the same struct back to TOML:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
title = "example"
|
||||||
|
|
||||||
|
[server]
|
||||||
|
host = "127.0.0.1"
|
||||||
|
port = 9090
|
||||||
|
```
|
||||||
|
|
||||||
### Untyped tree
|
### Untyped tree
|
||||||
|
|
||||||
```go
|
```go
|
||||||
tree, err := interpres.Parse(data) // map[string]any
|
tree, err := interpres.Parse(data) // []byte → map[string]any
|
||||||
|
out, err := interpres.Marshal(tree) // map[string]any → []byte
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Custom encoding
|
||||||
|
|
||||||
|
Types that need a non-default TOML shape can implement `Marshaler`:
|
||||||
|
|
||||||
|
```go
|
||||||
|
type Port int
|
||||||
|
|
||||||
|
func (p Port) MarshalTOML() (any, error) {
|
||||||
|
return int64(p), nil
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The returned value is encoded just like any other value passed to `Marshal`,
|
||||||
|
which means it can be any type `Marshal` itself understands.
|
||||||
|
|
||||||
### Strict decoding
|
### Strict decoding
|
||||||
|
|
||||||
Reject keys that have no matching struct field — useful for catching typos in
|
Reject keys that have no matching struct field — useful for catching typos in
|
||||||
@@ -95,6 +131,49 @@ skips the field.
|
|||||||
When decoding into a struct, these map onto the destination's concrete types
|
When decoding into a struct, these map onto the destination's concrete types
|
||||||
(any integer/unsigned/float width, slices, nested structs, and `map[string]T`).
|
(any integer/unsigned/float width, slices, nested structs, and `map[string]T`).
|
||||||
|
|
||||||
|
## Encoding
|
||||||
|
|
||||||
|
`Marshal` produces valid TOML 1.0 from any `struct` or `map[string]V` value,
|
||||||
|
applying these rules:
|
||||||
|
|
||||||
|
- The top-level value must be a struct or `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; `toml:"-"` skips). Anonymous (embedded) fields without a tag
|
||||||
|
are inlined.
|
||||||
|
- Maps emit keys in sorted order for deterministic output.
|
||||||
|
- **Output is grouped by kind at every level**: scalars come first, then
|
||||||
|
sub-tables, then arrays of tables. Within each group the order matches
|
||||||
|
struct field declaration order (or, for maps, sorted key order). This means
|
||||||
|
in TOML terms a struct that mixes scalars and sub-tables always lays out
|
||||||
|
scalars at the top of the section followed by the table headers — there is
|
||||||
|
no way to interleave them and still produce a document that re-parses to
|
||||||
|
the same tree.
|
||||||
|
- Slices and arrays of structs or maps become `[[a]]` arrays of tables. Empty
|
||||||
|
or nil arrays of tables are omitted (TOML forbids an empty `[[a]]`); empty
|
||||||
|
arrays of scalars emit as `key = []`.
|
||||||
|
- Other slices and arrays become TOML arrays, including mixed-type `[]any`.
|
||||||
|
- Scalars map to TOML scalars: bool, int64, float64, string, `time.Time`
|
||||||
|
(offset date-time), and the local variants (`LocalDateTime`, `LocalDate`,
|
||||||
|
`LocalTime`).
|
||||||
|
- Strings are always emitted as basic `"..."` strings with the escapes TOML
|
||||||
|
requires (`\"`, `\\`, control characters as `\uXXXX`).
|
||||||
|
- Keys are emitted as bare keys when they match `[A-Za-z0-9_-]+`, quoted
|
||||||
|
otherwise.
|
||||||
|
- Floats always carry a `.` or an exponent (so `1` is always emitted as
|
||||||
|
`1.0`), which preserves the distinction between an integer-typed value and
|
||||||
|
a float-typed value across a `Marshal` → `Parse` round-trip.
|
||||||
|
- Values implementing `Marshaler` are encoded by calling `MarshalTOML` and
|
||||||
|
using its result.
|
||||||
|
- `nil` pointer fields are omitted.
|
||||||
|
|
||||||
|
`Marshal` cannot encode cyclic data structures. The output is not guaranteed
|
||||||
|
to be byte-identical to any TOML document that produced the input value:
|
||||||
|
comments, whitespace, key order (for maps), the choice between `[table]`
|
||||||
|
headers and inline tables, and the basic-vs-literal quoting style are not
|
||||||
|
preserved. The emitted document is, however, guaranteed to be re-parsable by
|
||||||
|
`interpres.Parse` back into an equivalent value tree.
|
||||||
|
|
||||||
## TOML 1.0 support
|
## TOML 1.0 support
|
||||||
|
|
||||||
`interpres` implements the full [TOML 1.0](https://toml.io/en/v1.0.0) grammar:
|
`interpres` implements the full [TOML 1.0](https://toml.io/en/v1.0.0) grammar:
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"codeberg.org/petrbalvin/interpres"
|
"sourcedock.dev/petrbalvin/interpres"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
|||||||
+27
@@ -1,6 +1,7 @@
|
|||||||
package interpres
|
package interpres
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -22,6 +23,32 @@ type LocalDate struct{ time.Time }
|
|||||||
// The embedded time.Time uses the zero date.
|
// The embedded time.Time uses the zero date.
|
||||||
type LocalTime struct{ time.Time }
|
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 (
|
var (
|
||||||
offsetDateTimeLayouts = []string{
|
offsetDateTimeLayouts = []string{
|
||||||
"2006-01-02T15:04:05.999999999Z07:00",
|
"2006-01-02T15:04:05.999999999Z07:00",
|
||||||
|
|||||||
@@ -0,0 +1,626 @@
|
|||||||
|
package interpres
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
"reflect"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
"unicode/utf8"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
localDateTimeType = reflect.TypeOf(LocalDateTime{})
|
||||||
|
localDateType = reflect.TypeOf(LocalDate{})
|
||||||
|
localTimeType = reflect.TypeOf(LocalTime{})
|
||||||
|
timeGoType = reflect.TypeOf(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
|
||||||
|
}
|
||||||
|
|
||||||
|
func newEncoder() *encoder { return &encoder{} }
|
||||||
|
|
||||||
|
func (e *encoder) bytes() []byte { return e.buf.Bytes() }
|
||||||
|
|
||||||
|
// 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 {
|
||||||
|
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{}
|
||||||
|
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 -----------------------------------------
|
||||||
|
|
||||||
|
// tomlDoc holds the entries of one TOML table, partitioned by kind. Fields
|
||||||
|
// declared at the same TOML level are grouped: scalars come first, then
|
||||||
|
// sub-tables, then arrays of tables. The order WITHIN each group matches
|
||||||
|
// either struct field order (for structs) or sorted key order (for maps).
|
||||||
|
type tomlDoc struct {
|
||||||
|
scalars []tomlKV
|
||||||
|
tables []tomlTable
|
||||||
|
arrays []tomlArray
|
||||||
|
}
|
||||||
|
|
||||||
|
type tomlKV struct {
|
||||||
|
key string
|
||||||
|
val any
|
||||||
|
}
|
||||||
|
|
||||||
|
type tomlTable struct {
|
||||||
|
key string
|
||||||
|
doc *tomlDoc
|
||||||
|
}
|
||||||
|
|
||||||
|
type tomlArray struct {
|
||||||
|
key string
|
||||||
|
docs []*tomlDoc
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *tomlDoc) addScalar(key string, val any) {
|
||||||
|
d.scalars = append(d.scalars, tomlKV{key: key, val: val})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *tomlDoc) addTable(key string, sub *tomlDoc) {
|
||||||
|
d.tables = append(d.tables, tomlTable{key: key, doc: sub})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *tomlDoc) addArray(key string, subs []*tomlDoc) {
|
||||||
|
d.arrays = append(d.arrays, tomlArray{key: key, docs: subs})
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- reflection walk: struct ---------------------------------------------
|
||||||
|
|
||||||
|
func buildStructDoc(v reflect.Value, doc *tomlDoc, ctx string) error {
|
||||||
|
t := v.Type()
|
||||||
|
for i := 0; i < t.NumField(); i++ {
|
||||||
|
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.Split(tag, ",")[0]
|
||||||
|
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()
|
||||||
|
sort.Slice(keys, func(i, j int) bool { return keys[i].String() < keys[j].String() })
|
||||||
|
for _, k := range keys {
|
||||||
|
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 := normalizeValue(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{}
|
||||||
|
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 — skip.
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
n := v.Len()
|
||||||
|
if n == 0 {
|
||||||
|
if isTableElementType(v.Type().Elem()) {
|
||||||
|
// Empty array of tables has no valid TOML form — skip.
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return doc.appendScalar(name, []any{}, ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
if isTableElementValue(v.Index(0)) {
|
||||||
|
subs := make([]*tomlDoc, n)
|
||||||
|
for i := 0; i < n; i++ {
|
||||||
|
ev := followPtr(v.Index(i))
|
||||||
|
if !ev.IsValid() {
|
||||||
|
return fmt.Errorf("interpres: %s.%s[%d]: nil element", ctx, name, i)
|
||||||
|
}
|
||||||
|
sub := &tomlDoc{}
|
||||||
|
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 := 0; i < n; i++ {
|
||||||
|
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 := normalizeValue(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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// normalizeValue 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 normalizeValue(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 := 0; i < v.Len(); i++ {
|
||||||
|
val, err := normalizeValue(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 ------------------------------------------------------------
|
||||||
|
|
||||||
|
// writeHeaderSep 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) writeHeaderSep() {
|
||||||
|
if e.buf.Len() == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
e.buf.WriteByte('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *encoder) emitDoc(doc *tomlDoc, prefix []string) error {
|
||||||
|
for _, kv := range doc.scalars {
|
||||||
|
if err := e.writeKV(kv.key, kv.val); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, t := range doc.tables {
|
||||||
|
path := append(append([]string{}, prefix...), t.key)
|
||||||
|
e.writeHeaderSep()
|
||||||
|
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 doc.arrays {
|
||||||
|
path := append(append([]string{}, prefix...), a.key)
|
||||||
|
for _, sub := range a.docs {
|
||||||
|
e.writeHeaderSep()
|
||||||
|
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 := 0; i < len(s); i++ {
|
||||||
|
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 {
|
||||||
|
return writeQuotedString(&e.buf, s)
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
+679
@@ -0,0 +1,679 @@
|
|||||||
|
package interpres
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMarshalScalars(t *testing.T) {
|
||||||
|
type Cfg struct {
|
||||||
|
Title string `toml:"title"`
|
||||||
|
Count int `toml:"count"`
|
||||||
|
Unsigned uint64 `toml:"unsigned"`
|
||||||
|
Ratio float64 `toml:"ratio"`
|
||||||
|
Enabled bool `toml:"enabled"`
|
||||||
|
Disabled bool `toml:"disabled"`
|
||||||
|
}
|
||||||
|
out, err := Marshal(Cfg{
|
||||||
|
Title: "demo",
|
||||||
|
Count: 42,
|
||||||
|
Unsigned: 99,
|
||||||
|
Ratio: 3.14,
|
||||||
|
Enabled: true,
|
||||||
|
Disabled: false,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal: %v", err)
|
||||||
|
}
|
||||||
|
want := "title = \"demo\"\ncount = 42\nunsigned = 99\nratio = 3.14\nenabled = true\ndisabled = false\n"
|
||||||
|
if string(out) != want {
|
||||||
|
t.Errorf("output mismatch:\ngot: %q\nwant: %q", out, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMarshalFloatSpecials(t *testing.T) {
|
||||||
|
type Cfg struct {
|
||||||
|
PosInf float64 `toml:"pos_inf"`
|
||||||
|
NegInf float64 `toml:"neg_inf"`
|
||||||
|
NaN float64 `toml:"nan"`
|
||||||
|
Zero float64 `toml:"zero"`
|
||||||
|
IntVal float64 `toml:"int_val"`
|
||||||
|
NegZ float64 `toml:"neg_zero"`
|
||||||
|
}
|
||||||
|
out, err := Marshal(Cfg{
|
||||||
|
PosInf: math.Inf(1),
|
||||||
|
NegInf: math.Inf(-1),
|
||||||
|
NaN: math.NaN(),
|
||||||
|
Zero: 0,
|
||||||
|
IntVal: 7,
|
||||||
|
NegZ: math.Copysign(0, -1),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal: %v", err)
|
||||||
|
}
|
||||||
|
want := "pos_inf = inf\nneg_inf = -inf\nnan = nan\nzero = 0.0\nint_val = 7.0\nneg_zero = 0.0\n"
|
||||||
|
if string(out) != want {
|
||||||
|
t.Errorf("output mismatch:\ngot: %q\nwant: %q", out, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMarshalStringEscapes(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
in string
|
||||||
|
want string // the TOML scalar value (without "s = " prefix)
|
||||||
|
}{
|
||||||
|
{"plain", "hello", `"hello"`},
|
||||||
|
{"quote", `say "hi"`, `"say \"hi\""`},
|
||||||
|
{"backslash", `a\b`, `"a\\b"`},
|
||||||
|
{"newline", "line1\nline2", `"line1\nline2"`},
|
||||||
|
{"tab", "col1\tcol2", `"col1\tcol2"`},
|
||||||
|
{"cr", "line\rmore", `"line\rmore"`},
|
||||||
|
{"control", "a\x01b", `"a\u0001b"`},
|
||||||
|
{"unicode", "\u201csmart\u201d", `"“smart”"`}, // printable unicode; not escaped
|
||||||
|
{"empty", "", `""`},
|
||||||
|
{"slash_only", "a/b", `"a/b"`},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
out, err := Marshal(struct {
|
||||||
|
S string `toml:"s"`
|
||||||
|
}{S: c.in})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%s: marshal: %v", c.name, err)
|
||||||
|
}
|
||||||
|
got := strings.TrimSuffix(string(out), "\n")
|
||||||
|
want := "s = " + c.want
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("%s:\ngot: %s\nwant: %s", c.name, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMarshalDateTime(t *testing.T) {
|
||||||
|
type Cfg struct {
|
||||||
|
Offset time.Time `toml:"offset"`
|
||||||
|
Local LocalDateTime `toml:"local"`
|
||||||
|
Day LocalDate `toml:"day"`
|
||||||
|
Clock LocalTime `toml:"clock"`
|
||||||
|
}
|
||||||
|
out, err := Marshal(Cfg{
|
||||||
|
Offset: time.Date(2026, 6, 26, 10, 0, 0, 0, time.UTC),
|
||||||
|
Local: LocalDateTime{Time: time.Date(2026, 6, 26, 7, 32, 0, 0, time.UTC)},
|
||||||
|
Day: LocalDate{Time: time.Date(2026, 6, 26, 0, 0, 0, 0, time.UTC)},
|
||||||
|
Clock: LocalTime{Time: time.Date(0, 1, 1, 7, 32, 0, 0, time.UTC)},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal: %v", err)
|
||||||
|
}
|
||||||
|
want := "offset = 2026-06-26T10:00:00Z\nlocal = 2026-06-26T07:32:00\nday = 2026-06-26\nclock = 07:32:00\n"
|
||||||
|
if string(out) != want {
|
||||||
|
t.Errorf("output mismatch:\ngot: %q\nwant: %q", out, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMarshalDateTimeFractional(t *testing.T) {
|
||||||
|
out, err := Marshal(struct {
|
||||||
|
LDT LocalDateTime `toml:"ldt"`
|
||||||
|
LT LocalTime `toml:"lt"`
|
||||||
|
}{
|
||||||
|
LDT: LocalDateTime{Time: time.Date(2026, 6, 26, 7, 32, 0, 123456789, time.UTC)},
|
||||||
|
LT: LocalTime{Time: time.Date(0, 1, 1, 7, 32, 0, 123, time.UTC)},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal: %v", err)
|
||||||
|
}
|
||||||
|
want := "ldt = 2026-06-26T07:32:00.123456789\nlt = 07:32:00.000000123\n"
|
||||||
|
if string(out) != want {
|
||||||
|
t.Errorf("output mismatch:\ngot: %q\nwant: %q", out, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMarshalArraysOfScalars(t *testing.T) {
|
||||||
|
type Cfg struct {
|
||||||
|
Tags []string `toml:"tags"`
|
||||||
|
Ports []int `toml:"ports"`
|
||||||
|
Mixed []any `toml:"mixed"`
|
||||||
|
Empty []int `toml:"empty"`
|
||||||
|
EmptyS []string `toml:"empty_s"`
|
||||||
|
}
|
||||||
|
out, err := Marshal(Cfg{
|
||||||
|
Tags: []string{"a", "b"},
|
||||||
|
Ports: []int{80, 443},
|
||||||
|
Mixed: []any{int64(1), "x", true},
|
||||||
|
Empty: nil,
|
||||||
|
EmptyS: []string{},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal: %v", err)
|
||||||
|
}
|
||||||
|
want := "tags = [\"a\", \"b\"]\nports = [80, 443]\nmixed = [1, \"x\", true]\nempty_s = []\n"
|
||||||
|
if string(out) != want {
|
||||||
|
t.Errorf("output mismatch:\ngot: %q\nwant: %q", out, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMarshalNestedArrays(t *testing.T) {
|
||||||
|
type Cfg struct {
|
||||||
|
Matrix [][]int `toml:"matrix"`
|
||||||
|
Words [][]string `toml:"words"`
|
||||||
|
}
|
||||||
|
out, err := Marshal(Cfg{
|
||||||
|
Matrix: [][]int{{1, 2}, {3, 4}},
|
||||||
|
Words: [][]string{{"a", "b"}, {"c"}},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal: %v", err)
|
||||||
|
}
|
||||||
|
want := "matrix = [[1, 2], [3, 4]]\nwords = [[\"a\", \"b\"], [\"c\"]]\n"
|
||||||
|
if string(out) != want {
|
||||||
|
t.Errorf("output mismatch:\ngot: %q\nwant: %q", out, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMarshalFloatExponentNoLeadingZero(t *testing.T) {
|
||||||
|
// strconv.FormatFloat with 'g' would produce "1e+06" (leading zero in
|
||||||
|
// exponent). The encoder must strip it so the output is "1e+6".
|
||||||
|
type Cfg struct {
|
||||||
|
Large float64 `toml:"large"`
|
||||||
|
Small float64 `toml:"small"`
|
||||||
|
}
|
||||||
|
out, err := Marshal(Cfg{Large: 1e6, Small: 1e-5})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal: %v", err)
|
||||||
|
}
|
||||||
|
// Parse to check the output is valid TOML (for a strict parser that
|
||||||
|
// rejects leading zeros in exponents).
|
||||||
|
if _, err := Parse(out); err != nil {
|
||||||
|
t.Fatalf("marshalled output is not valid TOML:\n%s\nerror: %v", out, err)
|
||||||
|
}
|
||||||
|
if string(out) != "large = 1e+6\nsmall = 1e-5\n" {
|
||||||
|
t.Errorf("output mismatch:\ngot: %q", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMarshalStructAsTable(t *testing.T) {
|
||||||
|
type Server struct {
|
||||||
|
Host string `toml:"host"`
|
||||||
|
Port int `toml:"port"`
|
||||||
|
}
|
||||||
|
type Cfg struct {
|
||||||
|
Title string `toml:"title"`
|
||||||
|
Server Server `toml:"server"`
|
||||||
|
}
|
||||||
|
out, err := Marshal(Cfg{
|
||||||
|
Title: "demo",
|
||||||
|
Server: Server{Host: "127.0.0.1", Port: 9090},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal: %v", err)
|
||||||
|
}
|
||||||
|
want := "title = \"demo\"\n\n[server]\nhost = \"127.0.0.1\"\nport = 9090\n"
|
||||||
|
if string(out) != want {
|
||||||
|
t.Errorf("output mismatch:\ngot: %q\nwant: %q", out, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMarshalArrayOfTables(t *testing.T) {
|
||||||
|
type Item struct {
|
||||||
|
Name string `toml:"name"`
|
||||||
|
Qty int `toml:"qty"`
|
||||||
|
}
|
||||||
|
type Cfg struct {
|
||||||
|
Items []Item `toml:"items"`
|
||||||
|
}
|
||||||
|
out, err := Marshal(Cfg{
|
||||||
|
Items: []Item{
|
||||||
|
{Name: "a", Qty: 1},
|
||||||
|
{Name: "b", Qty: 2},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal: %v", err)
|
||||||
|
}
|
||||||
|
want := "[[items]]\nname = \"a\"\nqty = 1\n\n[[items]]\nname = \"b\"\nqty = 2\n"
|
||||||
|
if string(out) != want {
|
||||||
|
t.Errorf("output mismatch:\ngot: %q\nwant: %q", out, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMarshalEmptyArrayOfTablesIsSkipped(t *testing.T) {
|
||||||
|
type Item struct {
|
||||||
|
Name string `toml:"name"`
|
||||||
|
}
|
||||||
|
type Cfg struct {
|
||||||
|
Title string `toml:"title"`
|
||||||
|
Items []Item `toml:"items"`
|
||||||
|
}
|
||||||
|
out, err := Marshal(Cfg{
|
||||||
|
Title: "demo",
|
||||||
|
Items: nil,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal: %v", err)
|
||||||
|
}
|
||||||
|
want := "title = \"demo\"\n"
|
||||||
|
if string(out) != want {
|
||||||
|
t.Errorf("output mismatch:\ngot: %q\nwant: %q", out, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMarshalNestedTablesAndArrays(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"`
|
||||||
|
}
|
||||||
|
type Cfg struct {
|
||||||
|
Port int `toml:"port"`
|
||||||
|
Forms []Form `toml:"forms"`
|
||||||
|
}
|
||||||
|
out, err := Marshal(Cfg{
|
||||||
|
Port: 8080,
|
||||||
|
Forms: []Form{
|
||||||
|
{Name: "contact", SMTP: SMTP{Host: "h1", Port: 587}},
|
||||||
|
{Name: "feedback", SMTP: SMTP{Host: "h2", Port: 25}},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal: %v", err)
|
||||||
|
}
|
||||||
|
want := "port = 8080\n\n[[forms]]\nname = \"contact\"\n\n[forms.smtp]\nhost = \"h1\"\nport = 587\n\n[[forms]]\nname = \"feedback\"\n\n[forms.smtp]\nhost = \"h2\"\nport = 25\n"
|
||||||
|
if string(out) != want {
|
||||||
|
t.Errorf("output mismatch:\ngot: %q\nwant: %q", out, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMarshalStructTags(t *testing.T) {
|
||||||
|
type Cfg struct {
|
||||||
|
Keep string `toml:"keep"`
|
||||||
|
Rename string `toml:"renamed"`
|
||||||
|
Skip string `toml:"-"`
|
||||||
|
Untagged string
|
||||||
|
}
|
||||||
|
out, err := Marshal(Cfg{
|
||||||
|
Keep: "k", Rename: "r", Skip: "s", Untagged: "u",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal: %v", err)
|
||||||
|
}
|
||||||
|
want := "keep = \"k\"\nrenamed = \"r\"\nuntagged = \"u\"\n"
|
||||||
|
if string(out) != want {
|
||||||
|
t.Errorf("output mismatch:\ngot: %q\nwant: %q", out, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMarshalEmbeddedStructPromoted(t *testing.T) {
|
||||||
|
type Base struct {
|
||||||
|
ID int `toml:"id"`
|
||||||
|
}
|
||||||
|
type Derived struct {
|
||||||
|
Base
|
||||||
|
Name string `toml:"name"`
|
||||||
|
}
|
||||||
|
out, err := Marshal(Derived{Base: Base{ID: 1}, Name: "x"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal: %v", err)
|
||||||
|
}
|
||||||
|
want := "id = 1\nname = \"x\"\n"
|
||||||
|
if string(out) != want {
|
||||||
|
t.Errorf("output mismatch:\ngot: %q\nwant: %q", out, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMarshalEmbeddedStructAsTable(t *testing.T) {
|
||||||
|
type Inner struct {
|
||||||
|
Host string `toml:"host"`
|
||||||
|
}
|
||||||
|
type Cfg struct {
|
||||||
|
Inner Inner `toml:"inner"`
|
||||||
|
Name string `toml:"name"`
|
||||||
|
}
|
||||||
|
out, err := Marshal(Cfg{Inner: Inner{Host: "h"}, Name: "n"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal: %v", err)
|
||||||
|
}
|
||||||
|
want := "name = \"n\"\n\n[inner]\nhost = \"h\"\n"
|
||||||
|
if string(out) != want {
|
||||||
|
t.Errorf("output mismatch:\ngot: %q\nwant: %q", out, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMarshalMapKeysSorted(t *testing.T) {
|
||||||
|
m := map[string]any{
|
||||||
|
"zeta": 1,
|
||||||
|
"alpha": 2,
|
||||||
|
"mu": 3,
|
||||||
|
}
|
||||||
|
out, err := Marshal(m)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal: %v", err)
|
||||||
|
}
|
||||||
|
want := "alpha = 2\nmu = 3\nzeta = 1\n"
|
||||||
|
if string(out) != want {
|
||||||
|
t.Errorf("output mismatch:\ngot: %q\nwant: %q", out, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMarshalMapWithSubMap(t *testing.T) {
|
||||||
|
m := map[string]any{
|
||||||
|
"meta": map[string]any{"x": 1, "y": 2},
|
||||||
|
"a": "z",
|
||||||
|
}
|
||||||
|
out, err := Marshal(m)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal: %v", err)
|
||||||
|
}
|
||||||
|
want := "a = \"z\"\n\n[meta]\nx = 1\ny = 2\n"
|
||||||
|
if string(out) != want {
|
||||||
|
t.Errorf("output mismatch:\ngot: %q\nwant: %q", out, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMarshalMarshaler(t *testing.T) {
|
||||||
|
type Port int
|
||||||
|
type Cfg struct {
|
||||||
|
P Port `toml:"p"`
|
||||||
|
}
|
||||||
|
out, err := Marshal(Cfg{P: 8080})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal: %v", err)
|
||||||
|
}
|
||||||
|
want := "p = 8080\n"
|
||||||
|
if string(out) != want {
|
||||||
|
t.Errorf("output mismatch:\ngot: %q\nwant: %q", out, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMarshalMarshalerReturningScalar(t *testing.T) {
|
||||||
|
type Wrapped struct {
|
||||||
|
Value string `toml:"value"`
|
||||||
|
}
|
||||||
|
type Alias struct{}
|
||||||
|
out, err := Marshal(struct {
|
||||||
|
W Wrapped `toml:"w"`
|
||||||
|
}{W: Wrapped{Value: "hello"}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal: %v", err)
|
||||||
|
}
|
||||||
|
want := "[w]\nvalue = \"hello\"\n"
|
||||||
|
if string(out) != want {
|
||||||
|
t.Errorf("output mismatch:\ngot: %q\nwant: %q", out, want)
|
||||||
|
}
|
||||||
|
_ = Alias{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMarshalMarshalerReturningDifferentShape(t *testing.T) {
|
||||||
|
out, err := Marshal(struct {
|
||||||
|
C Custom `toml:"c"`
|
||||||
|
}{C: Custom{tag: "x"}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal: %v", err)
|
||||||
|
}
|
||||||
|
// Custom returns a string from MarshalTOML.
|
||||||
|
want := "c = \"x\"\n"
|
||||||
|
if string(out) != want {
|
||||||
|
t.Errorf("output mismatch:\ngot: %q\nwant: %q", out, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMarshalNilPointerFieldSkipped(t *testing.T) {
|
||||||
|
type Cfg struct {
|
||||||
|
Name string `toml:"name"`
|
||||||
|
Hidden *string `toml:"hidden"`
|
||||||
|
}
|
||||||
|
out, err := Marshal(Cfg{Name: "x"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal: %v", err)
|
||||||
|
}
|
||||||
|
want := "name = \"x\"\n"
|
||||||
|
if string(out) != want {
|
||||||
|
t.Errorf("output mismatch:\ngot: %q\nwant: %q", out, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMarshalNonNilPointerFollowed(t *testing.T) {
|
||||||
|
v := "v"
|
||||||
|
type Cfg struct {
|
||||||
|
Name string `toml:"name"`
|
||||||
|
Hidden *string `toml:"hidden"`
|
||||||
|
}
|
||||||
|
out, err := Marshal(Cfg{Name: "n", Hidden: &v})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal: %v", err)
|
||||||
|
}
|
||||||
|
want := "name = \"n\"\nhidden = \"v\"\n"
|
||||||
|
if string(out) != want {
|
||||||
|
t.Errorf("output mismatch:\ngot: %q\nwant: %q", out, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMarshalTopLevelMustBeStructOrMap(t *testing.T) {
|
||||||
|
if _, err := Marshal(42); err == nil {
|
||||||
|
t.Errorf("expected error marshalling int at top level")
|
||||||
|
}
|
||||||
|
if _, err := Marshal("hello"); err == nil {
|
||||||
|
t.Errorf("expected error marshalling string at top level")
|
||||||
|
}
|
||||||
|
if _, err := Marshal(nil); err == nil {
|
||||||
|
t.Errorf("expected error marshalling nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMarshalMapKeyMustBeString(t *testing.T) {
|
||||||
|
m := map[int]any{1: "x"}
|
||||||
|
if _, err := Marshal(m); err == nil {
|
||||||
|
t.Errorf("expected error for non-string map key")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMarshalUnexportedFieldSkipped(t *testing.T) {
|
||||||
|
type Cfg struct {
|
||||||
|
Pub string `toml:"pub"`
|
||||||
|
priv string
|
||||||
|
}
|
||||||
|
out, err := Marshal(Cfg{Pub: "p", priv: "s"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal: %v", err)
|
||||||
|
}
|
||||||
|
want := "pub = \"p\"\n"
|
||||||
|
if string(out) != want {
|
||||||
|
t.Errorf("output mismatch:\ngot: %q\nwant: %q", out, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMarshalBareAndQuotedKeys(t *testing.T) {
|
||||||
|
type Cfg struct {
|
||||||
|
Bare string `toml:"bare_key"`
|
||||||
|
Dash string `toml:"with-dash"`
|
||||||
|
Num string `toml:"num123"`
|
||||||
|
Q string `toml:"needs space"`
|
||||||
|
Dot string `toml:"needs.dot"`
|
||||||
|
}
|
||||||
|
out, err := Marshal(Cfg{
|
||||||
|
Bare: "a", Dash: "b", Num: "c", Q: "d", Dot: "e",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal: %v", err)
|
||||||
|
}
|
||||||
|
want := "bare_key = \"a\"\nwith-dash = \"b\"\nnum123 = \"c\"\n\"needs space\" = \"d\"\n\"needs.dot\" = \"e\"\n"
|
||||||
|
if string(out) != want {
|
||||||
|
t.Errorf("output mismatch:\ngot: %q\nwant: %q", out, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMarshalThenParseRoundTrip(t *testing.T) {
|
||||||
|
type Server struct {
|
||||||
|
Host string `toml:"host"`
|
||||||
|
Port int `toml:"port"`
|
||||||
|
Enabled bool `toml:"enabled"`
|
||||||
|
Tags []string `toml:"tags"`
|
||||||
|
}
|
||||||
|
type Form struct {
|
||||||
|
Name string `toml:"name"`
|
||||||
|
Allowed []string `toml:"allowed"`
|
||||||
|
}
|
||||||
|
type Cfg struct {
|
||||||
|
Title string `toml:"title"`
|
||||||
|
Count int `toml:"count"`
|
||||||
|
Ratio float64 `toml:"ratio"`
|
||||||
|
Server Server `toml:"server"`
|
||||||
|
Forms []Form `toml:"forms"`
|
||||||
|
Due time.Time `toml:"due"`
|
||||||
|
Day LocalDate `toml:"day"`
|
||||||
|
}
|
||||||
|
in := Cfg{
|
||||||
|
Title: "demo",
|
||||||
|
Count: 42,
|
||||||
|
Ratio: 3.14,
|
||||||
|
Server: Server{
|
||||||
|
Host: "127.0.0.1", Port: 9090, Enabled: true,
|
||||||
|
Tags: []string{"a", "b"},
|
||||||
|
},
|
||||||
|
Forms: []Form{
|
||||||
|
{Name: "contact", Allowed: []string{"x"}},
|
||||||
|
{Name: "feedback", Allowed: nil},
|
||||||
|
},
|
||||||
|
Due: time.Date(2026, 6, 26, 10, 0, 0, 0, time.UTC),
|
||||||
|
Day: LocalDate{Time: time.Date(2026, 6, 26, 0, 0, 0, 0, time.UTC)},
|
||||||
|
}
|
||||||
|
out, err := Marshal(in)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal: %v", err)
|
||||||
|
}
|
||||||
|
tree1, err := Parse(out)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse of marshalled: %v\noutput:\n%s", err, out)
|
||||||
|
}
|
||||||
|
// Decode back into the struct.
|
||||||
|
var out2 Cfg
|
||||||
|
if err := Unmarshal(out, &out2); err != nil {
|
||||||
|
t.Fatalf("unmarshal of marshalled: %v", err)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(in, out2) {
|
||||||
|
t.Errorf("round-trip mismatch:\nin: %#v\nout: %#v", in, out2)
|
||||||
|
}
|
||||||
|
_ = tree1
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMarshalRoundTripFromUntypedTree(t *testing.T) {
|
||||||
|
src := []byte(`title = "demo"
|
||||||
|
count = 42
|
||||||
|
ratio = 3.14
|
||||||
|
enabled = true
|
||||||
|
|
||||||
|
[server]
|
||||||
|
host = "127.0.0.1"
|
||||||
|
port = 9090
|
||||||
|
|
||||||
|
[[items]]
|
||||||
|
name = "a"
|
||||||
|
qty = 1
|
||||||
|
|
||||||
|
[[items]]
|
||||||
|
name = "b"
|
||||||
|
qty = 2
|
||||||
|
|
||||||
|
[meta]
|
||||||
|
created = 2026-06-26T10:00:00Z
|
||||||
|
`)
|
||||||
|
tree1, err := Parse(src)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse src: %v", err)
|
||||||
|
}
|
||||||
|
out, err := Marshal(tree1)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal: %v", err)
|
||||||
|
}
|
||||||
|
tree2, err := Parse(out)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("re-parse marshalled: %v\noutput:\n%s", err, out)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(tree1, tree2) {
|
||||||
|
t.Errorf("round-trip mismatch:\nbefore: %#v\nafter: %#v", tree1, tree2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMarshalUintOverflow(t *testing.T) {
|
||||||
|
type Cfg struct {
|
||||||
|
Big uint64 `toml:"big"`
|
||||||
|
}
|
||||||
|
if _, err := Marshal(Cfg{Big: 1<<63 + 1}); err == nil {
|
||||||
|
t.Errorf("expected overflow error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMarshalKeyRequiresUTF8(t *testing.T) {
|
||||||
|
m := map[string]any{"\xff": "x"}
|
||||||
|
if _, err := Marshal(m); err == nil {
|
||||||
|
t.Errorf("expected error for invalid UTF-8 key")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMarshalStringRequiresUTF8(t *testing.T) {
|
||||||
|
type Cfg struct {
|
||||||
|
S string `toml:"s"`
|
||||||
|
}
|
||||||
|
if _, err := Marshal(Cfg{S: "abc\xff"}); err == nil {
|
||||||
|
t.Errorf("expected error for invalid UTF-8 string")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLocalDateString(t *testing.T) {
|
||||||
|
ld := LocalDate{Time: time.Date(1979, 5, 27, 0, 0, 0, 0, time.UTC)}
|
||||||
|
if got := ld.String(); got != "1979-05-27" {
|
||||||
|
t.Errorf("LocalDate.String() = %q, want 1979-05-27", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLocalDateTimeString(t *testing.T) {
|
||||||
|
ldt := LocalDateTime{Time: time.Date(1979, 5, 27, 7, 32, 0, 0, time.UTC)}
|
||||||
|
if got := ldt.String(); got != "1979-05-27T07:32:00" {
|
||||||
|
t.Errorf("LocalDateTime.String() = %q, want 1979-05-27T07:32:00", got)
|
||||||
|
}
|
||||||
|
ldt2 := LocalDateTime{Time: time.Date(1979, 5, 27, 7, 32, 0, 5, time.UTC)}
|
||||||
|
if got := ldt2.String(); got != "1979-05-27T07:32:00.000000005" {
|
||||||
|
t.Errorf("LocalDateTime.String() = %q, want 1979-05-27T07:32:00.000000005", got)
|
||||||
|
}
|
||||||
|
ldt3 := LocalDateTime{Time: time.Date(1979, 5, 27, 7, 32, 0, 500, time.UTC)}
|
||||||
|
if got := ldt3.String(); got != "1979-05-27T07:32:00.000000500" {
|
||||||
|
t.Errorf("LocalDateTime.String() = %q, want 1979-05-27T07:32:00.000000500", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLocalTimeString(t *testing.T) {
|
||||||
|
lt := LocalTime{Time: time.Date(0, 1, 1, 7, 32, 0, 0, time.UTC)}
|
||||||
|
if got := lt.String(); got != "07:32:00" {
|
||||||
|
t.Errorf("LocalTime.String() = %q, want 07:32:00", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEncoderEquivalenceToMarshal(t *testing.T) {
|
||||||
|
type Cfg struct {
|
||||||
|
Title string `toml:"title"`
|
||||||
|
Count int `toml:"count"`
|
||||||
|
}
|
||||||
|
in := Cfg{Title: "x", Count: 7}
|
||||||
|
a, err := Marshal(in)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal: %v", err)
|
||||||
|
}
|
||||||
|
b, err := NewEncoder().Marshal(in)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("encoder marshal: %v", err)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(a, b) {
|
||||||
|
t.Errorf("Marshal and Encoder disagree:\n%s\n%s", a, b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Custom struct {
|
||||||
|
tag string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c Custom) MarshalTOML() (any, error) { return c.tag, nil }
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
// Command basic demonstrates decoding a TOML document with interpres.
|
// Command basic demonstrates decoding and encoding a TOML document with
|
||||||
|
// interpres.
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
|
|
||||||
"codeberg.org/petrbalvin/interpres"
|
"sourcedock.dev/petrbalvin/interpres"
|
||||||
)
|
)
|
||||||
|
|
||||||
const document = `
|
const document = `
|
||||||
@@ -48,4 +49,10 @@ func main() {
|
|||||||
for _, u := range cfg.Users {
|
for _, u := range cfg.Users {
|
||||||
fmt.Printf("user: %-6s admin=%t\n", u.Name, u.Admin)
|
fmt.Printf("user: %-6s admin=%t\n", u.Name, u.Admin)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
out, err := interpres.Marshal(cfg)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
fmt.Printf("\n--- marshal ---\n%s", out)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
module codeberg.org/petrbalvin/interpres
|
module sourcedock.dev/petrbalvin/interpres
|
||||||
|
|
||||||
go 1.26
|
go 1.26
|
||||||
|
|||||||
+58
-2
@@ -1,11 +1,13 @@
|
|||||||
// Package interpres is a dependency-free TOML parser for Go.
|
// Package interpres is a dependency-free TOML parser for Go.
|
||||||
//
|
//
|
||||||
// interpres reads TOML documents into Go values using only the standard
|
// interpres reads and writes TOML documents using only the standard library.
|
||||||
// library. It exposes a small, encoding/json-style API:
|
// It exposes a small, encoding/json-style API:
|
||||||
//
|
//
|
||||||
// var cfg Config
|
// var cfg Config
|
||||||
// err := interpres.Unmarshal(data, &cfg)
|
// err := interpres.Unmarshal(data, &cfg)
|
||||||
//
|
//
|
||||||
|
// out, err := interpres.Marshal(cfg)
|
||||||
|
//
|
||||||
// or, for an untyped tree:
|
// or, for an untyped tree:
|
||||||
//
|
//
|
||||||
// tree, err := interpres.Parse(data)
|
// tree, err := interpres.Parse(data)
|
||||||
@@ -84,3 +86,57 @@ func (d *Decoder) Decode(data []byte, v any) error {
|
|||||||
dec.disallowUnknown = d.disallowUnknown
|
dec.disallowUnknown = d.disallowUnknown
|
||||||
return dec.decode(tree, v)
|
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 — 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
func Marshal(v any) ([]byte, error) {
|
||||||
|
return NewEncoder().Marshal(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
// An Encoder encodes Go values into TOML.
|
||||||
|
type Encoder struct{}
|
||||||
|
|
||||||
|
// NewEncoder returns an Encoder.
|
||||||
|
func NewEncoder() *Encoder { return &Encoder{} }
|
||||||
|
|
||||||
|
// Marshal encodes v to TOML bytes. It is equivalent to calling Marshal with v.
|
||||||
|
func (e *Encoder) Marshal(v any) ([]byte, error) {
|
||||||
|
enc := newEncoder()
|
||||||
|
if err := enc.encode(v); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return enc.bytes(), nil
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user