commit 591d1f01d15545e752df556dfc427fee136770b6 Author: Petr Date: Sat Jun 20 12:24:03 2026 +0200 feat: scaffold interpres project with TOML parser and CI diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..8310d60 --- /dev/null +++ b/.editorconfig @@ -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 diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml new file mode 100644 index 0000000..4c78c5b --- /dev/null +++ b/.forgejo/workflows/release.yml @@ -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})." diff --git a/.forgejo/workflows/test.yml b/.forgejo/workflows/test.yml new file mode 100644 index 0000000..579490f --- /dev/null +++ b/.forgejo/workflows/test.yml @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..374340f --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +# Build artifacts +bin/ +*.test +*.out + +# Coverage +coverage.out +coverage.html diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..f9bcc8e --- /dev/null +++ b/CHANGELOG.md @@ -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`). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..f648037 --- /dev/null +++ b/CONTRIBUTING.md @@ -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:`). diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f83dd2a --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Petr Balvín (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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..c7f0a8d --- /dev/null +++ b/README.md @@ -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) diff --git a/cmd/interpres-decode/main.go b/cmd/interpres-decode/main.go new file mode 100644 index 0000000..af05534 --- /dev/null +++ b/cmd/interpres-decode/main.go @@ -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) + } +} diff --git a/datetime.go b/datetime.go new file mode 100644 index 0000000..bbd40e1 --- /dev/null +++ b/datetime.go @@ -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 "datetime". +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 +} diff --git a/decode.go b/decode.go new file mode 100644 index 0000000..42400a3 --- /dev/null +++ b/decode.go @@ -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 +} diff --git a/examples/basic/main.go b/examples/basic/main.go new file mode 100644 index 0000000..525704a --- /dev/null +++ b/examples/basic/main.go @@ -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) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..0d9271b --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module codeberg.org/petrbalvin/interpres + +go 1.26 diff --git a/interpres.go b/interpres.go new file mode 100644 index 0000000..f7eb320 --- /dev/null +++ b/interpres.go @@ -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) +} diff --git a/interpres_test.go b/interpres_test.go new file mode 100644 index 0000000..c1737b0 --- /dev/null +++ b/interpres_test.go @@ -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) + } +} diff --git a/justfile b/justfile new file mode 100644 index 0000000..dcb20fc --- /dev/null +++ b/justfile @@ -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 diff --git a/number.go b/number.go new file mode 100644 index 0000000..7270f95 --- /dev/null +++ b/number.go @@ -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') +} diff --git a/parser.go b/parser.go new file mode 100644 index 0000000..a87b2e6 --- /dev/null +++ b/parser.go @@ -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 +}