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