feat: scaffold interpres project with TOML parser and CI

This commit is contained in:
2026-06-20 12:24:03 +02:00
commit 591d1f01d1
18 changed files with 2637 additions and 0 deletions
+43
View File
@@ -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
+81
View File
@@ -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})."
+66
View File
@@ -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
+8
View File
@@ -0,0 +1,8 @@
# Build artifacts
bin/
*.test
*.out
# Coverage
coverage.out
coverage.html
+53
View File
@@ -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`).
+76
View File
@@ -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:`).
+21
View File
@@ -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.
+129
View File
@@ -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)
+104
View File
@@ -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
View File
@@ -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
}
+210
View File
@@ -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
}
+51
View File
@@ -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)
}
}
+3
View File
@@ -0,0 +1,3 @@
module codeberg.org/petrbalvin/interpres
go 1.26
+86
View File
@@ -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)
}
+508
View File
@@ -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)
}
}
+39
View File
@@ -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
+164
View File
@@ -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')
}
+892
View File
@@ -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
}