Skip to main content
Version: 0.5.x-dev

Parsing

Parsing turns a configuration file into a spec type. A spec type is a plain Rust struct whose fields are the settings you expect.

Spec is short for "specification." The collection of spec types is the specification for an application's operator-facing configuration surface.

Parsing checks structure only. It determines whether each field is present and has the right type. What the values mean is left to validation.

Located values

Every field on a spec is wrapped in a Located<T>.

pub struct Located<T> {
pub value: T,
pub span: Span,
}

A Located<T> contains a span. A span is a byte range in the configuration file. It gives each field its provenance. The span records where the value came from, so a later error can point at the exact line and column. Span and the SourceMap that resolves it are covered under Diagnostics.

A few behaviors are worth knowing:

  • Value-only equality. PartialEq, Eq, and Hash ignore the span, so two configs with the same values compare equal regardless of formatting.
  • Deref to T. Method calls pass through to the inner value.
  • Located::detached(value) produces a value with a sentinel span. Use it to build a spec in code (tests, builders, generated templates) with no source file behind it.
  • Default is detached(T::default()).
  • With the serde feature, Located<T> serializes transparently as T and deserializes detached.

Defining a spec

#[derive(confval::Spec)] writes the parser for a struct. Parsing is purely structural, so the macro never embeds semantic rules.

#[derive(confval::Spec)]
pub struct ServerSpec {
pub version: Located<i64>,
pub threads: Option<Located<i64>>,

#[confval(nested)]
pub limits: Option<Located<LimitsSpec>>,

#[confval(default = 30)]
pub refresh_interval_seconds: Located<i64>,
}

Field types

A field's type tells the parser how to read it. These are the types you can use:

  • Scalars: Located<String>, Located<i64>, Located<f64>, Located<bool>, and Located<PathBuf>.
  • Lists of strings: Vec<Located<String>>, or Option<Located<Vec<Located<String>>>> for an optional list.
  • Nested structs: another Spec type marked with #[confval(nested)], described below.

Optional fields and defaults

Every field is required by default. Leave a required field out of the file and the parser reports a missing field error against the block it belongs to.

Two things make a field optional.

  • Option<...> on the type turns an absent field into None.
  • #[confval(default)] or #[confval(default = expr)] fills an absent field instead of reporting it.

A bare #[confval(default)] uses the field type's Default. The default = expr form uses expr instead, so #[confval(default = 30)] gives the field the value 30 when the file leaves it out. A filled-in value carries a detached span, because no source text stands behind it.

Which form a field accepts depends on its shape.

Field shape#[confval(default)]#[confval(default = expr)]
Located<T>T::default()expr
Option<Located<T>>Some(T::default())Some(expr)
Vec<Located<String>>empty listcompile error
Located<S> with #[confval(nested)]S::default()compile error
Option<Located<S>> with #[confval(nested)]compile errorcompile error
Vec<Located<S>> with #[confval(nested)]compile errorcompile error
Option<Located<Vec<Located<String>>>>compile errorcompile error

Three rows are worth calling out.

Combining Option with a default means the field is never None for an absent value. The default fills it in. Leave the default off when you need the Option to report what the source omitted.

An optional nested block rejects a default because an absent block already yields None. A nested list rejects one because a list of blocks is zero-or-more already.

A string list accepts only the bare form, where the default is the empty list. There is no default = expr for a list.

caution

The spelling #[confval(nested, default)] also exists on the config side, where it means something different. On a spec it fills the omitted block during parsing, so the spec itself holds the default. On a config it leaves the spec field None and lowers S::default() in its place, so the spec stays faithful to the source and only the runtime value is filled in. The two are independent, and one setting can use either, both, or neither. See Lowering.

Nested structs

#[confval(nested)] tells the parser to read a field with its own Spec type instead of as a scalar. It works three ways:

  • a single struct, Located<T>
  • an optional struct, Option<Located<T>>
  • a list of structs, Vec<Located<T>>, which reads a block that may repeat

Unknown fields

A setting in a configuration file that does not exist in the Rust struct will be interpreted as a parsing error. There is no lenient mode that ignores extra settings/keys.

Stricter is better in general with configuration file structure, but this is particularly useful for LLM-edited configuration files as LLMs tend to invent settings that do not exist.

