feat: scaffold interpres project with TOML parser and CI
This commit is contained in:
@@ -0,0 +1,43 @@
|
|||||||
|
# EditorConfig: https://editorconfig.org
|
||||||
|
|
||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
charset = utf-8
|
||||||
|
end_of_line = lf
|
||||||
|
insert_final_newline = true
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 2
|
||||||
|
|
||||||
|
[*.{rb,erb,gemspec}]
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 2
|
||||||
|
|
||||||
|
[{Gemfile,Rakefile}]
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 2
|
||||||
|
|
||||||
|
[*.{js,html,css,vue}]
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 2
|
||||||
|
|
||||||
|
[*.{yml,yaml,json,toml}]
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 2
|
||||||
|
|
||||||
|
[*.{rs,cj}]
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 4
|
||||||
|
|
||||||
|
[*.go]
|
||||||
|
indent_style = tab
|
||||||
|
indent_size = 4
|
||||||
|
|
||||||
|
[justfile]
|
||||||
|
indent_style = tab
|
||||||
|
|
||||||
|
[*.md]
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 2
|
||||||
|
trim_trailing_whitespace = false
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
name: Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags: ["v*"]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
release:
|
||||||
|
runs-on: codeberg-small
|
||||||
|
steps:
|
||||||
|
- uses: https://data.forgejo.org/actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Verify tag and extract CHANGELOG section
|
||||||
|
env:
|
||||||
|
VERSION: ${{ forgejo.ref_name }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
if ! echo "$VERSION" | grep -qE '^v[0-9]+(\.[0-9]+){0,2}([-+].*)?$'; then
|
||||||
|
echo "ERROR: expected a semver tag like v1.2.3, got: '$VERSION'"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
VERSION_NO_V="${VERSION#v}"
|
||||||
|
echo "Looking for CHANGELOG section: ${VERSION_NO_V}"
|
||||||
|
|
||||||
|
sed -n "/^## \[${VERSION_NO_V}\] /,/^## \[/p" CHANGELOG.md \
|
||||||
|
| sed '$d' \
|
||||||
|
| tail -n +2 \
|
||||||
|
> release-body.md
|
||||||
|
|
||||||
|
if [ ! -s release-body.md ]; then
|
||||||
|
echo "ERROR: no CHANGELOG section found for ${VERSION_NO_V}"
|
||||||
|
echo "Expected a heading like: ## [${VERSION_NO_V}] — YYYY-MM-DD"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Release body preview:"
|
||||||
|
head -n 20 release-body.md
|
||||||
|
|
||||||
|
- name: Create release
|
||||||
|
env:
|
||||||
|
FORGEJO_TOKEN: ${{ secrets.FORGEJO_TOKEN }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# JSON-escape the release body with sed: backslashes first, then
|
||||||
|
# quotes, tabs, and carriage returns, and finally fold newlines into
|
||||||
|
# \n. Wrap the result in quotes to form a JSON string.
|
||||||
|
BODY=$(sed -e 's/\\/\\\\/g' \
|
||||||
|
-e 's/"/\\"/g' \
|
||||||
|
-e 's/\t/\\t/g' \
|
||||||
|
-e 's/\r//g' \
|
||||||
|
release-body.md \
|
||||||
|
| sed ':a;N;$!ba;s/\n/\\n/g')
|
||||||
|
BODY="\"${BODY}\""
|
||||||
|
|
||||||
|
response=$(curl -sS -w '\n%{http_code}' \
|
||||||
|
-H "Authorization: token ${FORGEJO_TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-X POST \
|
||||||
|
"${FORGEJO_SERVER_URL}/api/v1/repos/${FORGEJO_REPOSITORY}/releases" \
|
||||||
|
-d "{
|
||||||
|
\"tag_name\": \"${FORGEJO_REF_NAME}\",
|
||||||
|
\"name\": \"${FORGEJO_REF_NAME}\",
|
||||||
|
\"body\": ${BODY},
|
||||||
|
\"draft\": false,
|
||||||
|
\"prerelease\": false
|
||||||
|
}")
|
||||||
|
|
||||||
|
http_code=$(echo "$response" | tail -1)
|
||||||
|
payload=$(echo "$response" | sed '$d')
|
||||||
|
|
||||||
|
echo "HTTP ${http_code}"
|
||||||
|
if [ "$http_code" != "201" ]; then
|
||||||
|
echo "Failed to create release: ${payload}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
RELEASE_ID=$(echo "$payload" | grep -oE '"id"[[:space:]]*:[[:space:]]*[0-9]+' | head -1 | grep -oE '[0-9]+')
|
||||||
|
echo "Release ${FORGEJO_REF_NAME} is live (ID=${RELEASE_ID})."
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
name: Test
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [development]
|
||||||
|
pull_request:
|
||||||
|
branches: [development]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
vet:
|
||||||
|
runs-on: codeberg-small
|
||||||
|
steps:
|
||||||
|
- uses: https://data.forgejo.org/actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: https://data.forgejo.org/actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version: "1.26"
|
||||||
|
|
||||||
|
- name: gofmt
|
||||||
|
# gofmt -l prints unformatted files; an empty output is the only pass.
|
||||||
|
run: |
|
||||||
|
unformatted=$(gofmt -l .)
|
||||||
|
if [ -n "$unformatted" ]; then
|
||||||
|
echo "These files need gofmt:"
|
||||||
|
echo "$unformatted"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: go vet
|
||||||
|
run: go vet ./...
|
||||||
|
|
||||||
|
test:
|
||||||
|
runs-on: codeberg-small
|
||||||
|
needs: vet
|
||||||
|
steps:
|
||||||
|
- uses: https://data.forgejo.org/actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: https://data.forgejo.org/actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version: "1.26"
|
||||||
|
|
||||||
|
- name: go test -race
|
||||||
|
run: go test -race -count=1 ./...
|
||||||
|
|
||||||
|
- name: build example
|
||||||
|
run: go build ./examples/...
|
||||||
|
|
||||||
|
toml-test:
|
||||||
|
runs-on: codeberg-small
|
||||||
|
needs: vet
|
||||||
|
steps:
|
||||||
|
- uses: https://data.forgejo.org/actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: https://data.forgejo.org/actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version: "1.26"
|
||||||
|
|
||||||
|
- name: Install toml-test
|
||||||
|
# A dev/CI tool only; it is not a module dependency of interpres.
|
||||||
|
run: go install github.com/toml-lang/toml-test/cmd/toml-test@latest
|
||||||
|
|
||||||
|
- name: Run the toml-test compliance suite
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
go build -o bin/interpres-decode ./cmd/interpres-decode
|
||||||
|
"$(go env GOPATH)/bin/toml-test" bin/interpres-decode
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
# Build artifacts
|
||||||
|
bin/
|
||||||
|
*.test
|
||||||
|
*.out
|
||||||
|
|
||||||
|
# Coverage
|
||||||
|
coverage.out
|
||||||
|
coverage.html
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
All notable changes to **interpres** are documented in this file.
|
||||||
|
|
||||||
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||||
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [1.0.0] — 2026-06-20
|
||||||
|
|
||||||
|
First stable release: a dependency-free TOML 1.0 parser for Go that uses only
|
||||||
|
the standard library and passes the entire
|
||||||
|
[toml-test](https://github.com/toml-lang/toml-test) suite
|
||||||
|
(**185 valid and 371 invalid cases, 0 failures**).
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
**Parsing**
|
||||||
|
|
||||||
|
- `Parse(data []byte) (map[string]any, error)` — decode a TOML document into an
|
||||||
|
untyped tree.
|
||||||
|
- Full TOML 1.0 syntax: comments; bare, quoted, and dotted keys; tables
|
||||||
|
(`[a.b]`) and arrays of tables (`[[a]]`); basic and literal strings, including
|
||||||
|
multiline (`"""` / `'''`) with escape sequences and line-ending backslash
|
||||||
|
trimming; integers in decimal, hex (`0x`), octal (`0o`), and binary (`0b`)
|
||||||
|
with `_` separators; floats with exponents and `inf` / `nan`; booleans;
|
||||||
|
offset/local date-times, dates, and times; arrays; and inline tables.
|
||||||
|
- Distinct date-time types: offset date-times decode to `time.Time`, while
|
||||||
|
`LocalDateTime`, `LocalDate`, and `LocalTime` represent the local variants.
|
||||||
|
- Strict, spec-conformant validation that rejects invalid documents: leading
|
||||||
|
zeros, misplaced underscores, malformed floats and radix literals, control
|
||||||
|
characters in strings and comments, bare carriage returns, non-UTF-8 input,
|
||||||
|
out-of-range Unicode escapes, multiline strings used as keys, single-digit
|
||||||
|
date-time components, duplicate/overwriting inline-table keys, and the full
|
||||||
|
family of table redefinitions (header vs. header, header vs. dotted key,
|
||||||
|
array of tables vs. table, and dotted-key appends to defined tables).
|
||||||
|
|
||||||
|
**Decoding**
|
||||||
|
|
||||||
|
- `Unmarshal(data []byte, v any)` — parse and map onto a struct or
|
||||||
|
`map[string]any` via reflection.
|
||||||
|
- `toml:"name"` field tags, case-insensitive name fallback, and `toml:"-"` to
|
||||||
|
skip a field.
|
||||||
|
- `Decoder` with `DisallowUnknownFields` for strict decoding that rejects keys
|
||||||
|
without a destination field.
|
||||||
|
- `SyntaxError` carrying the 1-based line of a malformed document.
|
||||||
|
|
||||||
|
**Project**
|
||||||
|
|
||||||
|
- Standard library only — zero third-party modules.
|
||||||
|
- `cmd/interpres-decode`, a toml-test harness adapter (TOML on stdin, tagged
|
||||||
|
JSON on stdout).
|
||||||
|
- A runnable example, a Go test suite, and a `just` task set (`install`, `run`,
|
||||||
|
`build`, `test`, `coverage`, `uninstall`).
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
# Contributing
|
||||||
|
|
||||||
|
Thanks for your interest in **interpres**. This is a small, dependency-free TOML
|
||||||
|
parser for Go; contributions that keep it focused, correct, and
|
||||||
|
standard-library-only are welcome.
|
||||||
|
|
||||||
|
## Ground rules
|
||||||
|
|
||||||
|
- **Standard library only.** No third-party Go modules — that constraint is the
|
||||||
|
point of the project.
|
||||||
|
- **Formatted and vetted.** `just build` must pass cleanly (`go vet`, `gofmt`,
|
||||||
|
compile). `just test` must be green.
|
||||||
|
- **Tested.** New syntax support or decoding behaviour comes with table-driven
|
||||||
|
tests in `interpres_test.go`, and must not regress toml-test compliance.
|
||||||
|
|
||||||
|
## Branches and releases
|
||||||
|
|
||||||
|
- **`main`** holds released code only. It is never committed to directly.
|
||||||
|
- **`development`** is the integration branch where work lands.
|
||||||
|
|
||||||
|
Open pull requests against `development`. A release is cut by merging
|
||||||
|
`development` into `main` and pushing a `vX.Y.Z` tag to `main`; CI does not
|
||||||
|
perform the merge. Bump the version in `CHANGELOG.md` (with the date and a tag
|
||||||
|
link) as part of the release.
|
||||||
|
|
||||||
|
Releases follow [Semantic Versioning](https://semver.org).
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
```sh
|
||||||
|
just build # go vet + gofmt check + compile
|
||||||
|
just test # run the Go test suite
|
||||||
|
just run # run examples/basic
|
||||||
|
just coverage # write an HTML coverage report
|
||||||
|
```
|
||||||
|
|
||||||
|
## TOML 1.0 compliance
|
||||||
|
|
||||||
|
The bar for this parser is passing the official
|
||||||
|
[toml-test](https://github.com/toml-lang/toml-test) suite. `cmd/interpres-decode`
|
||||||
|
speaks the harness protocol (TOML on stdin, tagged JSON on stdout, non-zero exit
|
||||||
|
on invalid input):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go build -o interpres-decode ./cmd/interpres-decode
|
||||||
|
toml-test ./interpres-decode
|
||||||
|
```
|
||||||
|
|
||||||
|
Run this before and after any parser change; it must stay at zero failures.
|
||||||
|
|
||||||
|
## Project structure
|
||||||
|
|
||||||
|
```
|
||||||
|
interpres/
|
||||||
|
├── interpres.go # public API: Parse, Unmarshal, Decoder, SyntaxError
|
||||||
|
├── parser.go # recursive-descent parser → map[string]any
|
||||||
|
├── number.go # strict numeric token parsing
|
||||||
|
├── datetime.go # date-time types and parsing
|
||||||
|
├── decode.go # reflection mapping of the tree onto Go values
|
||||||
|
├── interpres_test.go # test suite
|
||||||
|
├── cmd/interpres-decode/ # toml-test harness adapter
|
||||||
|
└── examples/basic/ # runnable usage example
|
||||||
|
```
|
||||||
|
|
||||||
|
## Adding syntax or types
|
||||||
|
|
||||||
|
1. Extend `parser.go` / `number.go` / `datetime.go` (for syntax) or `decode.go`
|
||||||
|
(for new destination types).
|
||||||
|
2. Add focused tests covering both the happy path and malformed input.
|
||||||
|
3. Confirm `toml-test` still reports zero failures.
|
||||||
|
4. If behaviour or the public API changes, update `README.md` and `CHANGELOG.md`.
|
||||||
|
|
||||||
|
## Commit messages
|
||||||
|
|
||||||
|
Use [Conventional Commits](https://www.conventionalcommits.org/) (`feat:`,
|
||||||
|
`fix:`, `docs:`, `refactor:`, `test:`, `chore:`).
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
# interpres
|
||||||
|
|
||||||
|
A dependency-free **TOML 1.0 parser for Go** — standard library only, no
|
||||||
|
third-party modules. Passes the entire
|
||||||
|
[toml-test](https://github.com/toml-lang/toml-test) suite (185 valid and 371
|
||||||
|
invalid cases).
|
||||||
|
|
||||||
|
`interpres` (Latin for *interpreter*) reads TOML documents into Go values with a
|
||||||
|
small, `encoding/json`-style API. It is built for embedding in zero-dependency
|
||||||
|
Go programs that still want their configuration in TOML.
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go get codeberg.org/petrbalvin/interpres
|
||||||
|
```
|
||||||
|
|
||||||
|
Requires Go 1.26 or newer. The module imports only the standard library.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### Decode into a struct
|
||||||
|
|
||||||
|
```go
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"codeberg.org/petrbalvin/interpres"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
Title string `toml:"title"`
|
||||||
|
Server struct {
|
||||||
|
Host string `toml:"host"`
|
||||||
|
Port int `toml:"port"`
|
||||||
|
} `toml:"server"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
data := []byte(`
|
||||||
|
title = "example"
|
||||||
|
|
||||||
|
[server]
|
||||||
|
host = "127.0.0.1"
|
||||||
|
port = 9090
|
||||||
|
`)
|
||||||
|
|
||||||
|
var cfg Config
|
||||||
|
if err := interpres.Unmarshal(data, &cfg); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
fmt.Printf("%+v\n", cfg)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Untyped tree
|
||||||
|
|
||||||
|
```go
|
||||||
|
tree, err := interpres.Parse(data) // map[string]any
|
||||||
|
```
|
||||||
|
|
||||||
|
### Strict decoding
|
||||||
|
|
||||||
|
Reject keys that have no matching struct field — useful for catching typos in
|
||||||
|
configuration files:
|
||||||
|
|
||||||
|
```go
|
||||||
|
err := interpres.NewDecoder().
|
||||||
|
DisallowUnknownFields().
|
||||||
|
Decode(data, &cfg)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Struct mapping
|
||||||
|
|
||||||
|
Fields are matched to TOML keys by the `toml:"name"` tag, or by a
|
||||||
|
case-insensitive match on the field name when no tag is present. A tag of `"-"`
|
||||||
|
skips the field.
|
||||||
|
|
||||||
|
| TOML value | Go type (untyped tree) |
|
||||||
|
|------------|------------------------|
|
||||||
|
| string | `string` |
|
||||||
|
| integer | `int64` |
|
||||||
|
| float | `float64` |
|
||||||
|
| boolean | `bool` |
|
||||||
|
| offset date-time | `time.Time` |
|
||||||
|
| local date-time | `LocalDateTime` |
|
||||||
|
| local date | `LocalDate` |
|
||||||
|
| local time | `LocalTime` |
|
||||||
|
| array | `[]any` |
|
||||||
|
| table / inline table | `map[string]any` |
|
||||||
|
| array of tables | `[]map[string]any` |
|
||||||
|
|
||||||
|
When decoding into a struct, these map onto the destination's concrete types
|
||||||
|
(any integer/unsigned/float width, slices, nested structs, and `map[string]T`).
|
||||||
|
|
||||||
|
## TOML 1.0 support
|
||||||
|
|
||||||
|
`interpres` implements the full [TOML 1.0](https://toml.io/en/v1.0.0) grammar:
|
||||||
|
|
||||||
|
- Comments, and bare / quoted / dotted keys.
|
||||||
|
- Tables `[a.b]` and arrays of tables `[[a]]`.
|
||||||
|
- Basic and literal strings, including multiline (`"""` / `'''`) with escape
|
||||||
|
sequences (`\n`, `\t`, `\uXXXX`, `\UXXXXXXXX`, line-ending backslash).
|
||||||
|
- Integers in decimal, hex (`0x`), octal (`0o`), and binary (`0b`) with `_`
|
||||||
|
separators; floats with exponents and `inf` / `nan`.
|
||||||
|
- Booleans, offset/local date-times, dates, and times.
|
||||||
|
- Arrays (multiline, mixed types) and inline tables.
|
||||||
|
|
||||||
|
Invalid documents are rejected per the specification (malformed numbers and
|
||||||
|
floats, control characters, non-UTF-8 input, out-of-range escapes, table and
|
||||||
|
inline-table redefinitions, and more).
|
||||||
|
|
||||||
|
## Compliance
|
||||||
|
|
||||||
|
The parser is verified against the official
|
||||||
|
[toml-test](https://github.com/toml-lang/toml-test) suite via
|
||||||
|
`cmd/interpres-decode`:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go build -o interpres-decode ./cmd/interpres-decode
|
||||||
|
toml-test ./interpres-decode
|
||||||
|
```
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT — see [LICENSE](LICENSE).
|
||||||
|
Copyright © 2026 [Petr Balvín](https://petrbalvin.org)
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
// Command interpres-decode reads a TOML document from standard input and writes
|
||||||
|
// the toml-test "tagged JSON" representation to standard output.
|
||||||
|
//
|
||||||
|
// It exits non-zero on a parse error, which is how the toml-test harness checks
|
||||||
|
// that invalid documents are rejected. Run the official suite against it with:
|
||||||
|
//
|
||||||
|
// toml-test ./interpres-decode
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"math"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"codeberg.org/petrbalvin/interpres"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
data, err := io.ReadAll(os.Stdin)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "read stdin:", err)
|
||||||
|
os.Exit(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
tree, err := interpres.Parse(data)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
enc := json.NewEncoder(os.Stdout)
|
||||||
|
enc.SetEscapeHTML(false)
|
||||||
|
if err := enc.Encode(tag(tree)); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "encode:", err)
|
||||||
|
os.Exit(2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
// {"type", "value"} object.
|
||||||
|
func tag(v any) any {
|
||||||
|
switch x := v.(type) {
|
||||||
|
case map[string]any:
|
||||||
|
out := make(map[string]any, len(x))
|
||||||
|
for k, val := range x {
|
||||||
|
out[k] = tag(val)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
case []any:
|
||||||
|
out := make([]any, len(x))
|
||||||
|
for i, e := range x {
|
||||||
|
out[i] = tag(e)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
case []map[string]any:
|
||||||
|
out := make([]any, len(x))
|
||||||
|
for i, e := range x {
|
||||||
|
out[i] = tag(e)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
case string:
|
||||||
|
return tagged("string", x)
|
||||||
|
case bool:
|
||||||
|
return tagged("bool", strconv.FormatBool(x))
|
||||||
|
case int64:
|
||||||
|
return tagged("integer", strconv.FormatInt(x, 10))
|
||||||
|
case float64:
|
||||||
|
return tagged("float", formatFloat(x))
|
||||||
|
case time.Time:
|
||||||
|
return tagged("datetime", x.Format(time.RFC3339Nano))
|
||||||
|
case interpres.LocalDateTime:
|
||||||
|
return tagged("datetime-local", x.Format("2006-01-02T15:04:05.999999999"))
|
||||||
|
case interpres.LocalDate:
|
||||||
|
return tagged("date-local", x.Format("2006-01-02"))
|
||||||
|
case interpres.LocalTime:
|
||||||
|
return tagged("time-local", x.Format("15:04:05.999999999"))
|
||||||
|
default:
|
||||||
|
fmt.Fprintf(os.Stderr, "unsupported value type %T\n", v)
|
||||||
|
os.Exit(2)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func tagged(typ, value string) map[string]string {
|
||||||
|
return map[string]string{"type": typ, "value": value}
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatFloat(f float64) string {
|
||||||
|
switch {
|
||||||
|
case math.IsInf(f, 1):
|
||||||
|
return "inf"
|
||||||
|
case math.IsInf(f, -1):
|
||||||
|
return "-inf"
|
||||||
|
case math.IsNaN(f):
|
||||||
|
return "nan"
|
||||||
|
default:
|
||||||
|
return strconv.FormatFloat(f, 'g', -1, 64)
|
||||||
|
}
|
||||||
|
}
|
||||||
+103
@@ -0,0 +1,103 @@
|
|||||||
|
package interpres
|
||||||
|
|
||||||
|
import (
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TOML distinguishes four date-time kinds. interpres decodes an offset
|
||||||
|
// date-time to a plain time.Time (it carries a zone), and uses the wrapper
|
||||||
|
// types below for the local variants so callers can tell them apart.
|
||||||
|
|
||||||
|
// LocalDateTime is a TOML local date-time with no offset, e.g.
|
||||||
|
// 1979-05-27T07:32:00. The embedded time.Time is in UTC.
|
||||||
|
type LocalDateTime struct{ time.Time }
|
||||||
|
|
||||||
|
// LocalDate is a TOML local date with no time or offset, e.g. 1979-05-27.
|
||||||
|
// The embedded time.Time is at midnight UTC.
|
||||||
|
type LocalDate struct{ time.Time }
|
||||||
|
|
||||||
|
// LocalTime is a TOML local time with no date or offset, e.g. 07:32:00.999999.
|
||||||
|
// The embedded time.Time uses the zero date.
|
||||||
|
type LocalTime struct{ time.Time }
|
||||||
|
|
||||||
|
var (
|
||||||
|
offsetDateTimeLayouts = []string{
|
||||||
|
"2006-01-02T15:04:05.999999999Z07:00",
|
||||||
|
"2006-01-02T15:04:05Z07:00",
|
||||||
|
"2006-01-02 15:04:05.999999999Z07:00",
|
||||||
|
"2006-01-02 15:04:05Z07:00",
|
||||||
|
}
|
||||||
|
localDateTimeLayouts = []string{
|
||||||
|
"2006-01-02T15:04:05.999999999",
|
||||||
|
"2006-01-02T15:04:05",
|
||||||
|
"2006-01-02 15:04:05.999999999",
|
||||||
|
"2006-01-02 15:04:05",
|
||||||
|
}
|
||||||
|
localTimeLayouts = []string{
|
||||||
|
"15:04:05.999999999",
|
||||||
|
"15:04:05",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// dateTimeShape enforces the strict TOML grammar (two-digit components) that
|
||||||
|
// time.Parse would otherwise accept loosely (e.g. a single-digit hour).
|
||||||
|
var dateTimeShape = regexp.MustCompile(
|
||||||
|
`^\d{4}-\d{2}-\d{2}([Tt ]\d{2}:\d{2}:\d{2}(\.\d+)?([Zz]|[+-]\d{2}:\d{2})?)?$` +
|
||||||
|
`|^\d{2}:\d{2}:\d{2}(\.\d+)?$`,
|
||||||
|
)
|
||||||
|
|
||||||
|
// parseDateTime classifies and parses a bare token as a TOML date-time value.
|
||||||
|
// It returns the decoded value (time.Time, LocalDateTime, LocalDate, or
|
||||||
|
// LocalTime) and whether the token was a date-time at all.
|
||||||
|
func parseDateTime(tok string) (any, bool) {
|
||||||
|
if tok == "" || tok[0] < '0' || tok[0] > '9' {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
if !strings.ContainsAny(tok, "-:") {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
if !dateTimeShape.MatchString(tok) {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
// The ABNF accepts lowercase "t"/"z"; time.Parse only matches uppercase.
|
||||||
|
norm := strings.ToUpper(tok)
|
||||||
|
for _, layout := range offsetDateTimeLayouts {
|
||||||
|
if t, err := time.Parse(layout, norm); err == nil {
|
||||||
|
return t, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, layout := range localDateTimeLayouts {
|
||||||
|
if t, err := time.Parse(layout, norm); err == nil {
|
||||||
|
return LocalDateTime{t}, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if t, err := time.Parse("2006-01-02", norm); err == nil {
|
||||||
|
return LocalDate{t}, true
|
||||||
|
}
|
||||||
|
for _, layout := range localTimeLayouts {
|
||||||
|
if t, err := time.Parse(layout, norm); err == nil {
|
||||||
|
return LocalTime{t}, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// isDateToken reports whether s is exactly a YYYY-MM-DD date, used to detect a
|
||||||
|
// space-separated date-time written as "date<space>time".
|
||||||
|
func isDateToken(s string) bool {
|
||||||
|
if len(s) != 10 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for i := 0; i < len(s); i++ {
|
||||||
|
if i == 4 || i == 7 {
|
||||||
|
if s[i] != '-' {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
} else if !isDecDigit(s[i]) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
package interpres
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// decoder maps a parsed TOML tree onto Go values via reflection.
|
||||||
|
type decoder struct {
|
||||||
|
disallowUnknown bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func newDecoder() *decoder { return &decoder{} }
|
||||||
|
|
||||||
|
var timeType = reflect.TypeOf(time.Time{})
|
||||||
|
|
||||||
|
func (d *decoder) decode(tree map[string]any, v any) error {
|
||||||
|
rv := reflect.ValueOf(v)
|
||||||
|
if rv.Kind() != reflect.Pointer || rv.IsNil() {
|
||||||
|
return fmt.Errorf("interpres: decode target must be a non-nil pointer")
|
||||||
|
}
|
||||||
|
return d.assign(tree, rv.Elem())
|
||||||
|
}
|
||||||
|
|
||||||
|
// assign stores data into dst, converting between the TOML value kinds and the
|
||||||
|
// destination's Go type.
|
||||||
|
func (d *decoder) assign(data any, dst reflect.Value) error {
|
||||||
|
if dst.Kind() == reflect.Pointer {
|
||||||
|
if dst.IsNil() {
|
||||||
|
dst.Set(reflect.New(dst.Type().Elem()))
|
||||||
|
}
|
||||||
|
return d.assign(data, dst.Elem())
|
||||||
|
}
|
||||||
|
|
||||||
|
// An interface{} destination takes the value as-is.
|
||||||
|
if dst.Kind() == reflect.Interface && dst.NumMethod() == 0 {
|
||||||
|
dst.Set(reflect.ValueOf(data))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
switch v := data.(type) {
|
||||||
|
case map[string]any:
|
||||||
|
return d.assignTable(v, dst)
|
||||||
|
case []map[string]any:
|
||||||
|
return d.assignTableSlice(v, dst)
|
||||||
|
case []any:
|
||||||
|
return d.assignSlice(v, dst)
|
||||||
|
case string:
|
||||||
|
return setBasic(dst, reflect.ValueOf(v), "string")
|
||||||
|
case bool:
|
||||||
|
return setBasic(dst, reflect.ValueOf(v), "bool")
|
||||||
|
case int64:
|
||||||
|
return setInt(dst, v)
|
||||||
|
case float64:
|
||||||
|
return setFloat(dst, v)
|
||||||
|
case time.Time:
|
||||||
|
if dst.Type() != timeType {
|
||||||
|
return fmt.Errorf("interpres: cannot assign datetime to %s", dst.Type())
|
||||||
|
}
|
||||||
|
dst.Set(reflect.ValueOf(v))
|
||||||
|
return nil
|
||||||
|
default:
|
||||||
|
rv := reflect.ValueOf(data)
|
||||||
|
if rv.IsValid() && dst.Type() == rv.Type() {
|
||||||
|
dst.Set(rv)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fmt.Errorf("interpres: cannot assign %T to %s", data, dst.Type())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *decoder) assignTable(tbl map[string]any, dst reflect.Value) error {
|
||||||
|
switch dst.Kind() {
|
||||||
|
case reflect.Struct:
|
||||||
|
return d.assignStruct(tbl, dst)
|
||||||
|
case reflect.Map:
|
||||||
|
return d.assignMap(tbl, dst)
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("interpres: cannot assign table to %s", dst.Type())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *decoder) assignStruct(tbl map[string]any, dst reflect.Value) error {
|
||||||
|
fields := structFields(dst.Type())
|
||||||
|
for key, val := range tbl {
|
||||||
|
field, ok := fields[strings.ToLower(key)]
|
||||||
|
if !ok {
|
||||||
|
if d.disallowUnknown {
|
||||||
|
return fmt.Errorf("interpres: unknown field %q for %s", key, dst.Type())
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := d.assign(val, dst.Field(field)); err != nil {
|
||||||
|
return fmt.Errorf("%s: %w", key, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *decoder) assignMap(tbl map[string]any, dst reflect.Value) error {
|
||||||
|
if dst.Type().Key().Kind() != reflect.String {
|
||||||
|
return fmt.Errorf("interpres: map key must be a string, got %s", dst.Type().Key())
|
||||||
|
}
|
||||||
|
if dst.IsNil() {
|
||||||
|
dst.Set(reflect.MakeMap(dst.Type()))
|
||||||
|
}
|
||||||
|
elemType := dst.Type().Elem()
|
||||||
|
for key, val := range tbl {
|
||||||
|
elem := reflect.New(elemType).Elem()
|
||||||
|
if err := d.assign(val, elem); err != nil {
|
||||||
|
return fmt.Errorf("%s: %w", key, err)
|
||||||
|
}
|
||||||
|
dst.SetMapIndex(reflect.ValueOf(key), elem)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *decoder) assignSlice(items []any, dst reflect.Value) error {
|
||||||
|
if dst.Kind() != reflect.Slice {
|
||||||
|
return fmt.Errorf("interpres: cannot assign array to %s", dst.Type())
|
||||||
|
}
|
||||||
|
out := reflect.MakeSlice(dst.Type(), len(items), len(items))
|
||||||
|
for i, item := range items {
|
||||||
|
if err := d.assign(item, out.Index(i)); err != nil {
|
||||||
|
return fmt.Errorf("[%d]: %w", i, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dst.Set(out)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *decoder) assignTableSlice(items []map[string]any, dst reflect.Value) error {
|
||||||
|
if dst.Kind() != reflect.Slice {
|
||||||
|
return fmt.Errorf("interpres: cannot assign array of tables to %s", dst.Type())
|
||||||
|
}
|
||||||
|
out := reflect.MakeSlice(dst.Type(), len(items), len(items))
|
||||||
|
for i, item := range items {
|
||||||
|
if err := d.assign(item, out.Index(i)); err != nil {
|
||||||
|
return fmt.Errorf("[%d]: %w", i, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dst.Set(out)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- low-level setters -----------------------------------------------------
|
||||||
|
|
||||||
|
func setBasic(dst, val reflect.Value, kind string) error {
|
||||||
|
if dst.Kind() != val.Kind() {
|
||||||
|
return fmt.Errorf("interpres: cannot assign %s to %s", kind, dst.Type())
|
||||||
|
}
|
||||||
|
dst.Set(val)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func setInt(dst reflect.Value, v int64) error {
|
||||||
|
switch dst.Kind() {
|
||||||
|
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
|
||||||
|
if dst.OverflowInt(v) {
|
||||||
|
return fmt.Errorf("interpres: integer %d overflows %s", v, dst.Type())
|
||||||
|
}
|
||||||
|
dst.SetInt(v)
|
||||||
|
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
|
||||||
|
if v < 0 {
|
||||||
|
return fmt.Errorf("interpres: cannot assign negative %d to %s", v, dst.Type())
|
||||||
|
}
|
||||||
|
dst.SetUint(uint64(v))
|
||||||
|
case reflect.Float32, reflect.Float64:
|
||||||
|
dst.SetFloat(float64(v))
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("interpres: cannot assign integer to %s", dst.Type())
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func setFloat(dst reflect.Value, v float64) error {
|
||||||
|
switch dst.Kind() {
|
||||||
|
case reflect.Float32, reflect.Float64:
|
||||||
|
dst.SetFloat(v)
|
||||||
|
return nil
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("interpres: cannot assign float to %s", dst.Type())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// structFields builds a lower-cased lookup of field name → field index for the
|
||||||
|
// exported fields of t, honouring `toml:"name"` tags.
|
||||||
|
func structFields(t reflect.Type) map[string]int {
|
||||||
|
fields := make(map[string]int, t.NumField())
|
||||||
|
for i := 0; i < t.NumField(); i++ {
|
||||||
|
f := t.Field(i)
|
||||||
|
if f.PkgPath != "" { // unexported
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
name := f.Name
|
||||||
|
if tag, ok := f.Tag.Lookup("toml"); ok {
|
||||||
|
tag = strings.Split(tag, ",")[0]
|
||||||
|
if tag == "-" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if tag != "" {
|
||||||
|
name = tag
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fields[strings.ToLower(name)] = i
|
||||||
|
}
|
||||||
|
return fields
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
// Command basic demonstrates decoding a TOML document with interpres.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
|
||||||
|
"codeberg.org/petrbalvin/interpres"
|
||||||
|
)
|
||||||
|
|
||||||
|
const document = `
|
||||||
|
title = "interpres demo"
|
||||||
|
|
||||||
|
[server]
|
||||||
|
host = "127.0.0.1"
|
||||||
|
port = 9090
|
||||||
|
|
||||||
|
[[users]]
|
||||||
|
name = "petr"
|
||||||
|
admin = true
|
||||||
|
|
||||||
|
[[users]]
|
||||||
|
name = "guest"
|
||||||
|
admin = false
|
||||||
|
`
|
||||||
|
|
||||||
|
// Config mirrors the document above.
|
||||||
|
type Config struct {
|
||||||
|
Title string `toml:"title"`
|
||||||
|
Server struct {
|
||||||
|
Host string `toml:"host"`
|
||||||
|
Port int `toml:"port"`
|
||||||
|
} `toml:"server"`
|
||||||
|
Users []struct {
|
||||||
|
Name string `toml:"name"`
|
||||||
|
Admin bool `toml:"admin"`
|
||||||
|
} `toml:"users"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
var cfg Config
|
||||||
|
if err := interpres.Unmarshal([]byte(document), &cfg); err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("title: %s\n", cfg.Title)
|
||||||
|
fmt.Printf("server: %s:%d\n", cfg.Server.Host, cfg.Server.Port)
|
||||||
|
for _, u := range cfg.Users {
|
||||||
|
fmt.Printf("user: %-6s admin=%t\n", u.Name, u.Admin)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
// Package interpres is a dependency-free TOML parser for Go.
|
||||||
|
//
|
||||||
|
// interpres reads TOML documents into Go values using only the standard
|
||||||
|
// library. It exposes a small, encoding/json-style API:
|
||||||
|
//
|
||||||
|
// var cfg Config
|
||||||
|
// err := interpres.Unmarshal(data, &cfg)
|
||||||
|
//
|
||||||
|
// or, for an untyped tree:
|
||||||
|
//
|
||||||
|
// tree, err := interpres.Parse(data)
|
||||||
|
//
|
||||||
|
// A Decoder allows strict decoding that rejects keys without a matching
|
||||||
|
// struct field, mirroring (*json.Decoder).DisallowUnknownFields.
|
||||||
|
package interpres
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"unicode/utf8"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A SyntaxError describes a malformed TOML document, including the 1-based
|
||||||
|
// line on which the problem was detected.
|
||||||
|
type SyntaxError struct {
|
||||||
|
Line int
|
||||||
|
Msg string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *SyntaxError) Error() string {
|
||||||
|
return fmt.Sprintf("interpres: line %d: %s", e.Line, e.Msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse decodes a TOML document into a nested map[string]any.
|
||||||
|
//
|
||||||
|
// Values are mapped to Go types as follows: strings to string, integers to
|
||||||
|
// int64, floats to float64, booleans to bool, date-times to time.Time, arrays
|
||||||
|
// to []any, and tables (including inline tables) to map[string]any.
|
||||||
|
func Parse(data []byte) (map[string]any, error) {
|
||||||
|
if !utf8.Valid(data) {
|
||||||
|
return nil, &SyntaxError{Line: 1, Msg: "input is not valid UTF-8"}
|
||||||
|
}
|
||||||
|
p := &parser{src: []rune(string(data)), line: 1}
|
||||||
|
return p.parse()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unmarshal parses a TOML document and stores the result in the value pointed
|
||||||
|
// to by v. v is typically a pointer to a struct or to a map[string]any.
|
||||||
|
//
|
||||||
|
// Struct fields are matched to TOML keys by the `toml:"name"` tag, or by a
|
||||||
|
// case-insensitive match on the field name when no tag is present. A tag of
|
||||||
|
// "-" skips the field.
|
||||||
|
func Unmarshal(data []byte, v any) error {
|
||||||
|
tree, err := Parse(data)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return newDecoder().decode(tree, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A Decoder decodes a TOML document into a Go value with configurable
|
||||||
|
// strictness.
|
||||||
|
type Decoder struct {
|
||||||
|
disallowUnknown bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewDecoder returns a Decoder.
|
||||||
|
func NewDecoder() *Decoder { return &Decoder{} }
|
||||||
|
|
||||||
|
// DisallowUnknownFields causes Decode to return an error when the document
|
||||||
|
// contains a key with no matching destination struct field.
|
||||||
|
func (d *Decoder) DisallowUnknownFields() *Decoder {
|
||||||
|
d.disallowUnknown = true
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decode parses data and stores the result in the value pointed to by v,
|
||||||
|
// honouring the decoder's strictness settings.
|
||||||
|
func (d *Decoder) Decode(data []byte, v any) error {
|
||||||
|
tree, err := Parse(data)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
dec := newDecoder()
|
||||||
|
dec.disallowUnknown = d.disallowUnknown
|
||||||
|
return dec.decode(tree, v)
|
||||||
|
}
|
||||||
@@ -0,0 +1,508 @@
|
|||||||
|
package interpres
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseScalars(t *testing.T) {
|
||||||
|
tree, err := Parse([]byte(`
|
||||||
|
title = "interpres"
|
||||||
|
count = 42
|
||||||
|
ratio = 3.14
|
||||||
|
enabled = true
|
||||||
|
disabled = false
|
||||||
|
hexv = 0xFF
|
||||||
|
octv = 0o755
|
||||||
|
binv = 0b1010
|
||||||
|
grouped = 1_000_000
|
||||||
|
neg = -17
|
||||||
|
expv = 1e3
|
||||||
|
`))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cases := map[string]any{
|
||||||
|
"title": "interpres",
|
||||||
|
"count": int64(42),
|
||||||
|
"ratio": 3.14,
|
||||||
|
"enabled": true,
|
||||||
|
"disabled": false,
|
||||||
|
"hexv": int64(255),
|
||||||
|
"octv": int64(493),
|
||||||
|
"binv": int64(10),
|
||||||
|
"grouped": int64(1000000),
|
||||||
|
"neg": int64(-17),
|
||||||
|
"expv": 1000.0,
|
||||||
|
}
|
||||||
|
for k, want := range cases {
|
||||||
|
if got := tree[k]; got != want {
|
||||||
|
t.Errorf("%s = %#v (%T), want %#v (%T)", k, got, got, want, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseInfNan(t *testing.T) {
|
||||||
|
tree, err := Parse([]byte("pos = inf\nneg = -inf\nbad = nan\n"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse: %v", err)
|
||||||
|
}
|
||||||
|
if v := tree["pos"].(float64); !math.IsInf(v, 1) {
|
||||||
|
t.Errorf("pos = %v, want +Inf", v)
|
||||||
|
}
|
||||||
|
if v := tree["neg"].(float64); !math.IsInf(v, -1) {
|
||||||
|
t.Errorf("neg = %v, want -Inf", v)
|
||||||
|
}
|
||||||
|
if v := tree["bad"].(float64); !math.IsNaN(v) {
|
||||||
|
t.Errorf("bad = %v, want NaN", v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseStrings(t *testing.T) {
|
||||||
|
tree, err := Parse([]byte(`
|
||||||
|
basic = "a\tb\nc"
|
||||||
|
literal = 'C:\path\no\escape'
|
||||||
|
quote = "say \"hi\""
|
||||||
|
unicode = "\u00e9"
|
||||||
|
`))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse: %v", err)
|
||||||
|
}
|
||||||
|
if tree["basic"] != "a\tb\nc" {
|
||||||
|
t.Errorf("basic = %q", tree["basic"])
|
||||||
|
}
|
||||||
|
if tree["literal"] != `C:\path\no\escape` {
|
||||||
|
t.Errorf("literal = %q", tree["literal"])
|
||||||
|
}
|
||||||
|
if tree["quote"] != `say "hi"` {
|
||||||
|
t.Errorf("quote = %q", tree["quote"])
|
||||||
|
}
|
||||||
|
if tree["unicode"] != "é" {
|
||||||
|
t.Errorf("unicode = %q", tree["unicode"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseMultilineString(t *testing.T) {
|
||||||
|
tree, err := Parse([]byte("text = \"\"\"\nfirst\nsecond\"\"\"\n"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse: %v", err)
|
||||||
|
}
|
||||||
|
if tree["text"] != "first\nsecond" {
|
||||||
|
t.Errorf("text = %q, want %q", tree["text"], "first\nsecond")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseMultilineLineEndingBackslash(t *testing.T) {
|
||||||
|
tree, err := Parse([]byte("text = \"\"\"\\\n one \\\n two\"\"\"\n"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse: %v", err)
|
||||||
|
}
|
||||||
|
if tree["text"] != "one two" {
|
||||||
|
t.Errorf("text = %q, want %q", tree["text"], "one two")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseTablesAndDottedKeys(t *testing.T) {
|
||||||
|
tree, err := Parse([]byte(`
|
||||||
|
owner.name = "Petr"
|
||||||
|
|
||||||
|
[server]
|
||||||
|
host = "localhost"
|
||||||
|
port = 9090
|
||||||
|
|
||||||
|
[server.tls]
|
||||||
|
enabled = true
|
||||||
|
`))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse: %v", err)
|
||||||
|
}
|
||||||
|
server := tree["server"].(map[string]any)
|
||||||
|
if server["host"] != "localhost" || server["port"] != int64(9090) {
|
||||||
|
t.Errorf("server = %#v", server)
|
||||||
|
}
|
||||||
|
tls := server["tls"].(map[string]any)
|
||||||
|
if tls["enabled"] != true {
|
||||||
|
t.Errorf("tls = %#v", tls)
|
||||||
|
}
|
||||||
|
owner := tree["owner"].(map[string]any)
|
||||||
|
if owner["name"] != "Petr" {
|
||||||
|
t.Errorf("owner = %#v", owner)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseArrayOfTables(t *testing.T) {
|
||||||
|
tree, err := Parse([]byte(`
|
||||||
|
[[forms]]
|
||||||
|
name = "contact"
|
||||||
|
|
||||||
|
[[forms]]
|
||||||
|
name = "feedback"
|
||||||
|
`))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse: %v", err)
|
||||||
|
}
|
||||||
|
forms := tree["forms"].([]map[string]any)
|
||||||
|
if len(forms) != 2 {
|
||||||
|
t.Fatalf("len(forms) = %d, want 2", len(forms))
|
||||||
|
}
|
||||||
|
if forms[0]["name"] != "contact" || forms[1]["name"] != "feedback" {
|
||||||
|
t.Errorf("forms = %#v", forms)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseArraysAndInlineTables(t *testing.T) {
|
||||||
|
tree, err := Parse([]byte(`
|
||||||
|
ports = [80, 443]
|
||||||
|
mixed = [
|
||||||
|
"a",
|
||||||
|
"b",
|
||||||
|
]
|
||||||
|
point = { x = 1, y = 2 }
|
||||||
|
`))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse: %v", err)
|
||||||
|
}
|
||||||
|
ports := tree["ports"].([]any)
|
||||||
|
if len(ports) != 2 || ports[0] != int64(80) || ports[1] != int64(443) {
|
||||||
|
t.Errorf("ports = %#v", ports)
|
||||||
|
}
|
||||||
|
mixed := tree["mixed"].([]any)
|
||||||
|
if len(mixed) != 2 || mixed[0] != "a" || mixed[1] != "b" {
|
||||||
|
t.Errorf("mixed = %#v", mixed)
|
||||||
|
}
|
||||||
|
point := tree["point"].(map[string]any)
|
||||||
|
if point["x"] != int64(1) || point["y"] != int64(2) {
|
||||||
|
t.Errorf("point = %#v", point)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseDateTime(t *testing.T) {
|
||||||
|
tree, err := Parse([]byte(`
|
||||||
|
offset = 1979-05-27T07:32:00Z
|
||||||
|
local = 1979-05-27T07:32:00
|
||||||
|
day = 1979-05-27
|
||||||
|
clock = 07:32:00
|
||||||
|
`))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse: %v", err)
|
||||||
|
}
|
||||||
|
if off, ok := tree["offset"].(time.Time); !ok || off.Year() != 1979 || off.Hour() != 7 {
|
||||||
|
t.Errorf("offset = %#v (%T)", tree["offset"], tree["offset"])
|
||||||
|
}
|
||||||
|
if ldt, ok := tree["local"].(LocalDateTime); !ok || ldt.Year() != 1979 || ldt.Hour() != 7 {
|
||||||
|
t.Errorf("local = %#v (%T)", tree["local"], tree["local"])
|
||||||
|
}
|
||||||
|
if d, ok := tree["day"].(LocalDate); !ok || d.Month() != time.May || d.Day() != 27 {
|
||||||
|
t.Errorf("day = %#v (%T)", tree["day"], tree["day"])
|
||||||
|
}
|
||||||
|
if clk, ok := tree["clock"].(LocalTime); !ok || clk.Hour() != 7 || clk.Minute() != 32 {
|
||||||
|
t.Errorf("clock = %#v (%T)", tree["clock"], tree["clock"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDateTimeFormats(t *testing.T) {
|
||||||
|
tree, err := Parse([]byte("a = 1987-07-05 17:45:00Z\nb = 1987-07-05t17:45:00z\nc = 1977-12-21T10:32:00.555\n"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse: %v", err)
|
||||||
|
}
|
||||||
|
if _, ok := tree["a"].(time.Time); !ok {
|
||||||
|
t.Errorf("a is %T, want time.Time", tree["a"])
|
||||||
|
}
|
||||||
|
if _, ok := tree["b"].(time.Time); !ok {
|
||||||
|
t.Errorf("b is %T, want time.Time", tree["b"])
|
||||||
|
}
|
||||||
|
if _, ok := tree["c"].(LocalDateTime); !ok {
|
||||||
|
t.Errorf("c is %T, want LocalDateTime", tree["c"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnmarshalDateTime(t *testing.T) {
|
||||||
|
type Doc struct {
|
||||||
|
Created time.Time `toml:"created"`
|
||||||
|
Day LocalDate `toml:"day"`
|
||||||
|
}
|
||||||
|
var d Doc
|
||||||
|
if err := Unmarshal([]byte("created = 2026-06-20T10:00:00Z\nday = 2026-06-20\n"), &d); err != nil {
|
||||||
|
t.Fatalf("unmarshal: %v", err)
|
||||||
|
}
|
||||||
|
if d.Created.Year() != 2026 || d.Created.Hour() != 10 {
|
||||||
|
t.Errorf("Created = %v", d.Created)
|
||||||
|
}
|
||||||
|
if d.Day.Year() != 2026 || d.Day.Month() != time.June || d.Day.Day() != 20 {
|
||||||
|
t.Errorf("Day = %v", d.Day)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnmarshalStruct(t *testing.T) {
|
||||||
|
type SMTP struct {
|
||||||
|
Host string `toml:"host"`
|
||||||
|
Port int `toml:"port"`
|
||||||
|
}
|
||||||
|
type Form struct {
|
||||||
|
Name string `toml:"name"`
|
||||||
|
SMTP SMTP `toml:"smtp"`
|
||||||
|
Origins []string `toml:"allowed_origins"`
|
||||||
|
}
|
||||||
|
type Config struct {
|
||||||
|
Port int `toml:"port"`
|
||||||
|
Forms []Form `toml:"forms"`
|
||||||
|
}
|
||||||
|
|
||||||
|
data := []byte(`
|
||||||
|
port = 8080
|
||||||
|
|
||||||
|
[[forms]]
|
||||||
|
name = "contact"
|
||||||
|
allowed_origins = ["https://example.com"]
|
||||||
|
|
||||||
|
[forms.smtp]
|
||||||
|
host = "smtp.example.com"
|
||||||
|
port = 587
|
||||||
|
`)
|
||||||
|
|
||||||
|
var cfg Config
|
||||||
|
if err := Unmarshal(data, &cfg); err != nil {
|
||||||
|
t.Fatalf("unmarshal: %v", err)
|
||||||
|
}
|
||||||
|
if cfg.Port != 8080 {
|
||||||
|
t.Errorf("Port = %d", cfg.Port)
|
||||||
|
}
|
||||||
|
if len(cfg.Forms) != 1 {
|
||||||
|
t.Fatalf("len(Forms) = %d", len(cfg.Forms))
|
||||||
|
}
|
||||||
|
f := cfg.Forms[0]
|
||||||
|
if f.Name != "contact" || f.SMTP.Host != "smtp.example.com" || f.SMTP.Port != 587 {
|
||||||
|
t.Errorf("form = %#v", f)
|
||||||
|
}
|
||||||
|
if len(f.Origins) != 1 || f.Origins[0] != "https://example.com" {
|
||||||
|
t.Errorf("origins = %#v", f.Origins)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnmarshalCaseInsensitiveAndUntagged(t *testing.T) {
|
||||||
|
type Config struct {
|
||||||
|
Title string
|
||||||
|
Count int
|
||||||
|
}
|
||||||
|
var cfg Config
|
||||||
|
if err := Unmarshal([]byte("title = \"x\"\ncount = 3\n"), &cfg); err != nil {
|
||||||
|
t.Fatalf("unmarshal: %v", err)
|
||||||
|
}
|
||||||
|
if cfg.Title != "x" || cfg.Count != 3 {
|
||||||
|
t.Errorf("cfg = %#v", cfg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDisallowUnknownFields(t *testing.T) {
|
||||||
|
type C struct {
|
||||||
|
Known string `toml:"known"`
|
||||||
|
}
|
||||||
|
data := []byte("known = \"x\"\nbogus = 1\n")
|
||||||
|
|
||||||
|
var lenient C
|
||||||
|
if err := Unmarshal(data, &lenient); err != nil {
|
||||||
|
t.Fatalf("lenient unmarshal: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var strict C
|
||||||
|
err := NewDecoder().DisallowUnknownFields().Decode(data, &strict)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for unknown field, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSkippedFieldTag(t *testing.T) {
|
||||||
|
type C struct {
|
||||||
|
Keep string `toml:"keep"`
|
||||||
|
Skip string `toml:"-"`
|
||||||
|
}
|
||||||
|
var c C
|
||||||
|
if err := Unmarshal([]byte("keep = \"y\"\n"), &c); err != nil {
|
||||||
|
t.Fatalf("unmarshal: %v", err)
|
||||||
|
}
|
||||||
|
if c.Keep != "y" || c.Skip != "" {
|
||||||
|
t.Errorf("c = %#v", c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSyntaxErrorReportsLine(t *testing.T) {
|
||||||
|
_, err := Parse([]byte("a = 1\nb = \nc = 3\n"))
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected a syntax error")
|
||||||
|
}
|
||||||
|
se, ok := err.(*SyntaxError)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("error is %T, want *SyntaxError", err)
|
||||||
|
}
|
||||||
|
if se.Line != 2 {
|
||||||
|
t.Errorf("Line = %d, want 2", se.Line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComments(t *testing.T) {
|
||||||
|
tree, err := Parse([]byte(`
|
||||||
|
# a leading comment
|
||||||
|
key = "value" # trailing comment
|
||||||
|
# another
|
||||||
|
`))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse: %v", err)
|
||||||
|
}
|
||||||
|
if tree["key"] != "value" {
|
||||||
|
t.Errorf("key = %q", tree["key"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDuplicateKeyRejected(t *testing.T) {
|
||||||
|
_, err := Parse([]byte("a = 1\na = 2\n"))
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected duplicate key error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRejectsInvalidNumbers(t *testing.T) {
|
||||||
|
for _, tok := range []string{
|
||||||
|
"01", "-01", "00",
|
||||||
|
"1__0", "_1", "1_", "0x_1", "1_.0",
|
||||||
|
"1.", ".5", "1.2.3", "1.e2",
|
||||||
|
"0x", "0o", "0b", "0b2", "0o8", "0xG",
|
||||||
|
"+0x1",
|
||||||
|
} {
|
||||||
|
if _, err := Parse([]byte("v = " + tok + "\n")); err == nil {
|
||||||
|
t.Errorf("%q: expected an error, got none", tok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAcceptsNumberEdgeCases(t *testing.T) {
|
||||||
|
cases := map[string]any{
|
||||||
|
"0": int64(0),
|
||||||
|
"-0": int64(0),
|
||||||
|
"+99": int64(99),
|
||||||
|
"1_000": int64(1000),
|
||||||
|
"0xDEAD_BEEF": int64(0xDEADBEEF),
|
||||||
|
"0o755": int64(493),
|
||||||
|
"0b1010": int64(10),
|
||||||
|
"0.0": 0.0,
|
||||||
|
"3.14": 3.14,
|
||||||
|
"6.022e23": 6.022e23,
|
||||||
|
"1e10": 1e10,
|
||||||
|
"-2.5E-3": -2.5e-3,
|
||||||
|
}
|
||||||
|
for tok, want := range cases {
|
||||||
|
tree, err := Parse([]byte("v = " + tok + "\n"))
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("%q: %v", tok, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if got := tree["v"]; got != want {
|
||||||
|
t.Errorf("%q = %#v (%T), want %#v", tok, got, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRejectsTableRedefinition(t *testing.T) {
|
||||||
|
_, err := Parse([]byte("[a]\nx = 1\n\n[a]\ny = 2\n"))
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected a table-redefinition error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAllowsImplicitThenExplicitTable(t *testing.T) {
|
||||||
|
tree, err := Parse([]byte("[a.b]\nx = 1\n\n[a]\ny = 2\n"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse: %v", err)
|
||||||
|
}
|
||||||
|
a := tree["a"].(map[string]any)
|
||||||
|
if a["y"] != int64(2) {
|
||||||
|
t.Errorf("a.y = %#v", a["y"])
|
||||||
|
}
|
||||||
|
if b := a["b"].(map[string]any); b["x"] != int64(1) {
|
||||||
|
t.Errorf("a.b.x = %#v", b["x"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRejectsControlCharInString(t *testing.T) {
|
||||||
|
if _, err := Parse([]byte("v = \"a\x01b\"\n")); err == nil {
|
||||||
|
t.Fatal("expected a control-character error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAllowsEscapedControlChar(t *testing.T) {
|
||||||
|
tree, err := Parse([]byte(`v = "\u0000"`))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse: %v", err)
|
||||||
|
}
|
||||||
|
if tree["v"] != "\x00" {
|
||||||
|
t.Errorf("v = %q", tree["v"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMultilineQuotesAtDelimiter(t *testing.T) {
|
||||||
|
tree, err := Parse([]byte("a = '''''two quotes'''''\n"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse: %v", err)
|
||||||
|
}
|
||||||
|
if tree["a"] != "''two quotes''" {
|
||||||
|
t.Errorf("a = %q", tree["a"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRejectsInlineTableExtension(t *testing.T) {
|
||||||
|
cases := map[string]string{
|
||||||
|
"by header": "a = { b = 1 }\n[a.c]\nx = 2\n",
|
||||||
|
"by dotted key": "a = { b = 1 }\na.c = 2\n",
|
||||||
|
"header over it": "a = { b = 1 }\n[a]\nx = 2\n",
|
||||||
|
}
|
||||||
|
for name, doc := range cases {
|
||||||
|
if _, err := Parse([]byte(doc)); err == nil {
|
||||||
|
t.Errorf("%s: expected an inline-table extension error", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRejectsSpecInvalid(t *testing.T) {
|
||||||
|
cases := map[string]string{
|
||||||
|
"single-digit hour": "a = 2023-10-01T1:32:00Z\n",
|
||||||
|
"inline duplicate key": "a = { b = 1, b = 2 }\n",
|
||||||
|
"inline dotted overwrite": "a = { b = 1, b.c = 2 }\n",
|
||||||
|
"dotted over header": "[a.b]\nx = 1\n[a]\nb.y = 2\n",
|
||||||
|
"table over array": "[[t]]\n[t]\n",
|
||||||
|
}
|
||||||
|
for name, doc := range cases {
|
||||||
|
if _, err := Parse([]byte(doc)); err == nil {
|
||||||
|
t.Errorf("%s: expected an error", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestArrayOfTablesPerElementSubtable(t *testing.T) {
|
||||||
|
tree, err := Parse([]byte(`
|
||||||
|
[[forms]]
|
||||||
|
name = "a"
|
||||||
|
|
||||||
|
[forms.smtp]
|
||||||
|
host = "h1"
|
||||||
|
|
||||||
|
[[forms]]
|
||||||
|
name = "b"
|
||||||
|
|
||||||
|
[forms.smtp]
|
||||||
|
host = "h2"
|
||||||
|
`))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse: %v", err)
|
||||||
|
}
|
||||||
|
forms := tree["forms"].([]map[string]any)
|
||||||
|
if len(forms) != 2 {
|
||||||
|
t.Fatalf("len(forms) = %d", len(forms))
|
||||||
|
}
|
||||||
|
if h := forms[0]["smtp"].(map[string]any)["host"]; h != "h1" {
|
||||||
|
t.Errorf("forms[0].smtp.host = %v", h)
|
||||||
|
}
|
||||||
|
if h := forms[1]["smtp"].(map[string]any)["host"]; h != "h2" {
|
||||||
|
t.Errorf("forms[1].smtp.host = %v", h)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
set dotenv-load := false
|
||||||
|
set positional-arguments := false
|
||||||
|
|
||||||
|
default:
|
||||||
|
@just --list
|
||||||
|
|
||||||
|
# Tidy and download module dependencies (stdlib only — there are none)
|
||||||
|
install:
|
||||||
|
@echo "→ Tidying Go module…"
|
||||||
|
go mod tidy
|
||||||
|
|
||||||
|
# Run the example program against a sample document
|
||||||
|
run:
|
||||||
|
@echo "→ Running example…"
|
||||||
|
go run ./examples/basic
|
||||||
|
|
||||||
|
# Vet, format-check, and compile the whole module (must be clean)
|
||||||
|
build:
|
||||||
|
@echo "→ Vetting…"
|
||||||
|
go vet ./...
|
||||||
|
@echo "→ Checking formatting…"
|
||||||
|
@test -z "$(gofmt -l .)" || { echo "gofmt needed:"; gofmt -l .; exit 1; }
|
||||||
|
@echo "→ Compiling…"
|
||||||
|
go build ./...
|
||||||
|
|
||||||
|
# Run the test suite
|
||||||
|
test:
|
||||||
|
@echo "→ Running tests…"
|
||||||
|
go test ./...
|
||||||
|
|
||||||
|
# Run tests with coverage and write an HTML report
|
||||||
|
coverage:
|
||||||
|
go test -coverprofile=coverage.out ./...
|
||||||
|
go tool cover -html=coverage.out -o coverage.html
|
||||||
|
|
||||||
|
# Remove build and coverage artifacts
|
||||||
|
uninstall:
|
||||||
|
@echo "→ Removing build artifacts…"
|
||||||
|
rm -rf bin coverage.out coverage.html
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
package interpres
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// decodeNumber parses a bare numeric token under strict TOML rules: no leading
|
||||||
|
// zeros, underscores only between digits, prefixed radixes without a sign, and
|
||||||
|
// floats with explicit fraction/exponent digits.
|
||||||
|
func decodeNumber(tok string) (any, error) {
|
||||||
|
switch tok {
|
||||||
|
case "inf", "+inf":
|
||||||
|
return math.Inf(1), nil
|
||||||
|
case "-inf":
|
||||||
|
return math.Inf(-1), nil
|
||||||
|
case "nan", "+nan", "-nan":
|
||||||
|
return math.NaN(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(tok) >= 2 && tok[0] == '0' && (tok[1] == 'x' || tok[1] == 'o' || tok[1] == 'b') {
|
||||||
|
return decodeRadix(tok)
|
||||||
|
}
|
||||||
|
if strings.ContainsAny(tok, ".eE") {
|
||||||
|
return decodeFloat(tok)
|
||||||
|
}
|
||||||
|
return decodeDecimalInt(tok)
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeDecimalInt(tok string) (any, error) {
|
||||||
|
sign, body := splitSign(tok)
|
||||||
|
digits, err := joinDigits(body, isDecDigit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := checkNoLeadingZero(digits); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
i, err := strconv.ParseInt(sign+digits, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("integer %q out of range", tok)
|
||||||
|
}
|
||||||
|
return i, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeRadix(tok string) (any, error) {
|
||||||
|
var base int
|
||||||
|
var isDigit func(byte) bool
|
||||||
|
switch tok[1] {
|
||||||
|
case 'x':
|
||||||
|
base, isDigit = 16, isHexDigit
|
||||||
|
case 'o':
|
||||||
|
base, isDigit = 8, isOctDigit
|
||||||
|
case 'b':
|
||||||
|
base, isDigit = 2, isBinDigit
|
||||||
|
}
|
||||||
|
digits, err := joinDigits(tok[2:], isDigit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
i, err := strconv.ParseInt(digits, base, 64)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("integer %q out of range", tok)
|
||||||
|
}
|
||||||
|
return i, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeFloat(tok string) (any, error) {
|
||||||
|
sign, s := splitSign(tok)
|
||||||
|
|
||||||
|
mantissa, exp := s, ""
|
||||||
|
if i := strings.IndexAny(s, "eE"); i >= 0 {
|
||||||
|
mantissa, exp = s[:i], s[i+1:]
|
||||||
|
}
|
||||||
|
|
||||||
|
intPart, frac, hasDot := mantissa, "", false
|
||||||
|
if i := strings.IndexByte(mantissa, '.'); i >= 0 {
|
||||||
|
intPart, frac, hasDot = mantissa[:i], mantissa[i+1:], true
|
||||||
|
}
|
||||||
|
if !hasDot && exp == "" {
|
||||||
|
return nil, fmt.Errorf("invalid float %q", tok)
|
||||||
|
}
|
||||||
|
|
||||||
|
ip, err := joinDigits(intPart, isDecDigit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := checkNoLeadingZero(ip); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
build := sign + ip
|
||||||
|
|
||||||
|
if hasDot {
|
||||||
|
fp, err := joinDigits(frac, isDecDigit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
build += "." + fp
|
||||||
|
}
|
||||||
|
if exp != "" {
|
||||||
|
esign, edigits := splitSign(exp)
|
||||||
|
ed, err := joinDigits(edigits, isDecDigit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
build += "e" + esign + ed
|
||||||
|
}
|
||||||
|
|
||||||
|
f, err := strconv.ParseFloat(build, 64)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid float %q", tok)
|
||||||
|
}
|
||||||
|
return f, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// joinDigits validates that every rune is a digit (per isDigit) and that each
|
||||||
|
// underscore sits between two digits, returning the digits with underscores
|
||||||
|
// removed.
|
||||||
|
func joinDigits(s string, isDigit func(byte) bool) (string, error) {
|
||||||
|
if s == "" {
|
||||||
|
return "", fmt.Errorf("number is missing digits")
|
||||||
|
}
|
||||||
|
var b strings.Builder
|
||||||
|
for i := 0; i < len(s); i++ {
|
||||||
|
c := s[i]
|
||||||
|
if c == '_' {
|
||||||
|
if i == 0 || i == len(s)-1 || !isDigit(s[i-1]) || !isDigit(s[i+1]) {
|
||||||
|
return "", fmt.Errorf("misplaced underscore in number %q", s)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !isDigit(c) {
|
||||||
|
return "", fmt.Errorf("invalid character %q in number", string(c))
|
||||||
|
}
|
||||||
|
b.WriteByte(c)
|
||||||
|
}
|
||||||
|
return b.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkNoLeadingZero(digits string) error {
|
||||||
|
if len(digits) > 1 && digits[0] == '0' {
|
||||||
|
return fmt.Errorf("leading zeros are not allowed in numbers")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func splitSign(tok string) (sign, rest string) {
|
||||||
|
if tok != "" && (tok[0] == '+' || tok[0] == '-') {
|
||||||
|
if tok[0] == '-' {
|
||||||
|
return "-", tok[1:]
|
||||||
|
}
|
||||||
|
return "", tok[1:]
|
||||||
|
}
|
||||||
|
return "", tok
|
||||||
|
}
|
||||||
|
|
||||||
|
func isDecDigit(c byte) bool { return c >= '0' && c <= '9' }
|
||||||
|
func isOctDigit(c byte) bool { return c >= '0' && c <= '7' }
|
||||||
|
func isBinDigit(c byte) bool { return c == '0' || c == '1' }
|
||||||
|
func isHexDigit(c byte) bool {
|
||||||
|
return isDecDigit(c) || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')
|
||||||
|
}
|
||||||
@@ -0,0 +1,892 @@
|
|||||||
|
package interpres
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// parser is a recursive-descent TOML parser producing a map[string]any tree.
|
||||||
|
type parser struct {
|
||||||
|
src []rune
|
||||||
|
pos int
|
||||||
|
line int
|
||||||
|
|
||||||
|
root map[string]any
|
||||||
|
current map[string]any
|
||||||
|
headers map[string]bool
|
||||||
|
frozen map[string]bool
|
||||||
|
dotted map[string]bool
|
||||||
|
arrays map[string]bool
|
||||||
|
|
||||||
|
currentPath []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *parser) parse() (map[string]any, error) {
|
||||||
|
p.root = map[string]any{}
|
||||||
|
p.current = p.root
|
||||||
|
p.headers = map[string]bool{}
|
||||||
|
p.frozen = map[string]bool{}
|
||||||
|
p.dotted = map[string]bool{}
|
||||||
|
p.arrays = map[string]bool{}
|
||||||
|
p.currentPath = nil
|
||||||
|
|
||||||
|
for {
|
||||||
|
if err := p.skipBlank(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if p.eof() {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
c := p.peek()
|
||||||
|
switch {
|
||||||
|
case c == '[':
|
||||||
|
if err := p.parseTableHeader(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
if err := p.parseKeyValue(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := p.expectLineEnd(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return p.root, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- table headers ---------------------------------------------------------
|
||||||
|
|
||||||
|
func (p *parser) parseTableHeader() error {
|
||||||
|
array := false
|
||||||
|
p.next() // consume '['
|
||||||
|
if !p.eof() && p.peek() == '[' {
|
||||||
|
array = true
|
||||||
|
p.next()
|
||||||
|
}
|
||||||
|
|
||||||
|
key, err := p.parseKeyPath()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
p.skipInline()
|
||||||
|
if p.eof() || p.peek() != ']' {
|
||||||
|
return p.errf("expected ']' to close table header")
|
||||||
|
}
|
||||||
|
p.next()
|
||||||
|
if array {
|
||||||
|
if p.eof() || p.peek() != ']' {
|
||||||
|
return p.errf("expected ']]' to close array-of-tables header")
|
||||||
|
}
|
||||||
|
p.next()
|
||||||
|
}
|
||||||
|
|
||||||
|
if array {
|
||||||
|
tbl, err := p.appendArrayTable(key)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// A new array-of-tables element starts a fresh scope: sub-table headers
|
||||||
|
// and inline-table freezes from the previous element no longer apply.
|
||||||
|
p.resetScopeUnder(key)
|
||||||
|
p.arrays[pathKey(key)] = true
|
||||||
|
p.current = tbl
|
||||||
|
p.currentPath = key
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
pk := pathKey(key)
|
||||||
|
if p.headers[pk] || p.dotted[pk] || p.arrays[pk] {
|
||||||
|
return p.errf("table %q is defined more than once", strings.Join(key, "."))
|
||||||
|
}
|
||||||
|
p.headers[pk] = true
|
||||||
|
|
||||||
|
tbl, err := p.tableAt(key)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
p.current = tbl
|
||||||
|
p.currentPath = key
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// tableAt walks (creating intermediate tables) to the table named by key,
|
||||||
|
// relative to the document root, rejecting any step into a frozen inline table.
|
||||||
|
func (p *parser) tableAt(key []string) (map[string]any, error) {
|
||||||
|
cur := p.root
|
||||||
|
path := make([]string, 0, len(key))
|
||||||
|
for _, k := range key {
|
||||||
|
path = append(path, k)
|
||||||
|
if p.frozen[pathKey(path)] {
|
||||||
|
return nil, p.errf("cannot extend inline table %q", strings.Join(path, "."))
|
||||||
|
}
|
||||||
|
existing, ok := cur[k]
|
||||||
|
if !ok {
|
||||||
|
next := map[string]any{}
|
||||||
|
cur[k] = next
|
||||||
|
cur = next
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch v := existing.(type) {
|
||||||
|
case map[string]any:
|
||||||
|
cur = v
|
||||||
|
case []map[string]any:
|
||||||
|
if len(v) == 0 {
|
||||||
|
return nil, p.errf("key %q is an empty array of tables", k)
|
||||||
|
}
|
||||||
|
cur = v[len(v)-1]
|
||||||
|
default:
|
||||||
|
return nil, p.errf("key %q is not a table", k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cur, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *parser) appendArrayTable(key []string) (map[string]any, error) {
|
||||||
|
parent := p.root
|
||||||
|
for _, k := range key[:len(key)-1] {
|
||||||
|
existing, ok := parent[k]
|
||||||
|
if !ok {
|
||||||
|
next := map[string]any{}
|
||||||
|
parent[k] = next
|
||||||
|
parent = next
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch v := existing.(type) {
|
||||||
|
case map[string]any:
|
||||||
|
parent = v
|
||||||
|
case []map[string]any:
|
||||||
|
parent = v[len(v)-1]
|
||||||
|
default:
|
||||||
|
return nil, p.errf("key %q is not a table", k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
leaf := key[len(key)-1]
|
||||||
|
tbl := map[string]any{}
|
||||||
|
switch existing := parent[leaf].(type) {
|
||||||
|
case nil:
|
||||||
|
parent[leaf] = []map[string]any{tbl}
|
||||||
|
case []map[string]any:
|
||||||
|
parent[leaf] = append(existing, tbl)
|
||||||
|
default:
|
||||||
|
return nil, p.errf("key %q is not an array of tables", leaf)
|
||||||
|
}
|
||||||
|
return tbl, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- key/value -------------------------------------------------------------
|
||||||
|
|
||||||
|
func (p *parser) parseKeyValue() error {
|
||||||
|
key, err := p.parseKeyPath()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
p.skipInline()
|
||||||
|
if p.eof() || p.peek() != '=' {
|
||||||
|
return p.errf("expected '=' after key")
|
||||||
|
}
|
||||||
|
p.next()
|
||||||
|
p.skipInline()
|
||||||
|
|
||||||
|
val, err := p.parseValue()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
dest := p.current
|
||||||
|
abs := append([]string{}, p.currentPath...)
|
||||||
|
for _, k := range key[:len(key)-1] {
|
||||||
|
abs = append(abs, k)
|
||||||
|
if p.frozen[pathKey(abs)] {
|
||||||
|
return p.errf("cannot extend inline table %q", strings.Join(abs, "."))
|
||||||
|
}
|
||||||
|
if p.headers[pathKey(abs)] {
|
||||||
|
return p.errf("cannot extend table %q with a dotted key", strings.Join(abs, "."))
|
||||||
|
}
|
||||||
|
p.dotted[pathKey(abs)] = true
|
||||||
|
existing, ok := dest[k]
|
||||||
|
if !ok {
|
||||||
|
next := map[string]any{}
|
||||||
|
dest[k] = next
|
||||||
|
dest = next
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
m, ok := existing.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
return p.errf("key %q is not a table", k)
|
||||||
|
}
|
||||||
|
dest = m
|
||||||
|
}
|
||||||
|
leaf := key[len(key)-1]
|
||||||
|
abs = append(abs, leaf)
|
||||||
|
if _, exists := dest[leaf]; exists {
|
||||||
|
return p.errf("duplicate key %q", leaf)
|
||||||
|
}
|
||||||
|
dest[leaf] = val
|
||||||
|
p.freezeInline(abs, val)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// freezeInline marks the path of an inline table (and any nested inline tables)
|
||||||
|
// as immutable, so a later header or dotted key cannot extend it.
|
||||||
|
func (p *parser) freezeInline(path []string, val any) {
|
||||||
|
m, ok := val.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p.frozen[pathKey(path)] = true
|
||||||
|
for k, v := range m {
|
||||||
|
child := append(append([]string{}, path...), k)
|
||||||
|
p.freezeInline(child, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// resetScopeUnder forgets the header and freeze records nested under key, which
|
||||||
|
// belong to the previous element of an array of tables.
|
||||||
|
func (p *parser) resetScopeUnder(key []string) {
|
||||||
|
prefix := pathKey(key) + "\x00"
|
||||||
|
for k := range p.headers {
|
||||||
|
if strings.HasPrefix(k, prefix) {
|
||||||
|
delete(p.headers, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for k := range p.frozen {
|
||||||
|
if strings.HasPrefix(k, prefix) {
|
||||||
|
delete(p.frozen, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseKeyPath parses a dotted key into its components.
|
||||||
|
func (p *parser) parseKeyPath() ([]string, error) {
|
||||||
|
var parts []string
|
||||||
|
for {
|
||||||
|
p.skipInline()
|
||||||
|
part, err := p.parseKeyComponent()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
parts = append(parts, part)
|
||||||
|
p.skipInline()
|
||||||
|
if !p.eof() && p.peek() == '.' {
|
||||||
|
p.next()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
return parts, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *parser) parseKeyComponent() (string, error) {
|
||||||
|
if p.eof() {
|
||||||
|
return "", p.errf("expected a key")
|
||||||
|
}
|
||||||
|
switch c := p.peek(); c {
|
||||||
|
case '"':
|
||||||
|
if p.lookahead(`"""`) {
|
||||||
|
return "", p.errf("multiline strings are not allowed in keys")
|
||||||
|
}
|
||||||
|
return p.parseBasicString()
|
||||||
|
case '\'':
|
||||||
|
if p.lookahead(`'''`) {
|
||||||
|
return "", p.errf("multiline strings are not allowed in keys")
|
||||||
|
}
|
||||||
|
return p.parseLiteralString()
|
||||||
|
default:
|
||||||
|
start := p.pos
|
||||||
|
for !p.eof() {
|
||||||
|
c := p.peek()
|
||||||
|
if (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') ||
|
||||||
|
(c >= '0' && c <= '9') || c == '_' || c == '-' {
|
||||||
|
p.next()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if p.pos == start {
|
||||||
|
return "", p.errf("invalid key character %q", string(p.peek()))
|
||||||
|
}
|
||||||
|
return string(p.src[start:p.pos]), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- values ----------------------------------------------------------------
|
||||||
|
|
||||||
|
func (p *parser) parseValue() (any, error) {
|
||||||
|
if p.eof() {
|
||||||
|
return nil, p.errf("expected a value")
|
||||||
|
}
|
||||||
|
switch c := p.peek(); {
|
||||||
|
case c == '"':
|
||||||
|
return p.parseBasicString()
|
||||||
|
case c == '\'':
|
||||||
|
return p.parseLiteralString()
|
||||||
|
case c == '[':
|
||||||
|
return p.parseArray()
|
||||||
|
case c == '{':
|
||||||
|
return p.parseInlineTable()
|
||||||
|
case c == 't' || c == 'f':
|
||||||
|
return p.parseBool()
|
||||||
|
default:
|
||||||
|
return p.parseAtom()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *parser) parseBool() (any, error) {
|
||||||
|
if p.match("true") {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
if p.match("false") {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return nil, p.errf("invalid value")
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseAtom handles numbers, inf/nan, and date-times.
|
||||||
|
func (p *parser) parseAtom() (any, error) {
|
||||||
|
start := p.pos
|
||||||
|
p.scanBareToken()
|
||||||
|
tok := string(p.src[start:p.pos])
|
||||||
|
if tok == "" {
|
||||||
|
return nil, p.errf("expected a value")
|
||||||
|
}
|
||||||
|
// A date may be followed by a space and a time, forming one date-time.
|
||||||
|
if isDateToken(tok) && !p.eof() && p.peek() == ' ' &&
|
||||||
|
p.pos+1 < len(p.src) && p.src[p.pos+1] >= '0' && p.src[p.pos+1] <= '9' {
|
||||||
|
p.next() // consume the separating space
|
||||||
|
timeStart := p.pos
|
||||||
|
p.scanBareToken()
|
||||||
|
tok = tok + " " + string(p.src[timeStart:p.pos])
|
||||||
|
}
|
||||||
|
if v, ok := parseDateTime(tok); ok {
|
||||||
|
return v, nil
|
||||||
|
}
|
||||||
|
v, err := decodeNumber(tok)
|
||||||
|
if err != nil {
|
||||||
|
return nil, p.errf("%s", err)
|
||||||
|
}
|
||||||
|
return v, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// scanBareToken advances past a bare value token (number, bool, or date-time),
|
||||||
|
// stopping at whitespace, a separator, or a comment.
|
||||||
|
func (p *parser) scanBareToken() {
|
||||||
|
for !p.eof() {
|
||||||
|
c := p.peek()
|
||||||
|
if c == ' ' || c == '\t' || c == '\n' || c == '\r' ||
|
||||||
|
c == ',' || c == ']' || c == '}' || c == '#' {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p.next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- strings ---------------------------------------------------------------
|
||||||
|
|
||||||
|
func (p *parser) parseBasicString() (string, error) {
|
||||||
|
if p.lookahead(`"""`) {
|
||||||
|
return p.parseMultilineString('"', true)
|
||||||
|
}
|
||||||
|
p.next() // opening quote
|
||||||
|
var b strings.Builder
|
||||||
|
for {
|
||||||
|
if p.eof() {
|
||||||
|
return "", p.errf("unterminated string")
|
||||||
|
}
|
||||||
|
c := p.next()
|
||||||
|
switch c {
|
||||||
|
case '"':
|
||||||
|
return b.String(), nil
|
||||||
|
case '\n':
|
||||||
|
return "", p.errf("unterminated string")
|
||||||
|
case '\r':
|
||||||
|
return "", p.errf("bare carriage return is not allowed in a string")
|
||||||
|
case '\\':
|
||||||
|
r, err := p.readEscape()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
b.WriteRune(r)
|
||||||
|
default:
|
||||||
|
if isControlRune(c) {
|
||||||
|
return "", p.errf("control character U+%04X is not allowed in a string", c)
|
||||||
|
}
|
||||||
|
b.WriteRune(c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *parser) parseLiteralString() (string, error) {
|
||||||
|
if p.lookahead(`'''`) {
|
||||||
|
return p.parseMultilineString('\'', false)
|
||||||
|
}
|
||||||
|
p.next() // opening quote
|
||||||
|
var b strings.Builder
|
||||||
|
for {
|
||||||
|
if p.eof() {
|
||||||
|
return "", p.errf("unterminated literal string")
|
||||||
|
}
|
||||||
|
c := p.next()
|
||||||
|
if c == '\'' {
|
||||||
|
return b.String(), nil
|
||||||
|
}
|
||||||
|
if c == '\n' {
|
||||||
|
return "", p.errf("unterminated literal string")
|
||||||
|
}
|
||||||
|
if c == '\r' {
|
||||||
|
return "", p.errf("bare carriage return is not allowed in a string")
|
||||||
|
}
|
||||||
|
if isControlRune(c) {
|
||||||
|
return "", p.errf("control character U+%04X is not allowed in a string", c)
|
||||||
|
}
|
||||||
|
b.WriteRune(c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *parser) parseMultilineString(quote rune, escapes bool) (string, error) {
|
||||||
|
p.skipN(3) // opening delimiter
|
||||||
|
// A newline immediately after the opening delimiter is trimmed.
|
||||||
|
if !p.eof() && p.peek() == '\r' {
|
||||||
|
p.next()
|
||||||
|
}
|
||||||
|
if !p.eof() && p.peek() == '\n' {
|
||||||
|
p.line++
|
||||||
|
p.next()
|
||||||
|
}
|
||||||
|
|
||||||
|
var b strings.Builder
|
||||||
|
for {
|
||||||
|
if p.eof() {
|
||||||
|
return "", p.errf("unterminated multiline string")
|
||||||
|
}
|
||||||
|
if p.peek() == quote {
|
||||||
|
// Count the run of delimiter characters. The last three close the
|
||||||
|
// string; up to two extra ones belong to the content.
|
||||||
|
n := 0
|
||||||
|
for p.pos+n < len(p.src) && p.src[p.pos+n] == quote {
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
if n >= 3 {
|
||||||
|
if n > 5 {
|
||||||
|
return "", p.errf("too many '%c' before the closing delimiter", quote)
|
||||||
|
}
|
||||||
|
for i := 0; i < n-3; i++ {
|
||||||
|
b.WriteRune(quote)
|
||||||
|
}
|
||||||
|
p.skipN(n)
|
||||||
|
return b.String(), nil
|
||||||
|
}
|
||||||
|
for i := 0; i < n; i++ {
|
||||||
|
b.WriteRune(quote)
|
||||||
|
p.next()
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
c := p.next()
|
||||||
|
if c == '\n' {
|
||||||
|
p.line++
|
||||||
|
b.WriteRune(c)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if c == '\r' {
|
||||||
|
if !p.eof() && p.peek() == '\n' {
|
||||||
|
b.WriteRune(c)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return "", p.errf("bare carriage return is not allowed in a string")
|
||||||
|
}
|
||||||
|
if escapes && c == '\\' {
|
||||||
|
// Line-ending backslash trims the following whitespace/newlines.
|
||||||
|
if p.trimLineEndingBackslash() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
r, err := p.readEscape()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
b.WriteRune(r)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if isControlRune(c) {
|
||||||
|
return "", p.errf("control character U+%04X is not allowed in a string", c)
|
||||||
|
}
|
||||||
|
b.WriteRune(c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// trimLineEndingBackslash consumes whitespace through the next newline (and the
|
||||||
|
// blank lines that follow) when a backslash is the last token on a line.
|
||||||
|
// It reports whether it did so.
|
||||||
|
func (p *parser) trimLineEndingBackslash() bool {
|
||||||
|
save, saveLine := p.pos, p.line
|
||||||
|
for !p.eof() {
|
||||||
|
c := p.peek()
|
||||||
|
if c == ' ' || c == '\t' || c == '\r' {
|
||||||
|
p.next()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if c == '\n' {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
// Not a line-ending backslash; restore.
|
||||||
|
p.pos, p.line = save, saveLine
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if p.eof() {
|
||||||
|
p.pos, p.line = save, saveLine
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// Consume the newline and all following whitespace.
|
||||||
|
for !p.eof() {
|
||||||
|
c := p.peek()
|
||||||
|
if c == '\n' {
|
||||||
|
p.line++
|
||||||
|
p.next()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if c == ' ' || c == '\t' || c == '\r' {
|
||||||
|
p.next()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *parser) readEscape() (rune, error) {
|
||||||
|
if p.eof() {
|
||||||
|
return 0, p.errf("unterminated escape sequence")
|
||||||
|
}
|
||||||
|
c := p.next()
|
||||||
|
switch c {
|
||||||
|
case 'b':
|
||||||
|
return '\b', nil
|
||||||
|
case 't':
|
||||||
|
return '\t', nil
|
||||||
|
case 'n':
|
||||||
|
return '\n', nil
|
||||||
|
case 'f':
|
||||||
|
return '\f', nil
|
||||||
|
case 'r':
|
||||||
|
return '\r', nil
|
||||||
|
case '"':
|
||||||
|
return '"', nil
|
||||||
|
case '\\':
|
||||||
|
return '\\', nil
|
||||||
|
case 'u':
|
||||||
|
return p.readUnicode(4)
|
||||||
|
case 'U':
|
||||||
|
return p.readUnicode(8)
|
||||||
|
default:
|
||||||
|
return 0, p.errf("invalid escape sequence \\%c", c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *parser) readUnicode(n int) (rune, error) {
|
||||||
|
if p.pos+n > len(p.src) {
|
||||||
|
return 0, p.errf("invalid unicode escape")
|
||||||
|
}
|
||||||
|
hex := string(p.src[p.pos : p.pos+n])
|
||||||
|
p.pos += n
|
||||||
|
v, err := strconv.ParseInt(hex, 16, 64)
|
||||||
|
if err != nil {
|
||||||
|
return 0, p.errf("invalid unicode escape \\%s", hex)
|
||||||
|
}
|
||||||
|
if v > 0x10FFFF || (v >= 0xD800 && v <= 0xDFFF) {
|
||||||
|
return 0, p.errf("escape \\%s is not a valid Unicode scalar value", hex)
|
||||||
|
}
|
||||||
|
return rune(v), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- arrays and inline tables ---------------------------------------------
|
||||||
|
|
||||||
|
func (p *parser) parseArray() (any, error) {
|
||||||
|
p.next() // '['
|
||||||
|
arr := []any{}
|
||||||
|
for {
|
||||||
|
if err := p.skipArraySpace(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if p.eof() {
|
||||||
|
return nil, p.errf("unterminated array")
|
||||||
|
}
|
||||||
|
if p.peek() == ']' {
|
||||||
|
p.next()
|
||||||
|
return arr, nil
|
||||||
|
}
|
||||||
|
v, err := p.parseValue()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
arr = append(arr, v)
|
||||||
|
if err := p.skipArraySpace(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if p.eof() {
|
||||||
|
return nil, p.errf("unterminated array")
|
||||||
|
}
|
||||||
|
switch p.peek() {
|
||||||
|
case ',':
|
||||||
|
p.next()
|
||||||
|
case ']':
|
||||||
|
p.next()
|
||||||
|
return arr, nil
|
||||||
|
default:
|
||||||
|
return nil, p.errf("expected ',' or ']' in array")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *parser) parseInlineTable() (any, error) {
|
||||||
|
p.next() // '{'
|
||||||
|
tbl := map[string]any{}
|
||||||
|
assigned := map[string]bool{}
|
||||||
|
p.skipInline()
|
||||||
|
if !p.eof() && p.peek() == '}' {
|
||||||
|
p.next()
|
||||||
|
return tbl, nil
|
||||||
|
}
|
||||||
|
for {
|
||||||
|
p.skipInline()
|
||||||
|
key, err := p.parseKeyPath()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
p.skipInline()
|
||||||
|
if p.eof() || p.peek() != '=' {
|
||||||
|
return nil, p.errf("expected '=' in inline table")
|
||||||
|
}
|
||||||
|
p.next()
|
||||||
|
p.skipInline()
|
||||||
|
val, err := p.parseValue()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
dest := tbl
|
||||||
|
path := make([]string, 0, len(key))
|
||||||
|
for _, k := range key[:len(key)-1] {
|
||||||
|
path = append(path, k)
|
||||||
|
if assigned[pathKey(path)] {
|
||||||
|
return nil, p.errf("key %q is already defined", strings.Join(path, "."))
|
||||||
|
}
|
||||||
|
existing, ok := dest[k]
|
||||||
|
if !ok {
|
||||||
|
m := map[string]any{}
|
||||||
|
dest[k] = m
|
||||||
|
dest = m
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
m, isMap := existing.(map[string]any)
|
||||||
|
if !isMap {
|
||||||
|
return nil, p.errf("key %q is already defined", k)
|
||||||
|
}
|
||||||
|
dest = m
|
||||||
|
}
|
||||||
|
leaf := key[len(key)-1]
|
||||||
|
path = append(path, leaf)
|
||||||
|
if _, exists := dest[leaf]; exists {
|
||||||
|
return nil, p.errf("duplicate key %q in inline table", leaf)
|
||||||
|
}
|
||||||
|
dest[leaf] = val
|
||||||
|
assigned[pathKey(path)] = true
|
||||||
|
|
||||||
|
p.skipInline()
|
||||||
|
if p.eof() {
|
||||||
|
return nil, p.errf("unterminated inline table")
|
||||||
|
}
|
||||||
|
switch p.peek() {
|
||||||
|
case ',':
|
||||||
|
p.next()
|
||||||
|
case '}':
|
||||||
|
p.next()
|
||||||
|
return tbl, nil
|
||||||
|
default:
|
||||||
|
return nil, p.errf("expected ',' or '}' in inline table")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- scanning helpers ------------------------------------------------------
|
||||||
|
|
||||||
|
func (p *parser) eof() bool { return p.pos >= len(p.src) }
|
||||||
|
func (p *parser) peek() rune { return p.src[p.pos] }
|
||||||
|
|
||||||
|
func (p *parser) next() rune {
|
||||||
|
c := p.src[p.pos]
|
||||||
|
p.pos++
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *parser) skipN(n int) {
|
||||||
|
for i := 0; i < n && !p.eof(); i++ {
|
||||||
|
p.next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *parser) match(word string) bool {
|
||||||
|
if p.lookahead(word) {
|
||||||
|
p.skipN(len([]rune(word)))
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *parser) lookahead(s string) bool {
|
||||||
|
r := []rune(s)
|
||||||
|
if p.pos+len(r) > len(p.src) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for i, c := range r {
|
||||||
|
if p.src[p.pos+i] != c {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// skipInline consumes spaces and tabs only.
|
||||||
|
func (p *parser) skipInline() {
|
||||||
|
for !p.eof() {
|
||||||
|
if c := p.peek(); c == ' ' || c == '\t' {
|
||||||
|
p.next()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// skipArraySpace consumes whitespace, newlines, and comments inside arrays.
|
||||||
|
func (p *parser) skipArraySpace() error {
|
||||||
|
for !p.eof() {
|
||||||
|
switch p.peek() {
|
||||||
|
case ' ', '\t':
|
||||||
|
p.next()
|
||||||
|
case '\r':
|
||||||
|
if err := p.expectCRLF(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
case '\n':
|
||||||
|
p.line++
|
||||||
|
p.next()
|
||||||
|
case '#':
|
||||||
|
if err := p.skipComment(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// skipBlank consumes whitespace, blank lines, and comments between statements.
|
||||||
|
func (p *parser) skipBlank() error {
|
||||||
|
for !p.eof() {
|
||||||
|
switch p.peek() {
|
||||||
|
case ' ', '\t':
|
||||||
|
p.next()
|
||||||
|
case '\r':
|
||||||
|
if err := p.expectCRLF(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
case '\n':
|
||||||
|
p.line++
|
||||||
|
p.next()
|
||||||
|
case '#':
|
||||||
|
if err := p.skipComment(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *parser) skipComment() error {
|
||||||
|
p.next() // consume '#'
|
||||||
|
for !p.eof() {
|
||||||
|
c := p.peek()
|
||||||
|
switch {
|
||||||
|
case c == '\n':
|
||||||
|
return nil
|
||||||
|
case c == '\r':
|
||||||
|
if p.pos+1 < len(p.src) && p.src[p.pos+1] == '\n' {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return p.errf("bare carriage return is not allowed")
|
||||||
|
case c == '\t':
|
||||||
|
p.next()
|
||||||
|
case c < 0x20 || c == 0x7f:
|
||||||
|
return p.errf("control character U+%04X is not allowed in a comment", c)
|
||||||
|
default:
|
||||||
|
p.next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// expectCRLF consumes a carriage return that must be immediately followed by a
|
||||||
|
// line feed; a bare CR is invalid.
|
||||||
|
func (p *parser) expectCRLF() error {
|
||||||
|
if p.pos+1 < len(p.src) && p.src[p.pos+1] == '\n' {
|
||||||
|
p.next() // consume CR; the LF is handled by the caller
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return p.errf("bare carriage return is not allowed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// expectLineEnd consumes trailing inline whitespace and an optional comment,
|
||||||
|
// then requires a newline or end of input.
|
||||||
|
func (p *parser) expectLineEnd() error {
|
||||||
|
p.skipInline()
|
||||||
|
if p.eof() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if p.peek() == '#' {
|
||||||
|
if err := p.skipComment(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if p.eof() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if p.peek() == '\r' {
|
||||||
|
if err := p.expectCRLF(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if p.eof() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if p.peek() == '\n' {
|
||||||
|
p.line++
|
||||||
|
p.next()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return p.errf("unexpected %q after value", string(p.peek()))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *parser) errf(format string, args ...any) error {
|
||||||
|
return &SyntaxError{Line: p.line, Msg: fmt.Sprintf(format, args...)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// pathKey joins key components with a NUL separator so a dotted path can be
|
||||||
|
// used as a map key for tracking defined tables.
|
||||||
|
func pathKey(parts []string) string {
|
||||||
|
return strings.Join(parts, "\x00")
|
||||||
|
}
|
||||||
|
|
||||||
|
// isControlRune reports whether r is a control character disallowed in a string
|
||||||
|
// literal. Tab, line feed, and carriage return are permitted (handled
|
||||||
|
// elsewhere); everything else below U+0020, plus U+007F, is rejected.
|
||||||
|
func isControlRune(r rune) bool {
|
||||||
|
if r == '\t' || r == '\n' || r == '\r' {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return r < 0x20 || r == 0x7f
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user