Files
interpres/docs/ARCHITECTURE.md
T
petrbalvin bccaf087c8
Test / test (push) Successful in 1m33s
feat(cmd): add the encoder mode to the toml-test adapter
Assisted-by: DeepSeek V4.1 Flash
2026-09-19 11:38:19 +02:00

5.8 KiB

Architecture

How interpres is put together. Every file, package and arrow below exists in the source tree; nothing is aspirational.

Overview

interpres is one public library package, one command, and one example. The library implements the whole of TOML 1.1, decoding and encoding, in the standard library alone; the command wraps the parser and the encoder for the toml-test compliance harness, against which it stands at 214 valid, 467 invalid and 214 encoder cases with zero failures; the example demonstrates the API.

flowchart TD
    CLI[cmd/interpres-decode<br/>toml-test adapter] --> API
    EX[examples/basic<br/>usage demo] --> API
    subgraph Lib [package interpres]
        API[interpres.go<br/>public API and types]
        API --> P[parser.go<br/>recursive-descent parser]
        API --> DEC[decode.go<br/>tree onto Go values]
        API --> ENC[encode.go<br/>Go values to TOML]
        P --> N[number.go<br/>numeric tokens]
        P --> DT[datetime.go<br/>date-time atoms and types]
    end

The public API in interpres.go is a thin facade: every entry point funnels into ParseContext for parsing and into the unexported decoder and encoder for the reflection work. parser.go owns the grammar; it leans on number.go and datetime.go for the two token families that need their own strict validation.

Packages

Path Responsibility
. (package interpres) The whole library. interpres.go declares the exported surface (Parse, Unmarshal, Marshal, the *Context variants, Decoder, Encoder, Marshaler, Unmarshaler, SyntaxError, the local date-time types); everything below it is unexported.
cmd/interpres-decode The toml-test adapter, both directions. Reads TOML on stdin, writes tagged JSON on stdout; with -encode it reads tagged JSON and writes TOML. Owns no parsing logic and no emission logic.
examples/basic A runnable tour of the API. Documentation in executable form, not part of the library.

Inside the library package, one file owns one concern:

File Responsibility
parser.go The recursive-descent parser. Produces the map[string]any tree and enforces the structural rules of TOML 1.1 (table redefinitions, dotted keys, arrays of tables, multi-line inline tables). Reports a 1-based line on failure.
number.go Strict numeric tokens: integers in the four radixes with _ separators, and floats including inf and nan. Rejects leading zeros, misplaced underscores and malformed fractions.
datetime.go The three local date-time wrapper types and parseDateTime, which classifies a token into the four date-time kinds under the strict TOML grammar.
decode.go Maps the parsed tree onto Go values by reflection: struct fields, maps, slices, scalar conversion with overflow checks, Unmarshaler dispatch.
encode.go The reverse walk: builds an intermediate tomlDoc per table (which is what preserves declaration order and enables the group-by-kind partition) and then emits it as TOML.

The boundary that matters: parser.go produces only untyped trees (map[string]any, []any, []map[string]any, scalars); decode.go and encode.go are the only files that touch reflect; the command never touches either, it consumes Parse alone.

Data flow

Decoding is parse, then one reflection walk. SyntaxError values are produced inside parser.go and returned as-is; conversion errors are produced inside decode.go and wrapped with the key path as they unwind.

sequenceDiagram
    participant Caller
    participant API as interpres.go
    participant P as parser.go
    participant D as decode.go
    Caller->>API: Unmarshal(data, v)
    API->>P: ParseContext(ctx, data)
    P->>P: number and datetime atoms
    P-->>API: map tree or *SyntaxError
    API->>D: decode(tree, reflect value)
    D-->>API: nil or wrapped field error
    API-->>Caller: error

Encoding walks the other way. encode.go first builds a tomlDoc from the value, then emits it; the two phases are why GroupByKind can reorder entries without a second reflection pass, and why cancellation is checked during both.

sequenceDiagram
    participant Caller
    participant API as interpres.go
    participant B as encode.go build
    participant E as encode.go emit
    Caller->>API: Marshal(v)
    API->>B: build tomlDoc from struct or map
    B-->>API: tomlDoc or error
    API->>E: emitDoc(doc)
    E-->>API: bytes or error
    API-->>Caller: bytes, error

State and lifetime

  • The exported Decoder and Encoder hold configuration only. Every Decode, DecodeContext, Marshal and MarshalContext call allocates its own unexported worker, so a configured type is safe for concurrent use; the setter methods are not, and must finish before the value is shared.
  • The parser is allocated per ParseContext call; the parser itself caches nothing between documents.
  • The one piece of shared state is the struct-schema cache in decode.go: a sync.Map keyed by reflect.Type, holding the flattened field layout the decoder and the encoder both consult. A schema is immutable once published, so concurrent callers only race to build an identical value, the same trade-off encoding/json's field cache makes. The cache grows with the number of distinct struct types, never with document size.
  • The date-time wrappers are values, not pointers, and are immutable in use.
  • Nothing in the library starts goroutines; apart from the schema cache above, which never mutates a published entry, there is no shared mutable state.

Dependencies

None. go.mod declares the module and the Go version and carries no requires; the library imports the standard library only, which is the point of the project. The toml-test binary is a development and CI tool, never a module dependency.