What the derive does not handle

The derive only handles plain structs. It cannot express an enum.

That is rarely a problem, because a field with a discrete set of values is a Located<String> in a spec by convention rather than an enum. Writing parsers by hand covers that convention, along with the shapes that do need a handwritten parser.

Parsing a file

To parse, call the frontend for the format you enabled with the appropriate feature:

Entry pointFeatureBacked by
confval::format::hcl::parse_hclhclhcl-edit
confval::format::toml::parse_tomltomltoml_edit

Each takes a SourceMap, a SourceId, and a &mut Report, and returns your spec as an Option.

let spec: Option<ServerSpec> = confval::format::hcl::parse_hcl(&sources, id, &mut report);

The result is the same whichever format you read, so validation and lowering never depend on which frontend ran.

Both HCL spellings of a nested block parse into the same thing. A block, bind { port = 8080 }, and an attribute set to an object, bind = { port = 8080 }, are equivalent. TOML lines up with this: a [table] is a block, an inline { ... } is an object, and an array of tables ([[x]]) is a repeating block, so a Vec of nested structs reads from it the same way it reads from an HCL list of objects.

note

hcl-edit rejects duplicate attribute keys while parsing, so a repeated attribute is a syntax error. A repeated block parses, and confval reports it with a related span pointing at the first occurrence.

Writing parsers by hand

Most specs never need this. The Spec derive covers plain structs, which is nearly everything given the confval pipeline contract.

By convention, confval reduces a spec's fields to primitive types wherever it can. A spec holds the most broadly typed form of a field. Lowering narrows that value to a more specific type once validation has run.

A discrete set of values follows the same pattern rather than becoming an enum in the spec. Take a mode field that accepts "red", "green", or "blue" as an example. The spec holds a Located<String>. Validation checks it against a KeywordSet. Lowering converts the string to an enum.

Handwritten parsers cover the shapes that pattern cannot express. The clearest case is a block whose remaining fields depend on a discriminator, where the parser reads the discriminator first and dispatches on it. The same mechanism lets you put an enum directly in a spec. That compiles and parses correctly. It also abandons the convention above, which is why it is not the recommended shape.

A parser is an implementation of confval's FromFields trait. It is the same trait the derive generates.

pub trait FromFields: Sized {
fn from_fields(fields: &Fields, report: &mut Report) -> Option<Self>;
}

An implementation parses every field before deciding what to return. Parsing all of them first keeps one bad field from hiding the problems in the others.

Report each problem as you find it. Then return None when a field failed and the value cannot be built. The None itself carries no reason. Whatever explains the failure must already be in the report.

The field model

A handwritten parser reads Fields, confval's format-neutral view of one level of structure. A frontend builds it, and from there nothing knows which format the text was.

  • Fields is one level: the named entries of a body, table, or inline object, plus the span a missing-field error points at.
  • Field is one entry: its name, the span of the name, the span of the whole entry, and a FieldKind.
  • FieldKind is either Value for an attribute (name = value) or Block for a block (name { ... } in HCL, [name] in TOML). The split lets a diagnostic say "found block" rather than "found object".
  • Value is a span plus a ValueKind: a Scalar, a Seq (a list), a Map (nested fields), or Other.
  • Scalar is String, Int(i64), Float(f64), or Bool. Integers and floats stay distinct so a format that separates them, like TOML's 1 and 1.0, round-trips faithfully.
  • Other(label) is a value that exists in the file but falls outside the model, such as an HCL template or a TOML datetime. It always surfaces as a plain type mismatch named by the label, for example expected string, found datetime.

Helpers

confval ships helpers so a handwritten parser reports exactly like the derive does.

Leaf parsers turn one Field into a Located value, reporting a typed error on mismatch:

  • parse_string_field, parse_int_field (i64), parse_float_field, parse_bool_field
  • parse_string_list_field for arrays of strings

Structural parsers recurse through FromFields:

  • parse_struct_field: one nested struct from a block or a map value
  • parse_single_struct: like parse_struct_field, but reports duplicates when the field appears more than once
  • parse_struct_list_field: repeated blocks or a sequence of maps, collected into a Vec

Reporting helpers keep messages uniform: report_unknown_field, report_missing_field, report_duplicate_field. Unknown fields are always errors. There is no lenient mode.