Compare commits
51
Commits
v1.0.0
..
b695b69768
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b695b69768 | ||
|
|
959eaba4b0 | ||
|
|
8f85bb68fa | ||
|
|
bccaf087c8 | ||
|
|
0149a5b4d1 | ||
|
|
815141440e | ||
|
|
9023784da3 | ||
|
|
942c4b1489 | ||
|
|
8f0eae6604 | ||
|
|
1c7329aeea | ||
|
|
ad6c32d0c6 | ||
|
|
17574a0d15 | ||
|
|
8aa2b1b9c0 | ||
|
|
6a043e2824 | ||
|
|
81033bb27c | ||
|
|
dfd5d240d2 | ||
|
|
78946578d1 | ||
|
|
d365729b37 | ||
|
|
53102d70e6 | ||
|
|
f1a757ec5c | ||
|
|
3c8ac859c0 | ||
|
|
4def1b3e8b | ||
|
|
d5327568fb | ||
|
|
c485aab227 | ||
|
|
30b28fe7fc | ||
|
|
aaea68efc9 | ||
|
|
a8d69d90d5 | ||
|
|
bb238c98c3 | ||
|
|
54c6032a9a | ||
|
|
ec0d7a0023 | ||
|
|
feef4fe9ea | ||
|
|
3c1f65038b | ||
|
|
830f840f44 | ||
|
|
696f117c22 | ||
|
|
d2fc31d260 | ||
|
|
18f1cd51e9 | ||
|
|
3cd538fad6 | ||
|
|
e19a6f35f1 | ||
|
|
5a270d0879 | ||
|
|
3f41266710 | ||
|
|
1e3198c8b6 | ||
|
|
cdb42de561 | ||
|
|
0f6d81fe3e | ||
|
|
274b8a488c | ||
|
|
b061c97a81 | ||
|
|
fc50e3c49a | ||
|
|
93c36cf376 | ||
|
|
58e7dfb1d0 | ||
|
|
510cfb5182 | ||
|
|
3ac0b1e301 | ||
|
|
2737a5ac87 |
@@ -30,6 +30,14 @@ env:
|
|||||||
GOFLAGS: -p=1
|
GOFLAGS: -p=1
|
||||||
GOMAXPROCS: "2"
|
GOMAXPROCS: "2"
|
||||||
|
|
||||||
|
# A superseded run of the same ref is cancelled instead of queueing behind one
|
||||||
|
# that no longer matters. Verified on this Gitea on 2026-09-17: a queued run
|
||||||
|
# whose ref moved on is cancelled before it ever reaches the runner, while a
|
||||||
|
# run already dispatched there runs to completion.
|
||||||
|
concurrency:
|
||||||
|
group: ${{ gitea.workflow }}-${{ gitea.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
test:
|
||||||
runs-on: fedora
|
runs-on: fedora
|
||||||
@@ -91,10 +99,14 @@ jobs:
|
|||||||
# output has to be captured into a variable.
|
# output has to be captured into a variable.
|
||||||
env:
|
env:
|
||||||
GOBIN: ${{ gitea.workspace }}/bin
|
GOBIN: ${{ gitea.workspace }}/bin
|
||||||
run: go install github.com/toml-lang/toml-test/cmd/toml-test@v1.6.0
|
run: go install github.com/toml-lang/toml-test/v2/cmd/toml-test@v2.2.0
|
||||||
|
|
||||||
- name: Build the decoder
|
- name: Build the decoder
|
||||||
run: go build -o bin/interpres-decode ./cmd/interpres-decode
|
run: go build -o bin/interpres-decode ./cmd/interpres-decode
|
||||||
|
|
||||||
- name: Compliance suite
|
- name: Compliance suite
|
||||||
run: bin/toml-test bin/interpres-decode
|
# interpres implements TOML 1.1, and the suite runs both directions: the decoder
|
||||||
|
# on the valid and invalid corpora, the encoder on the tagged JSON of the valid
|
||||||
|
# one. The mode is pinned so an upstream default change cannot silently move the
|
||||||
|
# corpus.
|
||||||
|
run: bin/toml-test test -decoder=bin/interpres-decode -encoder='bin/interpres-decode -encode' -toml=1.1
|
||||||
|
|||||||
+154
-1
@@ -9,7 +9,160 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
-
|
- `encoding.TextMarshaler` and `encoding.TextUnmarshaler` are honoured by
|
||||||
|
default, with no option to switch them off. A type that implements them is
|
||||||
|
encoded as a TOML string and decoded from one: `net.IP` becomes
|
||||||
|
`"192.0.2.1"`, and a user type with `MarshalText` or `UnmarshalText` follows.
|
||||||
|
`MarshalTOML` and `UnmarshalTOML` still win over the text methods, and the
|
||||||
|
four date-time types keep their bare timestamp form instead of becoming a
|
||||||
|
quoted string. A struct type that implements the interface now encodes as a
|
||||||
|
string where it was a table before, which is the breaking part of the change.
|
||||||
|
- `time.Duration` is encoded in its canonical Go form as a TOML string,
|
||||||
|
`1h30m0s`, because TOML has no duration type; the decoder reads that string
|
||||||
|
back and still accepts a bare integer as the nanosecond count.
|
||||||
|
- `interpres-decode -encode`, the adapter's other direction: it reads the
|
||||||
|
toml-test tagged JSON from stdin and writes the TOML document it describes.
|
||||||
|
The compliance suite now runs the encoder as well as the decoder, 214
|
||||||
|
encoder cases against the tagged JSON of the valid corpus.
|
||||||
|
- `Encoder.InlineTables(threshold)`: a sub-table whose single-line rendering is
|
||||||
|
at most `threshold` bytes is written as an inline table instead of a header
|
||||||
|
section, which shortens a document of small tables. An array of tables keeps
|
||||||
|
its header form, because its inline form would re-parse as a value array.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- The output takes the TOML 1.1 form. A date-time writes its seconds only when
|
||||||
|
the value carries them and drops the trailing zeros of a fractional second,
|
||||||
|
so `07:32:00` is written `07:32` and half a second as `00.5`. Both are the
|
||||||
|
same value, and a document written without seconds now comes back without
|
||||||
|
them. `LocalDateTime.String()`, `LocalTime.String()` and the offset date-time
|
||||||
|
rendering follow the same rule.
|
||||||
|
- An inline table that would pass the hundredth column is written across lines
|
||||||
|
with a trailing comma and one tab of indentation per nesting level, the shape
|
||||||
|
TOML 1.1 allows an inline table to take.
|
||||||
|
- TOML 1.1 is the acceptance contract, and TOML 1.0 is not. The compliance
|
||||||
|
suite runs the 1.1 corpus alone, and the promise that every 1.0 document
|
||||||
|
parses exactly as before is withdrawn. Nothing that parses today stops
|
||||||
|
parsing: the 1.0 valid corpus still passes in full. The documents whose
|
||||||
|
verdict changes are the ones 1.1 relaxed, such as the `\xHH` escape
|
||||||
|
sequences 1.0 rejected.
|
||||||
|
- The module path carries the /v2 suffix the Go toolchain requires of
|
||||||
|
every major version 2 module: imports change to
|
||||||
|
`sourcedock.dev/petrbalvin/interpres/v2`.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Decoding into a defined type whose underlying kind is string or bool, such
|
||||||
|
as `type Name string`, panicked instead of storing the value, because a
|
||||||
|
value of the predeclared type is not assignable to a defined type and the
|
||||||
|
decoder assigned it without a conversion.
|
||||||
|
|
||||||
|
## [1.1.0] - 2026-09-18
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- TOML 1.1 support, on by default: date-times and times without seconds
|
||||||
|
(`07:32`, `1979-05-27T07:32`, normalised to full seconds on output), the
|
||||||
|
`\e` and `\xHH` escape sequences, and multi-line inline tables with
|
||||||
|
comments and trailing commas. The compliance suite runs in TOML 1.1 mode:
|
||||||
|
214 valid and 467 invalid cases, zero failures. Every TOML 1.0 document
|
||||||
|
parses exactly as before.
|
||||||
|
- `interpres-decode -validate [file ...]`: a validate mode beside the
|
||||||
|
toml-test adapter. It parses each named file, or stdin when none are named,
|
||||||
|
prints one line per invalid document to stderr, and exits 0 when all are
|
||||||
|
valid, 1 when one is not, and 2 on a usage or read failure. Install it with
|
||||||
|
`go install .../cmd/interpres-decode@latest`; releases still ship no
|
||||||
|
binaries.
|
||||||
|
- `DecodeError` and `EncodeError`: decode and encode failures are wrapped in
|
||||||
|
typed errors carrying the key path, read with `errors.AsType` instead of
|
||||||
|
parsing the message text. The rendered messages keep their shape; the only
|
||||||
|
visible change is that an encode failure on a top-level field no longer
|
||||||
|
gains a meaningless leading dot in its path.
|
||||||
|
- `omitzero` and `omitempty` tag options on encode: `toml:"name,omitzero"`
|
||||||
|
skips a field whose value is the zero value of its type (a type with an
|
||||||
|
`IsZero() bool` method decides through the method), and
|
||||||
|
`toml:"name,omitempty"` skips a nil or empty slice, array, or map. The
|
||||||
|
decoder ignores both options.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- The compliance suite is [toml-test](https://github.com/toml-lang/toml-test)
|
||||||
|
v2.2.0, up from v1.6.0. Its TOML 1.0 corpus holds 205 valid and 474 invalid
|
||||||
|
cases (185 and 371 before), and it caught the two documents the parser
|
||||||
|
still accepted, fixed below.
|
||||||
|
- The flattened struct layout the decoder consults is cached per struct type
|
||||||
|
and shared with the encoder, which now resolves duplicate field keys with
|
||||||
|
it. Strict decoding of an array of tables of structs runs about a quarter
|
||||||
|
faster; marshalling structs gained the same layout without measurable cost.
|
||||||
|
- The parser scans the input bytes in place instead of building a `[]rune`
|
||||||
|
copy of the document: every character that drives the grammar is ASCII and
|
||||||
|
the input is validated UTF-8 up front, so the conversion pass and its four
|
||||||
|
bytes per rune were pure overhead. Parsing a large array-of-tables document
|
||||||
|
runs about a fifth faster and allocates about half the memory.
|
||||||
|
- Numeric tokens without underscores skip the normalising rebuild: digits are
|
||||||
|
validated in place in `joinDigits`, and a float whose token is already
|
||||||
|
clean goes to `strconv.ParseFloat` directly. One allocation per integer
|
||||||
|
atom and two per float atom disappear.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- A `MarshalTOML` result of `nil` with a nil error fails the marshal with
|
||||||
|
`MarshalTOML returned a nil value`. The field silently vanished before, and
|
||||||
|
inside a value array the nil result reached reflection as a zero value and
|
||||||
|
panicked.
|
||||||
|
- Strict decoding reports the smallest unknown key. Several unknown keys in
|
||||||
|
one table made the message depend on Go's random map iteration order, so
|
||||||
|
the same document reported different keys across runs.
|
||||||
|
- Decoding into a struct that embeds a pointer to itself terminates. The
|
||||||
|
schema walk recursed through the embedded type forever, so such a
|
||||||
|
`Unmarshal` call hung the process; the walk now tracks the struct types on
|
||||||
|
the current path and stops when one repeats.
|
||||||
|
- An array-of-tables header whose path runs through an inline table
|
||||||
|
(`a = {b = {}}` followed by `[[a.b.c]]`) is rejected. The frozen-inline-table
|
||||||
|
check covered `[table]` headers and dotted keys but not the intermediate
|
||||||
|
steps of an array-of-tables header, so such a document silently extended the
|
||||||
|
inline table.
|
||||||
|
- A new element of an array of tables starts a fresh scope for dotted-key paths
|
||||||
|
and nested arrays of tables: `[[a]]`, `b.c = 1`, `[[a]]`, `[a.b]` parses, as
|
||||||
|
the TOML examples in the spec shape it. The records of the previous element
|
||||||
|
falsely rejected the same paths in the next one.
|
||||||
|
- `Marshal` emits exactly one key when two struct fields resolve to the same
|
||||||
|
TOML name, picking the field the decoder would fill (the shallower one, the
|
||||||
|
later declaration at equal depth). Such a struct previously marshalled into
|
||||||
|
a duplicate key, and the output never re-parsed, breaking the round-trip
|
||||||
|
guarantee.
|
||||||
|
- `Marshal` returns an error for a table header key or an inline-table key that
|
||||||
|
is not valid UTF-8, the way scalar keys already did, instead of silently
|
||||||
|
emitting corrupt TOML (a header that lost its key, an inline table with a
|
||||||
|
missing key).
|
||||||
|
- `UseLiteralMultiline` falls back to the escaped basic string when the value
|
||||||
|
cannot be carried verbatim by the literal form: a run of three single quotes,
|
||||||
|
a control character, or a lone carriage return. Such values previously
|
||||||
|
produced output that did not re-parse.
|
||||||
|
- A `[]any` holding only tables marshals in the value-array form with inline
|
||||||
|
tables, keeping the type `Parse` produces for such an array. It previously
|
||||||
|
took the `[[header]]` form, so a round-trip changed the value's type from
|
||||||
|
`[]any` to `[]map[string]any`.
|
||||||
|
- Decoding into a `uint` destination checks the type's platform width instead
|
||||||
|
of only the fixed widths, so a 32-bit `uint` no longer truncates silently;
|
||||||
|
decoding a finite float beyond the `float32` range is an overflow error
|
||||||
|
instead of a silent infinity.
|
||||||
|
- Struct fields that resolve to one key at equal depth decode through the
|
||||||
|
field declared later, matching the documented rule; the first one won before.
|
||||||
|
- A float with an exponent marker but no digits (`1e`, `0.0E`) is rejected;
|
||||||
|
the exponent requires at least one digit.
|
||||||
|
- A date-time offset outside 00:00 through 23:59 is rejected; such offsets
|
||||||
|
were accepted and silently rolled over (`+00:60` decoded as `+01:00`).
|
||||||
|
- Untagged embedded fields now decode symmetrically with encode: an embedded
|
||||||
|
struct receives its keys inline (a nil embedded pointer struct is
|
||||||
|
allocated), an embedded map catches the keys no field claims, and a name
|
||||||
|
clash resolves in favour of the shallower field. A struct with an untagged
|
||||||
|
embedded field previously decoded with all inline keys dropped and did not
|
||||||
|
round-trip.
|
||||||
|
- `Marshal` re-emits arrays that mix tables with scalars: the table elements
|
||||||
|
render as inline tables inside the value array. A tree that `Parse` accepts
|
||||||
|
from such a document previously failed with
|
||||||
|
`cannot encode map[string]interface {}`.
|
||||||
|
|
||||||
## [1.0.0] - 2026-08-20
|
## [1.0.0] - 2026-08-20
|
||||||
|
|
||||||
|
|||||||
+29
-8
@@ -1,10 +1,29 @@
|
|||||||
# Contributing
|
# Contributing
|
||||||
|
|
||||||
Thanks for contributing to **interpres**.
|
Contributions to **interpres** are governed by the Contributor terms
|
||||||
|
below; submitting one means you accept them.
|
||||||
|
|
||||||
|
## Contributor terms
|
||||||
|
|
||||||
|
1. This project belongs to its owner alone. The owner decides what is
|
||||||
|
accepted, in what form and when; the decision is final and needs no
|
||||||
|
justification.
|
||||||
|
2. By submitting a contribution you assign to Petr Balvín
|
||||||
|
<opensource@petrbalvin.org> all present and future copyright and
|
||||||
|
related rights in it, worldwide, for the full term of the rights,
|
||||||
|
with the right to relicense and sublicense without restriction,
|
||||||
|
including under proprietary terms.
|
||||||
|
3. Where that assignment is not effective, it counts as a perpetual,
|
||||||
|
irrevocable, royalty-free licence with the same scope.
|
||||||
|
4. To the fullest extent permitted by law, you waive any right of
|
||||||
|
attribution and integrity in the contribution. The project names no
|
||||||
|
contributors and keeps no credits list.
|
||||||
|
5. By submitting you represent that the work is yours and that you
|
||||||
|
hold the rights to assign it as above.
|
||||||
|
|
||||||
## Development setup
|
## Development setup
|
||||||
|
|
||||||
Requirements: Go 1.27.0, the version `go.mod` declares, and
|
Requirements: Go 1.27.1, the version `go.mod` declares, and
|
||||||
[just](https://github.com/casey/just) for the recipes.
|
[just](https://github.com/casey/just) for the recipes.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
@@ -26,17 +45,19 @@ just test
|
|||||||
formatting pass are three commits, never one.
|
formatting pass are three commits, never one.
|
||||||
4. Record every user-visible change in `CHANGELOG.md` under `## [development]`.
|
4. Record every user-visible change in `CHANGELOG.md` under `## [development]`.
|
||||||
5. Add or update tests. Coverage stays at 80 percent or more; it is a hard
|
5. Add or update tests. Coverage stays at 80 percent or more; it is a hard
|
||||||
gate. Parser and decoder changes must also keep the toml-test suite at zero
|
gate. Parser, decoder and encoder changes must also keep both directions of
|
||||||
failures, checked with `just toml-test`.
|
the toml-test suite at zero failures, checked with `just toml-test`.
|
||||||
6. Update the documentation when the public API, the configuration or the
|
6. Update the documentation when the public API, the configuration or the
|
||||||
behaviour changes; the documents move in the same commit as the behaviour
|
behaviour changes; the documents move in the same commit as the behaviour
|
||||||
they describe.
|
they describe.
|
||||||
7. Open a pull request against `development`.
|
7. Open a pull request against `development`.
|
||||||
|
|
||||||
Releases are cut by merging `development` into `main` and tagging `vX.Y.Z`. The
|
Releases are cut by merging `development` into `main` and tagging `vX.Y.Z`. The
|
||||||
release workflow runs the full gate set including the race detector and
|
release workflow validates the tag, runs the static gates and the test suite
|
||||||
publishes the Gitea release with the matching `CHANGELOG.md` section as its
|
with the coverage floor, and publishes the Gitea release with the matching
|
||||||
notes.
|
`CHANGELOG.md` section as its notes. The race detector is not in that set: race
|
||||||
|
never runs on a push path, and the local `just gates` raced the tree before the
|
||||||
|
tag was cut.
|
||||||
|
|
||||||
## Code style
|
## Code style
|
||||||
|
|
||||||
@@ -95,7 +116,7 @@ Workflows live in `.gitea/workflows/` and run on the project's own runners:
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Test | push or pull request to `development` | format check, vet, modernisation, build, the test suite with the coverage floor, the toml-test compliance suite |
|
| Test | push or pull request to `development` | format check, vet, modernisation, build, the test suite with the coverage floor, the toml-test compliance suite |
|
||||||
| Race | `workflow_dispatch`, by hand | the suite under the race detector, the same race gate the local `just gates` runs |
|
| Race | `workflow_dispatch`, by hand | the suite under the race detector, the same race gate the local `just gates` runs |
|
||||||
| Release | a `v*` tag | the same gates plus the race detector, then the Gitea release created from the `CHANGELOG.md` section |
|
| Release | a `v*` tag | tag validation, format, vet, modernisation, build and the test suite with the coverage floor, then the Gitea release created from the `CHANGELOG.md` section; no race detector |
|
||||||
|
|
||||||
The local equivalent is `just gates`, which is the same set plus the race
|
The local equivalent is `just gates`, which is the same set plus the race
|
||||||
detector.
|
detector.
|
||||||
|
|||||||
@@ -1,37 +1,41 @@
|
|||||||
# interpres
|
# interpres
|
||||||
|
|
||||||
A TOML 1.0 parser and encoder for Go, written with the standard library alone.
|
A TOML 1.1 parser and encoder for Go, written with the standard library
|
||||||
`interpres` (Latin for *interpreter*) gives zero-dependency programs an
|
alone. `interpres` (Latin for *interpreter*) gives zero-dependency
|
||||||
`encoding/json`-style API for reading and writing TOML, and passes the entire
|
programs an `encoding/json`-style API for reading and writing TOML, and passes
|
||||||
official [toml-test](https://github.com/toml-lang/toml-test) suite: 185 valid
|
the entire official [toml-test](https://github.com/toml-lang/toml-test) suite:
|
||||||
and 371 invalid cases, zero failures.
|
214 valid, 467 invalid and 214 encoder cases, zero failures.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **Full TOML 1.0**: bare, quoted and dotted keys; tables and arrays of tables;
|
- **Full TOML 1.1**: bare, quoted and dotted keys; tables and arrays of
|
||||||
basic and literal strings including multiline; integers in the four radixes
|
tables; basic and literal strings including multiline, with the 1.1 `\e` and
|
||||||
with `_` separators; floats with exponents, `inf` and `nan`; booleans; the
|
`\xHH` escapes; integers in the four radixes with `_` separators; floats with
|
||||||
four date-time kinds; arrays and inline tables.
|
exponents, `inf` and `nan`; booleans; the four date-time kinds, seconds
|
||||||
|
optional as of 1.1; arrays and inline tables, multi-line as of 1.1.
|
||||||
- **Decoding and encoding**: `Parse` for an untyped tree, `Unmarshal` and
|
- **Decoding and encoding**: `Parse` for an untyped tree, `Unmarshal` and
|
||||||
`Marshal` for structs and maps, mirroring `encoding/json`.
|
`Marshal` for structs and maps, mirroring `encoding/json`.
|
||||||
- **Strict decoding**: `NewDecoder().DisallowUnknownFields()` rejects keys that
|
- **Strict decoding**: `NewDecoder().DisallowUnknownFields()` rejects keys that
|
||||||
match no destination field, at every struct depth.
|
match no destination field, at every struct depth.
|
||||||
- **Custom types**: `Marshaler` and `Unmarshaler` let a type control its own
|
- **Custom types**: `Marshaler` and `Unmarshaler` let a type control its own
|
||||||
TOML representation in both directions.
|
TOML representation in both directions, and `encoding.TextMarshaler` and
|
||||||
|
`TextUnmarshaler` are honoured by default, so `net.IP`, `time.Duration` and
|
||||||
|
user types with text methods need no configuration.
|
||||||
- **Cancellation**: every entry point has a `*Context` sibling that honours a
|
- **Cancellation**: every entry point has a `*Context` sibling that honours a
|
||||||
`context.Context`.
|
`context.Context`.
|
||||||
- **Configurable emission**: `Encoder` options for declaration-order output,
|
- **Configurable emission**: `Encoder` options for declaration-order output,
|
||||||
omitting empty arrays, and literal multiline strings.
|
omitting empty arrays, literal multiline strings, and inlining small
|
||||||
|
sub-tables.
|
||||||
|
|
||||||
## Install
|
## Install
|
||||||
|
|
||||||
As a library:
|
As a library:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
go get sourcedock.dev/petrbalvin/interpres
|
go get sourcedock.dev/petrbalvin/interpres/v2
|
||||||
```
|
```
|
||||||
|
|
||||||
Requires Go 1.27.0 or newer. The module imports only the standard library.
|
Requires Go 1.27.1 or newer. The module imports only the standard library.
|
||||||
|
|
||||||
## Quick start
|
## Quick start
|
||||||
|
|
||||||
@@ -160,7 +164,7 @@ See [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) for the full workflow, and
|
|||||||
|
|
||||||
- [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md): components and data flow
|
- [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md): components and data flow
|
||||||
- [docs/API.md](docs/API.md): the API reference, decoding and encoding rules
|
- [docs/API.md](docs/API.md): the API reference, decoding and encoding rules
|
||||||
- [docs/CLI.md](docs/CLI.md): the interpres-decode toml-test adapter
|
- [docs/CLI.md](docs/CLI.md): the interpres-decode adapter and validator
|
||||||
|
|
||||||
## Licence
|
## Licence
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -7,7 +7,7 @@ releases do not receive them.
|
|||||||
|
|
||||||
| Version | Supported |
|
| Version | Supported |
|
||||||
|---|---|
|
|---|---|
|
||||||
| 1.0.0 | yes |
|
| 1.1.0 | yes |
|
||||||
| older releases | no |
|
| older releases | no |
|
||||||
|
|
||||||
## Reporting a vulnerability
|
## Reporting a vulnerability
|
||||||
|
|||||||
+129
@@ -0,0 +1,129 @@
|
|||||||
|
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package interpres
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// benchDoc is a representative configuration document: every scalar kind, an
|
||||||
|
// inline table, sub-tables, and an array of tables.
|
||||||
|
var benchDoc = []byte(`title = "benchmark configuration"
|
||||||
|
replicas = 3
|
||||||
|
ratio = 0.75
|
||||||
|
enabled = true
|
||||||
|
when = 2026-09-17T12:00:00Z
|
||||||
|
local = 2026-09-17T12:00:00
|
||||||
|
tags = ["alpha", "beta", "gamma"]
|
||||||
|
limits = { cpu = 4, memory = 1024 }
|
||||||
|
|
||||||
|
[server]
|
||||||
|
host = "localhost"
|
||||||
|
port = 8080
|
||||||
|
hosts = ["a.example", "b.example"]
|
||||||
|
|
||||||
|
[server.tls]
|
||||||
|
enabled = true
|
||||||
|
cert = "/etc/cert.pem"
|
||||||
|
|
||||||
|
[[items]]
|
||||||
|
name = "first"
|
||||||
|
weight = 10
|
||||||
|
flags = ["x", "y"]
|
||||||
|
|
||||||
|
[[items]]
|
||||||
|
name = "second"
|
||||||
|
weight = 20
|
||||||
|
flags = ["z"]
|
||||||
|
`)
|
||||||
|
|
||||||
|
// longDoc is generated once so the large-input benchmarks measure parsing,
|
||||||
|
// not document construction. Roughly 2000 array-of-tables entries.
|
||||||
|
var longDoc = func() []byte {
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString("title = \"long\"\n")
|
||||||
|
for i := range 2000 {
|
||||||
|
fmt.Fprintf(&b, "[[entry]]\nname = \"entry-%d\"\nweight = %d\nwhen = 2026-09-17T12:00:00Z\nratio = 0.5\ntags = [\"a\", \"b\", \"c\"]\n\n", i, i)
|
||||||
|
}
|
||||||
|
return []byte(b.String())
|
||||||
|
}()
|
||||||
|
|
||||||
|
type benchTLS struct {
|
||||||
|
Enabled bool `toml:"enabled"`
|
||||||
|
Cert string `toml:"cert"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type benchServer struct {
|
||||||
|
Host string `toml:"host"`
|
||||||
|
Port int `toml:"port"`
|
||||||
|
Hosts []string `toml:"hosts"`
|
||||||
|
TLS benchTLS `toml:"tls"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type benchItem struct {
|
||||||
|
Name string `toml:"name"`
|
||||||
|
Weight int `toml:"weight"`
|
||||||
|
Flags []string `toml:"flags"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type benchConfig struct {
|
||||||
|
Title string `toml:"title"`
|
||||||
|
Replicas int `toml:"replicas"`
|
||||||
|
Ratio float64 `toml:"ratio"`
|
||||||
|
Enabled bool `toml:"enabled"`
|
||||||
|
When time.Time `toml:"when"`
|
||||||
|
Local LocalDateTime `toml:"local"`
|
||||||
|
Tags []string `toml:"tags"`
|
||||||
|
Limits map[string]any `toml:"limits"`
|
||||||
|
Server benchServer `toml:"server"`
|
||||||
|
Items []benchItem `toml:"items"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func BenchmarkParse(b *testing.B) {
|
||||||
|
b.ReportAllocs()
|
||||||
|
b.SetBytes(int64(len(benchDoc)))
|
||||||
|
for b.Loop() {
|
||||||
|
if _, err := Parse(benchDoc); err != nil {
|
||||||
|
b.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func BenchmarkMarshal(b *testing.B) {
|
||||||
|
tree, err := Parse(benchDoc)
|
||||||
|
if err != nil {
|
||||||
|
b.Fatal(err)
|
||||||
|
}
|
||||||
|
b.ReportAllocs()
|
||||||
|
b.SetBytes(int64(len(benchDoc)))
|
||||||
|
for b.Loop() {
|
||||||
|
if _, err := Marshal(tree); err != nil {
|
||||||
|
b.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func BenchmarkStrictDecode(b *testing.B) {
|
||||||
|
dec := NewDecoder().DisallowUnknownFields()
|
||||||
|
b.ReportAllocs()
|
||||||
|
for b.Loop() {
|
||||||
|
var cfg benchConfig
|
||||||
|
if err := dec.Decode(benchDoc, &cfg); err != nil {
|
||||||
|
b.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func BenchmarkParseLong(b *testing.B) {
|
||||||
|
b.ReportAllocs()
|
||||||
|
b.SetBytes(int64(len(longDoc)))
|
||||||
|
for b.Loop() {
|
||||||
|
if _, err := Parse(longDoc); err != nil {
|
||||||
|
b.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+257
-11
@@ -1,17 +1,25 @@
|
|||||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||||
// SPDX-License-Identifier: MIT
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
// Command interpres-decode reads a TOML document from standard input and writes
|
// Command interpres-decode is the toml-test harness adapter and a TOML
|
||||||
// the toml-test "tagged JSON" representation to standard output.
|
// validator. Without flags it reads a TOML document from standard input and
|
||||||
|
// writes the toml-test "tagged JSON" representation to standard output. With
|
||||||
|
// -encode it is the reverse: it reads tagged JSON and writes the TOML document
|
||||||
|
// it describes. With -validate it checks the named documents, or standard
|
||||||
|
// input when none are named, and exits non-zero on the first invalid one:
|
||||||
//
|
//
|
||||||
// It exits non-zero on a parse error, which is how the toml-test harness checks
|
// interpres-decode -validate config.toml
|
||||||
// that invalid documents are rejected. Run the official suite against it with:
|
// interpres-decode -encode < case.json
|
||||||
//
|
//
|
||||||
// toml-test ./interpres-decode
|
// Run the official suite in both directions against the adapter with:
|
||||||
|
//
|
||||||
|
// toml-test test -decoder=./interpres-decode -encoder='./interpres-decode -encode'
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"math"
|
"math"
|
||||||
@@ -19,17 +27,41 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"sourcedock.dev/petrbalvin/interpres"
|
"sourcedock.dev/petrbalvin/interpres/v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
os.Exit(Run(os.Stdin, os.Stdout, os.Stderr))
|
os.Exit(Run(os.Args[1:], os.Stdin, os.Stdout, os.Stderr))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run reads a TOML document from stdin, emits the toml-test tagged-JSON form
|
// Run runs the command line and returns the process exit code: 0 success,
|
||||||
// on stdout, and returns the process exit code (0 success, 1 parse error,
|
// 1 an invalid document, 2 a usage, reading, encoding, or
|
||||||
// 2 I/O, encoding, or unsupported-value error).
|
// unsupported-value error.
|
||||||
func Run(stdin io.Reader, stdout, stderr io.Writer) int {
|
func Run(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
|
||||||
|
fs := flag.NewFlagSet("interpres-decode", flag.ContinueOnError)
|
||||||
|
fs.SetOutput(stderr)
|
||||||
|
validate := fs.Bool("validate", false, "validate the documents instead of emitting tagged JSON")
|
||||||
|
encode := fs.Bool("encode", false, "read tagged JSON from stdin and write TOML instead")
|
||||||
|
if err := fs.Parse(args); err != nil {
|
||||||
|
if errors.Is(err, flag.ErrHelp) {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
if *validate && *encode {
|
||||||
|
fmt.Fprintln(stderr, "interpres-decode: -validate and -encode cannot be combined")
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
if *validate {
|
||||||
|
return validatePaths(fs.Args(), stdin, stderr)
|
||||||
|
}
|
||||||
|
if fs.NArg() > 0 {
|
||||||
|
fmt.Fprintln(stderr, "interpres-decode: the adapter mode takes no arguments; name files with -validate")
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
if *encode {
|
||||||
|
return encodeJSON(stdin, stdout, stderr)
|
||||||
|
}
|
||||||
data, err := io.ReadAll(stdin)
|
data, err := io.ReadAll(stdin)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintln(stderr, "read stdin:", err)
|
fmt.Fprintln(stderr, "read stdin:", err)
|
||||||
@@ -54,6 +86,220 @@ func Run(stdin io.Reader, stdout, stderr io.Writer) int {
|
|||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// validatePaths parses every named file, or standard input when none are
|
||||||
|
// named, and reports each invalid document on stderr. It returns 0 when all
|
||||||
|
// documents parse, 1 when one does not, and 2 on a usage or read failure.
|
||||||
|
func validatePaths(paths []string, stdin io.Reader, stderr io.Writer) int {
|
||||||
|
if len(paths) == 0 {
|
||||||
|
paths = []string{"-"}
|
||||||
|
}
|
||||||
|
valid := true
|
||||||
|
for _, p := range paths {
|
||||||
|
name := p
|
||||||
|
var data []byte
|
||||||
|
var err error
|
||||||
|
if p == "-" {
|
||||||
|
data, err = io.ReadAll(stdin)
|
||||||
|
name = "<stdin>"
|
||||||
|
} else {
|
||||||
|
data, err = os.ReadFile(p)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(stderr, "interpres-decode: %s: %v\n", name, err)
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
if _, err := interpres.Parse(data); err != nil {
|
||||||
|
fmt.Fprintf(stderr, "%s: %v\n", name, err)
|
||||||
|
valid = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !valid {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// encodeJSON reads a toml-test tagged JSON description from standard input and
|
||||||
|
// writes the TOML document it describes to standard output.
|
||||||
|
func encodeJSON(stdin io.Reader, stdout, stderr io.Writer) int {
|
||||||
|
data, err := io.ReadAll(stdin)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(stderr, "read stdin:", err)
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
var desc any
|
||||||
|
if err := json.Unmarshal(data, &desc); err != nil {
|
||||||
|
fmt.Fprintln(stderr, "decode JSON:", err)
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
tree, err := untag(desc)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(stderr, err)
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
doc, ok := tree.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
fmt.Fprintln(stderr, "interpres-decode: the description must be a JSON object at the top level")
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
out, err := interpres.Marshal(doc)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(stderr, err)
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
if _, err := stdout.Write(out); err != nil {
|
||||||
|
fmt.Fprintln(stderr, "write stdout:", err)
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// untag converts a toml-test JSON description into the value tree Marshal
|
||||||
|
// expects: a JSON object becomes a map[string]any, a JSON array becomes a
|
||||||
|
// []any, and an object carrying exactly the keys "type" and "value" becomes
|
||||||
|
// the Go value for that TOML type.
|
||||||
|
func untag(v any) (any, error) {
|
||||||
|
switch x := v.(type) {
|
||||||
|
case map[string]any:
|
||||||
|
if typ, val, ok := taggedValue(x); ok {
|
||||||
|
return decodeTagged(typ, val)
|
||||||
|
}
|
||||||
|
out := make(map[string]any, len(x))
|
||||||
|
for k, e := range x {
|
||||||
|
u, err := untag(e)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%s: %w", k, err)
|
||||||
|
}
|
||||||
|
out[k] = u
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
case []any:
|
||||||
|
out := make([]any, len(x))
|
||||||
|
for i, e := range x {
|
||||||
|
u, err := untag(e)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("[%d]: %w", i, err)
|
||||||
|
}
|
||||||
|
out[i] = u
|
||||||
|
}
|
||||||
|
return asTables(out), nil
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("unsupported JSON value %T", v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// asTables returns the elements as a []map[string]any when there is at least
|
||||||
|
// one and every element is a table, the shape the encoder renders as an array
|
||||||
|
// of tables. The tagged JSON cannot tell an array of tables from a value array
|
||||||
|
// of inline tables, and both parse back to the same value, so the header form
|
||||||
|
// is chosen because it is the one the encoder otherwise never exercises. An
|
||||||
|
// empty array stays a []any, because TOML has no empty array of tables.
|
||||||
|
func asTables(items []any) any {
|
||||||
|
if len(items) == 0 {
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
tbls := make([]map[string]any, len(items))
|
||||||
|
for i, e := range items {
|
||||||
|
tbl, ok := e.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
tbls[i] = tbl
|
||||||
|
}
|
||||||
|
return tbls
|
||||||
|
}
|
||||||
|
|
||||||
|
// taggedValue reports whether m is a toml-test value object: a JSON object of
|
||||||
|
// exactly the two string keys "type" and "value", carrying a type this adapter
|
||||||
|
// knows. Any other object is a table.
|
||||||
|
func taggedValue(m map[string]any) (typ, val string, ok bool) {
|
||||||
|
if len(m) != 2 {
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
ts, ok := m["type"].(string)
|
||||||
|
if !ok || !knownType(ts) {
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
vs, ok := m["value"].(string)
|
||||||
|
if !ok {
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
return ts, vs, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func knownType(typ string) bool {
|
||||||
|
switch typ {
|
||||||
|
case "string", "integer", "float", "bool",
|
||||||
|
"datetime", "datetime-local", "date-local", "time-local":
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// decodeTagged returns the Go value for one tagged JSON value. Every type but
|
||||||
|
// string is parsed by the library itself, so the adapter and the library agree
|
||||||
|
// on what an integer, a float or a date-time is.
|
||||||
|
func decodeTagged(typ, val string) (any, error) {
|
||||||
|
if typ == "string" {
|
||||||
|
return val, nil
|
||||||
|
}
|
||||||
|
v, err := parseAtom(val)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%s %q: %w", typ, val, err)
|
||||||
|
}
|
||||||
|
// A float with no fractional part and no exponent is described by a bare
|
||||||
|
// integer literal, so here the tag decides and not the literal.
|
||||||
|
if n, ok := v.(int64); ok && typ == "float" {
|
||||||
|
return float64(n), nil
|
||||||
|
}
|
||||||
|
if !typeMatches(typ, v) {
|
||||||
|
return nil, fmt.Errorf("%s %q parsed as %T", typ, val, v)
|
||||||
|
}
|
||||||
|
return v, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseAtom parses one bare TOML value, by handing `v = <val>` to the library's
|
||||||
|
// parser and requiring the result to hold exactly that one statement, so a
|
||||||
|
// value carrying a newline or a comment cannot smuggle a second one in.
|
||||||
|
func parseAtom(val string) (any, error) {
|
||||||
|
tree, err := interpres.Parse([]byte("v = " + val + "\n"))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(tree) != 1 {
|
||||||
|
return nil, errors.New("not a single bare value")
|
||||||
|
}
|
||||||
|
return tree["v"], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// typeMatches reports whether v is the Go value the tagged type names.
|
||||||
|
func typeMatches(typ string, v any) bool {
|
||||||
|
switch typ {
|
||||||
|
case "integer":
|
||||||
|
_, ok := v.(int64)
|
||||||
|
return ok
|
||||||
|
case "float":
|
||||||
|
_, ok := v.(float64)
|
||||||
|
return ok
|
||||||
|
case "bool":
|
||||||
|
_, ok := v.(bool)
|
||||||
|
return ok
|
||||||
|
case "datetime":
|
||||||
|
_, ok := v.(time.Time)
|
||||||
|
return ok
|
||||||
|
case "datetime-local":
|
||||||
|
_, ok := v.(interpres.LocalDateTime)
|
||||||
|
return ok
|
||||||
|
case "date-local":
|
||||||
|
_, ok := v.(interpres.LocalDate)
|
||||||
|
return ok
|
||||||
|
case "time-local":
|
||||||
|
_, ok := v.(interpres.LocalTime)
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// tag converts an interpres value into its toml-test tagged-JSON form. Tables
|
// tag converts an interpres value into its toml-test tagged-JSON form. Tables
|
||||||
// become JSON objects and arrays become JSON arrays; scalars are wrapped in a
|
// become JSON objects and arrays become JSON arrays; scalars are wrapped in a
|
||||||
// {"type", "value"} object. An error is returned for value types the encoder
|
// {"type", "value"} object. An error is returned for value types the encoder
|
||||||
|
|||||||
@@ -7,11 +7,13 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"os"
|
||||||
|
"reflect"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"sourcedock.dev/petrbalvin/interpres"
|
"sourcedock.dev/petrbalvin/interpres/v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestRunParsesValidTOML(t *testing.T) {
|
func TestRunParsesValidTOML(t *testing.T) {
|
||||||
@@ -20,7 +22,7 @@ func TestRunParsesValidTOML(t *testing.T) {
|
|||||||
port = 8080
|
port = 8080
|
||||||
enabled = true
|
enabled = true
|
||||||
`))
|
`))
|
||||||
if code := Run(in, &stdout, &stderr); code != 0 {
|
if code := Run(nil, in, &stdout, &stderr); code != 0 {
|
||||||
t.Fatalf("Run returned %d, stderr = %q", code, stderr.String())
|
t.Fatalf("Run returned %d, stderr = %q", code, stderr.String())
|
||||||
}
|
}
|
||||||
var got map[string]any
|
var got map[string]any
|
||||||
@@ -41,7 +43,7 @@ enabled = true
|
|||||||
func TestRunRejectsInvalidInput(t *testing.T) {
|
func TestRunRejectsInvalidInput(t *testing.T) {
|
||||||
var stdout, stderr bytes.Buffer
|
var stdout, stderr bytes.Buffer
|
||||||
in := bytes.NewReader([]byte("v = \n"))
|
in := bytes.NewReader([]byte("v = \n"))
|
||||||
code := Run(in, &stdout, &stderr)
|
code := Run(nil, in, &stdout, &stderr)
|
||||||
if code != 1 {
|
if code != 1 {
|
||||||
t.Errorf("Run returned %d, want 1 (parse error); stderr = %q", code, stderr.String())
|
t.Errorf("Run returned %d, want 1 (parse error); stderr = %q", code, stderr.String())
|
||||||
}
|
}
|
||||||
@@ -52,7 +54,7 @@ func TestRunRejectsInvalidInput(t *testing.T) {
|
|||||||
|
|
||||||
func TestRunReadErrorReturnsTwo(t *testing.T) {
|
func TestRunReadErrorReturnsTwo(t *testing.T) {
|
||||||
var stdout, stderr bytes.Buffer
|
var stdout, stderr bytes.Buffer
|
||||||
code := Run(errorReader{}, &stdout, &stderr)
|
code := Run(nil, errorReader{}, &stdout, &stderr)
|
||||||
if code != 2 {
|
if code != 2 {
|
||||||
t.Errorf("Run returned %d, want 2 (read error); stderr = %q", code, stderr.String())
|
t.Errorf("Run returned %d, want 2 (read error); stderr = %q", code, stderr.String())
|
||||||
}
|
}
|
||||||
@@ -70,7 +72,7 @@ func TestRunEncodeErrorReturnsTwo(t *testing.T) {
|
|||||||
var stderr bytes.Buffer
|
var stderr bytes.Buffer
|
||||||
w := errorWriter{}
|
w := errorWriter{}
|
||||||
in := bytes.NewReader([]byte(`k = "v"` + "\n"))
|
in := bytes.NewReader([]byte(`k = "v"` + "\n"))
|
||||||
code := Run(in, w, &stderr)
|
code := Run(nil, in, w, &stderr)
|
||||||
if code != 2 {
|
if code != 2 {
|
||||||
t.Errorf("Run returned %d, want 2 (encode error); stderr = %q", code, stderr.String())
|
t.Errorf("Run returned %d, want 2 (encode error); stderr = %q", code, stderr.String())
|
||||||
}
|
}
|
||||||
@@ -210,3 +212,237 @@ func TestTaggedHelper(t *testing.T) {
|
|||||||
t.Errorf("tagged = %#v", got)
|
t.Errorf("tagged = %#v", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestValidateStdinAcceptsValidDocument(t *testing.T) {
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
in := bytes.NewReader([]byte("title = \"ok\"\n"))
|
||||||
|
if code := Run([]string{"-validate"}, in, &stdout, &stderr); code != 0 {
|
||||||
|
t.Fatalf("Run returned %d, stderr = %q", code, stderr.String())
|
||||||
|
}
|
||||||
|
if stdout.Len() != 0 || stderr.Len() != 0 {
|
||||||
|
t.Fatalf("validate should be quiet on success, stdout %q stderr %q", stdout.String(), stderr.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateStdinRejectsInvalidDocument(t *testing.T) {
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
in := bytes.NewReader([]byte("title = \"unterminated\n"))
|
||||||
|
if code := Run([]string{"-validate"}, in, &stdout, &stderr); code != 1 {
|
||||||
|
t.Fatalf("Run returned %d, want 1; stderr = %q", code, stderr.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(stderr.String(), "<stdin>") || !strings.Contains(stderr.String(), "line 1") {
|
||||||
|
t.Fatalf("stderr = %q, want the name and the line", stderr.String())
|
||||||
|
}
|
||||||
|
if stdout.Len() != 0 {
|
||||||
|
t.Fatalf("stdout should stay empty, got %q", stdout.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateFiles(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
good := dir + "/good.toml"
|
||||||
|
bad := dir + "/bad.toml"
|
||||||
|
if err := os.WriteFile(good, []byte("a = 1\n"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(bad, []byte("a =\n"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
if code := Run([]string{"-validate", good}, nil, &stdout, &stderr); code != 0 {
|
||||||
|
t.Fatalf("one valid file: Run returned %d, stderr = %q", code, stderr.String())
|
||||||
|
}
|
||||||
|
if code := Run([]string{"-validate", good, bad}, nil, &stdout, &stderr); code != 1 {
|
||||||
|
t.Fatalf("valid plus invalid: Run returned %d, want 1; stderr = %q", code, stderr.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(stderr.String(), bad) || !strings.Contains(stderr.String(), "line 1") {
|
||||||
|
t.Fatalf("stderr = %q, want the file name and the line", stderr.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateMissingFileReturnsTwo(t *testing.T) {
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
if code := Run([]string{"-validate", "no-such-file.toml"}, nil, &stdout, &stderr); code != 2 {
|
||||||
|
t.Fatalf("Run returned %d, want 2; stderr = %q", code, stderr.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdapterModeRejectsPositionalArgument(t *testing.T) {
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
in := bytes.NewReader([]byte("a = 1\n"))
|
||||||
|
if code := Run([]string{"file.toml"}, in, &stdout, &stderr); code != 2 {
|
||||||
|
t.Fatalf("Run returned %d, want 2; stderr = %q", code, stderr.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(stderr.String(), "-validate") {
|
||||||
|
t.Fatalf("stderr = %q, want it to point at -validate", stderr.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnknownFlagReturnsTwo(t *testing.T) {
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
if code := Run([]string{"-nope"}, nil, &stdout, &stderr); code != 2 {
|
||||||
|
t.Fatalf("Run returned %d, want 2; stderr = %q", code, stderr.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- encoder mode ----------------------------------------------------------
|
||||||
|
|
||||||
|
func TestRunEncoderScalars(t *testing.T) {
|
||||||
|
in := `{
|
||||||
|
"s": {"type": "string", "value": "quote \" and backslash \\"},
|
||||||
|
"nl": {"type": "string", "value": "line1\nline2"},
|
||||||
|
"i": {"type": "integer", "value": "-9223372036854775808"},
|
||||||
|
"g": {"type": "float", "value": "1.5"},
|
||||||
|
"f": {"type": "float", "value": "inf"},
|
||||||
|
"b": {"type": "bool", "value": "false"},
|
||||||
|
"dt": {"type": "datetime", "value": "1979-05-27T07:32:00-07:00"},
|
||||||
|
"ldt": {"type": "datetime-local", "value": "1979-05-27T07:32:00"},
|
||||||
|
"ld": {"type": "date-local", "value": "1979-05-27"},
|
||||||
|
"lt": {"type": "time-local", "value": "07:32:00.999"}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
code := Run([]string{"-encode"}, strings.NewReader(in), &stdout, &stderr)
|
||||||
|
if code != 0 {
|
||||||
|
t.Fatalf("Run returned %d, stderr = %q", code, stderr.String())
|
||||||
|
}
|
||||||
|
want := "b = false\n" +
|
||||||
|
"dt = 1979-05-27T07:32-07:00\n" +
|
||||||
|
"f = inf\n" +
|
||||||
|
"g = 1.5\n" +
|
||||||
|
"i = -9223372036854775808\n" +
|
||||||
|
"ld = 1979-05-27\n" +
|
||||||
|
"ldt = 1979-05-27T07:32\n" +
|
||||||
|
"lt = 07:32:00.999\n" +
|
||||||
|
"nl = \"line1\\nline2\"\n" +
|
||||||
|
"s = \"quote \\\" and backslash \\\\\"\n"
|
||||||
|
if stdout.String() != want {
|
||||||
|
t.Errorf("output mismatch:\ngot: %q\nwant: %q", stdout.String(), want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunEncoderNested(t *testing.T) {
|
||||||
|
in := `{
|
||||||
|
"tbl": {"x": {"type": "bool", "value": "true"},
|
||||||
|
"sub": {"y": {"type": "integer", "value": "1"}}},
|
||||||
|
"items": [{"n": {"type": "string", "value": "a"}},
|
||||||
|
{"n": {"type": "string", "value": "b"}}],
|
||||||
|
"list": [{"type": "integer", "value": "1"}, {"type": "string", "value": "two"}],
|
||||||
|
"emptyTbl": {},
|
||||||
|
"emptyArr": []
|
||||||
|
}
|
||||||
|
`
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
code := Run([]string{"-encode"}, strings.NewReader(in), &stdout, &stderr)
|
||||||
|
if code != 0 {
|
||||||
|
t.Fatalf("Run returned %d, stderr = %q", code, stderr.String())
|
||||||
|
}
|
||||||
|
want := "emptyArr = []\n" +
|
||||||
|
"list = [1, \"two\"]\n" +
|
||||||
|
"\n[emptyTbl]\n" +
|
||||||
|
"\n[tbl]\nx = true\n" +
|
||||||
|
"\n[tbl.sub]\ny = 1\n" +
|
||||||
|
"\n[[items]]\nn = \"a\"\n" +
|
||||||
|
"\n[[items]]\nn = \"b\"\n"
|
||||||
|
if stdout.String() != want {
|
||||||
|
t.Errorf("output mismatch:\ngot: %q\nwant: %q", stdout.String(), want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunEncoderFloatTagDecides(t *testing.T) {
|
||||||
|
// A float with no fraction is described by a bare integer literal, so the
|
||||||
|
// tag decides the type; the output must stay a float.
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
in := `{"whole": {"type": "float", "value": "1"}, "exp": {"type": "float", "value": "5e+22"}}`
|
||||||
|
code := Run([]string{"-encode"}, strings.NewReader(in), &stdout, &stderr)
|
||||||
|
if code != 0 {
|
||||||
|
t.Fatalf("Run returned %d, stderr = %q", code, stderr.String())
|
||||||
|
}
|
||||||
|
if want := "exp = 5e+22\nwhole = 1.0\n"; stdout.String() != want {
|
||||||
|
t.Errorf("output mismatch:\ngot: %q\nwant: %q", stdout.String(), want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunEncoderRejectsBadInput(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
in string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"not-json", "not json", "decode JSON"},
|
||||||
|
{"top-level-array", `[{"type": "integer", "value": "1"}]`, "must be a JSON object"},
|
||||||
|
{"untagged-scalar", `{"x": 1}`, "unsupported JSON value"},
|
||||||
|
{"literal-mismatch", `{"x": {"type": "integer", "value": "1.5"}}`, "parsed as float64"},
|
||||||
|
{"offset-for-local", `{"x": {"type": "datetime-local", "value": "1979-05-27T07:32:00Z"}}`, "parsed as time.Time"},
|
||||||
|
{"bad-literal", `{"x": {"type": "date-local", "value": "nope"}}`, "date-local"},
|
||||||
|
{"smuggled-statement", `{"x": {"type": "integer", "value": "1\nx = 2"}}`, "not a single bare value"},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
code := Run([]string{"-encode"}, strings.NewReader(c.in), &stdout, &stderr)
|
||||||
|
if code != 2 {
|
||||||
|
t.Errorf("%s: Run returned %d, want 2; stderr = %q", c.name, code, stderr.String())
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !strings.Contains(stderr.String(), c.want) {
|
||||||
|
t.Errorf("%s: stderr = %q, want it to mention %q", c.name, stderr.String(), c.want)
|
||||||
|
}
|
||||||
|
if stdout.Len() != 0 {
|
||||||
|
t.Errorf("%s: stdout should be empty, got %q", c.name, stdout.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunEncoderFlagConflicts(t *testing.T) {
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
if code := Run([]string{"-encode", "-validate"}, strings.NewReader(""), &stdout, &stderr); code != 2 {
|
||||||
|
t.Errorf("Run returned %d, want 2 for the two modes together", code)
|
||||||
|
}
|
||||||
|
if !strings.Contains(stderr.String(), "cannot be combined") {
|
||||||
|
t.Errorf("stderr = %q, want it to explain the conflict", stderr.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
stdout.Reset()
|
||||||
|
stderr.Reset()
|
||||||
|
if code := Run([]string{"-encode", "file.json"}, strings.NewReader(""), &stdout, &stderr); code != 2 {
|
||||||
|
t.Errorf("Run returned %d, want 2 for an argument", code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEncodeAfterDecodeRoundTrip(t *testing.T) {
|
||||||
|
doc := `title = "x"
|
||||||
|
flt = 1.5
|
||||||
|
whole = 7.0
|
||||||
|
big = 9223372036854775807
|
||||||
|
when = 1979-05-27T07:32:00-07:00
|
||||||
|
day = 1979-05-27
|
||||||
|
clock = 07:32:00.999
|
||||||
|
list = [1, "two"]
|
||||||
|
multi = "a\nb"
|
||||||
|
|
||||||
|
[tbl]
|
||||||
|
x = true
|
||||||
|
|
||||||
|
[[items]]
|
||||||
|
n = "a"
|
||||||
|
`
|
||||||
|
var tagged, stderr bytes.Buffer
|
||||||
|
if code := Run(nil, strings.NewReader(doc), &tagged, &stderr); code != 0 {
|
||||||
|
t.Fatalf("decode returned %d, stderr = %q", code, stderr.String())
|
||||||
|
}
|
||||||
|
var out bytes.Buffer
|
||||||
|
if code := Run([]string{"-encode"}, bytes.NewReader(tagged.Bytes()), &out, &stderr); code != 0 {
|
||||||
|
t.Fatalf("encode returned %d, stderr = %q", code, stderr.String())
|
||||||
|
}
|
||||||
|
want, err := interpres.Parse([]byte(doc))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse of the original: %v", err)
|
||||||
|
}
|
||||||
|
got, err := interpres.Parse(out.Bytes())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse of the encoder output (%q): %v", out.String(), err)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(want, got) {
|
||||||
|
t.Errorf("round trip changed the document:\noriginal: %#v\nencoded: %#v\noutput: %q", want, got, out.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+53
-19
@@ -6,6 +6,7 @@ package interpres
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -27,29 +28,42 @@ type LocalDate struct{ time.Time }
|
|||||||
type LocalTime struct{ time.Time }
|
type LocalTime struct{ time.Time }
|
||||||
|
|
||||||
// String returns the TOML-canonical rendering of the local date-time, e.g.
|
// 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
|
// "1979-05-27T07:32" or "1979-05-27T07:32:00.5" when the time carries a
|
||||||
// second. The fractional component is zero-padded to nanosecond precision.
|
// fractional second. TOML 1.1 makes the seconds optional, so they appear only
|
||||||
|
// when they are non-zero, and a fraction drops its trailing zeros.
|
||||||
func (ldt LocalDateTime) String() string {
|
func (ldt LocalDateTime) String() string {
|
||||||
base := ldt.Format("2006-01-02T15:04:05")
|
return ldt.Format("2006-01-02T") + clockString(ldt.Time)
|
||||||
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.
|
// String returns the TOML-canonical rendering of the local date, e.g.
|
||||||
// "1979-05-27".
|
// "1979-05-27".
|
||||||
func (ld LocalDate) String() string { return ld.Format("2006-01-02") }
|
func (ld LocalDate) String() string { return ld.Format("2006-01-02") }
|
||||||
|
|
||||||
// String returns the TOML-canonical rendering of the local time, e.g.
|
// String returns the TOML-canonical rendering of the local time, e.g. "07:32"
|
||||||
// "07:32:00" or "...:00.000000123" when the time has a fractional second.
|
// or "07:32:00.5" when the time carries a fractional second.
|
||||||
// The fractional component is zero-padded to nanosecond precision.
|
func (lt LocalTime) String() string { return clockString(lt.Time) }
|
||||||
func (lt LocalTime) String() string {
|
|
||||||
base := lt.Format("15:04:05")
|
// clockString renders a time of day the way TOML writes it: the seconds appear
|
||||||
if ns := lt.Nanosecond(); ns > 0 {
|
// only when the value carries them, and a fractional second drops its trailing
|
||||||
return base + "." + fmt.Sprintf("%09d", ns)
|
// zeros, so half a second is "00.5" and not "00.500000000". Both are the same
|
||||||
|
// value either way; the shorter form is the one TOML 1.1 allows.
|
||||||
|
func clockString(t time.Time) string {
|
||||||
|
out := t.Format("15:04")
|
||||||
|
ns := t.Nanosecond()
|
||||||
|
if t.Second() != 0 || ns != 0 {
|
||||||
|
out += t.Format(":05")
|
||||||
}
|
}
|
||||||
return base
|
if ns > 0 {
|
||||||
|
out += "." + strings.TrimRight(fmt.Sprintf("%09d", ns), "0")
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// offsetString renders an offset date-time, the fourth TOML kind, in the same
|
||||||
|
// shape: no zero seconds, no trailing zeros in the fraction, and the offset
|
||||||
|
// written as "Z" when it is zero.
|
||||||
|
func offsetString(t time.Time) string {
|
||||||
|
return t.Format("2006-01-02T") + clockString(t) + t.Format("Z07:00")
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -58,26 +72,39 @@ var (
|
|||||||
"2006-01-02T15:04:05Z07:00",
|
"2006-01-02T15:04:05Z07:00",
|
||||||
"2006-01-02 15:04:05.999999999Z07:00",
|
"2006-01-02 15:04:05.999999999Z07:00",
|
||||||
"2006-01-02 15:04:05Z07:00",
|
"2006-01-02 15:04:05Z07:00",
|
||||||
|
// TOML 1.1 makes the seconds optional.
|
||||||
|
"2006-01-02T15:04Z07:00",
|
||||||
|
"2006-01-02 15:04Z07:00",
|
||||||
}
|
}
|
||||||
localDateTimeLayouts = []string{
|
localDateTimeLayouts = []string{
|
||||||
"2006-01-02T15:04:05.999999999",
|
"2006-01-02T15:04:05.999999999",
|
||||||
"2006-01-02T15:04:05",
|
"2006-01-02T15:04:05",
|
||||||
"2006-01-02 15:04:05.999999999",
|
"2006-01-02 15:04:05.999999999",
|
||||||
"2006-01-02 15:04:05",
|
"2006-01-02 15:04:05",
|
||||||
|
"2006-01-02T15:04",
|
||||||
|
"2006-01-02 15:04",
|
||||||
}
|
}
|
||||||
localTimeLayouts = []string{
|
localTimeLayouts = []string{
|
||||||
"15:04:05.999999999",
|
"15:04:05.999999999",
|
||||||
"15:04:05",
|
"15:04:05",
|
||||||
|
"15:04",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
// dateTimeShape enforces the strict TOML grammar (two-digit components) that
|
// dateTimeShape enforces the strict TOML grammar (two-digit components,
|
||||||
// time.Parse would otherwise accept loosely (e.g. a single-digit hour).
|
// seconds optional since 1.1, a fraction only after seconds) that time.Parse
|
||||||
|
// would otherwise accept loosely (e.g. a single-digit hour).
|
||||||
var dateTimeShape = regexp.MustCompile(
|
var dateTimeShape = regexp.MustCompile(
|
||||||
`^\d{4}-\d{2}-\d{2}([Tt ]\d{2}:\d{2}:\d{2}(\.\d+)?([Zz]|[+-]\d{2}:\d{2})?)?$` +
|
`^\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+)?$`,
|
`|^\d{2}:\d{2}(:\d{2}(\.\d+)?)?$`,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// offsetBounds extracts the numeric offset of a date-time. The ABNF bounds it
|
||||||
|
// to 00:00 through 23:59, but time.Parse accepts values outside that range
|
||||||
|
// and rolls them over (for example "+00:60" becomes "+01:00"), so the bounds
|
||||||
|
// are enforced here.
|
||||||
|
var offsetBounds = regexp.MustCompile(`([+-])(\d{2}):(\d{2})$`)
|
||||||
|
|
||||||
// parseDateTime classifies and parses a bare token as a TOML date-time value.
|
// parseDateTime classifies and parses a bare token as a TOML date-time value.
|
||||||
// It returns the decoded value (time.Time, LocalDateTime, LocalDate, or
|
// It returns the decoded value (time.Time, LocalDateTime, LocalDate, or
|
||||||
// LocalTime) and whether the token was a date-time at all.
|
// LocalTime) and whether the token was a date-time at all.
|
||||||
@@ -91,6 +118,13 @@ func parseDateTime(tok string) (any, bool) {
|
|||||||
if !dateTimeShape.MatchString(tok) {
|
if !dateTimeShape.MatchString(tok) {
|
||||||
return nil, false
|
return nil, false
|
||||||
}
|
}
|
||||||
|
if m := offsetBounds.FindStringSubmatch(tok); m != nil {
|
||||||
|
hour, _ := strconv.Atoi(m[2])
|
||||||
|
minute, _ := strconv.Atoi(m[3])
|
||||||
|
if hour > 23 || minute > 59 {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
}
|
||||||
// The ABNF accepts lowercase "t"/"z"; time.Parse only matches uppercase.
|
// The ABNF accepts lowercase "t"/"z"; time.Parse only matches uppercase.
|
||||||
norm := strings.ToUpper(tok)
|
norm := strings.ToUpper(tok)
|
||||||
for _, layout := range offsetDateTimeLayouts {
|
for _, layout := range offsetDateTimeLayouts {
|
||||||
|
|||||||
@@ -4,10 +4,12 @@
|
|||||||
package interpres
|
package interpres
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding"
|
||||||
"fmt"
|
"fmt"
|
||||||
"math"
|
|
||||||
"reflect"
|
"reflect"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -62,6 +64,19 @@ func (d *decoder) assign(data any, dst reflect.Value) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A TOML string fills a destination that implements
|
||||||
|
// encoding.TextUnmarshaler, the rule encoding/json follows. Every other
|
||||||
|
// value kind keeps its own rule, so an integer still reaches a numeric
|
||||||
|
// destination.
|
||||||
|
if s, isString := data.(string); isString {
|
||||||
|
if tu, ok := textUnmarshalerOf(dst); ok {
|
||||||
|
if err := tu.UnmarshalText([]byte(s)); err != nil {
|
||||||
|
return fmt.Errorf("unmarshal text: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
switch v := data.(type) {
|
switch v := data.(type) {
|
||||||
case map[string]any:
|
case map[string]any:
|
||||||
return d.assignTable(v, dst)
|
return d.assignTable(v, dst)
|
||||||
@@ -70,6 +85,9 @@ func (d *decoder) assign(data any, dst reflect.Value) error {
|
|||||||
case []any:
|
case []any:
|
||||||
return d.assignSlice(v, dst)
|
return d.assignSlice(v, dst)
|
||||||
case string:
|
case string:
|
||||||
|
if dst.Type() == durationType {
|
||||||
|
return setDuration(dst, v)
|
||||||
|
}
|
||||||
return setBasic(dst, reflect.ValueOf(v), "string")
|
return setBasic(dst, reflect.ValueOf(v), "string")
|
||||||
case bool:
|
case bool:
|
||||||
return setBasic(dst, reflect.ValueOf(v), "bool")
|
return setBasic(dst, reflect.ValueOf(v), "bool")
|
||||||
@@ -93,6 +111,26 @@ func (d *decoder) assign(data any, dst reflect.Value) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// textUnmarshalerOf finds the encoding.TextUnmarshaler for dst: on the value
|
||||||
|
// itself, or on its address, so a pointer-receiver UnmarshalText is invoked on
|
||||||
|
// an addressable struct field. The TOML date-time types are excluded, because
|
||||||
|
// they carry time.Time's UnmarshalText through an embedded field while their
|
||||||
|
// only accepted form is a bare timestamp.
|
||||||
|
func textUnmarshalerOf(dst reflect.Value) (encoding.TextUnmarshaler, bool) {
|
||||||
|
if !dst.CanInterface() || isDateTimeType(dst.Type()) {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
if u, ok := dst.Interface().(encoding.TextUnmarshaler); ok {
|
||||||
|
return u, true
|
||||||
|
}
|
||||||
|
if dst.CanAddr() {
|
||||||
|
if u, ok := dst.Addr().Interface().(encoding.TextUnmarshaler); ok {
|
||||||
|
return u, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
func (d *decoder) assignTable(tbl map[string]any, dst reflect.Value) error {
|
func (d *decoder) assignTable(tbl map[string]any, dst reflect.Value) error {
|
||||||
switch dst.Kind() {
|
switch dst.Kind() {
|
||||||
case reflect.Struct:
|
case reflect.Struct:
|
||||||
@@ -105,17 +143,45 @@ func (d *decoder) assignTable(tbl map[string]any, dst reflect.Value) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (d *decoder) assignStruct(tbl map[string]any, dst reflect.Value) error {
|
func (d *decoder) assignStruct(tbl map[string]any, dst reflect.Value) error {
|
||||||
fields := structFields(dst.Type())
|
schema := cachedStructSchema(dst.Type())
|
||||||
|
if d.disallowUnknown {
|
||||||
|
// Map iteration order is random, so pick the unknown key to report
|
||||||
|
// deterministically: the smallest one.
|
||||||
|
unknown := ""
|
||||||
|
for key := range tbl {
|
||||||
|
if _, ok := schema.byName[strings.ToLower(key)]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if unknown == "" || key < unknown {
|
||||||
|
unknown = key
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if unknown != "" {
|
||||||
|
return fmt.Errorf("interpres: unknown field %q for %s", unknown, dst.Type())
|
||||||
|
}
|
||||||
|
}
|
||||||
for key, val := range tbl {
|
for key, val := range tbl {
|
||||||
field, ok := fields[strings.ToLower(key)]
|
field, ok := schema.byName[strings.ToLower(key)]
|
||||||
if !ok {
|
if !ok {
|
||||||
if d.disallowUnknown {
|
if schema.embedMaps != nil {
|
||||||
return fmt.Errorf("interpres: unknown field %q for %s", key, dst.Type())
|
// Leftover keys land in an untagged embedded map, the inverse
|
||||||
|
// of the encoder inlining that map's entries.
|
||||||
|
mv, err := fieldByIndex(dst, schema.embedMaps[0])
|
||||||
|
if err != nil {
|
||||||
|
return newDecodeError(key, err)
|
||||||
|
}
|
||||||
|
if err := d.assignMap(map[string]any{key: val}, mv); err != nil {
|
||||||
|
return newDecodeError(key, err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if err := d.assign(val, dst.Field(field)); err != nil {
|
fv, err := fieldByIndex(dst, field.index)
|
||||||
return fmt.Errorf("%s: %w", key, err)
|
if err != nil {
|
||||||
|
return newDecodeError(key, err)
|
||||||
|
}
|
||||||
|
if err := d.assign(val, fv); err != nil {
|
||||||
|
return newDecodeError(key, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@@ -132,7 +198,7 @@ func (d *decoder) assignMap(tbl map[string]any, dst reflect.Value) error {
|
|||||||
for key, val := range tbl {
|
for key, val := range tbl {
|
||||||
elem := reflect.New(elemType).Elem()
|
elem := reflect.New(elemType).Elem()
|
||||||
if err := d.assign(val, elem); err != nil {
|
if err := d.assign(val, elem); err != nil {
|
||||||
return fmt.Errorf("%s: %w", key, err)
|
return newDecodeError(key, err)
|
||||||
}
|
}
|
||||||
dst.SetMapIndex(reflect.ValueOf(key), elem)
|
dst.SetMapIndex(reflect.ValueOf(key), elem)
|
||||||
}
|
}
|
||||||
@@ -146,7 +212,7 @@ func (d *decoder) assignSlice(items []any, dst reflect.Value) error {
|
|||||||
out := reflect.MakeSlice(dst.Type(), len(items), len(items))
|
out := reflect.MakeSlice(dst.Type(), len(items), len(items))
|
||||||
for i, item := range items {
|
for i, item := range items {
|
||||||
if err := d.assign(item, out.Index(i)); err != nil {
|
if err := d.assign(item, out.Index(i)); err != nil {
|
||||||
return fmt.Errorf("[%d]: %w", i, err)
|
return newDecodeError(fmt.Sprintf("[%d]", i), err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
dst.Set(out)
|
dst.Set(out)
|
||||||
@@ -160,7 +226,7 @@ func (d *decoder) assignTableSlice(items []map[string]any, dst reflect.Value) er
|
|||||||
out := reflect.MakeSlice(dst.Type(), len(items), len(items))
|
out := reflect.MakeSlice(dst.Type(), len(items), len(items))
|
||||||
for i, item := range items {
|
for i, item := range items {
|
||||||
if err := d.assign(item, out.Index(i)); err != nil {
|
if err := d.assign(item, out.Index(i)); err != nil {
|
||||||
return fmt.Errorf("[%d]: %w", i, err)
|
return newDecodeError(fmt.Sprintf("[%d]", i), err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
dst.Set(out)
|
dst.Set(out)
|
||||||
@@ -173,7 +239,23 @@ func setBasic(dst, val reflect.Value, kind string) error {
|
|||||||
if dst.Kind() != val.Kind() {
|
if dst.Kind() != val.Kind() {
|
||||||
return fmt.Errorf("interpres: cannot assign %s to %s", kind, dst.Type())
|
return fmt.Errorf("interpres: cannot assign %s to %s", kind, dst.Type())
|
||||||
}
|
}
|
||||||
dst.Set(val)
|
// Convert rather than assign: a value of the predeclared type is not
|
||||||
|
// assignable to a defined type of the same kind, so a plain Set panics on
|
||||||
|
// a destination such as `type Name string`.
|
||||||
|
dst.Set(val.Convert(dst.Type()))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// setDuration reads a duration literal into a time.Duration destination. TOML
|
||||||
|
// has no duration type, so the encoder writes the canonical Go form and the
|
||||||
|
// decoder reads that back; a bare integer stays the nanosecond count it has
|
||||||
|
// always been, and reaches the destination through setInt.
|
||||||
|
func setDuration(dst reflect.Value, s string) error {
|
||||||
|
d, err := time.ParseDuration(s)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("interpres: invalid duration %q", s)
|
||||||
|
}
|
||||||
|
dst.SetInt(int64(d))
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -188,21 +270,21 @@ func setInt(dst reflect.Value, v int64) error {
|
|||||||
if v < 0 {
|
if v < 0 {
|
||||||
return fmt.Errorf("interpres: cannot assign negative %d to %s", v, dst.Type())
|
return fmt.Errorf("interpres: cannot assign negative %d to %s", v, dst.Type())
|
||||||
}
|
}
|
||||||
var max uint64
|
// OverflowUint knows every width, uint included on platforms where it
|
||||||
switch dst.Kind() {
|
// is narrower than uint64; SetUint would silently truncate instead.
|
||||||
case reflect.Uint8:
|
if dst.OverflowUint(uint64(v)) {
|
||||||
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())
|
return fmt.Errorf("interpres: integer %d overflows %s", v, dst.Type())
|
||||||
}
|
}
|
||||||
dst.SetUint(uint64(v))
|
dst.SetUint(uint64(v))
|
||||||
case reflect.Float32, reflect.Float64:
|
case reflect.Float32, reflect.Float64:
|
||||||
dst.SetFloat(float64(v))
|
// A finite value beyond the float32 range would silently become ±Inf;
|
||||||
|
// infinities and NaN themselves pass through. An int64 never
|
||||||
|
// overflows either float width.
|
||||||
|
f := float64(v)
|
||||||
|
if dst.OverflowFloat(f) {
|
||||||
|
return fmt.Errorf("interpres: integer %d overflows %s", v, dst.Type())
|
||||||
|
}
|
||||||
|
dst.SetFloat(f)
|
||||||
default:
|
default:
|
||||||
return fmt.Errorf("interpres: cannot assign integer to %s", dst.Type())
|
return fmt.Errorf("interpres: cannot assign integer to %s", dst.Type())
|
||||||
}
|
}
|
||||||
@@ -212,6 +294,9 @@ func setInt(dst reflect.Value, v int64) error {
|
|||||||
func setFloat(dst reflect.Value, v float64) error {
|
func setFloat(dst reflect.Value, v float64) error {
|
||||||
switch dst.Kind() {
|
switch dst.Kind() {
|
||||||
case reflect.Float32, reflect.Float64:
|
case reflect.Float32, reflect.Float64:
|
||||||
|
if dst.OverflowFloat(v) {
|
||||||
|
return fmt.Errorf("interpres: float %g overflows %s", v, dst.Type())
|
||||||
|
}
|
||||||
dst.SetFloat(v)
|
dst.SetFloat(v)
|
||||||
return nil
|
return nil
|
||||||
default:
|
default:
|
||||||
@@ -219,26 +304,118 @@ func setFloat(dst reflect.Value, v float64) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// structFields builds a lower-cased lookup of field name → field index for the
|
// structFieldLoc locates one destination field by its index path from the
|
||||||
// exported fields of t, honouring `toml:"name"` tags.
|
// struct root and by the depth the field sits at, which breaks name clashes
|
||||||
func structFields(t reflect.Type) map[string]int {
|
// in favour of the shallower field.
|
||||||
fields := make(map[string]int, t.NumField())
|
type structFieldLoc struct {
|
||||||
for i := range t.NumField() {
|
index []int
|
||||||
f := t.Field(i)
|
depth int
|
||||||
if f.PkgPath != "" { // unexported
|
}
|
||||||
continue
|
|
||||||
}
|
// structSchema flattens the exported fields of t for decode, mirroring the
|
||||||
name := f.Name
|
// encoder: an untagged embedded struct is inlined, so its own fields match
|
||||||
if tag, ok := f.Tag.Lookup("toml"); ok {
|
// keys of the same table, and an untagged embedded map is recorded in
|
||||||
tag = strings.Split(tag, ",")[0]
|
// embedMaps (first declaration first) as the destination for leftover keys.
|
||||||
if tag == "-" {
|
// When two fields resolve to one name, the shallower wins, then the later
|
||||||
|
// declaration.
|
||||||
|
type structSchema struct {
|
||||||
|
byName map[string]structFieldLoc
|
||||||
|
embedMaps [][]int
|
||||||
|
}
|
||||||
|
|
||||||
|
// structSchemaCache holds one schema per struct type. A schema is immutable
|
||||||
|
// once published, so concurrent callers only race to build an identical value,
|
||||||
|
// the same trade-off encoding/json's field cache makes. The cache grows with
|
||||||
|
// the number of distinct types decoded or encoded, never per document.
|
||||||
|
var structSchemaCache sync.Map // reflect.Type -> structSchema
|
||||||
|
|
||||||
|
func cachedStructSchema(t reflect.Type) structSchema {
|
||||||
|
if s, ok := structSchemaCache.Load(t); ok {
|
||||||
|
return s.(structSchema)
|
||||||
|
}
|
||||||
|
s := newStructSchema(t)
|
||||||
|
actual, _ := structSchemaCache.LoadOrStore(t, s)
|
||||||
|
return actual.(structSchema)
|
||||||
|
}
|
||||||
|
|
||||||
|
func newStructSchema(t reflect.Type) structSchema {
|
||||||
|
s := structSchema{byName: make(map[string]structFieldLoc, t.NumField())}
|
||||||
|
// A struct may embed a pointer to itself, which is legal Go, so the walk
|
||||||
|
// tracks the struct types on the current path and stops when one repeats;
|
||||||
|
// without the guard the recursion never terminates. A self-promoted key
|
||||||
|
// always loses to the shallower original, so skipping it changes nothing.
|
||||||
|
visiting := map[reflect.Type]bool{}
|
||||||
|
var walk func(t reflect.Type, prefix []int, depth int)
|
||||||
|
walk = func(t reflect.Type, prefix []int, depth int) {
|
||||||
|
visiting[t] = true
|
||||||
|
defer delete(visiting, t)
|
||||||
|
for i := range t.NumField() {
|
||||||
|
f := t.Field(i)
|
||||||
|
if f.PkgPath != "" { // unexported
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if tag != "" {
|
path := append(append([]int{}, prefix...), i)
|
||||||
name = tag
|
name := ""
|
||||||
|
if tag, ok := f.Tag.Lookup("toml"); ok {
|
||||||
|
name, _, _ = strings.Cut(tag, ",")
|
||||||
|
if name == "-" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if f.Anonymous && name == "" {
|
||||||
|
ft := f.Type
|
||||||
|
for ft.Kind() == reflect.Pointer {
|
||||||
|
ft = ft.Elem()
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case ft.Kind() == reflect.Struct && !isScalarStruct(ft):
|
||||||
|
if !visiting[ft] {
|
||||||
|
walk(ft, path, depth+1)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
case ft.Kind() == reflect.Map && ft.Key().Kind() == reflect.String:
|
||||||
|
s.embedMaps = append(s.embedMaps, path)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
name = f.Name
|
||||||
|
}
|
||||||
|
if name == "" {
|
||||||
|
name = f.Name
|
||||||
|
}
|
||||||
|
key := strings.ToLower(name)
|
||||||
|
if existing, ok := s.byName[key]; !ok || depth <= existing.depth {
|
||||||
|
s.byName[key] = structFieldLoc{index: path, depth: depth}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
fields[strings.ToLower(name)] = i
|
|
||||||
}
|
}
|
||||||
return fields
|
walk(t, nil, 0)
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// ownsKey reports whether the field at path is the one that resolves key.
|
||||||
|
// The encoder consults it to emit exactly the field the decoder would fill,
|
||||||
|
// so a struct with two fields mapping to one key does not marshal into a
|
||||||
|
// duplicate TOML key.
|
||||||
|
func (s structSchema) ownsKey(key string, path []int) bool {
|
||||||
|
loc, ok := s.byName[key]
|
||||||
|
return ok && slices.Equal(loc.index, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// fieldByIndex walks an index path from a struct value, allocating nil
|
||||||
|
// pointers along the way so a key can reach through an embedded pointer
|
||||||
|
// struct. Every field on the path is exported, so each step is settable.
|
||||||
|
func fieldByIndex(v reflect.Value, path []int) (reflect.Value, error) {
|
||||||
|
for i, x := range path {
|
||||||
|
v = v.Field(x)
|
||||||
|
if i < len(path)-1 && v.Kind() == reflect.Pointer {
|
||||||
|
if v.IsNil() {
|
||||||
|
if !v.CanSet() {
|
||||||
|
return reflect.Value{}, fmt.Errorf("cannot allocate nil embedded pointer")
|
||||||
|
}
|
||||||
|
v.Set(reflect.New(v.Type().Elem()))
|
||||||
|
}
|
||||||
|
v = v.Elem()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return v, nil
|
||||||
}
|
}
|
||||||
|
|||||||
+457
@@ -8,8 +8,11 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"math"
|
"math"
|
||||||
|
"net"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestSyntaxErrorMessage(t *testing.T) {
|
func TestSyntaxErrorMessage(t *testing.T) {
|
||||||
@@ -222,6 +225,33 @@ func TestUnmarshalIntToUint64FitsMaxInt64(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestUnmarshalFloat32Overflow(t *testing.T) {
|
||||||
|
// A finite float64 beyond the float32 range must not decode silently as
|
||||||
|
// an infinity.
|
||||||
|
type C struct {
|
||||||
|
X float32 `toml:"x"`
|
||||||
|
}
|
||||||
|
var c C
|
||||||
|
err := Unmarshal([]byte("x = 1e300\n"), &c)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected overflow error for float32")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "overflow") {
|
||||||
|
t.Errorf("err = %v, want substring 'overflow'", err.Error())
|
||||||
|
}
|
||||||
|
// Infinities themselves pass through, and in-range values are untouched.
|
||||||
|
var ok C
|
||||||
|
if err := Unmarshal([]byte("x = inf\n"), &ok); err != nil {
|
||||||
|
t.Fatalf("inf should decode into float32, got %v", err)
|
||||||
|
}
|
||||||
|
if !math.IsInf(float64(ok.X), 1) {
|
||||||
|
t.Errorf("X = %v, want +Inf", ok.X)
|
||||||
|
}
|
||||||
|
if err := Unmarshal([]byte("x = 1.5\n"), &ok); err != nil || ok.X != 1.5 {
|
||||||
|
t.Fatalf("1.5 should decode into float32, got %v (X=%v)", err, ok.X)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestUnmarshalNegativeIntToUint(t *testing.T) {
|
func TestUnmarshalNegativeIntToUint(t *testing.T) {
|
||||||
type C struct {
|
type C struct {
|
||||||
X uint8 `toml:"x"`
|
X uint8 `toml:"x"`
|
||||||
@@ -494,3 +524,430 @@ field = "y"
|
|||||||
t.Errorf("Field = %q, want \"y\"", cfg.R.Field)
|
t.Errorf("Field = %q, want \"y\"", cfg.R.Field)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- embedded field symmetry -----------------------------------------------
|
||||||
|
|
||||||
|
type RoundTripBase struct {
|
||||||
|
ID int `toml:"id"`
|
||||||
|
Name string `toml:"name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type RoundTripDerived struct {
|
||||||
|
RoundTripBase
|
||||||
|
X string `toml:"x"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnmarshalEmbeddedStructRoundTrip(t *testing.T) {
|
||||||
|
orig := RoundTripDerived{ID: 1, Name: "b", X: "x"}
|
||||||
|
out, err := Marshal(orig)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal: %v", err)
|
||||||
|
}
|
||||||
|
var back RoundTripDerived
|
||||||
|
if err := Unmarshal(out, &back); err != nil {
|
||||||
|
t.Fatalf("unmarshal: %v", err)
|
||||||
|
}
|
||||||
|
if back != orig {
|
||||||
|
t.Fatalf("round-trip mismatch:\nwas: %+v\nnow: %+v", orig, back)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type RoundTripPtrCfg struct {
|
||||||
|
*RoundTripBase
|
||||||
|
X string `toml:"x"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnmarshalEmbeddedPointerStruct(t *testing.T) {
|
||||||
|
var cfg RoundTripPtrCfg
|
||||||
|
if err := Unmarshal([]byte("id = 7\nname = \"n\"\nx = \"x\"\n"), &cfg); err != nil {
|
||||||
|
t.Fatalf("unmarshal: %v", err)
|
||||||
|
}
|
||||||
|
if cfg.RoundTripBase == nil || cfg.ID != 7 || cfg.Name != "n" || cfg.X != "x" {
|
||||||
|
t.Fatalf("decoded: %+v", cfg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A struct embedding a pointer to itself is legal Go; decoding into it must
|
||||||
|
// terminate. The schema walk used to recurse through the embedded type
|
||||||
|
// forever.
|
||||||
|
func TestUnmarshalSelfEmbeddedPointerStructTerminates(t *testing.T) {
|
||||||
|
type SelfLink struct {
|
||||||
|
*SelfLink
|
||||||
|
X int `toml:"x"`
|
||||||
|
Y string `toml:"y"`
|
||||||
|
}
|
||||||
|
var n SelfLink
|
||||||
|
if err := Unmarshal([]byte("x = 1\ny = \"s\"\n"), &n); err != nil {
|
||||||
|
t.Fatalf("unmarshal: %v", err)
|
||||||
|
}
|
||||||
|
if n.X != 1 || n.Y != "s" {
|
||||||
|
t.Fatalf("decoded: %+v", n)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A nil self pointer on the encode side stays skippable, as any nil
|
||||||
|
// embedded pointer is.
|
||||||
|
out, err := Marshal(SelfLink{X: 2})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal: %v", err)
|
||||||
|
}
|
||||||
|
if want := "x = 2\ny = \"\"\n"; string(out) != want {
|
||||||
|
t.Errorf("output mismatch:\ngot: %q\nwant: %q", out, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type RoundTripExtra map[string]int
|
||||||
|
|
||||||
|
type RoundTripMapCfg struct {
|
||||||
|
RoundTripExtra
|
||||||
|
X string `toml:"x"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnmarshalEmbeddedMap(t *testing.T) {
|
||||||
|
var cfg RoundTripMapCfg
|
||||||
|
if err := Unmarshal([]byte("alpha = 1\nx = \"x\"\n"), &cfg); err != nil {
|
||||||
|
t.Fatalf("unmarshal: %v", err)
|
||||||
|
}
|
||||||
|
if cfg.RoundTripExtra["alpha"] != 1 || cfg.X != "x" {
|
||||||
|
t.Fatalf("decoded: %+v", cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
orig := RoundTripMapCfg{RoundTripExtra: RoundTripExtra{"a": 1}, X: "x"}
|
||||||
|
out, err := Marshal(orig)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal: %v", err)
|
||||||
|
}
|
||||||
|
var back RoundTripMapCfg
|
||||||
|
if err := Unmarshal(out, &back); err != nil {
|
||||||
|
t.Fatalf("unmarshal: %v", err)
|
||||||
|
}
|
||||||
|
if back.X != "x" || back.RoundTripExtra["a"] != 1 {
|
||||||
|
t.Fatalf("round-trip mismatch: %+v", back)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnmarshalEmbeddedNameClashShallowerWins(t *testing.T) {
|
||||||
|
type Inner struct {
|
||||||
|
Name string `toml:"name"`
|
||||||
|
Deep string `toml:"deep"`
|
||||||
|
}
|
||||||
|
type Outer struct {
|
||||||
|
Inner
|
||||||
|
Name string `toml:"name"`
|
||||||
|
}
|
||||||
|
var v Outer
|
||||||
|
if err := Unmarshal([]byte("name = \"outer\"\ndeep = \"d\"\n"), &v); err != nil {
|
||||||
|
t.Fatalf("unmarshal: %v", err)
|
||||||
|
}
|
||||||
|
if v.Name != "outer" || v.Deep != "d" {
|
||||||
|
t.Fatalf("decoded: %+v", v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnmarshalNameClashEqualDepthLaterWins(t *testing.T) {
|
||||||
|
// At equal depth the field declared later resolves the name, matching the
|
||||||
|
// documented rule.
|
||||||
|
type C struct {
|
||||||
|
First string `toml:"v"`
|
||||||
|
Second int `toml:"v"`
|
||||||
|
}
|
||||||
|
var c C
|
||||||
|
if err := Unmarshal([]byte("v = 1\n"), &c); err != nil {
|
||||||
|
t.Fatalf("unmarshal: %v", err)
|
||||||
|
}
|
||||||
|
if c.Second != 1 {
|
||||||
|
t.Fatalf("decoded: %+v, want the later field to take the value", c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnmarshalUnknownKeyWithoutEmbeddedMap(t *testing.T) {
|
||||||
|
var cfg RoundTripDerived
|
||||||
|
if err := Unmarshal([]byte("rogue = 1\n"), &cfg); err != nil {
|
||||||
|
t.Fatalf("unmarshal: %v", err)
|
||||||
|
}
|
||||||
|
if cfg.ID != 0 || cfg.X != "" {
|
||||||
|
t.Fatalf("decoded: %+v", cfg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnmarshalStrictEmbeddedMapStaysStrict(t *testing.T) {
|
||||||
|
type Cfg struct {
|
||||||
|
RoundTripExtra
|
||||||
|
Name string `toml:"name"`
|
||||||
|
}
|
||||||
|
dec := NewDecoder().DisallowUnknownFields()
|
||||||
|
err := dec.Decode([]byte("name = \"n\"\nrogue = 1\n"), &Cfg{})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "unknown field") {
|
||||||
|
t.Fatalf("expected unknown field error, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDecodeErrorCarriesPath(t *testing.T) {
|
||||||
|
type Item struct {
|
||||||
|
Name string `toml:"name"`
|
||||||
|
Weight uint8 `toml:"weight"`
|
||||||
|
}
|
||||||
|
type Cfg struct {
|
||||||
|
Tags []string `toml:"tags"`
|
||||||
|
Items []Item `toml:"items"`
|
||||||
|
}
|
||||||
|
var cfg Cfg
|
||||||
|
err := Unmarshal([]byte("[[items]]\nname = \"a\"\nweight = 300\n"), &cfg)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected an overflow error")
|
||||||
|
}
|
||||||
|
de, ok := errors.AsType[*DecodeError](err)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected a *DecodeError, got %T: %v", err, err)
|
||||||
|
}
|
||||||
|
want := []string{"items", "[0]", "weight"}
|
||||||
|
if !slices.Equal(de.Path, want) {
|
||||||
|
t.Fatalf("Path = %v, want %v", de.Path, want)
|
||||||
|
}
|
||||||
|
if de.Err == nil || !strings.Contains(de.Err.Error(), "overflows uint8") {
|
||||||
|
t.Fatalf("Err = %v", de.Err)
|
||||||
|
}
|
||||||
|
// The rendered message keeps its shape: segments joined with ": ".
|
||||||
|
wantMsg := "items: [0]: weight: interpres: integer 300 overflows uint8"
|
||||||
|
if err.Error() != wantMsg {
|
||||||
|
t.Fatalf("message = %q, want %q", err.Error(), wantMsg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDecodeErrorOnMapDestination(t *testing.T) {
|
||||||
|
var m map[string]uint8
|
||||||
|
err := Unmarshal([]byte("count = -1\n"), &m)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected an error")
|
||||||
|
}
|
||||||
|
de, ok := errors.AsType[*DecodeError](err)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected a *DecodeError, got %T: %v", err, err)
|
||||||
|
}
|
||||||
|
if !slices.Equal(de.Path, []string{"count"}) {
|
||||||
|
t.Fatalf("Path = %v", de.Path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnmarshalIntoDefinedScalarTypes(t *testing.T) {
|
||||||
|
// A defined type whose underlying kind is string or bool takes the value.
|
||||||
|
// A bare reflect Set panics on such a type, because a string is not
|
||||||
|
// assignable to a defined string type without a conversion.
|
||||||
|
type Name string
|
||||||
|
type Flag bool
|
||||||
|
type Cfg struct {
|
||||||
|
N Name `toml:"n"`
|
||||||
|
F Flag `toml:"f"`
|
||||||
|
}
|
||||||
|
var cfg Cfg
|
||||||
|
if err := Unmarshal([]byte("n = \"x\"\nf = true\n"), &cfg); err != nil {
|
||||||
|
t.Fatalf("unmarshal: %v", err)
|
||||||
|
}
|
||||||
|
if cfg.N != "x" {
|
||||||
|
t.Errorf("N = %q, want \"x\"", cfg.N)
|
||||||
|
}
|
||||||
|
if !cfg.F {
|
||||||
|
t.Error("F = false, want true")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- encoding.TextUnmarshaler and time.Duration ----------------------------
|
||||||
|
|
||||||
|
// textReceiver implements encoding.TextUnmarshaler on the pointer receiver.
|
||||||
|
type textReceiver struct{ Text string }
|
||||||
|
|
||||||
|
func (t *textReceiver) UnmarshalText(text []byte) error {
|
||||||
|
t.Text = "got:" + string(text)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// upperText is a defined string type whose UnmarshalText transforms the
|
||||||
|
// content, so a plain string assignment would leave the wrong value behind.
|
||||||
|
type upperText string
|
||||||
|
|
||||||
|
func (u *upperText) UnmarshalText(text []byte) error {
|
||||||
|
*u = upperText(strings.ToUpper(string(text)))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// failingTextUnmarshaler fails the decode from UnmarshalText.
|
||||||
|
type failingTextUnmarshaler struct{}
|
||||||
|
|
||||||
|
func (f *failingTextUnmarshaler) UnmarshalText(_ []byte) error { return errors.New("text boom") }
|
||||||
|
|
||||||
|
// textAndTOMLReceiver implements both decode interfaces; the TOML method wins.
|
||||||
|
type textAndTOMLReceiver struct{ From string }
|
||||||
|
|
||||||
|
func (t *textAndTOMLReceiver) UnmarshalTOML(any) error { t.From = "toml"; return nil }
|
||||||
|
|
||||||
|
func (t *textAndTOMLReceiver) UnmarshalText([]byte) error { t.From = "text"; return nil }
|
||||||
|
|
||||||
|
func TestTextUnmarshalerByPointer(t *testing.T) {
|
||||||
|
type Cfg struct {
|
||||||
|
R textReceiver `toml:"r"`
|
||||||
|
}
|
||||||
|
var cfg Cfg
|
||||||
|
if err := Unmarshal([]byte(`r = "hello"`), &cfg); err != nil {
|
||||||
|
t.Fatalf("unmarshal: %v", err)
|
||||||
|
}
|
||||||
|
if cfg.R.Text != "got:hello" {
|
||||||
|
t.Errorf("Text = %q, want \"got:hello\"", cfg.R.Text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTextUnmarshalerWinsOverKindAssignment(t *testing.T) {
|
||||||
|
type Cfg struct {
|
||||||
|
U upperText `toml:"u"`
|
||||||
|
}
|
||||||
|
var cfg Cfg
|
||||||
|
if err := Unmarshal([]byte(`u = "abc"`), &cfg); err != nil {
|
||||||
|
t.Fatalf("unmarshal: %v", err)
|
||||||
|
}
|
||||||
|
if cfg.U != "ABC" {
|
||||||
|
t.Errorf("U = %q, want \"ABC\"", cfg.U)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTextUnmarshalerForNetIP(t *testing.T) {
|
||||||
|
type Cfg struct {
|
||||||
|
V4 net.IP `toml:"v4"`
|
||||||
|
V6 net.IP `toml:"v6"`
|
||||||
|
IPs []net.IP `toml:"ips"`
|
||||||
|
}
|
||||||
|
in := "v4 = \"192.0.2.1\"\nv6 = \"2001:db8::68\"\nips = [\"198.51.100.7\", \"203.0.113.9\"]\n"
|
||||||
|
var cfg Cfg
|
||||||
|
if err := Unmarshal([]byte(in), &cfg); err != nil {
|
||||||
|
t.Fatalf("unmarshal: %v", err)
|
||||||
|
}
|
||||||
|
if got := cfg.V4.String(); got != "192.0.2.1" {
|
||||||
|
t.Errorf("V4 = %q, want \"192.0.2.1\"", got)
|
||||||
|
}
|
||||||
|
if got := cfg.V6.String(); got != "2001:db8::68" {
|
||||||
|
t.Errorf("V6 = %q, want \"2001:db8::68\"", got)
|
||||||
|
}
|
||||||
|
if len(cfg.IPs) != 2 || cfg.IPs[0].String() != "198.51.100.7" || cfg.IPs[1].String() != "203.0.113.9" {
|
||||||
|
t.Errorf("IPs = %v, want two addresses", cfg.IPs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTextUnmarshalerSeesStringsOnly(t *testing.T) {
|
||||||
|
// An integer keeps its own rule: the text method is not consulted, and the
|
||||||
|
// value does not reach the receiver.
|
||||||
|
type Cfg struct {
|
||||||
|
R textReceiver `toml:"r"`
|
||||||
|
}
|
||||||
|
var cfg Cfg
|
||||||
|
err := Unmarshal([]byte("r = 1\n"), &cfg)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected an integer to be rejected for a text receiver")
|
||||||
|
}
|
||||||
|
if cfg.R.Text != "" {
|
||||||
|
t.Errorf("Text = %q, want it untouched", cfg.R.Text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnmarshalTOMLWinsOverTextUnmarshaler(t *testing.T) {
|
||||||
|
type Cfg struct {
|
||||||
|
B textAndTOMLReceiver `toml:"b"`
|
||||||
|
}
|
||||||
|
var cfg Cfg
|
||||||
|
if err := Unmarshal([]byte(`b = "x"`), &cfg); err != nil {
|
||||||
|
t.Fatalf("unmarshal: %v", err)
|
||||||
|
}
|
||||||
|
if cfg.B.From != "toml" {
|
||||||
|
t.Errorf("From = %q, want \"toml\"", cfg.B.From)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTextUnmarshalerErrorCarriesPath(t *testing.T) {
|
||||||
|
type Inner struct {
|
||||||
|
F failingTextUnmarshaler `toml:"f"`
|
||||||
|
}
|
||||||
|
type Cfg struct {
|
||||||
|
Inner Inner `toml:"inner"`
|
||||||
|
}
|
||||||
|
var cfg Cfg
|
||||||
|
err := Unmarshal([]byte("[inner]\nf = \"x\"\n"), &cfg)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected an error from UnmarshalText")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "unmarshal text: text boom") {
|
||||||
|
t.Errorf("err = %v, want the text error wrapped", err)
|
||||||
|
}
|
||||||
|
de, ok := errors.AsType[*DecodeError](err)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected a *DecodeError, got %T: %v", err, err)
|
||||||
|
}
|
||||||
|
if !slices.Equal(de.Path, []string{"inner", "f"}) {
|
||||||
|
t.Fatalf("Path = %v, want [inner f]", de.Path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTextUnmarshalerReportsBadText(t *testing.T) {
|
||||||
|
var cfg struct {
|
||||||
|
IP net.IP `toml:"ip"`
|
||||||
|
}
|
||||||
|
err := Unmarshal([]byte(`ip = "not-an-ip"`), &cfg)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected an error for a malformed address")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "unmarshal text:") {
|
||||||
|
t.Errorf("err = %v, want it wrapped as a text error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnmarshalDurations(t *testing.T) {
|
||||||
|
type Cfg struct {
|
||||||
|
FromText time.Duration `toml:"from_text"`
|
||||||
|
FromInt time.Duration `toml:"from_int"`
|
||||||
|
Fraction time.Duration `toml:"fraction"`
|
||||||
|
}
|
||||||
|
in := "from_text = \"1h30m\"\nfrom_int = 5400000000000\nfraction = \"1.5s\"\n"
|
||||||
|
var cfg Cfg
|
||||||
|
if err := Unmarshal([]byte(in), &cfg); err != nil {
|
||||||
|
t.Fatalf("unmarshal: %v", err)
|
||||||
|
}
|
||||||
|
if cfg.FromText != 90*time.Minute {
|
||||||
|
t.Errorf("FromText = %v, want %v", cfg.FromText, 90*time.Minute)
|
||||||
|
}
|
||||||
|
if cfg.FromInt != 90*time.Minute {
|
||||||
|
t.Errorf("FromInt = %v, want %v", cfg.FromInt, 90*time.Minute)
|
||||||
|
}
|
||||||
|
if cfg.Fraction != 1500*time.Millisecond {
|
||||||
|
t.Errorf("Fraction = %v, want %v", cfg.Fraction, 1500*time.Millisecond)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnmarshalDurationRejectsMalformedText(t *testing.T) {
|
||||||
|
var cfg struct {
|
||||||
|
D time.Duration `toml:"d"`
|
||||||
|
}
|
||||||
|
err := Unmarshal([]byte("d = \"90\"\n"), &cfg)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected an error for a duration without a unit")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "invalid duration") {
|
||||||
|
t.Errorf("err = %v, want an invalid-duration message", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQuotedStringNeverBecomesDateTime(t *testing.T) {
|
||||||
|
// The date-time types take a bare timestamp only, so the text path is
|
||||||
|
// excluded for them and a quoted string stays a string.
|
||||||
|
var stamp struct {
|
||||||
|
S time.Time `toml:"s"`
|
||||||
|
}
|
||||||
|
err := Unmarshal([]byte("s = \"2026-06-26T10:00:00Z\"\n"), &stamp)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected a quoted string to be rejected for time.Time")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "cannot assign string") {
|
||||||
|
t.Errorf("err = %v, want a cannot-assign message", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var day struct {
|
||||||
|
D LocalDate `toml:"d"`
|
||||||
|
}
|
||||||
|
if err := Unmarshal([]byte("d = \"1979-05-27\"\n"), &day); err == nil {
|
||||||
|
t.Fatal("expected a quoted string to be rejected for LocalDate")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+217
-29
@@ -1,12 +1,16 @@
|
|||||||
# API
|
# API
|
||||||
|
|
||||||
The library exports the surface below from the `sourcedock.dev/petrbalvin/interpres`
|
The library exports the surface below from the `sourcedock.dev/petrbalvin/interpres/v2`
|
||||||
package. The snippets assume:
|
package. The snippets assume:
|
||||||
|
|
||||||
```go
|
```go
|
||||||
import "sourcedock.dev/petrbalvin/interpres"
|
import "sourcedock.dev/petrbalvin/interpres/v2"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The parser implements TOML 1.1: date-times and times without seconds, the
|
||||||
|
`\e` and `\xHH` escape sequences, and multi-line inline tables with comments
|
||||||
|
and trailing commas. The encoder emits TOML 1.1.
|
||||||
|
|
||||||
## Functions
|
## Functions
|
||||||
|
|
||||||
### `func Parse(data []byte) (map[string]any, error)`
|
### `func Parse(data []byte) (map[string]any, error)`
|
||||||
@@ -46,7 +50,7 @@ The cancellable variant of `Unmarshal`.
|
|||||||
### `func Marshal(v any) ([]byte, error)`
|
### `func Marshal(v any) ([]byte, error)`
|
||||||
|
|
||||||
Encodes a `struct` or `map[string]V` value, or a non-nil pointer to one, into a
|
Encodes a `struct` or `map[string]V` value, or a non-nil pointer to one, into a
|
||||||
TOML 1.0 document. The emission rules are in the [Encoding](#encoding) section
|
TOML document. The emission rules are in the [Encoding](#encoding) section
|
||||||
below. Equivalent to `MarshalContext(context.Background(), v)`.
|
below. Equivalent to `MarshalContext(context.Background(), v)`.
|
||||||
|
|
||||||
```go
|
```go
|
||||||
@@ -100,16 +104,23 @@ For a struct destination, a TOML key matches a field as follows:
|
|||||||
1. The `toml:"name"` tag, using the part before any comma. The literal `-`
|
1. The `toml:"name"` tag, using the part before any comma. The literal `-`
|
||||||
excludes the field.
|
excludes the field.
|
||||||
2. Without a tag, the lower-cased field name.
|
2. Without a tag, the lower-cased field name.
|
||||||
3. The key itself is lower-cased before lookup, so the match is
|
3. An anonymous (embedded) field without a tag is inlined: the decoder walks
|
||||||
|
into the embedded struct and matches its own fields against the same keys,
|
||||||
|
mirroring how the encoder flattens it. A nil embedded pointer struct is
|
||||||
|
allocated on demand. An untagged embedded map receives the keys no field
|
||||||
|
claims.
|
||||||
|
4. The key itself is lower-cased before lookup, so the match is
|
||||||
case-insensitive on both sides: `DATABASEURL` matches a field named
|
case-insensitive on both sides: `DATABASEURL` matches a field named
|
||||||
`DatabaseUrl`.
|
`DatabaseUrl`.
|
||||||
|
|
||||||
The match is exact after lower-casing. No separator is inserted, so a TOML key
|
The match is exact after lower-casing. No separator is inserted, so a TOML key
|
||||||
`database_url` does not match a field named `DatabaseUrl`; tag such a field
|
`database_url` does not match a field named `DatabaseUrl`; tag such a field
|
||||||
(`toml:"database_url"`) or use the lower-cased name as the key. When two fields
|
(`toml:"database_url"`) or use the lower-cased name as the key. When two
|
||||||
resolve to the same name, the one declared later wins.
|
fields resolve to the same name, the shallower one wins; at equal depth, the
|
||||||
|
one declared later wins.
|
||||||
|
|
||||||
Unknown keys are ignored by default; see [Strict decoding](#strict-decoding).
|
Unknown keys are ignored by default, landing in an untagged embedded map when
|
||||||
|
the struct has one; [Strict decoding](#strict-decoding) rejects them instead.
|
||||||
|
|
||||||
### Numeric conversion
|
### Numeric conversion
|
||||||
|
|
||||||
@@ -119,8 +130,8 @@ The decoder converts to the destination type with explicit overflow checks:
|
|||||||
| Destination kind | Rule |
|
| Destination kind | Rule |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `int`, `int8`, `int16`, `int32`, `int64` | the `int64` value must not overflow the destination |
|
| `int`, `int8`, `int16`, `int32`, `int64` | the `int64` value must not overflow the destination |
|
||||||
| `uint`, `uint8`, `uint16`, `uint32`, `uint64` | the value must be non-negative; `uint8`, `uint16` and `uint32` enforce their own maxima; `uint64` accepts any non-negative `int64` |
|
| `uint`, `uint8`, `uint16`, `uint32`, `uint64` | the value must be non-negative and must not overflow the destination's own width, `uint` on a 32-bit platform included; `uint64` accepts any non-negative `int64` |
|
||||||
| `float32`, `float64` | copied verbatim; an integer also coerces, so TOML `5` decodes into `5.0` |
|
| `float32`, `float64` | copied verbatim, except that a finite value beyond the `float32` range is an overflow error rather than a silent infinity; an integer also coerces, so TOML `5` decodes into `5.0` |
|
||||||
| `bool`, `string` | exact kind match only, no coercion across kinds |
|
| `bool`, `string` | exact kind match only, no coercion across kinds |
|
||||||
| `time.Time` | offset date-times only; no implicit conversion to or from the local variants |
|
| `time.Time` | offset date-times only; no implicit conversion to or from the local variants |
|
||||||
|
|
||||||
@@ -132,8 +143,14 @@ offending key or index, for example `p: interpres: integer 300 overflows uint8`.
|
|||||||
Offset date-times decode into `time.Time` and keep their offset. The local
|
Offset date-times decode into `time.Time` and keep their offset. The local
|
||||||
variants decode into `LocalDateTime`, `LocalDate` and `LocalTime`, whose
|
variants decode into `LocalDateTime`, `LocalDate` and `LocalTime`, whose
|
||||||
embedded `time.Time` is normalised to UTC (midnight UTC for a local date, the
|
embedded `time.Time` is normalised to UTC (midnight UTC for a local date, the
|
||||||
zero date for a local time). There is no implicit conversion between the offset
|
zero date for a local time). Every kind may omit the seconds as of TOML 1.1
|
||||||
and local kinds; assigning one to the other is an error.
|
(`07:32`, `1979-05-27T07:32`); such a value carries a zero second, and the
|
||||||
|
encoder writes the seconds only when the value carries them, so a document
|
||||||
|
written without seconds comes back without them. There is no implicit
|
||||||
|
conversion between the offset and local kinds; assigning one to the other is an
|
||||||
|
error. The four types take a bare timestamp and never a quoted string, so a
|
||||||
|
document that writes a date-time with quotes does not decode into them, and
|
||||||
|
neither `encoding.TextUnmarshaler` nor the embedded `time.Time` changes that.
|
||||||
|
|
||||||
### Arrays of tables
|
### Arrays of tables
|
||||||
|
|
||||||
@@ -164,6 +181,38 @@ automatically, and a nil pointer destination is allocated first. An error
|
|||||||
returned from `UnmarshalTOML` halts the decode and propagates wrapped with the
|
returned from `UnmarshalTOML` halts the decode and propagates wrapped with the
|
||||||
key path, for example `addr: unmarshal: not a string`.
|
key path, for example `addr: unmarshal: not a string`.
|
||||||
|
|
||||||
|
### Custom decoding: `encoding.TextUnmarshaler`
|
||||||
|
|
||||||
|
A destination type that implements `encoding.TextUnmarshaler` receives a TOML
|
||||||
|
string as its text content, the rule `encoding/json` follows:
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (ip *IP) UnmarshalText(text []byte) error
|
||||||
|
```
|
||||||
|
|
||||||
|
The decoder looks for the method on the destination and on its address, so a
|
||||||
|
pointer-receiver `UnmarshalText` is invoked on an addressable struct field, and
|
||||||
|
the elements of a slice destination are reached the same way. The text path
|
||||||
|
applies to TOML strings only: every other value kind keeps its own rule, so
|
||||||
|
`r = 1` does not reach a receiver that expects text. An error from
|
||||||
|
`UnmarshalText` halts the decode and propagates with the key path and the
|
||||||
|
prefix `unmarshal text:`, for example `addr: unmarshal text: not an address`.
|
||||||
|
|
||||||
|
[`UnmarshalTOML`](#custom-decoding-unmarshaler) wins over `UnmarshalText` when
|
||||||
|
a type implements both, and the four [date-time
|
||||||
|
types](#date-time-values) are excluded: a quoted string stays a string and
|
||||||
|
never becomes a `time.Time` or one of the local wrappers.
|
||||||
|
|
||||||
|
### Durations
|
||||||
|
|
||||||
|
TOML has no duration type, so `time.Duration` has a rule of its own. The
|
||||||
|
encoder writes the canonical Go form in a TOML string, `1h30m0s`, and the
|
||||||
|
decoder reads that string back with `time.ParseDuration`. A bare integer is
|
||||||
|
still the nanosecond count it has always been, so `from_int = 5400000000000`
|
||||||
|
and `from_text = "1h30m"` decode to the same duration. Text that
|
||||||
|
`time.ParseDuration` rejects, `d = "90"` among it, fails with
|
||||||
|
`interpres: invalid duration "90"`.
|
||||||
|
|
||||||
### Strict decoding
|
### Strict decoding
|
||||||
|
|
||||||
By default unknown keys are dropped silently. A `Decoder` built with
|
By default unknown keys are dropped silently. A `Decoder` built with
|
||||||
@@ -179,7 +228,8 @@ A typo such as `database_urls` then fails with
|
|||||||
`interpres: unknown field "database_urls" for main.Config` instead of a silent
|
`interpres: unknown field "database_urls" for main.Config` instead of a silent
|
||||||
default-zero run. Strictness applies to every struct the decode reaches, at any
|
default-zero run. Strictness applies to every struct the decode reaches, at any
|
||||||
depth, including struct elements inside slices; map destinations accept every
|
depth, including struct elements inside slices; map destinations accept every
|
||||||
key by nature.
|
key by nature. When several keys are unknown, the message names the smallest
|
||||||
|
one, so it does not depend on map iteration order.
|
||||||
|
|
||||||
### Cancellation
|
### Cancellation
|
||||||
|
|
||||||
@@ -233,10 +283,33 @@ Keys that match `[A-Za-z0-9_-]+` are emitted bare, all others quoted. A
|
|||||||
`map[string]V` emits its keys in sorted order for deterministic output, and a
|
`map[string]V` emits its keys in sorted order for deterministic output, and a
|
||||||
nil map emits nothing.
|
nil map emits nothing.
|
||||||
|
|
||||||
Note the asymmetry: the encoder inlines untagged embedded structs, while the
|
### Tag options
|
||||||
decoder expects them under their lower-cased type name. A struct with an
|
|
||||||
untagged embedded struct therefore does not round-trip through `Unmarshal` into
|
The part of a `toml` tag after the first comma carries options. Both options
|
||||||
the same type.
|
shape emission only; the decoder ignores them.
|
||||||
|
|
||||||
|
- `omitzero` skips the field when its value is the zero value of its type. A
|
||||||
|
type with an `IsZero() bool` method (time.Time among them) decides through
|
||||||
|
that method, so a zero `time.Time` or an all-zero struct disappears from
|
||||||
|
the output.
|
||||||
|
- `omitempty` skips the field when it holds an empty collection: a nil or
|
||||||
|
empty slice or array, or a nil or empty map. Strings and other scalars are
|
||||||
|
not covered by `omitempty`; use `omitzero` for those.
|
||||||
|
|
||||||
|
```go
|
||||||
|
type Config struct {
|
||||||
|
Host string `toml:"host,omitzero"`
|
||||||
|
Started time.Time `toml:"started,omitzero"`
|
||||||
|
Tags []string `toml:"tags,omitempty"`
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Options combine after the name: `toml:"name,omitempty,omitzero"` is valid, and
|
||||||
|
an unknown option is ignored.
|
||||||
|
|
||||||
|
Untagged embedded fields round-trip: the decoder inlines embedded structs and
|
||||||
|
routes unclaimed keys into an embedded map exactly where the encoder flattened
|
||||||
|
them.
|
||||||
|
|
||||||
### Group-by-kind layout
|
### Group-by-kind layout
|
||||||
|
|
||||||
@@ -279,7 +352,10 @@ The returned value is encoded as if it had been passed in place of the
|
|||||||
receiver, so it may be a scalar, a slice, an array of tables, or another
|
receiver, so it may be a scalar, a slice, an array of tables, or another
|
||||||
struct or map, including the `Marshaler` result of another type; the encoder
|
struct or map, including the `Marshaler` result of another type; the encoder
|
||||||
recurses. An error returned from `MarshalTOML` fails the marshal wrapped with
|
recurses. An error returned from `MarshalTOML` fails the marshal wrapped with
|
||||||
the key path, for example `interpres: server.port: bad timestamp`.
|
the key path, for example `interpres: server.port: bad timestamp`. A result
|
||||||
|
of `nil` with a nil error fails the same way with
|
||||||
|
`MarshalTOML returned a nil value`: nil has no TOML representation, so
|
||||||
|
dropping the field silently is not an option.
|
||||||
|
|
||||||
```go
|
```go
|
||||||
type Port int
|
type Port int
|
||||||
@@ -289,6 +365,44 @@ func (p Port) MarshalTOML() (any, error) {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Custom encoding: `encoding.TextMarshaler`
|
||||||
|
|
||||||
|
A type that implements `encoding.TextMarshaler` is encoded as a TOML string
|
||||||
|
holding the text the method returns, which is the rule `encoding/json` follows:
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (ip IP) MarshalText() ([]byte, error)
|
||||||
|
```
|
||||||
|
|
||||||
|
The encoder looks for the method on the value and on its address, so a
|
||||||
|
pointer-receiver `MarshalText` is found on a struct field of an addressable
|
||||||
|
value (pass a pointer to `Marshal`) and always on a slice element. `net.IP`,
|
||||||
|
`netip.Addr` and user types follow this rule, and a struct that implements the
|
||||||
|
interface becomes a string rather than a table. `MarshalTOML` wins when a type
|
||||||
|
implements both, the four [date-time types](#date-time-values) keep their bare
|
||||||
|
timestamp form, and text that is not valid UTF-8 is an error rather than a
|
||||||
|
replacement character.
|
||||||
|
|
||||||
|
A duration carries no text method of its own; see [Durations](#durations) for
|
||||||
|
its rule.
|
||||||
|
|
||||||
|
### Arrays
|
||||||
|
|
||||||
|
An array whose every element is a table (`[]struct`, `[]map[string]V`, after
|
||||||
|
pointer dereference) emits as an array of tables. TOML also lets one array mix
|
||||||
|
tables with scalars; such an array emits as a plain value array, with the
|
||||||
|
table elements rendered as inline tables:
|
||||||
|
|
||||||
|
```go
|
||||||
|
tree, _ := interpres.Parse([]byte(`arr = [1, {a = 2}, "x"]`))
|
||||||
|
out, _ := interpres.Marshal(tree) // arr = [1, {a = 2}, "x"]
|
||||||
|
```
|
||||||
|
|
||||||
|
A `[]any` holding only tables keeps the value-array form as well, because that
|
||||||
|
is the shape `Parse` gives a value array of inline tables; emitting it as
|
||||||
|
`[[headers]]` would re-parse as `[]map[string]any` and change the value's type
|
||||||
|
across a round-trip.
|
||||||
|
|
||||||
### Empty arrays
|
### Empty arrays
|
||||||
|
|
||||||
A nil slice is always omitted. An empty (length 0) array of tables is always
|
A nil slice is always omitted. An empty (length 0) array of tables is always
|
||||||
@@ -299,17 +413,61 @@ omitted, because TOML forbids an empty `[[a]]`. Other empty arrays emit as
|
|||||||
### Long strings
|
### Long strings
|
||||||
|
|
||||||
By default every string is emitted as a basic `"..."` string with the escapes
|
By default every string is emitted as a basic `"..."` string with the escapes
|
||||||
TOML requires, and a string containing a newline is emitted as an escaped
|
TOML requires, a newline among them as `\n`. `UseLiteralMultiline(threshold)`
|
||||||
multi-line basic string. `UseLiteralMultiline(threshold)` switches strings that
|
switches strings that contain a newline and are at least `threshold` bytes long
|
||||||
contain a newline and are at least `threshold` bytes long to the literal
|
to the literal `'''...'''` form, which carries the newlines verbatim:
|
||||||
`'''...'''` form, which carries the newlines verbatim:
|
|
||||||
|
|
||||||
```go
|
```go
|
||||||
out, err := interpres.NewEncoder().UseLiteralMultiline(80).Marshal(cfg)
|
out, err := interpres.NewEncoder().UseLiteralMultiline(80).Marshal(cfg)
|
||||||
```
|
```
|
||||||
|
|
||||||
Single-line strings keep the basic form regardless of the threshold, and a
|
Single-line strings keep the basic form regardless of the threshold, and a
|
||||||
threshold of `0` or less disables the option.
|
threshold of `0` or less disables the option. A string the literal form cannot
|
||||||
|
carry verbatim (an embedded run of three single quotes, a control character
|
||||||
|
other than tab or newline, or a carriage return outside a CRLF pair) also keeps
|
||||||
|
the basic form, so the output always re-parses to the same value.
|
||||||
|
|
||||||
|
### Inline tables
|
||||||
|
|
||||||
|
A table element of a value array, and a sub-table inlined by
|
||||||
|
[`InlineTables`](#compact-documents), is written as one `{a = 1, b = 2}` line
|
||||||
|
while it fits. An inline table that would pass the hundredth column carries
|
||||||
|
newlines and a trailing comma instead, which TOML 1.1 allows:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
arr = [1, {
|
||||||
|
n = 1,
|
||||||
|
name = "a value long enough to push this line well past the one hundred column limit",
|
||||||
|
}]
|
||||||
|
```
|
||||||
|
|
||||||
|
The closing brace and the entries are indented one tab per nesting level, a
|
||||||
|
nested table is measured on its own line, and the output re-parses to the same
|
||||||
|
value either way.
|
||||||
|
|
||||||
|
### Compact documents
|
||||||
|
|
||||||
|
`InlineTables(threshold)` writes a sub-table as an inline table when its
|
||||||
|
single-line rendering is at most `threshold` bytes, and as a table header
|
||||||
|
section when it is longer. A document of small tables therefore grows shorter:
|
||||||
|
|
||||||
|
```go
|
||||||
|
out, err := interpres.NewEncoder().InlineTables(60).Marshal(cfg)
|
||||||
|
```
|
||||||
|
|
||||||
|
With `60` and a table of three short entries, the same value is written
|
||||||
|
|
||||||
|
```toml
|
||||||
|
server = {host = "127.0.0.1", port = 9090, tls = {on = false}}
|
||||||
|
```
|
||||||
|
|
||||||
|
instead of three lines under a `[server]` header and a `[server.tls]` section.
|
||||||
|
A nested sub-table takes part in the same way, and the whole option is off at
|
||||||
|
`0` or less. Two limits are deliberate. An array of tables keeps the `[[a]]`
|
||||||
|
header form, because its inline form re-parses as a value array and would change
|
||||||
|
the value's Go type. And because an inlined table is a value line, every one of
|
||||||
|
them precedes the first header of its document, so a table inlined next to a
|
||||||
|
header is not read back as part of that header's section.
|
||||||
|
|
||||||
### Cancellation
|
### Cancellation
|
||||||
|
|
||||||
@@ -325,6 +483,8 @@ The output is not byte-identical to any document that produced the value:
|
|||||||
- map keys are emitted in sorted order
|
- map keys are emitted in sorted order
|
||||||
- the choice between `[table]` headers and inline tables is not preserved
|
- the choice between `[table]` headers and inline tables is not preserved
|
||||||
- strings use the basic quoted form unless the literal option above applies
|
- strings use the basic quoted form unless the literal option above applies
|
||||||
|
- a date-time drops its zero seconds and the trailing zeros of its fraction, so
|
||||||
|
`07:32:00` is written `07:32`; both are the same value
|
||||||
- floats always carry a `.` or an exponent, so a float `1` is emitted as `1.0`
|
- floats always carry a `.` or an exponent, so a float `1` is emitted as `1.0`
|
||||||
and stays distinguishable from the integer `1` across a round-trip; negative
|
and stays distinguishable from the integer `1` across a round-trip; negative
|
||||||
zero is normalised to `0.0`
|
zero is normalised to `0.0`
|
||||||
@@ -362,6 +522,27 @@ if se, ok := errors.AsType[*interpres.SyntaxError](err); ok {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### `type DecodeError struct{ Path []string; Err error }`
|
||||||
|
|
||||||
|
Wraps a decoding failure with the key path at which it happened. `Path` lists
|
||||||
|
one segment per level from the document root, the outermost key first: a key
|
||||||
|
contributes its name, an array element its bracketed index, so the path of the
|
||||||
|
`weight` field in the first item reads `["items", "[0]", "weight"]`. The
|
||||||
|
rendered message is unchanged by the type; read the fields instead of parsing
|
||||||
|
the message:
|
||||||
|
|
||||||
|
```go
|
||||||
|
if de, ok := errors.AsType[*interpres.DecodeError](err); ok {
|
||||||
|
fmt.Println(de.Path, de.Err)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `type EncodeError struct{ Path string; Err error }`
|
||||||
|
|
||||||
|
Wraps an encoding failure with the key path of the value that failed, in the
|
||||||
|
document's own notation: `server.ports[2]`. Read it with `errors.AsType` the
|
||||||
|
same way.
|
||||||
|
|
||||||
### `type Decoder`
|
### `type Decoder`
|
||||||
|
|
||||||
Configurable strictness for decoding, constructed with `NewDecoder`. Set up
|
Configurable strictness for decoding, constructed with `NewDecoder`. Set up
|
||||||
@@ -380,12 +561,14 @@ encoder:
|
|||||||
| `GroupByKind(v bool)` | `true` | group entries as scalars, then sub-tables, then arrays of tables; `false` preserves declaration order |
|
| `GroupByKind(v bool)` | `true` | group entries as scalars, then sub-tables, then arrays of tables; `false` preserves declaration order |
|
||||||
| `OmitEmptyArrays()` | off | skip `key = []` for empty scalar arrays |
|
| `OmitEmptyArrays()` | off | skip `key = []` for empty scalar arrays |
|
||||||
| `UseLiteralMultiline(threshold int)` | `0` | emit multi-line strings of at least `threshold` bytes as literal `'''...'''` |
|
| `UseLiteralMultiline(threshold int)` | `0` | emit multi-line strings of at least `threshold` bytes as literal `'''...'''` |
|
||||||
|
| `InlineTables(threshold int)` | `0` | write a sub-table inline when its single-line form is at most `threshold` bytes |
|
||||||
|
|
||||||
```go
|
```go
|
||||||
out, err := interpres.NewEncoder().
|
out, err := interpres.NewEncoder().
|
||||||
GroupByKind(false).
|
GroupByKind(false).
|
||||||
OmitEmptyArrays().
|
OmitEmptyArrays().
|
||||||
UseLiteralMultiline(80).
|
UseLiteralMultiline(80).
|
||||||
|
InlineTables(60).
|
||||||
MarshalContext(ctx, cfg)
|
MarshalContext(ctx, cfg)
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -409,20 +592,25 @@ type LocalDate struct{ time.Time } // 1979-05-27
|
|||||||
type LocalTime struct{ time.Time } // 07:32:00.999999
|
type LocalTime struct{ time.Time } // 07:32:00.999999
|
||||||
```
|
```
|
||||||
|
|
||||||
Each carries a `String()` method returning the TOML-canonical rendering, with
|
Each carries a `String()` method returning the TOML-canonical rendering: the
|
||||||
the fractional second zero-padded to nanosecond precision when present. The
|
seconds appear only when the value carries them, and a fractional second drops
|
||||||
types are produced by `Parse` and accepted by `Marshal`.
|
its trailing zeros, so `07:32:00` renders as `07:32` and a half second as
|
||||||
|
`00.5`. The types are produced by `Parse` and accepted by `Marshal`, which
|
||||||
|
writes them through `String()`.
|
||||||
|
|
||||||
## Errors
|
## Errors
|
||||||
|
|
||||||
The entry points return:
|
The entry points return:
|
||||||
|
|
||||||
- `*SyntaxError` for a malformed document, with the 1-based line
|
- `*SyntaxError` for a malformed document, with the 1-based line
|
||||||
- a plain error for everything else: a non-pointer decode target, a type
|
- `*DecodeError` for a decoding failure, with the key path in `Path`
|
||||||
mismatch, an overflow, a marshal policy violation, a cancelled context
|
- `*EncodeError` for an encoding failure, with the key path in `Path`
|
||||||
|
- a plain error for the rest: a non-pointer decode target, a cancelled
|
||||||
|
context, a key that is not valid UTF-8
|
||||||
|
|
||||||
Decode and encode failures are wrapped with the key path or element index using
|
Decode and encode failures carry the key path or element index in the typed
|
||||||
`fmt.Errorf`, so `errors.Is` and `errors.AsType` see through them.
|
wrappers above, so `errors.Is` and `errors.AsType` see through them and the
|
||||||
|
path reads from a field instead of the message text.
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
|
|||||||
+16
-10
@@ -6,10 +6,10 @@ source tree; nothing is aspirational.
|
|||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
interpres is one public library package, one command, and one example. The
|
interpres is one public library package, one command, and one example. The
|
||||||
library implements the whole of TOML 1.0, decoding and encoding, in the
|
library implements the whole of TOML 1.1, decoding and encoding, in the
|
||||||
standard library alone; the command wraps the parser for the toml-test
|
standard library alone; the command wraps the parser and the encoder for the
|
||||||
compliance harness, against which it stands at 185 valid and 371 invalid cases
|
toml-test compliance harness, against which it stands at 214 valid, 467 invalid
|
||||||
with zero failures; the example demonstrates the API.
|
and 214 encoder cases with zero failures; the example demonstrates the API.
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
flowchart TD
|
flowchart TD
|
||||||
@@ -36,14 +36,14 @@ strict validation.
|
|||||||
| Path | Responsibility |
|
| Path | Responsibility |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `.` (package `interpres`) | The whole library. `interpres.go` declares the exported surface (`Parse`, `Unmarshal`, `Marshal`, the `*Context` variants, `Decoder`, `Encoder`, `Marshaler`, `Unmarshaler`, `SyntaxError`, the local date-time types); everything below it is unexported. |
|
| `.` (package `interpres`) | The whole library. `interpres.go` declares the exported surface (`Parse`, `Unmarshal`, `Marshal`, the `*Context` variants, `Decoder`, `Encoder`, `Marshaler`, `Unmarshaler`, `SyntaxError`, the local date-time types); everything below it is unexported. |
|
||||||
| `cmd/interpres-decode` | The toml-test adapter. Reads TOML on stdin, writes tagged JSON on stdout. Owns no parsing logic. |
|
| `cmd/interpres-decode` | The toml-test adapter, both directions. Reads TOML on stdin, writes tagged JSON on stdout; with `-encode` it reads tagged JSON and writes TOML. Owns no parsing logic and no emission logic. |
|
||||||
| `examples/basic` | A runnable tour of the API. Documentation in executable form, not part of the library. |
|
| `examples/basic` | A runnable tour of the API. Documentation in executable form, not part of the library. |
|
||||||
|
|
||||||
Inside the library package, one file owns one concern:
|
Inside the library package, one file owns one concern:
|
||||||
|
|
||||||
| File | Responsibility |
|
| File | Responsibility |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `parser.go` | The recursive-descent parser. Produces the `map[string]any` tree and enforces the structural rules of TOML 1.0 (table redefinitions, dotted keys, arrays of tables). Reports a 1-based line on failure. |
|
| `parser.go` | The recursive-descent parser. Produces the `map[string]any` tree and enforces the structural rules of TOML 1.1 (table redefinitions, dotted keys, arrays of tables, multi-line inline tables). Reports a 1-based line on failure. |
|
||||||
| `number.go` | Strict numeric tokens: integers in the four radixes with `_` separators, and floats including `inf` and `nan`. Rejects leading zeros, misplaced underscores and malformed fractions. |
|
| `number.go` | Strict numeric tokens: integers in the four radixes with `_` separators, and floats including `inf` and `nan`. Rejects leading zeros, misplaced underscores and malformed fractions. |
|
||||||
| `datetime.go` | The three local date-time wrapper types and `parseDateTime`, which classifies a token into the four date-time kinds under the strict TOML grammar. |
|
| `datetime.go` | The three local date-time wrapper types and `parseDateTime`, which classifies a token into the four date-time kinds under the strict TOML grammar. |
|
||||||
| `decode.go` | Maps the parsed tree onto Go values by reflection: struct fields, maps, slices, scalar conversion with overflow checks, `Unmarshaler` dispatch. |
|
| `decode.go` | Maps the parsed tree onto Go values by reflection: struct fields, maps, slices, scalar conversion with overflow checks, `Unmarshaler` dispatch. |
|
||||||
@@ -99,11 +99,17 @@ sequenceDiagram
|
|||||||
`Decode`, `DecodeContext`, `Marshal` and `MarshalContext` call allocates its
|
`Decode`, `DecodeContext`, `Marshal` and `MarshalContext` call allocates its
|
||||||
own unexported worker, so a configured type is safe for concurrent use; the
|
own unexported worker, so a configured type is safe for concurrent use; the
|
||||||
setter methods are not, and must finish before the value is shared.
|
setter methods are not, and must finish before the value is shared.
|
||||||
- The parser is allocated per `ParseContext` call; nothing is cached between
|
- The parser is allocated per `ParseContext` call; the parser itself caches
|
||||||
documents.
|
nothing between documents.
|
||||||
|
- The one piece of shared state is the struct-schema cache in `decode.go`: a
|
||||||
|
`sync.Map` keyed by `reflect.Type`, holding the flattened field layout the
|
||||||
|
decoder and the encoder both consult. A schema is immutable once published,
|
||||||
|
so concurrent callers only race to build an identical value, the same
|
||||||
|
trade-off `encoding/json`'s field cache makes. The cache grows with the
|
||||||
|
number of distinct struct types, never with document size.
|
||||||
- The date-time wrappers are values, not pointers, and are immutable in use.
|
- The date-time wrappers are values, not pointers, and are immutable in use.
|
||||||
- Nothing in the library starts goroutines or holds locks; concurrency safety
|
- Nothing in the library starts goroutines; apart from the schema cache above,
|
||||||
comes from having no shared mutable state.
|
which never mutates a published entry, there is no shared mutable state.
|
||||||
|
|
||||||
## Dependencies
|
## Dependencies
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
# Benchmarking
|
||||||
|
|
||||||
|
How the performance numbers attached to this project are measured, so that a
|
||||||
|
number in a changelog entry or a release note can be reproduced and trusted.
|
||||||
|
|
||||||
|
## The suite
|
||||||
|
|
||||||
|
The benchmarks live in `bench_test.go`, next to the code they measure:
|
||||||
|
|
||||||
|
| Benchmark | What it measures |
|
||||||
|
|---|---|
|
||||||
|
| `BenchmarkParse` | `Parse` over a representative configuration document |
|
||||||
|
| `BenchmarkMarshal` | `Marshal` of the tree `Parse` produced from the same document |
|
||||||
|
| `BenchmarkStrictDecode` | `Decode` into a struct under `DisallowUnknownFields` |
|
||||||
|
| `BenchmarkParseLong` | `Parse` over a generated document with about 2000 array-of-tables entries |
|
||||||
|
|
||||||
|
## Running
|
||||||
|
|
||||||
|
```sh
|
||||||
|
just bench
|
||||||
|
```
|
||||||
|
|
||||||
|
The recipe runs the suite with `-benchmem -count=5`. Every benchmark uses
|
||||||
|
`b.Loop`, so setup runs outside the timed region, and `ReportAllocs` records
|
||||||
|
allocations per operation. The parse and marshal benchmarks set `SetBytes`, so
|
||||||
|
their results read as input bytes per second.
|
||||||
|
|
||||||
|
## Method
|
||||||
|
|
||||||
|
- An idle machine only: a loaded box times whatever else is running, and the
|
||||||
|
fastest sample can land on the wrong function.
|
||||||
|
- An A/B comparison runs both variants inside one process, in one binary;
|
||||||
|
separate processes of identical binaries differ by more than the effect
|
||||||
|
being measured.
|
||||||
|
- The five counts are compared through their medians, allocations and bytes
|
||||||
|
per operation alongside the times. Differences within 1 to 2 percent are
|
||||||
|
noise; only a difference beyond that is a result.
|
||||||
|
- When timing is hopeless, the allocation and byte counts are the result.
|
||||||
|
|
||||||
|
## Reports
|
||||||
|
|
||||||
|
The repository stores no benchmark reports. A performance claim in
|
||||||
|
`CHANGELOG.md` is measured with the method above on the change that makes it,
|
||||||
|
and the number travels with the claim.
|
||||||
+75
-13
@@ -1,26 +1,53 @@
|
|||||||
# Command line
|
# Command line
|
||||||
|
|
||||||
The reference below is taken from the program itself. `interpres-decode` is the
|
The reference below is taken from the program itself. `interpres-decode` is
|
||||||
toml-test harness adapter, not a general-purpose tool: it takes no flags and no
|
the toml-test harness adapter in both directions, decoding TOML into tagged
|
||||||
arguments, reads one TOML document from stdin, and writes the toml-test
|
JSON and encoding tagged JSON back into TOML, and it also validates documents.
|
||||||
tagged-JSON form to stdout.
|
Install it with Go itself, no release assets involved:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go install sourcedock.dev/petrbalvin/interpres/v2/cmd/interpres-decode@latest
|
||||||
|
```
|
||||||
|
|
||||||
## Synopsis
|
## Synopsis
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
interpres-decode < document.toml
|
interpres-decode [flags]
|
||||||
|
interpres-decode -encode
|
||||||
|
interpres-decode -validate [file ...]
|
||||||
```
|
```
|
||||||
|
|
||||||
Build it with `just build`, which compiles it into `bin/interpres-decode`, or
|
Without `-validate` or `-encode` the program is the decoding half of the
|
||||||
run it straight from the module directory with `just run`.
|
toml-test adapter: it takes no arguments, reads one TOML document from stdin,
|
||||||
|
and writes the toml-test tagged-JSON form to stdout. Build it locally with
|
||||||
|
`just build`, which compiles it into `bin/interpres-decode`, or run it
|
||||||
|
straight from the module directory with `just run`.
|
||||||
|
|
||||||
|
With `-encode` the direction is reversed: the program reads a tagged-JSON
|
||||||
|
description from stdin and writes the TOML document it describes to stdout,
|
||||||
|
which is the shape toml-test expects of an encoder command. It takes no
|
||||||
|
arguments either, and `-validate` and `-encode` cannot be combined.
|
||||||
|
|
||||||
|
With `-validate` the program parses each named file instead, or stdin when no
|
||||||
|
file is named, and prints one line per invalid document to stderr. It is
|
||||||
|
quiet on valid documents, which is the shape a CI step wants. The `-` name
|
||||||
|
means stdin.
|
||||||
|
|
||||||
|
## Flags
|
||||||
|
|
||||||
|
| Flag | Effect |
|
||||||
|
|---|---|
|
||||||
|
| `-validate` | validate the documents instead of emitting tagged JSON |
|
||||||
|
| `-encode` | read tagged JSON from stdin and write TOML instead |
|
||||||
|
| `-h` | print the usage |
|
||||||
|
|
||||||
## Exit codes
|
## Exit codes
|
||||||
|
|
||||||
| Code | Meaning |
|
| Code | Meaning |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `0` | the document parsed, tagged JSON written to stdout |
|
| `0` | adapter: the document parsed and the tagged JSON was written; encode: the TOML was written; validate: every document parsed |
|
||||||
| `1` | parse error, the document is malformed; the message goes to stderr |
|
| `1` | adapter: parse error; validate: at least one document is invalid |
|
||||||
| `2` | reading stdin failed, or a value has no tagged representation |
|
| `2` | a usage error, a read failure, malformed tagged JSON, or a value with no TOML representation |
|
||||||
|
|
||||||
## Wire format
|
## Wire format
|
||||||
|
|
||||||
@@ -47,6 +74,14 @@ wrapped in an object with a `type` and a `value`:
|
|||||||
| local date | `date-local` | `1979-05-27` |
|
| local date | `date-local` | `1979-05-27` |
|
||||||
| local time | `time-local` | `07:32:00.999999` |
|
| local time | `time-local` | `07:32:00.999999` |
|
||||||
|
|
||||||
|
The `-encode` mode reads exactly this form back. Two properties of it are
|
||||||
|
worth knowing. A float whose value has no fraction and no exponent is written
|
||||||
|
as a bare integer string, `{"type": "float", "value": "1"}`, so there the tag
|
||||||
|
decides the type and not the literal. And the form cannot tell an array of
|
||||||
|
tables from a value array of inline tables, so the adapter writes the header
|
||||||
|
form, `[[a]]`, for an array whose every element is a JSON object; a mixed
|
||||||
|
array keeps the value form.
|
||||||
|
|
||||||
## Examples
|
## Examples
|
||||||
|
|
||||||
Echo a small document through the adapter:
|
Echo a small document through the adapter:
|
||||||
@@ -59,13 +94,40 @@ port = 9090
|
|||||||
' | ./bin/interpres-decode
|
' | ./bin/interpres-decode
|
||||||
```
|
```
|
||||||
|
|
||||||
The output is the equivalent value tree as one JSON object. Run the official
|
The output is the equivalent value tree as one JSON object. Turn a description
|
||||||
compliance suite against the binary:
|
back into TOML with `-encode`:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
echo '{"title": {"type": "string", "value": "hello"}}' | ./bin/interpres-decode -encode
|
||||||
|
```
|
||||||
|
|
||||||
|
```toml
|
||||||
|
title = "hello"
|
||||||
|
```
|
||||||
|
|
||||||
|
Validate the TOML files of another repository in CI:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
interpres-decode -validate config.toml deploy/example.toml
|
||||||
|
```
|
||||||
|
|
||||||
|
An invalid document reports the file and the library's line number:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
$ interpres-decode -validate bad.toml
|
||||||
|
bad.toml: interpres: line 1: expected a value
|
||||||
|
$ echo $?
|
||||||
|
1
|
||||||
|
```
|
||||||
|
|
||||||
|
Run the official compliance suite against the adapter:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
just toml-test
|
just toml-test
|
||||||
```
|
```
|
||||||
|
|
||||||
That recipe needs the `toml-test` binary on `PATH`, installed with
|
That recipe needs the `toml-test` binary on `PATH`, installed with
|
||||||
`go install github.com/toml-lang/toml-test/cmd/toml-test@v1.6.0`. The full
|
`go install github.com/toml-lang/toml-test/v2/cmd/toml-test@v2.2.0`. It runs
|
||||||
|
the suite in both directions: the decoder against the valid and invalid
|
||||||
|
corpora, and the encoder against the tagged JSON of the valid one. The full
|
||||||
reference for the library itself is [API.md](API.md).
|
reference for the library itself is [API.md](API.md).
|
||||||
|
|||||||
+12
-9
@@ -4,10 +4,10 @@ How to work on interpres.
|
|||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
- Go 1.27.0, the version the `go` directive in `go.mod` declares.
|
- Go 1.27.1, the version the `go` directive in `go.mod` declares.
|
||||||
- [just](https://github.com/casey/just) for the recipes.
|
- [just](https://github.com/casey/just) for the recipes.
|
||||||
- The `toml-test` binary on `PATH` for the compliance recipe, installed with
|
- The `toml-test` binary on `PATH` for the compliance recipe, installed with
|
||||||
`go install github.com/toml-lang/toml-test/cmd/toml-test@v1.6.0`.
|
`go install github.com/toml-lang/toml-test/v2/cmd/toml-test@v2.2.0`.
|
||||||
|
|
||||||
The module has no third-party dependencies, so there is nothing else to fetch.
|
The module has no third-party dependencies, so there is nothing else to fetch.
|
||||||
|
|
||||||
@@ -41,7 +41,7 @@ prints the same list.
|
|||||||
| `just run` | `go run ./cmd/interpres-decode`, reads TOML from stdin |
|
| `just run` | `go run ./cmd/interpres-decode`, reads TOML from stdin |
|
||||||
| `just dev` | the same run, for iterating |
|
| `just dev` | the same run, for iterating |
|
||||||
| `just example` | `go run ./examples/basic`, the usage tour |
|
| `just example` | `go run ./examples/basic`, the usage tour |
|
||||||
| `just toml-test` | builds the adapter and runs the official toml-test compliance suite against it |
|
| `just toml-test` | builds the adapter and runs the official toml-test compliance suite against it, decoder and encoder |
|
||||||
| `just coverage-html` | `just test`, then `go tool cover -html` into `coverage.html` |
|
| `just coverage-html` | `just test`, then `go tool cover -html` into `coverage.html` |
|
||||||
| `just install` | builds, then copies the binary into `~/.local/bin` (`BINDIR` overrides) |
|
| `just install` | builds, then copies the binary into `~/.local/bin` (`BINDIR` overrides) |
|
||||||
| `just uninstall` | removes the installed binary |
|
| `just uninstall` | removes the installed binary |
|
||||||
@@ -77,7 +77,9 @@ just bench
|
|||||||
```
|
```
|
||||||
|
|
||||||
Benchmark on an idle machine, and compare only runs made in one process against
|
Benchmark on an idle machine, and compare only runs made in one process against
|
||||||
each other. The recipe sweeps `./...` five times with `-benchmem`.
|
each other. The recipe sweeps `./...` five times with `-benchmem`. The binding
|
||||||
|
measurement method, and what counts as a result, is in
|
||||||
|
[docs/BENCHMARKING.md](BENCHMARKING.md).
|
||||||
|
|
||||||
## Debugging the build
|
## Debugging the build
|
||||||
|
|
||||||
@@ -97,12 +99,13 @@ pipeline.
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `test.yml` | push or pull request to `development` | format check, vet, modernisation, build, the test suite with the 80 percent coverage floor, then the toml-test compliance suite |
|
| `test.yml` | push or pull request to `development` | format check, vet, modernisation, build, the test suite with the 80 percent coverage floor, then the toml-test compliance suite |
|
||||||
| `race.yml` | `workflow_dispatch`, by hand | the suite under the race detector; the same race gate `just gates` runs locally |
|
| `race.yml` | `workflow_dispatch`, by hand | the suite under the race detector; the same race gate `just gates` runs locally |
|
||||||
| `release.yml` | a `v*` tag | the same gates plus the race detector, then the Gitea release from the CHANGELOG section |
|
| `release.yml` | a `v*` tag | tag validation, then format, vet, modernisation, build and the test suite with the coverage floor, then the Gitea release from the CHANGELOG section. No race detector: race never runs on a push path, and the local `just gates` raced the tree before the tag was cut |
|
||||||
|
|
||||||
## Releases
|
## Releases
|
||||||
|
|
||||||
Releases are cut by merging `development` into `main` and tagging `vX.Y.Z`. The
|
Releases are cut by merging `development` into `main` and tagging `vX.Y.Z`. The
|
||||||
tag drives the release workflow: it validates the tag, runs the full gate set
|
tag drives the release workflow: it validates the tag, runs the static gates
|
||||||
including the race detector, extracts the matching `## [X.Y.Z]` section from
|
and the test suite with the coverage floor, extracts the matching `## [X.Y.Z]`
|
||||||
`CHANGELOG.md`, and publishes the release with that section as its body. A
|
section from `CHANGELOG.md`, and publishes the release with that section as its
|
||||||
library ships no binaries, so the release carries the notes and nothing else.
|
body. A library ships no binaries, so the release carries the notes and nothing
|
||||||
|
else.
|
||||||
|
|||||||
@@ -6,7 +6,10 @@ package interpres
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"encoding"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"maps"
|
||||||
"math"
|
"math"
|
||||||
"reflect"
|
"reflect"
|
||||||
"slices"
|
"slices"
|
||||||
@@ -21,20 +24,54 @@ var (
|
|||||||
localDateType = reflect.TypeFor[LocalDate]()
|
localDateType = reflect.TypeFor[LocalDate]()
|
||||||
localTimeType = reflect.TypeFor[LocalTime]()
|
localTimeType = reflect.TypeFor[LocalTime]()
|
||||||
timeGoType = reflect.TypeFor[time.Time]()
|
timeGoType = reflect.TypeFor[time.Time]()
|
||||||
|
durationType = reflect.TypeFor[time.Duration]()
|
||||||
|
textMarshalerType = reflect.TypeFor[encoding.TextMarshaler]()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// inlineLimit is the column past which an inline table is written across
|
||||||
|
// lines. TOML 1.1 lets an inline table carry newlines and a trailing comma, so
|
||||||
|
// a long one stays readable instead of running off the line.
|
||||||
|
const inlineLimit = 100
|
||||||
|
|
||||||
|
// noInlineBreak is the limit a measuring encoder carries, high enough that the
|
||||||
|
// form it renders is always the single-line one.
|
||||||
|
const noInlineBreak = 1 << 30
|
||||||
|
|
||||||
// encoder produces a TOML document from a Go value via a small intermediate
|
// encoder produces a TOML document from a Go value via a small intermediate
|
||||||
// representation that preserves the order in which fields were declared.
|
// representation that preserves the order in which fields were declared.
|
||||||
type encoder struct {
|
type encoder struct {
|
||||||
buf bytes.Buffer
|
buf bytes.Buffer
|
||||||
ctx context.Context
|
ctx context.Context
|
||||||
opts Encoder
|
opts Encoder
|
||||||
|
|
||||||
|
// inlineDepth is the nesting level inside inline tables, which decides
|
||||||
|
// their indentation.
|
||||||
|
inlineDepth int
|
||||||
|
|
||||||
|
// limit is the column at which an inline table is broken; only a
|
||||||
|
// measuring encoder raises it.
|
||||||
|
limit int
|
||||||
}
|
}
|
||||||
|
|
||||||
func newEncoder() *encoder { return &encoder{} }
|
func newEncoder() *encoder { return &encoder{limit: inlineLimit} }
|
||||||
|
|
||||||
|
// flat returns an encoder that measures a value by rendering it on one line,
|
||||||
|
// so a caller can decide which form to write before writing it.
|
||||||
|
func (e *encoder) flat() *encoder {
|
||||||
|
return &encoder{ctx: e.ctx, opts: e.opts, limit: noInlineBreak}
|
||||||
|
}
|
||||||
|
|
||||||
func (e *encoder) bytes() []byte { return e.buf.Bytes() }
|
func (e *encoder) bytes() []byte { return e.buf.Bytes() }
|
||||||
|
|
||||||
|
// column reports how many bytes the current line already holds, so a form can
|
||||||
|
// be measured against the limit before it is written.
|
||||||
|
func (e *encoder) column() int {
|
||||||
|
if i := bytes.LastIndexByte(e.buf.Bytes(), '\n'); i >= 0 {
|
||||||
|
return e.buf.Len() - i - 1
|
||||||
|
}
|
||||||
|
return e.buf.Len()
|
||||||
|
}
|
||||||
|
|
||||||
func (e *encoder) checkCtx() error {
|
func (e *encoder) checkCtx() error {
|
||||||
if e.ctx == nil {
|
if e.ctx == nil {
|
||||||
return nil
|
return nil
|
||||||
@@ -142,6 +179,16 @@ func (d *tomlDoc) partitionedEntries() (scalars []entry, tables []entry, arrays
|
|||||||
// --- reflection walk: struct ---------------------------------------------
|
// --- reflection walk: struct ---------------------------------------------
|
||||||
|
|
||||||
func buildStructDoc(v reflect.Value, doc *tomlDoc, ctx string) error {
|
func buildStructDoc(v reflect.Value, doc *tomlDoc, ctx string) error {
|
||||||
|
return walkStructDoc(v, doc, ctx, nil, cachedStructSchema(v.Type()))
|
||||||
|
}
|
||||||
|
|
||||||
|
// walkStructDoc emits the fields of v into doc. prefix is v's index path from
|
||||||
|
// the struct whose schema resolves key conflicts; an embedded struct is walked
|
||||||
|
// with the outer schema and a longer prefix, so every leaf competes under the
|
||||||
|
// decoder's rule: the shallower field wins, the later declaration at equal
|
||||||
|
// depth. A field another field shadows is skipped, because emitting both
|
||||||
|
// would duplicate the key and the output would not re-parse.
|
||||||
|
func walkStructDoc(v reflect.Value, doc *tomlDoc, ctx string, prefix []int, schema structSchema) error {
|
||||||
t := v.Type()
|
t := v.Type()
|
||||||
for i := range t.NumField() {
|
for i := range t.NumField() {
|
||||||
if i%ctxCheckInterval == 0 {
|
if i%ctxCheckInterval == 0 {
|
||||||
@@ -153,6 +200,7 @@ func buildStructDoc(v reflect.Value, doc *tomlDoc, ctx string) error {
|
|||||||
if f.PkgPath != "" {
|
if f.PkgPath != "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
path := append(append([]int{}, prefix...), i)
|
||||||
if f.Anonymous {
|
if f.Anonymous {
|
||||||
tag, _ := f.Tag.Lookup("toml")
|
tag, _ := f.Tag.Lookup("toml")
|
||||||
if tag == "-" {
|
if tag == "-" {
|
||||||
@@ -167,12 +215,15 @@ func buildStructDoc(v reflect.Value, doc *tomlDoc, ctx string) error {
|
|||||||
case reflect.Struct:
|
case reflect.Struct:
|
||||||
if isScalarStruct(fv.Type()) {
|
if isScalarStruct(fv.Type()) {
|
||||||
name := strings.ToLower(f.Name)
|
name := strings.ToLower(f.Name)
|
||||||
|
if !schema.ownsKey(name, path) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
if err := doc.appendScalar(name, fv.Interface(), ctx); err != nil {
|
if err := doc.appendScalar(name, fv.Interface(), ctx); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if err := buildStructDoc(fv, doc, ctx); err != nil {
|
if err := walkStructDoc(fv, doc, ctx, path, schema); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
@@ -188,6 +239,12 @@ func buildStructDoc(v reflect.Value, doc *tomlDoc, ctx string) error {
|
|||||||
if name == "-" {
|
if name == "-" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if !schema.ownsKey(strings.ToLower(name), path) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if fieldOmitted(f, v.Field(i)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
if err := addField(doc, name, v.Field(i), ctx); err != nil {
|
if err := addField(doc, name, v.Field(i), ctx); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -195,6 +252,49 @@ func buildStructDoc(v reflect.Value, doc *tomlDoc, ctx string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// isZeroer mirrors encoding/json's omitzero: a type that knows its own zero
|
||||||
|
// state decides through that method before reflection is consulted.
|
||||||
|
type isZeroer interface{ IsZero() bool }
|
||||||
|
|
||||||
|
// fieldOmitted reports whether the field's tag options drop it from the
|
||||||
|
// output: omitzero skips the zero value of the field's type, omitempty skips
|
||||||
|
// an empty collection (slice, array, or map). The decoder ignores both
|
||||||
|
// options; they shape emission only.
|
||||||
|
func fieldOmitted(f reflect.StructField, v reflect.Value) bool {
|
||||||
|
tag, ok := f.Tag.Lookup("toml")
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
_, opts, _ := strings.Cut(tag, ",")
|
||||||
|
for opts != "" {
|
||||||
|
var opt string
|
||||||
|
opt, opts, _ = strings.Cut(opts, ",")
|
||||||
|
switch opt {
|
||||||
|
case "omitzero":
|
||||||
|
if isZeroValue(v) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
case "omitempty":
|
||||||
|
switch v.Kind() {
|
||||||
|
case reflect.Slice, reflect.Array, reflect.Map:
|
||||||
|
if v.Len() == 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func isZeroValue(v reflect.Value) bool {
|
||||||
|
if v.CanInterface() {
|
||||||
|
if z, ok := v.Interface().(isZeroer); ok {
|
||||||
|
return z.IsZero()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return v.IsZero()
|
||||||
|
}
|
||||||
|
|
||||||
// fieldName returns the TOML key for a struct field, honouring the `toml`
|
// fieldName returns the TOML key for a struct field, honouring the `toml`
|
||||||
// tag (name or `-`) and falling back to a lower-cased field name.
|
// tag (name or `-`) and falling back to a lower-cased field name.
|
||||||
func fieldName(f reflect.StructField) string {
|
func fieldName(f reflect.StructField) string {
|
||||||
@@ -235,16 +335,34 @@ func buildMapDoc(v reflect.Value, doc *tomlDoc, ctx string) error {
|
|||||||
|
|
||||||
// --- reflection walk: field dispatch -------------------------------------
|
// --- reflection walk: field dispatch -------------------------------------
|
||||||
|
|
||||||
|
// errNilMarshalTOML reports a Marshaler whose method returned a nil value
|
||||||
|
// with no error. nil has no TOML representation, so dropping the field
|
||||||
|
// silently or panicking on the invalid reflect.Value would both hide the
|
||||||
|
// contract violation.
|
||||||
|
var errNilMarshalTOML = errors.New("MarshalTOML returned a nil value")
|
||||||
|
|
||||||
func addField(doc *tomlDoc, name string, v reflect.Value, ctx string) error {
|
func addField(doc *tomlDoc, name string, v reflect.Value, ctx string) error {
|
||||||
if v.CanInterface() {
|
if v.CanInterface() {
|
||||||
if m, ok := v.Interface().(Marshaler); ok {
|
if m, ok := v.Interface().(Marshaler); ok {
|
||||||
mv, err := m.MarshalTOML()
|
mv, err := m.MarshalTOML()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("interpres: %s.%s: %w", ctx, name, err)
|
return &EncodeError{Path: joinKey(ctx, name), Err: err}
|
||||||
|
}
|
||||||
|
if mv == nil {
|
||||||
|
return &EncodeError{Path: joinKey(ctx, name), Err: errNilMarshalTOML}
|
||||||
}
|
}
|
||||||
v = reflect.ValueOf(mv)
|
v = reflect.ValueOf(mv)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// A type that renders itself as text becomes a TOML string, whether it is
|
||||||
|
// a scalar kind or a struct.
|
||||||
|
s, isText, err := textValue(v)
|
||||||
|
if err != nil {
|
||||||
|
return &EncodeError{Path: joinKey(ctx, name), Err: err}
|
||||||
|
}
|
||||||
|
if isText {
|
||||||
|
return doc.appendScalar(name, s, ctx)
|
||||||
|
}
|
||||||
v = followPtr(v)
|
v = followPtr(v)
|
||||||
if !v.IsValid() {
|
if !v.IsValid() {
|
||||||
return nil
|
return nil
|
||||||
@@ -313,7 +431,24 @@ func addArrayValue(doc *tomlDoc, name string, v reflect.Value, ctx string) error
|
|||||||
return doc.appendScalar(name, []any{}, ctx)
|
return doc.appendScalar(name, []any{}, ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
if isTableElementValue(v.Index(0)) {
|
// An array keeps the [[header]] form only when every element is a table.
|
||||||
|
// TOML lets one array mix tables with scalars, and that mix renders as a
|
||||||
|
// value array with the table elements written inline.
|
||||||
|
allTables := true
|
||||||
|
for i := range n {
|
||||||
|
if !isTableElementValue(v.Index(i)) {
|
||||||
|
allTables = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// A []any of tables is what Parse produces for a value array of inline
|
||||||
|
// tables; the [[header]] form would re-parse as []map[string]any and so
|
||||||
|
// change the value's Go type across a round-trip. The header form is
|
||||||
|
// reserved for typed table slices.
|
||||||
|
if v.Type().Elem().Kind() == reflect.Interface {
|
||||||
|
allTables = false
|
||||||
|
}
|
||||||
|
if allTables {
|
||||||
subs := make([]*tomlDoc, n)
|
subs := make([]*tomlDoc, n)
|
||||||
for i := range n {
|
for i := range n {
|
||||||
if i%ctxCheckInterval == 0 {
|
if i%ctxCheckInterval == 0 {
|
||||||
@@ -323,13 +458,13 @@ func addArrayValue(doc *tomlDoc, name string, v reflect.Value, ctx string) error
|
|||||||
}
|
}
|
||||||
ev := followPtr(v.Index(i))
|
ev := followPtr(v.Index(i))
|
||||||
if !ev.IsValid() {
|
if !ev.IsValid() {
|
||||||
return fmt.Errorf("interpres: %s.%s[%d]: nil element", ctx, name, i)
|
return &EncodeError{Path: fmt.Sprintf("%s[%d]", joinKey(ctx, name), i), Err: errors.New("nil element")}
|
||||||
}
|
}
|
||||||
sub := &tomlDoc{ctx: doc.ctx, opts: doc.opts}
|
sub := &tomlDoc{ctx: doc.ctx, opts: doc.opts}
|
||||||
switch ev.Kind() {
|
switch ev.Kind() {
|
||||||
case reflect.Struct:
|
case reflect.Struct:
|
||||||
if isScalarStruct(ev.Type()) {
|
if isScalarStruct(ev.Type()) {
|
||||||
return fmt.Errorf("interpres: %s.%s[%d]: heterogeneous array contains scalar", ctx, name, i)
|
return &EncodeError{Path: fmt.Sprintf("%s[%d]", joinKey(ctx, name), i), Err: errors.New("heterogeneous array contains scalar")}
|
||||||
}
|
}
|
||||||
if err := buildStructDoc(ev, sub, joinKey(ctx, fmt.Sprintf("%s[%d]", name, i))); err != nil {
|
if err := buildStructDoc(ev, sub, joinKey(ctx, fmt.Sprintf("%s[%d]", name, i))); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -339,7 +474,7 @@ func addArrayValue(doc *tomlDoc, name string, v reflect.Value, ctx string) error
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
return fmt.Errorf("interpres: %s.%s: heterogeneous array, expected table", ctx, name)
|
return &EncodeError{Path: joinKey(ctx, name), Err: errors.New("heterogeneous array, expected table")}
|
||||||
}
|
}
|
||||||
subs[i] = sub
|
subs[i] = sub
|
||||||
}
|
}
|
||||||
@@ -347,7 +482,8 @@ func addArrayValue(doc *tomlDoc, name string, v reflect.Value, ctx string) error
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Regular array of scalars.
|
// Value array. Table elements normalise to map[string]any and the emitter
|
||||||
|
// writes them as inline tables.
|
||||||
items := make([]any, n)
|
items := make([]any, n)
|
||||||
for i := range n {
|
for i := range n {
|
||||||
if i%ctxCheckInterval == 0 {
|
if i%ctxCheckInterval == 0 {
|
||||||
@@ -357,13 +493,16 @@ func addArrayValue(doc *tomlDoc, name string, v reflect.Value, ctx string) error
|
|||||||
}
|
}
|
||||||
ev := followPtr(v.Index(i))
|
ev := followPtr(v.Index(i))
|
||||||
if !ev.IsValid() {
|
if !ev.IsValid() {
|
||||||
return fmt.Errorf("interpres: %s.%s[%d]: nil element", ctx, name, i)
|
return &EncodeError{Path: fmt.Sprintf("%s[%d]", joinKey(ctx, name), i), Err: errors.New("nil element")}
|
||||||
}
|
}
|
||||||
if ev.CanInterface() {
|
if ev.CanInterface() {
|
||||||
if m, ok := ev.Interface().(Marshaler); ok {
|
if m, ok := ev.Interface().(Marshaler); ok {
|
||||||
mv, err := m.MarshalTOML()
|
mv, err := m.MarshalTOML()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("interpres: %s.%s[%d]: %w", ctx, name, i, err)
|
return &EncodeError{Path: fmt.Sprintf("%s[%d]", joinKey(ctx, name), i), Err: err}
|
||||||
|
}
|
||||||
|
if mv == nil {
|
||||||
|
return &EncodeError{Path: fmt.Sprintf("%s[%d]", joinKey(ctx, name), i), Err: errNilMarshalTOML}
|
||||||
}
|
}
|
||||||
ev = reflect.ValueOf(mv)
|
ev = reflect.ValueOf(mv)
|
||||||
ev = followPtr(ev)
|
ev = followPtr(ev)
|
||||||
@@ -371,7 +510,7 @@ func addArrayValue(doc *tomlDoc, name string, v reflect.Value, ctx string) error
|
|||||||
}
|
}
|
||||||
val, err := normaliseValue(ev)
|
val, err := normaliseValue(ev)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("interpres: %s.%s[%d]: %w", ctx, name, i, err)
|
return &EncodeError{Path: fmt.Sprintf("%s[%d]", joinKey(ctx, name), i), Err: err}
|
||||||
}
|
}
|
||||||
items[i] = val
|
items[i] = val
|
||||||
}
|
}
|
||||||
@@ -382,11 +521,43 @@ func addArrayValue(doc *tomlDoc, name string, v reflect.Value, ctx string) error
|
|||||||
// nested-array representations the emitter understands. Slices and arrays are
|
// nested-array representations the emitter understands. Slices and arrays are
|
||||||
// recursively normalised so that nested arrays (e.g. [][]int) work.
|
// recursively normalised so that nested arrays (e.g. [][]int) work.
|
||||||
func normaliseValue(v reflect.Value) (any, error) {
|
func normaliseValue(v reflect.Value) (any, error) {
|
||||||
|
// Map and slice elements arrive wrapped in interface{}; look through them.
|
||||||
|
for v.Kind() == reflect.Interface && !v.IsNil() {
|
||||||
|
v = v.Elem()
|
||||||
|
}
|
||||||
|
if v.Kind() == reflect.Interface {
|
||||||
|
return nil, fmt.Errorf("cannot encode nil value")
|
||||||
|
}
|
||||||
if v.CanInterface() {
|
if v.CanInterface() {
|
||||||
if m, ok := v.Interface().(Marshaler); ok {
|
if m, ok := v.Interface().(Marshaler); ok {
|
||||||
return m.MarshalTOML()
|
mv, err := m.MarshalTOML()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if mv == nil {
|
||||||
|
return nil, errNilMarshalTOML
|
||||||
|
}
|
||||||
|
return mv, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// The datetime structs are TOML scalars; the emitter renders each of them.
|
||||||
|
if t := v.Type(); t == timeGoType || isLocalDateType(t) {
|
||||||
|
return v.Interface(), nil
|
||||||
|
}
|
||||||
|
// TOML has no duration type, so a duration goes out in its canonical Go
|
||||||
|
// form, the shape it comes back in.
|
||||||
|
if v.Type() == durationType {
|
||||||
|
return time.Duration(v.Int()).String(), nil
|
||||||
|
}
|
||||||
|
// A type that renders itself as text becomes a TOML string, scalar kinds
|
||||||
|
// and structs alike.
|
||||||
|
s, isText, err := textValue(v)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if isText {
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
switch v.Kind() {
|
switch v.Kind() {
|
||||||
case reflect.String:
|
case reflect.String:
|
||||||
return v.String(), nil
|
return v.String(), nil
|
||||||
@@ -402,6 +573,21 @@ func normaliseValue(v reflect.Value) (any, error) {
|
|||||||
return int64(u), nil
|
return int64(u), nil
|
||||||
case reflect.Float32, reflect.Float64:
|
case reflect.Float32, reflect.Float64:
|
||||||
return v.Float(), nil
|
return v.Float(), nil
|
||||||
|
case reflect.Map:
|
||||||
|
// A table nested in a value array has no header form, so it renders
|
||||||
|
// inline; the keys normalise to strings for the emitter.
|
||||||
|
if v.Type().Key().Kind() != reflect.String {
|
||||||
|
return nil, fmt.Errorf("map key must be string, got %s", v.Type().Key())
|
||||||
|
}
|
||||||
|
out := make(map[string]any, v.Len())
|
||||||
|
for _, k := range v.MapKeys() {
|
||||||
|
val, err := normaliseValue(v.MapIndex(k))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("[%s]: %w", k.String(), err)
|
||||||
|
}
|
||||||
|
out[k.String()] = val
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
case reflect.Slice, reflect.Array:
|
case reflect.Slice, reflect.Array:
|
||||||
items := make([]any, v.Len())
|
items := make([]any, v.Len())
|
||||||
for i := range v.Len() {
|
for i := range v.Len() {
|
||||||
@@ -445,10 +631,75 @@ func isLocalDateType(t reflect.Type) bool {
|
|||||||
return t == localDateTimeType || t == localDateType || t == localTimeType
|
return t == localDateTimeType || t == localDateType || t == localTimeType
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// isDateTimeType reports whether t is one of the four TOML date-time types,
|
||||||
|
// which the encoder emits as bare atoms. Pointers are looked through. The types
|
||||||
|
// carry time.Time's text methods through an embedded field, and the atom form
|
||||||
|
// takes precedence over them.
|
||||||
|
func isDateTimeType(t reflect.Type) bool {
|
||||||
|
for t.Kind() == reflect.Pointer {
|
||||||
|
t = t.Elem()
|
||||||
|
}
|
||||||
|
return t == timeGoType || isLocalDateType(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
// isTextMarshalerType reports whether t or *t implements
|
||||||
|
// encoding.TextMarshaler. An array of such values stays a value array, because
|
||||||
|
// each element's TOML form is a string.
|
||||||
|
func isTextMarshalerType(t reflect.Type) bool {
|
||||||
|
if isDateTimeType(t) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return t.Implements(textMarshalerType) || reflect.PointerTo(t).Implements(textMarshalerType)
|
||||||
|
}
|
||||||
|
|
||||||
|
// textValue returns the string a value renders itself as through
|
||||||
|
// encoding.TextMarshaler. The date-time types are excluded, because their
|
||||||
|
// embedded time.Time would answer with an RFC 3339 string where the TOML form
|
||||||
|
// is a bare timestamp. A nil pointer offers no text and is left to the ordinary
|
||||||
|
// nil handling, which omits the field.
|
||||||
|
func textValue(v reflect.Value) (string, bool, error) {
|
||||||
|
for v.Kind() == reflect.Interface && !v.IsNil() {
|
||||||
|
v = v.Elem()
|
||||||
|
}
|
||||||
|
if !v.IsValid() || isDateTimeType(v.Type()) {
|
||||||
|
return "", false, nil
|
||||||
|
}
|
||||||
|
if v.Kind() == reflect.Pointer && v.IsNil() {
|
||||||
|
return "", false, nil
|
||||||
|
}
|
||||||
|
m, ok := textMarshalerOf(v)
|
||||||
|
if !ok {
|
||||||
|
return "", false, nil
|
||||||
|
}
|
||||||
|
b, err := m.MarshalText()
|
||||||
|
if err != nil {
|
||||||
|
return "", true, err
|
||||||
|
}
|
||||||
|
return string(b), true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// textMarshalerOf finds the encoding.TextMarshaler for v: on the value itself,
|
||||||
|
// or on its address, so a pointer-receiver MarshalText is found on an
|
||||||
|
// addressable struct field.
|
||||||
|
func textMarshalerOf(v reflect.Value) (encoding.TextMarshaler, bool) {
|
||||||
|
if !v.CanInterface() {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
if m, ok := v.Interface().(encoding.TextMarshaler); ok {
|
||||||
|
return m, true
|
||||||
|
}
|
||||||
|
if v.CanAddr() {
|
||||||
|
if m, ok := v.Addr().Interface().(encoding.TextMarshaler); ok {
|
||||||
|
return m, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
func isTableElementType(t reflect.Type) bool {
|
func isTableElementType(t reflect.Type) bool {
|
||||||
switch t.Kind() {
|
switch t.Kind() {
|
||||||
case reflect.Struct:
|
case reflect.Struct:
|
||||||
return !isScalarStruct(t)
|
return !isScalarStruct(t) && !isTextMarshalerType(t)
|
||||||
case reflect.Map:
|
case reflect.Map:
|
||||||
return t.Key().Kind() == reflect.String
|
return t.Key().Kind() == reflect.String
|
||||||
}
|
}
|
||||||
@@ -490,11 +741,26 @@ func (e *encoder) emitDoc(doc *tomlDoc, prefix []string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// An inlined sub-table is a value line, so it has to precede every
|
||||||
|
// header of this document: a line written after a [header] would be
|
||||||
|
// read back as part of that table.
|
||||||
|
headers := make([]entry, 0, len(tables))
|
||||||
for _, t := range tables {
|
for _, t := range tables {
|
||||||
|
inlined, err := e.writeInlineSubTableIfSmall(t.key, t.doc)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !inlined {
|
||||||
|
headers = append(headers, t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, t := range headers {
|
||||||
path := append(append([]string{}, prefix...), t.key)
|
path := append(append([]string{}, prefix...), t.key)
|
||||||
e.writeBlankLine()
|
e.writeBlankLine()
|
||||||
e.buf.WriteByte('[')
|
e.buf.WriteByte('[')
|
||||||
writeKeyPath(&e.buf, path)
|
if err := e.writeKeyPath(path); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
e.buf.WriteString("]\n")
|
e.buf.WriteString("]\n")
|
||||||
if err := e.emitDoc(t.doc, path); err != nil {
|
if err := e.emitDoc(t.doc, path); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -505,7 +771,9 @@ func (e *encoder) emitDoc(doc *tomlDoc, prefix []string) error {
|
|||||||
for _, sub := range a.docs {
|
for _, sub := range a.docs {
|
||||||
e.writeBlankLine()
|
e.writeBlankLine()
|
||||||
e.buf.WriteString("[[")
|
e.buf.WriteString("[[")
|
||||||
writeKeyPath(&e.buf, path)
|
if err := e.writeKeyPath(path); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
e.buf.WriteString("]]\n")
|
e.buf.WriteString("]]\n")
|
||||||
if err := e.emitDoc(sub, path); err != nil {
|
if err := e.emitDoc(sub, path); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -527,10 +795,19 @@ func (e *encoder) emitDoc(doc *tomlDoc, prefix []string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
case entryTable:
|
case entryTable:
|
||||||
|
inlined, err := e.writeInlineSubTableIfSmall(ent.key, ent.doc)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if inlined {
|
||||||
|
continue
|
||||||
|
}
|
||||||
path := append(append([]string{}, prefix...), ent.key)
|
path := append(append([]string{}, prefix...), ent.key)
|
||||||
e.writeBlankLine()
|
e.writeBlankLine()
|
||||||
e.buf.WriteByte('[')
|
e.buf.WriteByte('[')
|
||||||
writeKeyPath(&e.buf, path)
|
if err := e.writeKeyPath(path); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
e.buf.WriteString("]\n")
|
e.buf.WriteString("]\n")
|
||||||
if err := e.emitDoc(ent.doc, path); err != nil {
|
if err := e.emitDoc(ent.doc, path); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -540,7 +817,9 @@ func (e *encoder) emitDoc(doc *tomlDoc, prefix []string) error {
|
|||||||
for _, sub := range ent.docs {
|
for _, sub := range ent.docs {
|
||||||
e.writeBlankLine()
|
e.writeBlankLine()
|
||||||
e.buf.WriteString("[[")
|
e.buf.WriteString("[[")
|
||||||
writeKeyPath(&e.buf, path)
|
if err := e.writeKeyPath(path); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
e.buf.WriteString("]]\n")
|
e.buf.WriteString("]]\n")
|
||||||
if err := e.emitDoc(sub, path); err != nil {
|
if err := e.emitDoc(sub, path); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -552,10 +831,9 @@ func (e *encoder) emitDoc(doc *tomlDoc, prefix []string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (e *encoder) writeKV(key string, val any) error {
|
func (e *encoder) writeKV(key string, val any) error {
|
||||||
if !utf8.ValidString(key) {
|
if err := e.writeKey(key); err != nil {
|
||||||
return fmt.Errorf("interpres: key %q is not valid UTF-8", key)
|
return err
|
||||||
}
|
}
|
||||||
e.writeKey(key)
|
|
||||||
e.buf.WriteString(" = ")
|
e.buf.WriteString(" = ")
|
||||||
if err := e.writeValue(val); err != nil {
|
if err := e.writeValue(val); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -564,25 +842,30 @@ func (e *encoder) writeKV(key string, val any) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func writeKeyPath(buf *bytes.Buffer, path []string) {
|
func (e *encoder) writeKeyPath(path []string) error {
|
||||||
for i, p := range path {
|
for i, p := range path {
|
||||||
if i > 0 {
|
if i > 0 {
|
||||||
buf.WriteByte('.')
|
e.buf.WriteByte('.')
|
||||||
}
|
}
|
||||||
if isBareKey(p) {
|
if err := e.writeKey(p); err != nil {
|
||||||
buf.WriteString(p)
|
return err
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
writeQuotedString(buf, p)
|
|
||||||
}
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *encoder) writeKey(key string) {
|
// writeKey writes one key, bare when it qualifies and quoted otherwise. A key
|
||||||
|
// that is not valid UTF-8 is an error; writing it anyway would emit corrupt
|
||||||
|
// TOML, because the quoted form has no representation for it.
|
||||||
|
func (e *encoder) writeKey(key string) error {
|
||||||
if isBareKey(key) {
|
if isBareKey(key) {
|
||||||
e.buf.WriteString(key)
|
e.buf.WriteString(key)
|
||||||
return
|
return nil
|
||||||
}
|
}
|
||||||
writeQuotedString(&e.buf, key)
|
if !utf8.ValidString(key) {
|
||||||
|
return fmt.Errorf("interpres: key %q is not valid UTF-8", key)
|
||||||
|
}
|
||||||
|
return writeQuotedString(&e.buf, key)
|
||||||
}
|
}
|
||||||
|
|
||||||
// writeQuotedString writes s as a TOML basic string (double-quoted) to buf.
|
// writeQuotedString writes s as a TOML basic string (double-quoted) to buf.
|
||||||
@@ -660,7 +943,7 @@ func (e *encoder) writeValue(val any) error {
|
|||||||
case float64:
|
case float64:
|
||||||
return e.writeFloat(v)
|
return e.writeFloat(v)
|
||||||
case time.Time:
|
case time.Time:
|
||||||
e.buf.WriteString(v.Format(time.RFC3339Nano))
|
e.buf.WriteString(offsetString(v))
|
||||||
return nil
|
return nil
|
||||||
case LocalDateTime:
|
case LocalDateTime:
|
||||||
e.buf.WriteString(v.String())
|
e.buf.WriteString(v.String())
|
||||||
@@ -683,6 +966,8 @@ func (e *encoder) writeValue(val any) error {
|
|||||||
}
|
}
|
||||||
e.buf.WriteByte(']')
|
e.buf.WriteByte(']')
|
||||||
return nil
|
return nil
|
||||||
|
case map[string]any:
|
||||||
|
return e.writeInlineMap(v)
|
||||||
case nil:
|
case nil:
|
||||||
return fmt.Errorf("interpres: cannot encode nil value")
|
return fmt.Errorf("interpres: cannot encode nil value")
|
||||||
default:
|
default:
|
||||||
@@ -690,13 +975,208 @@ func (e *encoder) writeValue(val any) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// writeInlineMap renders m as a TOML inline table, on one line when it fits
|
||||||
|
// there and across lines when it does not.
|
||||||
|
func (e *encoder) writeInlineMap(m map[string]any) error {
|
||||||
|
flat := e.flat()
|
||||||
|
if err := flat.writeInlineMapFlat(m); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if e.column()+flat.buf.Len() <= e.limit {
|
||||||
|
e.buf.Write(flat.buf.Bytes())
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return e.writeInlineMapMultiline(m)
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeInlineMapFlat renders m as a single-line inline table with sorted keys,
|
||||||
|
// the order buildMapDoc uses for header tables. It backs the table elements of
|
||||||
|
// a value array, where the [[header]] form is not available.
|
||||||
|
func (e *encoder) writeInlineMapFlat(m map[string]any) error {
|
||||||
|
keys := slices.Sorted(maps.Keys(m))
|
||||||
|
e.buf.WriteByte('{')
|
||||||
|
for i, k := range keys {
|
||||||
|
if i > 0 {
|
||||||
|
e.buf.WriteString(", ")
|
||||||
|
}
|
||||||
|
if err := e.writeKey(k); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
e.buf.WriteString(" = ")
|
||||||
|
if err := e.writeValue(m[k]); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
e.buf.WriteByte('}')
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeInlineMapMultiline renders m with one entry per line and a trailing
|
||||||
|
// comma, the form TOML 1.1 allows for an inline table too long for one line.
|
||||||
|
func (e *encoder) writeInlineMapMultiline(m map[string]any) error {
|
||||||
|
keys := slices.Sorted(maps.Keys(m))
|
||||||
|
e.buf.WriteString("{\n")
|
||||||
|
e.inlineDepth++
|
||||||
|
for _, k := range keys {
|
||||||
|
e.writeInlineIndent()
|
||||||
|
if err := e.writeKey(k); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
e.buf.WriteString(" = ")
|
||||||
|
if err := e.writeValue(m[k]); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
e.buf.WriteString(",\n")
|
||||||
|
}
|
||||||
|
e.inlineDepth--
|
||||||
|
e.writeInlineIndent()
|
||||||
|
e.buf.WriteByte('}')
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeInlineIndent writes one tab per inline-table nesting level.
|
||||||
|
func (e *encoder) writeInlineIndent() {
|
||||||
|
for range e.inlineDepth {
|
||||||
|
e.buf.WriteByte('\t')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// errInlineArrayOfTables reports an attempt to render an array of tables
|
||||||
|
// inline, which has no form that keeps the value's type.
|
||||||
|
var errInlineArrayOfTables = errors.New("interpres: an array of tables has no inline form")
|
||||||
|
|
||||||
|
// inlinableDoc reports whether doc can be written as an inline table without
|
||||||
|
// changing the type of any value: scalars, value arrays and further sub-tables
|
||||||
|
// are fine, while an array of tables is not, because its inline form would
|
||||||
|
// re-parse as a value array.
|
||||||
|
func inlinableDoc(doc *tomlDoc) bool {
|
||||||
|
for _, ent := range doc.entries {
|
||||||
|
switch ent.kind {
|
||||||
|
case entryArray:
|
||||||
|
return false
|
||||||
|
case entryTable:
|
||||||
|
if !inlinableDoc(ent.doc) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeInlineDocEntry writes one "key = value" binding of an inline table,
|
||||||
|
// without the separator that follows it.
|
||||||
|
func (e *encoder) writeInlineDocEntry(ent entry) error {
|
||||||
|
if err := e.writeKey(ent.key); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
e.buf.WriteString(" = ")
|
||||||
|
switch ent.kind {
|
||||||
|
case entryTable:
|
||||||
|
return e.writeInlineDoc(ent.doc)
|
||||||
|
case entryArray:
|
||||||
|
return errInlineArrayOfTables
|
||||||
|
default:
|
||||||
|
return e.writeValue(ent.val)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeInlineDoc renders doc as a single-line inline table in entry order, the
|
||||||
|
// order the fields were declared in.
|
||||||
|
func (e *encoder) writeInlineDoc(doc *tomlDoc) error {
|
||||||
|
e.buf.WriteByte('{')
|
||||||
|
for i, ent := range doc.entries {
|
||||||
|
if i > 0 {
|
||||||
|
e.buf.WriteString(", ")
|
||||||
|
}
|
||||||
|
if err := e.writeInlineDocEntry(ent); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
e.buf.WriteByte('}')
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeInlineDocMultiline renders doc with one entry per line and a trailing
|
||||||
|
// comma, the form TOML 1.1 allows for an inline table too long for one line.
|
||||||
|
func (e *encoder) writeInlineDocMultiline(doc *tomlDoc) error {
|
||||||
|
e.buf.WriteString("{\n")
|
||||||
|
e.inlineDepth++
|
||||||
|
for _, ent := range doc.entries {
|
||||||
|
e.writeInlineIndent()
|
||||||
|
if err := e.writeInlineDocEntry(ent); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
e.buf.WriteString(",\n")
|
||||||
|
}
|
||||||
|
e.inlineDepth--
|
||||||
|
e.writeInlineIndent()
|
||||||
|
e.buf.WriteByte('}')
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeInlineSubTableIfSmall writes "key = {…}" for a sub-table whose
|
||||||
|
// single-line rendering fits the compact threshold, and reports whether it did
|
||||||
|
// so. An array of tables is never inlined, because its inline form would
|
||||||
|
// re-parse as a value array and change the value's Go type.
|
||||||
|
func (e *encoder) writeInlineSubTableIfSmall(name string, doc *tomlDoc) (bool, error) {
|
||||||
|
if e.opts.inlineTablesAt <= 0 || !inlinableDoc(doc) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
flat := e.flat()
|
||||||
|
if err := flat.writeInlineDoc(doc); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
if flat.buf.Len() > e.opts.inlineTablesAt {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
if err := e.writeKey(name); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
e.buf.WriteString(" = ")
|
||||||
|
if e.column()+flat.buf.Len() <= e.limit {
|
||||||
|
e.buf.Write(flat.buf.Bytes())
|
||||||
|
} else if err := e.writeInlineDocMultiline(doc); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
e.buf.WriteByte('\n')
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (e *encoder) writeStringVal(s string) error {
|
func (e *encoder) writeStringVal(s string) error {
|
||||||
if e.opts.literalMultilineAt > 0 && strings.ContainsRune(s, '\n') && len(s) >= e.opts.literalMultilineAt {
|
if e.opts.literalMultilineAt > 0 && strings.ContainsRune(s, '\n') &&
|
||||||
|
len(s) >= e.opts.literalMultilineAt && canBeLiteralMultiline(s) {
|
||||||
return writeLiteralMultilineString(&e.buf, s)
|
return writeLiteralMultilineString(&e.buf, s)
|
||||||
}
|
}
|
||||||
return writeQuotedString(&e.buf, s)
|
return writeQuotedString(&e.buf, s)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// canBeLiteralMultiline reports whether s can be carried verbatim by the
|
||||||
|
// literal ”'...”' form: the form has no escapes, so a run of three single
|
||||||
|
// quotes would close the delimiter early, and control characters beyond tab,
|
||||||
|
// and a carriage return outside a CRLF pair, have no representation at all.
|
||||||
|
// Anything else falls back to the escaped basic string.
|
||||||
|
func canBeLiteralMultiline(s string) bool {
|
||||||
|
if strings.Contains(s, "'''") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for i := 0; i < len(s); {
|
||||||
|
r, size := utf8.DecodeRuneInString(s[i:])
|
||||||
|
switch {
|
||||||
|
case r == '\t' || r == '\n':
|
||||||
|
case r == '\r':
|
||||||
|
if !strings.HasPrefix(s[i+size:], "\n") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
if r < 0x20 || r == 0x7f {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
i += size
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
// writeLiteralMultilineString writes s as a TOML literal multi-line string,
|
// writeLiteralMultilineString writes s as a TOML literal multi-line string,
|
||||||
// surrounded by triple single quotes. The opening delimiter is followed by a
|
// surrounded by triple single quotes. The opening delimiter is followed by a
|
||||||
// newline that the reader trims, so we always include one. The closing
|
// newline that the reader trims, so we always include one. The closing
|
||||||
@@ -724,7 +1204,8 @@ func (e *encoder) writeFloat(v float64) error {
|
|||||||
case math.IsInf(v, -1):
|
case math.IsInf(v, -1):
|
||||||
e.buf.WriteString("-inf")
|
e.buf.WriteString("-inf")
|
||||||
case v == 0:
|
case v == 0:
|
||||||
// Normalise negative zero to positive zero (TOML has no -0).
|
// Normalise negative zero to positive zero, the contract the output
|
||||||
|
// rules in the documentation state.
|
||||||
e.buf.WriteString("0.0")
|
e.buf.WriteString("0.0")
|
||||||
default:
|
default:
|
||||||
s := strconv.FormatFloat(v, 'g', -1, 64)
|
s := strconv.FormatFloat(v, 'g', -1, 64)
|
||||||
|
|||||||
+802
-10
@@ -8,6 +8,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"math"
|
"math"
|
||||||
|
"net"
|
||||||
"reflect"
|
"reflect"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -67,7 +68,7 @@ func TestMarshalFloatSpecials(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestMarshalFloatNormalizesNegativeZero(t *testing.T) {
|
func TestMarshalFloatNormalizesNegativeZero(t *testing.T) {
|
||||||
// TOML has no -0; the emitter must normalise negative zero to "0.0".
|
// The output contract normalises negative zero to "0.0".
|
||||||
type Cfg struct {
|
type Cfg struct {
|
||||||
Z float64 `toml:"z"`
|
Z float64 `toml:"z"`
|
||||||
}
|
}
|
||||||
@@ -267,6 +268,38 @@ func TestEncoderUseLiteralMultilineThresholdZero(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestEncoderLiteralMultilineFallsBackWhenUnsafe(t *testing.T) {
|
||||||
|
// The literal form carries the value verbatim, so content it cannot
|
||||||
|
// represent must fall back to the escaped basic string instead of
|
||||||
|
// producing output that does not re-parse.
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
in string
|
||||||
|
}{
|
||||||
|
{"embedded delimiter", "before ''' after\nsecond line"},
|
||||||
|
{"control character", "a\x01b\nsecond"},
|
||||||
|
{"delete character", "a\x7fb\nsecond"},
|
||||||
|
{"lone carriage return", "first\rsecond\nthird"},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
out, err := NewEncoder().UseLiteralMultiline(5).Marshal(map[string]any{"s": c.in})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%s: marshal: %v", c.name, err)
|
||||||
|
}
|
||||||
|
if !bytes.HasPrefix(out, []byte("s = \"")) {
|
||||||
|
t.Errorf("%s: expected the basic quoted form, got:\n%s", c.name, out)
|
||||||
|
}
|
||||||
|
re, err := Parse(out)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("%s: re-parse: %v\ndoc:\n%s", c.name, err, out)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if re["s"] != c.in {
|
||||||
|
t.Errorf("%s: round-trip changed the value: %q", c.name, re["s"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// marshalerFunc adapts a plain function value to the Marshaler interface.
|
// marshalerFunc adapts a plain function value to the Marshaler interface.
|
||||||
// Tests use it to express "this field produces this TOML value" without a
|
// Tests use it to express "this field produces this TOML value" without a
|
||||||
// dedicated struct definition.
|
// dedicated struct definition.
|
||||||
@@ -293,7 +326,7 @@ func TestMarshalerReturningTime(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("marshal: %v", err)
|
t.Fatalf("marshal: %v", err)
|
||||||
}
|
}
|
||||||
want := "m = 2026-06-26T10:00:00Z\n"
|
want := "m = 2026-06-26T10:00Z\n"
|
||||||
if string(out) != want {
|
if string(out) != want {
|
||||||
t.Errorf("output mismatch:\ngot: %q\nwant: %q", out, want)
|
t.Errorf("output mismatch:\ngot: %q\nwant: %q", out, want)
|
||||||
}
|
}
|
||||||
@@ -351,6 +384,75 @@ func TestMarshalerErrorPropagates(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// nilMarshalerFunc is a Marshaler whose method returns nil with no error.
|
||||||
|
type nilMarshalerFunc struct{}
|
||||||
|
|
||||||
|
func (nilMarshalerFunc) MarshalTOML() (any, error) { return nil, nil }
|
||||||
|
|
||||||
|
func TestMarshalRejectsNilMarshalerResult(t *testing.T) {
|
||||||
|
// nil has no TOML representation, so a MarshalTOML result of nil is an
|
||||||
|
// error, not a silently dropped field.
|
||||||
|
_, err := Marshal(struct {
|
||||||
|
F nilMarshalerFunc `toml:"f"`
|
||||||
|
}{})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected an error for a nil MarshalTOML result")
|
||||||
|
}
|
||||||
|
ee, ok := errors.AsType[*EncodeError](err)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected an *EncodeError, got %T: %v", err, err)
|
||||||
|
}
|
||||||
|
if ee.Path != "f" {
|
||||||
|
t.Fatalf("Path = %q, want %q", ee.Path, "f")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inside a value array the nil result used to reach reflection as a zero
|
||||||
|
// Value and panic.
|
||||||
|
_, err = Marshal(map[string]any{"arr": []any{1, nilMarshalerFunc{}}})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected an error for a nil MarshalTOML result in an array")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "MarshalTOML returned a nil value") {
|
||||||
|
t.Errorf("err = %v, want the nil-result message", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Two fields that resolve to one TOML key must marshal as one key, resolved
|
||||||
|
// the way the decoder resolves it, or the output would carry a duplicate key
|
||||||
|
// and never re-parse.
|
||||||
|
func TestMarshalDuplicateKeyResolvesToOneField(t *testing.T) {
|
||||||
|
type SameLevel struct {
|
||||||
|
First int `toml:"v"`
|
||||||
|
Second string `toml:"v"`
|
||||||
|
}
|
||||||
|
out, err := Marshal(SameLevel{First: 1, Second: "s"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal: %v", err)
|
||||||
|
}
|
||||||
|
if want := "v = \"s\"\n"; string(out) != want {
|
||||||
|
t.Errorf("output mismatch:\ngot: %q\nwant: %q", out, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
type Base struct {
|
||||||
|
Name string `toml:"name"`
|
||||||
|
}
|
||||||
|
type Embedded struct {
|
||||||
|
Base
|
||||||
|
Name string `toml:"name"`
|
||||||
|
}
|
||||||
|
out, err = Marshal(Embedded{Base: Base{Name: "inner"}, Name: "outer"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal: %v", err)
|
||||||
|
}
|
||||||
|
// The shallower field wins, matching the decoder.
|
||||||
|
if want := "name = \"outer\"\n"; string(out) != want {
|
||||||
|
t.Errorf("output mismatch:\ngot: %q\nwant: %q", out, want)
|
||||||
|
}
|
||||||
|
if _, err := Parse(out); err != nil {
|
||||||
|
t.Errorf("re-parse: %v\ndoc:\n%s", err, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestMarshalEmbeddedScalarStruct(t *testing.T) {
|
func TestMarshalEmbeddedScalarStruct(t *testing.T) {
|
||||||
// A field declared directly as a scalar-struct type (here LocalDateTime)
|
// A field declared directly as a scalar-struct type (here LocalDateTime)
|
||||||
// must be encoded as a TOML scalar at the parent level, not rendered as
|
// must be encoded as a TOML scalar at the parent level, not rendered as
|
||||||
@@ -364,7 +466,7 @@ func TestMarshalEmbeddedScalarStruct(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("marshal: %v", err)
|
t.Fatalf("marshal: %v", err)
|
||||||
}
|
}
|
||||||
want := "name = \"x\"\ns = 2026-06-26T00:00:00\n"
|
want := "name = \"x\"\ns = 2026-06-26T00:00\n"
|
||||||
if string(out) != want {
|
if string(out) != want {
|
||||||
t.Errorf("output mismatch:\ngot: %q\nwant: %q", out, want)
|
t.Errorf("output mismatch:\ngot: %q\nwant: %q", out, want)
|
||||||
}
|
}
|
||||||
@@ -439,7 +541,7 @@ func TestMarshalDateTime(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("marshal: %v", err)
|
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"
|
want := "offset = 2026-06-26T10:00Z\nlocal = 2026-06-26T07:32\nday = 2026-06-26\nclock = 07:32\n"
|
||||||
if string(out) != want {
|
if string(out) != want {
|
||||||
t.Errorf("output mismatch:\ngot: %q\nwant: %q", out, want)
|
t.Errorf("output mismatch:\ngot: %q\nwant: %q", out, want)
|
||||||
}
|
}
|
||||||
@@ -504,6 +606,109 @@ func TestMarshalNestedArrays(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestMarshalMixedArrayWithInlineTable(t *testing.T) {
|
||||||
|
// Parse accepts a mixed array (TOML allows any value kinds in one array),
|
||||||
|
// so Marshal of the parsed tree must re-emit it. The table element has no
|
||||||
|
// header form inside a value array and renders inline.
|
||||||
|
tree, err := Parse([]byte("arr = [1, {a = 2}, \"x\"]\n"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse: %v", err)
|
||||||
|
}
|
||||||
|
out, err := Marshal(tree)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal: %v", err)
|
||||||
|
}
|
||||||
|
want := "arr = [1, {a = 2}, \"x\"]\n"
|
||||||
|
if string(out) != want {
|
||||||
|
t.Fatalf("output mismatch:\ngot: %q\nwant: %q", out, want)
|
||||||
|
}
|
||||||
|
re, err := Parse(out)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("re-parse: %v", err)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(tree, re) {
|
||||||
|
t.Fatalf("round-trip changed the tree:\nwas: %#v\nnow: %#v", tree, re)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A []any of tables is what Parse produces for a value array of inline
|
||||||
|
// tables; it must stay in the value-array form, or the output would re-parse
|
||||||
|
// as []map[string]any and the round-trip would change the value's type.
|
||||||
|
func TestMarshalValueArrayOfTablesStaysInline(t *testing.T) {
|
||||||
|
for _, doc := range []string{
|
||||||
|
"0=[{}]",
|
||||||
|
"a = [{x = 1}, {x = 2}]\n",
|
||||||
|
"b = [{x = 1}, 2, \"three\"]\n",
|
||||||
|
} {
|
||||||
|
tree, err := Parse([]byte(doc))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%s: parse: %v", doc, err)
|
||||||
|
}
|
||||||
|
out, err := Marshal(tree)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%s: marshal: %v", doc, err)
|
||||||
|
}
|
||||||
|
if bytes.HasPrefix(out, []byte("[[")) {
|
||||||
|
t.Errorf("%s: emitted the [[header]] form for a value array:\n%s", doc, out)
|
||||||
|
}
|
||||||
|
re, err := Parse(out)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%s: re-parse: %v\ndoc:\n%s", doc, err, out)
|
||||||
|
}
|
||||||
|
if !tomlEqual(tree, re) {
|
||||||
|
t.Errorf("%s: round-trip changed the tree:\nwas: %#v\nnow: %#v\ndoc:\n%s", doc, tree, re, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMarshalNestedInlineTables(t *testing.T) {
|
||||||
|
tree := map[string]any{
|
||||||
|
"mix": []any{
|
||||||
|
int64(1),
|
||||||
|
map[string]any{"deep": map[string]any{"n": int64(0)}, "list": []any{"a", true}},
|
||||||
|
map[string]any{},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
out, err := Marshal(tree)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal: %v", err)
|
||||||
|
}
|
||||||
|
want := "mix = [1, {deep = {n = 0}, list = [\"a\", true]}, {}]\n"
|
||||||
|
if string(out) != want {
|
||||||
|
t.Fatalf("output mismatch:\ngot: %q\nwant: %q", out, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMarshalInlineTableWithDatetime(t *testing.T) {
|
||||||
|
when := time.Date(1979, 5, 27, 7, 32, 0, 0, time.UTC)
|
||||||
|
tree := map[string]any{
|
||||||
|
"mix": []any{when, map[string]any{"t": LocalDateTime{when}}},
|
||||||
|
}
|
||||||
|
out, err := Marshal(tree)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal: %v", err)
|
||||||
|
}
|
||||||
|
want := "mix = [1979-05-27T07:32Z, {t = 1979-05-27T07:32}]\n"
|
||||||
|
if string(out) != want {
|
||||||
|
t.Fatalf("output mismatch:\ngot: %q\nwant: %q", out, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMarshalArrayOfTablesStaysHeaderForm(t *testing.T) {
|
||||||
|
tree, err := Parse([]byte("[[items]]\nname = \"a\"\n\n[[items]]\nname = \"b\"\n"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse: %v", err)
|
||||||
|
}
|
||||||
|
out, err := Marshal(tree)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal: %v", err)
|
||||||
|
}
|
||||||
|
want := "[[items]]\nname = \"a\"\n\n[[items]]\nname = \"b\"\n"
|
||||||
|
if string(out) != want {
|
||||||
|
t.Fatalf("output mismatch:\ngot: %q\nwant: %q", out, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestMarshalFloatExponentNoLeadingZero(t *testing.T) {
|
func TestMarshalFloatExponentNoLeadingZero(t *testing.T) {
|
||||||
// strconv.FormatFloat with 'g' would produce "1e+06" (leading zero in
|
// strconv.FormatFloat with 'g' would produce "1e+06" (leading zero in
|
||||||
// exponent). The encoder must strip it so the output is "1e+6".
|
// exponent). The encoder must strip it so the output is "1e+6".
|
||||||
@@ -675,6 +880,85 @@ func TestMarshalEmbeddedStructAsTable(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestMarshalTagOptionOmitZero(t *testing.T) {
|
||||||
|
type Server struct {
|
||||||
|
Host string `toml:"host"`
|
||||||
|
}
|
||||||
|
type Cfg struct {
|
||||||
|
Name string `toml:"name,omitzero"`
|
||||||
|
Count int `toml:"count,omitzero"`
|
||||||
|
Ratio float64 `toml:"ratio,omitzero"`
|
||||||
|
When time.Time `toml:"when,omitzero"`
|
||||||
|
Server Server `toml:"server,omitzero"`
|
||||||
|
Always string `toml:"always"`
|
||||||
|
}
|
||||||
|
out, err := Marshal(Cfg{Always: "kept"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal: %v", err)
|
||||||
|
}
|
||||||
|
// Every omitzero field sits at its zero value, so only always is emitted.
|
||||||
|
want := "always = \"kept\"\n"
|
||||||
|
if string(out) != want {
|
||||||
|
t.Fatalf("output mismatch:\ngot: %q\nwant: %q", out, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
when := time.Date(2026, 9, 17, 12, 0, 0, 0, time.UTC)
|
||||||
|
out, err = Marshal(Cfg{Name: "x", Count: 1, Ratio: 0.5, When: when, Server: Server{Host: "h"}, Always: "kept"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal: %v", err)
|
||||||
|
}
|
||||||
|
want = "name = \"x\"\ncount = 1\nratio = 0.5\nwhen = 2026-09-17T12:00Z\nalways = \"kept\"\n\n[server]\nhost = \"h\"\n"
|
||||||
|
if string(out) != want {
|
||||||
|
t.Fatalf("output mismatch:\ngot: %q\nwant: %q", out, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMarshalTagOptionOmitEmpty(t *testing.T) {
|
||||||
|
type Cfg struct {
|
||||||
|
Tags []string `toml:"tags,omitempty"`
|
||||||
|
Ports []int `toml:"ports,omitempty"`
|
||||||
|
Matrix [][]int `toml:"matrix,omitempty"`
|
||||||
|
Extra map[string]any `toml:"extra,omitempty"`
|
||||||
|
Name string `toml:"name,omitempty"`
|
||||||
|
Keep []string `toml:"keep"`
|
||||||
|
}
|
||||||
|
out, err := Marshal(Cfg{
|
||||||
|
Ports: []int{},
|
||||||
|
Matrix: [][]int{{1}},
|
||||||
|
Extra: map[string]any{},
|
||||||
|
Name: "set",
|
||||||
|
Keep: []string{},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal: %v", err)
|
||||||
|
}
|
||||||
|
// tags is nil (omitted anyway), ports and extra are empty collections
|
||||||
|
// dropped by omitempty, matrix is populated, name is a string the option
|
||||||
|
// does not cover, keep is empty but carries no option so it emits [].
|
||||||
|
want := "matrix = [[1]]\nname = \"set\"\nkeep = []\n"
|
||||||
|
if string(out) != want {
|
||||||
|
t.Fatalf("output mismatch:\ngot: %q\nwant: %q", out, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMarshalTagOptionOnTaggedEmbeddedStruct(t *testing.T) {
|
||||||
|
type Inner struct {
|
||||||
|
N int `toml:"n"`
|
||||||
|
}
|
||||||
|
type Cfg struct {
|
||||||
|
Inner Inner `toml:"inner,omitzero"`
|
||||||
|
Name string `toml:"name"`
|
||||||
|
}
|
||||||
|
out, err := Marshal(Cfg{Name: "x"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal: %v", err)
|
||||||
|
}
|
||||||
|
want := "name = \"x\"\n"
|
||||||
|
if string(out) != want {
|
||||||
|
t.Fatalf("output mismatch:\ngot: %q\nwant: %q", out, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestMarshalMapKeysSorted(t *testing.T) {
|
func TestMarshalMapKeysSorted(t *testing.T) {
|
||||||
m := map[string]any{
|
m := map[string]any{
|
||||||
"zeta": 1,
|
"zeta": 1,
|
||||||
@@ -912,6 +1196,8 @@ qty = 2
|
|||||||
|
|
||||||
[meta]
|
[meta]
|
||||||
created = 2026-06-26T10:00:00Z
|
created = 2026-06-26T10:00:00Z
|
||||||
|
|
||||||
|
mixed = [1, {n = 1, name = "a value long enough to push this line well past the one hundred column limit"}]
|
||||||
`)
|
`)
|
||||||
tree1, err := Parse(src)
|
tree1, err := Parse(src)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -944,6 +1230,17 @@ func TestMarshalKeyRequiresUTF8(t *testing.T) {
|
|||||||
if _, err := Marshal(m); err == nil {
|
if _, err := Marshal(m); err == nil {
|
||||||
t.Errorf("expected error for invalid UTF-8 key")
|
t.Errorf("expected error for invalid UTF-8 key")
|
||||||
}
|
}
|
||||||
|
// The check must reach the keys of table headers and of inline tables
|
||||||
|
// nested inside value arrays, not only scalar keys: both write keys
|
||||||
|
// through the same path.
|
||||||
|
nested := map[string]any{"\xff": map[string]any{"k": "v"}}
|
||||||
|
if _, err := Marshal(nested); err == nil {
|
||||||
|
t.Errorf("expected error for invalid UTF-8 table header key")
|
||||||
|
}
|
||||||
|
inline := map[string]any{"mix": []any{1, map[string]any{"\xff": 1}}}
|
||||||
|
if _, err := Marshal(inline); err == nil {
|
||||||
|
t.Errorf("expected error for invalid UTF-8 inline table key")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestMarshalStringRequiresUTF8(t *testing.T) {
|
func TestMarshalStringRequiresUTF8(t *testing.T) {
|
||||||
@@ -963,24 +1260,35 @@ func TestLocalDateString(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestLocalDateTimeString(t *testing.T) {
|
func TestLocalDateTimeString(t *testing.T) {
|
||||||
|
// The rendering drops zero seconds and the trailing zeros of a fraction,
|
||||||
|
// which TOML 1.1 allows and which keeps a value written without seconds
|
||||||
|
// written without them.
|
||||||
ldt := LocalDateTime{Time: time.Date(1979, 5, 27, 7, 32, 0, 0, time.UTC)}
|
ldt := LocalDateTime{Time: time.Date(1979, 5, 27, 7, 32, 0, 0, time.UTC)}
|
||||||
if got := ldt.String(); got != "1979-05-27T07:32:00" {
|
if got := ldt.String(); got != "1979-05-27T07:32" {
|
||||||
t.Errorf("LocalDateTime.String() = %q, want 1979-05-27T07:32:00", got)
|
t.Errorf("LocalDateTime.String() = %q, want 1979-05-27T07:32", got)
|
||||||
}
|
}
|
||||||
ldt2 := LocalDateTime{Time: time.Date(1979, 5, 27, 7, 32, 0, 5, time.UTC)}
|
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" {
|
if got := ldt2.String(); got != "1979-05-27T07:32:00.000000005" {
|
||||||
t.Errorf("LocalDateTime.String() = %q, want 1979-05-27T07:32:00.000000005", got)
|
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)}
|
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" {
|
if got := ldt3.String(); got != "1979-05-27T07:32:00.0000005" {
|
||||||
t.Errorf("LocalDateTime.String() = %q, want 1979-05-27T07:32:00.000000500", got)
|
t.Errorf("LocalDateTime.String() = %q, want 1979-05-27T07:32:00.0000005", got)
|
||||||
|
}
|
||||||
|
ldt4 := LocalDateTime{Time: time.Date(1979, 5, 27, 7, 32, 30, 500000000, time.UTC)}
|
||||||
|
if got := ldt4.String(); got != "1979-05-27T07:32:30.5" {
|
||||||
|
t.Errorf("LocalDateTime.String() = %q, want 1979-05-27T07:32:30.5", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLocalTimeString(t *testing.T) {
|
func TestLocalTimeString(t *testing.T) {
|
||||||
lt := LocalTime{Time: time.Date(0, 1, 1, 7, 32, 0, 0, time.UTC)}
|
lt := LocalTime{Time: time.Date(0, 1, 1, 7, 32, 0, 0, time.UTC)}
|
||||||
if got := lt.String(); got != "07:32:00" {
|
if got := lt.String(); got != "07:32" {
|
||||||
t.Errorf("LocalTime.String() = %q, want 07:32:00", got)
|
t.Errorf("LocalTime.String() = %q, want 07:32", got)
|
||||||
|
}
|
||||||
|
lt2 := LocalTime{Time: time.Date(0, 1, 1, 7, 32, 15, 250000000, time.UTC)}
|
||||||
|
if got := lt2.String(); got != "07:32:15.25" {
|
||||||
|
t.Errorf("LocalTime.String() = %q, want 07:32:15.25", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1008,3 +1316,487 @@ type Custom struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c Custom) MarshalTOML() (any, error) { return c.tag, nil }
|
func (c Custom) MarshalTOML() (any, error) { return c.tag, nil }
|
||||||
|
|
||||||
|
// encodeErrBad is a Marshaler whose MarshalTOML always fails.
|
||||||
|
type encodeErrBad struct {
|
||||||
|
msg string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (encodeErrBad) MarshalTOML() (any, error) { return nil, errors.New("bad timestamp") }
|
||||||
|
|
||||||
|
func TestEncodeErrorCarriesPath(t *testing.T) {
|
||||||
|
type Inner struct {
|
||||||
|
Port encodeErrBad `toml:"port"`
|
||||||
|
}
|
||||||
|
type Cfg struct {
|
||||||
|
Server Inner `toml:"server"`
|
||||||
|
}
|
||||||
|
_, err := Marshal(Cfg{Server: Inner{Port: encodeErrBad{}}})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected a marshal error")
|
||||||
|
}
|
||||||
|
ee, ok := errors.AsType[*EncodeError](err)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected an *EncodeError, got %T: %v", err, err)
|
||||||
|
}
|
||||||
|
if ee.Path != "server.port" {
|
||||||
|
t.Fatalf("Path = %q, want %q", ee.Path, "server.port")
|
||||||
|
}
|
||||||
|
if ee.Err == nil || ee.Err.Error() != "bad timestamp" {
|
||||||
|
t.Fatalf("Err = %v", ee.Err)
|
||||||
|
}
|
||||||
|
if err.Error() != "interpres: server.port: bad timestamp" {
|
||||||
|
t.Fatalf("message = %q", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEncodeErrorTopLevelPathHasNoLeadingDot(t *testing.T) {
|
||||||
|
type Cfg struct {
|
||||||
|
Port encodeErrBad `toml:"port"`
|
||||||
|
}
|
||||||
|
_, err := Marshal(Cfg{})
|
||||||
|
ee, ok := errors.AsType[*EncodeError](err)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected an *EncodeError, got %T: %v", err, err)
|
||||||
|
}
|
||||||
|
if ee.Path != "port" {
|
||||||
|
t.Fatalf("Path = %q, want %q", ee.Path, "port")
|
||||||
|
}
|
||||||
|
if err.Error() != "interpres: port: bad timestamp" {
|
||||||
|
t.Fatalf("message = %q", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEncodeErrorHeterogeneousArrayPath(t *testing.T) {
|
||||||
|
type Item struct {
|
||||||
|
N int `toml:"n"`
|
||||||
|
}
|
||||||
|
cfg := map[string]any{
|
||||||
|
"items": []any{Item{}, 3},
|
||||||
|
}
|
||||||
|
_, err := Marshal(cfg)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected a heterogeneous array error")
|
||||||
|
}
|
||||||
|
ee, ok := errors.AsType[*EncodeError](err)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected an *EncodeError, got %T: %v", err, err)
|
||||||
|
}
|
||||||
|
if ee.Path != "items[0]" {
|
||||||
|
t.Fatalf("Path = %q, want %q", ee.Path, "items[0]")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- encoding.TextMarshaler and time.Duration ------------------------------
|
||||||
|
|
||||||
|
// textTag is a value-receiver encoding.TextMarshaler, so the encoder finds the
|
||||||
|
// method on the value itself.
|
||||||
|
type textTag string
|
||||||
|
|
||||||
|
func (t textTag) MarshalText() ([]byte, error) { return []byte("tag:" + string(t)), nil }
|
||||||
|
|
||||||
|
// textPointer carries MarshalText on the pointer receiver only, so the encoder
|
||||||
|
// has to look at the address of an addressable field.
|
||||||
|
type textPointer struct{ V string }
|
||||||
|
|
||||||
|
func (t *textPointer) MarshalText() ([]byte, error) { return []byte(strings.ToUpper(t.V)), nil }
|
||||||
|
|
||||||
|
// textAndTOML implements both encoding interfaces; the TOML method wins.
|
||||||
|
type textAndTOML struct{}
|
||||||
|
|
||||||
|
func (textAndTOML) MarshalTOML() (any, error) { return "toml", nil }
|
||||||
|
func (textAndTOML) MarshalText() ([]byte, error) { return []byte("text"), nil }
|
||||||
|
|
||||||
|
// brokenText fails the marshal from MarshalText.
|
||||||
|
type brokenText struct{}
|
||||||
|
|
||||||
|
func (brokenText) MarshalText() ([]byte, error) { return nil, errors.New("text boom") }
|
||||||
|
|
||||||
|
// notUTF8 renders bytes that no TOML string can carry.
|
||||||
|
type notUTF8 struct{}
|
||||||
|
|
||||||
|
func (notUTF8) MarshalText() ([]byte, error) { return []byte{0xff, 0xfe}, nil }
|
||||||
|
|
||||||
|
// textTagBoth renders itself with a prefix and strips it again on decode, so
|
||||||
|
// the round trip through a TOML string is lossless.
|
||||||
|
type textTagBoth string
|
||||||
|
|
||||||
|
func (t textTagBoth) MarshalText() ([]byte, error) { return []byte("tag:" + string(t)), nil }
|
||||||
|
|
||||||
|
func (t *textTagBoth) UnmarshalText(text []byte) error {
|
||||||
|
trimmed, ok := strings.CutPrefix(string(text), "tag:")
|
||||||
|
if !ok {
|
||||||
|
return errors.New("textTagBoth: missing the tag prefix")
|
||||||
|
}
|
||||||
|
*t = textTagBoth(trimmed)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMarshalTextValues(t *testing.T) {
|
||||||
|
// The pointer receiver is reachable only through an addressable field, so
|
||||||
|
// the whole value is marshalled through a pointer here.
|
||||||
|
type Cfg struct {
|
||||||
|
IP net.IP `toml:"ip"`
|
||||||
|
Duration time.Duration `toml:"duration"`
|
||||||
|
Tag textTag `toml:"tag"`
|
||||||
|
Pointer textPointer `toml:"pointer"`
|
||||||
|
Both textAndTOML `toml:"both"`
|
||||||
|
}
|
||||||
|
out, err := Marshal(&Cfg{
|
||||||
|
IP: net.IPv4(192, 0, 2, 1),
|
||||||
|
Duration: 90 * time.Minute,
|
||||||
|
Tag: "x",
|
||||||
|
Pointer: textPointer{V: "abc"},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal: %v", err)
|
||||||
|
}
|
||||||
|
want := "ip = \"192.0.2.1\"\nduration = \"1h30m0s\"\ntag = \"tag:x\"\npointer = \"ABC\"\nboth = \"toml\"\n"
|
||||||
|
if string(out) != want {
|
||||||
|
t.Errorf("output mismatch:\ngot: %q\nwant: %q", out, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMarshalTextValuesInContainers(t *testing.T) {
|
||||||
|
// Slice elements are addressable, so a pointer-receiver MarshalText is used
|
||||||
|
// there too, and an array of such values stays a value array: each element's
|
||||||
|
// TOML form is a string, so the [[header]] form cannot carry it.
|
||||||
|
type Cfg struct {
|
||||||
|
Map map[string]net.IP `toml:"map"`
|
||||||
|
Durs []time.Duration `toml:"durs"`
|
||||||
|
Ptrs []textPointer `toml:"ptrs"`
|
||||||
|
Empty []textPointer `toml:"empty"`
|
||||||
|
}
|
||||||
|
out, err := Marshal(Cfg{
|
||||||
|
Map: map[string]net.IP{"a": net.IPv4(10, 0, 0, 1)},
|
||||||
|
Durs: []time.Duration{0, 250 * time.Millisecond},
|
||||||
|
Ptrs: []textPointer{{V: "a"}, {V: "b"}},
|
||||||
|
Empty: []textPointer{},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal: %v", err)
|
||||||
|
}
|
||||||
|
want := "durs = [\"0s\", \"250ms\"]\nptrs = [\"A\", \"B\"]\nempty = []\n\n[map]\na = \"10.0.0.1\"\n"
|
||||||
|
if string(out) != want {
|
||||||
|
t.Errorf("output mismatch:\ngot: %q\nwant: %q", out, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMarshalTextLeavesDateTimesAlone(t *testing.T) {
|
||||||
|
// The four date-time types carry time.Time's text methods through an
|
||||||
|
// embedded field; their TOML form is a bare atom, never a quoted string.
|
||||||
|
stamp := time.Date(2026, 6, 26, 10, 0, 0, 0, time.UTC)
|
||||||
|
type Cfg struct {
|
||||||
|
Stamp time.Time `toml:"stamp"`
|
||||||
|
Ptr *time.Time `toml:"ptr"`
|
||||||
|
Day LocalDate `toml:"day"`
|
||||||
|
At LocalDateTime `toml:"at"`
|
||||||
|
Clock LocalTime `toml:"clock"`
|
||||||
|
}
|
||||||
|
out, err := Marshal(&Cfg{
|
||||||
|
Stamp: stamp,
|
||||||
|
Ptr: &stamp,
|
||||||
|
Day: LocalDate{time.Date(1979, 5, 27, 0, 0, 0, 0, time.UTC)},
|
||||||
|
At: LocalDateTime{time.Date(1979, 5, 27, 7, 32, 0, 0, time.UTC)},
|
||||||
|
Clock: LocalTime{time.Date(0, 1, 1, 7, 32, 0, 0, time.UTC)},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal: %v", err)
|
||||||
|
}
|
||||||
|
want := "stamp = 2026-06-26T10:00Z\nptr = 2026-06-26T10:00Z\nday = 1979-05-27\nat = 1979-05-27T07:32\nclock = 07:32\n"
|
||||||
|
if string(out) != want {
|
||||||
|
t.Errorf("output mismatch:\ngot: %q\nwant: %q", out, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMarshalTextNilPointerOmitted(t *testing.T) {
|
||||||
|
type Cfg struct {
|
||||||
|
P *textPointer `toml:"p"`
|
||||||
|
K string `toml:"k"`
|
||||||
|
}
|
||||||
|
out, err := Marshal(&Cfg{K: "x"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal: %v", err)
|
||||||
|
}
|
||||||
|
if want := "k = \"x\"\n"; string(out) != want {
|
||||||
|
t.Errorf("output mismatch:\ngot: %q\nwant: %q", out, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMarshalTextErrorCarriesPath(t *testing.T) {
|
||||||
|
type Inner struct {
|
||||||
|
F brokenText `toml:"f"`
|
||||||
|
}
|
||||||
|
type Cfg struct {
|
||||||
|
Inner Inner `toml:"inner"`
|
||||||
|
}
|
||||||
|
_, err := Marshal(Cfg{})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected an error from MarshalText")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "text boom") {
|
||||||
|
t.Errorf("err = %v, want substring \"text boom\"", err)
|
||||||
|
}
|
||||||
|
ee, ok := errors.AsType[*EncodeError](err)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected an *EncodeError, got %T: %v", err, err)
|
||||||
|
}
|
||||||
|
if ee.Path != "inner.f" {
|
||||||
|
t.Fatalf("Path = %q, want %q", ee.Path, "inner.f")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMarshalTextRejectsInvalidUTF8(t *testing.T) {
|
||||||
|
// A TOML string holds UTF-8 only, so text that is not gets an error rather
|
||||||
|
// than replacement characters.
|
||||||
|
_, err := Marshal(struct {
|
||||||
|
V notUTF8 `toml:"v"`
|
||||||
|
}{})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected an error for text that is not valid UTF-8")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "UTF-8") {
|
||||||
|
t.Errorf("err = %v, want a UTF-8 message", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMarshalTextValuesRoundTrip(t *testing.T) {
|
||||||
|
type Cfg struct {
|
||||||
|
Duration time.Duration `toml:"duration"`
|
||||||
|
IP net.IP `toml:"ip"`
|
||||||
|
Tag textTagBoth `toml:"tag"`
|
||||||
|
}
|
||||||
|
in := Cfg{Duration: 90 * time.Minute, IP: net.IPv4(198, 51, 100, 7), Tag: "y"}
|
||||||
|
out, err := Marshal(&in)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal: %v", err)
|
||||||
|
}
|
||||||
|
var back Cfg
|
||||||
|
if err := Unmarshal(out, &back); err != nil {
|
||||||
|
t.Fatalf("unmarshal: %v", err)
|
||||||
|
}
|
||||||
|
if back.Duration != in.Duration {
|
||||||
|
t.Errorf("Duration = %v, want %v", back.Duration, in.Duration)
|
||||||
|
}
|
||||||
|
if !back.IP.Equal(in.IP) {
|
||||||
|
t.Errorf("IP = %v, want %v", back.IP, in.IP)
|
||||||
|
}
|
||||||
|
if back.Tag != in.Tag {
|
||||||
|
t.Errorf("Tag = %q, want %q", back.Tag, in.Tag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- TOML 1.1 output forms -------------------------------------------------
|
||||||
|
|
||||||
|
func TestMarshalDateTimeRendering(t *testing.T) {
|
||||||
|
// The seconds are written only when the value carries them, and a fraction
|
||||||
|
// drops its trailing zeros. Both are the same value either way; the shorter
|
||||||
|
// form is the one TOML 1.1 allows.
|
||||||
|
base := time.Date(2026, 6, 26, 10, 0, 0, 0, time.UTC)
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
val any
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"offset-zero-seconds", base, "v = 2026-06-26T10:00Z\n"},
|
||||||
|
{"offset-seconds", base.Add(30 * time.Second), "v = 2026-06-26T10:00:30Z\n"},
|
||||||
|
{"offset-fraction", base.Add(500 * time.Millisecond), "v = 2026-06-26T10:00:00.5Z\n"},
|
||||||
|
{"offset-zone", time.Date(2026, 6, 26, 10, 0, 0, 0, time.FixedZone("", -7*3600)), "v = 2026-06-26T10:00-07:00\n"},
|
||||||
|
{"local-zero-seconds", LocalDateTime{Time: base}, "v = 2026-06-26T10:00\n"},
|
||||||
|
{"local-fraction", LocalDateTime{Time: base.Add(2500 * time.Millisecond)}, "v = 2026-06-26T10:00:02.5\n"},
|
||||||
|
{"date", LocalDate{Time: base}, "v = 2026-06-26\n"},
|
||||||
|
{"time-zero-seconds", LocalTime{Time: base}, "v = 10:00\n"},
|
||||||
|
{"time-seconds", LocalTime{Time: base.Add(15 * time.Second)}, "v = 10:00:15\n"},
|
||||||
|
{"time-nanoseconds", LocalTime{Time: base.Add(123456789 * time.Nanosecond)}, "v = 10:00:00.123456789\n"},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
out, err := Marshal(map[string]any{"v": c.val})
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("%s: marshal: %v", c.name, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if string(out) != c.want {
|
||||||
|
t.Errorf("%s: output mismatch:\ngot: %q\nwant: %q", c.name, out, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMarshalInlineTableBreaksWhenLong(t *testing.T) {
|
||||||
|
// A table element of a value array is written inline; a long one carries
|
||||||
|
// newlines and a trailing comma instead of running past the line limit,
|
||||||
|
// which TOML 1.1 allows an inline table to do.
|
||||||
|
const long = "a-very-long-value-that-pushes-the-line-well-past-the-one-hundred-column-limit"
|
||||||
|
type Cfg struct {
|
||||||
|
Arr []any `toml:"arr"`
|
||||||
|
}
|
||||||
|
out, err := Marshal(Cfg{Arr: []any{int64(1), map[string]any{"n": int64(1), "name": long}}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal: %v", err)
|
||||||
|
}
|
||||||
|
want := "arr = [1, {\n\tn = 1,\n\tname = \"" + long + "\",\n}]\n"
|
||||||
|
if string(out) != want {
|
||||||
|
t.Errorf("output mismatch:\ngot: %q\nwant: %q", out, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The same values without the long string stay on one line.
|
||||||
|
out, err = Marshal(Cfg{Arr: []any{int64(1), map[string]any{"n": int64(1), "name": "short"}}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal: %v", err)
|
||||||
|
}
|
||||||
|
if want := "arr = [1, {n = 1, name = \"short\"}]\n"; string(out) != want {
|
||||||
|
t.Errorf("output mismatch:\ngot: %q\nwant: %q", out, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The broken form parses back to the same tree.
|
||||||
|
tree, err := Parse(out)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse of the encoder output: %v", err)
|
||||||
|
}
|
||||||
|
if got := len(tree["arr"].([]any)); got != 2 {
|
||||||
|
t.Fatalf("arr has %d elements, want 2", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMarshalNestedInlineTableBreaksIndependently(t *testing.T) {
|
||||||
|
// A nested table breaks on its own measure, so a table whose entries stay
|
||||||
|
// short keeps the one-line form inside a parent that broke.
|
||||||
|
const long = "a-very-long-value-that-pushes-the-line-well-past-the-one-hundred-column-limit"
|
||||||
|
type Cfg struct {
|
||||||
|
Arr []any `toml:"arr"`
|
||||||
|
}
|
||||||
|
out, err := Marshal(Cfg{Arr: []any{int64(1), map[string]any{"n": int64(1), "sub": map[string]any{"name": long}}}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal: %v", err)
|
||||||
|
}
|
||||||
|
want := "arr = [1, {\n\tn = 1,\n\tsub = {name = \"" + long + "\"},\n}]\n"
|
||||||
|
if string(out) != want {
|
||||||
|
t.Errorf("output mismatch:\ngot: %q\nwant: %q", out, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type inlineTLS struct {
|
||||||
|
On bool `toml:"on"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type inlineServer struct {
|
||||||
|
Host string `toml:"host"`
|
||||||
|
Port int `toml:"port"`
|
||||||
|
TLS inlineTLS `toml:"tls"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type inlineBig struct {
|
||||||
|
A int `toml:"a"`
|
||||||
|
B int `toml:"b"`
|
||||||
|
C int `toml:"c"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEncoderInlineTables(t *testing.T) {
|
||||||
|
type Cfg struct {
|
||||||
|
Server inlineServer `toml:"server"`
|
||||||
|
Big inlineBig `toml:"big"`
|
||||||
|
}
|
||||||
|
cfg := Cfg{Server: inlineServer{Host: "127.0.0.1", Port: 9090}, Big: inlineBig{A: 1, B: 2, C: 3}}
|
||||||
|
|
||||||
|
// The default keeps every sub-table a header section.
|
||||||
|
headerForm, err := Marshal(cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal: %v", err)
|
||||||
|
}
|
||||||
|
want := "[server]\nhost = \"127.0.0.1\"\nport = 9090\n\n[server.tls]\non = false\n\n[big]\na = 1\nb = 2\nc = 3\n"
|
||||||
|
if string(headerForm) != want {
|
||||||
|
t.Errorf("default output mismatch:\ngot: %q\nwant: %q", headerForm, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
// With the option both fit the threshold and become inline tables, nested
|
||||||
|
// ones included.
|
||||||
|
out, err := NewEncoder().InlineTables(60).Marshal(cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal: %v", err)
|
||||||
|
}
|
||||||
|
want = "server = {host = \"127.0.0.1\", port = 9090, tls = {on = false}}\nbig = {a = 1, b = 2, c = 3}\n"
|
||||||
|
if string(out) != want {
|
||||||
|
t.Errorf("compact output mismatch:\ngot: %q\nwant: %q", out, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A threshold below the rendering keeps the header form.
|
||||||
|
out, err = NewEncoder().InlineTables(10).Marshal(cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal: %v", err)
|
||||||
|
}
|
||||||
|
if string(out) != string(headerForm) {
|
||||||
|
t.Errorf("small threshold output mismatch:\ngot: %q\nwant: %q", out, headerForm)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEncoderInlineTablesOrderAndRoundTrip(t *testing.T) {
|
||||||
|
// An inlined sub-table is a value line, so it precedes every header of the
|
||||||
|
// document; written after a header it would be read back as part of that
|
||||||
|
// table. The compact form and the header form parse to the same tree.
|
||||||
|
type Four struct {
|
||||||
|
A int `toml:"a"`
|
||||||
|
B int `toml:"b"`
|
||||||
|
C int `toml:"c"`
|
||||||
|
D int `toml:"d"`
|
||||||
|
}
|
||||||
|
type Cfg struct {
|
||||||
|
Small inlineTLS `toml:"small"`
|
||||||
|
Big Four `toml:"big"`
|
||||||
|
}
|
||||||
|
cfg := Cfg{Small: inlineTLS{On: true}, Big: Four{A: 1, B: 2, C: 3, D: 4}}
|
||||||
|
|
||||||
|
headerForm, err := Marshal(cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal: %v", err)
|
||||||
|
}
|
||||||
|
compact, err := NewEncoder().InlineTables(20).Marshal(cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal: %v", err)
|
||||||
|
}
|
||||||
|
want := "small = {on = true}\n\n[big]\na = 1\nb = 2\nc = 3\nd = 4\n"
|
||||||
|
if string(compact) != want {
|
||||||
|
t.Errorf("output mismatch:\ngot: %q\nwant: %q", compact, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := Parse(compact)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse of the compact output: %v", err)
|
||||||
|
}
|
||||||
|
ref, err := Parse(headerForm)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse of the header output: %v", err)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(got, ref) {
|
||||||
|
t.Errorf("the compact form changed the tree:\ncompact: %#v\nheaders: %#v", got, ref)
|
||||||
|
}
|
||||||
|
if _, ok := got["big"].(map[string]any); !ok {
|
||||||
|
t.Errorf("big = %#v, want a table", got["big"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEncoderInlineTablesKeepsArraysOfTables(t *testing.T) {
|
||||||
|
// An array of tables has no inline form that keeps the value's type, so the
|
||||||
|
// option leaves it alone and the tree keeps its []map[string]any shape.
|
||||||
|
type Item struct {
|
||||||
|
N int `toml:"n"`
|
||||||
|
}
|
||||||
|
type Cfg struct {
|
||||||
|
Items []Item `toml:"items"`
|
||||||
|
Small inlineTLS `toml:"small"`
|
||||||
|
}
|
||||||
|
cfg := Cfg{Items: []Item{{N: 1}}, Small: inlineTLS{On: true}}
|
||||||
|
out, err := NewEncoder().InlineTables(60).Marshal(cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal: %v", err)
|
||||||
|
}
|
||||||
|
want := "small = {on = true}\n\n[[items]]\nn = 1\n"
|
||||||
|
if string(out) != want {
|
||||||
|
t.Errorf("output mismatch:\ngot: %q\nwant: %q", out, want)
|
||||||
|
}
|
||||||
|
tree, err := Parse(out)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse: %v", err)
|
||||||
|
}
|
||||||
|
if _, ok := tree["items"].([]map[string]any); !ok {
|
||||||
|
t.Errorf("items = %#v, want []map[string]any", tree["items"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"sourcedock.dev/petrbalvin/interpres"
|
"sourcedock.dev/petrbalvin/interpres/v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
// document is a small but realistic configuration: it has scalars, a
|
// document is a small but realistic configuration: it has scalars, a
|
||||||
|
|||||||
+123
@@ -0,0 +1,123 @@
|
|||||||
|
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package interpres
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math"
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FuzzParse drives the parser with arbitrary input and holds it to the
|
||||||
|
// round-trip invariant: every document Parse accepts must survive its own
|
||||||
|
// re-emission. Marshal of the parsed tree must succeed, the emitted document
|
||||||
|
// must parse again, and the re-parsed tree must equal the original one.
|
||||||
|
func FuzzParse(f *testing.F) {
|
||||||
|
seeds := []string{
|
||||||
|
"",
|
||||||
|
"title = \"interpres\"\n",
|
||||||
|
"[server]\nhost = \"localhost\"\nport = 8080\n\n[server.tls]\nenabled = true\n",
|
||||||
|
"[[items]]\nname = \"a\"\n\n[[items]]\nname = \"b\"\n",
|
||||||
|
"inline = { a = 1, b = [2, 3], c = { d = 4 } }\n",
|
||||||
|
"arr = [1, 2.5, \"three\", true, 1979-05-27T07:32:00Z]\n",
|
||||||
|
"mix = [1, {a = 2}, \"x\"]\n",
|
||||||
|
"when = 1979-05-27T07:32:00Z\nlocal = 1979-05-27T07:32:00.999\nd = 1979-05-27\nt = 07:32:00\n",
|
||||||
|
"multi = \"\"\"\nlines\n\"\"\"\nlit = 'literal'\n",
|
||||||
|
"esc = \"\\u0000\\t\\n\\\"\\\\\"\n",
|
||||||
|
"neg = -0.0\nnan = nan\ninf = -inf\nexp = 1e6\n",
|
||||||
|
"\"quoted key\" = 'value'\n'1979-05-27' = 1\na.b.c = { d = \"dotted\" }\n",
|
||||||
|
"hex = 0xFF\noct = 0o755\nbin = 0b1010\nsep = 1_000_000\n",
|
||||||
|
"x = \"unterminated\n",
|
||||||
|
"[a]\n[a]\n",
|
||||||
|
"n = 0x1_0000_0000_0000_0000\n",
|
||||||
|
// TOML 1.1 forms.
|
||||||
|
"t = 13:37\ndt = 1979-05-27T07:32\nodt = 1979-05-27 07:32Z\n",
|
||||||
|
"esc = \"\\e\\x41\\x7f\\x00\"\n",
|
||||||
|
"m = {\n\ta = 1,\n\tb = [1, 2,],\n\tc = { d = 2 },\n} # close\n",
|
||||||
|
}
|
||||||
|
for _, s := range seeds {
|
||||||
|
f.Add([]byte(s))
|
||||||
|
}
|
||||||
|
f.Fuzz(func(t *testing.T, data []byte) {
|
||||||
|
tree, err := Parse(data)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out, err := Marshal(tree)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal of a parsed tree failed: %v\ntree: %#v", err, tree)
|
||||||
|
}
|
||||||
|
re, err := Parse(out)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("re-parse of the emitted document failed: %v\ndoc:\n%s", err, out)
|
||||||
|
}
|
||||||
|
if !tomlEqual(tree, re) {
|
||||||
|
t.Fatalf("round-trip changed the tree\ninput: %q\ndoc:\n%s\nwas: %#v\nnow: %#v", data, out, tree, re)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// tomlEqual reports whether two parsed trees are equal as TOML values. It
|
||||||
|
// differs from reflect.DeepEqual where DeepEqual is wrong for this domain:
|
||||||
|
// NaN compares equal to itself, date-times compare by their canonical TOML
|
||||||
|
// rendering so two parses of one document stay equal, and the local variants
|
||||||
|
// compare through their String form, which fully determines the value.
|
||||||
|
func tomlEqual(a, b any) bool {
|
||||||
|
switch av := a.(type) {
|
||||||
|
case nil:
|
||||||
|
return b == nil
|
||||||
|
case float64:
|
||||||
|
bv, ok := b.(float64)
|
||||||
|
return ok && (av == bv || (math.IsNaN(av) && math.IsNaN(bv)))
|
||||||
|
case time.Time:
|
||||||
|
bv, ok := b.(time.Time)
|
||||||
|
return ok && av.Format(time.RFC3339Nano) == bv.Format(time.RFC3339Nano)
|
||||||
|
case LocalDateTime:
|
||||||
|
bv, ok := b.(LocalDateTime)
|
||||||
|
return ok && av.String() == bv.String()
|
||||||
|
case LocalDate:
|
||||||
|
bv, ok := b.(LocalDate)
|
||||||
|
return ok && av.String() == bv.String()
|
||||||
|
case LocalTime:
|
||||||
|
bv, ok := b.(LocalTime)
|
||||||
|
return ok && av.String() == bv.String()
|
||||||
|
case []any:
|
||||||
|
bv, ok := b.([]any)
|
||||||
|
if !ok || len(av) != len(bv) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for i := range av {
|
||||||
|
if !tomlEqual(av[i], bv[i]) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
case []map[string]any:
|
||||||
|
bv, ok := b.([]map[string]any)
|
||||||
|
if !ok || len(av) != len(bv) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for i := range av {
|
||||||
|
if !tomlEqual(av[i], bv[i]) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
case map[string]any:
|
||||||
|
bv, ok := b.(map[string]any)
|
||||||
|
if !ok || len(av) != len(bv) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for k, v := range av {
|
||||||
|
other, ok := bv[k]
|
||||||
|
if !ok || !tomlEqual(v, other) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return reflect.DeepEqual(a, b)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,3 @@
|
|||||||
module sourcedock.dev/petrbalvin/interpres
|
module sourcedock.dev/petrbalvin/interpres/v2
|
||||||
|
|
||||||
go 1.27.0
|
go 1.27.1
|
||||||
|
|||||||
+111
-10
@@ -21,6 +21,7 @@ package interpres
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
)
|
)
|
||||||
@@ -36,6 +37,58 @@ func (e *SyntaxError) Error() string {
|
|||||||
return fmt.Sprintf("interpres: line %d: %s", e.Line, e.Msg)
|
return fmt.Sprintf("interpres: line %d: %s", e.Line, e.Msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A DecodeError wraps a decoding failure with the key path at which it
|
||||||
|
// happened. Path lists one segment per level from the document root, the
|
||||||
|
// outermost key first: a key contributes its name and an array element its
|
||||||
|
// bracketed index, so the path of the weight field in the first item reads
|
||||||
|
// ["items", "[0]", "weight"]. The rendered message is unchanged by the type;
|
||||||
|
// read it programmatically with errors.AsType:
|
||||||
|
//
|
||||||
|
// if de, ok := errors.AsType[*interpres.DecodeError](err); ok {
|
||||||
|
// fmt.Println(de.Path, de.Err)
|
||||||
|
// }
|
||||||
|
type DecodeError struct {
|
||||||
|
// Path is the key path from the document root, outermost key first.
|
||||||
|
Path []string
|
||||||
|
// Err is the failure at that path.
|
||||||
|
Err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *DecodeError) Error() string { return e.Path[0] + ": " + e.Err.Error() }
|
||||||
|
|
||||||
|
// Unwrap returns the failure the path points at.
|
||||||
|
func (e *DecodeError) Unwrap() error { return e.Err }
|
||||||
|
|
||||||
|
// newDecodeError wraps err with one path segment. The rest of the path comes
|
||||||
|
// from the DecodeError err already carries, if any: the decoder wraps each
|
||||||
|
// key and index on its way down, so the innermost wrap holds the deepest
|
||||||
|
// segments and each outer wrap prepends one.
|
||||||
|
func newDecodeError(key string, err error) *DecodeError {
|
||||||
|
path := make([]string, 0, 4)
|
||||||
|
path = append(path, key)
|
||||||
|
if de, ok := errors.AsType[*DecodeError](err); ok {
|
||||||
|
path = append(path, de.Path...)
|
||||||
|
}
|
||||||
|
return &DecodeError{Path: path, Err: err}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An EncodeError wraps an encoding failure with the key path of the value
|
||||||
|
// that failed, in the notation of a TOML document: fields join with dots and
|
||||||
|
// an array element carries its bracketed index, so the path of the third
|
||||||
|
// port under server reads "server.ports[2]". The rendered message is
|
||||||
|
// unchanged by the type; read it programmatically with errors.AsType.
|
||||||
|
type EncodeError struct {
|
||||||
|
// Path is the key path of the failing value.
|
||||||
|
Path string
|
||||||
|
// Err is the failure at that path.
|
||||||
|
Err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *EncodeError) Error() string { return "interpres: " + e.Path + ": " + e.Err.Error() }
|
||||||
|
|
||||||
|
// Unwrap returns the failure the path points at.
|
||||||
|
func (e *EncodeError) Unwrap() error { return e.Err }
|
||||||
|
|
||||||
// Parse decodes a TOML document into a nested map[string]any.
|
// Parse decodes a TOML document into a nested map[string]any.
|
||||||
//
|
//
|
||||||
// Values are mapped to Go types as follows: strings to string, integers to
|
// Values are mapped to Go types as follows: strings to string, integers to
|
||||||
@@ -57,7 +110,9 @@ func ParseContext(ctx context.Context, data []byte) (map[string]any, error) {
|
|||||||
if !utf8.Valid(data) {
|
if !utf8.Valid(data) {
|
||||||
return nil, &SyntaxError{Line: 1, Msg: "input is not valid UTF-8"}
|
return nil, &SyntaxError{Line: 1, Msg: "input is not valid UTF-8"}
|
||||||
}
|
}
|
||||||
p := &parser{src: []rune(string(data)), line: 1, ctx: ctx}
|
// The parser scans data in place; it only reads the buffer, and every
|
||||||
|
// string it stores in the tree is copied out of it.
|
||||||
|
p := &parser{src: data, line: 1, ctx: ctx}
|
||||||
return p.parse()
|
return p.parse()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,6 +123,11 @@ func ParseContext(ctx context.Context, data []byte) (map[string]any, error) {
|
|||||||
// case-insensitive match on the field name when no tag is present. A tag of
|
// case-insensitive match on the field name when no tag is present. A tag of
|
||||||
// "-" skips the field.
|
// "-" skips the field.
|
||||||
//
|
//
|
||||||
|
// A destination implementing Unmarshaler receives the parsed value as it is,
|
||||||
|
// a TOML string fills a destination implementing encoding.TextUnmarshaler, and
|
||||||
|
// a time.Duration destination takes a duration literal such as `1h30m` or a
|
||||||
|
// bare integer as its nanosecond count.
|
||||||
|
//
|
||||||
// Unmarshal is equivalent to UnmarshalContext with context.Background.
|
// Unmarshal is equivalent to UnmarshalContext with context.Background.
|
||||||
func Unmarshal(data []byte, v any) error {
|
func Unmarshal(data []byte, v any) error {
|
||||||
return UnmarshalContext(context.Background(), data, v)
|
return UnmarshalContext(context.Background(), data, v)
|
||||||
@@ -122,6 +182,10 @@ func (d *Decoder) DecodeContext(ctx context.Context, data []byte, v any) error {
|
|||||||
// then encodes as if the returned value had been passed in its place, which
|
// 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
|
// 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).
|
// struct as an inline table or a primitive alias as a richer value).
|
||||||
|
//
|
||||||
|
// MarshalTOML wins over encoding.TextMarshaler when a type implements both.
|
||||||
|
// A type that implements only encoding.TextMarshaler is encoded as a TOML
|
||||||
|
// string holding its text, and needs no method here.
|
||||||
type Marshaler interface {
|
type Marshaler interface {
|
||||||
MarshalTOML() (any, error)
|
MarshalTOML() (any, error)
|
||||||
}
|
}
|
||||||
@@ -139,29 +203,45 @@ type Marshaler interface {
|
|||||||
// UnmarshalTOML is invoked from (*Decoder).Decode / Unmarshal when the
|
// UnmarshalTOML is invoked from (*Decoder).Decode / Unmarshal when the
|
||||||
// destination type implements the interface. The decoder does not need to
|
// destination type implements the interface. The decoder does not need to
|
||||||
// consult the concrete return value; whatever the receiver stores is kept.
|
// consult the concrete return value; whatever the receiver stores is kept.
|
||||||
|
//
|
||||||
|
// UnmarshalTOML wins over encoding.TextUnmarshaler when a type implements
|
||||||
|
// both. A type that implements only encoding.TextUnmarshaler is filled from a
|
||||||
|
// TOML string holding its text, and needs no method here.
|
||||||
type Unmarshaler interface {
|
type Unmarshaler interface {
|
||||||
UnmarshalTOML(data any) error
|
UnmarshalTOML(data any) error
|
||||||
}
|
}
|
||||||
|
|
||||||
// Marshal returns the TOML 1.0 encoding of v.
|
// Marshal returns the TOML encoding of v. The output is valid TOML 1.1.
|
||||||
//
|
//
|
||||||
// Marshal traverses v using reflection and applies the following rules:
|
// 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
|
// - The top-level value must be a struct or a map[string]V. Pointers are
|
||||||
// followed; a nil top-level pointer is an error.
|
// followed; a nil top-level pointer is an error.
|
||||||
// - Struct fields are matched by `toml:"name"` tag (case-insensitive
|
// - Struct fields are matched by `toml:"name"` tag (case-insensitive
|
||||||
// fallback to field name; `-` skips). Anonymous (embedded) fields without
|
// fallback to field name; `-` skips). The tag options `omitzero` (skip
|
||||||
// a tag are inlined.
|
// the zero value of the field's type) and `omitempty` (skip an empty
|
||||||
|
// slice, array, or map) drop a field from the output on encode; the
|
||||||
|
// decoder ignores them. Anonymous (embedded) fields without a tag are
|
||||||
|
// inlined.
|
||||||
// - Maps use sorted keys for deterministic output.
|
// - Maps use sorted keys for deterministic output.
|
||||||
// - Slices and arrays of structs or maps become TOML arrays of tables; a
|
// - 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]]`),
|
// nil or empty array of tables is omitted (TOML forbids an empty `[[a]]`),
|
||||||
// while other empty arrays emit as `key = []`.
|
// while other empty arrays emit as `key = []`.
|
||||||
// - Other slices and arrays become TOML arrays.
|
// - Other slices and arrays become TOML arrays; a table element inside a
|
||||||
|
// value array (for example an inline table in a mixed array) emits as an
|
||||||
|
// inline table.
|
||||||
// - Scalars encode as TOML scalars: bool, int64, float64, string, time.Time
|
// - Scalars encode as TOML scalars: bool, int64, float64, string, time.Time
|
||||||
// (offset date-time), and LocalDateTime/LocalDate/LocalTime (local
|
// (offset date-time), and LocalDateTime/LocalDate/LocalTime (local
|
||||||
// variants).
|
// variants). A date-time writes its seconds only when the value carries
|
||||||
|
// them, and drops the trailing zeros of a fractional second.
|
||||||
|
// - A table element of a value array, and a sub-table inlined by
|
||||||
|
// Encoder.InlineTables, is written as an inline table, across lines when it
|
||||||
|
// does not fit one.
|
||||||
// - Values implementing Marshaler are encoded by calling MarshalTOML and
|
// - Values implementing Marshaler are encoded by calling MarshalTOML and
|
||||||
// using its result.
|
// using its result.
|
||||||
|
// - Values implementing encoding.TextMarshaler, and not one of the
|
||||||
|
// date-time types, encode as a TOML string holding the text the method
|
||||||
|
// returns. time.Duration is written in its canonical Go form, `1h30m0s`.
|
||||||
// - nil pointer fields are omitted.
|
// - nil pointer fields are omitted.
|
||||||
//
|
//
|
||||||
// Marshal cannot encode cyclic data structures; passing one will loop until
|
// Marshal cannot encode cyclic data structures; passing one will loop until
|
||||||
@@ -185,14 +265,16 @@ func MarshalContext(ctx context.Context, v any) ([]byte, error) {
|
|||||||
|
|
||||||
// An Encoder encodes Go values into TOML.
|
// An Encoder encodes Go values into TOML.
|
||||||
//
|
//
|
||||||
// All options default to behaviour that preserves byte-for-byte compatibility
|
// All options default to the behaviour that passes the toml-test compliance
|
||||||
// with previous releases and passes the toml-test compliance suite:
|
// suite in both directions:
|
||||||
//
|
//
|
||||||
// GroupByKind: true (scalars first, then tables, then arrays of tables)
|
// GroupByKind: true (scalars first, then tables, then arrays of tables)
|
||||||
// OmitEmptyArrays: false (a nil/empty []string slice emits [] as a value;
|
// OmitEmptyArrays: false (a nil/empty []string slice emits [] as a value;
|
||||||
// a nil/empty []Item struct slice is still skipped)
|
// a nil/empty []Item struct slice is still skipped)
|
||||||
// LiteralMultilineAt: 0 (always emit basic multi-line strings with
|
// LiteralMultilineAt: 0 (always emit the escaped basic form, never a
|
||||||
// escape sequences, never literal ones)
|
// literal one)
|
||||||
|
// InlineTablesAt: 0 (always emit a table header, never an inline
|
||||||
|
// table)
|
||||||
//
|
//
|
||||||
// Use the chainable option methods to opt out. The option state is private;
|
// 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
|
// callers that need the underlying knobs reach for the methods rather than
|
||||||
@@ -201,6 +283,7 @@ type Encoder struct {
|
|||||||
groupByKind bool // default true; set via (*Encoder).GroupByKind
|
groupByKind bool // default true; set via (*Encoder).GroupByKind
|
||||||
omitEmptyArrays bool // default false; set via (*Encoder).OmitEmptyArrays
|
omitEmptyArrays bool // default false; set via (*Encoder).OmitEmptyArrays
|
||||||
literalMultilineAt int // default 0; set via (*Encoder).UseLiteralMultiline
|
literalMultilineAt int // default 0; set via (*Encoder).UseLiteralMultiline
|
||||||
|
inlineTablesAt int // default 0; set via (*Encoder).InlineTables
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewEncoder returns an Encoder with default options.
|
// NewEncoder returns an Encoder with default options.
|
||||||
@@ -233,6 +316,24 @@ func (e *Encoder) UseLiteralMultiline(threshold int) *Encoder {
|
|||||||
return e
|
return e
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// InlineTables sets the size limit, in bytes of the single-line rendering, at
|
||||||
|
// which a sub-table is written as an inline table instead of a table header,
|
||||||
|
// which makes a document of small tables shorter. Use 0 or any negative value
|
||||||
|
// to disable (always emit a header).
|
||||||
|
//
|
||||||
|
// A sub-table is inlined only when doing so keeps every value's type: an array
|
||||||
|
// of tables keeps its header form, because its inline form would re-parse as a
|
||||||
|
// value array. An inlined table that does not fit the line is written across
|
||||||
|
// lines, which TOML 1.1 allows.
|
||||||
|
//
|
||||||
|
// With GroupByKind(false) the layout is already for presentation only, and an
|
||||||
|
// inlined table follows the same rule as any other value line: it lands in the
|
||||||
|
// section of the header that precedes it.
|
||||||
|
func (e *Encoder) InlineTables(threshold int) *Encoder {
|
||||||
|
e.inlineTablesAt = threshold
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
|
||||||
// Marshal encodes v to TOML bytes. It is equivalent to calling Marshal with v.
|
// Marshal encodes v to TOML bytes. It is equivalent to calling Marshal with v.
|
||||||
//
|
//
|
||||||
// Marshal is equivalent to MarshalContext with context.Background.
|
// Marshal is equivalent to MarshalContext with context.Background.
|
||||||
|
|||||||
+193
-1
@@ -5,6 +5,7 @@ package interpres
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"math"
|
"math"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -316,6 +317,26 @@ func TestDisallowUnknownFields(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDisallowUnknownFieldsReportsSmallestKey(t *testing.T) {
|
||||||
|
// Map iteration order is random, so the reported key must be chosen
|
||||||
|
// deterministically: the smallest unknown key, whichever order the map
|
||||||
|
// iterates in.
|
||||||
|
type C struct {
|
||||||
|
Known string `toml:"known"`
|
||||||
|
}
|
||||||
|
data := []byte("known = \"x\"\nzeta = 1\nalpha = 2\nmu = 3\n")
|
||||||
|
for range 20 {
|
||||||
|
var c C
|
||||||
|
err := NewDecoder().DisallowUnknownFields().Decode(data, &c)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for unknown fields")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), `unknown field "alpha"`) {
|
||||||
|
t.Fatalf("err = %v, want the smallest unknown key alpha", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestSkippedFieldTag(t *testing.T) {
|
func TestSkippedFieldTag(t *testing.T) {
|
||||||
type C struct {
|
type C struct {
|
||||||
Keep string `toml:"keep"`
|
Keep string `toml:"keep"`
|
||||||
@@ -370,6 +391,7 @@ func TestRejectsInvalidNumbers(t *testing.T) {
|
|||||||
"01", "-01", "00",
|
"01", "-01", "00",
|
||||||
"1__0", "_1", "1_", "0x_1", "1_.0",
|
"1__0", "_1", "1_", "0x_1", "1_.0",
|
||||||
"1.", ".5", "1.2.3", "1.e2",
|
"1.", ".5", "1.2.3", "1.e2",
|
||||||
|
"1e", "1e+", "1e-", "0.0E", "0.0e", "1.5e+",
|
||||||
"0x", "0o", "0b", "0b2", "0o8", "0xG",
|
"0x", "0o", "0b", "0b2", "0o8", "0xG",
|
||||||
"+0x1",
|
"+0x1",
|
||||||
} {
|
} {
|
||||||
@@ -379,6 +401,34 @@ func TestRejectsInvalidNumbers(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestParseRejectsOffsetOutOfRange(t *testing.T) {
|
||||||
|
for _, tok := range []string{
|
||||||
|
"1979-05-27T07:32:00+00:60",
|
||||||
|
"1979-05-27T07:32:00-00:99",
|
||||||
|
"1979-05-27T07:32:00+24:00",
|
||||||
|
"1979-05-27T07:32:00+99:99",
|
||||||
|
} {
|
||||||
|
if _, err := Parse([]byte("v = " + tok + "\n")); err == nil {
|
||||||
|
t.Errorf("%q: expected an error, got none", tok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseAcceptsOffsetBounds(t *testing.T) {
|
||||||
|
tree, err := Parse([]byte("a = 1979-05-27T07:32:00+23:59\nb = 1979-05-27T07:32:00-23:59\n"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse: %v", err)
|
||||||
|
}
|
||||||
|
a := tree["a"].(time.Time)
|
||||||
|
if _, offset := a.Zone(); offset != 23*3600+59*60 {
|
||||||
|
t.Fatalf("a offset = %d, want %d", offset, 23*3600+59*60)
|
||||||
|
}
|
||||||
|
b := tree["b"].(time.Time)
|
||||||
|
if _, offset := b.Zone(); offset != -(23*3600 + 59*60) {
|
||||||
|
t.Fatalf("b offset = %d", offset)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestAcceptsNumberEdgeCases(t *testing.T) {
|
func TestAcceptsNumberEdgeCases(t *testing.T) {
|
||||||
cases := map[string]any{
|
cases := map[string]any{
|
||||||
"0": int64(0),
|
"0": int64(0),
|
||||||
@@ -392,6 +442,10 @@ func TestAcceptsNumberEdgeCases(t *testing.T) {
|
|||||||
"3.14": 3.14,
|
"3.14": 3.14,
|
||||||
"6.022e23": 6.022e23,
|
"6.022e23": 6.022e23,
|
||||||
"1e10": 1e10,
|
"1e10": 1e10,
|
||||||
|
"1e0": 1.0,
|
||||||
|
"1e06": 1e6,
|
||||||
|
"0e00": 0.0,
|
||||||
|
"2E-3": 2e-3,
|
||||||
"-2.5E-3": -2.5e-3,
|
"-2.5E-3": -2.5e-3,
|
||||||
}
|
}
|
||||||
for tok, want := range cases {
|
for tok, want := range cases {
|
||||||
@@ -458,6 +512,9 @@ func TestRejectsInlineTableExtension(t *testing.T) {
|
|||||||
"by header": "a = { b = 1 }\n[a.c]\nx = 2\n",
|
"by header": "a = { b = 1 }\n[a.c]\nx = 2\n",
|
||||||
"by dotted key": "a = { b = 1 }\na.c = 2\n",
|
"by dotted key": "a = { b = 1 }\na.c = 2\n",
|
||||||
"header over it": "a = { b = 1 }\n[a]\nx = 2\n",
|
"header over it": "a = { b = 1 }\n[a]\nx = 2\n",
|
||||||
|
// The frozen check must cover the intermediate steps of an array-of-tables
|
||||||
|
// header, not only the leaf: [[a.b.c]] walks through a and a.b.
|
||||||
|
"by nested array header": "a = { b = {} }\n[[a.b.c]]\nx = 2\n",
|
||||||
}
|
}
|
||||||
for name, doc := range cases {
|
for name, doc := range cases {
|
||||||
if _, err := Parse([]byte(doc)); err == nil {
|
if _, err := Parse([]byte(doc)); err == nil {
|
||||||
@@ -466,6 +523,36 @@ func TestRejectsInlineTableExtension(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A new element of an array of tables starts a fresh scope: sub-table headers,
|
||||||
|
// nested arrays of tables, and dotted-key paths recorded for the previous
|
||||||
|
// element must not block the same paths in the next one.
|
||||||
|
func TestArrayOfTablesFreshScopePerElement(t *testing.T) {
|
||||||
|
cases := map[string]string{
|
||||||
|
"nested array of tables": "[[a]]\n[[a.b]]\nx = 1\n[[a]]\n[a.b]\ny = 2\n",
|
||||||
|
"dotted key": "[[a]]\nb.c = 1\n[[a]]\n[a.b]\nd = 2\n",
|
||||||
|
}
|
||||||
|
for name, doc := range cases {
|
||||||
|
tree, err := Parse([]byte(doc))
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("%s: %v", name, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
elements := tree["a"].([]map[string]any)
|
||||||
|
if len(elements) != 2 {
|
||||||
|
t.Errorf("%s: len(a) = %d, want 2", name, len(elements))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Within one element the redefinition rules keep applying.
|
||||||
|
for name, doc := range map[string]string{
|
||||||
|
"header over dotted in one element": "[[a]]\nb.c = 1\n[a.b]\nd = 2\n",
|
||||||
|
"table over nested array": "[[a]]\n[[a.b]]\n[a.b]\nx = 1\n",
|
||||||
|
} {
|
||||||
|
if _, err := Parse([]byte(doc)); err == nil {
|
||||||
|
t.Errorf("%s: expected an error, got none", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestRejectsSpecInvalid(t *testing.T) {
|
func TestRejectsSpecInvalid(t *testing.T) {
|
||||||
cases := map[string]string{
|
cases := map[string]string{
|
||||||
"single-digit hour": "a = 2023-10-01T1:32:00Z\n",
|
"single-digit hour": "a = 2023-10-01T1:32:00Z\n",
|
||||||
@@ -474,7 +561,8 @@ func TestRejectsSpecInvalid(t *testing.T) {
|
|||||||
"dotted over header": "[a.b]\nx = 1\n[a]\nb.y = 2\n",
|
"dotted over header": "[a.b]\nx = 1\n[a]\nb.y = 2\n",
|
||||||
"table over array": "[[t]]\n[t]\n",
|
"table over array": "[[t]]\n[t]\n",
|
||||||
"truncated datetime": "a = 2026-01-02T\n",
|
"truncated datetime": "a = 2026-01-02T\n",
|
||||||
"datetime no seconds": "a = 2026-01-02T07:32\n",
|
// "datetime no seconds" moved to the acceptance tests: TOML 1.1
|
||||||
|
// makes the seconds optional.
|
||||||
}
|
}
|
||||||
for name, doc := range cases {
|
for name, doc := range cases {
|
||||||
if _, err := Parse([]byte(doc)); err == nil {
|
if _, err := Parse([]byte(doc)); err == nil {
|
||||||
@@ -511,3 +599,107 @@ host = "h2"
|
|||||||
t.Errorf("forms[1].smtp.host = %v", h)
|
t.Errorf("forms[1].smtp.host = %v", h)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- TOML 1.1 --------------------------------------------------------------
|
||||||
|
|
||||||
|
func TestParseAcceptsNoSecondsDatetimes(t *testing.T) {
|
||||||
|
tree, err := Parse([]byte(`t = 13:37
|
||||||
|
dt = 1979-05-27T07:32
|
||||||
|
odt1 = 1979-05-27 07:32Z
|
||||||
|
odt2 = 1979-05-27 07:32-07:00
|
||||||
|
`))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse: %v", err)
|
||||||
|
}
|
||||||
|
// A value written without seconds comes back without them: the seconds are
|
||||||
|
// only written when the value carries them.
|
||||||
|
if got := tree["t"].(LocalTime).String(); got != "13:37" {
|
||||||
|
t.Errorf("t = %q, want %q", got, "13:37")
|
||||||
|
}
|
||||||
|
if got := tree["dt"].(LocalDateTime).String(); got != "1979-05-27T07:32" {
|
||||||
|
t.Errorf("dt = %q, want %q", got, "1979-05-27T07:32")
|
||||||
|
}
|
||||||
|
if got := tree["odt1"].(time.Time).Format(time.RFC3339Nano); got != "1979-05-27T07:32:00Z" {
|
||||||
|
t.Errorf("odt1 = %q", got)
|
||||||
|
}
|
||||||
|
if got := tree["odt2"].(time.Time).Format(time.RFC3339Nano); got != "1979-05-27T07:32:00-07:00" {
|
||||||
|
t.Errorf("odt2 = %q", got)
|
||||||
|
}
|
||||||
|
// The fraction still requires the seconds it belongs to.
|
||||||
|
if _, err := Parse([]byte("a = 07:32.5\n")); err == nil {
|
||||||
|
t.Error("07:32.5: expected an error, got none")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseAcceptsEscapeAndHexEscapes(t *testing.T) {
|
||||||
|
tree, err := Parse([]byte(`esc = "\e"
|
||||||
|
hex = "\x20\x7f\xf8"
|
||||||
|
nul = "\x00"
|
||||||
|
multi = """\x68\x65"""
|
||||||
|
lit = '\x20'
|
||||||
|
`))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse: %v", err)
|
||||||
|
}
|
||||||
|
if got := tree["esc"].(string); got != "\x1b" {
|
||||||
|
t.Errorf("esc = %q, want the escape character", got)
|
||||||
|
}
|
||||||
|
if got := tree["hex"].(string); got != " \x7f\u00f8" {
|
||||||
|
t.Errorf("hex = %q", got)
|
||||||
|
}
|
||||||
|
if got := tree["nul"].(string); got != "\x00" {
|
||||||
|
t.Errorf("nul = %q", got)
|
||||||
|
}
|
||||||
|
if got := tree["multi"].(string); got != "he" {
|
||||||
|
t.Errorf("multi = %q", got)
|
||||||
|
}
|
||||||
|
// A literal string carries the sequence verbatim.
|
||||||
|
if got := tree["lit"].(string); got != `\x20` {
|
||||||
|
t.Errorf("lit = %q, want the verbatim sequence", got)
|
||||||
|
}
|
||||||
|
// Two digits exactly; a short or non-hex escape is an error.
|
||||||
|
for _, doc := range []string{`a = "\x4"`, `a = "\x"`, `a = "\xgg"`} {
|
||||||
|
if _, err := Parse([]byte(doc)); err == nil {
|
||||||
|
t.Errorf("%s: expected an error, got none", doc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseAcceptsMultilineInlineTables(t *testing.T) {
|
||||||
|
tree, err := Parse([]byte("tbl = {\n\thello = \"world\",\n\tarr = [1,\n\t\t2,\n\t],\n\tsub = {\n\t\tk = 1,\n\t},\n\tbare = 2}\n"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse: %v", err)
|
||||||
|
}
|
||||||
|
tbl := tree["tbl"].(map[string]any)
|
||||||
|
if tbl["hello"] != "world" || tbl["bare"] != int64(2) {
|
||||||
|
t.Fatalf("tbl = %#v", tbl)
|
||||||
|
}
|
||||||
|
if arr := tbl["arr"].([]any); len(arr) != 2 {
|
||||||
|
t.Errorf("arr = %#v", tbl["arr"])
|
||||||
|
}
|
||||||
|
if sub := tbl["sub"].(map[string]any); sub["k"] != int64(1) {
|
||||||
|
t.Errorf("sub = %#v", tbl["sub"])
|
||||||
|
}
|
||||||
|
// Comments inside the table, and a trailing comma at both depths.
|
||||||
|
tree, err = Parse([]byte("m = { # one\n\t# two\n\ta = 1, # three\n\t# four\n}\n"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse with comments: %v", err)
|
||||||
|
}
|
||||||
|
if m := tree["m"].(map[string]any); m["a"] != int64(1) {
|
||||||
|
t.Errorf("m = %#v", m)
|
||||||
|
}
|
||||||
|
// The old single-line shapes keep working, with and without the comma.
|
||||||
|
if _, err := Parse([]byte("a = { b = 1, c = 2 }\n")); err != nil {
|
||||||
|
t.Errorf("single line: %v", err)
|
||||||
|
}
|
||||||
|
// Still rejected: two commas, a missing value, and an unclosed table.
|
||||||
|
for name, doc := range map[string]string{
|
||||||
|
"double comma": "a = { b = 1,, c = 2 }\n",
|
||||||
|
"missing value": "a = {\n\tb =\n}\n",
|
||||||
|
"unterminated": "a = { b = 1,\n",
|
||||||
|
} {
|
||||||
|
if _, err := Parse([]byte(doc)); err == nil {
|
||||||
|
t.Errorf("%s: expected an error, got none", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -92,9 +92,9 @@ run:
|
|||||||
dev:
|
dev:
|
||||||
go run -buildvcs=true {{package}}
|
go run -buildvcs=true {{package}}
|
||||||
|
|
||||||
# Runs the official toml-test compliance suite against the built adapter; toml-test must be on PATH (go install github.com/toml-lang/toml-test/cmd/toml-test@v1.6.0); not standard because no canonical recipe covers a domain compliance suite.
|
# Runs the official toml-test compliance suite in both directions, decoder and encoder, against the built adapter; toml-test must be on PATH (go install github.com/toml-lang/toml-test/v2/cmd/toml-test@v2.2.0); not standard because no canonical recipe covers a domain compliance suite.
|
||||||
toml-test: build
|
toml-test: build
|
||||||
toml-test bin/interpres-decode
|
toml-test test -decoder=bin/interpres-decode -encoder='bin/interpres-decode -encode' -toml=1.1
|
||||||
|
|
||||||
# Coverage report as an HTML map from the gate's profile; not standard because the gate needs only the numeric floor, and a browser artefact is exploration, not a gate.
|
# Coverage report as an HTML map from the gate's profile; not standard because the gate needs only the numeric floor, and a browser artefact is exploration, not a gate.
|
||||||
coverage-html: test
|
coverage-html: test
|
||||||
|
|||||||
@@ -74,15 +74,16 @@ func decodeFloat(tok string) (any, error) {
|
|||||||
sign, s := splitSign(tok)
|
sign, s := splitSign(tok)
|
||||||
|
|
||||||
mantissa, exp := s, ""
|
mantissa, exp := s, ""
|
||||||
|
hasExp := false
|
||||||
if i := strings.IndexAny(s, "eE"); i >= 0 {
|
if i := strings.IndexAny(s, "eE"); i >= 0 {
|
||||||
mantissa, exp = s[:i], s[i+1:]
|
mantissa, exp, hasExp = s[:i], s[i+1:], true
|
||||||
}
|
}
|
||||||
|
|
||||||
intPart, frac, hasDot := mantissa, "", false
|
intPart, frac, hasDot := mantissa, "", false
|
||||||
if i := strings.IndexByte(mantissa, '.'); i >= 0 {
|
if i := strings.IndexByte(mantissa, '.'); i >= 0 {
|
||||||
intPart, frac, hasDot = mantissa[:i], mantissa[i+1:], true
|
intPart, frac, hasDot = mantissa[:i], mantissa[i+1:], true
|
||||||
}
|
}
|
||||||
if !hasDot && exp == "" {
|
if !hasDot && !hasExp {
|
||||||
return nil, fmt.Errorf("invalid float %q", tok)
|
return nil, fmt.Errorf("invalid float %q", tok)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,24 +94,43 @@ func decodeFloat(tok string) (any, error) {
|
|||||||
if err := checkNoLeadingZero(ip); err != nil {
|
if err := checkNoLeadingZero(ip); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
build := sign + ip
|
|
||||||
|
|
||||||
|
fp := ""
|
||||||
if hasDot {
|
if hasDot {
|
||||||
fp, err := joinDigits(frac, isDecDigit)
|
if fp, err = joinDigits(frac, isDecDigit); err != nil {
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
// The ABNF requires at least one digit after the exponent marker, so a
|
||||||
|
// trailing e or E is an error even though strconv would accept it. The
|
||||||
|
// digits are a zero-prefixable integer, so leading zeros are fine here
|
||||||
|
// (the corpus holds valid cases such as 1e06 and 0e00).
|
||||||
|
esign, ed := "", ""
|
||||||
|
if hasExp {
|
||||||
|
var digits string
|
||||||
|
esign, digits = splitSign(exp)
|
||||||
|
if ed, err = joinDigits(digits, isDecDigit); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The checks above validated the token's shape, and every character a
|
||||||
|
// valid token may carry is one strconv.ParseFloat accepts in place, so
|
||||||
|
// only a token with underscores needs the stripped rebuild.
|
||||||
|
if !strings.ContainsRune(tok, '_') {
|
||||||
|
f, err := strconv.ParseFloat(tok, 64)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid float %q", tok)
|
||||||
|
}
|
||||||
|
return f, nil
|
||||||
|
}
|
||||||
|
build := sign + ip
|
||||||
|
if hasDot {
|
||||||
build += "." + fp
|
build += "." + fp
|
||||||
}
|
}
|
||||||
if exp != "" {
|
if hasExp {
|
||||||
esign, edigits := splitSign(exp)
|
|
||||||
ed, err := joinDigits(edigits, isDecDigit)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
build += "e" + esign + ed
|
build += "e" + esign + ed
|
||||||
}
|
}
|
||||||
|
|
||||||
f, err := strconv.ParseFloat(build, 64)
|
f, err := strconv.ParseFloat(build, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("invalid float %q", tok)
|
return nil, fmt.Errorf("invalid float %q", tok)
|
||||||
@@ -120,11 +140,20 @@ func decodeFloat(tok string) (any, error) {
|
|||||||
|
|
||||||
// joinDigits validates that every rune is a digit (per isDigit) and that each
|
// joinDigits validates that every rune is a digit (per isDigit) and that each
|
||||||
// underscore sits between two digits, returning the digits with underscores
|
// underscore sits between two digits, returning the digits with underscores
|
||||||
// removed.
|
// removed. A token without underscores, the common case, is validated in
|
||||||
|
// place and returned without a copy.
|
||||||
func joinDigits(s string, isDigit func(byte) bool) (string, error) {
|
func joinDigits(s string, isDigit func(byte) bool) (string, error) {
|
||||||
if s == "" {
|
if s == "" {
|
||||||
return "", fmt.Errorf("number is missing digits")
|
return "", fmt.Errorf("number is missing digits")
|
||||||
}
|
}
|
||||||
|
if !strings.ContainsRune(s, '_') {
|
||||||
|
for i := range len(s) {
|
||||||
|
if !isDigit(s[i]) {
|
||||||
|
return "", fmt.Errorf("invalid character %q in number", string(s[i]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
for i := range len(s) {
|
for i := range len(s) {
|
||||||
c := s[i]
|
c := s[i]
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"unicode/utf8"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ctxCheckInterval is the number of top-level parser iterations between
|
// ctxCheckInterval is the number of top-level parser iterations between
|
||||||
@@ -16,8 +17,15 @@ import (
|
|||||||
const ctxCheckInterval = 64
|
const ctxCheckInterval = 64
|
||||||
|
|
||||||
// parser is a recursive-descent TOML parser producing a map[string]any tree.
|
// parser is a recursive-descent TOML parser producing a map[string]any tree.
|
||||||
|
//
|
||||||
|
// The scanner works on bytes, not runes: the input is validated UTF-8 before
|
||||||
|
// the parser runs, every character that drives the grammar (quotes,
|
||||||
|
// separators, newlines, bare-key characters) is ASCII, and multi-byte runes
|
||||||
|
// matter only as string content, where they are decoded on the spot. Holding
|
||||||
|
// the source as []rune instead would cost a conversion pass plus four bytes
|
||||||
|
// per rune of extra memory before parsing even starts.
|
||||||
type parser struct {
|
type parser struct {
|
||||||
src []rune
|
src []byte
|
||||||
pos int
|
pos int
|
||||||
line int
|
line int
|
||||||
ctx context.Context
|
ctx context.Context
|
||||||
@@ -85,10 +93,10 @@ func (p *parser) checkCtx() error {
|
|||||||
|
|
||||||
func (p *parser) parseTableHeader() error {
|
func (p *parser) parseTableHeader() error {
|
||||||
array := false
|
array := false
|
||||||
p.next() // consume '['
|
p.pos++ // consume '['
|
||||||
if !p.eof() && p.peek() == '[' {
|
if !p.eof() && p.peek() == '[' {
|
||||||
array = true
|
array = true
|
||||||
p.next()
|
p.pos++
|
||||||
}
|
}
|
||||||
|
|
||||||
key, err := p.parseKeyPath()
|
key, err := p.parseKeyPath()
|
||||||
@@ -100,12 +108,12 @@ func (p *parser) parseTableHeader() error {
|
|||||||
if p.eof() || p.peek() != ']' {
|
if p.eof() || p.peek() != ']' {
|
||||||
return p.errf("expected ']' to close table header")
|
return p.errf("expected ']' to close table header")
|
||||||
}
|
}
|
||||||
p.next()
|
p.pos++
|
||||||
if array {
|
if array {
|
||||||
if p.eof() || p.peek() != ']' {
|
if p.eof() || p.peek() != ']' {
|
||||||
return p.errf("expected ']]' to close array-of-tables header")
|
return p.errf("expected ']]' to close array-of-tables header")
|
||||||
}
|
}
|
||||||
p.next()
|
p.pos++
|
||||||
}
|
}
|
||||||
|
|
||||||
if array {
|
if array {
|
||||||
@@ -171,7 +179,12 @@ func (p *parser) tableAt(key []string) (map[string]any, error) {
|
|||||||
|
|
||||||
func (p *parser) appendArrayTable(key []string) (map[string]any, error) {
|
func (p *parser) appendArrayTable(key []string) (map[string]any, error) {
|
||||||
parent := p.root
|
parent := p.root
|
||||||
|
path := make([]string, 0, len(key))
|
||||||
for _, k := range key[:len(key)-1] {
|
for _, k := range key[:len(key)-1] {
|
||||||
|
path = append(path, k)
|
||||||
|
if p.frozen[pathKey(path)] {
|
||||||
|
return nil, p.errf("cannot extend inline table %q", strings.Join(path, "."))
|
||||||
|
}
|
||||||
existing, ok := parent[k]
|
existing, ok := parent[k]
|
||||||
if !ok {
|
if !ok {
|
||||||
next := map[string]any{}
|
next := map[string]any{}
|
||||||
@@ -213,7 +226,7 @@ func (p *parser) parseKeyValue() error {
|
|||||||
if p.eof() || p.peek() != '=' {
|
if p.eof() || p.peek() != '=' {
|
||||||
return p.errf("expected '=' after key")
|
return p.errf("expected '=' after key")
|
||||||
}
|
}
|
||||||
p.next()
|
p.pos++
|
||||||
p.skipInline()
|
p.skipInline()
|
||||||
|
|
||||||
val, err := p.parseValue()
|
val, err := p.parseValue()
|
||||||
@@ -222,7 +235,10 @@ func (p *parser) parseKeyValue() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
dest := p.current
|
dest := p.current
|
||||||
abs := append([]string{}, p.currentPath...)
|
// One allocation covers the current section plus the dotted key; a
|
||||||
|
// top-level statement reuses it for the leaf.
|
||||||
|
abs := make([]string, 0, len(p.currentPath)+len(key))
|
||||||
|
abs = append(abs, p.currentPath...)
|
||||||
for _, k := range key[:len(key)-1] {
|
for _, k := range key[:len(key)-1] {
|
||||||
abs = append(abs, k)
|
abs = append(abs, k)
|
||||||
if p.frozen[pathKey(abs)] {
|
if p.frozen[pathKey(abs)] {
|
||||||
@@ -269,18 +285,17 @@ func (p *parser) freezeInline(path []string, val any) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// resetScopeUnder forgets the header and freeze records nested under key, which
|
// resetScopeUnder forgets the definition records nested under key, which
|
||||||
// belong to the previous element of an array of tables.
|
// belong to the previous element of an array of tables: headers, frozen
|
||||||
|
// inline tables, dotted-key paths, and nested arrays of tables all start
|
||||||
|
// fresh in the new element.
|
||||||
func (p *parser) resetScopeUnder(key []string) {
|
func (p *parser) resetScopeUnder(key []string) {
|
||||||
prefix := pathKey(key) + "\x00"
|
prefix := pathKey(key) + "\x00"
|
||||||
for k := range p.headers {
|
for _, m := range []map[string]bool{p.headers, p.frozen, p.dotted, p.arrays} {
|
||||||
if strings.HasPrefix(k, prefix) {
|
for k := range m {
|
||||||
delete(p.headers, k)
|
if strings.HasPrefix(k, prefix) {
|
||||||
}
|
delete(m, k)
|
||||||
}
|
}
|
||||||
for k := range p.frozen {
|
|
||||||
if strings.HasPrefix(k, prefix) {
|
|
||||||
delete(p.frozen, k)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -297,7 +312,7 @@ func (p *parser) parseKeyPath() ([]string, error) {
|
|||||||
parts = append(parts, part)
|
parts = append(parts, part)
|
||||||
p.skipInline()
|
p.skipInline()
|
||||||
if !p.eof() && p.peek() == '.' {
|
if !p.eof() && p.peek() == '.' {
|
||||||
p.next()
|
p.pos++
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
@@ -309,7 +324,7 @@ func (p *parser) parseKeyComponent() (string, error) {
|
|||||||
if p.eof() {
|
if p.eof() {
|
||||||
return "", p.errf("expected a key")
|
return "", p.errf("expected a key")
|
||||||
}
|
}
|
||||||
switch c := p.peek(); c {
|
switch p.peek() {
|
||||||
case '"':
|
case '"':
|
||||||
if p.lookahead(`"""`) {
|
if p.lookahead(`"""`) {
|
||||||
return "", p.errf("multiline strings are not allowed in keys")
|
return "", p.errf("multiline strings are not allowed in keys")
|
||||||
@@ -326,13 +341,14 @@ func (p *parser) parseKeyComponent() (string, error) {
|
|||||||
c := p.peek()
|
c := p.peek()
|
||||||
if (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') ||
|
if (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') ||
|
||||||
(c >= '0' && c <= '9') || c == '_' || c == '-' {
|
(c >= '0' && c <= '9') || c == '_' || c == '-' {
|
||||||
p.next()
|
p.pos++
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
if p.pos == start {
|
if p.pos == start {
|
||||||
return "", p.errf("invalid key character %q", string(p.peek()))
|
r, _ := utf8.DecodeRune(p.src[p.pos:])
|
||||||
|
return "", p.errf("invalid key character %q", string(r))
|
||||||
}
|
}
|
||||||
return string(p.src[start:p.pos]), nil
|
return string(p.src[start:p.pos]), nil
|
||||||
}
|
}
|
||||||
@@ -381,7 +397,7 @@ func (p *parser) parseAtom() (any, error) {
|
|||||||
// A date may be followed by a space and a time, forming one date-time.
|
// A date may be followed by a space and a time, forming one date-time.
|
||||||
if isDateToken(tok) && !p.eof() && p.peek() == ' ' {
|
if isDateToken(tok) && !p.eof() && p.peek() == ' ' {
|
||||||
if next, ok := p.peekAt(1); ok && next >= '0' && next <= '9' {
|
if next, ok := p.peekAt(1); ok && next >= '0' && next <= '9' {
|
||||||
p.next() // consume the separating space
|
p.pos++ // consume the separating space
|
||||||
timeStart := p.pos
|
timeStart := p.pos
|
||||||
p.scanBareToken()
|
p.scanBareToken()
|
||||||
tok = tok + " " + string(p.src[timeStart:p.pos])
|
tok = tok + " " + string(p.src[timeStart:p.pos])
|
||||||
@@ -406,7 +422,7 @@ func (p *parser) scanBareToken() {
|
|||||||
c == ',' || c == ']' || c == '}' || c == '#' {
|
c == ',' || c == ']' || c == '}' || c == '#' {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
p.next()
|
p.pos++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -416,31 +432,32 @@ func (p *parser) parseBasicString() (string, error) {
|
|||||||
if p.lookahead(`"""`) {
|
if p.lookahead(`"""`) {
|
||||||
return p.parseMultilineString('"', true)
|
return p.parseMultilineString('"', true)
|
||||||
}
|
}
|
||||||
p.next() // opening quote
|
p.pos++ // opening quote
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
for {
|
for {
|
||||||
if p.eof() {
|
if p.eof() {
|
||||||
return "", p.errf("unterminated string")
|
return "", p.errf("unterminated string")
|
||||||
}
|
}
|
||||||
c := p.next()
|
c := p.peek()
|
||||||
switch c {
|
switch c {
|
||||||
case '"':
|
case '"':
|
||||||
|
p.pos++
|
||||||
return b.String(), nil
|
return b.String(), nil
|
||||||
case '\n':
|
case '\n':
|
||||||
return "", p.errf("unterminated string")
|
return "", p.errf("unterminated string")
|
||||||
case '\r':
|
case '\r':
|
||||||
return "", p.errf("bare carriage return is not allowed in a string")
|
return "", p.errf("bare carriage return is not allowed in a string")
|
||||||
case '\\':
|
case '\\':
|
||||||
|
p.pos++
|
||||||
r, err := p.readEscape()
|
r, err := p.readEscape()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
b.WriteRune(r)
|
b.WriteRune(r)
|
||||||
default:
|
default:
|
||||||
if isControlRune(c) {
|
if err := p.writeContentRune(&b); err != nil {
|
||||||
return "", p.errf("control character U+%04X is not allowed in a string", c)
|
return "", err
|
||||||
}
|
}
|
||||||
b.WriteRune(c)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -449,38 +466,58 @@ func (p *parser) parseLiteralString() (string, error) {
|
|||||||
if p.lookahead(`'''`) {
|
if p.lookahead(`'''`) {
|
||||||
return p.parseMultilineString('\'', false)
|
return p.parseMultilineString('\'', false)
|
||||||
}
|
}
|
||||||
p.next() // opening quote
|
p.pos++ // opening quote
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
for {
|
for {
|
||||||
if p.eof() {
|
if p.eof() {
|
||||||
return "", p.errf("unterminated literal string")
|
return "", p.errf("unterminated literal string")
|
||||||
}
|
}
|
||||||
c := p.next()
|
c := p.peek()
|
||||||
if c == '\'' {
|
switch c {
|
||||||
|
case '\'':
|
||||||
|
p.pos++
|
||||||
return b.String(), nil
|
return b.String(), nil
|
||||||
}
|
case '\n':
|
||||||
if c == '\n' {
|
|
||||||
return "", p.errf("unterminated literal string")
|
return "", p.errf("unterminated literal string")
|
||||||
}
|
case '\r':
|
||||||
if c == '\r' {
|
|
||||||
return "", p.errf("bare carriage return is not allowed in a string")
|
return "", p.errf("bare carriage return is not allowed in a string")
|
||||||
|
default:
|
||||||
|
if err := p.writeContentRune(&b); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
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) {
|
// writeContentRune appends the rune at the cursor to b and advances past it.
|
||||||
|
// An ASCII byte, which includes every control character the grammar forbids,
|
||||||
|
// is checked and written directly; a multi-byte rune is decoded and can never
|
||||||
|
// be a control character.
|
||||||
|
func (p *parser) writeContentRune(b *strings.Builder) error {
|
||||||
|
c := p.peek()
|
||||||
|
if c < utf8.RuneSelf {
|
||||||
|
if isControlRune(rune(c)) {
|
||||||
|
return p.errf("control character U+%04X is not allowed in a string", c)
|
||||||
|
}
|
||||||
|
p.pos++
|
||||||
|
b.WriteByte(c)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
r, size := utf8.DecodeRune(p.src[p.pos:])
|
||||||
|
p.pos += size
|
||||||
|
b.WriteRune(r)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *parser) parseMultilineString(quote byte, escapes bool) (string, error) {
|
||||||
p.skipN(3) // opening delimiter
|
p.skipN(3) // opening delimiter
|
||||||
// A newline immediately after the opening delimiter is trimmed.
|
// A newline immediately after the opening delimiter is trimmed.
|
||||||
if !p.eof() && p.peek() == '\r' {
|
if !p.eof() && p.peek() == '\r' {
|
||||||
p.next()
|
p.pos++
|
||||||
}
|
}
|
||||||
if !p.eof() && p.peek() == '\n' {
|
if !p.eof() && p.peek() == '\n' {
|
||||||
p.line++
|
p.line++
|
||||||
p.next()
|
p.pos++
|
||||||
}
|
}
|
||||||
|
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
@@ -500,31 +537,32 @@ func (p *parser) parseMultilineString(quote rune, escapes bool) (string, error)
|
|||||||
return "", p.errf("too many '%c' before the closing delimiter", quote)
|
return "", p.errf("too many '%c' before the closing delimiter", quote)
|
||||||
}
|
}
|
||||||
for range n - 3 {
|
for range n - 3 {
|
||||||
b.WriteRune(quote)
|
b.WriteByte(quote)
|
||||||
}
|
}
|
||||||
p.skipN(n)
|
p.skipN(n)
|
||||||
return b.String(), nil
|
return b.String(), nil
|
||||||
}
|
}
|
||||||
for range n {
|
for range n {
|
||||||
b.WriteRune(quote)
|
b.WriteByte(quote)
|
||||||
p.next()
|
p.pos++
|
||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
c := p.next()
|
c := p.peek()
|
||||||
if c == '\n' {
|
switch {
|
||||||
|
case c == '\n':
|
||||||
p.line++
|
p.line++
|
||||||
b.WriteRune(c)
|
p.pos++
|
||||||
continue
|
b.WriteByte(c)
|
||||||
}
|
case c == '\r':
|
||||||
if c == '\r' {
|
if p.pos+1 < len(p.src) && p.src[p.pos+1] == '\n' {
|
||||||
if !p.eof() && p.peek() == '\n' {
|
b.WriteByte(c)
|
||||||
b.WriteRune(c)
|
p.pos++
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
return "", p.errf("bare carriage return is not allowed in a string")
|
return "", p.errf("bare carriage return is not allowed in a string")
|
||||||
}
|
case escapes && c == '\\':
|
||||||
if escapes && c == '\\' {
|
p.pos++
|
||||||
// Line-ending backslash trims the following whitespace/newlines.
|
// Line-ending backslash trims the following whitespace/newlines.
|
||||||
if p.trimLineEndingBackslash() {
|
if p.trimLineEndingBackslash() {
|
||||||
continue
|
continue
|
||||||
@@ -534,12 +572,11 @@ func (p *parser) parseMultilineString(quote rune, escapes bool) (string, error)
|
|||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
b.WriteRune(r)
|
b.WriteRune(r)
|
||||||
continue
|
default:
|
||||||
|
if err := p.writeContentRune(&b); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if isControlRune(c) {
|
|
||||||
return "", p.errf("control character U+%04X is not allowed in a string", c)
|
|
||||||
}
|
|
||||||
b.WriteRune(c)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -551,7 +588,7 @@ func (p *parser) trimLineEndingBackslash() bool {
|
|||||||
for !p.eof() {
|
for !p.eof() {
|
||||||
c := p.peek()
|
c := p.peek()
|
||||||
if c == ' ' || c == '\t' || c == '\r' {
|
if c == ' ' || c == '\t' || c == '\r' {
|
||||||
p.next()
|
p.pos++
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if c == '\n' {
|
if c == '\n' {
|
||||||
@@ -570,11 +607,11 @@ func (p *parser) trimLineEndingBackslash() bool {
|
|||||||
c := p.peek()
|
c := p.peek()
|
||||||
if c == '\n' {
|
if c == '\n' {
|
||||||
p.line++
|
p.line++
|
||||||
p.next()
|
p.pos++
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if c == ' ' || c == '\t' || c == '\r' {
|
if c == ' ' || c == '\t' || c == '\r' {
|
||||||
p.next()
|
p.pos++
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
@@ -598,16 +635,25 @@ func (p *parser) readEscape() (rune, error) {
|
|||||||
return '\f', nil
|
return '\f', nil
|
||||||
case 'r':
|
case 'r':
|
||||||
return '\r', nil
|
return '\r', nil
|
||||||
|
case 'e':
|
||||||
|
// TOML 1.1: the escape character.
|
||||||
|
return '\x1b', nil
|
||||||
case '"':
|
case '"':
|
||||||
return '"', nil
|
return '"', nil
|
||||||
case '\\':
|
case '\\':
|
||||||
return '\\', nil
|
return '\\', nil
|
||||||
|
case 'x':
|
||||||
|
// TOML 1.1: two hex digits, code points 0x00 through 0xFF.
|
||||||
|
return p.readUnicode(2)
|
||||||
case 'u':
|
case 'u':
|
||||||
return p.readUnicode(4)
|
return p.readUnicode(4)
|
||||||
case 'U':
|
case 'U':
|
||||||
return p.readUnicode(8)
|
return p.readUnicode(8)
|
||||||
default:
|
default:
|
||||||
return 0, p.errf("invalid escape sequence \\%c", c)
|
// The byte just consumed starts a rune: the backslash before it is a
|
||||||
|
// boundary, and the input is valid UTF-8.
|
||||||
|
r, _ := utf8.DecodeRune(p.src[p.pos-1:])
|
||||||
|
return 0, p.errf("invalid escape sequence \\%c", r)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -630,17 +676,17 @@ func (p *parser) readUnicode(n int) (rune, error) {
|
|||||||
// --- arrays and inline tables ---------------------------------------------
|
// --- arrays and inline tables ---------------------------------------------
|
||||||
|
|
||||||
func (p *parser) parseArray() (any, error) {
|
func (p *parser) parseArray() (any, error) {
|
||||||
p.next() // '['
|
p.pos++ // '['
|
||||||
arr := []any{}
|
arr := []any{}
|
||||||
for {
|
for {
|
||||||
if err := p.skipArraySpace(); err != nil {
|
if err := p.skipNestedSpace(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if p.eof() {
|
if p.eof() {
|
||||||
return nil, p.errf("unterminated array")
|
return nil, p.errf("unterminated array")
|
||||||
}
|
}
|
||||||
if p.peek() == ']' {
|
if p.peek() == ']' {
|
||||||
p.next()
|
p.pos++
|
||||||
return arr, nil
|
return arr, nil
|
||||||
}
|
}
|
||||||
v, err := p.parseValue()
|
v, err := p.parseValue()
|
||||||
@@ -648,7 +694,7 @@ func (p *parser) parseArray() (any, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
arr = append(arr, v)
|
arr = append(arr, v)
|
||||||
if err := p.skipArraySpace(); err != nil {
|
if err := p.skipNestedSpace(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if p.eof() {
|
if p.eof() {
|
||||||
@@ -656,9 +702,9 @@ func (p *parser) parseArray() (any, error) {
|
|||||||
}
|
}
|
||||||
switch p.peek() {
|
switch p.peek() {
|
||||||
case ',':
|
case ',':
|
||||||
p.next()
|
p.pos++
|
||||||
case ']':
|
case ']':
|
||||||
p.next()
|
p.pos++
|
||||||
return arr, nil
|
return arr, nil
|
||||||
default:
|
default:
|
||||||
return nil, p.errf("expected ',' or ']' in array")
|
return nil, p.errf("expected ',' or ']' in array")
|
||||||
@@ -667,16 +713,23 @@ func (p *parser) parseArray() (any, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (p *parser) parseInlineTable() (any, error) {
|
func (p *parser) parseInlineTable() (any, error) {
|
||||||
p.next() // '{'
|
p.pos++ // '{'
|
||||||
tbl := map[string]any{}
|
tbl := map[string]any{}
|
||||||
assigned := map[string]bool{}
|
assigned := map[string]bool{}
|
||||||
p.skipInline()
|
// TOML 1.1 lets an inline table span lines: interior whitespace includes
|
||||||
|
// newlines and comments, and a trailing comma is allowed before the
|
||||||
|
// closing brace.
|
||||||
|
if err := p.skipNestedSpace(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
if !p.eof() && p.peek() == '}' {
|
if !p.eof() && p.peek() == '}' {
|
||||||
p.next()
|
p.pos++
|
||||||
return tbl, nil
|
return tbl, nil
|
||||||
}
|
}
|
||||||
for {
|
for {
|
||||||
p.skipInline()
|
if err := p.skipNestedSpace(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
key, err := p.parseKeyPath()
|
key, err := p.parseKeyPath()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -685,7 +738,7 @@ func (p *parser) parseInlineTable() (any, error) {
|
|||||||
if p.eof() || p.peek() != '=' {
|
if p.eof() || p.peek() != '=' {
|
||||||
return nil, p.errf("expected '=' in inline table")
|
return nil, p.errf("expected '=' in inline table")
|
||||||
}
|
}
|
||||||
p.next()
|
p.pos++
|
||||||
p.skipInline()
|
p.skipInline()
|
||||||
val, err := p.parseValue()
|
val, err := p.parseValue()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -720,15 +773,24 @@ func (p *parser) parseInlineTable() (any, error) {
|
|||||||
dest[leaf] = val
|
dest[leaf] = val
|
||||||
assigned[pathKey(path)] = true
|
assigned[pathKey(path)] = true
|
||||||
|
|
||||||
p.skipInline()
|
if err := p.skipNestedSpace(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
if p.eof() {
|
if p.eof() {
|
||||||
return nil, p.errf("unterminated inline table")
|
return nil, p.errf("unterminated inline table")
|
||||||
}
|
}
|
||||||
switch p.peek() {
|
switch p.peek() {
|
||||||
case ',':
|
case ',':
|
||||||
p.next()
|
p.pos++
|
||||||
|
if err := p.skipNestedSpace(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !p.eof() && p.peek() == '}' {
|
||||||
|
p.pos++
|
||||||
|
return tbl, nil
|
||||||
|
}
|
||||||
case '}':
|
case '}':
|
||||||
p.next()
|
p.pos++
|
||||||
return tbl, nil
|
return tbl, nil
|
||||||
default:
|
default:
|
||||||
return nil, p.errf("expected ',' or '}' in inline table")
|
return nil, p.errf("expected ',' or '}' in inline table")
|
||||||
@@ -739,12 +801,12 @@ func (p *parser) parseInlineTable() (any, error) {
|
|||||||
// --- scanning helpers ------------------------------------------------------
|
// --- scanning helpers ------------------------------------------------------
|
||||||
|
|
||||||
func (p *parser) eof() bool { return p.pos >= len(p.src) }
|
func (p *parser) eof() bool { return p.pos >= len(p.src) }
|
||||||
func (p *parser) peek() rune { return p.src[p.pos] }
|
func (p *parser) peek() byte { return p.src[p.pos] }
|
||||||
|
|
||||||
// peekAt returns the rune at offset n from the current position and whether the
|
// peekAt returns the byte at offset n from the current position and whether the
|
||||||
// offset is within the source. Use it instead of indexing p.src directly when
|
// offset is within the source. Use it instead of indexing p.src directly when
|
||||||
// the offset may sit past the end.
|
// the offset may sit past the end.
|
||||||
func (p *parser) peekAt(n int) (rune, bool) {
|
func (p *parser) peekAt(n int) (byte, bool) {
|
||||||
i := p.pos + n
|
i := p.pos + n
|
||||||
if i < 0 || i >= len(p.src) {
|
if i < 0 || i >= len(p.src) {
|
||||||
return 0, false
|
return 0, false
|
||||||
@@ -752,7 +814,7 @@ func (p *parser) peekAt(n int) (rune, bool) {
|
|||||||
return p.src[i], true
|
return p.src[i], true
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *parser) next() rune {
|
func (p *parser) next() byte {
|
||||||
c := p.src[p.pos]
|
c := p.src[p.pos]
|
||||||
p.pos++
|
p.pos++
|
||||||
return c
|
return c
|
||||||
@@ -766,49 +828,43 @@ func (p *parser) skipN(n int) {
|
|||||||
|
|
||||||
func (p *parser) match(word string) bool {
|
func (p *parser) match(word string) bool {
|
||||||
if p.lookahead(word) {
|
if p.lookahead(word) {
|
||||||
p.skipN(len([]rune(word)))
|
p.skipN(len(word))
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// lookahead reports whether s follows the cursor. Every lookahead argument in
|
||||||
|
// the grammar is ASCII, so comparing bytes is exact.
|
||||||
func (p *parser) lookahead(s string) bool {
|
func (p *parser) lookahead(s string) bool {
|
||||||
r := []rune(s)
|
return p.pos+len(s) <= len(p.src) && string(p.src[p.pos:p.pos+len(s)]) == 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.
|
// skipInline consumes spaces and tabs only.
|
||||||
func (p *parser) skipInline() {
|
func (p *parser) skipInline() {
|
||||||
for !p.eof() {
|
for !p.eof() {
|
||||||
if c := p.peek(); c == ' ' || c == '\t' {
|
if c := p.peek(); c == ' ' || c == '\t' {
|
||||||
p.next()
|
p.pos++
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// skipArraySpace consumes whitespace, newlines, and comments inside arrays.
|
// skipNestedSpace consumes whitespace, newlines, and comments inside a value
|
||||||
func (p *parser) skipArraySpace() error {
|
// container (an array, or an inline table under TOML 1.1).
|
||||||
|
func (p *parser) skipNestedSpace() error {
|
||||||
for !p.eof() {
|
for !p.eof() {
|
||||||
switch p.peek() {
|
switch p.peek() {
|
||||||
case ' ', '\t':
|
case ' ', '\t':
|
||||||
p.next()
|
p.pos++
|
||||||
case '\r':
|
case '\r':
|
||||||
if err := p.expectCRLF(); err != nil {
|
if err := p.expectCRLF(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
case '\n':
|
case '\n':
|
||||||
p.line++
|
p.line++
|
||||||
p.next()
|
p.pos++
|
||||||
case '#':
|
case '#':
|
||||||
if err := p.skipComment(); err != nil {
|
if err := p.skipComment(); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -825,14 +881,14 @@ func (p *parser) skipBlank() error {
|
|||||||
for !p.eof() {
|
for !p.eof() {
|
||||||
switch p.peek() {
|
switch p.peek() {
|
||||||
case ' ', '\t':
|
case ' ', '\t':
|
||||||
p.next()
|
p.pos++
|
||||||
case '\r':
|
case '\r':
|
||||||
if err := p.expectCRLF(); err != nil {
|
if err := p.expectCRLF(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
case '\n':
|
case '\n':
|
||||||
p.line++
|
p.line++
|
||||||
p.next()
|
p.pos++
|
||||||
case '#':
|
case '#':
|
||||||
if err := p.skipComment(); err != nil {
|
if err := p.skipComment(); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -845,7 +901,7 @@ func (p *parser) skipBlank() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (p *parser) skipComment() error {
|
func (p *parser) skipComment() error {
|
||||||
p.next() // consume '#'
|
p.pos++ // consume '#'
|
||||||
for !p.eof() {
|
for !p.eof() {
|
||||||
c := p.peek()
|
c := p.peek()
|
||||||
switch {
|
switch {
|
||||||
@@ -857,11 +913,11 @@ func (p *parser) skipComment() error {
|
|||||||
}
|
}
|
||||||
return p.errf("bare carriage return is not allowed")
|
return p.errf("bare carriage return is not allowed")
|
||||||
case c == '\t':
|
case c == '\t':
|
||||||
p.next()
|
p.pos++
|
||||||
case c < 0x20 || c == 0x7f:
|
case c < 0x20 || c == 0x7f:
|
||||||
return p.errf("control character U+%04X is not allowed in a comment", c)
|
return p.errf("control character U+%04X is not allowed in a comment", c)
|
||||||
default:
|
default:
|
||||||
p.next()
|
p.pos++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@@ -871,7 +927,7 @@ func (p *parser) skipComment() error {
|
|||||||
// line feed; a bare CR is invalid.
|
// line feed; a bare CR is invalid.
|
||||||
func (p *parser) expectCRLF() error {
|
func (p *parser) expectCRLF() error {
|
||||||
if p.pos+1 < len(p.src) && p.src[p.pos+1] == '\n' {
|
if p.pos+1 < len(p.src) && p.src[p.pos+1] == '\n' {
|
||||||
p.next() // consume CR; the LF is handled by the caller
|
p.pos++ // consume CR; the LF is handled by the caller
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return p.errf("bare carriage return is not allowed")
|
return p.errf("bare carriage return is not allowed")
|
||||||
@@ -902,10 +958,11 @@ func (p *parser) expectLineEnd() error {
|
|||||||
}
|
}
|
||||||
if p.peek() == '\n' {
|
if p.peek() == '\n' {
|
||||||
p.line++
|
p.line++
|
||||||
p.next()
|
p.pos++
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return p.errf("unexpected %q after value", string(p.peek()))
|
r, _ := utf8.DecodeRune(p.src[p.pos:])
|
||||||
|
return p.errf("unexpected %q after value", string(r))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *parser) errf(format string, args ...any) error {
|
func (p *parser) errf(format string, args ...any) error {
|
||||||
|
|||||||
+2
@@ -0,0 +1,2 @@
|
|||||||
|
go test fuzz v1
|
||||||
|
[]byte("0=[{}]")
|
||||||
Reference in New Issue
Block a user